This commit is contained in:
Your Name
2026-08-27 14:23:23 +08:00
parent b5b14516a1
commit 2fa8492c56
27 changed files with 3832 additions and 1435 deletions
+231
View File
@@ -1291,3 +1291,234 @@ def test_ask_sends_only_question_and_relies_on_server_full_context(
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() == "AiConsultClinicalSectionTitle"
]
assert titles == ["症状演变与疗效评估", "血糖控制与监测细节", "用药依从性与生活方式"]
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