feat: 站点 JSON、语音终端 WebSocket 指派与客户端联调

- 用 OR_SITE_CONFIG_JSON_FILE 统一术间配置(video_rtsp_urls + voice_or_room_bindings)
- VoiceTerminalHub:assignment、WS 推送与 HTTP 查询;开录/停录后 notify
- 一键联调 orchestrate-and-start 与 /client/surgeries/start 共用指派逻辑,修复 demo 路径不发 WS
- 语音桌面端:SIGINT 退出、shutdown 清理、仅 WS 指派、固定 pending 轮询间隔、界面仅保留录音时长
- 新增/调整契约与绑定测试,文档与示例配置同步

Made-with: Cursor
This commit is contained in:
Kevin
2026-04-27 11:21:16 +08:00
parent 4c3f9a367b
commit 6b3adb4ad8
36 changed files with 1194 additions and 162 deletions

View File

@@ -2,7 +2,17 @@ import asyncio
from collections.abc import Awaitable, Callable
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Path, UploadFile, status
from fastapi import (
APIRouter,
Depends,
File,
HTTPException,
Path,
Query,
UploadFile,
WebSocket,
status,
)
from fastapi.responses import JSONResponse
from loguru import logger
from sqlalchemy.exc import SQLAlchemyError
@@ -10,7 +20,7 @@ from sqlalchemy.exc import SQLAlchemyError
from app.baked import pipeline as bp
from app.config import settings
from app.database import check_database
from app.dependencies import get_surgery_pipeline
from app.dependencies import get_surgery_pipeline, get_voice_terminal_hub
from app.schemas import (
HealthResponse,
SurgeryApiResponse,
@@ -20,9 +30,14 @@ from app.schemas import (
SurgeryPendingConfirmationResponse,
SurgeryResultResponse,
SurgeryStartRequest,
VoiceTerminalAssignmentResponse,
build_consumption_summary,
)
from app.services.surgery_pipeline import SurgeryPipeline
from app.services.voice_terminal_hub import (
VoiceTerminalHub,
assign_voice_terminal_after_recording_started,
)
from app.surgery_errors import SurgeryPipelineError
router = APIRouter()
@@ -123,13 +138,13 @@ async def health() -> HealthResponse | JSONResponse:
description="供 demo 页探测:是否启用 orchestrator、RTSP 文件配置等;此路由始终存在,不依赖 DEMO_ORCHESTRATOR_ENABLED。",
)
async def demo_orchestrator_status() -> dict:
f = (settings.video_rtsp_urls_json_file or "").strip()
f = (settings.or_site_config_json_file or "").strip()
return {
"orchestrator_enabled": bool(settings.demo_orchestrator_enabled),
"orchestrate_method": "POST",
"orchestrate_path": "/internal/demo/orchestrate-and-start",
"video_rtsp_urls_json_file_set": bool(f),
"video_rtsp_urls_json_file": f or None,
"or_site_config_json_file_set": bool(f),
"or_site_config_json_file": f or None,
"orchestrator_rtsp_port": settings.demo_orchestrator_rtsp_port,
"orchestrator_rtsp_json_host": settings.demo_orchestrator_rtsp_json_host,
}
@@ -157,6 +172,7 @@ async def demo_orchestrator_status() -> dict:
async def start_surgery(
payload: SurgeryStartRequest,
pipeline: Annotated[SurgeryPipeline, Depends(get_surgery_pipeline)],
voice_hub: Annotated[VoiceTerminalHub, Depends(get_voice_terminal_hub)],
) -> SurgeryApiResponse:
logger.info(
"Start surgery: surgery_id={}, cameras={}, candidates={}",
@@ -180,6 +196,14 @@ async def start_surgery(
)
except SurgeryPipelineError as exc:
_raise_surgery_pipeline_http(exc, payload.surgery_id)
await assign_voice_terminal_after_recording_started(
voice_hub,
surgery_id=payload.surgery_id,
camera_ids=list(payload.camera_ids),
set_voice_terminal_id=pipeline.set_voice_terminal_id,
)
return SurgeryApiResponse(
surgery_id=payload.surgery_id,
status="accepted",
@@ -209,11 +233,14 @@ async def start_surgery(
async def end_surgery(
payload: SurgeryEndRequest,
pipeline: Annotated[SurgeryPipeline, Depends(get_surgery_pipeline)],
voice_hub: Annotated[VoiceTerminalHub, Depends(get_voice_terminal_hub)],
) -> SurgeryApiResponse:
logger.info("End surgery: surgery_id={}", payload.surgery_id)
voice_terminal_id: str | None = None
try:
async def _stop() -> None:
await pipeline.stop_recording(payload.surgery_id)
nonlocal voice_terminal_id
voice_terminal_id = await pipeline.stop_recording(payload.surgery_id)
await _call_recording_with_retries(
_stop,
@@ -223,6 +250,10 @@ async def end_surgery(
)
except SurgeryPipelineError as exc:
_raise_surgery_pipeline_http(exc, payload.surgery_id)
if voice_terminal_id:
await voice_hub.notify_end(voice_terminal_id, payload.surgery_id)
return SurgeryApiResponse(
surgery_id=payload.surgery_id,
status="accepted",
@@ -230,6 +261,33 @@ async def end_surgery(
)
@router.get(
"/client/voice-terminals/{terminal_id}/assignment",
response_model=VoiceTerminalAssignmentResponse,
tags=["client"],
summary="查询语音终端当前指派的手术",
description="供桌面客户端在 WebSocket 不可用时的轮询兜底;与 WS 推送的 assignment 状态一致。",
)
async def get_voice_terminal_assignment(
terminal_id: Annotated[str, Path(min_length=1, max_length=256)],
hub: Annotated[VoiceTerminalHub, Depends(get_voice_terminal_hub)],
) -> VoiceTerminalAssignmentResponse:
tid = terminal_id.strip()
return VoiceTerminalAssignmentResponse(
voice_terminal_id=tid,
active_surgery_id=hub.get_assignment(tid),
)
@router.websocket("/client/voice-terminals/ws")
async def voice_terminal_websocket(
websocket: WebSocket,
terminal_id: Annotated[str, Query(..., min_length=1, max_length=256)],
) -> None:
container = websocket.app.state.container
await container.voice_terminal_hub.handle_websocket(websocket, terminal_id)
@router.get(
"/client/surgeries/{surgery_id}/result",
response_model=SurgeryResultResponse,

View File

@@ -15,8 +15,9 @@ def default_consumable_classifier_labels_yaml_path() -> str:
return str(_PACKAGE_DIR / "resources" / "consumable_classifier_labels.yaml")
def default_camera_rtsp_urls_sample_path() -> str:
return str(_PACKAGE_DIR / "resources" / "camera_rtsp_urls.sample.json")
def default_or_site_config_sample_path() -> str:
"""站点配置示例video_rtsp_urls + voice_or_room_bindings。"""
return str(_PACKAGE_DIR / "resources" / "or_site_config.sample.json")
# --- 耗材分类YOLO-cls---

View File

@@ -1,4 +1,5 @@
import json
from __future__ import annotations
from pathlib import Path
from urllib.parse import quote_plus
from typing import Any, Literal
@@ -7,6 +8,7 @@ from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
from app.baked import algorithm as baked_algorithm
from app.or_site_config import OrSiteConfig
class _SettingsGroup:
@@ -39,8 +41,7 @@ class _VideoGroup(_SettingsGroup):
"video_default_backend",
"video_camera_backend_overrides_json",
"video_rtsp_url_template",
"video_rtsp_urls_json",
"video_rtsp_urls_json_file",
"or_site_config_json_file",
)
@@ -135,8 +136,8 @@ class Settings(BaseSettings):
video_default_backend: Literal["rtsp", "hikvision_sdk", "auto"] = "rtsp"
video_camera_backend_overrides_json: str = ""
video_rtsp_url_template: str = ""
video_rtsp_urls_json: str = ""
video_rtsp_urls_json_file: str = ""
#: 手术室站点配置UTF-8 JSON须含 video_rtsp_urls 与 voice_or_room_bindings见 or_site_config.sample.json
or_site_config_json_file: str = ""
hikvision_lib_dir: str = "/opt/hikvision/lib"
hikvision_sdk_enabled: bool = False
@@ -236,45 +237,27 @@ class Settings(BaseSettings):
and self.minio_bucket.strip()
)
@staticmethod
def _parse_rtsp_urls_object(raw: str) -> dict[str, str]:
raw = (raw or "").strip()
if not raw:
return {}
try:
data: Any = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid VIDEO_RTSP_URLS_JSON: {exc}") from exc
if not isinstance(data, dict):
raise ValueError("VIDEO_RTSP_URLS_JSON must be a JSON object")
return {str(k): str(v) for k, v in data.items()}
def load_or_site_config(self) -> OrSiteConfig | None:
"""解析 ``or_site_config_json_file``;未配置路径时返回 ``None``。"""
from app.or_site_config import load_or_site_config_from_path
path_raw = (self.or_site_config_json_file or "").strip()
if not path_raw:
return None
path = Path(path_raw).expanduser()
if not path.is_file():
raise ValueError(f"OR_SITE_CONFIG_JSON_FILE is set but file not found: {path}")
return load_or_site_config_from_path(path)
def video_rtsp_url_map(self) -> dict[str, str]:
merged: dict[str, str] = {}
path_raw = (self.video_rtsp_urls_json_file or "").strip()
if path_raw:
path = Path(path_raw).expanduser()
if not path.is_file():
raise ValueError(
f"VIDEO_RTSP_URLS_JSON_FILE is set but file not found: {path}"
)
try:
file_obj: Any = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(
f"Invalid JSON in VIDEO_RTSP_URLS_JSON_FILE {path}: {exc}"
) from exc
if not isinstance(file_obj, dict):
raise ValueError(
f"VIDEO_RTSP_URLS_JSON_FILE must contain a JSON object: {path}"
)
merged = {str(k): str(v) for k, v in file_obj.items()}
merged.update(self._parse_rtsp_urls_object(self.video_rtsp_urls_json))
return merged
cfg = self.load_or_site_config()
if cfg is None:
return {}
return dict(cfg.video_rtsp_urls)
@property
def camera_rtsp_urls_sample_path(self) -> str:
return baked_algorithm.default_camera_rtsp_urls_sample_path()
def or_site_config_sample_path(self) -> str:
return baked_algorithm.default_or_site_config_sample_path()
@property
def video(self) -> _VideoGroup:

View File

@@ -25,6 +25,7 @@ from app.services.surgery_pipeline import SurgeryPipeline
from app.services.video.hikvision_runtime import HikvisionRuntime
from app.services.video.session_manager import CameraSessionManager
from app.services.voice_resolution import VoiceConfirmationService
from app.services.voice_terminal_hub import VoiceTerminalHub
@dataclass
@@ -42,6 +43,7 @@ class AppContainer:
tear_segment_model_bundle: TearGatedSegmentModelBundle | None
voice_confirmation_service: VoiceConfirmationService
surgery_pipeline: SurgeryPipeline
voice_terminal_hub: VoiceTerminalHub
async def start(self) -> None:
await self.camera_session_manager.start_archive_retry_loop()
@@ -96,6 +98,7 @@ def build_container(
voice_confirmation=voice,
session_factory=sf,
)
voice_hub = VoiceTerminalHub(s)
return AppContainer(
settings=s,
consumable_vision_algorithm_service=vision,
@@ -108,6 +111,7 @@ def build_container(
tear_segment_model_bundle=tear_bundle,
voice_confirmation_service=voice,
surgery_pipeline=pipeline,
voice_terminal_hub=voice_hub,
)
@@ -140,3 +144,7 @@ def get_surgery_result_repository(request: Request) -> SurgeryResultRepository:
def get_voice_confirmation_service(request: Request) -> VoiceConfirmationService:
return get_container(request).voice_confirmation_service
def get_voice_terminal_hub(request: Request) -> VoiceTerminalHub:
return get_container(request).voice_terminal_hub

103
app/or_site_config.py Normal file
View File

@@ -0,0 +1,103 @@
"""手术室站点配置:单一 JSON 文件,严格结构,无历史格式分支。"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from loguru import logger
from app.services.voice_terminal_binding import VoiceTerminalBindingIndex
_ALLOWED_TOP_LEVEL = frozenset({"video_rtsp_urls", "voice_or_room_bindings"})
@dataclass(frozen=True)
class OrSiteConfig:
"""根对象须含 ``video_rtsp_urls`` 与 ``voice_or_room_bindings`` 两个键。"""
video_rtsp_urls: dict[str, str]
voice_bindings: VoiceTerminalBindingIndex
def parse_or_site_config_object(data: Any, *, source: str | Path = "") -> OrSiteConfig:
label = str(source) if source else "JSON"
if not isinstance(data, dict):
raise ValueError(f"{label}: OR site config must be a JSON object")
extra = set(data.keys()) - _ALLOWED_TOP_LEVEL
if extra:
raise ValueError(
f"{label}: unknown top-level keys {sorted(extra)}; "
f"allowed: {sorted(_ALLOWED_TOP_LEVEL)}"
)
if "video_rtsp_urls" not in data or "voice_or_room_bindings" not in data:
raise ValueError(
f"{label}: must include video_rtsp_urls and voice_or_room_bindings"
)
urls = data["video_rtsp_urls"]
if not isinstance(urls, dict):
raise ValueError(f"{label}: video_rtsp_urls must be a JSON object")
raw_bindings = data["voice_or_room_bindings"]
if not isinstance(raw_bindings, list):
raise ValueError(f"{label}: voice_or_room_bindings must be a JSON array")
for k, v in urls.items():
if not isinstance(v, str):
raise ValueError(
f"{label}: video_rtsp_urls[{k!r}] must be a string (RTSP URL)"
)
idx = VoiceTerminalBindingIndex.from_binding_list(raw_bindings)
if idx is None:
raise ValueError(f"{label}: invalid voice_or_room_bindings content")
return OrSiteConfig(
video_rtsp_urls={str(k): str(v) for k, v in urls.items()},
voice_bindings=idx,
)
def load_or_site_config_from_path(path: Path) -> OrSiteConfig:
p = path.expanduser()
try:
raw_text = p.read_text(encoding="utf-8")
except OSError as exc:
raise ValueError(f"Cannot read OR site config {p}: {exc}") from exc
try:
data: Any = json.loads(raw_text)
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON in OR site config {p}: {exc}") from exc
return parse_or_site_config_object(data, source=p)
def merge_video_rtsp_urls_into_file(
path: Path,
url_map: dict[str, str],
*,
replace_host: str,
) -> None:
"""写入/更新站点配置中的 ``video_rtsp_urls``,保留 ``voice_or_room_bindings``。"""
if replace_host in ("", "127.0.0.1"):
out_urls = dict(url_map)
else:
out_urls = {
k: v.replace("127.0.0.1", replace_host, 1)
for k, v in url_map.items()
}
path = path.expanduser()
path.parent.mkdir(parents=True, exist_ok=True)
if path.is_file():
raw_text = path.read_text(encoding="utf-8")
data: Any = json.loads(raw_text)
parse_or_site_config_object(data, source=path)
bindings_list = data["voice_or_room_bindings"]
else:
bindings_list = []
doc = {
"video_rtsp_urls": out_urls,
"voice_or_room_bindings": bindings_list,
}
text = json.dumps(doc, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
temp = path.with_name(path.name + ".tmp")
temp.write_text(text, encoding="utf-8")
temp.replace(path)
logger.info("Updated video_rtsp_urls in OR site config {}", path)

View File

@@ -1,4 +0,0 @@
{
"or-cam-01": "rtsp://admin:ChangeMe@192.168.1.101:554/Streaming/Channels/101",
"or-cam-02": "rtsp://admin:ChangeMe@192.168.1.102:554/Streaming/Channels/101"
}

View File

@@ -0,0 +1,15 @@
{
"video_rtsp_urls": {
"or-cam-01": "rtsp://127.0.0.1:18554/demo1"
},
"voice_or_room_bindings": [
{
"camera_ids": [
"or-cam-01",
"or-cam-02"
],
"or_room_id": "OR-DEMO",
"voice_terminal_id": "desktop-1"
}
]
}

View File

@@ -13,10 +13,15 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, s
from loguru import logger
from app.config import settings
from app.dependencies import get_surgery_pipeline
from app.dependencies import get_surgery_pipeline, get_voice_terminal_hub
from app.schemas import SurgeryApiResponse, SurgeryStartRequest
from app.services.synthetic_rtsp import StreamSpec, SyntheticRtspManager, write_rtsp_url_json_file
from app.or_site_config import merge_video_rtsp_urls_into_file
from app.services.synthetic_rtsp import StreamSpec, SyntheticRtspManager
from app.services.surgery_pipeline import SurgeryPipeline
from app.services.voice_terminal_hub import (
VoiceTerminalHub,
assign_voice_terminal_after_recording_started,
)
from app.surgery_errors import SurgeryPipelineError
router = APIRouter(prefix="/internal/demo", tags=["demo"])
@@ -39,7 +44,8 @@ def _orchestrate_write_rtsp_host() -> str:
summary="一键联调:上传 14 路视频并开录",
description=(
"仅当 DEMO_ORCHESTRATOR_ENABLED=true。保存一路或多路视频、启动 MediaMTX+ffmpeg、"
"将 RTSP 映射写入 VIDEO_RTSP_URLS_JSON_FILE,再执行与 /client/surgeries/start 相同的开录逻辑"
"将 RTSP 映射合并写入 OR_SITE_CONFIG_JSON_FILE 的 video_rtsp_urls,再执行与 /client/surgeries/start 相同的开录逻辑"
"(含按 voice_or_room_bindings 解析并 WebSocket 推送语音终端指派)。"
),
)
async def orchestrate_and_start(
@@ -58,6 +64,7 @@ async def orchestrate_and_start(
rtsp_path_4: Annotated[str, Form()] = "demo4",
candidate_consumables_json: Annotated[str, Form()] = "[]",
pipeline: SurgeryPipeline = Depends(get_surgery_pipeline),
voice_hub: VoiceTerminalHub = Depends(get_voice_terminal_hub),
) -> SurgeryApiResponse:
logger.info(
"demo orchestrate-and-start: surgery_id={} cameras={} rpaths={}",
@@ -70,12 +77,13 @@ async def orchestrate_and_start(
status_code=status.HTTP_404_NOT_FOUND,
detail="Demo orchestrator disabled (set DEMO_ORCHESTRATOR_ENABLED=true).",
)
path_raw = (settings.video_rtsp_urls_json_file or "").strip()
path_raw = (settings.or_site_config_json_file or "").strip()
if not path_raw:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"VIDEO_RTSP_URLS_JSON_FILE must be set to a writable path; "
"OR_SITE_CONFIG_JSON_FILE must be set to a writable path "
"(strict site JSON with video_rtsp_urls + voice_or_room_bindings); "
"in Docker, bind-mount a host file to this path."
),
)
@@ -195,7 +203,7 @@ async def orchestrate_and_start(
try:
def _write() -> None:
write_rtsp_url_json_file(
merge_video_rtsp_urls_into_file(
json_path,
url_map_host,
replace_host=host_for_json,
@@ -224,6 +232,13 @@ async def orchestrate_and_start(
detail={"code": exc.code, "message": exc.message, "surgery_id": body.surgery_id},
) from exc
await assign_voice_terminal_after_recording_started(
voice_hub,
surgery_id=body.surgery_id,
camera_ids=list(body.camera_ids),
set_voice_terminal_id=pipeline.set_voice_terminal_id,
)
return SurgeryApiResponse(
surgery_id=body.surgery_id,
status="accepted",

View File

@@ -61,6 +61,13 @@ class SurgeryApiResponse(BaseModel):
message: str = Field(description="返回说明。")
class VoiceTerminalAssignmentResponse(BaseModel):
"""语音桌面终端当前被指派的手术HTTP 轮询兜底,与 WebSocket 推送一致)。"""
voice_terminal_id: str
active_surgery_id: str | None = None
class SurgeryClientErrorDetail(BaseModel):
"""与 `HTTPException(detail={...})` 对应;最终 JSON 为 `{"detail": {...}}`。"""

View File

@@ -77,13 +77,16 @@ class SurgeryPipeline:
f"开录未能确认:{exc}",
) from exc
async def stop_recording(self, surgery_id: str) -> None:
"""停止该手术关联的摄像头录制。仅在确认已全部停录时返回。"""
async def stop_recording(self, surgery_id: str) -> str | None:
"""停止该手术关联的摄像头录制。仅在确认已全部停录时返回。返回绑定的语音终端 ID若有"""
try:
await self._sessions.stop_surgery(surgery_id, require_active=True)
return await self._sessions.stop_surgery(surgery_id, require_active=True)
except SurgeryPipelineError:
raise
def set_voice_terminal_id(self, surgery_id: str, terminal_id: str | None) -> None:
self._sessions.set_voice_terminal_id(surgery_id, terminal_id)
async def get_consumption_details_for_client(
self,
surgery_id: str,

View File

@@ -6,7 +6,6 @@ process is gone — reconnect or re-orchestrate for another playthrough.
from __future__ import annotations
import json
import os
import shutil
import socket
@@ -222,25 +221,3 @@ class SyntheticRtspManager:
self._active = run
return run, url_map
def write_rtsp_url_json_file(
path: Path,
url_map: dict[str, str],
*,
replace_host: str,
) -> None:
"""Write JSON map; replace 127.0.0.1 in values with `replace_host` (e.g. host.docker.internal)."""
if replace_host in ("", "127.0.0.1"):
out = url_map
else:
out = {
k: v.replace("127.0.0.1", replace_host, 1)
for k, v in url_map.items()
}
path.parent.mkdir(parents=True, exist_ok=True)
text = json.dumps(out, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
temp = path.with_name(path.name + ".tmp")
temp.write_text(text, encoding="utf-8")
temp.replace(path)
logger.info("Wrote RTSP map to {}", path)

View File

@@ -54,7 +54,7 @@ class BackendResolver:
return VideoBackendKind.RTSP
def rtsp_url_for_camera(self, camera_id: str) -> str:
# Re-read on each use so VIDEO_RTSP_URLS_JSON_FILE can be hot-updated (e.g. dev orchestrator).
# Re-read on each use so OR_SITE_CONFIG_JSON_FILE can be hot-updated (e.g. dev orchestrator).
m = self._s.video_rtsp_url_map()
if camera_id in m:
return m[camera_id]
@@ -67,8 +67,8 @@ class BackendResolver:
f"video_rtsp_url_template missing placeholder: {exc}"
) from exc
raise ValueError(
f"No RTSP URL for camera_id={camera_id!r}: set VIDEO_RTSP_URLS_JSON_FILE, "
f"VIDEO_RTSP_URLS_JSON, or VIDEO_RTSP_URL_TEMPLATE"
f"No RTSP URL for camera_id={camera_id!r}: set OR_SITE_CONFIG_JSON_FILE "
f"(video_rtsp_urls) or VIDEO_RTSP_URL_TEMPLATE"
)
def rtsp_url_after_hikvision_login(self, camera_id: str) -> str:

View File

@@ -223,7 +223,17 @@ class CameraSessionManager:
await self.stop_surgery(surgery_id, require_active=True)
raise
async def stop_surgery(self, surgery_id: str, *, require_active: bool = True) -> None:
def set_voice_terminal_id(self, surgery_id: str, terminal_id: str | None) -> None:
"""开录成功后写入,供停录时向对应桌面终端推送 end。"""
run = self._registry.get_running(surgery_id)
if run is None:
return
tid = (terminal_id or "").strip()
run.state.voice_terminal_id = tid or None
async def stop_surgery(
self, surgery_id: str, *, require_active: bool = True
) -> str | None:
run = await self._registry.unregister(surgery_id)
if run is None:
if require_active:
@@ -231,8 +241,9 @@ class CameraSessionManager:
"RECORDING_NOT_STOPPED",
"停录未能完成:当前没有该手术的活跃录制会话。",
)
return
return None
voice_tid = run.state.voice_terminal_id
run.stop_event.set()
results = await asyncio.gather(*run.tasks, return_exceptions=True)
for res in results:
@@ -255,6 +266,7 @@ class CameraSessionManager:
append_consumption_log_summary(surgery_id, totals)
print_consumption_summary_markdown(totals)
await self._archive.persist_or_archive(surgery_id, details)
return voice_tid
# ------------------------------------------------------------------
# PendingConfirmationStore 协议委托

View File

@@ -85,6 +85,8 @@ class SurgerySessionState:
last_voice_error: str | None = None
#: ``start_surgery`` 创建会话时的 ``time.time()``,用于日志中「相对开录的流逝时间」。
surgery_started_wall: float | None = None
#: 术间绑定配置解析出的语音桌面终端 ID停录时用于推送 end。
voice_terminal_id: str | None = None
@dataclass

View File

@@ -0,0 +1,84 @@
"""术间配置camera_ids 集合与语音桌面终端 ID 绑定。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from loguru import logger
@dataclass(frozen=True)
class OrRoomBinding:
or_room_id: str
camera_ids: frozenset[str]
voice_terminal_id: str
@dataclass
class VoiceTerminalBindingIndex:
"""由 ``or_site_config.voice_or_room_bindings`` 数组构建。"""
rooms: tuple[OrRoomBinding, ...]
def resolve_terminal(self, camera_ids: list[str]) -> str | None:
"""精确匹配 camera 集合;否则开录路数为术间子集时匹配最小超集术间。"""
key = frozenset(str(x).strip() for x in camera_ids if str(x).strip())
if not key:
return None
for r in self.rooms:
if r.camera_ids == key:
return r.voice_terminal_id
candidates = [r for r in self.rooms if key <= r.camera_ids]
if not candidates:
return None
if len(candidates) == 1:
return candidates[0].voice_terminal_id
candidates.sort(key=lambda r: (len(r.camera_ids), r.or_room_id, r.voice_terminal_id))
return candidates[0].voice_terminal_id
@staticmethod
def from_binding_list(data: list[Any]) -> VoiceTerminalBindingIndex | None:
rows: list[OrRoomBinding] = []
seen_terminals: set[str] = set()
seen_camera_sets: set[frozenset[str]] = set()
for i, item in enumerate(data):
if not isinstance(item, dict):
logger.warning("voice_or_room_bindings[{}] must be an object", i)
return None
rid = str(item.get("or_room_id") or "").strip()
tid = str(item.get("voice_terminal_id") or "").strip()
cams = item.get("camera_ids")
if not rid or not tid or not isinstance(cams, list):
logger.warning(
"voice_or_room_bindings[{}] missing or invalid fields", i
)
return None
cam_set = frozenset(str(x).strip() for x in cams if str(x).strip())
if not cam_set:
logger.warning(
"voice_or_room_bindings[{}] camera_ids must be non-empty", i
)
return None
if tid in seen_terminals:
logger.warning(
"voice_or_room_bindings: duplicate voice_terminal_id {!r}",
tid,
)
return None
if cam_set in seen_camera_sets:
logger.warning(
"voice_or_room_bindings: duplicate camera_ids set for room {!r}",
rid,
)
return None
seen_terminals.add(tid)
seen_camera_sets.add(cam_set)
rows.append(
OrRoomBinding(
or_room_id=rid,
camera_ids=cam_set,
voice_terminal_id=tid,
)
)
return VoiceTerminalBindingIndex(rooms=tuple(rows))

View File

@@ -0,0 +1,152 @@
"""语音桌面终端assignment 状态、WebSocket 推送与 HTTP 轮询兜底。"""
from __future__ import annotations
import json
from asyncio import Lock
from collections import defaultdict
from collections.abc import Callable
from fastapi import WebSocket
from loguru import logger
from starlette.websockets import WebSocketDisconnect
from app.config import Settings
from app.services.voice_terminal_binding import VoiceTerminalBindingIndex
async def assign_voice_terminal_after_recording_started(
hub: VoiceTerminalHub,
*,
surgery_id: str,
camera_ids: list[str],
set_voice_terminal_id: Callable[[str, str | None], None],
) -> None:
"""开录成功后:按站点绑定解析终端、写入会话、并 WebSocket 推送 start与 HTTP 开录一致)。"""
voice_tid = hub.resolve_terminal(list(camera_ids))
if voice_tid:
set_voice_terminal_id(surgery_id, voice_tid)
await hub.notify_start(voice_tid, surgery_id)
elif hub.bindings is not None:
logger.warning(
"voice or room bindings have no camera set matching start "
"surgery_id={} camera_ids={}",
surgery_id,
camera_ids,
)
class VoiceTerminalHub:
"""进程内终端连接与当前手术分配(多 worker 需另行同步)。"""
def __init__(self, settings: Settings) -> None:
cfg = settings.load_or_site_config()
self._bindings = cfg.voice_bindings if cfg else None
self._assignments: dict[str, str] = {}
self._lock = Lock()
self._connections: dict[str, set[WebSocket]] = defaultdict(set)
@property
def bindings(self) -> VoiceTerminalBindingIndex | None:
return self._bindings
def resolve_terminal(self, camera_ids: list[str]) -> str | None:
if self._bindings is None:
return None
return self._bindings.resolve_terminal(camera_ids)
def get_assignment(self, terminal_id: str) -> str | None:
return self._assignments.get(terminal_id.strip())
async def notify_start(self, terminal_id: str, surgery_id: str) -> None:
tid = terminal_id.strip()
if not tid:
return
payload = {
"type": "voice_assignment",
"action": "start",
"surgery_id": surgery_id,
}
async with self._lock:
self._assignments[tid] = surgery_id
await self._broadcast(tid, payload)
logger.info(
"Voice terminal {} assigned surgery {} (start push)",
tid,
surgery_id,
)
async def notify_end(self, terminal_id: str | None, surgery_id: str) -> None:
if not terminal_id:
return
tid = terminal_id.strip()
if not tid:
return
payload = {
"type": "voice_assignment",
"action": "end",
"surgery_id": surgery_id,
}
async with self._lock:
if self._assignments.get(tid) == surgery_id:
del self._assignments[tid]
await self._broadcast(tid, payload)
logger.info(
"Voice terminal {} released surgery {} (end push)",
tid,
surgery_id,
)
async def handle_websocket(self, websocket: WebSocket, terminal_id: str) -> None:
tid = terminal_id.strip()
if not tid:
await websocket.close(code=4400)
return
await websocket.accept()
async with self._lock:
self._connections[tid].add(websocket)
try:
# 连接后立即推送当前 assignment避免错过 start
sid = self._assignments.get(tid)
if sid:
await websocket.send_text(
json.dumps(
{
"type": "voice_assignment",
"action": "start",
"surgery_id": sid,
},
ensure_ascii=False,
)
)
# 不能用 receive_text():桌面端 websocket-client 会发 ping/二进制控制帧,
# ASGI 可能呈现为无 "text" 的 websocket.receivereceive_text 会 KeyError 并掐断连接。
while True:
message = await websocket.receive()
if message["type"] == "websocket.disconnect":
break
except WebSocketDisconnect:
pass
finally:
async with self._lock:
conns = self._connections.get(tid)
if conns:
conns.discard(websocket)
if not conns:
del self._connections[tid]
async def _broadcast(self, terminal_id: str, payload: dict) -> None:
text = json.dumps(payload, ensure_ascii=False)
async with self._lock:
targets = list(self._connections.get(terminal_id, ()))
dead: list[WebSocket] = []
for ws in targets:
try:
await ws.send_text(text)
except Exception as exc:
logger.debug("voice terminal ws send failed: {}", exc)
dead.append(ws)
if dead:
async with self._lock:
for ws in dead:
self._connections[terminal_id].discard(ws)