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,