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,10 @@
from __future__ import annotations
import asyncio
import json
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -10,7 +13,12 @@ from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.api import router as api_router
from app.dependencies import get_surgery_pipeline
from app.dependencies import get_surgery_pipeline, get_voice_terminal_hub
from app.services.voice_terminal_hub import (
VoiceTerminalHub,
assign_voice_terminal_after_recording_started,
)
from app.config import Settings
from app.schemas import (
SurgeryConsumptionDetail,
SurgeryPendingConfirmationResponse,
@@ -36,6 +44,8 @@ def api_app(monkeypatch: pytest.MonkeyPatch) -> FastAPI:
app = FastAPI()
app.include_router(api_router)
hub = VoiceTerminalHub(Settings())
app.dependency_overrides[get_voice_terminal_hub] = lambda: hub
return app
@@ -66,6 +76,98 @@ def test_start_surgery_accepted(api_app: FastAPI, instant_sleep: None) -> None:
pipeline.start_recording.assert_awaited_once()
def test_assign_voice_terminal_helper_matches_start_surgery_behavior(
tmp_path: Path,
) -> None:
site = {
"video_rtsp_urls": {"cam1": "rtsp://x/c1"},
"voice_or_room_bindings": [
{
"or_room_id": "R1",
"camera_ids": ["cam1"],
"voice_terminal_id": "TERM-X",
}
],
}
p = tmp_path / "site.json"
p.write_text(json.dumps(site), encoding="utf-8")
hub = VoiceTerminalHub(Settings(or_site_config_json_file=str(p)))
set_vtid = MagicMock()
async def _run() -> None:
await assign_voice_terminal_after_recording_started(
hub,
surgery_id="123456",
camera_ids=["cam1"],
set_voice_terminal_id=set_vtid,
)
asyncio.run(_run())
set_vtid.assert_called_once_with("123456", "TERM-X")
assert hub.get_assignment("TERM-X") == "123456"
def test_start_surgery_notifies_voice_terminal_when_binding_matches(
api_app: FastAPI, instant_sleep: None, tmp_path: Path
) -> None:
site = {
"video_rtsp_urls": {"cam1": "rtsp://x/c1", "cam2": "rtsp://x/c2"},
"voice_or_room_bindings": [
{
"or_room_id": "R1",
"camera_ids": ["cam1", "cam2"],
"voice_terminal_id": "TERM-OR1",
}
],
}
p = tmp_path / "site.json"
p.write_text(json.dumps(site), encoding="utf-8")
hub = VoiceTerminalHub(Settings(or_site_config_json_file=str(p)))
api_app.dependency_overrides[get_voice_terminal_hub] = lambda: hub
pipeline = MagicMock()
pipeline.start_recording = AsyncMock(return_value=None)
pipeline.set_voice_terminal_id = MagicMock()
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline
client = TestClient(api_app)
r = client.post(
"/client/surgeries/start",
json={
"surgery_id": "123456",
"camera_ids": ["cam2", "cam1"],
"candidate_consumables": ["纱布"],
},
)
assert r.status_code == 200
pipeline.set_voice_terminal_id.assert_called_once_with("123456", "TERM-OR1")
assert hub.get_assignment("TERM-OR1") == "123456"
def test_voice_terminal_assignment_get(api_app: FastAPI) -> None:
hub = api_app.dependency_overrides[get_voice_terminal_hub]()
asyncio.run(hub.notify_start("t-assign", "999999"))
client = TestClient(api_app)
r = client.get("/client/voice-terminals/t-assign/assignment")
assert r.status_code == 200
assert r.json() == {
"voice_terminal_id": "t-assign",
"active_surgery_id": "999999",
}
def test_end_surgery_notifies_voice_terminal(
api_app: FastAPI, instant_sleep: None
) -> None:
hub = api_app.dependency_overrides[get_voice_terminal_hub]()
asyncio.run(hub.notify_start("t-end", "123456"))
pipeline = MagicMock()
pipeline.stop_recording = AsyncMock(return_value="t-end")
api_app.dependency_overrides[get_surgery_pipeline] = lambda: pipeline
client = TestClient(api_app)
r = client.post("/client/surgeries/end", json={"surgery_id": "123456"})
assert r.status_code == 200
assert hub.get_assignment("t-end") is None
def test_start_surgery_503_on_pipeline_error(
api_app: FastAPI, instant_sleep: None
) -> None: