feat: surgery pipeline API, video inference, voice confirm, and tests

- Add FastAPI routes for surgery start/end, results, pending confirmation (WAV upload), and health checks.
- Implement RTSP/Hikvision capture, consumable classification, session manager, MinIO/Baidu voice resolution, and DB persistence.
- Add documentation (client API, video backends, staging checklist) and sample camera/RTSP config.
- Add pytest suite (API contract, session manager, voice, repositories, pipeline persistence) and httpx dev dependency.
- Replace deprecated HTTP_422_UNPROCESSABLE_ENTITY with HTTP_422_UNPROCESSABLE_CONTENT.
- Fix SurgeryPipeline DB reads to use an explicit transaction with autobegin disabled.

Made-with: Cursor
This commit is contained in:
Kevin
2026-04-21 18:33:54 +08:00
parent d1a3d029ec
commit 04866559db
56 changed files with 7196 additions and 43 deletions

View File

@@ -0,0 +1,103 @@
from __future__ import annotations
import json
from typing import Any
from loguru import logger
from app.config import Settings
from app.services.video.hikvision_runtime import HikvisionRuntime
from app.services.video.types import VideoBackendKind
class BackendResolver:
"""Resolve per-camera backend (RTSP vs Hikvision SDK) and RTSP URL."""
def __init__(
self,
settings: Settings,
*,
hikvision_runtime: HikvisionRuntime | None,
) -> None:
self._s = settings
self._hik = hikvision_runtime
self._rtsp_urls_map = settings.video_rtsp_url_map()
def _parse_json_object(self, raw: str) -> dict[str, Any]:
raw = (raw or "").strip()
if not raw:
return {}
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSON mapping: {exc}") from exc
if not isinstance(data, dict):
raise ValueError("JSON mapping must be an object")
return {str(k): v for k, v in data.items()}
def backend_for_camera(self, camera_id: str) -> VideoBackendKind:
overrides = self._parse_json_object(self._s.video_camera_backend_overrides_json)
if camera_id in overrides:
v = str(overrides[camera_id]).lower()
if v in ("rtsp", "hikvision_sdk", "sdk"):
return (
VideoBackendKind.HIKVISION_SDK
if v in ("hikvision_sdk", "sdk")
else VideoBackendKind.RTSP
)
default = self._s.video_default_backend.strip().lower()
if default in ("auto", ""):
if self._hik is not None and self._s.hikvision_sdk_enabled:
return VideoBackendKind.HIKVISION_SDK
return VideoBackendKind.RTSP
if default in ("hikvision_sdk", "sdk"):
return VideoBackendKind.HIKVISION_SDK
return VideoBackendKind.RTSP
def rtsp_url_for_camera(self, camera_id: str) -> str:
if camera_id in self._rtsp_urls_map:
return self._rtsp_urls_map[camera_id]
tpl = (self._s.video_rtsp_url_template or "").strip()
if tpl:
try:
return tpl.format(camera_id=camera_id)
except KeyError as exc:
raise ValueError(
f"video_rtsp_url_template missing placeholder: {exc}"
) from exc
raise ValueError(
f"No RTSP URL for camera_id={camera_id!r}: set VIDEO_RTSP_URLS_JSON_FILE, "
f"VIDEO_RTSP_URLS_JSON, or VIDEO_RTSP_URL_TEMPLATE"
)
def rtsp_url_after_hikvision_login(self, camera_id: str) -> str:
"""RTSP URL used after SDK login (often same as device preview URL)."""
urls = self._parse_json_object(self._s.hikvision_camera_rtsp_urls_json)
if camera_id in urls:
return str(urls[camera_id])
tpl = (self._s.hikvision_preview_rtsp_template or "").strip()
if not tpl:
logger.warning(
"Hikvision backend without HIKVISION_PREVIEW_RTSP_TEMPLATE / "
"HIKVISION_CAMERA_RTSP_URLS_JSON — falling back to generic RTSP map"
)
return self.rtsp_url_for_camera(camera_id)
return self._format_hikvision_rtsp(tpl, camera_id)
def _format_hikvision_rtsp(self, template: str, camera_id: str) -> str:
ip = self._s.hikvision_device_ip.strip()
user = self._s.hikvision_user.strip()
password = self._s.hikvision_password.strip()
channel = self._s.hikvision_channel
try:
return template.format(
camera_id=camera_id,
ip=ip,
user=user,
password=password,
channel=channel,
)
except KeyError as exc:
raise ValueError(
f"hikvision_preview_rtsp_template missing key: {exc}"
) from exc