1660 lines
56 KiB
Python
1660 lines
56 KiB
Python
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
from types import SimpleNamespace
|
||
from typing import Any
|
||
|
||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||
|
||
import pytest
|
||
from PySide6.QtCore import Qt
|
||
from PySide6.QtTest import QTest
|
||
from PySide6.QtWidgets import (
|
||
QApplication,
|
||
QDialog,
|
||
QLabel,
|
||
QPushButton,
|
||
QTextBrowser,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
from doctor_workstation.core import PermissionSet
|
||
from doctor_workstation.services import DemoDoctorRepository
|
||
from doctor_workstation.ui.dialogs import ai_consult as ai_consult_module
|
||
from doctor_workstation.ui.dialogs.ai_consult import (
|
||
AiConsultDialog,
|
||
build_patient_ai_context,
|
||
can_open_ai_consult,
|
||
present_ai_consult,
|
||
render_chat_payload,
|
||
)
|
||
from doctor_workstation.ui.pages import appointments as appointments_module
|
||
from doctor_workstation.ui.pages import consultations as consultations_module
|
||
from doctor_workstation.ui.pages import patients as patients_module
|
||
from doctor_workstation.ui.pages import reception as reception_module
|
||
from doctor_workstation.ui.pages.appointments import AppointmentsPage
|
||
from doctor_workstation.ui.pages.consultations import ConsultationsPage
|
||
from doctor_workstation.ui.pages.patients import PatientListWorkspace, PatientsPage
|
||
from doctor_workstation.ui.pages.reception import ReceptionPage
|
||
|
||
LONG_CLINICAL_REPLY = """
|
||
基于本诊单资料,患者为56岁男性,空腹血糖偏高(6.4 mmol/L),既往有脂肪肝并主诉性功能下降。
|
||
|
||
### 1. 可能证候分析
|
||
- **脾肾两虚,兼夹痰湿**
|
||
- **支持点:** 脾虚失健运,痰湿内阻,空腹血糖受损。
|
||
- **肾气不足:** 年过五旬且性功能下降,需结合四诊进一步辨别。
|
||
- **肝肾阴虚,虚火内扰(需鉴别)**
|
||
- **支持点:** 若伴口干、潮热、舌红少苔,则需要纳入鉴别。
|
||
|
||
### 2. 关键矛盾与不足
|
||
- **缺乏四诊合参:** 尚无舌象与脉象资料。
|
||
- **症状细节模糊:** 性功能下降的具体表现与病程仍需确认。
|
||
- **代谢指标单一:** 缺少糖化血红蛋白与餐后血糖。
|
||
|
||
### 3. 建议下一步
|
||
1. **补充四诊:** 采集舌象、脉象及症状细节。
|
||
2. **完善检查:** 复查糖化血红蛋白、餐后血糖、肝功能和血脂。
|
||
3. **评估代谢风险:** 综合评估胰岛素抵抗、脂肪肝及心血管风险。
|
||
4. **制定随访:** 根据检查结果制定治疗与随访计划。
|
||
|
||
### 4. 风险提示
|
||
- **代谢综合征风险:** 血糖偏高合并脂肪肝,需要关注代谢风险。
|
||
- **排查心血管因素:** 性功能异常可能与血管因素有关。
|
||
|
||
**重要提示:** 以上分析仅作临床辅助,不能替代执业医师的面诊、确诊或处方。
|
||
""".strip()
|
||
|
||
|
||
@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_consult_module, "run_async", run_immediately)
|
||
|
||
|
||
def test_ai_consult_dialog_matches_workspace_chrome(
|
||
application: QApplication,
|
||
immediate_async: None,
|
||
) -> None:
|
||
repository = DemoDoctorRepository()
|
||
dialog = AiConsultDialog(repository, PermissionSet(["tcm.diagnosis/aiAssistant"]))
|
||
dialog.open_for(
|
||
diagnosis_id=501,
|
||
patient_id=301,
|
||
seed={"patient_name": "杨永", "age": 52, "clinical_diagnosis": "2型糖尿病"},
|
||
source_title="问诊列表",
|
||
)
|
||
dialog.show()
|
||
application.processEvents()
|
||
|
||
labels = [widget.text() for widget in dialog.findChildren(QLabel) if widget.text()]
|
||
assert "问诊详情" in labels
|
||
assert "AI 助手" in labels
|
||
assert "智能分析" in labels
|
||
assert "快捷工具" in labels
|
||
assert "对话建议" in labels
|
||
assert dialog.tabs.tabText(0) == "问诊对话"
|
||
assert dialog.send_button.objectName() == "AiConsultSend"
|
||
dialog.close()
|
||
|
||
|
||
@pytest.mark.parametrize("key", [Qt.Key.Key_Return, Qt.Key.Key_Enter])
|
||
def test_input_enter_submits_once_without_closing_dialog(
|
||
application: QApplication,
|
||
key: Qt.Key,
|
||
) -> None:
|
||
dialog = AiConsultDialog(
|
||
DemoDoctorRepository(),
|
||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||
)
|
||
submitted: list[str] = []
|
||
rejected: list[bool] = []
|
||
dialog._ask = lambda text: submitted.append(text) # type: ignore[method-assign]
|
||
dialog.rejected.connect(lambda: rejected.append(True))
|
||
dialog.show()
|
||
dialog.input.setText("总结当前病情")
|
||
dialog.input.setFocus()
|
||
application.processEvents()
|
||
|
||
QTest.keyClick(dialog.input, key)
|
||
application.processEvents()
|
||
|
||
assert submitted == ["总结当前病情"]
|
||
assert rejected == []
|
||
assert dialog.isVisible()
|
||
dialog.close()
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("prompt", "expected"),
|
||
[
|
||
("开个处方", True),
|
||
("请给当前患者开一张处方", True),
|
||
("新建处方", True),
|
||
("分析当前处方", False),
|
||
("怎么开方更合理", False),
|
||
("是否需要开方", False),
|
||
("给出用药建议", False),
|
||
],
|
||
)
|
||
def test_prescription_action_intent_is_explicit_and_conservative(
|
||
prompt: str,
|
||
expected: bool,
|
||
) -> None:
|
||
assert ai_consult_module._is_open_prescription_intent(prompt) is expected
|
||
|
||
|
||
def test_prescription_action_without_permission_does_not_call_ai_or_repository(
|
||
application: QApplication,
|
||
) -> None:
|
||
class Repository:
|
||
def create_prescription(self, _payload: Any) -> None:
|
||
raise AssertionError("must not create without permission")
|
||
|
||
dialog = AiConsultDialog(
|
||
Repository(),
|
||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||
)
|
||
dialog.diagnosis_id = 501
|
||
|
||
dialog._ask("开个处方")
|
||
application.processEvents()
|
||
|
||
bodies = [
|
||
browser.toPlainText()
|
||
for browser in dialog.findChildren(QTextBrowser, "AiConsultBubbleText")
|
||
]
|
||
assert any("没有开方权限" in body for body in bodies)
|
||
assert dialog._stream_worker is None
|
||
assert not dialog._asking
|
||
dialog.close()
|
||
|
||
|
||
def test_prescription_action_opens_editor_and_forces_current_diagnosis_ownership(
|
||
application: QApplication,
|
||
immediate_async: None,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
created: list[dict[str, Any]] = []
|
||
editor_seeds: list[dict[str, Any]] = []
|
||
current_user = {"id": 12, "name": "甄医生"}
|
||
|
||
class Repository:
|
||
def get_diagnosis_detail(
|
||
self,
|
||
diagnosis_id: int,
|
||
*,
|
||
readonly: bool = False,
|
||
) -> dict[str, Any]:
|
||
assert diagnosis_id == 501
|
||
assert readonly
|
||
return {
|
||
"diagnosis": {
|
||
"id": 501,
|
||
"patient_id": 301,
|
||
"patient_name": "尹山",
|
||
"gender": 1,
|
||
"age": 56,
|
||
"phone": "13800000000",
|
||
"clinical_diagnosis": "脾肾两虚证",
|
||
"case_record": {"chief_complaint": "性功能下降"},
|
||
},
|
||
"patient": {"id": 301, "name": "尹山"},
|
||
"appointment": {"id": 801, "doctor_name": "甄医生"},
|
||
}
|
||
|
||
@staticmethod
|
||
def generate_ai_prescription(diagnosis_id: int) -> dict[str, Any]:
|
||
assert diagnosis_id == 501
|
||
return {
|
||
"diagnosis_id": 501,
|
||
"task": "prescription_generate",
|
||
"context_scope": "patient_longitudinal",
|
||
"prescription_draft": {
|
||
"clinical_diagnosis": "脾肾两虚证",
|
||
"herbs": [
|
||
{"name": "茯苓", "dosage": 10, "formula_type": "主方"}
|
||
],
|
||
"dose_count": 7,
|
||
"usage_days": 7,
|
||
},
|
||
}
|
||
|
||
def create_prescription(self, prescription: dict[str, Any]) -> dict[str, Any]:
|
||
created.append(prescription)
|
||
return {"id": 9001, **prescription}
|
||
|
||
def list_prescriptions_by_diagnosis(
|
||
self,
|
||
diagnosis_id: int,
|
||
) -> list[dict[str, Any]]:
|
||
return [{"id": 9001, "diagnosis_id": diagnosis_id, "sn": "RX-9001"}]
|
||
|
||
class FakeSignal:
|
||
def connect(self, _slot: Any) -> None:
|
||
return None
|
||
|
||
class FakeEditor:
|
||
def __init__(
|
||
self,
|
||
_repository: Any,
|
||
seed: dict[str, Any],
|
||
**kwargs: Any,
|
||
) -> None:
|
||
editor_seeds.append(seed)
|
||
assert kwargs["mode"] == "add"
|
||
assert kwargs["current_user"] == current_user
|
||
self.diagnosis_requested = FakeSignal()
|
||
|
||
def exec(self) -> Any:
|
||
return ai_consult_module.QDialog.DialogCode.Accepted
|
||
|
||
@staticmethod
|
||
def payload() -> dict[str, Any]:
|
||
return {
|
||
"diagnosis_id": 999,
|
||
"appointment_id": 998,
|
||
"case_record": {"forged": True},
|
||
"doctor_name": "甄医生",
|
||
"herbs": [{"name": "茯苓", "dosage": 10, "unit": "g"}],
|
||
}
|
||
|
||
from doctor_workstation.ui.dialogs import prescription as prescription_module
|
||
|
||
monkeypatch.setattr(
|
||
prescription_module,
|
||
"PrescriptionEditorDialog",
|
||
FakeEditor,
|
||
)
|
||
host = QWidget()
|
||
host.current_user = current_user # type: ignore[attr-defined]
|
||
dialog = AiConsultDialog(
|
||
Repository(),
|
||
PermissionSet(
|
||
["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang"]
|
||
),
|
||
parent=host,
|
||
)
|
||
dialog.diagnosis_id = 501
|
||
dialog.patient_id = 301
|
||
dialog.show()
|
||
|
||
dialog._ask("开个处方")
|
||
for _ in range(3):
|
||
application.processEvents()
|
||
|
||
assert len(editor_seeds) == 1
|
||
assert editor_seeds[0]["diagnosis_id"] == 501
|
||
assert editor_seeds[0]["appointment_id"] == 801
|
||
assert editor_seeds[0]["herbs"][0]["name"] == "茯苓"
|
||
assert created and created[0]["diagnosis_id"] == 501
|
||
assert created[0]["appointment_id"] == 801
|
||
assert created[0]["case_record"] == {"chief_complaint": "性功能下降"}
|
||
assert created[0]["patient_id"] == 301
|
||
assert created[0]["phone"] == "13800000000"
|
||
assert dialog._stream_worker is None
|
||
assert not dialog._asking
|
||
bodies = [
|
||
browser.toPlainText()
|
||
for browser in dialog.findChildren(QTextBrowser, "AiConsultBubbleText")
|
||
]
|
||
assert any("处方已开具并提交审核" in body for body in bodies)
|
||
dialog.close()
|
||
host.close()
|
||
|
||
|
||
def test_prescription_action_rejects_mismatched_patient_detail(
|
||
application: QApplication,
|
||
immediate_async: None,
|
||
) -> None:
|
||
opened = False
|
||
created = False
|
||
|
||
class Repository:
|
||
@staticmethod
|
||
def get_diagnosis_detail(
|
||
diagnosis_id: int,
|
||
*,
|
||
readonly: bool = False,
|
||
) -> dict[str, Any]:
|
||
assert readonly
|
||
return {
|
||
"diagnosis": {
|
||
"id": diagnosis_id,
|
||
"patient_id": 999,
|
||
"patient_name": "其他患者",
|
||
}
|
||
}
|
||
|
||
@staticmethod
|
||
def create_prescription(prescription: Any) -> None:
|
||
nonlocal created
|
||
del prescription
|
||
created = True
|
||
|
||
@staticmethod
|
||
def generate_ai_prescription(diagnosis_id: int) -> dict[str, Any]:
|
||
raise AssertionError(f"mismatched diagnosis {diagnosis_id} must fail before AI")
|
||
|
||
dialog = AiConsultDialog(
|
||
Repository(),
|
||
PermissionSet(
|
||
["tcm.diagnosis/aiAssistant", "tcm.diagnosis/chufang"]
|
||
),
|
||
)
|
||
dialog.diagnosis_id = 501
|
||
dialog.patient_id = 301
|
||
|
||
def capture_open(*_args: Any) -> None:
|
||
nonlocal opened
|
||
opened = True
|
||
|
||
dialog._open_prescription_editor_from_chat = capture_open # type: ignore[method-assign]
|
||
dialog._ask("开个处方")
|
||
for _ in range(2):
|
||
application.processEvents()
|
||
|
||
assert not opened
|
||
assert not created
|
||
bodies = [
|
||
browser.toPlainText()
|
||
for browser in dialog.findChildren(QTextBrowser, "AiConsultBubbleText")
|
||
]
|
||
assert any("患者与当前会话不一致" in body for body in bodies)
|
||
dialog.close()
|
||
|
||
|
||
def test_cancelling_prescription_editor_never_creates_prescription(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
created: list[Any] = []
|
||
|
||
class Repository:
|
||
@staticmethod
|
||
def create_prescription(prescription: Any) -> None:
|
||
created.append(prescription)
|
||
|
||
class FakeEditor:
|
||
def __init__(self, *_args: Any, **_kwargs: Any) -> None:
|
||
return None
|
||
|
||
@staticmethod
|
||
def exec() -> Any:
|
||
return ai_consult_module.QDialog.DialogCode.Rejected
|
||
|
||
@staticmethod
|
||
def payload() -> dict[str, Any]:
|
||
raise AssertionError("cancelled editor must not read payload")
|
||
|
||
from doctor_workstation.ui.dialogs import prescription as prescription_module
|
||
|
||
monkeypatch.setattr(
|
||
prescription_module,
|
||
"PrescriptionEditorDialog",
|
||
FakeEditor,
|
||
)
|
||
dialog = AiConsultDialog(
|
||
Repository(),
|
||
PermissionSet(
|
||
["tcm.diagnosis/aiAssistant", "tcm.diagnosis/kaifang"]
|
||
),
|
||
)
|
||
dialog.diagnosis_id = 501
|
||
dialog.patient_id = 301
|
||
status = dialog._append_bubble("ai", "正在打开处方编辑器。", time_text="系统")
|
||
detail = {
|
||
"diagnosis": {
|
||
"id": 501,
|
||
"patient_id": 301,
|
||
"patient_name": "尹山",
|
||
"case_record": {},
|
||
},
|
||
"appointment": {"id": 801},
|
||
}
|
||
|
||
dialog._open_prescription_editor_from_chat(
|
||
dialog._generation,
|
||
501,
|
||
status,
|
||
detail,
|
||
)
|
||
|
||
assert created == []
|
||
assert status.body is not None
|
||
assert "已取消开方" in status.body.toPlainText()
|
||
dialog.close()
|
||
|
||
|
||
def test_present_ai_consult_requires_diagnosis_id(
|
||
application: QApplication,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
opened: list[int] = []
|
||
monkeypatch.setattr(ai_consult_module.AiConsultDialog, "exec", lambda self: opened.append(self.diagnosis_id))
|
||
present_ai_consult(
|
||
DemoDoctorRepository(),
|
||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||
None,
|
||
diagnosis_id=0,
|
||
)
|
||
assert opened == []
|
||
present_ai_consult(
|
||
DemoDoctorRepository(),
|
||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||
None,
|
||
diagnosis_id=501,
|
||
seed={"patient_name": "杨永"},
|
||
)
|
||
assert opened == [501]
|
||
|
||
|
||
def test_four_entry_points_expose_ai_consult_action(application: QApplication) -> None:
|
||
repository = DemoDoctorRepository()
|
||
allowed = PermissionSet(["*", "tcm.diagnosis/aiAssistant"])
|
||
assert can_open_ai_consult(allowed)
|
||
|
||
patients = PatientListWorkspace(repository, allowed)
|
||
patients.show()
|
||
application.processEvents()
|
||
assert patients.ai_consult_button.text() == "AI 分析"
|
||
assert not patients.ai_consult_button.isHidden()
|
||
|
||
reception = ReceptionPage(repository, allowed)
|
||
reception.show()
|
||
application.processEvents()
|
||
menu_titles = [action.text() for action in reception.more_button.menu().actions()]
|
||
assert "AI 分析" in menu_titles
|
||
assert reception.ai_consult_button.text() == "AI 分析"
|
||
|
||
appointments = AppointmentsPage(repository, permissions=allowed)
|
||
appointments.show()
|
||
application.processEvents()
|
||
assert appointments.toolbar_ai_consult_button.text() == "AI 分析"
|
||
assert not appointments.toolbar_ai_consult_button.isHidden()
|
||
|
||
consultations = ConsultationsPage(repository, permissions=allowed)
|
||
assert consultations.table_host.action_policy.get("ai_consult") is True
|
||
patients.close()
|
||
reception.close()
|
||
appointments.close()
|
||
consultations.close()
|
||
|
||
|
||
def test_global_ai_openers_keep_the_selected_diagnosis_context(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
opened: list[tuple[int, int, str]] = []
|
||
|
||
def capture(_repository: Any, _permissions: Any, _parent: Any, **kwargs: Any) -> None:
|
||
opened.append(
|
||
(
|
||
int(kwargs["diagnosis_id"]),
|
||
int(kwargs["patient_id"]),
|
||
str(kwargs["source_title"]),
|
||
)
|
||
)
|
||
|
||
for module in (
|
||
appointments_module,
|
||
consultations_module,
|
||
patients_module,
|
||
reception_module,
|
||
):
|
||
monkeypatch.setattr(module, "present_ai_consult", capture)
|
||
|
||
allowed = PermissionSet(["tcm.diagnosis/aiAssistant"])
|
||
row = {"diagnosis_id": 501, "source_patient_id": 301, "patient_id": 301}
|
||
|
||
appointment_page = SimpleNamespace(
|
||
repository=object(),
|
||
permissions=allowed,
|
||
_current_row=lambda: row,
|
||
)
|
||
assert AppointmentsPage.open_selected_ai_consult(appointment_page)
|
||
|
||
consultation_page = SimpleNamespace(
|
||
repository=object(),
|
||
permissions=allowed,
|
||
table=SimpleNamespace(current_data=lambda: row),
|
||
)
|
||
assert ConsultationsPage.open_selected_ai_consult(consultation_page)
|
||
|
||
patient_workspace = SimpleNamespace(
|
||
table=SimpleNamespace(current_data=lambda: row),
|
||
)
|
||
patient_page = SimpleNamespace(
|
||
repository=object(),
|
||
permissions=allowed,
|
||
tabs=SimpleNamespace(currentWidget=lambda: patient_workspace),
|
||
)
|
||
patient_page._diagnosis_id = lambda value: PatientsPage._diagnosis_id(
|
||
patient_page, value
|
||
)
|
||
patient_page._open_ai_consult = lambda value: PatientsPage._open_ai_consult(
|
||
patient_page, value
|
||
)
|
||
assert PatientsPage.open_selected_ai_consult(patient_page)
|
||
|
||
reception_page = SimpleNamespace(
|
||
repository=object(),
|
||
permissions=allowed,
|
||
_can_ai_assistant=True,
|
||
_detail_loading=False,
|
||
_selection_context=lambda: (1, 101, 501, 301),
|
||
_selected_detail=row,
|
||
_selected_record=None,
|
||
)
|
||
assert ReceptionPage.open_selected_ai_consult(reception_page)
|
||
|
||
assert opened == [
|
||
(501, 301, "问诊列表"),
|
||
(501, 301, "问诊列表"),
|
||
(501, 301, "我的患者"),
|
||
(501, 301, "接诊台"),
|
||
]
|
||
|
||
|
||
def test_ai_consult_sidebar_loads_patient_facts_and_reports(
|
||
application: QApplication,
|
||
immediate_async: None,
|
||
) -> None:
|
||
dialog = AiConsultDialog(
|
||
DemoDoctorRepository(),
|
||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||
)
|
||
dialog.open_for(diagnosis_id=501, patient_id=301, seed={"patient_name": "林晓岚"})
|
||
dialog.show()
|
||
application.processEvents()
|
||
|
||
values = {
|
||
widget.text()
|
||
for widget in dialog.findChildren(QLabel)
|
||
if widget.objectName() == "AiConsultKeyValue"
|
||
}
|
||
assert "22.1" in values
|
||
assert any("病程" in text or "3" in text for text in values)
|
||
titles = {
|
||
widget.text()
|
||
for widget in dialog.findChildren(QLabel)
|
||
if widget.objectName() == "AiConsultRecordTitle"
|
||
}
|
||
assert "血糖控制评估" in titles
|
||
assert "并发症风险评估" in titles
|
||
bodies = [
|
||
widget.toPlainText()
|
||
for widget in dialog.findChildren(QTextBrowser)
|
||
if widget.objectName() == "AiConsultBubbleText"
|
||
]
|
||
assert any("病情与证候分析" in text for text in bodies)
|
||
assert any("###" not in text for text in bodies if "病情与证候分析" in text)
|
||
dialog.close()
|
||
|
||
|
||
def test_ai_consult_sidebar_survives_chat_archive_errors(
|
||
application: QApplication,
|
||
immediate_async: None,
|
||
) -> None:
|
||
class BrokenChatRepository(DemoDoctorRepository):
|
||
def list_im_chat_messages(self, diagnosis_id: int, *, only_archived: bool = True):
|
||
raise RuntimeError("archive unavailable")
|
||
|
||
dialog = AiConsultDialog(
|
||
BrokenChatRepository(),
|
||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||
)
|
||
dialog.open_for(diagnosis_id=501, patient_id=301)
|
||
dialog.show()
|
||
application.processEvents()
|
||
values = {
|
||
widget.text()
|
||
for widget in dialog.findChildren(QLabel)
|
||
if widget.objectName() == "AiConsultKeyValue"
|
||
}
|
||
titles = {
|
||
widget.text()
|
||
for widget in dialog.findChildren(QLabel)
|
||
if widget.objectName() == "AiConsultRecordTitle"
|
||
}
|
||
assert "22.1" in values
|
||
assert "血糖控制评估" in titles
|
||
dialog.close()
|
||
|
||
|
||
def test_chat_payload_parses_markdown_html_and_json(application: QApplication) -> None:
|
||
browser = QTextBrowser()
|
||
render_chat_payload(browser, "### 病情摘要\n\n**核心病机**\n\n- 口干")
|
||
assert "病情摘要" in browser.toPlainText()
|
||
assert "核心病机" in browser.toPlainText()
|
||
assert "###" not in browser.toPlainText()
|
||
assert "<h3" in browser.toHtml().lower()
|
||
|
||
render_chat_payload(browser, "<p>空腹血糖 <strong>6.8</strong></p>")
|
||
assert "空腹血糖" in browser.toPlainText()
|
||
assert "6.8" in browser.toPlainText()
|
||
|
||
render_chat_payload(browser, '{"diagnosis":"肝郁脾虚证","risk":["血糖波动"]}')
|
||
assert "肝郁脾虚证" in browser.toPlainText()
|
||
assert "{\"diagnosis\"" not in browser.toPlainText()
|
||
browser.deleteLater()
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("business_id", "expected_title", "expected_marker"),
|
||
[
|
||
("patient_opened_chat", "患者进入在线问诊", "入"),
|
||
("patient_closed_chat", "患者离开在线问诊", "离"),
|
||
("doctor_entered_consult_room", "医生进入在线诊室", "医"),
|
||
("consultation_complete", "本次问诊已完成", "完"),
|
||
],
|
||
)
|
||
def test_custom_im_events_are_normalized_before_rendering(
|
||
business_id: str,
|
||
expected_title: str,
|
||
expected_marker: str,
|
||
) -> None:
|
||
item = ai_consult_module._parse_chat_item(
|
||
{
|
||
"msg_type": "custom",
|
||
"text": json.dumps(
|
||
{
|
||
"businessID": business_id,
|
||
"patientId": "11173",
|
||
"patientName": "尹山",
|
||
"doctorId": "12",
|
||
"time": 1787045982311,
|
||
},
|
||
ensure_ascii=False,
|
||
),
|
||
"is_from_doctor": False,
|
||
}
|
||
)
|
||
|
||
assert item is not None
|
||
assert item.variant == "event"
|
||
assert item.title == expected_title
|
||
assert item.marker == expected_marker
|
||
assert item.time_text
|
||
assert "11173" not in item.detail
|
||
assert "doctorId" not in item.detail
|
||
|
||
|
||
def test_rtc_event_prefers_nested_command_and_formats_duration() -> None:
|
||
payload = {
|
||
"businessID": 1,
|
||
"data": json.dumps(
|
||
{
|
||
"version": 4,
|
||
"call_type": 2,
|
||
"businessID": "rtc_call",
|
||
"data": {"cmd": "hangup", "inviter": "doctor_12"},
|
||
"call_end": 81,
|
||
}
|
||
),
|
||
# A conflicting legacy action must not override the nested command.
|
||
"actionType": 1,
|
||
"inviteID": "sensitive-invite-id",
|
||
}
|
||
item = ai_consult_module._parse_chat_item(
|
||
{
|
||
"msg_type": "custom",
|
||
"text": json.dumps(payload),
|
||
"time": 1787103129,
|
||
}
|
||
)
|
||
|
||
assert item is not None
|
||
assert item.variant == "event"
|
||
assert item.title == "视频问诊已结束"
|
||
assert item.detail == "通话 1 分 21 秒"
|
||
assert "sensitive-invite-id" not in item.detail
|
||
|
||
|
||
def test_transient_typing_event_is_not_added_to_history() -> None:
|
||
item = ai_consult_module._parse_chat_item(
|
||
{
|
||
"msg_type": "custom",
|
||
"text": '{"businessID":"user_typing_status","typing":true}',
|
||
}
|
||
)
|
||
assert item is None
|
||
|
||
|
||
def test_chat_archive_renders_system_timeline_without_raw_signals(
|
||
application: QApplication,
|
||
) -> None:
|
||
dialog = AiConsultDialog(
|
||
DemoDoctorRepository(),
|
||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||
)
|
||
dialog._render_messages(
|
||
[
|
||
{
|
||
"msg_type": "custom",
|
||
"text": '{"businessID":"patient_opened_chat","patientName":"尹山","patientId":"11173"}',
|
||
"time": 1787103040,
|
||
},
|
||
{
|
||
"msg_type": "text",
|
||
"text": "我已确认问诊信息无误。",
|
||
"is_from_doctor": "0",
|
||
"time": 1787103050,
|
||
},
|
||
]
|
||
)
|
||
dialog.show()
|
||
application.processEvents()
|
||
|
||
event_titles = {
|
||
label.text()
|
||
for label in dialog.findChildren(QLabel)
|
||
if label.objectName() == "AiConsultEventTitle"
|
||
}
|
||
bodies = [
|
||
browser.toPlainText()
|
||
for browser in dialog.findChildren(QTextBrowser)
|
||
if browser.objectName() == "AiConsultBubbleText"
|
||
]
|
||
visible_copy = "\n".join([*event_titles, *bodies])
|
||
|
||
assert "患者进入在线问诊" in event_titles
|
||
assert any("我已确认问诊信息无误" in text for text in bodies)
|
||
assert "businessID" not in visible_copy
|
||
assert "11173" not in visible_copy
|
||
dialog.close()
|
||
|
||
|
||
def test_archived_ai_clinical_reply_restores_the_structured_panel(
|
||
application: QApplication,
|
||
) -> None:
|
||
dialog = AiConsultDialog(
|
||
DemoDoctorRepository(),
|
||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||
)
|
||
dialog._render_messages(
|
||
[
|
||
{
|
||
"msg_type": "text",
|
||
"role": "assistant",
|
||
"text": LONG_CLINICAL_REPLY,
|
||
"time": 1787103050,
|
||
}
|
||
]
|
||
)
|
||
dialog.show()
|
||
application.processEvents()
|
||
|
||
panels = dialog.findChildren(QWidget, "AiConsultClinicalPanel")
|
||
assert len(panels) == 1
|
||
assert panels[0].isVisible()
|
||
dialog.close()
|
||
|
||
|
||
def test_chat_layout_keeps_ai_answer_readable_in_compact_window(
|
||
application: QApplication,
|
||
) -> None:
|
||
dialog = AiConsultDialog(
|
||
DemoDoctorRepository(),
|
||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||
)
|
||
dialog.resize(1080, 680)
|
||
dialog._append_bubble(
|
||
"ai",
|
||
"### 病情分析\n\n" + "需要结合病历与检查结果综合判断。" * 8,
|
||
time_text="AI 分析",
|
||
)
|
||
dialog.show()
|
||
application.processEvents()
|
||
|
||
ai_frames = [
|
||
frame
|
||
for frame in dialog.findChildren(ai_consult_module.QFrame)
|
||
if frame.objectName() == "AiConsultBubbleAi"
|
||
]
|
||
assert ai_frames
|
||
assert ai_frames[-1].width() >= dialog.chat_scroll.viewport().width() * 0.6
|
||
assert dialog.input.height() <= 50
|
||
assert dialog.input.parentWidget().height() <= 120
|
||
dialog.close()
|
||
|
||
|
||
def test_completed_clinical_reply_is_parsed_into_scan_first_sections() -> None:
|
||
model = ai_consult_module._parse_clinical_analysis(LONG_CLINICAL_REPLY)
|
||
|
||
assert model is not None
|
||
assert "脾肾两虚" in model.summary
|
||
assert [item.label for item in model.evidence] == [
|
||
"空腹血糖",
|
||
"既往史",
|
||
"主诉",
|
||
"基本信息",
|
||
]
|
||
assert len(model.hypotheses) == 2
|
||
assert len(model.gaps) == 3
|
||
assert len(model.steps) == 4
|
||
assert len(model.risks) == 2
|
||
assert "不能替代执业医师" in model.disclaimer
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"payload",
|
||
[
|
||
"请继续补充舌象与脉象。",
|
||
'{"businessID":"patient_opened_chat"}',
|
||
"<p>普通 HTML 回复</p>",
|
||
],
|
||
)
|
||
def test_short_or_machine_payloads_keep_the_standard_chat_bubble(payload: str) -> None:
|
||
assert ai_consult_module._parse_clinical_analysis(payload) is None
|
||
|
||
|
||
def test_completed_clinical_reply_switches_one_bubble_to_structured_panel(
|
||
application: QApplication,
|
||
) -> None:
|
||
dialog = AiConsultDialog(
|
||
DemoDoctorRepository(),
|
||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||
)
|
||
bubble = dialog._append_bubble("ai", LONG_CLINICAL_REPLY, time_text="千问 · 11:48")
|
||
assert bubble.body is not None and not bubble.body.isHidden()
|
||
|
||
assert bubble.finalize_clinical_analysis()
|
||
dialog.show()
|
||
application.processEvents()
|
||
|
||
panel = bubble.findChild(QWidget, "AiConsultClinicalPanel")
|
||
assert panel is not None and panel.isVisible()
|
||
assert bubble.body.isHidden()
|
||
assert "可能证候分析" in bubble.body.toPlainText()
|
||
assert len(panel.findChildren(ai_consult_module.QFrame, "AiConsultEvidenceCard")) == 4
|
||
assert len(panel.findChildren(ai_consult_module.QFrame, "AiConsultGapRow")) == 3
|
||
assert len(panel.findChildren(ai_consult_module.QFrame, "AiConsultWorkflowStep")) == 4
|
||
assert len(panel.findChildren(ai_consult_module.QFrame, "AiConsultRiskCard")) == 1
|
||
dialog.close()
|
||
|
||
|
||
def test_structured_clinical_reply_has_no_horizontal_overflow_at_minimum_size(
|
||
application: QApplication,
|
||
) -> None:
|
||
dialog = AiConsultDialog(
|
||
DemoDoctorRepository(),
|
||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||
)
|
||
dialog.resize(1080, 680)
|
||
bubble = dialog._append_bubble("ai", LONG_CLINICAL_REPLY, time_text="千问 · 11:48")
|
||
assert bubble.finalize_clinical_analysis()
|
||
dialog.show()
|
||
for _ in range(3):
|
||
application.processEvents()
|
||
|
||
panel = bubble.findChild(QWidget, "AiConsultClinicalPanel")
|
||
assert panel is not None
|
||
assert dialog.chat_scroll.horizontalScrollBar().maximum() == 0
|
||
assert panel.width() <= dialog.chat_scroll.viewport().width()
|
||
assert panel.minimumSizeHint().width() <= dialog.chat_scroll.viewport().width()
|
||
dialog.close()
|
||
|
||
|
||
def test_sidebar_replacement_hides_old_cards_immediately() -> None:
|
||
dialog = AiConsultDialog(
|
||
DemoDoctorRepository(),
|
||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||
)
|
||
dialog._set_key_facts([("病程", "2个月"), ("BMI", "22.1")])
|
||
previous = [
|
||
frame
|
||
for frame in dialog.findChildren(ai_consult_module.QFrame)
|
||
if frame.objectName() == "AiConsultKeyCard"
|
||
]
|
||
assert previous
|
||
|
||
dialog._set_key_facts([("病程", "3个月")])
|
||
|
||
assert all(frame.isHidden() for frame in previous)
|
||
assert all(frame.parentWidget() is None for frame in previous)
|
||
dialog.close()
|
||
|
||
|
||
def test_stream_chunks_update_one_ai_bubble_before_done_and_preserve_order(
|
||
application: QApplication,
|
||
) -> None:
|
||
dialog = AiConsultDialog(
|
||
DemoDoctorRepository(),
|
||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||
)
|
||
dialog.show()
|
||
dialog._stream_bubble = dialog._append_bubble("ai", "")
|
||
bubble = dialog._stream_bubble
|
||
generation = dialog._generation
|
||
stream_generation = dialog._stream_generation
|
||
|
||
dialog._stream_event(
|
||
generation,
|
||
stream_generation,
|
||
{"event": "delta", "text": "第一段"},
|
||
)
|
||
dialog._flush_timer.stop()
|
||
dialog._flush_stream_chunks()
|
||
application.processEvents()
|
||
assert bubble is not None and bubble.body is not None
|
||
assert bubble.body.toPlainText() == "第一段"
|
||
ai_bubble_count = len(
|
||
[frame for frame in dialog.findChildren(ai_consult_module.QFrame) if frame.objectName() == "AiConsultBubbleAi"]
|
||
)
|
||
|
||
dialog._stream_event(
|
||
generation,
|
||
stream_generation,
|
||
{"event": "delta", "text": "第二段"},
|
||
)
|
||
dialog._stream_event(
|
||
generation,
|
||
stream_generation,
|
||
{"event": "done", "model_label": "千问"},
|
||
)
|
||
application.processEvents()
|
||
assert bubble.body.toPlainText() == "第一段第二段"
|
||
assert len(
|
||
[frame for frame in dialog.findChildren(ai_consult_module.QFrame) if frame.objectName() == "AiConsultBubbleAi"]
|
||
) == ai_bubble_count
|
||
dialog.close()
|
||
|
||
|
||
def test_long_stream_stays_markdown_until_done_then_switches_in_place(
|
||
application: QApplication,
|
||
) -> None:
|
||
dialog = AiConsultDialog(
|
||
DemoDoctorRepository(),
|
||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||
)
|
||
dialog.show()
|
||
dialog._stream_bubble = dialog._append_bubble("ai", "")
|
||
bubble = dialog._stream_bubble
|
||
assert bubble is not None
|
||
generation = dialog._generation
|
||
stream_generation = dialog._stream_generation
|
||
midpoint = len(LONG_CLINICAL_REPLY) // 2
|
||
|
||
dialog._stream_event(
|
||
generation,
|
||
stream_generation,
|
||
{"event": "delta", "text": LONG_CLINICAL_REPLY[:midpoint]},
|
||
)
|
||
dialog._flush_timer.stop()
|
||
dialog._flush_stream_chunks()
|
||
application.processEvents()
|
||
assert bubble.findChild(QWidget, "AiConsultClinicalPanel") is None
|
||
assert bubble.body is not None and not bubble.body.isHidden()
|
||
|
||
dialog._stream_event(
|
||
generation,
|
||
stream_generation,
|
||
{"event": "delta", "text": LONG_CLINICAL_REPLY[midpoint:]},
|
||
)
|
||
dialog._stream_event(
|
||
generation,
|
||
stream_generation,
|
||
{"event": "done", "model_label": "千问"},
|
||
)
|
||
application.processEvents()
|
||
|
||
panel = bubble.findChild(QWidget, "AiConsultClinicalPanel")
|
||
assert panel is not None and panel.isVisible()
|
||
assert bubble.body.toPlainText().startswith("基于本诊单资料")
|
||
assert bubble.body.isHidden()
|
||
dialog.close()
|
||
|
||
|
||
def test_stream_error_and_cancelled_late_chunk_reuse_or_leave_current_bubble(
|
||
application: QApplication,
|
||
) -> None:
|
||
dialog = AiConsultDialog(
|
||
DemoDoctorRepository(),
|
||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||
)
|
||
dialog.show()
|
||
dialog._stream_bubble = dialog._append_bubble("ai", "")
|
||
bubble = dialog._stream_bubble
|
||
generation = dialog._generation
|
||
stream_generation = dialog._stream_generation
|
||
dialog._stream_event(
|
||
generation,
|
||
stream_generation,
|
||
{"event": "delta", "text": "已生成"},
|
||
)
|
||
dialog._stream_failed(generation, stream_generation, RuntimeError("模型繁忙"))
|
||
application.processEvents()
|
||
assert bubble is not None and bubble.body is not None
|
||
assert "已生成" in bubble.body.toPlainText()
|
||
assert "模型繁忙" in bubble.body.toPlainText()
|
||
|
||
before_cancel = bubble.body.toPlainText()
|
||
dialog.close()
|
||
application.processEvents()
|
||
dialog._stream_event(
|
||
generation,
|
||
stream_generation,
|
||
{"event": "delta", "text": "迟到内容"},
|
||
)
|
||
application.processEvents()
|
||
assert bubble.body.toPlainText() == before_cancel
|
||
|
||
|
||
def test_chat_scroll_follows_bottom_but_respects_user_scroll_and_send_restores_it(
|
||
application: QApplication,
|
||
) -> None:
|
||
dialog = AiConsultDialog(
|
||
DemoDoctorRepository(),
|
||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||
)
|
||
dialog.diagnosis_id = 501
|
||
dialog.show()
|
||
for index in range(28):
|
||
dialog._append_bubble("ai", f"历史消息 {index}:" + "辨证内容" * 16)
|
||
application.processEvents()
|
||
bar = dialog.chat_scroll.verticalScrollBar()
|
||
bar.setValue(bar.maximum())
|
||
application.processEvents()
|
||
assert dialog._follow_chat
|
||
|
||
bar.setValue(max(0, bar.maximum() // 3))
|
||
application.processEvents()
|
||
reading_position = bar.value()
|
||
assert not dialog._follow_chat
|
||
dialog._append_bubble("ai", "新的流式内容" * 20)
|
||
application.processEvents()
|
||
assert bar.value() == reading_position
|
||
|
||
dialog._ask("请继续分析")
|
||
application.processEvents()
|
||
assert dialog._follow_chat
|
||
assert bar.value() == bar.maximum()
|
||
dialog.close()
|
||
|
||
|
||
def test_build_patient_ai_context_covers_videos_tongue_blood_sugar_and_reports() -> None:
|
||
detail = {
|
||
"diagnosis": {
|
||
"fasting_blood_sugar": "6.8",
|
||
"tongue": "舌淡红,苔薄白",
|
||
"pulse": "弦细",
|
||
},
|
||
"tongue_images": ["a.jpg", "b.jpg"],
|
||
}
|
||
tracking = {
|
||
"blood_sugar": {
|
||
"entries": [
|
||
{"date": "2026-08-18", "value": "7.2", "period": "空腹"},
|
||
{"date": "2026-08-19", "value": "9.1", "period": "餐后"},
|
||
],
|
||
}
|
||
}
|
||
analysis = {"summary": "血糖控制欠佳", "risk_assessment": ["低血糖风险"]}
|
||
prescriptions = [{"prescription_name": "逍遥散", "prescription_remark": "疏肝健脾"}]
|
||
call_records = [
|
||
{
|
||
"diagnosis_id": 501,
|
||
"transcript_text": "患者:睡眠好转。医生:继续观察。",
|
||
"start_time_text": "2026-08-20 09:10:00",
|
||
},
|
||
{
|
||
"diagnosis_id": 999,
|
||
"transcript_text": "其他诊单的文字不应混入",
|
||
},
|
||
]
|
||
|
||
text, labels = build_patient_ai_context(
|
||
detail=detail,
|
||
tracking=tracking,
|
||
analysis=analysis,
|
||
prescriptions=prescriptions,
|
||
call_records=call_records,
|
||
diagnosis_id=501,
|
||
)
|
||
|
||
assert labels == ["每日血糖", "舌苔/脉象", "视频问诊文字", "历史AI报告", "处方记录"]
|
||
assert text.startswith("【患者综合资料】")
|
||
assert "【每日血糖】" in text and "7.2" in text and "9.1" in text
|
||
assert "【舌苔/脉象】" in text and "舌淡红" in text and "舌苔图片 2 张" in text
|
||
assert "【视频问诊文字】" in text and "睡眠好转" in text
|
||
assert "其他诊单的文字不应混入" not in text
|
||
assert "【历史AI报告】" in text and "血糖控制欠佳" in text
|
||
assert "【处方记录】" in text and "逍遥散" in text
|
||
assert len(text) <= 360
|
||
|
||
|
||
def test_build_patient_ai_context_with_minimal_detail_returns_empty() -> None:
|
||
text, labels = build_patient_ai_context(
|
||
detail={"patient_name": "张三", "age": 45},
|
||
diagnosis_id=1,
|
||
)
|
||
assert text == ""
|
||
assert labels == []
|
||
|
||
text_none, labels_none = build_patient_ai_context(diagnosis_id=0)
|
||
assert text_none == ""
|
||
assert labels_none == []
|
||
|
||
|
||
def test_compose_ai_prompt_truncates_long_question_within_limit() -> None:
|
||
context = "【患者综合资料】\n【每日血糖】诊时6.8"
|
||
long_question = "请详细分析血糖波动原因与调护建议:" * 30
|
||
|
||
composed = ai_consult_module._compose_ai_prompt(long_question, context)
|
||
assert composed.startswith(context)
|
||
assert "— 医生提问 —" in composed
|
||
assert composed.endswith("…")
|
||
assert len(composed) <= ai_consult_module.AI_PROMPT_LIMIT
|
||
|
||
short = ai_consult_module._compose_ai_prompt("睡眠如何?", context)
|
||
assert short == f"{context}\n\n— 医生提问 —\n睡眠如何?"
|
||
|
||
assert ai_consult_module._compose_ai_prompt("", context) == ""
|
||
assert ai_consult_module._compose_ai_prompt(" ", context) == ""
|
||
assert ai_consult_module._compose_ai_prompt("问题内容", "") == "问题内容"
|
||
|
||
|
||
def test_ai_consult_dialog_renders_patient_context_bubble(
|
||
application: QApplication,
|
||
immediate_async: None,
|
||
) -> None:
|
||
dialog = AiConsultDialog(
|
||
DemoDoctorRepository(),
|
||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||
)
|
||
dialog.open_for(diagnosis_id=501, patient_id=301)
|
||
dialog.show()
|
||
application.processEvents()
|
||
|
||
assert dialog._patient_ai_context.startswith("【患者综合资料】")
|
||
assert "舌苔/脉象" in dialog._patient_ai_context_labels
|
||
assert "视频问诊文字" in dialog._patient_ai_context_labels
|
||
assert "睡眠比上周好一些" in dialog._patient_ai_context
|
||
|
||
bodies = [
|
||
widget.toPlainText()
|
||
for widget in dialog.findChildren(QTextBrowser)
|
||
if widget.objectName() == "AiConsultBubbleText"
|
||
]
|
||
assert any("实际 AI 请求由服务端实时聚合全量纵向资料" in text for text in bodies)
|
||
assert any("共 1 条视频问诊记录" in text for text in bodies)
|
||
|
||
toggles = [
|
||
widget
|
||
for widget in dialog.findChildren(QPushButton)
|
||
if widget.objectName() == "AiConsultContextToggle"
|
||
]
|
||
assert toggles
|
||
assert toggles[0].text() == "查看本地资料预览"
|
||
|
||
reveals = [
|
||
widget
|
||
for widget in dialog.findChildren(QLabel)
|
||
if widget.objectName() == "AiConsultContextReveal"
|
||
]
|
||
assert reveals
|
||
assert reveals[0].text() == dialog._patient_ai_context
|
||
assert reveals[0].isHidden()
|
||
|
||
toggles[0].click()
|
||
application.processEvents()
|
||
assert reveals[0].isVisible()
|
||
assert toggles[0].text() == "收起本地资料预览"
|
||
dialog.close()
|
||
|
||
|
||
def test_ask_sends_only_question_and_relies_on_server_full_context(
|
||
application: QApplication,
|
||
immediate_async: None,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
captured: list[str] = []
|
||
|
||
class FakeSignal:
|
||
def connect(self, _slot: Any) -> None:
|
||
return None
|
||
|
||
class FakeWorker:
|
||
def __init__(
|
||
self,
|
||
repository: Any,
|
||
*,
|
||
diagnosis_id: int,
|
||
prompt: str,
|
||
task: str,
|
||
) -> None:
|
||
captured.append(prompt)
|
||
self.signals = SimpleNamespace(
|
||
event=FakeSignal(),
|
||
error=FakeSignal(),
|
||
finished=FakeSignal(),
|
||
)
|
||
|
||
def cancel(self) -> None:
|
||
return None
|
||
|
||
monkeypatch.setattr(ai_consult_module, "_AiStreamWorker", FakeWorker)
|
||
monkeypatch.setattr(
|
||
ai_consult_module,
|
||
"QThreadPool",
|
||
SimpleNamespace(
|
||
globalInstance=lambda: SimpleNamespace(start=lambda worker: None)
|
||
),
|
||
)
|
||
|
||
dialog = AiConsultDialog(
|
||
DemoDoctorRepository(),
|
||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||
)
|
||
dialog.open_for(diagnosis_id=501, patient_id=301)
|
||
dialog.show()
|
||
application.processEvents()
|
||
assert dialog._patient_ai_context
|
||
|
||
dialog._ask("请结合资料分析当前证候")
|
||
assert captured, "AI stream worker should have been started with a prompt"
|
||
|
||
prompt = captured[0]
|
||
assert prompt == "请结合资料分析当前证候"
|
||
assert dialog._patient_ai_context not in prompt
|
||
assert len(prompt) <= ai_consult_module.AI_PROMPT_LIMIT
|
||
|
||
all_bubble_texts = [
|
||
widget.toPlainText()
|
||
for widget in dialog.findChildren(QTextBrowser)
|
||
if widget.objectName() == "AiConsultBubbleText"
|
||
]
|
||
assert any("请结合资料分析当前证候" in text for text in all_bubble_texts)
|
||
assert any("服务端将按当前诊单实时附带患者全部纵向资料" in text for text in all_bubble_texts)
|
||
dialog.close()
|
||
|
||
|
||
def _bubble_texts(dialog: AiConsultDialog) -> list[str]:
|
||
return [
|
||
widget.toPlainText()
|
||
for widget in dialog.findChildren(QTextBrowser)
|
||
if widget.objectName() == "AiConsultBubbleText"
|
||
]
|
||
|
||
|
||
def _silent_dialog(monkeypatch: pytest.MonkeyPatch) -> AiConsultDialog:
|
||
"""A dialog whose stream workers are never actually started."""
|
||
|
||
monkeypatch.setattr(
|
||
ai_consult_module,
|
||
"QThreadPool",
|
||
SimpleNamespace(
|
||
globalInstance=lambda: SimpleNamespace(start=lambda worker: None)
|
||
),
|
||
)
|
||
dialog = AiConsultDialog(
|
||
DemoDoctorRepository(),
|
||
PermissionSet(["tcm.diagnosis/aiAssistant"]),
|
||
)
|
||
dialog.open_for(diagnosis_id=501, patient_id=301)
|
||
return dialog
|
||
|
||
|
||
def test_full_context_notice_is_shown_once_per_conversation(
|
||
application: QApplication,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
# 每轮问答都重复同一句全量上下文说明会把真正的回答挤出可视区。
|
||
dialog = _silent_dialog(monkeypatch)
|
||
dialog.show()
|
||
application.processEvents()
|
||
|
||
notice = "服务端将按当前诊单实时附带患者全部纵向资料"
|
||
counts = []
|
||
for question in ("第一个问题", "第二个问题", "第三个问题"):
|
||
dialog._cancel_stream()
|
||
dialog._ask(question)
|
||
counts.append(sum(1 for text in _bubble_texts(dialog) if notice in text))
|
||
assert counts == [1, 1, 1]
|
||
|
||
# 换患者视为新会话,需要重新提示一次。
|
||
dialog.open_for(diagnosis_id=502, patient_id=302)
|
||
application.processEvents()
|
||
dialog._ask("新患者的问题")
|
||
assert sum(1 for text in _bubble_texts(dialog) if notice in text) == 1
|
||
dialog.close()
|
||
|
||
|
||
def test_pending_and_silently_closed_streams_never_show_a_blank_bubble(
|
||
application: QApplication,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
# 服务端既不发 done 也不报错时,占位气泡会永远停在空白状态。
|
||
dialog = _silent_dialog(monkeypatch)
|
||
dialog.show()
|
||
application.processEvents()
|
||
|
||
dialog._ask("请评估当前用药是否合理")
|
||
assert dialog._stream_bubble is not None
|
||
assert dialog._stream_bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT
|
||
assert any(
|
||
ai_consult_module.AI_STREAM_PENDING_TEXT in text for text in _bubble_texts(dialog)
|
||
)
|
||
|
||
dialog._stream_finished(
|
||
dialog._generation, dialog._stream_generation, dialog._stream_worker
|
||
)
|
||
application.processEvents()
|
||
assert dialog._stream_text == ai_consult_module.AI_STREAM_SILENT_TEXT
|
||
assert any(ai_consult_module.AI_STREAM_SILENT_TEXT in text for text in _bubble_texts(dialog))
|
||
assert dialog.send_button.isEnabled()
|
||
dialog.close()
|
||
|
||
|
||
def test_answered_stream_replaces_the_pending_placeholder(
|
||
application: QApplication,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
dialog = _silent_dialog(monkeypatch)
|
||
dialog.show()
|
||
application.processEvents()
|
||
|
||
dialog._ask("请总结当前病情")
|
||
generation, stream_generation = dialog._generation, dialog._stream_generation
|
||
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "证候:"})
|
||
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "脾肾两虚"})
|
||
dialog._stream_event(generation, stream_generation, {"event": "done", "model_label": "千问"})
|
||
dialog._stream_finished(generation, stream_generation, dialog._stream_worker)
|
||
application.processEvents()
|
||
|
||
assert dialog._stream_text == "证候:脾肾两虚"
|
||
texts = _bubble_texts(dialog)
|
||
assert not any(ai_consult_module.AI_STREAM_PENDING_TEXT in text for text in texts)
|
||
assert not any(ai_consult_module.AI_STREAM_SILENT_TEXT in text for text in texts)
|
||
dialog.close()
|
||
|
||
|
||
def test_ai_consult_window_can_be_maximized(
|
||
application: QApplication,
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
# 这个窗口信息密度很高,必须允许医生放大到整屏。
|
||
dialog = _silent_dialog(monkeypatch)
|
||
flags = dialog.windowFlags()
|
||
assert flags & Qt.WindowType.WindowMaximizeButtonHint
|
||
assert flags & Qt.WindowType.WindowMinimizeButtonHint
|
||
assert dialog.isSizeGripEnabled()
|
||
dialog.showMaximized()
|
||
application.processEvents()
|
||
assert dialog.isMaximized()
|
||
dialog.close()
|
||
|
||
|
||
def test_long_reply_gets_section_hierarchy_and_breathing_room(
|
||
application: QApplication,
|
||
) -> None:
|
||
"""QTextDocument's markdown importer ignores setDefaultStyleSheet.
|
||
|
||
Spacing therefore has to be applied to the parsed document; without it every
|
||
block renders at one size with 6px margins and the reply reads as a wall.
|
||
"""
|
||
|
||
browser = ai_consult_module._RichMessage("ai")
|
||
browser.resize(660, 400)
|
||
browser.set_payload(
|
||
"概述段落。\n\n"
|
||
"1. **症状演变与疗效评估**\n"
|
||
" - **麻木症状:** 服药十四天后是否缓解?\n"
|
||
" - **皮肤瘙痒:** 目前是否仍有发作?\n"
|
||
"2. **血糖控制与监测细节**\n"
|
||
" - **监测习惯:** 是否规律监测餐后血糖?\n"
|
||
)
|
||
|
||
document = browser.document()
|
||
base_px = browser.font().pixelSize()
|
||
seen: dict[str, list] = {"section": [], "item": [], "paragraph": []}
|
||
block = document.begin()
|
||
while block.isValid():
|
||
text_list = block.textList()
|
||
indent = text_list.format().indent() if text_list is not None else 0
|
||
kind = "item" if indent >= 2 else "section" if indent == 1 else "paragraph"
|
||
seen[kind].append(block)
|
||
block = block.next()
|
||
|
||
assert seen["section"] and seen["item"], "both list levels must be present"
|
||
|
||
# 小节标题比正文更大更重,否则四个小节无法一眼分辨。
|
||
section = seen["section"][0]
|
||
section_size = section.begin().fragment().charFormat().font().pixelSize()
|
||
assert section_size > base_px
|
||
|
||
# 小节之间的留白必须大于同一小节内要点之间的留白。
|
||
section_top = seen["section"][0].blockFormat().topMargin()
|
||
item_top = seen["item"][0].blockFormat().topMargin()
|
||
assert section_top > item_top > 0
|
||
|
||
# 一条要点的折行必须比两条要点之间更紧,否则整段会散成碎片。
|
||
item_line = seen["item"][0].blockFormat().lineHeight()
|
||
assert 0 < item_line < 170
|
||
|
||
# 首块不带上边距,避免气泡顶部出现一段空白。
|
||
assert document.begin().blockFormat().topMargin() == 0
|
||
|
||
|
||
def test_short_reply_is_not_over_spaced(application: QApplication) -> None:
|
||
browser = ai_consult_module._RichMessage("ai")
|
||
browser.resize(660, 200)
|
||
browser.set_payload("血糖控制尚可,暂无需调整降糖方案。")
|
||
block = browser.document().begin()
|
||
assert block.blockFormat().topMargin() == 0
|
||
assert block.next().isValid() is False
|
||
|
||
|
||
def test_sectioned_reply_becomes_a_structured_report(application: QApplication) -> None:
|
||
"""The clinical panel only knows four fixed section names.
|
||
|
||
Real answers are sectioned as 症状演变 / 血糖控制 / 用药依从性 …, which matched
|
||
none of them and therefore fell back to a plain wall of text.
|
||
"""
|
||
|
||
reply = (
|
||
"以下是针对该患者当前情况,建议向患者确认的关键问诊问题,用于补充现有病历中的信息缺口:\n\n"
|
||
"1. **症状演变与疗效评估**\n"
|
||
" - **麻木症状:** 服药十四天后四肢麻木是否有所缓解?\n"
|
||
" - **皮肤瘙痒:** 目前是否仍有发作?是否与血糖波动有关?\n"
|
||
"2. **血糖控制与监测细节**\n"
|
||
" - **空腹血糖波动:** 近期是否有反复的低血糖发作?\n"
|
||
"3. **用药依从性与生活方式**\n"
|
||
" - **西药服用情况:** 近期是否有漏服或自行调整剂量?\n\n"
|
||
"**提示:** 以上问题基于现有脱敏病例资料梳理,需由执业医师复核后确定。\n"
|
||
)
|
||
parsed = ai_consult_module.parse_structured_report(reply)
|
||
assert parsed is not None
|
||
intro, sections, disclaimer = parsed
|
||
assert [section.title for section in sections] == [
|
||
"症状演变与疗效评估",
|
||
"血糖控制与监测细节",
|
||
"用药依从性与生活方式",
|
||
]
|
||
assert [len(section.items) for section in sections] == [2, 1, 1]
|
||
assert "信息缺口" in intro
|
||
assert "执业医师" in disclaimer
|
||
|
||
bubble = ai_consult_module._ChatBubble(role="ai", text=reply, time_text="千问 · 11:01")
|
||
assert bubble.finalize_clinical_analysis() is True
|
||
panel = bubble.findChild(ai_consult_module._StructuredReportPanel)
|
||
assert panel is not None
|
||
titles = [
|
||
label.text()
|
||
for label in panel.findChildren(QLabel)
|
||
if label.objectName() == "AiConsultReportSectionTitle"
|
||
]
|
||
assert titles == ["症状演变与疗效评估", "血糖控制与监测细节", "用药依从性与生活方式"]
|
||
# 要点不再每句一个方框,而是「小标题 + 正文」两级文字。
|
||
assert panel.findChildren(ai_consult_module.QFrame, "AiConsultGapRow") == []
|
||
leads = [
|
||
label.text()
|
||
for label in panel.findChildren(QLabel)
|
||
if label.objectName() == "AiConsultReportLead"
|
||
]
|
||
assert leads == ["麻木症状", "皮肤瘙痒", "空腹血糖波动", "西药服用情况"]
|
||
bubble.deleteLater()
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"reply",
|
||
[
|
||
"血糖控制尚可,暂无需调整降糖方案。",
|
||
"1. **只有一个小节**\n - 一条要点\n",
|
||
'{"summary": "结构化 JSON 走既有解析路径"}',
|
||
],
|
||
)
|
||
def test_unsectioned_replies_stay_plain_text(reply: str, application: QApplication) -> None:
|
||
assert ai_consult_module.parse_structured_report(reply) is None
|
||
|
||
|
||
def test_report_points_split_into_a_scannable_label_and_body() -> None:
|
||
split = ai_consult_module.split_report_lead
|
||
|
||
assert split("糖尿病管理缺失: 患者确诊糖尿病3年,空腹血糖为 8.5 mmol/L。") == (
|
||
"糖尿病管理缺失",
|
||
"患者确诊糖尿病3年,空腹血糖为 8.5 mmol/L。",
|
||
)
|
||
assert split("结论:由于患者当前未使用任何药物,无需复核。")[0] == "结论"
|
||
# 冒号前是一整句话,或正文太短,都按普通要点整段显示。
|
||
assert split("尽管无需复核用药,但基于患者病史,以下临床风险点需重点关注。以下为要点:细节")[0] == ""
|
||
assert split("空腹血糖: 8.5") == ("", "空腹血糖: 8.5")
|
||
assert split("没有冒号的一条要点") == ("", "没有冒号的一条要点")
|
||
|
||
|
||
def test_report_section_titles_drop_the_number_the_chip_already_shows() -> None:
|
||
strip = ai_consult_module._strip_leading_ordinal
|
||
|
||
assert strip("1. 当前用药状态评估") == "当前用药状态评估"
|
||
assert strip("二、临床风险与干预提示") == "临床风险与干预提示"
|
||
assert strip("建议下一步行动") == "建议下一步行动"
|
||
|
||
|
||
def test_report_body_escapes_markup_and_carries_reading_rhythm() -> None:
|
||
html = ai_consult_module._reading_html('血糖 <7.0 mmol/L 且 "达标" & 稳定')
|
||
|
||
assert "line-height" in html
|
||
assert "<7.0" in html
|
||
assert "&" in html
|
||
assert "<7.0" not in html
|
||
|
||
|
||
def test_structured_report_uses_a_readable_column_width(application: QApplication) -> None:
|
||
reply = (
|
||
"针对该患者的用药复核评估如下:\n\n"
|
||
"### 1. 当前用药状态评估\n"
|
||
"- 无当前处方药物: 病例数据中明确记录患者目前没有服药,系统内也没有有效处方记录。\n"
|
||
"- 结论: 患者当前未使用任何药物,不存在药物相互作用或配伍禁忌风险。\n"
|
||
"### 2. 临床风险与干预提示\n"
|
||
"- 糖尿病管理缺失: 患者确诊糖尿病3年,空腹血糖高于一般控制目标且未接受药物治疗。\n"
|
||
"重要提示: 本分析不能替代执业医师的面诊与完整病历评估。\n"
|
||
)
|
||
bubble = ai_consult_module._ChatBubble(role="ai", text=reply, time_text="千问 · 16:32")
|
||
|
||
assert bubble.finalize_clinical_analysis() is True
|
||
panel = bubble.findChild(ai_consult_module._StructuredReportPanel)
|
||
assert panel is not None
|
||
# 报告收窄到易读行宽,而不是继续用多栏面板的 1080。
|
||
assert panel.PREFERRED_MAX_WIDTH == 880
|
||
assert bubble._bubble_frame.maximumWidth() == 880
|
||
sections = panel.findChildren(ai_consult_module.QFrame, "AiConsultReportSection")
|
||
assert len(sections) == 2
|
||
assert panel.findChildren(ai_consult_module.QFrame, "AiConsultGapRow") == []
|
||
bubble.deleteLater()
|
||
|
||
|
||
def _fitted_bubble(reply: str, width: int = 900) -> Any:
|
||
host = QDialog()
|
||
host.setObjectName("AiConsultDialog")
|
||
host.setStyleSheet(ai_consult_module.AI_CONSULT_QSS)
|
||
layout = QVBoxLayout(host)
|
||
layout.setContentsMargins(12, 12, 12, 12)
|
||
bubble = ai_consult_module._ChatBubble(role="ai", text=reply, time_text="千问 · 16:32")
|
||
assert bubble.finalize_clinical_analysis() is True
|
||
layout.addWidget(bubble)
|
||
layout.addStretch(1)
|
||
host.setFixedWidth(width)
|
||
host.show()
|
||
for _ in range(12):
|
||
QApplication.processEvents()
|
||
host.adjustSize()
|
||
for _ in range(6):
|
||
QApplication.processEvents()
|
||
return host, bubble
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"reply",
|
||
[
|
||
LONG_CLINICAL_REPLY,
|
||
(
|
||
"针对该患者的用药复核评估如下:\n\n"
|
||
"### 1. 当前用药状态评估\n"
|
||
"- 无当前处方药物: 病例数据中明确记录患者目前没有服药,系统内也没有有效处方记录,"
|
||
"因此不存在药物相互作用或配伍禁忌风险,无需再做安全性复核。\n"
|
||
"### 2. 临床风险与干预提示\n"
|
||
"- 糖尿病管理缺失: 患者确诊糖尿病3年,空腹血糖高于一般控制目标且未接受任何药物治疗,"
|
||
"存在长期高血糖导致微血管及大血管并发症的风险,需要尽快评估。\n"
|
||
),
|
||
],
|
||
ids=["clinical", "structured"],
|
||
)
|
||
def test_report_bubbles_report_the_height_they_actually_paint(
|
||
reply: str,
|
||
application: QApplication,
|
||
) -> None:
|
||
"""否则聊天区会按高估的高度撑出滚动空白,打开就是一片空白要往上滑。"""
|
||
|
||
host, bubble = _fitted_bubble(reply)
|
||
|
||
assert bubble.height() > 0
|
||
assert abs(bubble.sizeHint().height() - bubble.height()) <= 2
|
||
host.close()
|
||
host.deleteLater()
|
||
|
||
|
||
def test_risk_block_uses_the_red_alert_palette() -> None:
|
||
qss = ai_consult_module.AI_CONSULT_QSS
|
||
|
||
risk_card = qss.split("QFrame#AiConsultRiskCard {", 1)[1].split("}", 1)[0]
|
||
assert "#FEF3F2" in risk_card
|
||
assert "#F1B35C" not in risk_card # 旧的橙色描边
|
||
marker = qss.split("QLabel#AiConsultRiskMarker {", 1)[1].split("}", 1)[0]
|
||
assert "#C0392B" in marker
|
||
|
||
|
||
def test_clinical_bodies_are_no_longer_rendered_at_eleven_pixels() -> None:
|
||
qss = ai_consult_module.AI_CONSULT_QSS
|
||
|
||
body = qss.split("QLabel#AiConsultRiskBody {\n color: #46557A;", 1)
|
||
assert len(body) == 2 or "font-size: 13px" in qss
|
||
block = qss.split("QLabel#AiConsultClinicalBody,", 1)[1].split("}", 1)[0]
|
||
assert "font-size: 13px" in block
|
||
assert "font-size: 11px" not in block
|