340 lines
16 KiB
Python
340 lines
16 KiB
Python
"""Synthetic quick-action contract checks; no credentials, records or live network.
|
|
|
|
Use actual Qt click signals, QRunnable workers, repository and SSE parsing. Every
|
|
HTTP request terminates in MockTransport at the reserved example.test host.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import os
|
|
from threading import Event
|
|
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, QPushButton
|
|
|
|
from doctor_workstation.core import PermissionSet
|
|
from doctor_workstation.services import ApiClient, RemoteDoctorRepository
|
|
from doctor_workstation.ui.dialogs import ai_consult as module
|
|
|
|
# These are explicit observed contract values, rather than expectations computed
|
|
# with diagnosis_ai_task itself. Semantic routing issues are documented separately.
|
|
ENTRIES = (
|
|
("tool", "分析病历", "请根据当前病历给出辨证与病情摘要。", "tcm_pattern"),
|
|
("tool", "血糖趋势", "请评估该患者的血糖控制情况与趋势。", "custom"),
|
|
("tool", "用药评估", "请核对四诊及舌脉,按八纲、气血津液与脏腑辨证,归纳证名、治法,以中药为主评估药证、合用、用法及禁忌;西药只简要核对相互作用与必要监测。", "medication_review"),
|
|
("tool", "并发症风险", "请评估并发症风险并给出随访建议。", "complication_risk"),
|
|
("tool", "生成问诊小结", "请生成本次问诊小结,便于写入病历。", "custom"),
|
|
("tool", "健康建议", "请给出饮食、运动与生活方式建议。", "custom"),
|
|
("chip", "总结当前病情", "请根据当前病历总结患者病情、证候与风险。", "summary"),
|
|
("chip", "给出用药建议", "请先核对四诊及舌脉,再按八纲、气血津液与脏腑归纳证名、治法,重点给出中药用药提醒与方药复核建议;西药只保留必要的安全核对。", "medication_review"),
|
|
("menu", "建议检查项目", "请根据现病史给出下一步检查与检验建议。", "exam_review"),
|
|
("menu", "并发症风险评估", "请评估并发症风险并给出随访要点。", "complication_risk"),
|
|
("menu", "生成问诊问题", "请生成接下来应向患者确认的关键问诊问题。", "custom"),
|
|
)
|
|
SECTION_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 manifest(status: str) -> dict:
|
|
return {
|
|
"attachment_count": 7,
|
|
"submitted_attachment_count": 7 if status == "submitted" else 0,
|
|
"attachment_transfer": "uploaded_files",
|
|
"items": [{"kind": "image", "label": f"图片附件{index}", "status": status}
|
|
for index in range(1, 8)],
|
|
"required_sections": [{"key": key, "availability": "present", "status": status}
|
|
for key in SECTION_KEYS],
|
|
}
|
|
|
|
|
|
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 = 3.0) -> None:
|
|
deadline = monotonic() + timeout
|
|
while not predicate() and monotonic() < deadline:
|
|
QTest.qWait(5)
|
|
assert predicate(), "synthetic client did not reach the expected state"
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def application():
|
|
return QApplication.instance() or QApplication([])
|
|
|
|
|
|
@pytest.fixture
|
|
def client_dialog(application, monkeypatch):
|
|
resources = []
|
|
|
|
def create(handler, grants=None):
|
|
client = ApiClient("https://example.test", transport=httpx.MockTransport(handler))
|
|
dialog = module.AiConsultDialog(
|
|
RemoteDoctorRepository(client),
|
|
PermissionSet(grants or ["tcm.diagnosis/aiAssistant"]),
|
|
)
|
|
# Workspace loading is intentionally excluded: this test never fetches a
|
|
# patient record, image URL, filesystem attachment or saved credential.
|
|
monkeypatch.setattr(dialog, "_load_workspace", lambda _: None)
|
|
dialog.open_for(diagnosis_id=501, patient_id=1501)
|
|
dialog.show()
|
|
application.processEvents()
|
|
resources.append((client, dialog))
|
|
return dialog
|
|
|
|
yield create
|
|
for client, dialog in resources:
|
|
dialog.close()
|
|
assert QThreadPool.globalInstance().waitForDone(3000)
|
|
for timer in dialog.findChildren(QTimer):
|
|
timer.stop()
|
|
client.close()
|
|
dialog.deleteLater()
|
|
application.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
|
application.processEvents()
|
|
|
|
|
|
def click(dialog, entry: tuple[str, str, str, str]) -> None:
|
|
kind, label, _, _ = entry
|
|
if kind == "tool":
|
|
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)
|
|
elif kind == "chip":
|
|
next(button for button in dialog.findChildren(QPushButton)
|
|
if button.text() == label).click()
|
|
else:
|
|
more = next(button for button in dialog.findChildren(QPushButton)
|
|
if button.text() == "更多")
|
|
next(action for action in more.menu().actions() if action.text() == label).trigger()
|
|
|
|
|
|
@pytest.mark.parametrize("entry", ENTRIES, ids=[f"{kind}-{label}" for kind, label, _, _ in ENTRIES])
|
|
@pytest.mark.parametrize("outcome", ["submitted", "rejected"])
|
|
def test_every_quick_action_reaches_exact_api_and_owns_its_seven_image_receipt(
|
|
client_dialog, entry, outcome,
|
|
):
|
|
requests = []
|
|
|
|
def handler(request):
|
|
assert request.url.host == "example.test"
|
|
requests.append(request)
|
|
assert request.url.path == "/adminapi/tcm.diagnosis/aiAssistantStream"
|
|
events = sse("start", {"model_label": "合成模型"})
|
|
events += sse("progress", {"phase": "attachments", "status": "submitted",
|
|
**manifest("submitted")})
|
|
if outcome == "submitted":
|
|
events += sse("delta", {"text": "七张合成图片的测试回复"})
|
|
events += sse("done", {"transfer_manifest_final": True, **manifest("submitted")})
|
|
else:
|
|
events += sse("error", {"code": "REQUIRED_FILES_REJECTED",
|
|
"message": "合成模型拒绝七张附件",
|
|
"transfer_manifest_final": True, **manifest("failed")})
|
|
# An invalid late success must never overwrite an error terminal.
|
|
events += sse("done", {"answer": "不应显示的迟到成功", **manifest("submitted")})
|
|
return httpx.Response(200, headers={"content-type": "text/event-stream"}, text=events)
|
|
|
|
dialog = client_dialog(handler)
|
|
dialog._patient_ai_context = "不应进入请求的本地旧预览"
|
|
click(dialog, entry)
|
|
bubble = dialog._stream_bubble
|
|
assert bubble is not None
|
|
wait_until(lambda: bool(requests) and dialog._stream_worker is None and not dialog._asking)
|
|
assert len(requests) == 1 # Includes default max_retries=2: POST must not retry.
|
|
body = json.loads(requests[0].content)
|
|
assert requests[0].method == "POST"
|
|
assert set(body) == {"id", "prompt", "task", "request_id"}
|
|
assert body["id"] == 501 and body["prompt"] == entry[2] and body["task"] == entry[3]
|
|
assert len(body["request_id"]) == 36
|
|
assert "本地旧预览" not in requests[0].content.decode()
|
|
expected = "已上传并提交" if outcome == "submitted" else "提交失败"
|
|
receipt = bubble.transfer_status.text()
|
|
for index in range(1, 8):
|
|
assert f"图片附件{index}(图片):{expected}" in receipt
|
|
assert dialog._stream_completed is (outcome == "submitted")
|
|
assert dialog.send_button.isEnabled()
|
|
assert not dialog._stream_timeout_timer.isActive()
|
|
assert not dialog._delivery_timer.isActive()
|
|
assert not dialog._stream_elapsed_timer.isActive()
|
|
assert "不应显示的迟到成功" not in bubble._raw_payload
|
|
if outcome == "rejected":
|
|
assert dialog._stream_meta["code"] == "REQUIRED_FILES_REJECTED"
|
|
assert "已上传并提交" not in receipt
|
|
assert "合成模型拒绝七张附件" in bubble._raw_payload
|
|
|
|
|
|
def test_all_tools_recover_after_rejection_without_receipt_or_request_identity_leak(client_dialog):
|
|
requests, old = [], []
|
|
|
|
def handler(request):
|
|
requests.append(request)
|
|
body = json.loads(request.content)
|
|
assert request.url.path == "/adminapi/tcm.diagnosis/aiAssistantStream"
|
|
# Alternate terminal failures and successes on the same dialog and host.
|
|
if len(requests) % 2:
|
|
events = sse("progress", {"phase": "attachments", "status": "submitted",
|
|
**manifest("submitted")})
|
|
events += sse("error", {"code": "REQUIRED_FILES_REJECTED",
|
|
"message": "合成拒绝", **manifest("failed")})
|
|
else:
|
|
events = sse("progress", {"phase": "attachments", "status": "submitted",
|
|
**manifest("submitted")})
|
|
events += sse("done", {"answer": f"当前合成回复{body['request_id']}"})
|
|
return httpx.Response(200, headers={"content-type": "text/event-stream"}, text=events)
|
|
|
|
dialog = client_dialog(handler)
|
|
for index, entry in enumerate(ENTRIES):
|
|
click(dialog, entry)
|
|
bubble, worker = dialog._stream_bubble, dialog._stream_worker
|
|
generation, stream_generation = dialog._generation, dialog._stream_generation
|
|
assert bubble is not None and worker is not None
|
|
wait_until(lambda count=index + 1: len(requests) == count and dialog._stream_worker is None)
|
|
current_body, receipt = bubble._raw_payload, bubble.transfer_status.text()
|
|
for old_generation, old_stream_generation, old_worker in old:
|
|
dialog._stream_event(old_generation, old_stream_generation,
|
|
{"event": "done", "answer": "旧请求迟到正文", **manifest("failed")})
|
|
dialog._stream_failed(old_generation, old_stream_generation, RuntimeError("旧请求迟到错误"))
|
|
dialog._stream_finished(old_generation, old_stream_generation, old_worker)
|
|
assert bubble._raw_payload == current_body and bubble.transfer_status.text() == receipt
|
|
assert dialog._stream_completed is (index % 2 == 1)
|
|
assert not dialog._asking and dialog.send_button.isEnabled()
|
|
old.append((generation, stream_generation, worker))
|
|
ids = [json.loads(request.content)["request_id"] for request in requests]
|
|
assert len(ids) == len(set(ids)) == len(ENTRIES)
|
|
assert [json.loads(request.content)["task"] for request in requests] == [entry[3] for entry in ENTRIES]
|
|
|
|
|
|
def test_prescription_chip_uses_owned_blocking_request_and_never_writes_after_rejection(
|
|
client_dialog, monkeypatch,
|
|
):
|
|
requests = []
|
|
|
|
def immediate(function, *args, on_success=None, on_error=None, on_finished=None, **kwargs):
|
|
try:
|
|
result = function(*args, **kwargs)
|
|
except Exception as error:
|
|
if on_error:
|
|
on_error(error)
|
|
else:
|
|
if on_success:
|
|
on_success(result)
|
|
finally:
|
|
if on_finished:
|
|
on_finished()
|
|
return object()
|
|
|
|
monkeypatch.setattr(module, "run_async", immediate)
|
|
|
|
def handler(request):
|
|
assert request.url.host == "example.test"
|
|
requests.append(request)
|
|
if request.url.path == "/adminapi/tcm.diagnosis/readonlyDetail":
|
|
assert request.method == "GET" and request.url.params["id"] == "501"
|
|
return httpx.Response(200, json={"code": 1, "data": {
|
|
"diagnosis": {"id": 501, "patient_id": 1501},
|
|
}})
|
|
assert request.url.path == "/adminapi/tcm.diagnosis/aiAssistant"
|
|
return httpx.Response(200, json={
|
|
"code": 0, "msg": "合成模型拒绝七张附件", "data": {
|
|
"code": "REQUIRED_FILES_REJECTED", "transfer_manifest_final": True,
|
|
**manifest("failed"),
|
|
},
|
|
})
|
|
|
|
dialog = client_dialog(handler, [
|
|
"tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang",
|
|
"tcm.diagnosis/aiGeneratePrescription",
|
|
])
|
|
next(button for button in dialog.findChildren(QPushButton)
|
|
if button.text() == "AI 生成处方").click()
|
|
wait_until(lambda: len(requests) == 2)
|
|
assert len(requests) == 2 # No fallback, retry or create-prescription write.
|
|
body = json.loads(requests[1].content)
|
|
assert body == {
|
|
"id": 501, "task": "prescription_generate",
|
|
"prompt": "请依据患者全部纵向资料生成一份可复核的中医处方草稿。",
|
|
}
|
|
bubbles = dialog.findChildren(module._ChatBubble)
|
|
assert any("AI处方草稿生成失败:合成模型拒绝七张附件" in bubble._raw_payload
|
|
for bubble in bubbles)
|
|
assert dialog._stream_worker is None and not dialog._asking
|
|
|
|
|
|
def test_live_quick_switches_cancel_exact_old_request_and_reject_duplicate_clicks(client_dialog):
|
|
streams, cancels, closed = [], [], []
|
|
release = Event()
|
|
|
|
class PausedStream(httpx.AsyncByteStream):
|
|
def __init__(self, request_id):
|
|
self.request_id = request_id
|
|
|
|
async def __aiter__(self):
|
|
yield sse("progress", {"phase": "attachments", "status": "submitted",
|
|
**manifest("submitted")}).encode()
|
|
while not release.is_set():
|
|
await asyncio.sleep(0.01)
|
|
yield sse("delta", {"text": f"最终合成回复{self.request_id}"}).encode()
|
|
yield sse("done", {}).encode()
|
|
|
|
async def aclose(self):
|
|
closed.append(self.request_id)
|
|
|
|
def handler(request):
|
|
assert request.url.host == "example.test"
|
|
body = json.loads(request.content)
|
|
if request.url.path == "/adminapi/tcm.diagnosis/cancelAiAssistant":
|
|
cancels.append(body)
|
|
return httpx.Response(200, json={"code": 1, "data": {
|
|
"cancel_requested": True, "upstream_stop_confirmed": False,
|
|
}})
|
|
assert request.url.path == "/adminapi/tcm.diagnosis/aiAssistantStream"
|
|
streams.append(body)
|
|
return httpx.Response(200, headers={"content-type": "text/event-stream"},
|
|
stream=PausedStream(body["request_id"]))
|
|
|
|
dialog = client_dialog(handler)
|
|
prior_bubble, prior_worker = None, None
|
|
try:
|
|
for index, entry in enumerate(ENTRIES):
|
|
click(dialog, entry)
|
|
bubble, worker = dialog._stream_bubble, dialog._stream_worker
|
|
assert bubble is not None and worker is not None
|
|
assert bubble is not prior_bubble and worker is not prior_worker
|
|
assert dialog._stream_meta == {} # Never inherits the preceding seven images.
|
|
wait_until(lambda count=index + 1: len(streams) == count and len(dialog._stream_meta.get("items", [])) == 7)
|
|
assert streams[-1]["prompt"] == entry[2] and streams[-1]["task"] == entry[3]
|
|
assert all(item["status"] == "submitted" for item in dialog._stream_meta["items"])
|
|
click(dialog, entry)
|
|
QTest.qWait(15)
|
|
assert len(streams) == index + 1 and dialog._stream_worker is worker
|
|
if prior_worker is not None:
|
|
assert prior_worker.is_cancelled()
|
|
assert "上游是否停止尚未确认" in prior_bubble.transfer_status.text()
|
|
assert "最终合成回复" not in prior_bubble._raw_payload
|
|
prior_bubble, prior_worker = bubble, worker
|
|
wait_until(lambda: len(cancels) == len(ENTRIES) - 1)
|
|
expected = [{"id": 501, "request_id": item["request_id"]} for item in streams[:-1]]
|
|
assert {json.dumps(item, sort_keys=True) for item in cancels} == {
|
|
json.dumps(item, sort_keys=True) for item in expected
|
|
}
|
|
assert len({item["request_id"] for item in streams}) == len(ENTRIES)
|
|
release.set()
|
|
wait_until(lambda: dialog._stream_worker is None and dialog._stream_completed)
|
|
assert prior_bubble._raw_payload == f"最终合成回复{streams[-1]['request_id']}"
|
|
wait_until(lambda: len(closed) == len(ENTRIES))
|
|
assert sorted(closed) == sorted(item["request_id"] for item in streams)
|
|
finally:
|
|
release.set()
|