"""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()