Merge branch 'master' of http://git.zhenyangtang.com.cn/ZYT/zyt
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
.env
|
||||
.local-workstation/
|
||||
.venv/
|
||||
.venv-build/
|
||||
.venv-win7-check/
|
||||
|
||||
@@ -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/),然后在项目根目录执行:
|
||||
|
||||
@@ -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%
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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) == []
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
@@ -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'])
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 {}
|
||||
Reference in New Issue
Block a user