This commit is contained in:
Your Name
2026-08-22 08:51:35 +08:00
parent 6c444a4a04
commit c06d293424
69 changed files with 11431 additions and 1601 deletions
+315
View File
@@ -0,0 +1,315 @@
"""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()