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:
70
app/api.py
70
app/api.py
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user