Files
zyt/app/scripts/check_ai_context.py
2026-08-22 08:51:35 +08:00

316 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()