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

@@ -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",