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, 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, ) 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 "
普通 HTML 回复
", ], ) 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()