feat: 语音确认、联调与运维增强

- 语音:序数解析(第一个/第二个等)、解析失败计数与 API detail.retry_remaining;
  百度 ASR 固定 dev_pid 为普通话;SurgeryPipelineError 支持 extra 并入 HTTP detail。
- Demo:demo 路由与假 RTSP、客户端 index 与 README;BackendResolver 与配置调整。
- 可观测:消耗 TSV 日志、语音文件日志、终端 Markdown 辅助;相关测试与依赖更新。
- 注意:.env 仍被 gitignore,本地密钥不会进入本提交。

Made-with: Cursor
This commit is contained in:
Kevin
2026-04-23 14:24:20 +08:00
parent 42720f81cf
commit 0c05463617
39 changed files with 3030 additions and 143 deletions

189
app/routers/demo_orch.py Normal file
View File

@@ -0,0 +1,189 @@
"""Dev-only: upload two videos, start synthetic RTSP, write RTSP URL file, then start surgery."""
from __future__ import annotations
import json
import shutil
import tempfile
from pathlib import Path
from typing import Annotated
import anyio
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from loguru import logger
from app.config import settings
from app.dependencies import get_surgery_pipeline
from app.schemas import SurgeryApiResponse, SurgeryStartRequest
from app.services.synthetic_rtsp import StreamSpec, SyntheticRtspManager, write_rtsp_url_json_file
from app.services.surgery_pipeline import SurgeryPipeline
from app.surgery_errors import SurgeryPipelineError
router = APIRouter(prefix="/internal/demo", tags=["demo"])
def _orchestrate_write_rtsp_host() -> str:
"""Write JSON 里用于 RTSP 的主机名。
一键在本进程起 MediaMTX端口映射在**本机网络命名空间**的 127.0.0.1并拉流OpenCV
必须连 ``rtsp://127.0.0.1:port/...``。若改写成 ``host.docker.internal``,会指到
宿主机上的同端口,通常没有这路流,故 DESCRIBE 返回 404。
`DEMO_ORCHESTRATOR_RTSP_JSON_HOST` 对此路由无效;手填假流+仅改 JSON 的拓扑仍可用该配置。
"""
return "127.0.0.1"
@router.post(
"/orchestrate-and-start",
response_model=SurgeryApiResponse,
summary="一键联调:上传两路视频并开录",
description=(
"仅当 DEMO_ORCHESTRATOR_ENABLED=true。保存两路视频、启动 MediaMTX+ffmpeg、"
"将 RTSP 映射写入 VIDEO_RTSP_URLS_JSON_FILE再执行与 /client/surgeries/start 相同的开录逻辑。"
),
)
async def orchestrate_and_start(
video1: Annotated[UploadFile, File(description="第一路视频")],
video2: Annotated[UploadFile, File(description="第二路视频")],
surgery_id: Annotated[str, Form()],
camera_1: Annotated[str, Form()] = "or-cam-01",
camera_2: Annotated[str, Form()] = "or-cam-02",
rtsp_path_1: Annotated[str, Form()] = "demo1",
rtsp_path_2: Annotated[str, Form()] = "demo2",
candidate_consumables_json: Annotated[str, Form()] = "[]",
pipeline: SurgeryPipeline = Depends(get_surgery_pipeline),
) -> SurgeryApiResponse:
logger.info(
"demo orchestrate-and-start: surgery_id={} cameras={} {}",
surgery_id,
(camera_1, camera_2),
f"rpaths=({rtsp_path_1},{rtsp_path_2})",
)
if not settings.demo_orchestrator_enabled:
raise HTTPException(
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()
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; "
"in Docker, bind-mount a host file to this path."
),
)
json_path = Path(path_raw).expanduser()
try:
candidates = json.loads(candidate_consumables_json)
except json.JSONDecodeError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"invalid candidate_consumables_json: {exc}",
) from exc
if not isinstance(candidates, list) or not all(isinstance(x, str) for x in candidates):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="candidate_consumables_json must be a JSON array of strings",
)
try:
body = SurgeryStartRequest(
surgery_id=surgery_id,
camera_ids=[camera_1.strip(), camera_2.strip()],
candidate_consumables=[str(x) for x in candidates],
)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
ext1 = Path(video1.filename or "a.mp4").suffix or ".mp4"
ext2 = Path(video2.filename or "b.mp4").suffix or ".mp4"
v1_bytes = await video1.read()
v2_bytes = await video2.read()
if not v1_bytes or not v2_bytes:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Video files must be non-empty",
)
work_root = Path(tempfile.mkdtemp(prefix="orm-orch-"))
try:
fp1 = work_root / f"v1{ext1}"
fp2 = work_root / f"v2{ext2}"
def _save_files() -> None:
fp1.write_bytes(v1_bytes)
fp2.write_bytes(v2_bytes)
await anyio.to_thread.run_sync(_save_files)
except OSError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"failed to save uploads: {exc}",
) from exc
streams = [
StreamSpec(camera_id=body.camera_ids[0], file_path=fp1, rtsp_path=rtsp_path_1.strip() or "demo1"),
StreamSpec(camera_id=body.camera_ids[1], file_path=fp2, rtsp_path=rtsp_path_2.strip() or "demo2"),
]
port = int(settings.demo_orchestrator_rtsp_port)
try:
def _start_synth() -> dict[str, str]:
mgr = SyntheticRtspManager.get()
_run, url_map = mgr.start(streams, host_port=port, work_dir=work_root)
return url_map
url_map_host = await anyio.to_thread.run_sync(_start_synth)
except (FileNotFoundError, OSError, ValueError, RuntimeError) as exc:
logger.exception("synthetic RTSP start failed: {}", exc)
await anyio.to_thread.run_sync(SyntheticRtspManager.stop_active)
shutil.rmtree(work_root, ignore_errors=True)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"synthetic RTSP failed: {exc}",
) from exc
host_for_json = _orchestrate_write_rtsp_host()
try:
def _write() -> None:
write_rtsp_url_json_file(
json_path,
url_map_host,
replace_host=host_for_json,
)
await anyio.to_thread.run_sync(_write)
except OSError as exc:
await anyio.to_thread.run_sync(SyntheticRtspManager.stop_active)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"failed to write RTSP JSON file: {exc}",
) from exc
await anyio.sleep(0.2)
try:
await pipeline.start_recording(
body.surgery_id,
list(body.camera_ids),
list(body.candidate_consumables),
)
except SurgeryPipelineError as exc:
await anyio.to_thread.run_sync(SyntheticRtspManager.stop_active)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={"code": exc.code, "message": exc.message, "surgery_id": body.surgery_id},
) from exc
return SurgeryApiResponse(
surgery_id=body.surgery_id,
status="accepted",
message="假 RTSP 已起;映射已写入;摄像头录制已开始。",
)