Files
zyt/app/tests/test_ai_quick_tools_task_analysis_r37.py
2026-10-27 14:03:35 +08:00

263 lines
13 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.
"""Six task actions reach the real repository and preserve mocked model answers.
Finite offscreen Qt tests only. HTTP terminates in MockTransport; all clinical
text and transfer metadata below are synthetic, including incomplete sources.
"""
from __future__ import annotations
import json
import os
from time import monotonic
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import httpx
import pytest
from PySide6.QtCore import QEvent, Qt, QThreadPool, QTimer
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QLabel, QTextBrowser
from doctor_workstation.core import PermissionSet
from doctor_workstation.services import ApiClient, RemoteDoctorRepository
from doctor_workstation.services.repository import _diagnosis_ai_request
from doctor_workstation.ui.dialogs import ai_consult as module
from doctor_workstation.ui.dialogs.prescription_ai import diagnosis_ai_task
# Explicit acceptance values; neither tasks nor answer content are derived from
# the implementation's classifier or tool-task mapping.
TOOLS = (
("分析病历", "case_analysis", "病历分析", "已有口渴记录,结合血糖异常可分析津液受损的可能;不能据此直接确立证型。"),
("血糖趋势", "glucose_trend", "血糖趋势", "已有一条空腹血糖 8.2 mmol/L;单点不能确定升降趋势,应结合日期与连续监测解释。"),
("用药评估", "medication_review", "用药评估", "已记录二甲双胍,但剂量与肾功能缺失;可提示胃肠不适监测,不能据此增加剂量。"),
("并发症风险", "complication_risk", "并发症风险", "糖尿病病史提示需关注眼、肾和足部风险;未记录相应筛查不能推定已患或排除并发症。"),
("生成问诊小结", "summary", "问诊小结", "本次主诉口渴,既往有糖尿病;已记录二甲双胍及一条空腹血糖,病程与检查日期待明确。"),
("健康建议", "lifestyle_advice", "健康建议", "可先规律分配主食并记录饮食与血糖;运动宜从适量步行开始,出现低血糖症状时应停止活动。"),
)
SOURCE_KEYS = (
"basic_info", "vital_signs", "chief_complaint", "present_illness", "past_history",
"other_history", "diagnosis_info", "doctor_image_notes", "doctor_file_notes",
"daily_tracking", "prescriptions", "video_consultation_text", "chat",
)
def source_receipt() -> dict:
return {
"transfer_manifest_final": True,
"attachment_count": 0,
"submitted_attachment_count": 0,
"items": [],
"required_sections": [
{"key": key, "availability": "present" if key in {
"chief_complaint", "past_history", "prescriptions", "daily_tracking",
} else "absent", "status": "submitted"}
for key in SOURCE_KEYS
],
}
def answer_for(tool) -> str:
_label, task, title, analysis = tool
return (
f"当前{title}依据已记录的部分资料完成。\n\n"
f"## {title}判断\n- {analysis}\n- 专属结果标记:{task}_ACTUAL_MODEL_ANSWER。\n\n"
f"## {title}后续建议\n- 根据以上已有信息执行相应随访,不将缺项改写为正常。"
"\n- 缺失资料仅影响相应结论,不取代上述分析。"
)
def sse(kind: str, data: dict) -> str:
return f"event: {kind}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
def wait_until(predicate, timeout: float = 4.0) -> None:
deadline = monotonic() + timeout
while not predicate() and monotonic() < deadline:
QTest.qWait(5)
assert predicate(), "finite mocked request did not finish"
@pytest.fixture(scope="module")
def application():
return QApplication.instance() or QApplication([])
@pytest.fixture
def dialog_factory(application, monkeypatch):
resources = []
def create(handler):
client = ApiClient("https://example.test", transport=httpx.MockTransport(handler))
dialog = module.AiConsultDialog(
RemoteDoctorRepository(client), PermissionSet(["tcm.diagnosis/aiAssistant"]),
)
monkeypatch.setattr(dialog, "_load_workspace", lambda _: None)
dialog.open_for(diagnosis_id=501, patient_id=1501)
dialog._detail = {"diagnosis": {"id": 501, "patient_id": 1501}}
# The intentionally absent case and errored local previews cannot block
# inference, synthesize a result, or become a stale request context.
dialog._workspace_sections = {"diagnosis": {"value": None, "error": "合成本地资料未加载"}}
dialog._patient_ai_context = "不能发往服务的本地旧预览"
dialog.show()
application.processEvents()
resources.append((dialog, client))
return dialog
yield create
for dialog, client in resources:
assert QThreadPool.globalInstance().waitForDone(4000)
dialog.close()
for timer in dialog.findChildren(QTimer):
timer.stop()
client.close()
dialog.deleteLater()
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
application.processEvents()
def click_tool(dialog, label: str) -> None:
cards = dialog.findChildren(module._ClickCard, "AiConsultToolCard")
target = next(child for card in cards for child in card.findChildren(QLabel)
if child.text() == label)
QTest.mouseClick(target, Qt.MouseButton.LeftButton)
def readable_answer(bubble) -> str:
if bubble._clinical_panel is None:
return bubble.body.toPlainText()
return "\n".join(view.toPlainText()
for view in bubble._clinical_panel.findChildren(QTextBrowser))
@pytest.mark.parametrize("tool", TOOLS, ids=[tool[1] for tool in TOOLS])
@pytest.mark.parametrize("route", ["stream", "legacy404"])
def test_incomplete_case_click_dispatches_distinct_task_and_shows_actual_task_answer(
dialog_factory, tool, route,
):
requests = []
answer = answer_for(tool)
label, task, title, analysis = tool
def handler(request):
assert request.url.host == "example.test"
requests.append(request)
if request.url.path.endswith("/aiAssistantStream"):
if route == "legacy404":
return httpx.Response(404, json={"msg": "合成旧部署无流式路由"})
events = sse("start", {"model_key": "qwen", "model_label": "原配置千问"})
events += sse("progress", {"phase": "attachments", "status": "submitted", **source_receipt()})
events += sse("delta", {"text": answer[:35]})
events += sse("delta", {"text": answer[35:]})
events += sse("done", {"model_key": "qwen", "model_label": "原配置千问", **source_receipt()})
return httpx.Response(200, headers={"content-type": "text/event-stream"}, text=events)
assert route == "legacy404" and request.url.path.endswith("/aiAssistant")
return httpx.Response(200, json={"code": 1, "data": {
"answer": answer, "model_key": "qwen", "model_label": "原配置千问", **source_receipt(),
}})
dialog = dialog_factory(handler)
click_tool(dialog, label)
bubble = dialog._stream_bubble
assert bubble is not None
wait_until(lambda: dialog._stream_worker is None and not dialog._asking)
assert len(requests) == (1 if route == "stream" else 2)
expected_prompt = next(prompt for _, name, prompt in module.TOOL_ITEMS if name == label)
for request in requests:
assert request.method == "POST"
body = json.loads(request.content)
assert set(body) == ({"id", "prompt", "task", "request_id"}
if request.url.path.endswith("/aiAssistantStream")
else {"id", "prompt", "task"})
assert body["task"] == task and body["id"] == 501
assert body["prompt"] == expected_prompt
assert "本地旧预览" not in request.content.decode()
assert "model" not in body and "model_key" not in body # Server model selection remains authoritative.
assert bubble._raw_payload == answer
assert isinstance(bubble._clinical_panel, module._StructuredReportPanel)
assert bubble._clinical_panel.findChild(QLabel, "AiConsultClinicalTitle").text() == f"AI · {title}"
visible = readable_answer(bubble)
assert analysis in visible and f"{task}_ACTUAL_MODEL_ANSWER" in visible
assert [section.title for section in bubble._clinical_panel.sections] == [
f"{title}判断", f"{title}后续建议",
]
assert len(dialog._stream_meta["required_sections"]) == 13
assert [item["key"] for item in dialog._stream_meta["required_sections"]] == list(SOURCE_KEYS)
assert dialog._stream_meta["model_key"] == "qwen" and "原配置千问" in bubble.stamp.text()
assert dialog._stream_completed and dialog.send_button.isEnabled()
assert not any(timer.isActive() for timer in (
dialog._delivery_timer, dialog._stream_timeout_timer, dialog._stream_elapsed_timer,
))
@pytest.mark.parametrize("tool", TOOLS, ids=[tool[1] for tool in TOOLS])
def test_each_tool_failed_model_request_is_honest_and_does_not_show_fake_success(dialog_factory, tool):
requests = []
label, task, title, _analysis = tool
def handler(request):
assert request.url.host == "example.test"
requests.append(request)
events = sse("start", {"model_key": "qwen", "model_label": "原配置千问"})
events += sse("error", {"code": "SYNTHETIC_MODEL_FAILURE", "message": f"{title}合成模型失败"})
events += sse("done", {"answer": f"{task}_FAKE_LATE_SUCCESS"})
return httpx.Response(200, headers={"content-type": "text/event-stream"}, text=events)
dialog = dialog_factory(handler)
click_tool(dialog, label)
bubble = dialog._stream_bubble
wait_until(lambda: dialog._stream_worker is None and not dialog._asking)
assert len(requests) == 1 and json.loads(requests[0].content)["task"] == task
assert f"{title}合成模型失败" in bubble._raw_payload
assert "FAKE_LATE_SUCCESS" not in bubble._raw_payload
assert "ACTUAL_MODEL_ANSWER" not in bubble._raw_payload
assert bubble._role_name.text() == f"AI · {title}"
assert bubble._clinical_panel is None and not dialog._stream_completed
assert dialog._stream_meta["code"] == "SYNTHETIC_MODEL_FAILURE"
assert dialog.send_button.isEnabled()
@pytest.mark.parametrize("tool", TOOLS, ids=[tool[1] for tool in TOOLS])
def test_task_classifier_and_real_repository_allowlist_keep_six_explicit_tasks(tool):
label, expected_task, _title, _analysis = tool
prompt = next(prompt for _, name, prompt in module.TOOL_ITEMS if name == label)
assert diagnosis_ai_task(prompt) == expected_task
assert _diagnosis_ai_request(501, prompt, expected_task) == (prompt, expected_task)
assert len(prompt) <= 500
assert "舌脉" not in prompt and "脉象" not in prompt
def test_unsupported_task_still_rejected():
with pytest.raises(ValueError, match="task is not supported"):
_diagnosis_ai_request(501, "合成提问", "not_a_supported_task")
@pytest.mark.parametrize("tool", TOOLS, ids=[tool[1] for tool in TOOLS])
def test_tool_answers_keep_all_points_and_extra_sections_in_old_clinical_heading_shape(application, tool):
_label, task, title, _analysis = tool
headings = ("证候分析", "信息缺口", "建议下一步", "风险提示", f"{title}专属补充")
points = [f"{heading}要点{index}:" + "这段完整推理与建议需要保留原意和全部内容。" * 6
+ f"{task}_{ordinal}_{index}_TAIL"
for ordinal, heading in enumerate(headings) for index in range(5)]
reply = "已按当前任务完成分析。\n\n" + "\n\n".join(
f"## {heading}\n" + "\n".join(f"- {point}" for point in points[index * 5:(index + 1) * 5])
for index, heading in enumerate(headings)
)
bubble = module._ChatBubble(role="ai", text=reply)
try:
bubble.set_analysis_task(task)
assert bubble.finalize_clinical_analysis()
assert isinstance(bubble._clinical_panel, module._StructuredReportPanel)
assert bubble._raw_payload == reply
assert [section.title for section in bubble._clinical_panel.sections] == list(headings)
assert sum(len(section.items) for section in bubble._clinical_panel.sections) == 25
visible = readable_answer(bubble)
for point in points:
assert any(point in section.items for section in bubble._clinical_panel.sections)
lead, body = point.split(":", 1)
assert lead in visible and body in visible
assert "当前更倾向" not in visible
finally:
bubble.close()
bubble.deleteLater()
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
application.processEvents()