316 lines
11 KiB
Python
316 lines
11 KiB
Python
"""Smoke test for the AI context builder helpers in ai_consult.py.
|
||
|
||
This avoids importing PySide6-bound modules by extracting only the pure
|
||
helper functions we want to verify.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import ast
|
||
import os
|
||
import sys
|
||
|
||
|
||
AI_CONSULT_PATH = os.path.join(
|
||
os.path.dirname(__file__), "..", "src", "doctor_workstation", "ui", "dialogs",
|
||
"ai_consult.py",
|
||
)
|
||
|
||
|
||
def _make_helpers() -> dict[str, object]:
|
||
"""Pull the pure helpers out of ai_consult.py without importing PySide6."""
|
||
|
||
with open(AI_CONSULT_PATH, encoding="utf-8") as stream:
|
||
source = stream.read()
|
||
tree = ast.parse(source)
|
||
wanted_names = {
|
||
"AI_CONTEXT_MAX_CHARS",
|
||
"AI_PROMPT_LIMIT",
|
||
"AI_CONTEXT_SEPARATOR",
|
||
"_truncate_for_context",
|
||
"_patient_context_blood_sugar",
|
||
"_patient_context_tongue",
|
||
"_patient_context_reports",
|
||
"_patient_context_prescriptions",
|
||
"_patient_context_videos",
|
||
"build_patient_ai_context",
|
||
"_compose_ai_prompt",
|
||
}
|
||
selected: list[ast.stmt] = []
|
||
for node in tree.body:
|
||
if isinstance(node, ast.FunctionDef) and node.name in wanted_names:
|
||
selected.append(node)
|
||
continue
|
||
if isinstance(node, ast.Assign):
|
||
for target in node.targets:
|
||
if isinstance(target, ast.Name) and target.id in wanted_names:
|
||
selected.append(node)
|
||
break
|
||
|
||
namespace: dict[str, object] = {}
|
||
|
||
def _as_mapping(value: object) -> dict[str, object]:
|
||
if isinstance(value, dict):
|
||
return dict(value)
|
||
raw = getattr(value, "raw", None)
|
||
return dict(raw) if isinstance(raw, dict) else {}
|
||
|
||
def first_value(value: object, *keys: str, default: object = None) -> object:
|
||
"""Return the first present, non-empty value from ``keys``.
|
||
|
||
Mirrors ``widgets.first_value``:
|
||
``first_value(mapping, "k1", "k2", default=...)``.
|
||
"""
|
||
|
||
for key in keys:
|
||
if not isinstance(value, dict):
|
||
break
|
||
if key in value and value[key] not in (None, "", "—"):
|
||
return value[key]
|
||
return default
|
||
|
||
def get_value(source: object, key: str, default: object = None) -> object:
|
||
if isinstance(source, dict) and key in source:
|
||
return source[key]
|
||
return default
|
||
|
||
def _human_value(value: object, *, empty: str = "未记录") -> str:
|
||
if value in (None, "", "—"):
|
||
return empty
|
||
if isinstance(value, str):
|
||
return value.strip() or empty
|
||
if isinstance(value, bool):
|
||
return "是" if value else "否"
|
||
if isinstance(value, dict):
|
||
parts = []
|
||
for key, nested in value.items():
|
||
rendered = _human_value(nested, empty="")
|
||
if rendered:
|
||
parts.append(f"{key}:{rendered}")
|
||
return ";".join(parts) or empty
|
||
if isinstance(value, list):
|
||
parts = [_human_value(item, empty="") for item in value]
|
||
return "、".join(part for part in parts if part) or empty
|
||
return str(value).strip() or empty
|
||
|
||
def display_text(value: object, *, default: str = "") -> str:
|
||
if value in (None, "", "—"):
|
||
return default
|
||
return str(value).strip() or default
|
||
|
||
def _exact_positive_id(value: object, expected: int) -> bool:
|
||
if value in (None, ""):
|
||
return False
|
||
try:
|
||
return int(value) == expected
|
||
except (TypeError, ValueError):
|
||
return False
|
||
|
||
# Provide fallback names for ``collections.abc`` symbols referenced by
|
||
# the helpers without forcing the real module imports on this stub box.
|
||
import collections.abc as _abc
|
||
Sequence = _abc.Sequence # type: ignore[attr-defined]
|
||
Mapping = _abc.Mapping # type: ignore[attr-defined]
|
||
Any = object # type: ignore[assignment]
|
||
|
||
namespace.update(
|
||
{
|
||
"_as_mapping": _as_mapping,
|
||
"first_value": first_value,
|
||
"get_value": get_value,
|
||
"_human_value": _human_value,
|
||
"display_text": display_text,
|
||
"_exact_positive_id": _exact_positive_id,
|
||
"Sequence": Sequence,
|
||
"Mapping": Mapping,
|
||
"Any": Any,
|
||
}
|
||
)
|
||
|
||
module_ast = ast.Module(body=selected, type_ignores=[])
|
||
ast.fix_missing_locations(module_ast)
|
||
exec(compile(module_ast, AI_CONSULT_PATH, "exec"), namespace)
|
||
return namespace
|
||
|
||
|
||
def main() -> None:
|
||
helpers = _make_helpers()
|
||
AI_CONTEXT_MAX_CHARS = helpers["AI_CONTEXT_MAX_CHARS"]
|
||
AI_PROMPT_LIMIT = helpers["AI_PROMPT_LIMIT"]
|
||
_truncate_for_context = helpers["_truncate_for_context"]
|
||
_patient_context_blood_sugar = helpers["_patient_context_blood_sugar"]
|
||
_patient_context_tongue = helpers["_patient_context_tongue"]
|
||
_patient_context_reports = helpers["_patient_context_reports"]
|
||
_patient_context_videos = helpers["_patient_context_videos"]
|
||
build_patient_ai_context = helpers["build_patient_ai_context"]
|
||
_compose_ai_prompt = helpers["_compose_ai_prompt"]
|
||
|
||
def fail(message: str) -> None:
|
||
raise AssertionError(message)
|
||
|
||
def assertEqual(actual: object, expected: object, message: str) -> None:
|
||
if actual != expected:
|
||
fail(f"{message}: expected {expected!r}, got {actual!r}")
|
||
|
||
def assertContains(container: object, needle: str, message: str) -> None:
|
||
if not isinstance(container, str) or needle not in container:
|
||
fail(f"{message}: {needle!r} missing in output")
|
||
|
||
# 1. _truncate_for_context
|
||
short = _truncate_for_context("hello", max_chars=10)
|
||
assertEqual(short, "hello", "short text should pass through unchanged")
|
||
|
||
long_text = _truncate_for_context(
|
||
"诊断:血糖偏高,建议调整饮食结构,配合运动每周三次以上。",
|
||
max_chars=12,
|
||
)
|
||
assertContains(long_text, "…", "long text should end with ellipsis")
|
||
assertEqual(len(long_text), 12, "truncated text should respect max_chars")
|
||
|
||
# 2. _patient_context_tongue
|
||
tongue = _patient_context_tongue(
|
||
{
|
||
"diagnosis": {
|
||
"tongue": "舌红苔黄腻",
|
||
"tongue_coating": "黄腻",
|
||
"pulse": "弦滑",
|
||
"tongue_images": ["url1", "url2", "url3"],
|
||
}
|
||
}
|
||
)
|
||
assertContains(tongue, "舌红苔黄腻", "tongue text missing")
|
||
assertContains(tongue, "弦滑", "pulse text missing")
|
||
assertContains(tongue, "舌苔图片 3 张", "tongue image count missing")
|
||
|
||
# 3. _patient_context_blood_sugar
|
||
blood_sugar = _patient_context_blood_sugar(
|
||
{"diagnosis": {"fasting_blood_sugar": "7.8"}},
|
||
{
|
||
"blood_sugar": {
|
||
"entries": [
|
||
{"date": "2026-08-15", "value": "6.2", "period": "空腹"},
|
||
{"date": "2026-08-14", "value": "9.1", "period": "餐后"},
|
||
]
|
||
}
|
||
},
|
||
)
|
||
assertContains(blood_sugar, "7.8", "fasting reading missing")
|
||
assertContains(blood_sugar, "每日血糖", "tracking summary missing")
|
||
|
||
# 4. _patient_context_videos: filters by current diagnosis id.
|
||
videos = _patient_context_videos(
|
||
[
|
||
{
|
||
"diagnosis_id": 99,
|
||
"transcript_text": "其他诊单",
|
||
"start_time_text": "今天",
|
||
},
|
||
{
|
||
"diagnosis_id": 501,
|
||
"transcript_text": "医生:请问您最近睡眠如何;患者:经常失眠。",
|
||
"start_time_text": "2026-08-15 10:30",
|
||
},
|
||
],
|
||
diagnosis_id=501,
|
||
)
|
||
assertContains(videos, "医生", "transcript text missing")
|
||
assertContains(videos, "2026-08-15", "transcript timestamp missing")
|
||
|
||
# 5. _patient_context_reports
|
||
reports = _patient_context_reports(
|
||
{
|
||
"summary": "近期血糖偏高",
|
||
"diagnosis_advice": "建议控制饮食",
|
||
"risk_assessment": ["心血管风险升高", "肾功负担加重"],
|
||
}
|
||
)
|
||
assertContains(reports, "既往AI摘要", "summary missing")
|
||
assertContains(reports, "诊断建议", "advice missing")
|
||
assertContains(reports, "心血管风险", "risk bullet missing")
|
||
|
||
# 6. build_patient_ai_context: full envelope assembly.
|
||
detail = {
|
||
"diagnosis": {
|
||
"tongue": "舌淡苔白",
|
||
"pulse": "细弱",
|
||
"fasting_blood_sugar": "8.0",
|
||
},
|
||
"tongue_images": ["a", "b"],
|
||
}
|
||
tracking = {
|
||
"blood_sugar": {
|
||
"entries": [
|
||
{"date": "2026-08-19", "value": "6.1", "period": "空腹"},
|
||
]
|
||
}
|
||
}
|
||
analysis = {
|
||
"summary": "控制尚可",
|
||
"diagnosis_advice": "调整饮食",
|
||
"risk_assessment": ["肾功"],
|
||
}
|
||
prescriptions = [
|
||
{"prescription_name": "六味地黄丸", "prescription_remark": "调理方"},
|
||
]
|
||
call_records = [
|
||
{
|
||
"diagnosis_id": 501,
|
||
"transcript_text": "对话内容:患者表述近期乏力。",
|
||
"start_time_text": "2026-08-18 14:00",
|
||
}
|
||
]
|
||
context_text, present = build_patient_ai_context(
|
||
detail=detail,
|
||
tracking=tracking,
|
||
analysis=analysis,
|
||
prescriptions=prescriptions,
|
||
call_records=call_records,
|
||
diagnosis_id=501,
|
||
)
|
||
for label in (
|
||
"每日血糖",
|
||
"舌苔/脉象",
|
||
"视频问诊文字",
|
||
"历史AI报告",
|
||
"处方记录",
|
||
):
|
||
assertContains(context_text, label, f"section {label} missing in envelope")
|
||
if label not in present:
|
||
fail(f"label {label} not in present labels")
|
||
if len(context_text) > AI_CONTEXT_MAX_CHARS + 12:
|
||
fail(f"context exceeds {AI_CONTEXT_MAX_CHARS} chars: {len(context_text)}")
|
||
|
||
# 7. _compose_ai_prompt: short answer stays untouched.
|
||
short_prompt = _compose_ai_prompt("血糖如何?", context_text)
|
||
assertContains(short_prompt, context_text, "short prompt loses context")
|
||
assertContains(short_prompt, "血糖如何?", "short prompt loses question")
|
||
if len(short_prompt) > AI_PROMPT_LIMIT:
|
||
fail("short prompt exceeds limit")
|
||
|
||
# 8. Long answer is truncated with ellipsis.
|
||
long_question = (
|
||
"请结合患者既往糖尿病史、家族史以及服用的多种药物,给出一份详尽的"
|
||
"个性化治疗方案,并解释每一步的理由,最终输出一份结构化报告,"
|
||
"包括风险评估、用药合理性、并发症筛查和分级随访计划。"
|
||
) * 6
|
||
long_prompt = _compose_ai_prompt(long_question, context_text)
|
||
if len(long_prompt) > AI_PROMPT_LIMIT:
|
||
fail(f"long prompt exceeds limit: {len(long_prompt)}")
|
||
if context_text not in long_prompt:
|
||
fail("long prompt loses context")
|
||
assertContains(long_prompt, "…", "long prompt should end with ellipsis")
|
||
|
||
# 9. Empty context returns bare question.
|
||
bare = _compose_ai_prompt("血糖?", "")
|
||
assertEqual(bare, "血糖?", "empty context should drop the envelope entirely")
|
||
|
||
# 10. Empty question returns empty string.
|
||
empty = _compose_ai_prompt("", context_text)
|
||
assertEqual(empty, "", "empty question returns empty string")
|
||
|
||
print("OK: all AI context helper assertions passed")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|