From 3f15786491716fbd6b5ec2f4a500283a7b315182 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 23 Oct 2026 09:26:49 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/.gitignore | 1 + app/README.md | 6 + app/Run_DoctorWorkstation_Local.bat | 10 + app/scripts/run_local_workstation.py | 70 ++++ .../doctor_workstation/services/api_client.py | 138 ++++++- .../doctor_workstation/services/repository.py | 28 +- .../ui/dialogs/ai_consult.py | 298 ++++++++++++-- app/tests/test_ai_cancel_status_ui.py | 322 +++++++++++++++ app/tests/test_ai_cancel_transport.py | 221 +++++++++++ app/tests/test_ai_consult_ui.py | 24 +- app/tests/test_local_launcher.py | 48 +++ app/tests/test_repository_parity.py | 4 +- .../controller/tcm/DiagnosisController.php | 233 ++++++----- .../http/middleware/AuthMiddleware.php | 4 +- .../adminapi/logic/tcm/DiagnosisAiLogic.php | 98 ++++- .../logic/tcm/PatientAiReportLogic.php | 31 +- .../service/AiContextCompactionCache.php | 3 +- .../service/AssistantRequestControl.php | 286 ++++++++++++++ .../adminapi/service/AssistantSseProtocol.php | 4 +- .../adminapi/service/AssistantSseTiming.php | 4 +- .../validate/tcm/DiagnosisValidate.php | 18 + server/app/common/service/DifyChatService.php | 372 ++++++++++++++++-- .../ai_assistant_cancel_review_fixes.md | 50 +++ .../tests/AiContextCompactionLatencyTest.php | 17 + ...gnosisAiAssistantCancellationDrainTest.php | 201 ++++++++++ .../DiagnosisAiAssistantCancellationTest.php | 87 ++++ ...sisAiAssistantPreparationLifecycleTest.php | 67 ++++ ...agnosisAiAssistantStopCoordinationTest.php | 134 +++++++ ...DiagnosisAiAssistantStreamContractTest.php | 26 +- ...tStrictAndCompatibilityIntegrationTest.php | 125 ++++++ ...isAiAssistantTransportCancellationTest.php | 120 ++++++ ...isAiPrescriptionGeneratePermissionTest.php | 12 + .../PrescriptionAiUpstreamContractTest.php | 6 + .../fixtures/CancellableCurlMultiStub.php | 23 ++ 34 files changed, 2866 insertions(+), 225 deletions(-) create mode 100644 app/Run_DoctorWorkstation_Local.bat create mode 100644 app/scripts/run_local_workstation.py create mode 100644 app/tests/test_ai_cancel_status_ui.py create mode 100644 app/tests/test_ai_cancel_transport.py create mode 100644 app/tests/test_local_launcher.py create mode 100644 server/app/adminapi/service/AssistantRequestControl.php create mode 100644 server/research/ai_assistant_cancel_review_fixes.md create mode 100644 server/tests/DiagnosisAiAssistantCancellationDrainTest.php create mode 100644 server/tests/DiagnosisAiAssistantCancellationTest.php create mode 100644 server/tests/DiagnosisAiAssistantPreparationLifecycleTest.php create mode 100644 server/tests/DiagnosisAiAssistantStopCoordinationTest.php create mode 100644 server/tests/DiagnosisAiAssistantStrictAndCompatibilityIntegrationTest.php create mode 100644 server/tests/DiagnosisAiAssistantTransportCancellationTest.php create mode 100644 server/tests/fixtures/CancellableCurlMultiStub.php diff --git a/app/.gitignore b/app/.gitignore index a5034dcb2..932d7b054 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1,4 +1,5 @@ .env +.local-workstation/ .venv/ .venv-build/ .venv-win7-check/ diff --git a/app/README.md b/app/README.md index a2a5e935e..3ee068fe3 100644 --- a/app/README.md +++ b/app/README.md @@ -35,6 +35,12 @@ Windows 打包机需预先安装 `uv` 与 Node.js 20+;脚本会自动处理项 - 腾讯视频:沿用现有项目的 `@trtc/calls-uikit-vue` 主链;UserSig 只从后端短时获取,客户端不包含 SDKSecretKey。当前仅支持隔离的 QtWebEngine 内嵌模式。 - PyInstaller Windows/macOS 构建脚本、macOS 摄像头/麦克风权限配置与自动化测试。 +## 本地后端联调 + +本机 phpStudy 站点准备好后,双击 `Run_DoctorWorkstation_Local.bat`,使用当前源码连接 `https://css.zhenyangtang.com.cn`。此入口要求域名解析到本机,启用 TLS 校验,复用本机站点的公开证书;偏好、日志和登录凭据使用独立目录及凭据命名空间。正式启动入口及正式服务器配置保持原样。本地入口首次启动需要重新登录,不会借用正式环境的会话。 + +仅检查配置可执行 `Run_DoctorWorkstation_Local.bat --check`。其他本机 HTTPS 站点可通过 `DOCTOR_LOCAL_API_URL` 和 `DOCTOR_LOCAL_CA_FILE` 指定域名与公开证书路径,禁止传入私钥。配置保存在已忽略的 `.local-workstation/` 中;本地后端仍使用 `server/.env` 的现有配置。 + ## 手动运行 先安装 [uv](https://docs.astral.sh/uv/),然后在项目根目录执行: diff --git a/app/Run_DoctorWorkstation_Local.bat b/app/Run_DoctorWorkstation_Local.bat new file mode 100644 index 000000000..eabe93bb6 --- /dev/null +++ b/app/Run_DoctorWorkstation_Local.bat @@ -0,0 +1,10 @@ +@echo off +setlocal EnableExtensions DisableDelayedExpansion +set "LOCAL_APP_ROOT=%~dp0" +if not exist "%LOCAL_APP_ROOT%.venv\Scripts\python.exe" ( + echo Local Python environment is missing. Prepare the source environment first. + exit /b 1 +) +"%LOCAL_APP_ROOT%.venv\Scripts\python.exe" "%LOCAL_APP_ROOT%scripts\run_local_workstation.py" %* +set "LOCAL_RESULT=%ERRORLEVEL%" +endlocal & exit /b %LOCAL_RESULT% diff --git a/app/scripts/run_local_workstation.py b/app/scripts/run_local_workstation.py new file mode 100644 index 000000000..21ed304f2 --- /dev/null +++ b/app/scripts/run_local_workstation.py @@ -0,0 +1,70 @@ +"""Start the source workstation against a loopback backend with isolated login state.""" +from __future__ import annotations + +import functools +import os +import socket +import sys +from pathlib import Path +from urllib.parse import urlsplit + +import certifi + + +def validate_local_url(api: str) -> None: + parsed = urlsplit(api) + if (parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password + or parsed.query or parsed.fragment): + raise ValueError("本地入口需要不带凭据的 HTTPS 地址") + addresses = {row[4][0] for row in socket.getaddrinfo(parsed.hostname, parsed.port or 443)} + if not addresses or not addresses <= {"127.0.0.1", "::1"}: + raise ValueError("本地入口仅连接解析到本机的后端") + + +def validate_effective_config(config, requested_api: str) -> None: + from doctor_workstation.config import normalize_api_base_url + validate_local_url(config.api_base_url) + if config.api_base_url != normalize_api_base_url(requested_api) or not config.verify_ssl: + raise ValueError("本地独立配置已改动,请在 .local-workstation/config 中核对本地服务器设置") + + +def main() -> int: + root = Path(__file__).resolve().parents[1] + api = os.environ.get("DOCTOR_LOCAL_API_URL", "https://css.zhenyangtang.com.cn") + validate_local_url(api) + certificate = Path(os.environ.get( + "DOCTOR_LOCAL_CA_FILE", + "D:/phpstudy_pro/Extensions/Nginx1.15.11/conf/ssl/css.zhenyangtang.com.cn.pem", + )) + private_root = root / ".local-workstation" + private_root.mkdir(parents=True, exist_ok=True) + ca_bundle = private_root / "trusted-ca.pem" + ca_bundle.write_bytes(Path(certifi.where()).read_bytes() + b"\n" + certificate.read_bytes()) + os.environ.update({ + "DOCTOR_CONFIG_DIR": str(private_root / "config"), + "DOCTOR_LOG_DIR": str(private_root / "logs"), + "DOCTOR_API_BASE_URL": api, + "DOCTOR_DEMO_MODE": "false", + "DOCTOR_VERIFY_SSL": "true", + "SSL_CERT_FILE": str(ca_bundle), + }) + sys.path.insert(0, str(root / "src")) + import doctor_workstation + # This explicit source-only entry does not edit the formal build configuration. + doctor_workstation.DEBUG_MODE = True + from doctor_workstation.config import AppConfig + config = AppConfig.load() + validate_effective_config(config, api) + if "--check" in sys.argv: + print("Local backend: loopback HTTPS; TLS verification enabled; isolated configuration and credential namespace") + return 0 + from doctor_workstation import app as workstation_app + # The normal credential service remains untouched even after a local login. + workstation_app.TokenStore = functools.partial( + workstation_app.TokenStore, service_name="zyt-doctor-workstation-local-source" + ) + return workstation_app.main() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/app/src/doctor_workstation/services/api_client.py b/app/src/doctor_workstation/services/api_client.py index 6509ea923..a081facb5 100644 --- a/app/src/doctor_workstation/services/api_client.py +++ b/app/src/doctor_workstation/services/api_client.py @@ -2,10 +2,12 @@ from __future__ import annotations +import asyncio import json import time +from collections import deque from collections.abc import Callable, Iterator, Mapping -from contextlib import contextmanager +from contextlib import contextmanager, suppress from threading import Condition, RLock from typing import Any from urllib.parse import urljoin, urlsplit, urlunsplit @@ -229,6 +231,29 @@ class ApiClient: headers=headers, ) + def post_independent( + self, + endpoint: str, + payload: Mapping[str, Any], + *, + timeout: float = 10.0, + ) -> Any: + """Send a short control request without waiting for the ordinary pool.""" + + try: + with httpx.Client(transport=self._stream_transport, verify=self._verify) as client: + response = client.post( + self._endpoint_url(endpoint), + json=dict(payload), + headers=self._headers(None), + timeout=timeout, + ) + return self._unwrap(response) + except httpx.TimeoutException as exc: + raise ApiTimeoutError(f"POST {endpoint} timed out") from exc + except httpx.RequestError as exc: + raise ApiTransportError(f"POST {endpoint} failed") from exc + def post_event_stream( self, endpoint: str, @@ -248,6 +273,14 @@ class ApiClient: headers = self._headers({"Accept": "text/event-stream"}) request_timeout = self.timeout if timeout is None else timeout try: + if cancelled is not None and ( + self._stream_transport is None + or isinstance(self._stream_transport, httpx.AsyncBaseTransport) + ): + yield from self._cancellable_event_stream( + url, payload, headers, request_timeout, cancelled + ) + return with httpx.Client( transport=self._stream_transport, verify=self._verify, @@ -287,6 +320,109 @@ class ApiClient: data={"method": "POST", "endpoint": endpoint}, ) from exc + def _cancellable_event_stream( + self, + url: str, + payload: Mapping[str, Any], + headers: Mapping[str, str], + timeout: float | httpx.Timeout, + cancelled: Callable[[], bool], + ) -> Iterator[dict[str, Any]]: + """Own network reads and cancellation on the same worker-local event loop. + + Closing a synchronous httpx response from another thread can wait on its + active read lock. Cancelling an async read interrupts idle headers/body + reads and closes their sockets on their owning loop instead. + """ + + async def events(): + async with httpx.AsyncClient( + transport=self._stream_transport, verify=self._verify + ) as client, client.stream( + "POST", url, json=dict(payload), headers=headers, timeout=timeout + ) as response: + if not 200 <= response.status_code < 300: + raise ApiHttpError( + f"API returned HTTP {response.status_code}", + status_code=response.status_code, + request_id=self._request_id(response), + ) + content_type = response.headers.get("content-type", "").lower() + if "text/event-stream" not in content_type: + await response.aread() + if "json" in content_type: + self._unwrap(response) + raise ApiProtocolError("API response is not an event stream") + frame: list[str] = [] + async for line in response.aiter_lines(): + frame.append(line) + if not line: + for event in _iter_event_stream(frame): + yield event + frame.clear() + for event in _iter_event_stream(frame): + yield event + + if cancelled(): + return + loop = asyncio.new_event_loop() + iterator = events() + pending: deque[dict[str, Any]] = deque() + polling = False + wake_handle: asyncio.Handle | None = None + + def wake_consumer() -> None: + nonlocal wake_handle + if polling and wake_handle is None: + wake_handle = loop.call_soon(loop.stop) + + async def pump() -> None: + try: + async for event in iterator: + if cancelled(): + return + pending.append(event) + wake_consumer() + # Bound buffered work and let the scheduled batch wake run, + # even when one HTTP chunk contains thousands of SSE frames. + if len(pending) >= 256: + await asyncio.sleep(0) + finally: + wake_consumer() + + producer = loop.create_task(pump()) + + async def close_producer() -> None: + producer.cancel() + with suppress(asyncio.CancelledError, Exception): + await producer + await iterator.aclose() + + try: + while not cancelled(): + # Read buffered frames directly. There is one persistent async + # producer, not a new task/event-loop crossing for every delta. + if pending: + yield pending.popleft() + continue + if producer.done(): + producer.result() # Propagate EOF errors after queued tails. + return + polling = True + wake_handle = None + timer = loop.call_later(0.03, loop.stop) + try: + loop.run_forever() + finally: + polling = False + timer.cancel() + if wake_handle is not None: + wake_handle.cancel() + finally: + loop.run_until_complete(close_producer()) + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.close() + def get_bytes(self, url: str, *, max_bytes: int = 5 * 1024 * 1024) -> bytes: """Download a public binary asset without applying the JSON envelope contract.""" diff --git a/app/src/doctor_workstation/services/repository.py b/app/src/doctor_workstation/services/repository.py index ebbd84ef0..89821b728 100644 --- a/app/src/doctor_workstation/services/repository.py +++ b/app/src/doctor_workstation/services/repository.py @@ -206,9 +206,13 @@ class DoctorRepository(Protocol): prompt: str, *, task: str = "custom", + request_id: str | None = None, cancelled: Callable[[], bool] | None = None, ) -> Iterator[dict[str, Any]]: - """Yield normalized ``start``/``delta``/``done`` assistant events.""" + """Yield normalized assistant content and preparation progress events.""" + + def cancel_diagnosis_ai(self, diagnosis_id: int, request_id: str) -> dict[str, Any]: + """Request server/upstream cancellation of one correlated stream.""" def generate_ai_prescription(self, diagnosis_id: int) -> dict[str, Any]: """Generate a server-validated prescription draft for doctor review.""" @@ -1413,12 +1417,16 @@ class RemoteDoctorRepository: prompt: str, *, task: str = "custom", + request_id: str | None = None, cancelled: Callable[[], bool] | None = None, ) -> Iterator[dict[str, Any]]: """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} + body = { + "id": diagnosis_id, "prompt": clean_prompt, "task": clean_task, + "request_id": request_id or str(uuid4()), + } received_event = False received_done = False try: @@ -1467,6 +1475,18 @@ class RemoteDoctorRepository: yield {"event": "delta", "text": answer, "fallback": True} yield {**result, "event": "done", "fallback": True} + def cancel_diagnosis_ai(self, diagnosis_id: int, request_id: str) -> dict[str, Any]: + """Use an independent short request, even while the stream is idle.""" + + if diagnosis_id <= 0 or not request_id.strip(): + raise ValueError("diagnosis_id and request_id are required") + method = "post_independent" if callable(getattr(self.client, "post_independent", None)) else "post" + payload = _client_request( + self.client, method, "tcm.diagnosis/cancelAiAssistant", + {"id": diagnosis_id, "request_id": request_id}, timeout=10.0, + ) + return dict(_require_mapping(payload, "tcm.diagnosis/cancelAiAssistant")) + def generate_ai_prescription(self, diagnosis_id: int) -> dict[str, Any]: """Generate a structured draft using the server's full patient context.""" @@ -2990,10 +3010,10 @@ def _normalise_diagnosis_ai_event(raw: Mapping[str, Any]) -> dict[str, Any] | No payload = dict(data) if isinstance(data, Mapping) else {} if kind == "message": kind = str(payload.get("event") or payload.get("type") or "message").lower() - if kind == "start": + if kind in {"start", "progress"}: payload.pop("event", None) payload.pop("type", None) - return {**payload, "event": "start"} + return {**payload, "event": kind} if kind == "delta": text = ( data diff --git a/app/src/doctor_workstation/ui/dialogs/ai_consult.py b/app/src/doctor_workstation/ui/dialogs/ai_consult.py index 03bbf3758..8f3ccec7d 100644 --- a/app/src/doctor_workstation/ui/dialogs/ai_consult.py +++ b/app/src/doctor_workstation/ui/dialogs/ai_consult.py @@ -9,9 +9,11 @@ from collections.abc import Callable, Container, Mapping, Sequence from contextlib import suppress from copy import deepcopy from datetime import datetime -from threading import Event, Lock -from time import monotonic +from inspect import Parameter, signature +from threading import Event, Lock, Thread +from time import monotonic, perf_counter from typing import Any +from uuid import uuid4 from PySide6.QtCore import ( QCoreApplication, @@ -3051,6 +3053,28 @@ class _ChatBubble(QWidget): def set_provenance(self, value: Any) -> None: self._provenance = dict(value) if isinstance(value, Mapping) else {} + def set_transfer_status(self, text: str) -> None: + """Keep actual input/submission status visible while answer text streams.""" + + label = getattr(self, "transfer_status", None) + if label is None: + label = QLabel(self._bubble_frame) + label.setObjectName("AiConsultTransferStatus") + label.setTextFormat(Qt.TextFormat.PlainText) + label.setWordWrap(True) + label.setStyleSheet("color:#65718A;font-size:12px;") + self._inner.insertWidget(1, label) + self.transfer_status = label + label.setText(text) + label.setVisible(bool(text)) + + def set_cancel_status(self, text: str, *, has_text: bool) -> None: + """Update cancellation without reparsing the received clinical text.""" + + self.set_transfer_status(text) + if self.body is not None and not has_text: + self.body.hide() + def set_time_text(self, text: str) -> None: if not text: return @@ -3209,6 +3233,7 @@ class _AiStreamSignals(QObject): ready = Signal() error = Signal(object) finished = Signal() + cancel_result = Signal(object) class _AiStreamWorker(QRunnable): @@ -3227,18 +3252,54 @@ class _AiStreamWorker(QRunnable): self.diagnosis_id = diagnosis_id self.prompt = prompt self.task = task + self.request_id = str(uuid4()) self.signals = _AiStreamSignals() self._cancelled = Event() self._pending_lock = Lock() self._events: list[Any] = [] self._chunks: list[str] = [] self._first_text_queued = False + self._completed = Event() + self.cancel_text = "" + self.cancel_meta: dict[str, Any] = {} + self.cancel_reason = "" + # High-resolution monotonic diagnostics; GetTickCount64 used by + # monotonic() on Windows/Python 3.12 can hide sub-16ms queue latency. + # No patient/content logging or cross-thread widget access. + self.queued_at: float | None = None + self.run_started_at: float | None = None + self.first_delta_received_at: float | None = None + self.first_delta_processed_at: float | None = None + + def cancel(self) -> list[Any]: + """Signal cancellation before GUI work, preserving the received tail.""" - def cancel(self) -> None: - self._cancelled.set() with self._pending_lock: - self._events.clear() - self._chunks.clear() + request_cancel = not self.is_cancelled() and not self._completed.is_set() + if request_cancel: + self._cancelled.set() + if self._chunks: + self._events.append({"event": "delta", "text": "".join(self._chunks)}) + self._chunks.clear() + events, self._events = self._events, [] + if request_cancel: + # Never submit control HTTP on the GUI or the occupied stream worker. + Thread(target=self._request_cancel, name="ai-cancel", daemon=True).start() + return events + + def mark_completed(self) -> None: + self._completed.set() + + def _request_cancel(self) -> None: + try: + cancel = getattr(self.repository, "cancel_diagnosis_ai", None) + result = cancel(self.diagnosis_id, self.request_id) if callable(cancel) else {} + result = dict(result) if isinstance(result, Mapping) else {} + except Exception: + # Do not expose exception text, request bodies or patient metadata. + result = {"cancel_request_failed": True} + with suppress(RuntimeError): + self.signals.cancel_result.emit(result) def is_cancelled(self) -> bool: return self._cancelled.is_set() @@ -3248,18 +3309,22 @@ class _AiStreamWorker(QRunnable): delta = payload.get("event") == "delta" and isinstance(payload.get("text"), str) wake = not delta with self._pending_lock: - if self.is_cancelled(): + if self.is_cancelled() or self._completed.is_set(): return if delta: self._chunks.append(payload["text"]) if payload["text"] and not self._first_text_queued: self._first_text_queued = True + self.first_delta_received_at = perf_counter() wake = True else: if self._chunks: self._events.append({"event": "delta", "text": "".join(self._chunks)}) self._chunks.clear() self._events.append(event) + if payload.get("event") == "done": + # Completion and its mailbox event are atomic with cancel. + self._completed.set() # Only control events and the first text wake the GUI. A GUI timer drains # all later text even if the upstream pauses after its last chunk. if wake: @@ -3275,17 +3340,17 @@ class _AiStreamWorker(QRunnable): @Slot() def run(self) -> None: + self.run_started_at = perf_counter() try: if self.is_cancelled(): return stream = getattr(self.repository, "stream_diagnosis_ai", None) if callable(stream): - events = stream( - self.diagnosis_id, - self.prompt, - task=self.task, - cancelled=self.is_cancelled, - ) + options = {"task": self.task, "cancelled": self.is_cancelled} + parameters = signature(stream).parameters.values() + if any(p.name == "request_id" or p.kind is Parameter.VAR_KEYWORD for p in parameters): + options["request_id"] = self.request_id + events = stream(self.diagnosis_id, self.prompt, **options) else: result = invoke( self.repository, @@ -3306,9 +3371,10 @@ class _AiStreamWorker(QRunnable): for event in events: if self.is_cancelled(): return - self._queue_event(event) if isinstance(event, Mapping) and event.get("event") == "done": + self._queue_event(event) return + self._queue_event(event) except Exception as error: # UI boundary: render transport/domain errors in place. if not self.is_cancelled(): self.signals.error.emit(error) @@ -3346,8 +3412,8 @@ AI_PROMPT_LIMIT = 500 #: 等待服务端整理诊单资料时显示的占位文案。 AI_STREAM_PENDING_TEXT = "正在整理诊单资料…" -#: 服务端确认资料已准备好、尚未收到回答时显示的占位文案。 -AI_STREAM_WAITING_TEXT = "资料已整理,正在等待 AI 回复…" +#: 旧版服务只有 start;连接建立不代表资料已经整理或发送完成。 +AI_STREAM_WAITING_TEXT = "已连接 AI 服务,正在等待资料处理或回复…" #: 连接结束但一个字都没收到时的兜底文案。 AI_STREAM_SILENT_TEXT = ( @@ -3357,6 +3423,75 @@ AI_STREAM_TOTAL_TIMEOUT_MS = 180_000 AI_STREAM_TIMEOUT_TEXT = "等待 AI 回复超时,已停止本次等待,请稍后重试。" AI_CONTEXT_SEPARATOR = "\n\n— 医生提问 —\n" +_TRANSFER_SOURCE_LABELS = ( + ("diagnosis_count", "诊单"), ("doctor_note_count", "医生备注"), + ("tracking_note_count", "随访记录"), ("blood_record_count", "血糖血压记录"), + ("diet_record_count", "饮食记录"), ("exercise_record_count", "运动记录"), + ("prescription_count", "历史处方"), ("im_message_count", "IM 消息"), + ("wechat_message_count", "企微消息"), ("call_record_count", "视频问诊"), + ("transcript_segment_count", "转写片段"), +) + + +def _transfer_description(meta: Mapping[str, Any], state: str) -> str: + lines = [state] + summary = meta.get("source_summary") + if isinstance(summary, Mapping): + counts = [f"{label} {summary[key]} 条" for key, label in _TRANSFER_SOURCE_LABELS + if type(summary.get(key)) is int and summary[key] > 0] + if counts: + lines.append("本请求资料范围:" + "、".join(counts)) + if summary.get("may_be_truncated") is True: + lines.append("资料可能按模型限制裁剪;以上为来源范围。") + if meta.get("source_form") == "evidence_summary": + lines.append("本次正文:来源归并证据摘要。") + if meta.get("evidence_cache_hit") is True: + lines.append("复用已整理的证据摘要。") + elif meta.get("source_form") == "full_context": + lines.append("本次正文:来源上下文。") + items = meta.get("items") + if isinstance(items, list): + kinds = {"image": "图片", "document": "文档", "audio": "音频", "video": "视频", "custom": "附件"} + statuses = {"submitting": "提交中", "submitted": "引用已提交", "omitted": "未提交", "failed": "提交失败"} + for index, item in enumerate(items, 1): + if not isinstance(item, Mapping): + continue + kind = kinds.get(str(item.get("kind")), "附件") + # Never display a legacy filename, URL or patient-identifying label. + label = str(item.get("label") or "") + if not re.fullmatch(r"(?:图片附件|文档附件|文件附件|音频附件|视频附件|自定义附件|附件|检查报告|舌象)\s*\d+", label): + label = f"{kind}附件{index}" + lines.append(f"{label}({kind}):{statuses.get(str(item.get('status')), '等待提交')}") + count = meta.get("attachment_count") + if type(count) is int and count >= 0: + lines.append(f"本请求附件通道:{count} 项;模型实际读取进度不可获取。") + return "\n".join(lines) + + +def _cancel_description(meta: Mapping[str, Any]) -> str: + terminal = meta.get("upstream_terminal_status") + if ( + meta.get("upstream_terminal_confirmed") is True + and isinstance(terminal, str) + and terminal in {"stopped", "succeeded", "failed"} + ): + notice = { + "stopped": "上游工作流已停止。", + "succeeded": "上游工作流已结束(已完成)。", + "failed": "上游工作流已结束(失败)。", + }[terminal] + elif meta.get("upstream_stop_confirmed") is True: + notice = "上游已接受停止请求;工作流是否结束尚未确认。" + elif meta.get("cancel_request_failed"): + notice = "已停止本次等待,取消请求未确认;上游是否停止尚未确认。" + elif meta.get("cancel_requested") is True: + notice = "已发送取消请求;上游是否停止尚未确认。" + else: + notice = "本次请求已取消;上游是否停止尚未确认。" + if meta.get("transport_closed") is True: + notice = f"本次传输已结束。{notice}" + return notice + def _truncate_for_context(text: str, *, max_chars: int) -> str: """Trim a long textual field while preserving a trailing ellipsis.""" @@ -6268,8 +6403,15 @@ class AiConsultDialog(QDialog): worker.signals.finished.connect( lambda: self._stream_finished(generation, stream_generation, worker) ) + cancel_signal = getattr(worker.signals, "cancel_result", None) + if cancel_signal is not None: + bubble = self._stream_bubble + cancel_signal.connect( + lambda result: self._stream_cancel_result(generation, diagnosis_id, bubble, worker, result) + ) self._stream_timeout_timer.start(AI_STREAM_TOTAL_TIMEOUT_MS) self._delivery_timer.start() + worker.queued_at = perf_counter() QThreadPool.globalInstance().start(worker) self._request_chat_scroll() @@ -6337,15 +6479,45 @@ class AiConsultDialog(QDialog): if kind == "start": self._stream_meta.update(payload) self._update_stream_elapsed() + self._update_transfer_status(AI_STREAM_PENDING_TEXT) 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 == "progress": + self._stream_meta.update(payload) + phase = str(payload.get("phase") or "") + state = { + "preparing": AI_STREAM_PENDING_TEXT, + "attachments": "正在提交资料与附件到模型…", + "upstream": "资料已提交,正在等待 AI 正文…", + "cancelled": "本次请求已取消;上游是否停止尚未确认。", + }.get(phase, "正在处理本请求资料…") + if phase == "attachments" and payload.get("status") == "submitted": + state = "附件引用已提交,等待模型处理…" + if phase == "preparing" and payload.get("status") == "ready": + state = "资料正文已整理,正在准备提交…" + if payload.get("status") == "failed": + state = "资料提交失败。" + if phase == "cancelled": + state = _cancel_description(self._stream_meta) + self._update_transfer_status(state) + if phase == "cancelled" and self._stream_bubble is not None: + self._stream_bubble.set_cancel_status( + _transfer_description(self._stream_meta, state), + has_text=bool(self._stream_text or self._pending_chunks), + ) + elif self._stream_bubble is not None and not self._stream_text and not self._pending_chunks: + self._stream_bubble.set_payload(state) + return if kind == "delta": chunk = payload.get("text") if isinstance(chunk, str) and chunk: if self._stream_first_delta_at is None: self._stream_first_delta_at = monotonic() + if self._stream_worker is not None: + self._stream_worker.first_delta_processed_at = perf_counter() self._update_stream_elapsed() + self._update_transfer_status("已收到 AI 正文,正在生成…") self._pending_chunks.append(chunk) if not self._stream_text: # The first received text replaces the waiting state now. @@ -6357,6 +6529,11 @@ class AiConsultDialog(QDialog): return self._stream_meta.update(payload) self._stream_completed = True + if self._stream_worker is not None: + mark_completed = getattr(self._stream_worker, "mark_completed", None) + if callable(mark_completed): + mark_completed() + self._update_transfer_status("本次回复已完成。") self._delivery_timer.stop() self._stream_timeout_timer.stop() self._stop_stream_clock() @@ -6377,6 +6554,23 @@ class AiConsultDialog(QDialog): self._asking = False self.send_button.setEnabled(True) + def _update_transfer_status(self, state: str) -> None: + if self._stream_bubble is not None: + self._stream_bubble.set_transfer_status(_transfer_description(self._stream_meta, state)) + + def _stream_cancel_result(self, generation: int, diagnosis_id: int, bubble: Any, worker: _AiStreamWorker, result: Any) -> None: + if self._session_closed or generation != self._generation or diagnosis_id != self.diagnosis_id: + return + if bubble is None or not isValid(bubble): + return + payload = result if isinstance(result, Mapping) else {} + notice = _cancel_description(payload) + if worker.cancel_reason: + notice = f"{worker.cancel_reason}\n{notice}" + bubble.set_cancel_status( + _transfer_description(worker.cancel_meta, notice), has_text=bool(worker.cancel_text), + ) + def _flush_stream_chunks(self) -> None: self._flush_timer.stop() if not self._pending_chunks or self._stream_bubble is None: @@ -6401,11 +6595,28 @@ class AiConsultDialog(QDialog): self._stop_stream_clock() self._flush_stream_chunks() message = friendly_error(error) - if self._stream_text: + error_data = getattr(error, "data", None) + cancelled = self._stream_meta.get("phase") == "cancelled" or ( + isinstance(error_data, Mapping) and error_data.get("code") == "AI_ASSISTANT_CANCELLED" + ) + if cancelled: + if isinstance(error_data, Mapping): + self._stream_meta.update(error_data) + self._stream_meta["phase"] = "cancelled" + message = _cancel_description(self._stream_meta) + if self._stream_worker is not None: + self._stream_worker.mark_completed() + self._update_transfer_status(message if cancelled else "本次请求失败,资料或回复未完成。") + if cancelled: + if self._stream_bubble is not None: + self._stream_bubble.set_cancel_status( + _transfer_description(self._stream_meta, message), has_text=bool(self._stream_text), + ) + elif self._stream_text: self._stream_text = f"{self._stream_text}\n\n> 生成中断:{message}" else: self._stream_text = message - if self._stream_bubble is not None: + if not cancelled and self._stream_bubble is not None: self._stream_bubble.set_payload(self._stream_text) self._asking = False self.send_button.setEnabled(True) @@ -6422,7 +6633,8 @@ class AiConsultDialog(QDialog): self._delivery_timer.stop() self._stream_timeout_timer.stop() self._flush_stream_chunks() - if self._stream_bubble is not None: + cancelled = self._stream_meta.get("phase") == "cancelled" + if self._stream_bubble is not None and not cancelled: self._stream_bubble.finish_stream_text() self._stop_stream_clock() if self._stream_worker is worker: @@ -6431,6 +6643,7 @@ class AiConsultDialog(QDialog): # 将阶段占位文案替换成明确说明。 if ( not self._stream_completed + and not cancelled and not self._stream_text and self._stream_bubble is not None ): @@ -6442,37 +6655,42 @@ class AiConsultDialog(QDialog): def _stream_timed_out(self) -> None: if not self._asking or self._stream_completed: return - self._poll_stream_events() - if not self._asking or self._stream_completed: - return - self._flush_stream_chunks() - bubble = self._stream_bubble - text = self._stream_text - self._stop_stream_clock() - 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 - ) + self._cancel_stream(reason=AI_STREAM_TIMEOUT_TEXT) - def _cancel_stream(self, *, replaced: bool = False) -> None: - if replaced and self._asking: - self._poll_stream_events() - if replaced and self._asking: + def _cancel_stream(self, *, replaced: bool = False, reason: str = "") -> None: + worker = self._stream_worker + if worker is not None: + worker.cancel_reason = reason + # Atomically preserve queued tails/done and signal cancellation before + # any GUI formatting. Already completed workers never send control HTTP. + events = worker.cancel() or [] + if (replaced or reason) and self._asking: + for event in events: + self._stream_event(self._generation, self._stream_generation, event) + if reason and self._stream_completed: + # A queued done wins a simultaneous timeout; retain normal completion. + return + if (replaced or reason) and self._asking: self._flush_stream_chunks() self._stop_stream_clock() if self._stream_bubble is not None: - notice = "已停止本次生成,已切换到新的请求。" - self._stream_bubble.set_payload( - f"{self._stream_text}\n\n> {notice}" if self._stream_text else notice + notice = ( + "已停止本次等待,已切换到新的请求;正在请求取消,上游是否停止尚未确认。" + if replaced else "已停止本次等待;正在请求取消,上游是否停止尚未确认。" + ) + if reason: + notice = f"{reason}\n{notice}" + self._stream_bubble.set_cancel_status( + _transfer_description(self._stream_meta, notice), has_text=bool(self._stream_text), ) self._stream_generation += 1 self._flush_timer.stop() self._stream_timeout_timer.stop() self._stream_elapsed_timer.stop() self._delivery_timer.stop() - if self._stream_worker is not None: - self._stream_worker.cancel() + if worker is not None: + worker.cancel_text = self._stream_text + worker.cancel_meta = dict(self._stream_meta) self._stream_worker = None self._stream_bubble = None self._pending_chunks.clear() diff --git a/app/tests/test_ai_cancel_status_ui.py b/app/tests/test_ai_cancel_status_ui.py new file mode 100644 index 000000000..68ad18467 --- /dev/null +++ b/app/tests/test_ai_cancel_status_ui.py @@ -0,0 +1,322 @@ +"""Only observed input states and confirmed upstream cancellation are claimed.""" + +from __future__ import annotations + +import os +from threading import Event +from time import monotonic +from types import SimpleNamespace +from uuid import UUID + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import pytest +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QApplication + +from doctor_workstation.core.errors import ApiBusinessError +from doctor_workstation.ui.dialogs import ai_consult as module + + +@pytest.fixture(scope="module") +def application(): + return QApplication.instance() or QApplication([]) + + +def dialog_for(monkeypatch, repository): + monkeypatch.setattr(module, "QThreadPool", SimpleNamespace(globalInstance=lambda: SimpleNamespace(start=lambda worker: None))) + dialog = module.AiConsultDialog(repository) + monkeypatch.setattr(dialog, "_load_workspace", lambda _: None) + dialog.open_for(diagnosis_id=501, patient_id=1501) + return dialog + + +def test_progress_lists_actual_materials_without_claiming_model_reading(application, monkeypatch): + dialog = dialog_for(monkeypatch, object()) + dialog._ask("合成问题") + generation, stream_generation = dialog._generation, dialog._stream_generation + bubble = dialog._stream_bubble + dialog._stream_event(generation, stream_generation, {"event": "start"}) + assert "资料已整理" not in bubble._raw_payload + assert "整理" in bubble.transfer_status.text() + dialog._stream_event(generation, stream_generation, { + "event": "progress", "phase": "preparing", + "source_summary": {"diagnosis_count": 2, "doctor_note_count": 3, "patient_name": "敏感姓名"}, + }) + assert "诊单 2 条" in bubble.transfer_status.text() and "医生备注 3 条" in bubble.transfer_status.text() + dialog._stream_event(generation, stream_generation, { + "event": "progress", "phase": "attachments", "status": "submitting", "attachment_count": 2, + "source_form": "evidence_summary", "evidence_cache_hit": True, + "items": [{"label": "图片附件1", "kind": "image", "status": "submitting"}, + {"label": "敏感姓名的检查报告.pdf", "kind": "document", "status": "failed"}], + }) + text = bubble.transfer_status.text() + assert "图片附件1(图片):提交中" in text and "文档附件2(文档):提交失败" in text + assert "来源归并证据摘要" in text and "复用已整理" in text + assert "敏感姓名" not in text and "pdf" not in text and "%" not in text + dialog._stream_event(generation, stream_generation, { + "event": "progress", "phase": "attachments", "status": "submitted", + "items": [{"label": "图片附件1", "kind": "image", "status": "submitted"}], + }) + assert "引用已提交" in bubble.transfer_status.text() and "模型实际读取进度不可获取" in bubble.transfer_status.text() + dialog._stream_event(generation, stream_generation, {"event": "progress", "phase": "upstream"}) + assert "等待 AI 正文" in bubble._raw_payload + dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "合成正文"}) + assert bubble._raw_payload == "合成正文" and "图片附件1" in bubble.transfer_status.text() + dialog._stream_event(generation, stream_generation, {"event": "done"}) + assert "本次回复已完成" in bubble.transfer_status.text() + dialog.close() + + +@pytest.mark.parametrize("confirmed", [False, True]) +def test_replacement_cancel_is_background_correlated_and_requires_upstream_confirmation(application, monkeypatch, confirmed): + entered, release, returned = Event(), Event(), Event() + calls = [] + + class Repository: + def cancel_diagnosis_ai(self, diagnosis_id, request_id): + calls.append((diagnosis_id, request_id)) + entered.set() + assert release.wait(2) + returned.set() + return {"cancel_requested": True, "transport_closed": True, "upstream_stop_confirmed": confirmed} + + dialog = dialog_for(monkeypatch, Repository()) + try: + dialog._ask("旧问题") + old_worker, old_bubble = dialog._stream_worker, dialog._stream_bubble + request_id = old_worker.request_id + assert str(UUID(request_id)) == request_id + dialog._ask("新问题", replace_active=True) + new_bubble = dialog._stream_bubble + assert entered.wait(1) + assert "上游是否停止尚未确认" in old_bubble.transfer_status.text() + assert old_bubble.body.isHidden() + assert calls == [(501, request_id)] + assert dialog._stream_worker.request_id != request_id + release.set() + assert returned.wait(1) + deadline = monotonic() + 1 + while "正在请求取消" in old_bubble.transfer_status.text() and monotonic() < deadline: + QTest.qWait(10) + assert ("上游已接受停止" in old_bubble.transfer_status.text()) is confirmed + assert "尚未确认" in old_bubble.transfer_status.text() + assert "上游工作流已停止" not in old_bubble.transfer_status.text() + assert new_bubble._raw_payload == module.AI_STREAM_PENDING_TEXT + finally: + release.set() + dialog.close() + + +@pytest.mark.parametrize("exit_path", ["timeout", "patient", "close"]) +def test_abort_paths_send_cancel_once_and_done_never_sends_cancel(application, monkeypatch, exit_path): + called = Event() + calls = [] + + class Repository: + def cancel_diagnosis_ai(self, diagnosis_id, request_id): + calls.append((diagnosis_id, request_id)) + called.set() + return {"cancel_requested": True, "upstream_stop_confirmed": False} + + dialog = dialog_for(monkeypatch, Repository()) + dialog._ask("已完成问题") + dialog._stream_event(dialog._generation, dialog._stream_generation, {"event": "done", "answer": "合成结果"}) + dialog._ask("需要取消的问题") + assert calls == [] + worker = dialog._stream_worker + if exit_path == "timeout": + dialog._stream_timed_out() + elif exit_path == "patient": + dialog.open_for(diagnosis_id=502, patient_id=1502) + else: + dialog.close() + assert called.wait(1) + worker.cancel() + assert calls == [(501, worker.request_id)] + dialog.close() + + +def test_cancel_confirmation_cannot_change_reused_patient_session(application, monkeypatch): + dialog = dialog_for(monkeypatch, object()) + dialog._ask("旧问题") + old_worker, old_bubble, generation = dialog._stream_worker, dialog._stream_bubble, dialog._generation + dialog.open_for(diagnosis_id=502, patient_id=1502) + dialog._ask("新患者问题") + before = dialog._stream_bubble._raw_payload + dialog._stream_cancel_result(generation, 501, old_bubble, old_worker, {"upstream_stop_confirmed": True}) + assert dialog._stream_bubble._raw_payload == before + dialog.close() + + +def test_server_cancel_terminal_preserves_confirmed_state_without_retry(application, monkeypatch): + dialog = dialog_for(monkeypatch, object()) + dialog._ask("服务端取消测试") + generation, stream_generation = dialog._generation, dialog._stream_generation + worker, bubble = dialog._stream_worker, dialog._stream_bubble + dialog._stream_event(generation, stream_generation, { + "event": "progress", "phase": "cancelled", "status": "cancelled", + "upstream_stop_confirmed": True, "transport_closed": True, + }) + dialog._stream_failed(generation, stream_generation, ApiBusinessError( + "本次请求已取消", data={"code": "AI_ASSISTANT_CANCELLED"}, + )) + assert "上游已接受停止" in bubble.transfer_status.text() and "本次传输已结束" in bubble.transfer_status.text() + assert not dialog._asking and worker._completed.is_set() + assert "尚未确认" in bubble.transfer_status.text() + assert bubble.body.isHidden() + dialog.close() + + +@pytest.mark.parametrize("ack", [ + {"upstream_stop_confirmed": True}, + {"upstream_terminal_confirmed": True, "upstream_terminal_status": "stopped"}, +]) +def test_long_replacement_preserves_tail_and_cancels_before_gui_work(application, monkeypatch, ack): + dialog = dialog_for(monkeypatch, object()) + dialog._ask("旧长报告") + worker, bubble = dialog._stream_worker, dialog._stream_bubble + worker.signals.blockSignals(True) + body = "## 合成长报告\n\n" + "逐项核对原始记录。\n\n" * 250 + dialog._stream_event(dialog._generation, dialog._stream_generation, {"event": "delta", "text": body}) + dialog._stream_event(dialog._generation, dialog._stream_generation, {"event": "delta", "text": "GUI未刷尾段"}) + worker._queue_event({"event": "delta", "text": "mailbox未刷尾段"}) + original_append, original_status = bubble.append_stream_text, bubble.set_cancel_status + observed = [] + + def append(*args): + assert worker.is_cancelled(), "cancel flag must precede pending GUI rendering" + observed.append("append") + original_append(*args) + + def status(*args, **kwargs): + assert worker.is_cancelled(), "cancel flag must precede status updates" + observed.append("status") + original_status(*args, **kwargs) + + monkeypatch.setattr(bubble, "append_stream_text", append) + monkeypatch.setattr(bubble, "set_cancel_status", status) + monkeypatch.setattr(bubble, "set_payload", lambda *_: pytest.fail("cancel reparsed complete bubble")) + monkeypatch.setattr(bubble.body, "set_payload", lambda *_: pytest.fail("cancel reparsed clinical text")) + dialog._ask("新问题", replace_active=True) + expected = body + "GUI未刷尾段mailbox未刷尾段" + assert bubble._raw_payload == expected and worker.cancel_text == expected + assert "GUI未刷尾段mailbox未刷尾段" in bubble.body.toPlainText() + assert observed and "status" in observed + before_stamp = bubble.stamp.text() + dialog._stream_cancel_result(dialog._generation, 501, bubble, worker, ack) + assert bubble._raw_payload == expected and bubble.stamp.text() == before_stamp + assert not bubble.body.isHidden() + assert dialog._stream_bubble._raw_payload == module.AI_STREAM_PENDING_TEXT + dialog.close() + + +@pytest.mark.parametrize("exit_path", ["replacement", "timeout"]) +def test_queued_done_is_drained_without_cancel_and_preserves_all_tail(application, monkeypatch, exit_path): + dialog = dialog_for(monkeypatch, object()) + dialog._ask("已到达完成事件的旧问题") + worker, bubble = dialog._stream_worker, dialog._stream_bubble + worker.signals.blockSignals(True) + worker._queue_event({"event": "delta", "text": "完整前段"}) + worker._queue_event({"event": "delta", "text": "与完整尾段"}) + worker._queue_event({"event": "done"}) + monkeypatch.setattr(worker, "_request_cancel", lambda: pytest.fail("normal done sent cancel")) + if exit_path == "replacement": + dialog._ask("新问题", replace_active=True) + else: + dialog._stream_timed_out() + assert dialog._stream_completed and dialog._stream_bubble is bubble + assert dialog._stream_text == "完整前段与完整尾段" + assert not worker.is_cancelled() + assert bubble._raw_payload == "完整前段与完整尾段" + assert "本次回复已完成" in bubble.transfer_status.text() + assert "正在请求取消" not in bubble.transfer_status.text() + dialog.close() + + +def test_server_cancel_preserves_long_body_through_finished_without_reparse(application, monkeypatch): + dialog = dialog_for(monkeypatch, object()) + dialog._ask("服务端取消长报告") + generation, stream_generation = dialog._generation, dialog._stream_generation + worker, bubble = dialog._stream_worker, dialog._stream_bubble + body = "## 合成长报告\n\n" + "保留既有临床正文。\n\n" * 250 + dialog._stream_event(generation, stream_generation, {"event": "delta", "text": body}) + dialog._stream_event(generation, stream_generation, {"event": "delta", "text": "未刷尾段"}) + monkeypatch.setattr(bubble, "set_payload", lambda *_: pytest.fail("server cancel reparsed complete bubble")) + monkeypatch.setattr(bubble.body, "set_payload", lambda *_: pytest.fail("server cancel reparsed clinical text")) + dialog._stream_event(generation, stream_generation, { + "event": "progress", "phase": "cancelled", "upstream_terminal_confirmed": True, + "upstream_terminal_status": "stopped", + }) + dialog._stream_failed(generation, stream_generation, ApiBusinessError( + "本次请求已取消", data={"code": "AI_ASSISTANT_CANCELLED"}, + )) + dialog._stream_finished(generation, stream_generation, worker) + assert bubble._raw_payload == body + "未刷尾段" + assert "未刷尾段" in bubble.body.toPlainText() + assert "上游工作流已停止" in bubble.transfer_status.text() + assert not bubble.body.isHidden() and not dialog._asking + dialog.close() + + +@pytest.mark.parametrize("confirmed,status,expected", [ + (True, "stopped", "上游工作流已停止"), + (True, "succeeded", "上游工作流已结束(已完成)"), + (True, "failed", "上游工作流已结束(失败)"), + (False, "stopped", "尚未确认"), + (True, "", "尚未确认"), + (True, "unrecognized", "尚未确认"), +]) +@pytest.mark.parametrize("path", ["ack", "server_terminal"]) +def test_only_confirmed_workflow_terminal_claims_ended(application, monkeypatch, confirmed, status, expected, path): + dialog = dialog_for(monkeypatch, object()) + dialog._ask("终态校验") + generation, stream_generation = dialog._generation, dialog._stream_generation + worker, bubble = dialog._stream_worker, dialog._stream_bubble + state = {"upstream_stop_confirmed": True, "upstream_terminal_confirmed": confirmed, + "upstream_terminal_status": status, "transport_closed": True} + if path == "ack": + dialog._ask("新问题", replace_active=True) + dialog._stream_cancel_result(generation, 501, bubble, worker, state) + else: + dialog._stream_event(generation, stream_generation, {"event": "progress", "phase": "cancelled", **state}) + dialog._stream_failed(generation, stream_generation, ApiBusinessError( + "本次请求已取消", data={"code": "AI_ASSISTANT_CANCELLED"}, + )) + dialog._stream_finished(generation, stream_generation, worker) + assert expected in bubble.transfer_status.text() + if not confirmed or status not in {"stopped", "succeeded", "failed"}: + assert "上游工作流已停止" not in bubble.transfer_status.text() + assert "上游工作流已结束" not in bubble.transfer_status.text() + assert bubble.body.isHidden() + dialog.close() + + +def test_worker_monotonic_timings_separate_queue_and_gui_delivery(application, monkeypatch): + now = [100.0] + monkeypatch.setattr(module, "monotonic", lambda: now[0]) + monkeypatch.setattr(module, "perf_counter", lambda: now[0]) + + class Repository: + def stream_diagnosis_ai(self, *args, **kwargs): + now[0] = 103.0 + yield {"event": "delta", "text": "合成正文"} + yield {"event": "delta", "text": "尾段"} + yield {"event": "done"} + + dialog = dialog_for(monkeypatch, Repository()) + dialog._ask("时间戳校验") + worker = dialog._stream_worker + assert worker.queued_at == 100.0 and worker.run_started_at is None + worker.signals.blockSignals(True) + now[0] = 102.0 + worker.run() + assert worker.run_started_at == 102.0 and worker.first_delta_received_at == 103.0 + assert worker.first_delta_processed_at is None + now[0] = 105.0 + dialog._poll_stream_events() + assert worker.first_delta_processed_at == 105.0 + assert worker.run_started_at - worker.queued_at == 2.0 + assert worker.first_delta_processed_at - worker.first_delta_received_at == 2.0 + assert dialog._stream_bubble._raw_payload == "合成正文尾段" + dialog.close() diff --git a/app/tests/test_ai_cancel_transport.py b/app/tests/test_ai_cancel_transport.py new file mode 100644 index 000000000..19cdaa2ba --- /dev/null +++ b/app/tests/test_ai_cancel_transport.py @@ -0,0 +1,221 @@ +"""Cancellation interrupts idle I/O and uses an independent control request.""" + +from __future__ import annotations + +import asyncio +import json +import socket +from concurrent.futures import ThreadPoolExecutor +from threading import Event, Thread, get_ident +from uuid import uuid4 + +import httpx +import pytest + +from doctor_workstation.services.api_client import ApiClient +from doctor_workstation.services.repository import RemoteDoctorRepository + + +@pytest.mark.parametrize("stage", ["headers", "body"]) +def test_cancel_interrupts_idle_read_and_cleans_up_on_owning_loop(stage): + entered, cancelled = Event(), Event() + body_threads, closed_threads = [], [] + + class IdleStream(httpx.AsyncByteStream): + async def __aiter__(self): + body_threads.append(get_ident()) + entered.set() + await asyncio.sleep(30) + yield b"event: done\ndata: {}\n\n" + + async def aclose(self): + closed_threads.append(get_ident()) + + async def handler(request): + if stage == "headers": + entered.set() + await asyncio.sleep(30) + return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=IdleStream()) + + client = ApiClient("https://example.test", transport=httpx.MockTransport(handler)) + with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(lambda: list(client.post_event_stream( + "tcm.diagnosis/aiAssistantStream", {"id": 501}, cancelled=cancelled.is_set, + ))) + assert entered.wait(2) + cancelled.set() + assert future.result(timeout=0.8) == [] + if stage == "body": + assert body_threads == closed_threads + client.close() + + +@pytest.mark.parametrize("headers_sent", [False, True]) +def test_default_transport_closes_idle_local_socket_without_another_line(headers_sent): + entered, disconnected, cancelled = Event(), Event(), Event() + listener = socket.socket() + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + listener.settimeout(3) + + def serve(): + try: + connection, _ = listener.accept() + with connection: + connection.settimeout(3) + request = b"" + while b"\r\n\r\n" not in request: + request += connection.recv(4096) + header, body = request.split(b"\r\n\r\n", 1) + length = next(int(line.split(b":", 1)[1]) for line in header.split(b"\r\n") if line.lower().startswith(b"content-length:")) + while len(body) < length: + body += connection.recv(4096) + if headers_sent: + connection.sendall(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n") + entered.set() + if connection.recv(1) == b"": + disconnected.set() + finally: + listener.close() + + thread = Thread(target=serve, daemon=True) + thread.start() + with ApiClient(f"http://127.0.0.1:{listener.getsockname()[1]}") as client, ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(lambda: list(client.post_event_stream( + "tcm.diagnosis/aiAssistantStream", {"id": 501}, cancelled=cancelled.is_set, + ))) + try: + assert entered.wait(2) + cancelled.set() + assert future.result(timeout=0.8) == [] + assert disconnected.wait(0.8) + finally: + cancelled.set() + thread.join(timeout=1) + assert not thread.is_alive() + + +def test_cancel_control_post_bypasses_busy_short_request_pool(monkeypatch): + from doctor_workstation.services import api_client as module + + entered, release = Event(), Event() + created = [] + + class Client: + def __init__(self, **options): + created.append(self) + + def request(self, *args, **kwargs): + entered.set() + assert release.wait(2) + return httpx.Response(200, json={"code": 1, "data": {}}) + + def post(self, url, **kwargs): + return httpx.Response(200, json={"code": 1, "data": {"cancel_requested": True}}) + + def close(self): + pass + + def __enter__(self): + return self + + def __exit__(self, *args): + self.close() + + monkeypatch.setattr(module.httpx, "Client", Client) + client = ApiClient("https://example.test", max_parallel_requests=1) + with ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(client.get, "synthetic/busy") + try: + assert entered.wait(1) + assert client.post_independent("tcm.diagnosis/cancelAiAssistant", {"id": 501}) == {"cancel_requested": True} + assert len(created) == 2 + finally: + release.set() + future.result(timeout=1) + client.close() + + +def test_stream_progress_and_cancel_preserve_same_request_identity(): + requests = [] + request_id = str(uuid4()) + + def handler(request): + requests.append(request) + if request.url.path.endswith("cancelAiAssistant"): + return httpx.Response(200, json={"code": 1, "data": {"cancel_requested": True, "upstream_stop_confirmed": False}}) + return httpx.Response(200, headers={"content-type": "text/event-stream"}, text=( + 'event: progress\ndata: {"phase":"attachments","items":[{"kind":"image","label":"图片附件1","status":"submitted"}],"attachment_count":1}\n\n' + 'event: done\ndata: {}\n\n' + )) + + with ApiClient("https://example.test", transport=httpx.MockTransport(handler)) as client: + repository = RemoteDoctorRepository(client) + events = list(repository.stream_diagnosis_ai(501, "合成问题", request_id=request_id)) + result = repository.cancel_diagnosis_ai(501, request_id) + assert events[0]["event"] == "progress" and events[0]["items"][0]["status"] == "submitted" + assert json.loads(requests[0].content)["request_id"] == request_id + assert json.loads(requests[1].content) == {"id": 501, "request_id": request_id} + assert result["cancel_requested"] and not result["upstream_stop_confirmed"] + + +def test_async_bridge_batches_thousands_of_frames_without_per_delta_tasks(monkeypatch): + from doctor_workstation.services import api_client as module + + calls = {"tasks": 0, "loop_runs": 0} + new_event_loop = asyncio.new_event_loop + + def counted_loop(): + loop = new_event_loop() + create_task, run_forever = loop.create_task, loop.run_forever + + def task(*args, **kwargs): + calls["tasks"] += 1 + return create_task(*args, **kwargs) + + def run(): + calls["loop_runs"] += 1 + return run_forever() + + loop.create_task, loop.run_forever = task, run + return loop + + monkeypatch.setattr(module.asyncio, "new_event_loop", counted_loop) + chunks = [f"片段{index}🩺" for index in range(6000)] + content = "".join("event: delta\ndata: " + json.dumps({"text": text}) + "\n\n" for text in chunks) + content += 'event: done\ndata: {}\n\n' + transport = httpx.MockTransport(lambda request: httpx.Response( + 200, headers={"content-type": "text/event-stream"}, text=content, + )) + with ApiClient("https://example.test", transport=transport) as client: + events = list(client.post_event_stream("synthetic/stream", {}, cancelled=lambda: False)) + assert [event["data"]["text"] for event in events[:-1]] == chunks + assert events[-1]["event"] == "done" + assert calls["tasks"] <= 8 + assert calls["loop_runs"] <= 40 + + +@pytest.mark.parametrize("ending", ["eof", "error"]) +def test_async_batch_drains_queued_tail_before_eof_or_error(ending): + class TailStream(httpx.AsyncByteStream): + async def __aiter__(self): + yield b'event: delta\ndata: {"text":"first"}\n\nevent: delta\ndata: {"text":"tail"}\n\n' + if ending == "error": + raise httpx.ReadError("synthetic read failure") + # A valid final SSE frame need not end with a blank line. + yield b'event: delta\ndata: {"text":"last"}' + + transport = httpx.MockTransport(lambda request: httpx.Response( + 200, headers={"content-type": "text/event-stream"}, stream=TailStream(), + )) + with ApiClient("https://example.test", transport=transport) as client: + stream = client.post_event_stream("synthetic/stream", {}, cancelled=lambda: False) + assert next(stream)["data"]["text"] == "first" + assert next(stream)["data"]["text"] == "tail" + if ending == "error": + from doctor_workstation.core.errors import ApiTransportError + with pytest.raises(ApiTransportError, match="stream failed"): + next(stream) + else: + assert next(stream)["data"]["text"] == "last" + assert list(stream) == [] diff --git a/app/tests/test_ai_consult_ui.py b/app/tests/test_ai_consult_ui.py index be7134c82..ab9007c4d 100644 --- a/app/tests/test_ai_consult_ui.py +++ b/app/tests/test_ai_consult_ui.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import json import os from threading import Event @@ -1517,7 +1518,8 @@ def test_stalled_stream_exits_pending_state_and_ignores_late_events( 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 + assert ai_consult_module.AI_STREAM_TIMEOUT_TEXT in bubble.transfer_status.text() + assert bubble.body.isHidden() calls: list[int] = [] monkeypatch.setattr( @@ -1530,7 +1532,8 @@ def test_stalled_stream_exits_pending_state_and_ignores_late_events( 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 + assert ai_consult_module.AI_STREAM_TIMEOUT_TEXT in bubble.transfer_status.text() + assert bubble.body.isHidden() dialog._ask("请补充说明") second_bubble = dialog._stream_bubble @@ -1541,8 +1544,8 @@ def test_stalled_stream_exits_pending_state_and_ignores_late_events( {"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 + assert second_bubble._raw_payload == "已收到部分分析" + assert ai_consult_module.AI_STREAM_TIMEOUT_TEXT in second_bubble.transfer_status.text() dialog.close() @@ -1685,8 +1688,8 @@ def test_http_stream_paints_before_upstream_finishes_on_the_ui_thread( release = Event() waiting_for_end = Event() - class DelayedStream(httpx.SyncByteStream): - def __iter__(self): + class DelayedStream(httpx.AsyncByteStream): + async def __aiter__(self): first = ( 'event: start\r\ndata: {"model_label":"千问"}\r\n\r\n' 'event: delta\r\ndata: {"text":"先收到的内容"}\r\n\r\n' @@ -1696,8 +1699,11 @@ def test_http_stream_paints_before_upstream_finishes_on_the_ui_thread( yield first[:split] yield first[split:] waiting_for_end.set() - if not release.wait(5): - raise AssertionError("UI did not receive the first streamed text") + deadline = monotonic() + 5 + while not release.is_set(): + if monotonic() > deadline: + raise AssertionError("UI did not receive the first streamed text") + await asyncio.sleep(0.01) yield ( 'event: delta\r\ndata: {"text":",然后完成。"}\r\n\r\n' 'event: done\r\ndata: {"model_label":"千问"}\r\n\r\n' @@ -1806,7 +1812,7 @@ def test_clicking_another_quick_tool_switches_the_active_request( assert worker.prompt == ai_consult_module.TOOL_ITEMS[1][2] assert old_worker.is_cancelled() assert "已收到的分析" in old_bubble._raw_payload - assert "已停止" in old_bubble._raw_payload + assert "已停止" in old_bubble.transfer_status.text() assert bubble is not None and bubble is not old_bubble assert bubble._raw_payload == ai_consult_module.AI_STREAM_PENDING_TEXT assert "已等待 0 秒" in bubble.stamp.text() diff --git a/app/tests/test_local_launcher.py b/app/tests/test_local_launcher.py new file mode 100644 index 000000000..0b8725699 --- /dev/null +++ b/app/tests/test_local_launcher.py @@ -0,0 +1,48 @@ +"""The explicit local launcher must validate the effective endpoint after preferences.""" +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import SimpleNamespace + +import pytest + +spec = importlib.util.spec_from_file_location( + "local_launcher", Path(__file__).parents[1] / "scripts/run_local_workstation.py" +) +launcher = importlib.util.module_from_spec(spec) +spec.loader.exec_module(launcher) + + +@pytest.fixture(autouse=True) +def loopback_dns(monkeypatch): + monkeypatch.setattr(launcher.socket, "getaddrinfo", lambda *_: [(None, None, None, None, ("127.0.0.1", 443))]) + + +@pytest.mark.parametrize("url", [ + "http://local.test", "https://user:password@local.test", "https://local.test?token=private", + "https://local.test#fragment", "https://local.test:invalid", "https://local.test:70000", +]) +def test_local_url_rejects_unsecured_or_credential_url(url): + with pytest.raises(ValueError): + launcher.validate_local_url(url) + + +def test_local_url_rejects_external_dns(monkeypatch): + monkeypatch.setattr(launcher.socket, "getaddrinfo", lambda *_: [(None, None, None, None, ("192.0.2.1", 443))]) + with pytest.raises(ValueError): + launcher.validate_local_url("https://local.test") + + +@pytest.mark.parametrize("effective", [ + "http://local.test/adminapi", "https://local.test:8443/adminapi", "https://local.test/other/adminapi", +]) +def test_preferences_cannot_change_validated_scheme_port_or_path(effective): + with pytest.raises(ValueError): + launcher.validate_effective_config(SimpleNamespace(api_base_url=effective, verify_ssl=True), "https://local.test") + + +def test_local_config_keeps_certificate_verification(): + launcher.validate_effective_config(SimpleNamespace(api_base_url="https://local.test/adminapi", verify_ssl=True), "https://local.test") + with pytest.raises(ValueError): + launcher.validate_effective_config(SimpleNamespace(api_base_url="https://local.test/adminapi", verify_ssl=False), "https://local.test") diff --git a/app/tests/test_repository_parity.py b/app/tests/test_repository_parity.py index a6bcf3e5b..1af6230ec 100644 --- a/app/tests/test_repository_parity.py +++ b/app/tests/test_repository_parity.py @@ -5,6 +5,7 @@ from __future__ import annotations from datetime import date from pathlib import Path from typing import Any +from uuid import UUID import pytest @@ -759,7 +760,8 @@ def test_remote_diagnosis_ai_stream_normalises_chunks_in_order() -> None: class StreamingClient(RecordingClient): def post_event_stream(self, endpoint: str, payload: dict[str, Any], **kwargs: Any): assert endpoint == "tcm.diagnosis/aiAssistantStream" - assert payload == {"id": 501, "prompt": "请辨证", "task": "tcm_pattern"} + assert {key: value for key, value in payload.items() if key != "request_id"} == {"id": 501, "prompt": "请辨证", "task": "tcm_pattern"} + assert str(UUID(payload["request_id"])) == payload["request_id"] assert kwargs["timeout"] == 105.0 yield {"event": "start", "data": {"model_key": "qwen"}} yield {"event": "delta", "data": {"content": "肝郁"}} diff --git a/server/app/adminapi/controller/tcm/DiagnosisController.php b/server/app/adminapi/controller/tcm/DiagnosisController.php index 68a5fa8e4..240a4939e 100755 --- a/server/app/adminapi/controller/tcm/DiagnosisController.php +++ b/server/app/adminapi/controller/tcm/DiagnosisController.php @@ -23,6 +23,7 @@ use app\adminapi\logic\tcm\PatientAiReportLogic; use app\adminapi\logic\tcm\TrackingNoteLogic; use app\adminapi\service\AssistantSseProtocol; use app\adminapi\service\AssistantSseTiming; +use app\adminapi\service\AssistantRequestControl; use app\adminapi\validate\tcm\DiagnosisValidate; use app\common\model\Order; use app\common\model\WechatChatRecord; @@ -936,135 +937,163 @@ class DiagnosisController extends BaseAdminController public function aiAssistantStream() { $timing = new AssistantSseTiming(); - // 登录由全局中间件完成;请求校验、旧助手权限与 DataScope 必须全部 - // 在任何 SSE header / start 事件之前完成,失败时仍返回标准 JSON。 - $params = (new DiagnosisValidate())->post()->goCheck('aiAssistant'); - $prepared = DiagnosisAiLogic::prepareAssistant( - (int) $params['id'], - (string) $params['task'], - (string) ($params['prompt'] ?? ''), - $this->adminId, - $this->adminInfo, - $timing - ); - if ($prepared === null) { - return $this->fail(DiagnosisAiLogic::getError()); + $params = (new DiagnosisValidate())->post()->goCheck('aiAssistantStream'); + // Old streaming clients may omit request_id; only requests with a known UUID + // can be explicitly cancelled by a second HTTP request. + $requestId = (string) ($params['request_id'] ?? ''); + if ($requestId === '') { + $hex = bin2hex(random_bytes(16)); + $requestId = substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-4' . substr($hex, 13, 3) + . '-8' . substr($hex, 17, 3) . '-' . substr($hex, 20); + } + $emit = null; + $control = null; + $profile = ''; + $sseStartedAt = hrtime(true); + $disconnected = false; + $shouldAbort = static function () use (&$control, &$disconnected): bool { + if (connection_aborted() === 1 && !$disconnected) { + $disconnected = true; + if ($control !== null) $control->signalCancel(); + } + return $disconnected || ($control !== null && $control->shouldAbort()); + }; + $options = [ + 'should_abort' => $shouldAbort, + 'upstream_user' => 'admin-diagnosis-assistant-' . $this->adminId, + 'on_authorized' => function (array $metadata) use (&$control, &$emit, &$profile, &$sseStartedAt, $requestId, $timing): bool { + // prepareAssistant reaches this callback only after permission, task + // and canonical diagnosis DataScope checks. Failures before it are JSON. + $control = new AssistantRequestControl($this->adminId, (int) $metadata['diagnosis_id'], $requestId); + if (!$control->begin()) return false; + $profile = (string) $metadata['model_key']; + $emit = $this->openAssistantSse(); + $sseStartedAt = hrtime(true); + $started = $emit('start', $metadata + ['request_id' => $requestId, 'message' => '已连接,正在整理资料']); + $timing->log('sse_start', ['status' => $started ? 'ok' : 'disconnected']); + return true; + }, + 'on_progress' => static function (array $progress) use (&$emit): void { + if ($emit !== null) $emit('progress', $progress); + }, + 'on_stop' => static function (bool $confirmed, string $taskId = '') use (&$control): void { + if ($control !== null && $taskId !== '') $control->markStop($confirmed, $taskId); + }, + 'on_transfer_end' => static function (string $taskId = '') use (&$control): void { + if ($control !== null) $control->clearTask($taskId !== '' ? $taskId : null); + }, + 'on_cancel_task' => static function (string $taskId, callable $performStop) use (&$control): array { + if ($control === null) return ['accepted' => false, 'pending' => false]; + $control->signalCancel(); + return $control->stopBoundTask($performStop, $taskId); + }, + 'on_terminal' => static function (string $taskId, string $status) use (&$control): void { + if ($control !== null) $control->markTerminal($taskId, $status); + }, + ]; + $upstreamUser = $options['upstream_user']; + $options['on_task'] = static function (string $taskId) use (&$control, &$profile, $upstreamUser): void { + if ($control !== null) $control->bindTask($profile, $upstreamUser, $taskId); + }; + try { + $prepared = DiagnosisAiLogic::prepareAssistant( + (int) $params['id'], (string) $params['task'], (string) ($params['prompt'] ?? ''), + $this->adminId, $this->adminInfo, $timing, $options + ); + if ($emit === null) { + return $this->fail(DiagnosisAiLogic::getError()); + } + if ($prepared === null) { + $this->finishAssistantError($emit, $control, $shouldAbort()); + exit; + } + $this->runAssistantSse($prepared, $timing, $emit, $shouldAbort, $options, $control, $sseStartedAt); + } catch (\Throwable $e) { + \think\facade\Log::warning('diagnosis ai assistant sse failed ' . json_encode(['trace_id' => $timing->id()])); + $timing->log('sse_done', ['duration_ms' => AssistantSseTiming::elapsedMs($sseStartedAt), 'status' => 'exception']); + if ($emit === null) return $this->fail('AI 助手暂时不可用,请稍后重试'); + try { if ($control !== null) $control->finish('error'); } catch (\Throwable $ignored) {} + $emit('error', ['code' => 'AI_ASSISTANT_FAILED', 'message' => 'AI 助手暂时不可用,请稍后重试']); + exit; } - - $this->runAssistantSse($prepared, $timing); } - /** @param array $prepared */ - private function runAssistantSse(array $prepared, AssistantSseTiming $timing): void + public function cancelAiAssistant() { - while (ob_get_level() > 0) { - ob_end_clean(); - } + $params = (new DiagnosisValidate())->post()->goCheck('cancelAiAssistant'); + $result = DiagnosisAiLogic::cancelAssistant((int) $params['id'], (string) $params['request_id'], $this->adminId, $this->adminInfo); + return $result === null ? $this->fail(DiagnosisAiLogic::getError()) : $this->data($result); + } + private function openAssistantSse(): callable + { + while (ob_get_level() > 0) ob_end_clean(); @ini_set('output_buffering', 'off'); @ini_set('zlib.output_compression', '0'); ignore_user_abort(true); - if (function_exists('apache_setenv')) { - @apache_setenv('no-gzip', '1'); - } - + if (function_exists('apache_setenv')) @apache_setenv('no-gzip', '1'); header('Content-Type: text/event-stream; charset=utf-8'); header('Cache-Control: no-cache, no-transform'); header('Connection: keep-alive'); header('X-Accel-Buffering: no'); header('Content-Encoding: none'); - echo ':' . str_repeat(' ', 2048) . "\n\n"; $this->flushSseOutput(); - $protocol = new AssistantSseProtocol(); - $emit = function (string $event, array $payload) use ($protocol): bool { - if ($protocol->isTerminal() || connection_aborted()) { - return false; - } + return function (string $event, array $payload) use ($protocol): bool { + if ($protocol->isTerminal() || connection_aborted()) return false; $encoded = $protocol->encode($event, $payload); - if ($encoded === null) { - return false; - } + if ($encoded === null) return false; echo $encoded; $this->flushSseOutput(); return !connection_aborted(); }; + } - $startEmitted = $emit('start', [ - 'task' => (string) ($prepared['task'] ?? ''), - 'model_key' => (string) ($prepared['profile'] ?? ''), - 'diagnosis_id' => (int) ($prepared['diagnosis_id'] ?? 0), - 'context_scope' => (string) ($prepared['context_scope'] ?? ''), - 'context_version' => (string) ($prepared['context_version'] ?? ''), - 'source_summary' => is_array($prepared['source_summary'] ?? null) - ? $prepared['source_summary'] - : [], - 'message' => '已连接,正在生成…', - ]); - $sseStartedAt = hrtime(true); + private function finishAssistantError(callable $emit, ?AssistantRequestControl $control, bool $cancelled): void + { + if ($control !== null) $control->finish($cancelled ? 'cancelled' : 'error'); + if ($cancelled) { + $emit('progress', ['phase' => 'cancelled', 'status' => 'cancelled', 'message' => '本次请求已取消'] + + ($control !== null ? $control->publicState() : [])); + } + $emit('error', ['code' => $cancelled ? 'AI_ASSISTANT_CANCELLED' : DiagnosisAiLogic::getAssistantErrorCode(), + 'message' => $cancelled ? '本次请求已取消' : DiagnosisAiLogic::getError()]); + } + + /** @param array $prepared */ + private function runAssistantSse(array $prepared, AssistantSseTiming $timing, callable $emit, callable $shouldAbort, + array $options, AssistantRequestControl $control, int $sseStartedAt): void + { $firstDeltaMs = null; - $timing->log('sse_start', ['status' => $startEmitted ? 'ok' : 'disconnected']); - - try { - $result = DiagnosisAiLogic::streamPreparedAssistant( - $prepared, - static function (string $delta) use ($emit, $timing, $sseStartedAt, &$firstDeltaMs): bool { - $observedAtMs = $firstDeltaMs === null && $delta !== '' - ? AssistantSseTiming::elapsedMs($sseStartedAt) - : null; - $accepted = $emit('delta', ['text' => $delta]); - if ($accepted && $observedAtMs !== null) { - $firstDeltaMs = $observedAtMs; - $timing->log('first_delta', [ - 'duration_ms' => $firstDeltaMs, - 'delta_bytes' => strlen($delta), - ]); - } - return $accepted; - }, - static fn (): bool => connection_aborted() === 1, - $timing - ); - if (connection_aborted()) { - $timing->log('sse_done', [ - 'duration_ms' => AssistantSseTiming::elapsedMs($sseStartedAt), - 'first_delta_observed' => $firstDeltaMs !== null, - 'first_delta_ms' => $firstDeltaMs, - 'status' => 'disconnected', - ]); - exit; - } - if ($result === null) { - $emit('error', [ - 'code' => DiagnosisAiLogic::getAssistantErrorCode(), - 'message' => DiagnosisAiLogic::getError(), - ]); + $result = DiagnosisAiLogic::streamPreparedAssistant( + $prepared, + static function (string $delta) use ($emit, $timing, $sseStartedAt, &$firstDeltaMs, $shouldAbort): bool { + if ($shouldAbort()) return false; + $observedAtMs = $firstDeltaMs === null && $delta !== '' ? AssistantSseTiming::elapsedMs($sseStartedAt) : null; + $accepted = $emit('delta', ['text' => $delta]); + if ($accepted && $observedAtMs !== null) { + $firstDeltaMs = $observedAtMs; + $timing->log('first_delta', ['duration_ms' => $firstDeltaMs, 'delta_bytes' => strlen($delta)]); + } + return $accepted; + }, + $shouldAbort, $timing, $options + ); + $cancelled = $shouldAbort(); + if ($result === null || $cancelled) { + $this->finishAssistantError($emit, $control, $cancelled); + } else { + $control->finish('done'); + if ($control->publicState()['status'] === 'cancelled') { + $this->finishAssistantError($emit, $control, true); } else { $emit('done', $result); } - $timing->log('sse_done', [ - 'duration_ms' => AssistantSseTiming::elapsedMs($sseStartedAt), - 'first_delta_observed' => $firstDeltaMs !== null, - 'first_delta_ms' => $firstDeltaMs, - 'status' => $result === null ? 'error' : 'ok', - ]); - } catch (\Throwable $e) { - \think\facade\Log::warning('diagnosis ai assistant sse failed ' . json_encode([ - 'trace_id' => $timing->id(), - ], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE)); - $timing->log('sse_done', [ - 'duration_ms' => AssistantSseTiming::elapsedMs($sseStartedAt), - 'first_delta_observed' => $firstDeltaMs !== null, - 'first_delta_ms' => $firstDeltaMs, - 'status' => 'exception', - ]); - $emit('error', [ - 'code' => 'AI_ASSISTANT_FAILED', - 'message' => 'AI 助手暂时不可用,请稍后重试', - ]); } - + $timing->log('sse_done', ['duration_ms' => AssistantSseTiming::elapsedMs($sseStartedAt), + 'first_delta_observed' => $firstDeltaMs !== null, 'first_delta_ms' => $firstDeltaMs, + 'status' => $cancelled ? 'cancelled' : ($result === null ? 'error' : 'ok')]); exit; } diff --git a/server/app/adminapi/http/middleware/AuthMiddleware.php b/server/app/adminapi/http/middleware/AuthMiddleware.php index c0957bbab..56b41b331 100755 --- a/server/app/adminapi/http/middleware/AuthMiddleware.php +++ b/server/app/adminapi/http/middleware/AuthMiddleware.php @@ -123,7 +123,7 @@ class AuthMiddleware if (!in_array($accessUri, $allUri, true) && !PharmacyUploadPermissionAlias::allows($accessUri, $allUri) && !$this->isCriticalPrescriptionWrite($accessUri) - && !($accessUri === 'tcm.diagnosis/aiassistantstream' + && !(in_array($accessUri, ['tcm.diagnosis/aiassistantstream', 'tcm.diagnosis/cancelaiassistant'], true) && in_array('tcm.diagnosis/aiassistant', $allUri, true))) { return $next($request); } @@ -184,7 +184,7 @@ class AuthMiddleware { // AI 助手流式端点与旧 blocking 端点共享同一权限;别名同时用于 // allUri 判定和当前管理员权限判定,确保在 SSE headers 前完成鉴权。 - if ($accessUri === 'tcm.diagnosis/aiassistantstream' + if (in_array($accessUri, ['tcm.diagnosis/aiassistantstream', 'tcm.diagnosis/cancelaiassistant'], true) && in_array('tcm.diagnosis/aiassistant', $adminUris, true)) { return true; } diff --git a/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php b/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php index f77802e6a..6b0a05ffd 100644 --- a/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php +++ b/server/app/adminapi/logic/tcm/DiagnosisAiLogic.php @@ -14,6 +14,7 @@ use app\common\service\NihaixiaClinicalSkill; use app\common\service\TcmAncientBooksReference; use app\adminapi\service\AssistantSseTiming; use app\adminapi\service\AiContextCompactionCache; +use app\adminapi\service\AssistantRequestControl; use think\facade\Db; use think\facade\Log; @@ -347,7 +348,8 @@ class DiagnosisAiLogic extends BaseLogic } /** - * 在 SSE headers 发出前完成参数、权限、DataScope、病例和模型选择预检。 + * 参数、权限、DataScope 和模型选择完成后可通过 on_authorized 提前开启 SSE; + * 随后的资料聚合/证据整理通过可选 progress 与取消回调协作。 * 返回值只供同一请求内的流执行使用,绝不能直接序列化给客户端。 * * @param array $adminInfo @@ -362,7 +364,8 @@ class DiagnosisAiLogic extends BaseLogic string $prompt, int $adminId, array $adminInfo, - ?AssistantSseTiming $timing = null + ?AssistantSseTiming $timing = null, + array $options = [] ): ?array { $prepareStartedAt = hrtime(true); $aggregateMs = 0; @@ -402,16 +405,6 @@ class DiagnosisAiLogic extends BaseLogic return null; } - $aggregateStartedAt = hrtime(true); - $context = self::buildCaseContext($diagnosis, $adminId, $adminInfo); - $aggregateMs = AssistantSseTiming::elapsedMs($aggregateStartedAt); - $sourceBytes = strlen((string) ($context['case_text'] ?? '')); - $attachmentCount = count(is_array($context['files'] ?? null) ? $context['files'] : []); - if ($context['case_lines'] === []) { - self::setError('患者纵向资料为空或聚合失败,无法使用 AI 助手'); - return null; - } - $profile = self::selectAssistantProfile($task, $prompt); $modelConfig = self::modelConfigs()[$profile] ?? []; $model = trim((string) ($modelConfig['name'] ?? '')); @@ -420,14 +413,40 @@ class DiagnosisAiLogic extends BaseLogic self::setError('AI 模型服务尚未完整配置'); return null; } + // This is the only early-start boundary: all identity, scope and task + // checks have succeeded, while no aggregation or model work has begun. + if (is_callable($options['on_authorized'] ?? null) + && ($options['on_authorized'])(['diagnosis_id' => $diagnosisId, 'task' => $task, 'model_key' => $profile]) === false) { + self::setError('该请求标识已使用,请重新发起'); + return null; + } + if (self::assistantCancelled($options['should_abort'] ?? null)) return null; + self::assistantProgress($options, ['phase' => 'preparing', 'status' => 'aggregating', 'message' => '正在聚合本次授权范围内的患者资料']); + $aggregateStartedAt = hrtime(true); + $context = self::buildCaseContext($diagnosis, $adminId, $adminInfo); + $aggregateMs = AssistantSseTiming::elapsedMs($aggregateStartedAt); + $sourceBytes = strlen((string) ($context['case_text'] ?? '')); + $attachmentCount = count(is_array($context['files'] ?? null) ? $context['files'] : []); + if (self::assistantCancelled($options['should_abort'] ?? null)) return null; + self::assistantProgress($options, ['phase' => 'preparing', 'status' => 'aggregated', + 'message' => '资料已聚合,正在整理本次提交内容', + 'source_summary' => $context['source_summary'] ?? [], 'attachment_count' => $attachmentCount]); + if ($context['case_lines'] === []) { + self::setError('患者纵向资料为空或聚合失败,无法使用 AI 助手'); + return null; + } $compactionAttempted = $sourceBytes > self::MAX_PROMPT_SOURCE_BYTES; $compactionStartedAt = hrtime(true); - if (!self::fitContextForPrompt($context, $profile, $timing)) { + if (!self::fitContextForPrompt($context, $profile, $timing, $options)) { $compactionMs = $compactionAttempted ? AssistantSseTiming::elapsedMs($compactionStartedAt) : 0; return null; } $compactionMs = $compactionAttempted ? AssistantSseTiming::elapsedMs($compactionStartedAt) : 0; + if (self::assistantCancelled($options['should_abort'] ?? null)) return null; + self::assistantProgress($options, ['phase' => 'preparing', 'status' => 'ready', + 'message' => '资料正文已整理,准备提交给模型', + 'source_summary' => $context['source_summary'] ?? [], 'attachment_count' => $attachmentCount]); $query = self::buildAssistantPrompt($context, $task, $prompt); $inputs = self::buildUpstreamInputs( @@ -455,6 +474,8 @@ class DiagnosisAiLogic extends BaseLogic 'context_scope' => (string) ($context['context_scope'] ?? 'patient_longitudinal'), 'context_version' => (string) ($context['context_version'] ?? self::ASSISTANT_PROMPT_VERSION), 'source_summary' => is_array($context['source_summary'] ?? null) ? $context['source_summary'] : [], + 'source_form' => !empty($context['source_summary']['source_compacted']) ? 'evidence_summary' : 'full_context', + 'evidence_cache_hit' => !empty($context['compaction_cache_hit']), 'source_diagnosis_ids' => is_array($context['source_diagnosis_ids'] ?? null) ? $context['source_diagnosis_ids'] : [], @@ -487,7 +508,8 @@ class DiagnosisAiLogic extends BaseLogic array $prepared, callable $onDelta, ?callable $shouldAbort = null, - ?AssistantSseTiming $timing = null + ?AssistantSseTiming $timing = null, + array $options = [] ): ?array { $diagnosisId = (int) ($prepared['diagnosis_id'] ?? 0); $profile = (string) ($prepared['profile'] ?? ''); @@ -510,6 +532,7 @@ class DiagnosisAiLogic extends BaseLogic }; try { + if (self::assistantCancelled($shouldAbort)) return null; try { $result = DifyChatService::streamChat( $profile, @@ -518,7 +541,10 @@ class DiagnosisAiLogic extends BaseLogic (string) ($prepared['user'] ?? ''), $forwardDelta, $shouldAbort, - is_array($prepared['files'] ?? null) ? $prepared['files'] : [] + is_array($prepared['files'] ?? null) ? $prepared['files'] : [], + $options + ['source_summary' => $prepared['source_summary'] ?? [], + 'source_form' => $prepared['source_form'] ?? 'full_context', + 'evidence_cache_hit' => !empty($prepared['evidence_cache_hit'])] ); } catch (\Throwable $e) { if ($timing === null) { @@ -573,7 +599,10 @@ class DiagnosisAiLogic extends BaseLogic (string) ($prepared['query'] ?? ''), (string) ($prepared['user'] ?? ''), is_array($prepared['files'] ?? null) ? $prepared['files'] : [], - ['timeout' => $remainingTimeout] + $options + ['timeout' => $remainingTimeout, 'should_abort' => $shouldAbort, 'blocking_compatibility' => true, + 'source_summary' => $prepared['source_summary'] ?? [], + 'source_form' => $prepared['source_form'] ?? 'full_context', + 'evidence_cache_hit' => !empty($prepared['evidence_cache_hit'])] ); } catch (\Throwable $e) { if ($timing === null) { @@ -601,6 +630,7 @@ class DiagnosisAiLogic extends BaseLogic } } + if (self::assistantCancelled($shouldAbort)) { $status = 'cancelled'; return null; } $formatted = self::formatAssistantResult($prepared, $result); $status = $formatted === null ? 'error' : 'ok'; return $formatted; @@ -619,6 +649,35 @@ class DiagnosisAiLogic extends BaseLogic } } + public static function cancelAssistant(int $diagnosisId, string $requestId, int $adminId, array $adminInfo): ?array + { + if (!AssistantRequestControl::validRequestId($requestId)) { + self::setError('请求标识无效'); + return null; + } + if (self::loadAuthorizedDiagnosis($diagnosisId, $adminId, $adminInfo, self::PERMISSION_ASSISTANT, + '权限不足,无法取消诊单 AI 助手') === null) return null; + try { + return (new AssistantRequestControl($adminId, $diagnosisId, $requestId))->requestCancel(); + } catch (\Throwable $ignored) { + self::setError('取消请求暂未送达,请稍后重试'); + return null; + } + } + + private static function assistantCancelled(?callable $shouldAbort): bool + { + if ($shouldAbort === null || !$shouldAbort()) return false; + self::$assistantErrorCode = 'AI_ASSISTANT_CANCELLED'; + self::setError('本次请求已取消'); + return true; + } + + private static function assistantProgress(array $options, array $event): void + { + if (is_callable($options['on_progress'] ?? null)) ($options['on_progress'])($event); + } + /** * @param array $prepared * @param array $result @@ -1570,7 +1629,8 @@ PROMPT; private static function fitContextForPrompt( array &$context, string $profile, - ?AssistantSseTiming $timing = null + ?AssistantSseTiming $timing = null, + array $options = [] ): bool { $caseText = (string) ($context['case_text'] ?? ''); @@ -1581,9 +1641,11 @@ PROMPT; try { $compacted = AiContextCompactionCache::remember( (string) ($context['compaction_cache_scope'] ?? ''), $profile, $caseText, - static fn (): array => PatientAiReportLogic::compactSourceForPrompt($profile, $caseText) + static fn (): array => PatientAiReportLogic::compactSourceForPrompt($profile, $caseText, $options), + $options['should_abort'] ?? null ); } catch (\Throwable $e) { + if (self::assistantCancelled($options['should_abort'] ?? null)) return false; if ($timing === null) { Log::warning('diagnosis ai context compaction failed', [ 'source_bytes' => strlen($caseText), diff --git a/server/app/adminapi/logic/tcm/PatientAiReportLogic.php b/server/app/adminapi/logic/tcm/PatientAiReportLogic.php index e7dfcc246..4eafd0154 100644 --- a/server/app/adminapi/logic/tcm/PatientAiReportLogic.php +++ b/server/app/adminapi/logic/tcm/PatientAiReportLogic.php @@ -550,8 +550,24 @@ class PatientAiReportLogic extends BaseLogic * * @return array{text:string,chunk_count:int,reduction_rounds:int,source_bytes:int,compacted:bool} */ - public static function compactSourceForPrompt(string $modelKey, string $sourceJson): array + public static function compactSourceForPrompt(string $modelKey, string $sourceJson, array $options = []): array { + $abort = $options['should_abort'] ?? null; + $check = static function () use ($abort): void { + if (is_callable($abort) && $abort()) throw new \RuntimeException('Context operation cancelled'); + }; + $progress = static function (string $message, int $completed, int $total) use ($options): void { + if (is_callable($options['on_progress'] ?? null)) ($options['on_progress'])([ + 'phase' => 'preparing', 'status' => 'compacting', 'message' => $message, + 'completed' => $completed, 'total' => $total, + ]); + }; + $transportOptions = array_intersect_key($options, array_flip([ + 'should_abort', 'on_task', 'on_stop', 'on_transfer_end', 'on_cancel_task', 'on_terminal', + ])); + $transportOptions['knowledge_mode'] = 'evidence'; + $upstreamUser = (string) ($options['upstream_user'] ?? 'patient-longitudinal-context'); + $check(); $sourceBytes = strlen($sourceJson); if ($sourceBytes <= self::MAX_PROMPT_CHUNK_BYTES) { return [ @@ -567,6 +583,8 @@ class PatientAiReportLogic extends BaseLogic $chunkCount = count($chunks); $summaries = []; foreach ($chunks as $index => $chunk) { + $check(); + $progress('正在向模型提交资料分片并整理证据', $index, $chunkCount); $part = DifyChatService::chat( $modelKey, [ @@ -576,8 +594,9 @@ class PatientAiReportLogic extends BaseLogic 'prompt_version' => self::PROMPT_VERSION, ], self::buildChunkPrompt($chunk, $index + 1, $chunkCount), - 'patient-longitudinal-context', [], ['knowledge_mode' => 'evidence'] + $upstreamUser, [], $transportOptions ); + $check(); if (empty($part['ok']) || trim((string) ($part['content'] ?? '')) === '') { throw new \RuntimeException('Patient context chunk analysis failed'); } @@ -586,6 +605,7 @@ class PatientAiReportLogic extends BaseLogic 'total' => $chunkCount, 'summary' => self::cleanSourceText($part['content'], true), ]; + $progress('资料分片证据已返回', $index + 1, $chunkCount); } $reductionRounds = 0; @@ -598,6 +618,8 @@ class PatientAiReportLogic extends BaseLogic $summaryChunks = self::splitUtf8ByBytes($summaryJson, self::MAX_PROMPT_CHUNK_BYTES); $reduced = []; foreach ($summaryChunks as $index => $chunk) { + $check(); + $progress('正在归并第' . $reductionRounds . '轮资料证据', $index, count($summaryChunks)); $part = DifyChatService::chat( $modelKey, [ @@ -608,8 +630,9 @@ class PatientAiReportLogic extends BaseLogic 'prompt_version' => self::PROMPT_VERSION, ], self::buildReductionPrompt($chunk, $index + 1, count($summaryChunks)), - 'patient-longitudinal-context', [], ['knowledge_mode' => 'evidence'] + $upstreamUser, [], $transportOptions ); + $check(); if (empty($part['ok']) || trim((string) ($part['content'] ?? '')) === '') { throw new \RuntimeException('Patient context reduction failed'); } @@ -618,6 +641,7 @@ class PatientAiReportLogic extends BaseLogic 'total' => count($summaryChunks), 'summary' => self::cleanSourceText($part['content'], true), ]; + $progress('资料证据归并已返回', $index + 1, count($summaryChunks)); } $nextJson = self::encodeJson($reduced, true); if (strlen($nextJson) >= strlen($summaryJson) && count($reduced) >= count($summaries)) { @@ -627,6 +651,7 @@ class PatientAiReportLogic extends BaseLogic $summaryJson = $nextJson; } + $check(); return [ 'text' => '患者纵向完整资料(服务端已逐片读取全部来源后归并的证据摘要,覆盖 ' . $chunkCount . ' 个来源片段):' . $summaryJson, diff --git a/server/app/adminapi/service/AiContextCompactionCache.php b/server/app/adminapi/service/AiContextCompactionCache.php index 42614b04f..3a5c1d728 100644 --- a/server/app/adminapi/service/AiContextCompactionCache.php +++ b/server/app/adminapi/service/AiContextCompactionCache.php @@ -11,7 +11,7 @@ use think\facade\Cache; /** Short-lived evidence only. The caller must re-authorize and aggregate before lookup. */ final class AiContextCompactionCache { - public static function remember(string $scope, string $profile, string $source, callable $produce): array + public static function remember(string $scope, string $profile, string $source, callable $produce, ?callable $shouldAbort = null): array { $config = config('prescription_ai') ?: []; $ttl = max(0, min(600, (int) ($config['context_compaction_cache_ttl'] ?? 300))); @@ -28,6 +28,7 @@ final class AiContextCompactionCache } } $result = $produce(); + if ($shouldAbort !== null && $shouldAbort()) throw new \RuntimeException('Context operation cancelled'); if ($scope !== '' && $ttl > 0 && self::valid($result, strlen($source))) { try { Cache::set($key, $result, $ttl); diff --git a/server/app/adminapi/service/AssistantRequestControl.php b/server/app/adminapi/service/AssistantRequestControl.php new file mode 100644 index 000000000..7a668b0b4 --- /dev/null +++ b/server/app/adminapi/service/AssistantRequestControl.php @@ -0,0 +1,286 @@ +getRuntimePath() . 'assistant-requests'; + if (!is_dir($directory) && !@mkdir($directory, 0700, true) && !is_dir($directory)) { + throw new \RuntimeException('Assistant request control unavailable'); + } + $this->path = rtrim($directory, '/\\') . DIRECTORY_SEPARATOR + . hash('sha256', $adminId . ':' . $diagnosisId . ':' . strtolower($requestId)) . '.json'; + $this->ttl = max(600, $ttl); + $this->directory = $directory; + $this->collectExpired(); + } + + public static function validRequestId(string $id): bool + { + return preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $id) === 1; + } + + /** A cancel tombstone created before prepare is retained; a replay never restarts work. */ + public function begin(): bool + { + return $this->update(static function (array &$state): bool { + if (!empty($state['begun'])) { + return false; + } + $state['begun'] = true; + $state['status'] = !empty($state['cancel_requested']) ? 'cancel_requested' : 'running'; + return true; + }); + } + + public function shouldAbort(): bool + { + return $this->update(static fn (array &$state): bool => !empty($state['cancel_requested']), false); + } + + public function bindTask(string $profile, string $user, string $taskId): void + { + if (!in_array($profile, ['qwen', 'openai'], true) + || preg_match('/^[a-zA-Z0-9_-]{1,128}$/', $taskId) !== 1 || strlen($user) > 128) { + throw new \RuntimeException('Invalid upstream task identity'); + } + $this->update(static function (array &$state) use ($profile, $user, $taskId): void { + if (in_array($state['status'] ?? '', ['done', 'error', 'cancelled'], true)) return; + $previous = $state['upstream'] ?? []; + if (($previous['task_id'] ?? '') === $taskId && ($previous['user'] ?? '') === $user + && ($previous['profile'] ?? '') === $profile) return; + $state['task_generation'] = (int) ($state['task_generation'] ?? 0) + 1; + $state['upstream'] = ['profile' => $profile, 'user' => $user, 'task_id' => $taskId, + 'generation' => $state['task_generation']]; + $state['upstream_stop_confirmed'] = false; + $state['upstream_terminal_confirmed'] = false; + $state['upstream_terminal_status'] = ''; + $state['stop_attempts'] = 0; + unset($state['stop_claim'], $state['stop_retry_after']); + }); + } + + public function clearTask(?string $taskId = null): void + { + $this->update(static function (array &$state) use ($taskId): void { + if ($taskId !== null && ($state['upstream']['task_id'] ?? '') !== $taskId) return; + self::releaseTask($state); + }); + } + + public function markStop(bool $confirmed, ?string $taskId = null): void + { + $this->update(static function (array &$state) use ($confirmed, $taskId): void { + if ($taskId !== null && ($state['upstream']['task_id'] ?? '') !== $taskId) return; + $state['upstream_stop_confirmed'] = !empty($state['upstream_stop_confirmed']) || $confirmed; + }); + } + + /** Only a real workflow terminal event confirms execution ended; stop acceptance does not. */ + public function markTerminal(string $taskId, string $status): void + { + if (!in_array($status, ['stopped', 'succeeded', 'failed'], true)) return; + $this->update(static function (array &$state) use ($taskId, $status): void { + if (($state['upstream']['task_id'] ?? '') !== $taskId || !empty($state['upstream_terminal_confirmed'])) return; + $state['upstream_terminal_confirmed'] = true; + $state['upstream_terminal_status'] = $status; + }); + } + + /** Safe inside a transport callback: writes intent and never performs network I/O. */ + public function signalCancel(): void + { + $this->update(static function (array &$state): void { + if (in_array($state['status'] ?? '', ['done', 'error'], true)) return; + $state['cancel_requested'] = true; + if (($state['status'] ?? '') !== 'cancelled') $state['status'] = 'cancel_requested'; + $state['stop_retry_until'] = $state['stop_retry_until'] ?? microtime(true) + self::STOP_RETENTION_SECONDS; + }); + } + + /** Safe client view. A requested cancellation is never reported as confirmed stop. */ + public function requestCancel(?callable $stop = null): array + { + $this->signalCancel(); + $this->stopBoundTask($stop); + return $this->publicState(); + } + + /** + * Shared by the cancel endpoint and stream owner. The lease exceeds the 1s stop + * transport budget; a crashed claimant can be replaced, while accepted stops + * never repeat. Failed tasks remain eligible for one short-lived compensation. + * @return array{accepted:bool,pending:bool} + */ + public function stopBoundTask(?callable $stop = null, ?string $expectedTaskId = null): array + { + $token = bin2hex(random_bytes(12)); + $claim = $this->update(static function (array &$state) use ($token, $expectedTaskId): array { + $now = microtime(true); + $task = $state['upstream'] ?? null; + $accepted = !empty($state['upstream_stop_confirmed']); + if (!is_array($task) || ($expectedTaskId !== null && $task['task_id'] !== $expectedTaskId)) { + return ['accepted' => false, 'pending' => false]; + } + // A cancel worker may load a task written by the previous local + // release. Upgrade its private identity atomically before claiming. + if (!isset($task['generation'])) { + $state['task_generation'] = (int) ($state['task_generation'] ?? 0) + 1; + $task['generation'] = $state['task_generation']; + $state['upstream'] = $task; + } + if ($accepted || !empty($state['upstream_terminal_confirmed']) || empty($state['cancel_requested']) + || (float) ($state['stop_retry_until'] ?? 0) <= $now) { + return ['accepted' => $accepted, 'pending' => false]; + } + if ((float) ($state['stop_claim']['until'] ?? 0) > $now + || (float) ($state['stop_retry_after'] ?? 0) > $now) { + return ['accepted' => false, 'pending' => true]; + } + if ((int) ($state['stop_attempts'] ?? 0) >= self::MAX_STOP_ATTEMPTS) { + return ['accepted' => false, 'pending' => false]; + } + $state['stop_claim'] = ['token' => $token, 'until' => $now + self::STOP_LEASE_SECONDS]; + $state['stop_attempts'] = (int) ($state['stop_attempts'] ?? 0) + 1; + return ['task' => $task, 'accepted' => false, 'pending' => true]; + }); + if (!isset($claim['task'])) return $claim; + $task = $claim['task']; + $confirmed = false; + // Never hold either file lock while waiting for the remote stop endpoint. + try { + $confirmed = ($stop !== null ? $stop($task) : DifyChatService::stopTask( + $task['profile'], $task['task_id'], $task['user'] + )) === true; + } catch (\Throwable $ignored) {} + return $this->update(static function (array &$state) use ($task, $token, $confirmed): array { + if (($state['upstream']['generation'] ?? 0) !== $task['generation'] + || ($state['upstream']['task_id'] ?? '') !== $task['task_id'] + || ($state['stop_claim']['token'] ?? '') !== $token) { + return ['accepted' => false, 'pending' => false]; + } + $state['upstream_stop_confirmed'] = !empty($state['upstream_stop_confirmed']) || $confirmed; + unset($state['stop_claim']); + $state['stop_retry_after'] = microtime(true) + self::STOP_RETRY_DELAY_SECONDS; + return ['accepted' => !empty($state['upstream_stop_confirmed']), 'pending' => false]; + }); + } + + private static function releaseTask(array &$state): void + { + if (!empty($state['cancel_requested']) && empty($state['upstream_terminal_confirmed']) + && (float) ($state['stop_retry_until'] ?? 0) > microtime(true)) { + return; + } + unset($state['upstream'], $state['stop_claim']); + } + + public function finish(string $status): void + { + if (!in_array($status, ['done', 'error', 'cancelled'], true)) { + throw new \InvalidArgumentException('Invalid assistant terminal state'); + } + $this->update(static function (array &$state) use ($status): void { + if (in_array($state['status'] ?? '', ['done', 'error', 'cancelled'], true)) { + return; + } + $state['status'] = !empty($state['cancel_requested']) ? 'cancelled' : $status; + $state['transport_closed'] = true; + self::releaseTask($state); + }); + } + + public function publicState(): array + { + return $this->update(static fn (array &$state): array => [ + 'status' => $state['status'] ?? 'pending', + 'cancel_requested' => !empty($state['cancel_requested']), + 'transport_closed' => !empty($state['transport_closed']), + 'upstream_stop_confirmed' => !empty($state['upstream_stop_confirmed']), + 'upstream_terminal_confirmed' => !empty($state['upstream_terminal_confirmed']), + 'upstream_terminal_status' => (string) ($state['upstream_terminal_status'] ?? ''), + ], false); + } + + private function update(callable $apply, bool $persist = true) + { + $guard = @fopen($this->directory . DIRECTORY_SEPARATOR . '.gc.lock', 'c+'); + if ($guard === false || !flock($guard, LOCK_SH)) { + if (is_resource($guard)) fclose($guard); + throw new \RuntimeException('Assistant request control unavailable'); + } + $handle = @fopen($this->path, 'c+'); + if ($handle === false || !flock($handle, $persist ? LOCK_EX : LOCK_SH)) { + if (is_resource($handle)) fclose($handle); + flock($guard, LOCK_UN); + fclose($guard); + throw new \RuntimeException('Assistant request control unavailable'); + } + try { + @chmod($this->path, 0600); + $raw = stream_get_contents($handle); + $state = is_string($raw) ? json_decode($raw, true) : null; + if ($raw !== '' && !is_array($state)) throw new \RuntimeException('Assistant request control unavailable'); + if (!is_array($state) || (int) ($state['expires_at'] ?? 0) <= time()) { + $state = ['expires_at' => time() + $this->ttl]; + } + $result = $apply($state); + if (!$persist) return $result; + $state['expires_at'] = time() + $this->ttl; + $encoded = json_encode($state, JSON_THROW_ON_ERROR); + rewind($handle); + if (!ftruncate($handle, 0) || fwrite($handle, $encoded) !== strlen($encoded) || !fflush($handle)) { + throw new \RuntimeException('Assistant request control unavailable'); + } + return $result; + } finally { + flock($handle, LOCK_UN); + fclose($handle); + flock($guard, LOCK_UN); + fclose($guard); + } + } + + /** The global guard prevents unlink/reopen races with any other worker. */ + private function collectExpired(): void + { + $guard = @fopen($this->directory . DIRECTORY_SEPARATOR . '.gc.lock', 'c+'); + if ($guard === false) return; + try { + if (!flock($guard, LOCK_EX | LOCK_NB)) return; + $paths = glob($this->directory . DIRECTORY_SEPARATOR . '*.json') ?: []; + // Bound each sweep; rotating the scan also reclaims older files in large directories. + if (count($paths) > 256) shuffle($paths); + foreach (array_slice($paths, 0, 256) as $path) { + if ((int) @filemtime($path) > time() - 600) continue; + $state = json_decode((string) @file_get_contents($path), true); + if (!is_array($state) || (int) ($state['expires_at'] ?? 0) <= time()) @unlink($path); + } + } finally { + flock($guard, LOCK_UN); + fclose($guard); + } + } +} diff --git a/server/app/adminapi/service/AssistantSseProtocol.php b/server/app/adminapi/service/AssistantSseProtocol.php index b718a97d9..739b84422 100644 --- a/server/app/adminapi/service/AssistantSseProtocol.php +++ b/server/app/adminapi/service/AssistantSseProtocol.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace app\adminapi\service; /** - * 诊单 AI 助手 SSE 事件状态机:start -> delta* -> done|error。 + * 诊单 AI 助手 SSE 事件状态机:start -> (progress|delta)* -> done|error。 */ final class AssistantSseProtocol { @@ -18,7 +18,7 @@ final class AssistantSseProtocol /** @param array $payload */ public function encode(string $event, array $payload): ?string { - if ($this->terminal || !in_array($event, ['start', 'delta', 'done', 'error'], true)) { + if ($this->terminal || !in_array($event, ['start', 'progress', 'delta', 'done', 'error'], true)) { return null; } if ((!$this->started && $event !== 'start') || ($this->started && $event === 'start')) { diff --git a/server/app/adminapi/service/AssistantSseTiming.php b/server/app/adminapi/service/AssistantSseTiming.php index 774d7c0d4..1de83df9a 100644 --- a/server/app/adminapi/service/AssistantSseTiming.php +++ b/server/app/adminapi/service/AssistantSseTiming.php @@ -25,7 +25,7 @@ final class AssistantSseTiming 'CONFIG_DISABLED', 'INVALID_PROFILE', 'CONFIG_MISSING', 'CONFIG_INVALID', 'CURL_UNAVAILABLE', 'SKILL_UNAVAILABLE', 'CLIENT_DISCONNECTED', 'STREAM_DELIVERY_FAILED', 'REQUEST_BUILD_FAILED', 'CURL_INIT_FAILED', - 'UPSTREAM_BUSY', 'INVALID_RESPONSE', + 'UPSTREAM_BUSY', 'INVALID_RESPONSE', 'AI_ASSISTANT_CANCELLED', ]; private string $traceId; @@ -72,7 +72,7 @@ final class AssistantSseTiming $event[$field] = $metrics[$field]; } } - if (isset($metrics['status']) && in_array($metrics['status'], ['ok', 'error', 'exception', 'disconnected'], true)) { + if (isset($metrics['status']) && in_array($metrics['status'], ['ok', 'error', 'exception', 'disconnected', 'cancelled'], true)) { $event['status'] = $metrics['status']; } if (isset($metrics['error_code']) && in_array($metrics['error_code'], self::ERROR_CODES, true)) { diff --git a/server/app/adminapi/validate/tcm/DiagnosisValidate.php b/server/app/adminapi/validate/tcm/DiagnosisValidate.php index 2b0bdb4fc..f548117a5 100755 --- a/server/app/adminapi/validate/tcm/DiagnosisValidate.php +++ b/server/app/adminapi/validate/tcm/DiagnosisValidate.php @@ -206,6 +206,24 @@ class DiagnosisValidate extends BaseValidate return $this->only(['id', 'task', 'prompt']); } + public function sceneAiAssistantStream() + { + return $this->only(['id', 'task', 'prompt', 'request_id']) + ->append('request_id', 'checkAssistantRequestId'); + } + + public function sceneCancelAiAssistant() + { + return $this->only(['id', 'request_id']) + ->append('request_id', 'require|checkAssistantRequestId'); + } + + protected function checkAssistantRequestId($value) + { + return is_string($value) && \app\adminapi\service\AssistantRequestControl::validRequestId($value) + ? true : 'AI请求标识无效'; + } + public function sceneAiAnalysis() { return $this->only(['id', 'model']) diff --git a/server/app/common/service/DifyChatService.php b/server/app/common/service/DifyChatService.php index d82d7e337..508fcd3b9 100644 --- a/server/app/common/service/DifyChatService.php +++ b/server/app/common/service/DifyChatService.php @@ -23,6 +23,10 @@ class DifyChatService private const DEFAULT_MAX_FILES = 3; + private const CANCEL_METADATA_SECONDS = 2.0; + + private const CANCEL_DRAIN_SECONDS = 2.5; + /** * 上游明确以“这批附件我处理不了”拒绝整次请求时使用的状态码。 * 命中后会去掉附件重试一次,避免一张舌象图让整份病历分析失败。 @@ -42,7 +46,17 @@ class DifyChatService array $options = [] ): array { + // Cancellable callers collect a real upstream stream internally. Dify's stop + // API cannot stop blocking mode. Legacy blocking callers are unchanged. + // Only the server's pre-delta compatibility retry opts into real blocking; + // its abort closes transport and deliberately leaves remote stop unconfirmed. + if (is_callable($options['should_abort'] ?? null) && empty($options['blocking_compatibility'])) { + return self::streamChat($profile, $inputs, $query, $user, + static fn (string $delta): bool => true, + $options['should_abort'], $files, $options); + } $config = config('prescription_ai') ?: []; + $shouldAbort = is_callable($options['should_abort'] ?? null) ? $options['should_abort'] : null; if (empty($config['enable'])) { return self::error('CONFIG_DISABLED', 'AI 报告功能未启用'); } @@ -122,6 +136,7 @@ class DifyChatService $lastSpec = []; foreach ($requestSpecs as $index => $requestSpec) { + if ($shouldAbort !== null && $shouldAbort()) return self::error('AI_ASSISTANT_CANCELLED', '本次请求已取消'); // The legacy compatible path cannot transmit documents. Strict callers must // receive an explicit gap, never a successful text-only fallback. if ($strictFiles && !self::strictProtocolSupportsFiles($requestSpec['protocol'], $attempt['files'])) { @@ -141,8 +156,12 @@ class DifyChatService $requestSpec['url'], $requestSpec['payload'], $apiKey, - $remainingTimeout + $remainingTimeout, + $options + ['attachment_items' => self::attachmentItems($attempt['files'], $attempt['omitted'], $requestSpec['protocol'])] ); + if (!empty($response['client_aborted']) || ($shouldAbort !== null && $shouldAbort())) { + return self::error('AI_ASSISTANT_CANCELLED', '本次请求已取消', self::elapsedMilliseconds($startedAt)); + } $lastResponse = $response; $lastSpec = $requestSpec; $fileRejected = $fileRejected || self::isFileRejection($response, $attempt['files']); @@ -207,7 +226,8 @@ class DifyChatService string $user, callable $onDelta, ?callable $shouldAbort = null, - array $files = [] + array $files = [], + array $options = [] ): array { $config = config('prescription_ai') ?: []; if (empty($config['enable'])) { @@ -229,7 +249,7 @@ class DifyChatService return self::error('CONFIG_INVALID', 'AI 服务配置无效'); } - $timeout = (int) ($config['timeout'] ?? 0); + $timeout = (int) ($options['timeout'] ?? $config['timeout'] ?? 0); if (!self::isValidTimeout($timeout)) { return self::error('CONFIG_INVALID', 'AI 服务超时配置无效'); } @@ -242,18 +262,28 @@ class DifyChatService return self::error('CONFIG_INVALID', 'AI 模型配置无效'); } - $ancientBookSources = ClinicalKnowledgeReference::ancientBookSources($query); + $evidenceOnly = ($options['knowledge_mode'] ?? '') === 'evidence'; + $ancientBookSources = $evidenceOnly ? [] : ClinicalKnowledgeReference::ancientBookSources($query); try { - $query = ClinicalKnowledgeReference::augmentQueryWithAncientBookSources($query, $ancientBookSources); + if (!$evidenceOnly) { + $query = ClinicalKnowledgeReference::augmentQueryWithAncientBookSources($query, $ancientBookSources); + } } catch (\RuntimeException $error) { return self::error('SKILL_UNAVAILABLE', '临床 AI 参考资料不可用'); } - $normalized = self::normalizeFiles($files, self::maxFiles($config, $modelConfig)); + $strictFiles = !empty($options['strict_files']); + $normalized = self::normalizeFiles($files, self::maxFiles($config, $modelConfig), !$strictFiles); + if ($strictFiles && !self::strictFilesComplete($files, $normalized)) { + return self::error('STRICT_FILES_INVALID_OR_LIMIT', '附件无效或超出单批上限,须分批处理'); + } $startedAt = microtime(true); $formatted = null; - foreach (self::buildAttemptPlan($normalized['kept'], $normalized['dropped']) as $attempt) { + $attemptPlan = $strictFiles + ? [['files' => $normalized['kept'], 'omitted' => []]] + : self::buildAttemptPlan($normalized['kept'], $normalized['dropped']); + foreach ($attemptPlan as $attempt) { $fileRejected = false; $inputRejected = false; @@ -275,6 +305,12 @@ class DifyChatService $lastSpec = []; foreach ($requestSpecs as $index => $requestSpec) { + if ($shouldAbort !== null && $shouldAbort()) { + return self::error('AI_ASSISTANT_CANCELLED', '本次请求已取消'); + } + if ($strictFiles && !self::strictProtocolSupportsFiles($requestSpec['protocol'], $attempt['files'])) { + return self::error('FILE_TYPE_UNSUPPORTED', '当前模型接口不支持此类原始附件'); + } $elapsedSeconds = (int) floor(microtime(true) - $startedAt); $remainingTimeout = $timeout - $elapsedSeconds; if ($remainingTimeout < self::MIN_TIMEOUT) { @@ -292,7 +328,10 @@ class DifyChatService $apiKey, $remainingTimeout, $onDelta, - $shouldAbort + $shouldAbort, + $options + ['profile' => $profile, 'user' => $user, + 'source_summary' => $options['source_summary'] ?? [], + 'attachment_items' => self::attachmentItems($attempt['files'], $attempt['omitted'], $requestSpec['protocol'])] ); $lastResponse = $response; $lastSpec = $requestSpec; @@ -317,9 +356,17 @@ class DifyChatService $lastResponse = $lastResponse ?? self::emptyStreamResponse(0); $formatted = self::formatStreamResponse($lastResponse, $startedAt); + if (!empty($lastResponse['client_aborted']) || !empty($lastResponse['callback_error']) + || ($shouldAbort !== null && $shouldAbort())) { + return self::error('AI_ASSISTANT_CANCELLED', '本次请求已取消', self::elapsedMilliseconds($startedAt)); + } self::logUpstreamFailure($lastSpec, $lastResponse, $query, $attempt['files'], $formatted); if (!empty($formatted['ok'])) { $formatted['ancient_book_sources'] = $ancientBookSources; + if ($strictFiles) { + $formatted['transmitted_file_count'] = count($attempt['files']); + $formatted['attachment_transport'] = (string) ($lastSpec['protocol'] ?? ''); + } return $formatted; } if ( @@ -340,6 +387,38 @@ class DifyChatService return $formatted ?? self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', self::elapsedMilliseconds($startedAt)); } + /** The task id is obtained from streaming metadata; user must match the original request. */ + public static function stopTask(string $profile, string $taskId, string $user): bool + { + if (preg_match('/^[a-zA-Z0-9_-]{1,128}$/', $taskId) !== 1 || $user === '') return false; + $config = config('prescription_ai') ?: []; + $model = self::resolveProfileConfig($config, $profile); + $base = trim((string) ($config['base_url'] ?? '')); + $key = (string) ($model['api_key'] ?? ''); + if (!self::isValidBaseUrl($base) || trim($key) === '' || strpbrk($key, "\r\n") !== false) return false; + $endpoint = self::buildEndpoint(rtrim($base, '/'), 'chat-messages'); + $response = self::sendRequest($endpoint . '/' . rawurlencode($taskId) . '/stop', ['user' => $user], trim($key), 1); + $body = json_decode((string) ($response['body'] ?? ''), true); + return ($response['errno'] ?? -1) === 0 && ($response['http_code'] ?? 0) === 200 + && ($body['result'] ?? '') === 'success'; + } + + /** Safe labels only; submitted means request references sent, never remote file reading. */ + private static function attachmentItems(array $files, array $omitted, string $protocol): array + { + $labels = ['image' => '图片附件', 'document' => '文档附件', 'audio' => '音频附件', 'video' => '视频附件', 'custom' => '其他附件']; + $items = []; + $counts = []; + foreach (array_merge($files, $omitted) as $index => $file) { + $kind = $file['type'] ?? 'custom'; + $counts[$kind] = ($counts[$kind] ?? 0) + 1; + $transmitted = $index < count($files) && ($protocol === 'dify' || $kind === 'image'); + $items[] = ['label' => ($labels[$kind] ?? '附件') . $counts[$kind], 'kind' => $kind, + 'status' => $transmitted ? 'submitting' : 'omitted']; + } + return $items; + } + /** * @param array $config * @return array|null @@ -383,6 +462,9 @@ class DifyChatService 'query' => self::withAttachmentManifest($query, $omitted), 'response_mode' => $streaming ? 'streaming' : 'blocking', 'user' => $user, + // The workstation does not use Dify conversation titles. Avoid a + // separate background LLM call that may outlive the answer's stop. + 'auto_generate_name' => false, ], ]; if ($files !== []) { @@ -740,7 +822,8 @@ class DifyChatService string $url, array $payload, string $apiKey, - int $timeout + int $timeout, + array $options = [] ): array { $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); if ($body === false) { @@ -751,7 +834,14 @@ class DifyChatService if ($ch === false) { return ['body' => '', 'errno' => -2, 'http_code' => 0]; } - curl_setopt_array($ch, [ + $shouldAbort = is_callable($options['should_abort'] ?? null) ? $options['should_abort'] : null; + $clientAborted = false; + $notify = self::transferProgressNotifier($options); + $submitted = false; + $markSubmitted = static function () use (&$submitted, $notify): void { + if (!$submitted) { $submitted = true; $notify('submitted'); } + }; + $curlOptions = [ CURLOPT_URL => $url, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, @@ -765,20 +855,85 @@ class DifyChatService 'Accept: application/json', 'Authorization: Bearer ' . $apiKey, ], - ]); + ]; + if (isset($options['timeout_ms'])) { + $milliseconds = max(1, min($timeout * 1000, (int) $options['timeout_ms'])); + $curlOptions[CURLOPT_TIMEOUT_MS] = $milliseconds; + $curlOptions[CURLOPT_CONNECTTIMEOUT_MS] = min(1000, $milliseconds); + } + if ($shouldAbort !== null) { + // Dify blocking mode exposes no streaming task id. Compatibility mode + // can close its transfer promptly, but cannot claim remote stop acceptance. + $notify('submitting'); + $progressOption = self::curlProgressOption(); + if ($progressOption === null) { + curl_close($ch); + return ['body' => '', 'errno' => -3, 'http_code' => 0, 'client_aborted' => true]; + } + $curlOptions[CURLOPT_NOPROGRESS] = false; + $curlOptions[$progressOption] = static function (...$values) use ($shouldAbort, &$clientAborted, $markSubmitted): int { + if (($values[3] ?? 0) > 0 && ($values[4] ?? 0) >= $values[3]) $markSubmitted(); + if ($shouldAbort()) { $clientAborted = true; return 1; } + return 0; + }; + $curlOptions[CURLOPT_HEADERFUNCTION] = static function ($handle, string $line) use ($markSubmitted): int { + if (preg_match('/^HTTP\/\S+\s+2\d{2}(?:\s|$)/i', trim($line))) $markSubmitted(); + return strlen($line); + }; + } + curl_setopt_array($ch, $curlOptions); - $responseBody = curl_exec($ch); - $errno = curl_errno($ch); - $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); + $responseBody = ''; + $errno = -3; + $httpCode = 0; + try { + $responseBody = curl_exec($ch); + $errno = curl_errno($ch); + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + if ($shouldAbort !== null && $shouldAbort()) $clientAborted = true; + } catch (\Throwable $ignored) { + $clientAborted = $shouldAbort !== null; + } finally { + curl_close($ch); + } + if ($shouldAbort !== null) { + if ($clientAborted && is_callable($options['on_stop'] ?? null)) ($options['on_stop'])(false); + if (is_callable($options['on_transfer_end'] ?? null)) ($options['on_transfer_end'])(); + if (!$clientAborted && ($errno !== 0 || $httpCode >= 400)) $notify('failed'); + } return [ 'body' => is_string($responseBody) ? $responseBody : '', 'errno' => $errno, 'http_code' => $httpCode, + 'client_aborted' => $clientAborted, ]; } + private static function transferProgressNotifier(array $options): callable + { + return static function (string $status) use ($options): void { + if (!is_callable($options['on_progress'] ?? null)) return; + $items = $options['attachment_items'] ?? []; + foreach ($items as &$item) { + if ($item['status'] !== 'omitted') $item['status'] = $status; + } + unset($item); + ($options['on_progress'])(['phase' => 'attachments', 'status' => $status, + 'message' => $status === 'submitted' ? '资料正文与附件引用已提交,等待模型;远程附件读取进度不可观测' + : ($status === 'failed' ? '本次资料请求提交失败' : '正在提交资料正文与附件引用'), + 'source_summary' => $options['source_summary'] ?? [], + 'source_form' => $options['source_form'] ?? 'full_context', + 'evidence_cache_hit' => !empty($options['evidence_cache_hit']), + 'attachment_transfer' => 'remote_url_references', + 'attachment_count' => count(array_filter($items, static fn (array $item): bool => $item['status'] !== 'omitted')), + 'items' => $items]); + if ($status === 'submitted') ($options['on_progress'])([ + 'phase' => 'upstream', 'status' => 'waiting', 'message' => '资料请求已提交,正在等待模型正文', + ]); + }; + } + /** * libcurl 7.32+ exposes CURLOPT_XFERINFOFUNCTION, while CentOS/RHEL 7 commonly * ships libcurl 7.29 with only CURLOPT_PROGRESSFUNCTION. Resolve the option @@ -810,7 +965,8 @@ class DifyChatService string $apiKey, int $timeout, callable $onDelta, - ?callable $shouldAbort + ?callable $shouldAbort, + array $options = [] ): array { $body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE); if ($body === false) { @@ -824,10 +980,74 @@ class DifyChatService $buffer = ''; $state = self::newStreamState(); + $state['on_task'] = $options['on_task'] ?? null; + $state['on_terminal'] = $options['on_terminal'] ?? null; + $notify = self::transferProgressNotifier($options); + $notify('submitting'); + $submitted = false; + $markSubmitted = static function () use (&$submitted, $notify): void { + if (!$submitted) { $submitted = true; $notify('submitted'); } + }; + $cancelAt = null; + $stopAttempted = false; + $stopAccepted = false; + $attemptStop = static function () use ($options, $protocol, $url, $payload, $apiKey, + &$state, &$cancelAt, &$stopAttempted, &$stopAccepted): void { + if ($protocol !== 'dify' || $state['task_id'] === '' || $state['upstream_terminal_confirmed']) return; + $remainingMs = (int) floor((self::CANCEL_DRAIN_SECONDS - (hrtime(true) / 1e9 - $cancelAt)) * 1000); + if ($remainingMs <= 0) return; + $performStop = static function () use ($url, $payload, $apiKey, &$state, $remainingMs): bool { + $response = self::sendRequest(rtrim($url, '/') . '/' . rawurlencode($state['task_id']) . '/stop', + ['user' => (string) ($payload['user'] ?? '')], $apiKey, 1, + ['timeout_ms' => min(1000, $remainingMs)]); + $body = json_decode((string) ($response['body'] ?? ''), true); + return ($response['errno'] ?? -1) === 0 && ($response['http_code'] ?? 0) === 200 + && ($body['result'] ?? '') === 'success'; + }; + if (is_callable($options['on_cancel_task'] ?? null)) { + // Both workers use the same atomic task claim; a pending owner + // keeps this generation connection alive while the peer stops it. + $outcome = ($options['on_cancel_task'])($state['task_id'], $performStop); + $stopAccepted = $stopAccepted || (is_array($outcome) && !empty($outcome['accepted'])); + } elseif (!$stopAttempted) { + $stopAttempted = true; + $stopAccepted = $performStop(); + } + }; + $abort = static function () use ($shouldAbort, $protocol, &$state, &$cancelAt, $attemptStop): bool { + if ($cancelAt === null && !$state['callback_error'] && ($shouldAbort === null || !$shouldAbort())) return false; + $cancelAt = $cancelAt ?? hrtime(true) / 1e9; + $state['client_aborted'] = true; + $state['discard_deltas'] = true; + if ($protocol !== 'dify') return true; + // One absolute cancellation budget includes stop I/O and metadata + // discovery. Silent headers/body cannot prolong it: the multi pump + // polls even when libcurl's progress callback has no network events. + $budget = $state['task_id'] === '' ? self::CANCEL_METADATA_SECONDS : self::CANCEL_DRAIN_SECONDS; + if (hrtime(true) / 1e9 - $cancelAt >= $budget) return true; + $attemptStop(); + // workflow_finished confirms execution state, but Dify can still + // yield message_end and persist cleanup after that frame. Preserve + // the connection until natural EOF or this same absolute budget. + return $state['callback_error'] || hrtime(true) / 1e9 - $cancelAt >= $budget; + }; + $forward = static function (string $delta) use ($shouldAbort, $onDelta, &$state, &$cancelAt): bool { + if ($cancelAt !== null || ($shouldAbort !== null && $shouldAbort())) { + $state['discard_deltas'] = true; + return true; // Consume metadata/terminal frames without accumulating cancelled text. + } + $accepted = $onDelta($delta) !== false; + if (!$accepted && $shouldAbort !== null && $shouldAbort()) { + $state['discard_deltas'] = true; + return true; + } + return $accepted; + }; $responseCode = 0; - $header = static function ($handle, string $line) use (&$responseCode): int { + $header = static function ($handle, string $line) use (&$responseCode, $markSubmitted): int { if (preg_match('/^HTTP\/\S+\s+(\d{3})(?:\s|$)/i', trim($line), $matches) === 1) { $responseCode = (int) $matches[1]; + if ($responseCode >= 200 && $responseCode < 300) $markSubmitted(); } return strlen($line); }; @@ -836,24 +1056,21 @@ class DifyChatService &$buffer, &$state, &$responseCode, - $onDelta, - $shouldAbort + $forward, + $abort ): int { - if ($shouldAbort !== null && $shouldAbort()) { - $state['client_aborted'] = true; - return 0; - } if ($responseCode < 200 || $responseCode >= 300) { // Never decode or forward an error response body. Besides preventing // leakage, this keeps 404/405 protocol fallback side-effect free. return strlen($chunk); } - self::consumeStreamBytes($protocol, $buffer, $chunk, $state, $onDelta); + self::consumeStreamBytes($protocol, $buffer, $chunk, $state, $forward); + if ($abort()) { $state['client_aborted'] = true; return 0; } return $state['callback_error'] ? 0 : strlen($chunk); }; - $progress = static function (...$unused) use (&$state, $shouldAbort): int { - if ($shouldAbort !== null && $shouldAbort()) { - $state['client_aborted'] = true; + $progress = static function (...$values) use ($abort, $markSubmitted): int { + if (($values[3] ?? 0) > 0 && ($values[4] ?? 0) >= $values[3]) $markSubmitted(); + if ($abort()) { return 1; } return 0; @@ -883,15 +1100,40 @@ class DifyChatService } curl_setopt_array($ch, $curlOptions); - curl_exec($ch); - $errno = curl_errno($ch); - $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - - if (!$state['client_aborted'] && !$state['callback_error']) { - self::consumeStreamBytes($protocol, $buffer, '', $state, $onDelta, true); + $errno = -3; + $httpCode = $responseCode; + try { + if ($shouldAbort !== null) { + $errno = self::executeCancellableStream($ch, $abort); + } else { + curl_exec($ch); + $errno = curl_errno($ch); + } + $httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + } catch (\Throwable $ignored) { + // Observer/storage errors must fail closed and still stop a known task. + $state['callback_error'] = true; + } finally { + // A callback failure may escape libcurl before the regular cancel + // poll. Make the coordinated stop attempt before closing this handle. + try { $abort(); } catch (\Throwable $ignored) {} + curl_close($ch); } + if (!$state['callback_error']) { + try { self::consumeStreamBytes($protocol, $buffer, '', $state, $forward, true); } + catch (\Throwable $ignored) { $state['callback_error'] = true; } + } + $cancelled = $state['client_aborted'] || $state['callback_error']; + try { $cancelled = $cancelled || ($shouldAbort !== null && $shouldAbort()); } + catch (\Throwable $ignored) { $cancelled = true; } + if ($cancelled) { + $state['client_aborted'] = true; + if (is_callable($options['on_stop'] ?? null)) ($options['on_stop'])($stopAccepted, $state['task_id']); + } + if (is_callable($options['on_transfer_end'] ?? null)) ($options['on_transfer_end'])($state['task_id']); + if (!$cancelled && ($errno !== 0 || $httpCode >= 400)) $notify('failed'); + return [ 'errno' => $errno, 'http_code' => $httpCode, @@ -903,9 +1145,39 @@ class DifyChatService 'client_aborted' => $state['client_aborted'], 'callback_error' => $state['callback_error'], 'finished' => $state['finished'], + 'upstream_terminal_confirmed' => $state['upstream_terminal_confirmed'], + 'upstream_terminal_status' => $state['upstream_terminal_status'], ]; } + /** Poll cancellation at most 50ms apart, including a silent upstream. */ + private static function executeCancellableStream($ch, callable $abort): int + { + $multi = curl_multi_init(); + $added = false; + $errno = 0; + try { + if (curl_multi_add_handle($multi, $ch) !== CURLM_OK) return -3; + $added = true; + do { + if ($abort()) return CURLE_ABORTED_BY_CALLBACK; + do { $result = curl_multi_exec($multi, $running); } + while ($result === CURLM_CALL_MULTI_PERFORM); + while (($info = curl_multi_info_read($multi)) !== false) { + if (($info['handle'] ?? null) === $ch) $errno = (int) $info['result']; + } + if ($result !== CURLM_OK) return -3; + if (!$running) break; + if ($abort()) return CURLE_ABORTED_BY_CALLBACK; + if (curl_multi_select($multi, 0.05) === -1) usleep(1000); + } while (true); + return $errno; + } finally { + if ($added) curl_multi_remove_handle($multi, $ch); + curl_multi_close($multi); + } + } + /** * @return array{ * content:string,message_id:string,emitted:bool,upstream_error:bool, @@ -923,6 +1195,10 @@ class DifyChatService 'client_aborted' => false, 'callback_error' => false, 'finished' => false, + 'task_id' => '', + 'discard_deltas' => false, + 'upstream_terminal_confirmed' => false, + 'upstream_terminal_status' => '', ]; } @@ -980,7 +1256,7 @@ class DifyChatService array &$state, callable $onDelta ): void { - if ($state['finished'] || $state['upstream_error'] || $state['callback_error']) { + if ($state['callback_error']) { return; } @@ -1009,7 +1285,31 @@ class DifyChatService $delta = ''; if ($protocol === 'dify') { + $taskId = $decoded['task_id'] ?? ''; + if (is_string($taskId) && preg_match('/^[a-zA-Z0-9_-]{1,128}$/', $taskId) === 1) { + // One HTTP generation owns one task. Ignore foreign late metadata. + if ($state['task_id'] !== '' && $taskId !== $state['task_id']) return; + if ($state['task_id'] === '') { + $state['task_id'] = $taskId; + if (is_callable($state['on_task'] ?? null)) ($state['on_task'])($taskId); + } + } $event = strtolower((string) ($decoded['event'] ?? '')); + if ($event === 'workflow_finished') { + $rawStatus = $decoded['data']['status'] ?? ''; + $status = is_string($rawStatus) ? strtolower($rawStatus) : ''; + if ($status === 'cancelled') $status = 'stopped'; + if (!$state['upstream_terminal_confirmed'] && $state['task_id'] !== '' + && in_array($status, ['stopped', 'succeeded', 'failed'], true)) { + $state['upstream_terminal_confirmed'] = true; + $state['upstream_terminal_status'] = $status; + if (is_callable($state['on_terminal'] ?? null)) ($state['on_terminal'])($state['task_id'], $status); + } + return; + } + // Keep observing workflow_finished even after message_end; the latter + // alone is stream completion, never proof that a workflow stopped. + if ($state['finished'] || $state['upstream_error']) return; if ($event === 'message_end') { $state['message_id'] = (string) ($decoded['message_id'] ?? $state['message_id']); $state['finished'] = true; @@ -1027,6 +1327,7 @@ class DifyChatService $delta = is_string($decoded['answer'] ?? null) ? $decoded['answer'] : ''; $state['message_id'] = (string) ($decoded['message_id'] ?? $state['message_id']); } else { + if ($state['finished'] || $state['upstream_error']) return; $delta = self::extractStreamDelta($decoded); $state['message_id'] = (string) ($decoded['id'] ?? $state['message_id']); } @@ -1044,6 +1345,7 @@ class DifyChatService $state['callback_error'] = true; return; } + if ($state['discard_deltas']) return; $state['content'] .= $delta; $state['emitted'] = true; } diff --git a/server/research/ai_assistant_cancel_review_fixes.md b/server/research/ai_assistant_cancel_review_fixes.md new file mode 100644 index 000000000..835894243 --- /dev/null +++ b/server/research/ai_assistant_cancel_review_fixes.md @@ -0,0 +1,50 @@ +# AI 助手取消最终审查修正 + +2026-10-22。仅修改 `server/`,未联网、未访问真实模型/患者、未部署、未提交 commit。对应 `app/artifacts/ai_concurrency/cancel_status_final_review.md` 的服务端 P2 #1 / #2;本说明为原交付报告的补充。 + +## #1:strict_files 与取消组合 + +`streamChat()` 现在与既有 blocking 路径遵守相同严格附件契约: + +- 严格模式关闭 URL 去重,保留同一地址的不同逻辑附件。 +- 无效或超限附件在上游请求前返回 `STRICT_FILES_INVALID_OR_LIMIT`。 +- 使用单一附件 attempt,不做移除附件的文本降级。 +- 每个协议尝试前检查附件类型能力;OpenAI compatible 不支持的文档返回 `FILE_TYPE_UNSUPPORTED`,不把原始文档遗漏后返回成功。 +- 成功返回真实 `transmitted_file_count` 与 `attachment_transport`。既有“不丢 query 临床正文”的空 inputs 兼容行为保留;其附件列表完整不变。 + +普通 `chat(..., ['should_abort'=>...])` 仍内部使用能取得 Dify task_id 的真正流式传输,准备阶段证据提取的知识禁用及取消逻辑保持原样。 + +## #2:真实 blocking 兼容请求与取消边界 + +保留首字前一次 blocking-only 网关兼容能力。`DiagnosisAiLogic` 的兼容重试显式传服务端 `blocking_compatibility=true`,`chat()` 在这个专用分支实际发送 `response_mode=blocking`,不再重定向为第二次 streaming。正常可取消 chat、资料预处理继续使用原有内部 streaming。 + +native blocking cURL 加入同一取消回调:请求正文上传和等待响应时均可通过进度回调关闭传输;进入任何协议/附件/inputs 重试前,以及传输返回后,都检查取消。取消返回 `AI_ASSISTANT_CANCELLED`,不生成后续请求或临床 delta。blocking 的实际提交状态沿用现有真实 progress 格式。 + +**Dify blocking 没有流式 task_id,因此该专用兼容请求在取消时只能关闭本端传输,不能确认远端停止。** `on_stop(false)` 明确保留 `upstream_stop_confirmed=false`;`on_transfer_end()` 表示传输已结束。不会伪造 task_id、调用虚假的 stop 或声称供应商 GPU 已停止。普通 streaming 路径已取得 task_id 时的真实 stop 调用没有改变。 + +## 离线回归 + +新增 `server/tests/DiagnosisAiAssistantStrictAndCompatibilityIntegrationTest.php`:使用真实 `DiagnosisAiLogic` 与 `DifyChatService`,仅 namespaced cURL fake,无服务类 stub、无网络。 + +断言覆盖: + +- strict+abort 的无效/超限请求零传输;逻辑附件重复 URL 保留;Dify/OpenAI 图片成功元数据准确。 +- 严格附件被拒后不发无附件请求、不返回假成功。 +- OpenAI 文档在请求前被拒;Dify 路径不存在后的协议回退也不会把文档遗漏后返回成功。 +- streaming 被拒、blocking 可用的网关,真实 wire modes 严格为 `[streaming,blocking]`,返回完整回答。 +- blocking 兼容请求中取消时,真实 wire modes 仍只有上述两次,两个 cURL 均关闭,没有 delta/后续重试,stop 回调仅为 false、错误码为取消。 + +本轮共运行8组相关回归,均通过: + +1. `DiagnosisAiAssistantStrictAndCompatibilityIntegrationTest.php` +2. `DiagnosisAiAssistantRetryBudgetTest.php` +3. `DiagnosisAiAssistantTransportCancellationTest.php` +4. `DiagnosisAiAssistantStreamContractTest.php` +5. `DifyChatStreamContractTest.php` +6. `DifyChatStreamDeliveryTest.php` +7. `PrescriptionAiUpstreamContractTest.php` +8. `PrescriptionAiSecretSafetyTest.php` + +两个变更源文件与新增测试 `php -l` 通过;`git diff --check -- server` 通过。没有扩大并发或真实上游测试,根代理此前真实合成基准不作为本轮新增离线回归的替代。 + +本轮产品代码只涉及:`server/app/common/service/DifyChatService.php`、`server/app/adminapi/logic/tcm/DiagnosisAiLogic.php`;新增上述测试与本说明。原审查文本保留作为历史发现记录,由根代理更新整体交付结论。 diff --git a/server/tests/AiContextCompactionLatencyTest.php b/server/tests/AiContextCompactionLatencyTest.php index 2a440d1a2..44f608ccf 100644 --- a/server/tests/AiContextCompactionLatencyTest.php +++ b/server/tests/AiContextCompactionLatencyTest.php @@ -111,5 +111,22 @@ namespace { $short['case_text'] = str_repeat('a', 120000); $copy = $short; compactExpect($apply($short) && $short === $copy, 'exact threshold keeps the source untouched'); + Cache::$items = []; + $compactionConfig['context_compaction_cache_ttl'] = 300; + DifyChatService::$calls = []; + $cancelledContext = $original; + $progress = []; + $cancelOptions = ['should_abort' => static fn (): bool => count(DifyChatService::$calls) >= 1, + 'on_cancel_task' => static fn (string $taskId, callable $stop): array => ['accepted' => false, 'pending' => true], + 'on_terminal' => static function (string $taskId, string $status): void {}, + 'on_progress' => static function (array $event) use (&$progress): void { $progress[] = $event; }]; + compactExpect(!$fit->invokeArgs(null, [&$cancelledContext, 'qwen', new AssistantSseTiming(), $cancelOptions]), 'cancel during preprocessing terminates preparation'); + compactExpect(count(DifyChatService::$calls) === 1 && Cache::$items === [] && $cancelledContext === $original, 'cancelled partial evidence never enters cache or final prompt'); + compactExpect(DifyChatService::$calls[0]['options']['knowledge_mode'] === 'evidence' + && is_callable(DifyChatService::$calls[0]['options']['should_abort']) + && DifyChatService::$calls[0]['options']['on_cancel_task'] === $cancelOptions['on_cancel_task'] + && DifyChatService::$calls[0]['options']['on_terminal'] === $cancelOptions['on_terminal'], + 'intermediate evidence request preserves abort, shared stop and terminal observers'); + compactExpect(count($progress) === 1 && $progress[0]['completed'] === 0 && $progress[0]['total'] === 2, 'chunk progress counts only completed evidence results'); echo "AI context compaction latency and isolation: OK\n"; } diff --git a/server/tests/DiagnosisAiAssistantCancellationDrainTest.php b/server/tests/DiagnosisAiAssistantCancellationDrainTest.php new file mode 100644 index 000000000..52929d26a --- /dev/null +++ b/server/tests/DiagnosisAiAssistantCancellationDrainTest.php @@ -0,0 +1,201 @@ + stream_socket_get_name($listener, false)]) . "\n"; + flush(); + $clients = []; $generation = null; $generationAt = null; $terminalAt = null; $tailAt = null; + $earlySent = false; $taskSent = false; $done = false; + $metrics = ['stop_count' => 0, 'generation_open_on_stop' => false, 'title_disabled' => false, + 'inputs_object' => false, 'generation_eof' => false, 'terminal_tail_sent' => false, + 'closed_before_terminal_tail' => false]; + $deadline = microtime(true) + 8; + while (!$done && microtime(true) < $deadline) { + while (($socket = @stream_socket_accept($listener, 0)) !== false) { + stream_set_blocking($socket, false); + $clients[(int) $socket] = ['socket' => $socket, 'buffer' => '', 'handled' => false]; + } + foreach (array_keys($clients) as $key) { + $socket = $clients[$key]['socket']; + $chunk = @fread($socket, 65536); + if ($chunk === '' && feof($socket)) { + if ($generation === $key) { + $metrics['generation_eof'] = true; + $metrics['closed_before_terminal_tail'] = $tailAt !== null; + $done = true; + } + fclose($socket); unset($clients[$key]); continue; + } + if ($clients[$key]['handled']) continue; + $clients[$key]['buffer'] .= is_string($chunk) ? $chunk : ''; + $parts = explode("\r\n\r\n", $clients[$key]['buffer'], 2); + if (count($parts) !== 2) continue; + preg_match('/Content-Length:\s*(\d+)/i', $parts[0], $length); + if (strlen($parts[1]) < (int) ($length[1] ?? 0)) continue; + $clients[$key]['handled'] = true; + $payload = json_decode($parts[1]); + if (preg_match('#^POST /v1/chat-messages/synthetic-drain-task/stop #', $parts[0])) { + ++$metrics['stop_count']; + $metrics['generation_open_on_stop'] = $generation !== null && isset($clients[$generation]); + $metrics['user_matches'] = ($payload->user ?? '') === 'synthetic-drain-user'; + $body = '{"result":"success"}'; + fwrite($socket, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " + . strlen($body) . "\r\nConnection: close\r\n\r\n" . $body); + fclose($socket); unset($clients[$key]); + if (in_array($scenario, ['late_metadata', 'terminal_tail', 'terminal_silent'], true)) $terminalAt = microtime(true) + 0.08; + } else { + $generation = $key; $generationAt = microtime(true); + $metrics['title_disabled'] = ($payload->auto_generate_name ?? null) === false; + $metrics['inputs_object'] = is_object($payload->inputs ?? null); + if ($scenario !== 'silent_headers') { + fwrite($socket, "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n"); + } + if (in_array($scenario, ['no_terminal', 'terminal_tail', 'terminal_silent'], true)) { + fwrite($socket, "data: {\"event\":\"workflow_started\",\"task_id\":\"synthetic-drain-task\"}\n\n"); + $taskSent = true; + } + } + } + if ($generation !== null && isset($clients[$generation])) { + $socket = $clients[$generation]['socket']; + if ($scenario === 'late_metadata' && !$earlySent && microtime(true) - $generationAt >= 0.12) { + // Cancellation has already been requested, but task metadata is + // still absent. This body must not short-circuit the grace. + fwrite($socket, "data: {\"event\":\"message\",\"answer\":\"discard-before-task\"}\n\n"); + $earlySent = true; + } + if ($scenario === 'late_metadata' && !$taskSent && microtime(true) - $generationAt >= 0.20) { + fwrite($socket, "data: {\"event\":\"workflow_started\",\"task_id\":\"synthetic-drain-task\"}\n\n"); + $taskSent = true; + } + if ($terminalAt !== null && microtime(true) >= $terminalAt) { + if (in_array($scenario, ['terminal_tail', 'terminal_silent'], true)) { + fwrite($socket, "data: {\"event\":\"workflow_finished\",\"task_id\":\"synthetic-drain-task\",\"data\":{\"status\":\"stopped\"}}\n\n"); + $terminalAt = null; + if ($scenario === 'terminal_tail') $tailAt = microtime(true) + 0.20; + continue; + } + fwrite($socket, "data: {\"event\":\"message\",\"task_id\":\"synthetic-drain-task\",\"answer\":\"discard-tail\"}\n\n" + . "data: {\"event\":\"message_end\"}\n\n" + . "data: {\"event\":\"workflow_finished\",\"task_id\":\"synthetic-drain-task\",\"data\":{\"status\":\"stopped\"}}\n\n"); + fclose($socket); unset($clients[$generation]); $done = true; + } + if ($tailAt !== null && microtime(true) >= $tailAt) { + $metrics['terminal_tail_sent'] = true; + fwrite($socket, "data: {\"event\":\"message_end\"}\n\n"); + $tailAt = null; + fclose($socket); unset($clients[$generation]); $done = true; + } + } + usleep(1000); + } + foreach ($clients as $client) fclose($client['socket']); + fclose($listener); + echo json_encode($metrics) . "\n"; + exit($done ? 0 : 3); + } + + $directory = sys_get_temp_dir() . '/zyt-drain-' . bin2hex(random_bytes(8)); + $rows = []; + try { + foreach (['late_metadata', 'terminal_tail', 'terminal_silent', 'no_terminal', 'silent_headers', 'silent_body'] as $index => $scenario) { + $process = proc_open([PHP_BINARY, __FILE__, 'server', $scenario], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + drainExpect(is_resource($process), 'loopback server starts'); + fclose($pipes[0]); stream_set_timeout($pipes[1], 10); + $ready = json_decode((string) fgets($pipes[1]), true); + drainExpect(isset($ready['address']), 'loopback server reports its ephemeral port'); + $drainConfig = ['enable' => true, 'base_url' => 'http://' . $ready['address'] . '/v1/chat-messages', + 'timeout' => 6, 'models' => ['qwen' => ['name' => 'synthetic', 'api_key' => 'loopback-only']]]; + $requestId = sprintf('12345678-1234-4234-8234-%012d', $index); + $control = new AssistantRequestControl(7, 11, $requestId, $directory); + drainExpect($control->begin(), 'fresh synthetic control begins'); + $transferAt = null; $cancelAt = null; $deltas = []; $terminals = []; + $abort = static function () use ($control, &$transferAt, &$cancelAt): bool { + if ($transferAt !== null && $cancelAt === null && hrtime(true) / 1e9 - $transferAt >= 0.08) { + $cancelAt = hrtime(true) / 1e9; + $control->signalCancel(); + } + return $control->shouldAbort(); + }; + $result = DifyChatService::streamChat('qwen', [], '合成取消测试,无真实患者', 'synthetic-drain-user', + static function (string $text) use (&$deltas): bool { $deltas[] = $text; return true; }, $abort, [], [ + 'knowledge_mode' => 'evidence', + 'on_progress' => static function (array $event) use (&$transferAt): void { + if ($transferAt === null) $transferAt = hrtime(true) / 1e9; + }, + 'on_task' => static fn (string $taskId) => $control->bindTask('qwen', 'synthetic-drain-user', $taskId), + 'on_cancel_task' => static function (string $taskId, callable $stop) use ($control): array { + $control->signalCancel(); return $control->stopBoundTask($stop, $taskId); + }, + 'on_terminal' => static function (string $taskId, string $status) use ($control, &$terminals): void { + $terminals[] = $status; $control->markTerminal($taskId, $status); + }, + 'on_stop' => static fn (bool $accepted, string $taskId) => $control->markStop($accepted, $taskId), + 'on_transfer_end' => static fn (string $taskId) => $control->clearTask($taskId !== '' ? $taskId : null), + ]); + $cancelMs = (hrtime(true) / 1e9 - $cancelAt) * 1000; + $control->finish('cancelled'); + $state = $control->publicState(); + $peer = json_decode((string) fgets($pipes[1]), true); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); fclose($pipes[2]); + drainExpect(proc_close($process) === 0 && $stderr === '', $scenario . ': loopback peer exits cleanly'); + drainExpect(($result['error_code'] ?? '') === 'AI_ASSISTANT_CANCELLED' && $deltas === [], + $scenario . ': cancellation delivers no body and returns cancelled'); + drainExpect($peer['title_disabled'] && $peer['inputs_object'], 'real wire disables titles and preserves input object'); + drainExpect($cancelMs < 2900, $scenario . ': absolute cancellation budget bounds a silent upstream'); + if (in_array($scenario, ['late_metadata', 'terminal_tail'], true)) { + drainExpect($peer['stop_count'] === 1 && $peer['generation_open_on_stop'] && $peer['user_matches'], + 'known task stops once before the generation closes using its exact user'); + drainExpect($terminals === ['stopped'] && $state['upstream_terminal_confirmed'] + && $state['upstream_terminal_status'] === 'stopped', 'discarded tails still expose workflow terminal metadata'); + if ($scenario === 'terminal_tail') { + drainExpect($peer['terminal_tail_sent'] && !$peer['closed_before_terminal_tail'], + 'workflow_finished preserves the connection through delayed message_end and natural EOF'); + } + } elseif ($scenario === 'terminal_silent') { + drainExpect($peer['stop_count'] === 1 && $state['upstream_terminal_confirmed'] + && $peer['generation_eof'] && $cancelMs >= 2400, + 'terminal followed by silence exits on the original budget without repeated stop'); + } elseif ($scenario === 'no_terminal') { + drainExpect($peer['stop_count'] === 1 && $state['upstream_stop_confirmed'] + && !$state['upstream_terminal_confirmed'] && $peer['generation_eof'], + 'stop acceptance without terminal metadata remains distinct after bounded draining'); + drainExpect($cancelMs >= 2400, 'known task gets its bounded terminal observation window'); + } else { + drainExpect($peer['stop_count'] === 0 && !$state['upstream_stop_confirmed'] + && !$state['upstream_terminal_confirmed'] && $peer['generation_eof'], + 'unknown task closes transport without inventing upstream stop or terminal confirmation'); + drainExpect($cancelMs >= 1900 && $cancelMs < 2300, 'unknown metadata grace is bounded to two seconds'); + } + $rows[] = ['scenario' => $scenario, 'cancel_ms' => round($cancelMs, 2), 'stop_count' => $peer['stop_count']]; + } + echo 'Diagnosis AI real loopback cancel drain / terminal / silent bounds: OK ' . json_encode($rows) . "\n"; + } finally { + foreach (glob($directory . '/*') ?: [] as $path) unlink($path); + if (is_file($directory . '/.gc.lock')) unlink($directory . '/.gc.lock'); + if (is_dir($directory)) rmdir($directory); + } +} diff --git a/server/tests/DiagnosisAiAssistantCancellationTest.php b/server/tests/DiagnosisAiAssistantCancellationTest.php new file mode 100644 index 000000000..8faedc799 --- /dev/null +++ b/server/tests/DiagnosisAiAssistantCancellationTest.php @@ -0,0 +1,87 @@ +requestCancel()); + else echo json_encode(['began' => $control->begin()]); + exit; +} + +$directory = sys_get_temp_dir() . '/zyt-assistant-control-' . bin2hex(random_bytes(8)); +$id = '12345678-1234-4234-8234-123456789abc'; +$make = static fn (int $admin = 7, int $diagnosis = 11): AssistantRequestControl => new AssistantRequestControl($admin, $diagnosis, $id, $directory); +try { + $control = $make(); + $tombstone = $control->requestCancel(); + cancelExpect($tombstone['status'] === 'cancel_requested' && !$tombstone['transport_closed'] && !$tombstone['upstream_stop_confirmed'], 'early cancel is only requested'); + cancelExpect($control->begin() && $control->shouldAbort(), 'prepare preserves cancellation that arrived before begin'); + cancelExpect(!$control->begin(), 'same request id cannot reenter or resubmit'); + cancelExpect(!$make(8)->shouldAbort() && !$make(7, 12)->shouldAbort(), 'admin and canonical diagnosis isolate cancellation'); + $control->bindTask('qwen', 'synthetic-user', 'synthetic-task'); + $calls = 0; + $stop = static function (array $task) use (&$calls): bool { ++$calls; return $task['task_id'] === 'synthetic-task'; }; + cancelExpect($control->requestCancel($stop)['upstream_stop_confirmed'], 'bound task can be stopped by a different control instance'); + $make()->requestCancel($stop); + cancelExpect($calls === 1, 'confirmed stop is idempotent'); + $control->finish('cancelled'); + cancelExpect($control->publicState()['transport_closed'], 'owning worker alone confirms transfer has ended'); + cancelExpect(!str_contains(json_encode($control->publicState()), 'synthetic-task'), 'public status excludes task identity'); + + $id2 = '22345678-1234-4234-8234-123456789abc'; + $control2 = new AssistantRequestControl(7, 11, $id2, $directory); + cancelExpect($control2->begin(), 'fresh request begins'); + $run = static function (string $mode, string $requestId) use ($directory): array { + $pipes = []; + $process = proc_open([PHP_BINARY, __FILE__, 'worker', $mode, $directory, $requestId], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (!is_resource($process)) throw new RuntimeException('Cannot start independent PHP worker'); + fclose($pipes[0]); + return [$process, $pipes]; + }; + [$process, $pipes] = $run('cancel', $id2); + $status = json_decode(stream_get_contents($pipes[1]), true); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); fclose($pipes[2]); + cancelExpect(proc_close($process) === 0 && $stderr === '' && $status['cancel_requested'], 'independent PHP process accepts cancellation'); + cancelExpect($control2->shouldAbort(), 'original process observes cancellation through flock state'); + + $id3 = '32345678-1234-4234-8234-123456789abc'; + $workers = []; + for ($i = 0; $i < 4; ++$i) $workers[] = $run('begin', $id3); + $began = 0; + foreach ($workers as [$process, $pipes]) { + $result = json_decode(stream_get_contents($pipes[1]), true); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); fclose($pipes[2]); + cancelExpect(proc_close($process) === 0 && $stderr === '', 'racing worker terminates cleanly'); + $began += (int) $result['began']; + } + cancelExpect($began === 1, 'flock allows exactly one of four independent workers to begin'); + + $path = $directory . '/' . hash('sha256', '7:11:' . $id3) . '.json'; + file_put_contents($path, json_encode(['begun' => true, 'expires_at' => time() - 1])); + touch($path, time() - 700); + new AssistantRequestControl(7, 11, $id3, $directory); + cancelExpect(!is_file($path), 'expired request files are reclaimed under a directory guard'); + + $alias = new ReflectionMethod(AuthMiddleware::class, 'matchPermissionAlias'); + cancelExpect($alias->invoke(new AuthMiddleware(), 'tcm.diagnosis/cancelaiassistant', ['tcm.diagnosis/aiassistant']), 'cancel action uses existing assistant menu permission'); + cancelExpect(!$alias->invoke(new AuthMiddleware(), 'tcm.diagnosis/cancelaiassistant', []), 'cancel alias denies ungranted administrator'); + cancelExpect(!AssistantRequestControl::validRequestId('../secret') && !AssistantRequestControl::validRequestId('arbitrary'), 'only UUID identities are accepted'); + echo "Diagnosis AI assistant cancellation / independent Windows PHP workers: OK\n"; +} finally { + foreach (glob($directory . '/*') ?: [] as $path) unlink($path); + if (is_file($directory . '/.gc.lock')) unlink($directory . '/.gc.lock'); + if (is_dir($directory)) rmdir($directory); +} diff --git a/server/tests/DiagnosisAiAssistantPreparationLifecycleTest.php b/server/tests/DiagnosisAiAssistantPreparationLifecycleTest.php new file mode 100644 index 000000000..08fce62bd --- /dev/null +++ b/server/tests/DiagnosisAiAssistantPreparationLifecycleTest.php @@ -0,0 +1,67 @@ + 5, 'models' => ['qwen' => ['name' => 'synthetic', 'label' => '合成模型']]]; + } + class DiagnosisLogic { + public static function detail($params, $adminInfo): array { + $GLOBALS['lifecycle'][] = 'diagnosis_loaded'; + return ['id' => $params['id'], 'patient_id' => 99, 'gender' => '女', 'age' => 42]; + } + } + class PatientAiReportLogic { + public static function contextForAuthorizedDiagnosis($diagnosis, $adminId, $adminInfo): array { + $GLOBALS['lifecycle'][] = 'aggregate'; + if (!empty($GLOBALS['cancelInAggregate'])) $GLOBALS['lifecycleCancelled'] = true; + return ['snapshot' => ['clinical_source' => '合成资料,无真实患者'], + 'source_summary' => ['diagnosis_count' => 2, 'doctor_note_count' => 3, 'snapshot_complete' => true, 'may_be_truncated' => false], + 'source_diagnosis_ids' => [11, 12], 'files' => []]; + } + } +} + +namespace { + require dirname(__DIR__) . '/vendor/autoload.php'; + use app\adminapi\logic\tcm\DiagnosisAiLogic; + use app\adminapi\logic\firstvisit\MyPatientLogic; + function lifecycleExpect(bool $ok, string $message): void { if (!$ok) throw new RuntimeException($message); } + $lifecycle = []; $lifecycleCancelled = false; $cancelInAggregate = false; + $options = ['should_abort' => static fn (): bool => $GLOBALS['lifecycleCancelled'], + 'on_authorized' => static function (array $metadata): bool { + $GLOBALS['lifecycle'][] = 'start'; + lifecycleExpect($metadata === ['diagnosis_id' => 11, 'task' => 'summary', 'model_key' => 'qwen'], 'early start exposes only authorized identity and model key'); + return true; + }, + 'on_progress' => static function (array $event): void { $GLOBALS['lifecycle'][] = $event; }]; + $prepared = DiagnosisAiLogic::prepareAssistant(11, 'summary', '', 7, ['root' => 1], null, $options); + lifecycleExpect(is_array($prepared), 'authorized synthetic preparation succeeds'); + lifecycleExpect(array_slice($lifecycle, 0, 5) === ['scope_checked', 'diagnosis_loaded', 'start', + ['phase' => 'preparing', 'status' => 'aggregating', 'message' => '正在聚合本次授权范围内的患者资料'], 'aggregate'], 'start and truthful preparation progress occur after scope but before aggregation'); + lifecycleExpect($prepared['source_form'] === 'full_context' && $prepared['source_summary']['doctor_note_count'] === 3, 'real aggregate counts and full source form are preserved'); + lifecycleExpect(end($lifecycle)['status'] === 'ready', 'ready occurs after successful aggregation'); + $lifecycle = []; + MyPatientLogic::$allowed = false; + lifecycleExpect(DiagnosisAiLogic::prepareAssistant(11, 'summary', '', 7, ['root' => 1], null, $options) === null + && $lifecycle === ['scope_checked'], 'denied DataScope produces no start/progress or aggregation'); + lifecycleExpect(DiagnosisAiLogic::cancelAssistant(11, '12345678-1234-4234-8234-123456789abc', 7, ['root' => 1]) === null + && DiagnosisAiLogic::getError() === '诊单不存在或无权访问', 'cancel has the same canonical DataScope restriction'); + MyPatientLogic::$allowed = true; + $lifecycle = []; $cancelInAggregate = true; + lifecycleExpect(DiagnosisAiLogic::prepareAssistant(11, 'summary', '', 7, ['root' => 1], null, $options) === null + && DiagnosisAiLogic::getAssistantErrorCode() === 'AI_ASSISTANT_CANCELLED', 'cancellation during aggregation blocks final prompt preparation'); + lifecycleExpect(end($lifecycle) === 'aggregate', 'cancelled aggregation never claims prepared or submitted'); + echo "Diagnosis AI assistant early start / DataScope / preparation lifecycle: OK\n"; +} diff --git a/server/tests/DiagnosisAiAssistantStopCoordinationTest.php b/server/tests/DiagnosisAiAssistantStopCoordinationTest.php new file mode 100644 index 000000000..8036bb59f --- /dev/null +++ b/server/tests/DiagnosisAiAssistantStopCoordinationTest.php @@ -0,0 +1,134 @@ +requestCancel(static function (array $task) use ($directory, $mode): bool { + file_put_contents($directory . '/calls', $task['task_id'] . "\n", FILE_APPEND | LOCK_EX); + if ($mode === 'abandon') exit(0); // Simulate a worker dying with its lease still held. + usleep(180000); + return true; + })); + exit; +} + +$directory = sys_get_temp_dir() . '/zyt-stop-coordination-' . bin2hex(random_bytes(8)); +$make = static function (int $number) use ($directory): AssistantRequestControl { + $control = new AssistantRequestControl(7, 11, sprintf('12345678-1234-4234-8234-%012d', $number), $directory); + stopExpect($control->begin(), 'fresh control begins'); + $control->bindTask('qwen', 'synthetic-stop-user', 'synthetic-stop-' . $number); + return $control; +}; +$launch = static function (string $mode, int $number) use ($directory): array { + $pipes = []; + $process = proc_open([PHP_BINARY, __FILE__, 'worker', $mode, $directory, + sprintf('12345678-1234-4234-8234-%012d', $number)], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + stopExpect(is_resource($process), 'independent stop worker launches'); + fclose($pipes[0]); + return [$process, $pipes]; +}; +$join = static function (array $worker): string { + [$process, $pipes] = $worker; + $stdout = stream_get_contents($pipes[1]); $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); fclose($pipes[2]); + stopExpect(proc_close($process) === 0 && $stderr === '', 'independent stop worker exits cleanly'); + return $stdout; +}; +try { + $control = $make(1); + $workers = []; + for ($index = 0; $index < 4; ++$index) $workers[] = $launch('race', 1); + file_put_contents($directory . '/gate', '1'); + foreach ($workers as $worker) $join($worker); + $calls = file($directory . '/calls', FILE_IGNORE_NEW_LINES); + stopExpect(count($calls) === 1 && $control->publicState()['upstream_stop_confirmed'], + 'four independent workers send exactly one accepted stop for the same task'); + stopExpect(!$control->publicState()['upstream_terminal_confirmed'], 'stop API acceptance does not imply workflow termination'); + $ownerCalls = 0; + $ownerOutcome = $control->stopBoundTask(static function () use (&$ownerCalls): bool { ++$ownerCalls; return true; }, 'synthetic-stop-1'); + stopExpect($ownerOutcome['accepted'] && $ownerCalls === 0, 'owner observes peer acceptance without a second HTTP stop'); + + $abandoned = $make(2); + $join($launch('abandon', 2)); + $takeovers = 0; + $compensate = static function () use (&$takeovers): bool { ++$takeovers; return true; }; + stopExpect($abandoned->stopBoundTask($compensate)['pending'] && $takeovers === 0, + 'an active claim lease blocks a duplicate stop'); + usleep(1300000); + stopExpect($abandoned->stopBoundTask($compensate)['accepted'] && $takeovers === 1, + 'an abandoned lease expires and allows one bounded takeover'); + + $failed = $make(3); $failed->signalCancel(); + $attempts = 0; + $reject = static function () use (&$attempts): bool { ++$attempts; return false; }; + stopExpect(!$failed->stopBoundTask($reject)['accepted'], 'a rejected stop remains unconfirmed'); + $failed->clearTask('synthetic-stop-3'); $failed->finish('cancelled'); + usleep(260000); + $retryState = $failed->requestCancel(static function () use (&$attempts): bool { ++$attempts; return true; }); + stopExpect($attempts === 2 && $retryState['upstream_stop_confirmed'] && $retryState['transport_closed'], + 'failed known task remains available for compensation after local transport termination'); + $failed->requestCancel($reject); + stopExpect($attempts === 2, 'accepted compensation stays idempotent'); + + $bounded = $make(4); $bounded->signalCancel(); $attempts = 0; + $bounded->stopBoundTask($reject); usleep(260000); $bounded->stopBoundTask($reject); + usleep(260000); $bounded->requestCancel($reject); + stopExpect($attempts === 2 && !$bounded->publicState()['upstream_stop_confirmed'], 'failure never creates an unbounded stop retry loop'); + + $expired = $make(5); $expired->signalCancel(); + $path = $directory . '/' . hash('sha256', '7:11:12345678-1234-4234-8234-000000000005') . '.json'; + $state = json_decode((string) file_get_contents($path), true); + $state['stop_retry_until'] = microtime(true) - 1; + file_put_contents($path, json_encode($state)); + $attempts = 0; $expired->requestCancel($reject); + stopExpect($attempts === 0, 'expired compensation window is not extended by repeated cancel requests'); + + $generation = $make(6); $generation->signalCancel(); + $oldResult = $generation->stopBoundTask(static function () use ($generation): bool { + $generation->bindTask('qwen', 'synthetic-stop-user', 'synthetic-next-generation'); + return true; + }, 'synthetic-stop-6'); + $generation->markStop(true, 'synthetic-stop-6'); + $generation->markTerminal('synthetic-stop-6', 'stopped'); + $generation->clearTask('synthetic-stop-6'); + stopExpect(!$oldResult['accepted'] && !$generation->publicState()['upstream_stop_confirmed'] + && !$generation->publicState()['upstream_terminal_confirmed'], 'late stop and terminal callbacks cannot poison the next task generation'); + $attempts = 0; + $generation->stopBoundTask($reject, 'synthetic-stop-6'); + stopExpect($attempts === 0, 'a stale owner cannot stop a different task generation'); + stopExpect($generation->stopBoundTask(static fn (): bool => true, 'synthetic-next-generation')['accepted'], + 'current task generation remains independently stoppable'); + $generation->markTerminal('synthetic-next-generation', 'running'); + stopExpect(!$generation->publicState()['upstream_terminal_confirmed'], 'running metadata is never accepted as terminal'); + $generation->markTerminal('synthetic-next-generation', 'stopped'); + $generation->markTerminal('synthetic-next-generation', 'failed'); + stopExpect($generation->publicState()['upstream_terminal_confirmed'] + && $generation->publicState()['upstream_terminal_status'] === 'stopped', 'only matching terminal metadata confirms the workflow state'); + $sibling = $make(7); + stopExpect(!$sibling->shouldAbort() && !$sibling->publicState()['upstream_stop_confirmed'], 'new request UUID is isolated from all old task callbacks'); + stopExpect(!str_contains(json_encode($generation->publicState()), 'synthetic-next-generation'), 'public state never discloses task identifiers'); + $legacy = $make(8); $legacy->signalCancel(); + $legacyPath = $directory . '/' . hash('sha256', '7:11:12345678-1234-4234-8234-000000000008') . '.json'; + $legacyState = json_decode((string) file_get_contents($legacyPath), true); + unset($legacyState['upstream']['generation']); + file_put_contents($legacyPath, json_encode($legacyState)); + stopExpect($legacy->stopBoundTask(static fn (): bool => true)['accepted'], 'in-flight task state from the previous release is upgraded before claiming'); + echo "Diagnosis AI stop claims / multi-process dedup / lease recovery / compensation / task generations: OK\n"; +} finally { + foreach (glob($directory . '/*') ?: [] as $path) unlink($path); + if (is_file($directory . '/.gc.lock')) unlink($directory . '/.gc.lock'); + if (is_dir($directory)) rmdir($directory); +} diff --git a/server/tests/DiagnosisAiAssistantStreamContractTest.php b/server/tests/DiagnosisAiAssistantStreamContractTest.php index a41c9d871..bdaf8ba71 100644 --- a/server/tests/DiagnosisAiAssistantStreamContractTest.php +++ b/server/tests/DiagnosisAiAssistantStreamContractTest.php @@ -69,13 +69,19 @@ assistantStreamExpect( ); $actionStart = strpos($controller, 'public function aiAssistantStream()'); -$checkAt = strpos($controller, "goCheck('aiAssistant')", $actionStart); +$checkAt = strpos($controller, "goCheck('aiAssistantStream')", $actionStart); $prepareAt = strpos($controller, 'DiagnosisAiLogic::prepareAssistant(', $actionStart); -$runAt = strpos($controller, '$this->runAssistantSse($prepared, $timing)', $actionStart); +$runAt = strpos($controller, '$this->runAssistantSse($prepared, $timing,', $actionStart); $headerAt = strpos($controller, "header('Content-Type: text/event-stream; charset=utf-8')", $actionStart); +$logicPrepareAt = strpos($logic, 'public static function prepareAssistant('); +$authorizeAt = strpos($logic, '$diagnosis = self::loadAuthorizedDiagnosis(', $logicPrepareAt); +$startCallbackAt = strpos($logic, "is_callable(\$options['on_authorized']", $authorizeAt); +$aggregateAt = strpos($logic, '$context = self::buildCaseContext(', $startCallbackAt); assistantStreamExpect( - $actionStart !== false && $checkAt > $actionStart && $prepareAt > $checkAt && $runAt > $prepareAt && $headerAt > $runAt, - 'request validation and authorized preparation occur before every SSE header' + $actionStart !== false && $checkAt > $actionStart && $prepareAt > $checkAt && $runAt > $prepareAt && $headerAt > $runAt + && $authorizeAt < $startCallbackAt && $startCallbackAt < $aggregateAt + && str_contains($controller, '$emit = $this->openAssistantSse();'), + 'request validation and diagnosis authorization precede early start; aggregation follows it' ); assistantStreamExpect( str_contains($validate, "return \$this->only(['id', 'task', 'prompt']);"), @@ -88,10 +94,20 @@ assistantStreamExpect( 'stream preparation reuses assistant permission and canonical diagnosis row authorization' ); assistantStreamExpect( - str_contains($auth, "\$accessUri === 'tcm.diagnosis/aiassistantstream'") + str_contains($auth, "['tcm.diagnosis/aiassistantstream', 'tcm.diagnosis/cancelaiassistant']") && str_contains($auth, "'tcm.diagnosis/aiassistant', \$adminUris"), 'middleware maps stream access to the old registered assistant permission' ); + +$progressProtocol = new AssistantSseProtocol(); +assistantStreamExpect($progressProtocol->encode('progress', ['phase' => 'preparing']) === null, 'progress cannot precede authenticated start'); +$progressProtocol->encode('start', []); +$progressFrame = parseAssistantSse((string) $progressProtocol->encode('progress', ['phase' => 'attachments', 'status' => 'submitted'])); +assistantStreamExpect($progressFrame['data']['seq'] === 2, 'real preparation and submission progress shares monotonic event sequence'); +$progressProtocol->encode('progress', ['phase' => 'cancelled', 'status' => 'cancelled']); +$cancelFrame = parseAssistantSse((string) $progressProtocol->encode('error', ['code' => 'AI_ASSISTANT_CANCELLED'])); +assistantStreamExpect($cancelFrame['data']['seq'] === 4 && $progressProtocol->encode('done', []) === null + && $progressProtocol->encode('progress', []) === null, 'cancellation has exactly one terminal event and no late success or progress'); $matchPermissionAlias = (new ReflectionClass(AuthMiddleware::class))->getMethod('matchPermissionAlias'); $authMiddleware = new AuthMiddleware(); assistantStreamExpect( diff --git a/server/tests/DiagnosisAiAssistantStrictAndCompatibilityIntegrationTest.php b/server/tests/DiagnosisAiAssistantStrictAndCompatibilityIntegrationTest.php new file mode 100644 index 000000000..b04fcaf68 --- /dev/null +++ b/server/tests/DiagnosisAiAssistantStrictAndCompatibilityIntegrationTest.php @@ -0,0 +1,125 @@ +options = $options; + $handle->url = $options[CURLOPT_URL]; + $handle->payload = json_decode($options[CURLOPT_POSTFIELDS], true); + return true; + } + function curl_exec(\stdClass $handle) { + $payload = $handle->payload; + $streaming = ($payload['response_mode'] ?? '') === 'streaming' || !empty($payload['stream']); + $GLOBALS['integrationRequests'][] = ['url' => $handle->url, 'payload' => $payload, 'streaming' => $streaming]; + $scenario = $GLOBALS['integrationScenario']; + if (($scenario === 'blocking_only' || $scenario === 'cancel_blocking') && $streaming) $handle->code = 400; + elseif ($scenario === 'file_rejected' && !empty($payload['files'])) $handle->code = 400; + elseif ($scenario === 'protocol_fallback' && str_ends_with($handle->url, '/chat-messages')) $handle->code = 404; + else $handle->code = 200; + if (isset($handle->options[CURLOPT_HEADERFUNCTION])) { + ($handle->options[CURLOPT_HEADERFUNCTION])($handle, 'HTTP/1.1 ' . $handle->code . " Synthetic\r\n"); + } + if ($handle->code !== 200) return $streaming ? true : '{}'; + $progressOption = defined('CURLOPT_XFERINFOFUNCTION') ? CURLOPT_XFERINFOFUNCTION : CURLOPT_PROGRESSFUNCTION; + $progress = $handle->options[$progressOption] ?? null; + if (!$streaming) { + if ($scenario === 'cancel_blocking') $GLOBALS['integrationCancelled'] = true; + if (is_callable($progress) && $progress($handle, 0, 0, 100, 100)) return false; + return json_encode(['answer' => '合成完整回答']); + } + if (is_callable($progress) && $progress($handle, 0, 0, 100, 100)) return false; + $write = $handle->options[CURLOPT_WRITEFUNCTION]; + if (str_ends_with($handle->url, '/chat-messages')) { + $write($handle, 'data: ' . json_encode(['event' => 'message', 'answer' => '合成完整回答']) . "\n\n"); + $write($handle, "data: {\"event\":\"message_end\"}\n\n"); + } else { + $write($handle, 'data: ' . json_encode(['choices' => [['delta' => ['content' => '合成完整回答']]]]) . "\n\n"); + $write($handle, "data: [DONE]\n\n"); + } + return true; + } + function curl_errno(\stdClass $handle): int { return 0; } + function curl_getinfo(\stdClass $handle, int $option): int { return $handle->code ?? 200; } + function curl_close(\stdClass $handle): void { ++$GLOBALS['integrationClosed']; } +} + +namespace { + require dirname(__DIR__) . '/vendor/autoload.php'; + use app\common\service\DifyChatService; + use app\adminapi\logic\tcm\DiagnosisAiLogic; + use app\adminapi\service\AssistantSseTiming; + function integrationExpect(bool $ok, string $message): void { if (!$ok) throw new RuntimeException($message); } + $integrationConfig = ['enable' => true, 'base_url' => 'https://synthetic.invalid/v1/chat-messages', 'timeout' => 5, + 'max_files' => 2, 'models' => ['qwen' => ['name' => 'synthetic', 'api_key' => 'fixture-only']]]; + $reset = static function (string $scenario = 'success'): void { + $GLOBALS['integrationScenario'] = $scenario; + $GLOBALS['integrationRequests'] = []; + $GLOBALS['integrationCancelled'] = false; + $GLOBALS['integrationClosed'] = 0; + }; + $abort = static fn (): bool => $GLOBALS['integrationCancelled']; + $strict = ['strict_files' => true, 'knowledge_mode' => 'evidence', 'should_abort' => $abort]; + $images = array_map(static fn (int $id): array => ['type' => 'image', 'url' => 'https://synthetic.invalid/' . $id . '.png'], [1, 2, 3]); + $document = [['type' => 'document', 'url' => 'https://synthetic.invalid/report.pdf']]; + $call = static fn (array $files): array => DifyChatService::chat('qwen', [], '合成资料', 'synthetic-user', $files, $strict); + + $reset(); + integrationExpect($call($images)['error_code'] === 'STRICT_FILES_INVALID_OR_LIMIT' && $integrationRequests === [], 'strict + abort rejects over-limit files before transport'); + integrationExpect($call([['type' => 'image', 'url' => 'invalid']])['error_code'] === 'STRICT_FILES_INVALID_OR_LIMIT' + && $integrationRequests === [], 'strict + abort rejects invalid files before transport'); + $result = $call([$images[0], $images[0]]); + integrationExpect(!empty($result['ok']) && $result['transmitted_file_count'] === 2 && $result['attachment_transport'] === 'dify' + && count($integrationRequests[0]['payload']['files']) === 2, 'strict stream retains logical duplicates and exact success metadata'); + + $reset('file_rejected'); + $result = $call([$images[0]]); + integrationExpect(empty($result['ok']) && count($integrationRequests) === 1 && count($integrationRequests[0]['payload']['files']) === 1, + 'strict rejected files never trigger a text-only success fallback'); + $integrationConfig['base_url'] = 'https://synthetic.invalid/v1/chat/completions'; + $reset(); + integrationExpect($call($document)['error_code'] === 'FILE_TYPE_UNSUPPORTED' && $integrationRequests === [], 'strict documents cannot be omitted by OpenAI transport'); + $result = $call([$images[0]]); + integrationExpect(!empty($result['ok']) && $result['transmitted_file_count'] === 1 && $result['attachment_transport'] === 'openai', 'strict supported OpenAI images retain success metadata'); + $integrationConfig['base_url'] = 'https://synthetic.invalid/v1'; + $reset('protocol_fallback'); + integrationExpect($call($document)['error_code'] === 'FILE_TYPE_UNSUPPORTED' && count($integrationRequests) === 1, + 'strict protocol fallback fails explicitly before an unsupported document request'); + + $integrationConfig['base_url'] = 'https://synthetic.invalid/v1/chat-messages'; + $prepared = ['profile' => 'qwen', 'task' => 'summary', 'query' => '合成资料', 'inputs' => [], 'user' => 'synthetic-user', 'upstream_timeout' => 5]; + $runAssistant = static function () use ($prepared, $abort): array { + $stops = []; $ends = 0; $deltas = []; $progress = []; + $result = DiagnosisAiLogic::streamPreparedAssistant($prepared, + static function (string $text) use (&$deltas): bool { $deltas[] = $text; return true; }, $abort, new AssistantSseTiming(), [ + 'knowledge_mode' => 'evidence', + 'on_stop' => static function (bool $accepted) use (&$stops): void { $stops[] = $accepted; }, + 'on_transfer_end' => static function () use (&$ends): void { ++$ends; }, + 'on_progress' => static function (array $event) use (&$progress): void { $progress[] = $event; }, + ]); + return compact('result', 'stops', 'ends', 'deltas', 'progress'); + }; + $reset('blocking_only'); + $test = $runAssistant(); + integrationExpect(is_array($test['result']) && $test['result']['answer'] === '合成完整回答', 'actual blocking-only gateway compatibility succeeds'); + integrationExpect(array_column($integrationRequests, 'streaming') === [true, false] + && array_column(array_column($integrationRequests, 'payload'), 'response_mode') === ['streaming', 'blocking'], 'compatibility sends streaming once then genuine blocking once'); + integrationExpect($test['ends'] === 2 && $test['stops'] === [], 'completed compatibility transfer does not invent stop acceptance'); + + $reset('cancel_blocking'); + $test = $runAssistant(); + integrationExpect($test['result'] === null && DiagnosisAiLogic::getAssistantErrorCode() === 'AI_ASSISTANT_CANCELLED', 'cancellation during real blocking compatibility terminates safely'); + integrationExpect(array_column($integrationRequests, 'streaming') === [true, false] && $integrationClosed === 2 + && $test['ends'] === 2 && $test['stops'] === [false] && $test['deltas'] === [], 'cancel closes blocking curl, leaves remote stop unconfirmed and sends no retry or late text'); + echo "Diagnosis AI strict files + cancellation / actual blocking compatibility integration: OK\n"; +} diff --git a/server/tests/DiagnosisAiAssistantTransportCancellationTest.php b/server/tests/DiagnosisAiAssistantTransportCancellationTest.php new file mode 100644 index 000000000..41bf04d84 --- /dev/null +++ b/server/tests/DiagnosisAiAssistantTransportCancellationTest.php @@ -0,0 +1,120 @@ +options = $options; + $handle->url = $options[CURLOPT_URL]; + $handle->body = json_decode($options[CURLOPT_POSTFIELDS], true); + return true; + } + function curl_exec(\stdClass $handle) { + $GLOBALS['requests'][] = ['url' => $handle->url, 'body' => $handle->body]; + if (str_ends_with($handle->url, '/stop')) { + if ($GLOBALS['closed'] !== 0) throw new \RuntimeException('Stop must precede generation close'); + return json_encode(['result' => $GLOBALS['stopAccepted'] ? 'success' : 'failed']); + } + if (($GLOBALS['mode'] === 'file_rejection' && !empty($handle->body['files'])) + || ($GLOBALS['mode'] === 'protocol_fallback' && str_ends_with($handle->url, 'chat-messages'))) { + $handle->code = $GLOBALS['mode'] === 'file_rejection' ? 400 : 404; + ($handle->options[CURLOPT_HEADERFUNCTION])($handle, 'HTTP/1.1 ' . $handle->code . " Rejected\r\n"); + return true; + } + ($handle->options[CURLOPT_HEADERFUNCTION])($handle, "HTTP/1.1 200 OK\r\n"); + $progressOption = defined('CURLOPT_XFERINFOFUNCTION') ? CURLOPT_XFERINFOFUNCTION : CURLOPT_PROGRESSFUNCTION; + $progress = $handle->options[$progressOption]; + $write = $handle->options[CURLOPT_WRITEFUNCTION]; + $progress($handle, 0, 0, 100, 100); + $mode = $GLOBALS['mode']; + if ($mode === 'before_metadata') $GLOBALS['cancelled'] = true; + if (!str_contains($handle->url, 'chat/completions')) { + $write($handle, "data: {\"event\":\"workflow_started\",\"task_id\":\"synthetic-task\"}\n\n"); + } + if ($mode === 'before_text') $GLOBALS['cancelled'] = true; + if ($progress($handle, 0, 0, 100, 100)) return false; + $write($handle, str_contains($handle->url, 'chat/completions') + ? "data: {\"choices\":[{\"delta\":{\"content\":\"合成回答\"}}]}\n\n" + : "data: {\"event\":\"message\",\"task_id\":\"synthetic-task\",\"answer\":\"合成回答\"}\n\n"); + if ($mode === 'after_text') $GLOBALS['cancelled'] = true; + if ($progress($handle, 0, 0, 100, 100)) return false; + $write($handle, str_contains($handle->url, 'chat/completions') ? "data: [DONE]\n\n" : "data: {\"event\":\"message_end\"}\n\n"); + return true; + } + function curl_errno(\stdClass $handle): int { return 0; } + function curl_getinfo(\stdClass $handle, int $option): int { return $handle->code ?? 200; } + function curl_close(\stdClass $handle): void { if (!str_ends_with($handle->url, '/stop')) ++$GLOBALS['closed']; } +} + +namespace { + require dirname(__DIR__) . '/vendor/autoload.php'; + use app\common\service\DifyChatService; + function transportExpect(bool $ok, string $message): void { if (!$ok) throw new RuntimeException($message); } + $transportConfig = ['enable' => true, 'base_url' => 'https://synthetic.invalid/v1/chat-messages', 'timeout' => 5, + 'max_files' => 2, 'models' => ['qwen' => ['name' => 'synthetic', 'api_key' => 'fixture-only']]]; + $run = static function (string $scenario, bool $accept = true, bool $blocking = false) { + $GLOBALS['mode'] = $scenario; $GLOBALS['cancelled'] = false; $GLOBALS['requests'] = []; + $GLOBALS['closed'] = 0; $GLOBALS['stopAccepted'] = $accept; + $progress = []; $deltas = []; $tasks = []; $stops = []; $ends = 0; + $abort = static fn (): bool => $GLOBALS['cancelled']; + $options = ['knowledge_mode' => 'evidence', 'source_summary' => ['diagnosis_count' => 4], + 'source_form' => 'evidence_summary', 'evidence_cache_hit' => true, + 'on_progress' => static function (array $event) use (&$progress): void { $progress[] = $event; }, + 'on_task' => static function (string $task) use (&$tasks): void { $tasks[] = $task; }, + 'on_stop' => static function (bool $ok) use (&$stops): void { $stops[] = $ok; }, + 'on_transfer_end' => static function () use (&$ends): void { ++$ends; }]; + $files = array_map(static fn (int $i): array => ['type' => 'image', 'url' => 'https://synthetic.invalid/' . $i . '.png'], [1, 2, 3]); + $delta = static function (string $text) use (&$deltas): bool { $deltas[] = $text; return true; }; + $result = $blocking + ? DifyChatService::chat('qwen', [], '合成资料', 'synthetic-user', [], $options + ['should_abort' => $abort]) + : DifyChatService::streamChat('qwen', [], '合成资料', 'synthetic-user', $delta, $abort, $files, $options); + return compact('result', 'progress', 'deltas', 'tasks', 'stops', 'ends'); + }; + foreach (['before_metadata', 'before_text', 'after_text'] as $scenario) { + $test = $run($scenario); + transportExpect(($test['result']['error_code'] ?? '') === 'AI_ASSISTANT_CANCELLED', $scenario . ': cancellation returned'); + transportExpect($test['stops'] === [true] && $test['ends'] === 1, $scenario . ': stop accepted and transfer ended'); + transportExpect(count($requests) === 2 && str_ends_with($requests[1]['url'], '/synthetic-task/stop'), $scenario . ': exactly one generation and one stop; no retry'); + transportExpect($requests[1]['body'] === ['user' => 'synthetic-user'], 'stop uses exact original user'); + transportExpect(count($test['deltas']) === ($scenario === 'after_text' ? 1 : 0), 'no post-cancel text is delivered'); + transportExpect(!str_contains(json_encode([$test['result'], $test['progress']]), 'synthetic-task'), 'no task identity leaks to progress or response'); + } + $test = $run('before_text', false); + transportExpect($test['stops'] === [false], 'unaccepted stop is never reported as confirmed'); + $test = $run('before_text', true, true); + transportExpect($test['stops'] === [true] && $requests[0]['body']['response_mode'] === 'streaming', 'cancellable blocking preprocessing uses stoppable Dify streaming'); + transportExpect($test['progress'][0]['source_form'] === 'evidence_summary', 'progress identifies cached evidence rather than full raw source'); + $test = $run('success'); + transportExpect(!empty($test['result']['ok']), 'noncancelled stream preserves success'); + $submitted = array_values(array_filter($test['progress'], static fn (array $p): bool => ($p['phase'] ?? '') === 'attachments' && $p['status'] === 'submitted'))[0]; + transportExpect($submitted['attachment_count'] === 2 && count($submitted['items']) === 3 && $submitted['items'][2]['status'] === 'omitted', 'actual file limit is reflected without claiming omitted file submission'); + transportExpect($submitted['source_summary']['diagnosis_count'] === 4 && $submitted['attachment_transfer'] === 'remote_url_references', 'real source counts and URL transfer form are shown'); + transportExpect(!str_contains(json_encode($submitted), 'https://'), 'safe labels never disclose remote patient URLs'); + $test = $run('file_rejection'); + $attempts = array_values(array_filter($test['progress'], static fn (array $p): bool => ($p['phase'] ?? '') === 'attachments')); + $last = end($attempts); + transportExpect(!empty($test['result']['ok']) && count($requests) === 2 && $last['attachment_count'] === 0 + && array_unique(array_column($last['items'], 'status')) === ['omitted'], 'file rejection updates final attempt to zero inline files and all omitted'); + transportExpect(in_array('failed', array_column($attempts, 'status'), true), 'rejected submission has an explicit failed state before fallback'); + $transportConfig['base_url'] = 'https://synthetic.invalid/v1'; + $test = $run('protocol_fallback'); + transportExpect(!empty($test['result']['ok']) && count($requests) === 2 + && str_ends_with($requests[1]['url'], '/chat/completions'), 'progress follows the actual protocol fallback attempt'); + $transportConfig['base_url'] = 'https://synthetic.invalid/v1/chat/completions'; + $test = $run('after_text'); + transportExpect(count($requests) === 1 && $test['stops'] === [false] && $test['ends'] === 1, 'OpenAI cancellation closes transport without inventing a Dify stop'); + $requests = []; + $neverStarted = DifyChatService::streamChat('qwen', [], '合成资料', 'synthetic-user', static fn (): bool => true, + static fn (): bool => true, [], ['knowledge_mode' => 'evidence']); + transportExpect($neverStarted['error_code'] === 'AI_ASSISTANT_CANCELLED' && $requests === [], 'cancellation before any upstream work sends no generation request'); + echo "Diagnosis AI assistant cancellable transport / task stop / real progress: OK\n"; +} diff --git a/server/tests/DiagnosisAiPrescriptionGeneratePermissionTest.php b/server/tests/DiagnosisAiPrescriptionGeneratePermissionTest.php index 569c0de37..6a340f39a 100644 --- a/server/tests/DiagnosisAiPrescriptionGeneratePermissionTest.php +++ b/server/tests/DiagnosisAiPrescriptionGeneratePermissionTest.php @@ -42,6 +42,13 @@ prescriptionPermissionExpect( ); PrescriptionGenerateAuthCacheDouble::$uris = ['tcm.diagnosis/aiAssistant']; +$earlyStarts = 0; +$deniedOptions = ['on_authorized' => static function () use (&$earlyStarts): void { ++$earlyStarts; }]; +prescriptionPermissionExpect( + DiagnosisAiLogic::prepareAssistant(1, 'prescription_generate', '', 7, ['root' => 0], null, $deniedOptions) === null + && $earlyStarts === 0, + 'a denied task cannot invoke early SSE start or progress callbacks' +); prescriptionPermissionExpect( DiagnosisAiLogic::prepareAssistant(1, 'prescription_generate', '', 7, ['root' => 0]) === null, 'general assistant permission alone cannot generate a prescription' @@ -66,6 +73,11 @@ prescriptionPermissionExpect( ); PrescriptionGenerateAuthCacheDouble::$uris = ['tcm.diagnosis/aiGeneratePrescription']; +prescriptionPermissionExpect( + DiagnosisAiLogic::cancelAssistant(1, '12345678-1234-4234-8234-123456789abc', 7, ['root' => 0]) === null + && DiagnosisAiLogic::getError() === '权限不足,无法取消诊单 AI 助手', + 'cancel endpoint enforces the assistant permission before touching shared control state' +); prescriptionPermissionExpect( DiagnosisAiLogic::prepareAssistant(1, 'prescription_generate', '', 7, ['root' => 0]) === null, 'prescription permission alone does not bypass the general assistant permission' diff --git a/server/tests/PrescriptionAiUpstreamContractTest.php b/server/tests/PrescriptionAiUpstreamContractTest.php index 9072929d7..e9a306e85 100644 --- a/server/tests/PrescriptionAiUpstreamContractTest.php +++ b/server/tests/PrescriptionAiUpstreamContractTest.php @@ -63,6 +63,12 @@ $generic = callPrivate('buildRequestSpecs', [ expectSame(2, count($generic), 'ambiguous /v1 base should support both protocols'); expectSame('https://ai.example.test/v1/chat-messages', $generic[0]['url'], 'Dify endpoint'); expectSame('blocking', $generic[0]['payload']['response_mode'], 'Dify blocking request'); +expectSame(false, $generic[0]['payload']['auto_generate_name'], 'Dify blocking generation disables background titles'); +expectSame(false, array_key_exists('auto_generate_name', $generic[1]['payload']), 'OpenAI protocol has no Dify title field'); +$streamingSpecs = callPrivate('buildRequestSpecs', ['https://ai.example.test/v1', 'model-name', [], + 'clinical prompt', 'server-user', true]); +expectSame(false, $streamingSpecs[0]['payload']['auto_generate_name'], 'Dify streaming generation disables background titles'); +expectSame(true, $streamingSpecs[0]['payload']['inputs'] instanceof stdClass, 'empty inputs retain JSON object semantics'); expectSame('https://ai.example.test/v1/chat/completions', $generic[1]['url'], 'OpenAI endpoint'); expectSame('model-name', $generic[1]['payload']['model'], 'profile model selection'); expectSame('clinical prompt', $generic[1]['payload']['messages'][0]['content'], 'OpenAI prompt'); diff --git a/server/tests/fixtures/CancellableCurlMultiStub.php b/server/tests/fixtures/CancellableCurlMultiStub.php new file mode 100644 index 000000000..a87449390 --- /dev/null +++ b/server/tests/fixtures/CancellableCurlMultiStub.php @@ -0,0 +1,23 @@ +handle = $handle; return CURLM_OK; } +function curl_multi_exec(\stdClass $multi, ?int &$running): int { + curl_exec($multi->handle); + $multi->completion = ['handle' => $multi->handle, 'result' => curl_errno($multi->handle)]; + $running = 0; + return CURLM_OK; +} +function curl_multi_info_read(\stdClass $multi): array|false { + $info = $multi->completion ?? false; + unset($multi->completion); + return $info; +} +function curl_multi_select(\stdClass $multi, float $timeout): int { return 0; } +function curl_multi_remove_handle(\stdClass $multi, \stdClass $handle): int { return CURLM_OK; } +function curl_multi_close(\stdClass $multi): void {}