This commit is contained in:
Your Name
2026-10-09 14:13:57 +08:00
parent 51d8fd409e
commit 5706e9a6a5
15 changed files with 732 additions and 162 deletions
@@ -19,7 +19,6 @@ from doctor_workstation.core.errors import (
ApiBusinessError,
ApiHttpError,
ApiProtocolError,
ApiTimeoutError,
ApiTransportError,
AuthenticationExpiredError,
)
@@ -1413,11 +1412,11 @@ class RemoteDoctorRepository:
task: str = "custom",
cancelled: Callable[[], bool] | None = None,
) -> Iterator[dict[str, Any]]:
"""Stream a diagnosis answer, with one legacy fallback before first content."""
"""Stream a diagnosis answer; use the legacy route only when streaming is absent."""
clean_prompt, clean_task = _diagnosis_ai_request(diagnosis_id, prompt, task)
body = {"id": diagnosis_id, "prompt": clean_prompt, "task": clean_task}
received_delta = False
received_event = False
received_done = False
try:
for raw_event in self.client.post_event_stream(
@@ -1428,13 +1427,12 @@ class RemoteDoctorRepository:
):
if cancelled is not None and cancelled():
return
received_event = True
event = _normalise_diagnosis_ai_event(raw_event)
if event is None:
continue
kind = event["event"]
if kind == "delta":
received_delta = True
elif kind == "done":
if kind == "done":
received_done = True
yield event
if kind == "done":
@@ -1443,12 +1441,16 @@ class RemoteDoctorRepository:
return
if not received_done:
raise ApiProtocolError("AI assistant stream ended before done")
except (ApiHttpError, ApiProtocolError, ApiTimeoutError, ApiTransportError):
if received_delta or (cancelled is not None and cancelled()):
except ApiHttpError as error:
if (
error.status_code not in {404, 405}
or received_event
or (cancelled is not None and cancelled())
):
raise
# Older deployments do not expose the stream route. Submit exactly one
# request through the confirmed non-streaming endpoint in that case.
# Only a missing stream route on an older deployment justifies another
# full request. Timeouts, transport failures and malformed streams do not.
result = self.analyze_diagnosis_ai(
diagnosis_id,
clean_prompt,
@@ -3146,6 +3146,8 @@ class _AiStreamWorker(QRunnable):
@Slot()
def run(self) -> None:
try:
if self.is_cancelled():
return
stream = getattr(self.repository, "stream_diagnosis_ai", None)
if callable(stream):
events = stream(
@@ -3169,6 +3171,8 @@ class _AiStreamWorker(QRunnable):
{"event": "delta", "text": answer, "fallback": True},
{**payload, "event": "done", "fallback": True},
)
if self.is_cancelled():
return
for event in events:
if self.is_cancelled():
return
@@ -3207,13 +3211,18 @@ class _ClickCard(QFrame):
AI_CONTEXT_MAX_CHARS = 320
AI_PROMPT_LIMIT = 500
#: 首个增量到达前显示的占位文案,避免留下一块没有任何说明的空白气泡。
AI_STREAM_PENDING_TEXT = "正在生成…"
#: 等待服务端整理诊单资料时显示的占位文案。
AI_STREAM_PENDING_TEXT = "正在整理诊单资料…"
#: 服务端确认资料已准备好、尚未收到回答时显示的占位文案。
AI_STREAM_WAITING_TEXT = "资料已整理,正在等待 AI 回复…"
#: 连接结束但一个字都没收到时的兜底文案。
AI_STREAM_SILENT_TEXT = (
"AI 助手没有返回内容,可能是服务端未响应或连接中断,请稍后重试。"
)
AI_STREAM_TOTAL_TIMEOUT_MS = 180_000
AI_STREAM_TIMEOUT_TEXT = "等待 AI 回复超时,已停止本次等待,请稍后重试。"
AI_CONTEXT_SEPARATOR = "\n\n— 医生提问 —\n"
@@ -3534,6 +3543,9 @@ class AiConsultDialog(QDialog):
self._flush_timer.setSingleShot(True)
self._flush_timer.setInterval(40)
self._flush_timer.timeout.connect(self._flush_stream_chunks)
self._stream_timeout_timer = QTimer(self)
self._stream_timeout_timer.setSingleShot(True)
self._stream_timeout_timer.timeout.connect(self._stream_timed_out)
self.setObjectName("AiConsultDialog")
self.setWindowTitle("问诊详情")
# QDialog 默认只带关闭按钮,医生无法把这个信息密度很高的窗口放大到整屏。
@@ -6028,6 +6040,7 @@ class AiConsultDialog(QDialog):
worker.signals.finished.connect(
lambda: self._stream_finished(generation, stream_generation, worker)
)
self._stream_timeout_timer.start(AI_STREAM_TOTAL_TIMEOUT_MS)
QThreadPool.globalInstance().start(worker)
QTimer.singleShot(0, self._scroll_chat_to_bottom)
@@ -6046,6 +6059,8 @@ class AiConsultDialog(QDialog):
kind = str(payload.get("event") or "").lower()
if kind == "start":
self._stream_meta.update(payload)
if self._stream_bubble is not None and not self._stream_text and not self._pending_chunks:
self._stream_bubble.set_payload(AI_STREAM_WAITING_TEXT)
return
if kind == "delta":
chunk = payload.get("text")
@@ -6058,6 +6073,7 @@ class AiConsultDialog(QDialog):
return
self._stream_meta.update(payload)
self._stream_completed = True
self._stream_timeout_timer.stop()
if not self._stream_text and not self._pending_chunks:
answer = first_value(payload, "answer", "content", default="")
if answer not in (None, ""):
@@ -6094,6 +6110,7 @@ class AiConsultDialog(QDialog):
) -> None:
if not self._stream_is_current(generation, stream_generation):
return
self._stream_timeout_timer.stop()
self._flush_stream_chunks()
message = friendly_error(error)
if self._stream_text:
@@ -6112,10 +6129,11 @@ class AiConsultDialog(QDialog):
) -> None:
if not self._stream_is_current(generation, stream_generation):
return
self._stream_timeout_timer.stop()
if self._stream_worker is worker:
self._stream_worker = None
# 连接结束却既没有 done 也没有报错时(服务端静默断开),占位气泡会永远
# 停在“正在生成…”。这里补一条明确说明,而不是留一块空白卡片。
# 连接结束却既没有 done 也没有报错时(服务端静默断开),
# 将阶段占位文案替换成明确说明。
if (
not self._stream_completed
and not self._stream_text
@@ -6127,9 +6145,23 @@ class AiConsultDialog(QDialog):
self._asking = False
self.send_button.setEnabled(True)
def _stream_timed_out(self) -> None:
if not self._asking or self._stream_completed:
return
self._flush_stream_chunks()
bubble = self._stream_bubble
text = self._stream_text
self._cancel_stream()
if bubble is not None:
bubble.set_payload(
f"{text}\n\n> {AI_STREAM_TIMEOUT_TEXT}" if text else AI_STREAM_TIMEOUT_TEXT
)
bubble.set_time_text(datetime.now().strftime("%H:%M"))
def _cancel_stream(self) -> None:
self._stream_generation += 1
self._flush_timer.stop()
self._stream_timeout_timer.stop()
if self._stream_worker is not None:
self._stream_worker.cancel()
self._stream_worker = None
+63
View File
@@ -1491,6 +1491,55 @@ def test_pending_and_silently_closed_streams_never_show_a_blank_bubble(
dialog.close()
def test_stalled_stream_exits_pending_state_and_ignores_late_events(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
) -> None:
dialog = _silent_dialog(monkeypatch)
dialog.show()
application.processEvents()
dialog._ask("请总结当前病情")
generation, stream_generation = dialog._generation, dialog._stream_generation
bubble = dialog._stream_bubble
worker = dialog._stream_worker
assert bubble is not None and worker is not None
assert dialog._stream_timeout_timer.isActive()
dialog._stream_timed_out()
assert worker.is_cancelled()
assert not dialog._stream_timeout_timer.isActive()
assert not dialog._asking
assert dialog.send_button.isEnabled()
assert bubble._raw_payload == ai_consult_module.AI_STREAM_TIMEOUT_TEXT
calls: list[int] = []
monkeypatch.setattr(
dialog.repository,
"stream_diagnosis_ai",
lambda *args, **kwargs: calls.append(1),
raising=False,
)
worker.run()
assert calls == []
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "迟到回复"})
dialog._stream_finished(generation, stream_generation, worker)
assert bubble._raw_payload == ai_consult_module.AI_STREAM_TIMEOUT_TEXT
dialog._ask("请补充说明")
second_bubble = dialog._stream_bubble
assert second_bubble is not None
dialog._stream_event(
dialog._generation,
dialog._stream_generation,
{"event": "delta", "text": "已收到部分分析"},
)
dialog._stream_timed_out()
assert second_bubble._raw_payload.startswith("已收到部分分析\n\n>")
assert ai_consult_module.AI_STREAM_TIMEOUT_TEXT in second_bubble._raw_payload
dialog.close()
def test_answered_stream_replaces_the_pending_placeholder(
application: QApplication,
monkeypatch: pytest.MonkeyPatch,
@@ -1501,15 +1550,29 @@ def test_answered_stream_replaces_the_pending_placeholder(
dialog._ask("请总结当前病情")
generation, stream_generation = dialog._generation, dialog._stream_generation
assert dialog._stream_bubble is not None
assert dialog._stream_bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT
dialog._stream_event(
generation,
stream_generation,
{"event": "start", "source_summary": {"patient_name": "敏感姓名"}},
)
assert dialog._stream_bubble._raw_payload == ai_consult_module.AI_STREAM_WAITING_TEXT
assert "敏感姓名" not in dialog._stream_bubble._raw_payload
dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "证候:"})
dialog._flush_timer.stop()
dialog._flush_stream_chunks()
assert dialog._stream_bubble._raw_payload == "证候:"
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 == "证候:脾肾两虚"
assert not dialog._stream_timeout_timer.isActive()
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_WAITING_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()
+61 -3
View File
@@ -8,7 +8,13 @@ from typing import Any
import pytest
from doctor_workstation.core.errors import ApiBusinessError, ApiHttpError, ApiProtocolError
from doctor_workstation.core.errors import (
ApiBusinessError,
ApiHttpError,
ApiProtocolError,
ApiTimeoutError,
ApiTransportError,
)
from doctor_workstation.core.models import Appointment, Consultation, PageResult, Prescription
from doctor_workstation.services.mock_repository import DemoDoctorRepository
from doctor_workstation.services.repository import (
@@ -774,10 +780,11 @@ def test_remote_diagnosis_ai_stream_normalises_chunks_in_order() -> None:
assert client.post_calls == []
def test_remote_diagnosis_ai_stream_falls_back_once_but_not_for_error_event() -> None:
@pytest.mark.parametrize("status_code", [404, 405])
def test_remote_diagnosis_ai_stream_falls_back_for_missing_route(status_code: int) -> None:
class MissingStreamClient(RecordingClient):
def post_event_stream(self, *args: Any, **kwargs: Any):
raise ApiHttpError("missing", status_code=404)
raise ApiHttpError("missing", status_code=status_code)
missing_client = MissingStreamClient()
events = list(
@@ -789,6 +796,57 @@ def test_remote_diagnosis_ai_stream_falls_back_once_but_not_for_error_event() ->
"tcm.diagnosis/aiAssistant"
]
@pytest.mark.parametrize(
"stream_error",
[
ApiHttpError("server error", status_code=500),
ApiTimeoutError("timed out"),
ApiTransportError("disconnected"),
ApiProtocolError("invalid event stream"),
],
)
def test_remote_diagnosis_ai_stream_failures_do_not_issue_full_request(
stream_error: Exception,
) -> None:
class FailedStreamClient(RecordingClient):
def post_event_stream(self, *args: Any, **kwargs: Any):
raise stream_error
client = FailedStreamClient()
with pytest.raises(type(stream_error)):
list(RemoteDoctorRepository(client).stream_diagnosis_ai(501, "请分析"))
assert client.post_calls == []
@pytest.mark.parametrize("after_start", [False, True])
def test_remote_diagnosis_ai_stream_incomplete_response_does_not_fall_back(
after_start: bool,
) -> None:
class IncompleteStreamClient(RecordingClient):
def post_event_stream(self, *args: Any, **kwargs: Any):
if after_start:
yield {"event": "start", "data": {"model_key": "qwen"}}
client = IncompleteStreamClient()
with pytest.raises(ApiProtocolError, match="ended before done"):
list(RemoteDoctorRepository(client).stream_diagnosis_ai(501, "请分析"))
assert client.post_calls == []
def test_remote_diagnosis_ai_stream_does_not_fall_back_after_start() -> None:
class FailedAfterStartClient(RecordingClient):
def post_event_stream(self, *args: Any, **kwargs: Any):
yield {"event": "start", "data": {"model_key": "qwen"}}
raise ApiHttpError("missing", status_code=404)
client = FailedAfterStartClient()
with pytest.raises(ApiHttpError):
list(RemoteDoctorRepository(client).stream_diagnosis_ai(501, "请分析"))
assert client.post_calls == []
def test_remote_diagnosis_ai_stream_error_event_does_not_fall_back() -> None:
class ErrorStreamClient(RecordingClient):
def post_event_stream(self, *args: Any, **kwargs: Any):
yield {"event": "error", "data": {"message": "模型繁忙"}}