Files
zyt/app/tests/test_ai_consult_ui.py
T
2026-08-28 18:24:37 +08:00

1660 lines
56 KiB
Python
Raw 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.
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 "&lt;7.0" in html
assert "&amp;" 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