This commit is contained in:
gr
2026-10-23 09:27:45 +08:00
34 changed files with 2866 additions and 225 deletions
+1
View File
@@ -1,4 +1,5 @@
.env
.local-workstation/
.venv/
.venv-build/
.venv-win7-check/
+6
View File
@@ -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/),然后在项目根目录执行:
+10
View File
@@ -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%
+70
View File
@@ -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())
@@ -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."""
@@ -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
@@ -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()
+322
View File
@@ -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()
+221
View File
@@ -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) == []
+15 -9
View File
@@ -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()
+48
View File
@@ -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")
+3 -1
View File
@@ -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": "肝郁"}}
@@ -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<string,mixed> $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<string,mixed> $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;
}
@@ -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;
}
@@ -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<string,mixed> $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<string,mixed> $prepared
* @param array<string,mixed> $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),
@@ -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,
@@ -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);
@@ -0,0 +1,286 @@
<?php
declare(strict_types=1);
namespace app\adminapi\service;
use app\common\service\DifyChatService;
/**
* Shared between FPM workers on one host. Contains no credentials or clinical text.
* The caller MUST authorize the diagnosis before constructing or reading a control.
*/
final class AssistantRequestControl
{
private const STOP_LEASE_SECONDS = 1.25;
private const STOP_RETRY_DELAY_SECONDS = 0.25;
private const STOP_RETENTION_SECONDS = 30;
private const MAX_STOP_ATTEMPTS = 2;
private string $path;
private int $ttl;
private string $directory;
public function __construct(int $adminId, int $diagnosisId, string $requestId, ?string $directory = null, int $ttl = 1200)
{
if ($adminId <= 0 || $diagnosisId <= 0 || !self::validRequestId($requestId)) {
throw new \InvalidArgumentException('Invalid assistant request identity');
}
$directory = $directory ?? app()->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);
}
}
}
@@ -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<string,mixed> $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')) {
@@ -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)) {
@@ -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'])
+337 -35
View File
@@ -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<string,mixed> $config
* @return array<string,mixed>|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;
}
@@ -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`;新增上述测试与本说明。原审查文本保留作为历史发现记录,由根代理更新整体交付结论。
@@ -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";
}
@@ -0,0 +1,201 @@
<?php
declare(strict_types=1);
namespace app\common\service {
function config(string $name): array { return $GLOBALS['drainConfig']; }
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\service\AssistantRequestControl;
use app\common\service\DifyChatService;
function drainExpect(bool $ok, string $message): void {
if (!$ok) throw new RuntimeException($message);
}
// A real loopback HTTP peer. It accepts the stop on another socket while the
// generation stays open, then either sends terminal metadata or stays silent.
if (($argv[1] ?? '') === 'server') {
$scenario = $argv[2];
$listener = stream_socket_server('tcp://127.0.0.1:0', $error, $message);
if ($listener === false) exit(2);
stream_set_blocking($listener, false);
echo json_encode(['address' => 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);
}
}
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\service\AssistantRequestControl;
use app\adminapi\http\middleware\AuthMiddleware;
function cancelExpect(bool $ok, string $message): void {
if (!$ok) throw new RuntimeException($message);
}
if (($argv[1] ?? '') === 'worker') {
$control = new AssistantRequestControl(7, 11, $argv[4], $argv[3]);
if ($argv[2] === 'cancel') echo json_encode($control->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);
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace app\adminapi\logic\firstvisit {
class MyPatientLogic {
public static bool $allowed = true;
public static function canAccessDiagnosis($id, $adminId, $adminInfo): bool {
$GLOBALS['lifecycle'][] = 'scope_checked';
return self::$allowed;
}
}
}
namespace app\adminapi\logic\tcm {
function config(string $key): array {
return ['timeout' => 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";
}
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\service\AssistantRequestControl;
function stopExpect(bool $ok, string $message): void {
if (!$ok) throw new RuntimeException($message);
}
if (($argv[1] ?? '') === 'worker') {
[$unused, $workerFlag, $mode, $directory, $id] = $argv;
$control = new AssistantRequestControl(7, 11, $id, $directory);
$deadline = microtime(true) + 5;
while (!is_file($directory . '/gate') && microtime(true) < $deadline) usleep(1000);
echo json_encode($control->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);
}
@@ -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(
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace think\facade {
class Log {
public static function warning(string $message): void {}
}
}
namespace app\common\service {
require __DIR__ . '/fixtures/CancellableCurlMultiStub.php';
function config(string $name): array { return $GLOBALS['integrationConfig']; }
function curl_init(): \stdClass { return new \stdClass(); }
function curl_setopt_array(\stdClass $handle, array $options): bool {
$handle->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";
}
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace think\facade {
class Log {
public static function warning(string $message): void { $GLOBALS['safeLogs'][] = $message; }
}
}
namespace app\common\service {
require __DIR__ . '/fixtures/CancellableCurlMultiStub.php';
function config(string $name): array { return $GLOBALS['transportConfig']; }
function curl_init(): \stdClass { return new \stdClass(); }
function curl_setopt_array(\stdClass $handle, array $options): bool {
$handle->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";
}
@@ -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'
@@ -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');
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace app\common\service;
// Adapt the existing synchronous HTTP fixtures to the cancellable multi pump.
function curl_multi_init(): \stdClass { return new \stdClass(); }
function curl_multi_add_handle(\stdClass $multi, \stdClass $handle): int { $multi->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 {}