更新
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user