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

141 lines
6.2 KiB
Python

"""Summary intent and the released shortcut use the summary API task.
Offscreen workstation widgets and the real repository are exercised with
synthetic answers. Every HTTP request ends in MockTransport at example.test.
"""
from __future__ import annotations
import json
import os
from types import SimpleNamespace
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import httpx
import pytest
from PySide6.QtCore import QEvent, Qt, QTimer
from PySide6.QtTest import QTest
from PySide6.QtWidgets import QApplication, QPushButton
from doctor_workstation.core import PermissionSet
from doctor_workstation.services import ApiClient, RemoteDoctorRepository
from doctor_workstation.ui.dialogs import ai_consult as module
from doctor_workstation.ui.dialogs.prescription_ai import diagnosis_ai_task
class _SyncMockTransport(httpx.BaseTransport):
"""Use the finite sync parser without Windows event-loop socket setup."""
def __init__(self, handler):
self.mock = httpx.MockTransport(handler)
def handle_request(self, request):
return self.mock.handle_request(request)
@pytest.mark.parametrize(("prompt", "expected"), [
("请根据当前病历总结患者病情、证候与风险。", "summary"),
("请概括患者现有证候和舌象。", "summary"),
("请给出当前辨证思路的摘要。", "summary"),
("请用一句话 summary 当前证候。", "summary"),
("请总结当前病情及风险。", "summary"),
("请生成完整中医辨证报告,并在末尾总结。", "tcm_pattern"),
("请生成完整的中医报告并附病情摘要。", "tcm_pattern"),
("请生成 full TCM report,附 summary。", "tcm_pattern"),
("task=tcm_pattern;总结当前病情。", "tcm_pattern"),
("请辨证并给出证候、治法。", "tcm_pattern"),
("tcm", "tcm_pattern"),
("请总结血糖趋势及可能证候。", "glucose_trend"),
("请总结用药风险及证候。", "medication_review"),
("请总结并发症风险及证候。", "complication_risk"),
("请摘要处方配伍及证候。", "prescription_review"),
("请总结检查结果及证候。", "exam_review"),
("请分析当前病历并总结辨证思路。", "case_analysis"),
])
def test_mixed_summary_and_tcm_intent_preserves_specific_tasks(prompt, expected):
assert diagnosis_ai_task(prompt) == expected
@pytest.fixture(scope="module")
def application():
return QApplication.instance() or QApplication([])
@pytest.mark.parametrize("route", ["stream", "legacy404"])
@pytest.mark.parametrize("entry", ["shortcut", "free_text", "explicit_tcm"])
def test_summary_workstation_dispatch_reaches_real_repository(
application, monkeypatch, route, entry,
):
requests = []
expected_task = "tcm_pattern" if entry == "explicit_tcm" else "summary"
expected_prompt = "请根据当前病历总结患者病情、证候与风险。"
answer = "## 本次病情小结\n合成主诉为口渴,已记录证候待复核。\n\n## 后续事项\n合成病程日期待明确。"
workers = []
# Dispatch uses the real worker, run deterministically after the click so
# routing coverage does not depend on offscreen Qt thread scheduling.
monkeypatch.setattr(module, "QThreadPool", SimpleNamespace(
globalInstance=lambda: SimpleNamespace(start=workers.append),
))
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 = (
f"event: delta\ndata: {json.dumps({'text': answer}, ensure_ascii=False)}\n\n"
"event: done\ndata: {}\n\n"
)
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}})
client = ApiClient("https://example.test", transport=_SyncMockTransport(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.show()
application.processEvents()
try:
if entry == "shortcut":
# A stable shortcut task must survive a classifier change. This
# would still fail if only the free-text classifier were repaired.
monkeypatch.setattr(module, "diagnosis_ai_task", lambda _: "tcm_pattern")
target = next(button for button in dialog.findChildren(QPushButton)
if button.text() == "总结当前病情")
QTest.mouseClick(target, Qt.MouseButton.LeftButton)
elif entry == "explicit_tcm":
dialog._ask(expected_prompt, task="tcm_pattern")
else:
dialog.input.setText(expected_prompt)
QTest.mouseClick(dialog.send_button, Qt.MouseButton.LeftButton)
bubble = dialog._stream_bubble
assert bubble is not None
assert len(workers) == 1
workers[0].run()
application.processEvents()
assert dialog._stream_worker is None and not dialog._asking
assert len(requests) == (1 if route == "stream" else 2)
for request in requests:
body = json.loads(request.content)
assert body["id"] == 501
assert body["prompt"] == expected_prompt
assert body["task"] == expected_task
assert "model" not in body and "model_key" not in body
assert dialog._stream_task == expected_task
assert dialog._stream_completed and bubble._raw_payload == answer
if expected_task == "summary":
assert bubble._role_name.text() == "AI · 问诊小结"
assert dialog.send_button.isEnabled()
finally:
dialog.close()
for timer in dialog.findChildren(QTimer):
timer.stop()
client.close()
dialog.deleteLater()
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
application.processEvents()