更新
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
@@ -7,13 +8,22 @@ from typing import Any
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication, QLabel, QTextBrowser
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QTextBrowser,
|
||||
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,
|
||||
@@ -27,6 +37,34 @@ 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:
|
||||
@@ -85,6 +123,333 @@ def test_ai_consult_dialog_matches_workspace_chrome(
|
||||
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,
|
||||
@@ -294,9 +659,286 @@ def test_chat_payload_parses_markdown_html_and_json(application: QApplication) -
|
||||
|
||||
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:
|
||||
@@ -342,6 +984,51 @@ def test_stream_chunks_update_one_ai_bubble_before_done_and_preserve_order(
|
||||
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:
|
||||
@@ -407,3 +1094,200 @@ def test_chat_scroll_follows_bottom_but_respects_user_scroll_and_send_restores_i
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user