524 lines
19 KiB
Python
524 lines
19 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|
|
|
import pytest
|
|
from PySide6.QtWidgets import QApplication, QLabel, QTextBrowser
|
|
|
|
from doctor_workstation.core import PermissionSet
|
|
from doctor_workstation.core.errors import ApiTimeoutError
|
|
from doctor_workstation.services import DemoDoctorRepository
|
|
from doctor_workstation.ui.dialogs import prescription_ai as ai_module
|
|
from doctor_workstation.ui.dialogs.prescription_ai import (
|
|
DIAGNOSIS_AI_KIND,
|
|
DiagnosisAiAssistantDialog,
|
|
PrescriptionAiReportDialog,
|
|
can_open_ai_explain,
|
|
can_open_diagnosis_ai_report,
|
|
can_use_diagnosis_ai_assistant,
|
|
diagnosis_ai_task,
|
|
preferred_ai_model,
|
|
structured_report_to_text,
|
|
structured_text_to_report,
|
|
)
|
|
from doctor_workstation.ui.pages.prescription_library import PrescriptionLibraryPage
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def application() -> QApplication:
|
|
return QApplication.instance() or QApplication([])
|
|
|
|
|
|
@pytest.fixture
|
|
def immediate_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
def run_immediately(
|
|
function: Any,
|
|
*args: Any,
|
|
on_success: Any = None,
|
|
on_error: Any = None,
|
|
on_finished: Any = None,
|
|
**kwargs: Any,
|
|
) -> object:
|
|
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(ai_module, "run_async", run_immediately)
|
|
|
|
|
|
def test_structured_report_round_trips_eight_chinese_sections() -> None:
|
|
parsed = structured_text_to_report(
|
|
structured_report_to_text(
|
|
{
|
|
"summary": "肝郁脾虚",
|
|
"possible_symptoms": ["胁胀", "纳差"],
|
|
"main_indications": "疏肝健脾",
|
|
"efficacy": ["疏肝"],
|
|
"suitable_people": ["情志不畅者"],
|
|
"compatibility_analysis": "柴胡配白芍",
|
|
"cautions": ["需辨证"],
|
|
"disclaimer": "仅供审方",
|
|
}
|
|
)
|
|
)
|
|
|
|
assert parsed["ok"] is True
|
|
assert parsed["report"]["summary"] == "肝郁脾虚"
|
|
assert parsed["report"]["possible_symptoms"] == ["胁胀", "纳差"]
|
|
assert "缺少章节标题" in structured_text_to_report("核心判断\n有内容").get("error", "")
|
|
|
|
|
|
def test_ai_explain_permission_matches_admin_or_guard() -> None:
|
|
assert can_open_ai_explain(PermissionSet(["wcf.prescription/read"]))
|
|
assert can_open_ai_explain(PermissionSet(["tcm.prescriptionLibrary/aiReports"]))
|
|
assert not can_open_ai_explain(PermissionSet(["wcf.prescription/edit"]))
|
|
|
|
|
|
def test_ai_entry_content_selects_the_initial_server_report() -> None:
|
|
assert preferred_ai_model(entry="prescription") == "qwen"
|
|
assert preferred_ai_model("请给出中药用药调整建议", entry="reception_assistant") == "qwen"
|
|
assert preferred_ai_model("下一步并发症筛查", entry="reception_assistant") == "openai"
|
|
assert diagnosis_ai_task("下一步检查建议") == "exam_review"
|
|
assert diagnosis_ai_task("用药调整建议") == "medication_review"
|
|
assert diagnosis_ai_task("并发症筛查") == "complication_risk"
|
|
assert diagnosis_ai_task("请核对最新版指南") == "guideline_review"
|
|
assert diagnosis_ai_task("概括当前病情") == "summary"
|
|
assert diagnosis_ai_task("评估当前用药风险") == "medication_review"
|
|
assert diagnosis_ai_task("患者教育要点") == "custom"
|
|
|
|
|
|
def test_library_hides_ai_explain_without_permission(application: QApplication) -> None:
|
|
page = PrescriptionLibraryPage(
|
|
SimpleNamespace(),
|
|
PermissionSet(["wcf.prescription/edit"]),
|
|
SimpleNamespace(id=1),
|
|
)
|
|
assert page.ai_button.isHidden()
|
|
page.close()
|
|
application.processEvents()
|
|
|
|
|
|
def test_dialog_renders_saved_structured_report(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
) -> None:
|
|
dialog = PrescriptionAiReportDialog(
|
|
DemoDoctorRepository(),
|
|
PermissionSet(
|
|
[
|
|
"tcm.prescriptionLibrary/aiReports",
|
|
"tcm.prescriptionLibrary/generateAiReports",
|
|
"tcm.prescriptionLibrary/editAiReport",
|
|
"wcf.prescription/read",
|
|
]
|
|
),
|
|
)
|
|
dialog.open_for(
|
|
{
|
|
"id": 701,
|
|
"prescription_name": "疏肝健脾基础方",
|
|
"formula_type": "主方",
|
|
"herbs": [{"name": "柴胡", "dosage": "10g"}],
|
|
}
|
|
)
|
|
|
|
labels = [widget.text() for widget in dialog.findChildren(QLabel)]
|
|
assert dialog.windowTitle() == "AI 处方解释"
|
|
assert dialog.subtitle_label.text() == "疏肝健脾基础方"
|
|
assert "柴胡 10g" in dialog.snapshot_body.text()
|
|
assert any("核心判断" in text for text in labels)
|
|
assert any("疏肝健脾" in text for text in labels)
|
|
assert dialog.can_refresh is True
|
|
assert not dialog.generate_button.isHidden()
|
|
dialog.close()
|
|
application.processEvents()
|
|
|
|
|
|
def test_dialog_generate_creates_missing_model_reports(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
) -> None:
|
|
repository = DemoDoctorRepository()
|
|
dialog = PrescriptionAiReportDialog(
|
|
repository,
|
|
PermissionSet(["*", "tcm.prescriptionLibrary/editAiReport"]),
|
|
)
|
|
dialog.open_for(
|
|
{
|
|
"id": 702,
|
|
"prescription_name": "安神助眠加减方",
|
|
"formula_type": "辅方",
|
|
"herbs": [{"name": "酸枣仁", "dosage": "20g"}],
|
|
}
|
|
)
|
|
assert dialog._state("qwen").data is None
|
|
dialog._generate()
|
|
assert dialog._state("qwen").data is not None
|
|
assert dialog._state("openai").data is not None
|
|
assert "安神助眠" in str(dialog._state("qwen").data.get("report", {}).get("summary", ""))
|
|
dialog.close()
|
|
application.processEvents()
|
|
|
|
|
|
def test_dialog_edit_saves_structured_json_payload(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
) -> None:
|
|
calls: list[dict[str, Any]] = []
|
|
|
|
class Repository:
|
|
def list_prescription_template_ai_reports(self, template_id: int) -> dict[str, Any]:
|
|
return {
|
|
"prescription_id": template_id,
|
|
"can_refresh": True,
|
|
"can_edit": True,
|
|
"reports": [
|
|
{
|
|
"report_id": 11,
|
|
"model_key": "qwen",
|
|
"model_label": "千问",
|
|
"model_name": "qwen3.6-35b",
|
|
"generated_at": "2026-08-13 10:00:00",
|
|
"report": {
|
|
"summary": "原判断",
|
|
"possible_symptoms": ["乏力"],
|
|
"main_indications": "健脾",
|
|
"efficacy": ["益气"],
|
|
"suitable_people": ["脾虚者"],
|
|
"compatibility_analysis": "黄芪为君",
|
|
"cautions": ["需辨证"],
|
|
"disclaimer": "仅供审方",
|
|
},
|
|
"content": "",
|
|
"is_stale": False,
|
|
"is_edited": False,
|
|
}
|
|
],
|
|
}
|
|
|
|
def edit_prescription_template_ai_report(
|
|
self, template_id: int, *, report_id: int, content: str
|
|
) -> dict[str, Any]:
|
|
calls.append(
|
|
{"template_id": template_id, "report_id": report_id, "content": content}
|
|
)
|
|
return {
|
|
"can_edit": True,
|
|
"can_refresh": True,
|
|
"report": {
|
|
"report_id": report_id,
|
|
"model_key": "qwen",
|
|
"model_label": "千问",
|
|
"model_name": "qwen3.6-35b",
|
|
"generated_at": "2026-08-13 10:00:00",
|
|
"report": {
|
|
"summary": "修订判断",
|
|
"possible_symptoms": ["乏力"],
|
|
"main_indications": "健脾",
|
|
"efficacy": ["益气"],
|
|
"suitable_people": ["脾虚者"],
|
|
"compatibility_analysis": "黄芪为君",
|
|
"cautions": ["需辨证"],
|
|
"disclaimer": "仅供审方",
|
|
},
|
|
"is_edited": True,
|
|
},
|
|
}
|
|
|
|
dialog = PrescriptionAiReportDialog(
|
|
Repository(),
|
|
PermissionSet(["tcm.prescriptionLibrary/editAiReport"]),
|
|
)
|
|
dialog.open_for({"id": 88, "prescription_name": "测试方", "formula_type": "主方", "herbs": []})
|
|
dialog._begin_edit()
|
|
assert dialog._state("qwen").editing is True
|
|
dialog._state("qwen").draft = structured_report_to_text(
|
|
{
|
|
"summary": "修订判断",
|
|
"possible_symptoms": ["乏力"],
|
|
"main_indications": "健脾",
|
|
"efficacy": ["益气"],
|
|
"suitable_people": ["脾虚者"],
|
|
"compatibility_analysis": "黄芪为君",
|
|
"cautions": ["需辨证"],
|
|
"disclaimer": "仅供审方",
|
|
}
|
|
)
|
|
dialog._save_edit()
|
|
assert calls[0]["template_id"] == 88
|
|
assert calls[0]["report_id"] == 11
|
|
assert '"summary": "修订判断"' in calls[0]["content"]
|
|
assert dialog._state("qwen").editing is False
|
|
dialog.close()
|
|
application.processEvents()
|
|
|
|
|
|
def test_diagnosis_ai_permission_matches_reception_or_guard() -> None:
|
|
assert can_open_diagnosis_ai_report(PermissionSet(["doctor.appointment/reception"]))
|
|
assert can_open_diagnosis_ai_report(PermissionSet(["tcm.diagnosis/aiReports"]))
|
|
assert can_open_diagnosis_ai_report(PermissionSet(["tcm.diagnosis/readonlyDetail"]))
|
|
assert not can_open_diagnosis_ai_report(PermissionSet(["tcm.diagnosis/edit"]))
|
|
assert can_use_diagnosis_ai_assistant(PermissionSet(["tcm.diagnosis/aiAssistant"]))
|
|
assert not can_use_diagnosis_ai_assistant(PermissionSet(["doctor.appointment/reception"]))
|
|
|
|
|
|
def test_diagnosis_dialog_renders_saved_case_report(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
) -> None:
|
|
dialog = PrescriptionAiReportDialog(
|
|
DemoDoctorRepository(),
|
|
PermissionSet(
|
|
[
|
|
"tcm.diagnosis/aiReports",
|
|
"tcm.diagnosis/generateAiReports",
|
|
"tcm.diagnosis/editAiReport",
|
|
]
|
|
),
|
|
kind=DIAGNOSIS_AI_KIND,
|
|
)
|
|
dialog.open_for(
|
|
{
|
|
"id": 501,
|
|
"diagnosis_id": 501,
|
|
"patient_name": "林晓岚",
|
|
"consultation_type": "复诊",
|
|
"clinical_diagnosis": "肝郁脾虚证",
|
|
"tongue": "舌淡红,苔薄白",
|
|
"pulse": "弦细",
|
|
}
|
|
)
|
|
|
|
labels = [widget.text() for widget in dialog.findChildren(QLabel)]
|
|
assert dialog.windowTitle() == "AI 报告"
|
|
assert dialog.subtitle_label.text() == "林晓岚"
|
|
assert dialog.snapshot_caption.text() == "完整病历"
|
|
assert "肝郁脾虚证" in dialog.snapshot_body.text()
|
|
assert any("核心判断" in text for text in labels)
|
|
assert dialog.can_refresh is True
|
|
assert not dialog.generate_button.isHidden()
|
|
dialog.close()
|
|
application.processEvents()
|
|
|
|
|
|
def test_dialog_preferred_model_and_capabilities_respect_local_permission(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
) -> None:
|
|
class Repository:
|
|
def list_diagnosis_ai_reports(self, diagnosis_id: int) -> dict[str, Any]:
|
|
assert diagnosis_id == 501
|
|
return {
|
|
"can_refresh": True,
|
|
"can_edit": True,
|
|
"reports": [
|
|
{"report_id": 1, "model_key": "qwen", "content": "千问报告"},
|
|
{"report_id": 2, "model_key": "openai", "content": "OpenAI 报告"},
|
|
],
|
|
}
|
|
|
|
dialog = PrescriptionAiReportDialog(
|
|
Repository(),
|
|
PermissionSet(["tcm.diagnosis/aiReports"]),
|
|
kind=DIAGNOSIS_AI_KIND,
|
|
)
|
|
dialog.open_for(
|
|
{"id": 501, "patient_name": "林晓岚"},
|
|
preferred_model="openai",
|
|
)
|
|
|
|
assert dialog.active_profile == "openai"
|
|
assert dialog.tabs.currentIndex() == 1
|
|
assert dialog.can_refresh is False
|
|
assert dialog.can_edit is False
|
|
assert dialog.generate_button.isHidden()
|
|
dialog.close()
|
|
application.processEvents()
|
|
|
|
|
|
def test_dialog_shows_friendly_timeout_and_reenables_loading_state(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
) -> None:
|
|
class Repository:
|
|
def list_diagnosis_ai_reports(self, diagnosis_id: int) -> dict[str, Any]:
|
|
raise ApiTimeoutError(f"diagnosis {diagnosis_id} timed out")
|
|
|
|
dialog = PrescriptionAiReportDialog(
|
|
Repository(),
|
|
PermissionSet(["tcm.diagnosis/aiReports"]),
|
|
kind=DIAGNOSIS_AI_KIND,
|
|
)
|
|
dialog.open_for({"id": 501, "patient_name": "超时患者"})
|
|
|
|
assert dialog.load_loading is False
|
|
assert dialog.load_error == "连接服务器超时,请检查网络后重试。"
|
|
dialog.close()
|
|
application.processEvents()
|
|
|
|
|
|
def test_diagnosis_assistant_calls_repository_with_exact_safe_payload(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
) -> None:
|
|
calls: list[dict[str, Any]] = []
|
|
|
|
class Repository:
|
|
def analyze_diagnosis_ai(
|
|
self,
|
|
diagnosis_id: int,
|
|
prompt: str,
|
|
*,
|
|
task: str,
|
|
) -> dict[str, Any]:
|
|
calls.append({"diagnosis_id": diagnosis_id, "prompt": prompt, "task": task})
|
|
return {
|
|
"answer": "### 核心建议\n\n**重点复核**\n\n- 肾功能\n- 眼底",
|
|
"model_key": "openai",
|
|
}
|
|
|
|
dialog = DiagnosisAiAssistantDialog(Repository())
|
|
dialog.open_for(501, "并发症筛查", task="complication_risk")
|
|
|
|
assert calls == [
|
|
{"diagnosis_id": 501, "prompt": "并发症筛查", "task": "complication_risk"}
|
|
]
|
|
assert dialog.answer_label.toPlainText() == "核心建议\n重点复核\n肾功能\n眼底"
|
|
assert "###" not in dialog.answer_label.toPlainText()
|
|
assert "**" not in dialog.answer_label.toPlainText()
|
|
rendered_html = dialog.answer_label.toHtml().lower()
|
|
assert "<h3" in rendered_html
|
|
assert "font-weight:700" in rendered_html.replace(" ", "")
|
|
assert "openai" in dialog.model_label.text()
|
|
assert isinstance(dialog.answer_label, QTextBrowser)
|
|
assert dialog.answer_label.objectName() == "PrescriptionAiAnswer"
|
|
assert dialog.answer_label.openLinks() is False
|
|
assert dialog.answer_label.openExternalLinks() is False
|
|
assert dialog.loading is False
|
|
assert dialog.retry_button.isEnabled()
|
|
dialog.close()
|
|
application.processEvents()
|
|
|
|
|
|
def test_diagnosis_assistant_markdown_disables_model_supplied_html(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
) -> None:
|
|
class Repository:
|
|
def analyze_diagnosis_ai(self, *args: Any, **kwargs: Any) -> dict[str, Any]:
|
|
return {
|
|
"answer": "### 安全内容\n\n<img src=\"https://invalid.example/pixel\">\n\n- 建议复诊",
|
|
"model_key": "qwen",
|
|
}
|
|
|
|
dialog = DiagnosisAiAssistantDialog(Repository())
|
|
dialog.open_for(501, "复诊建议", task="custom")
|
|
|
|
assert "安全内容" in dialog.answer_label.toPlainText()
|
|
assert "建议复诊" in dialog.answer_label.toPlainText()
|
|
assert "<img" in dialog.answer_label.toPlainText()
|
|
assert 'src="https://invalid.example/pixel"' not in dialog.answer_label.toHtml()
|
|
assert (
|
|
dialog.answer_label.loadResource(
|
|
ai_module.QTextDocument.ResourceType.ImageResource,
|
|
"https://invalid.example/pixel",
|
|
)
|
|
is None
|
|
)
|
|
dialog.answer_label.selectAll()
|
|
copied = dialog.answer_label.createMimeDataFromSelection()
|
|
assert copied.hasText()
|
|
assert copied.hasHtml() is False
|
|
assert set(copied.formats()) == {"text/plain"}
|
|
dialog.close()
|
|
application.processEvents()
|
|
|
|
|
|
def test_diagnosis_assistant_timeout_is_visible_and_retryable(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
) -> None:
|
|
class Repository:
|
|
def analyze_diagnosis_ai(
|
|
self,
|
|
diagnosis_id: int,
|
|
prompt: str,
|
|
*,
|
|
task: str,
|
|
) -> dict[str, Any]:
|
|
raise ApiTimeoutError(
|
|
f"diagnosis {diagnosis_id} {task} {prompt} timed out"
|
|
)
|
|
|
|
dialog = DiagnosisAiAssistantDialog(Repository())
|
|
dialog.open_for(501, "下一步检查建议", task="exam_review")
|
|
|
|
assert dialog.status_banner.label.text() == "连接服务器超时,请检查网络后重试。"
|
|
assert dialog.loading is False
|
|
assert dialog.retry_button.isEnabled()
|
|
dialog.close()
|
|
application.processEvents()
|
|
|
|
|
|
def test_diagnosis_assistant_exposes_loading_state(
|
|
application: QApplication,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
pending: dict[str, Any] = {}
|
|
|
|
def hold_request(function: Any, **callbacks: Any) -> object:
|
|
pending.update({"function": function, **callbacks})
|
|
return object()
|
|
|
|
monkeypatch.setattr(ai_module, "run_async", hold_request)
|
|
dialog = DiagnosisAiAssistantDialog(SimpleNamespace())
|
|
dialog.open_for(501, "下一步检查建议", task="exam_review")
|
|
|
|
assert dialog.loading is True
|
|
assert not dialog.retry_button.isEnabled()
|
|
assert "正在" in dialog.status_banner.label.text()
|
|
pending["on_success"]({"answer": "检查建议", "model_key": "openai"})
|
|
assert dialog.loading is False
|
|
assert dialog.retry_button.isEnabled()
|
|
dialog.close()
|
|
application.processEvents()
|
|
|
|
|
|
def test_diagnosis_dialog_generate_creates_missing_model_reports(
|
|
application: QApplication,
|
|
immediate_async: None,
|
|
) -> None:
|
|
repository = DemoDoctorRepository()
|
|
dialog = PrescriptionAiReportDialog(
|
|
repository,
|
|
PermissionSet(["*", "tcm.diagnosis/editAiReport"]),
|
|
kind=DIAGNOSIS_AI_KIND,
|
|
)
|
|
dialog.open_for(
|
|
{
|
|
"id": 502,
|
|
"patient_name": "赵明远",
|
|
"clinical_diagnosis": "痰湿中阻证",
|
|
}
|
|
)
|
|
assert dialog._state("qwen").data is None
|
|
dialog._generate()
|
|
assert dialog._state("qwen").data is not None
|
|
assert dialog._state("openai").data is not None
|
|
assert "赵明远" in str(dialog._state("qwen").data.get("report", {}).get("summary", ""))
|
|
dialog.close()
|
|
application.processEvents()
|