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,206 @@
"""Surgery stop -> DB persist, archive retry, and SurgeryPipeline result resolution order."""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from unittest.mock import MagicMock
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.config import Settings
from app.repositories.surgery_results import SurgeryResultRepository
from app.schemas import SurgeryConsumptionDetail
from app.services.surgery_pipeline import SurgeryPipeline
from app.services.video.session_manager import (
ArchivedSurgery,
CameraSessionManager,
RunningSurgery,
SurgerySessionState,
)
from app.services.voice_resolution import VoiceConfirmationService
def _patch_db_sessions(
sqlite_session_factory: async_sessionmaker[AsyncSession],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"app.services.video.session_manager.AsyncSessionLocal",
sqlite_session_factory,
)
monkeypatch.setattr(
"app.services.surgery_pipeline.AsyncSessionLocal",
sqlite_session_factory,
)
@pytest.mark.asyncio
async def test_stop_surgery_persists_final_result(
sqlite_session_factory: async_sessionmaker[AsyncSession],
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_db_sessions(sqlite_session_factory, monkeypatch)
repo = SurgeryResultRepository()
settings = Settings()
mgr = CameraSessionManager(
settings=settings,
consumable_classifier=MagicMock(),
tear_action=MagicMock(),
hikvision_runtime=None,
result_repository=repo,
)
ts = datetime(2026, 4, 21, 12, 0, tzinfo=timezone.utc)
st = SurgerySessionState(candidate_consumables=["纱布"])
st.details.append(
SurgeryConsumptionDetail(
item_id="纱布",
item_name="纱布",
quantity=1,
doctor_id="vision",
timestamp=ts,
source="vision",
)
)
st.ready.set()
mgr._active["123456"] = RunningSurgery(
stop_event=asyncio.Event(), state=st, tasks=[]
)
await mgr.stop_surgery("123456", require_active=True)
async with sqlite_session_factory() as session:
async with session.begin():
loaded = await repo.load_final_details(session, "123456")
assert loaded is not None
assert len(loaded) == 1
assert loaded[0].item_id == "纱布"
assert mgr._archive.get("123456") is None
class _FlakyResultRepo(SurgeryResultRepository):
def __init__(self) -> None:
super().__init__()
self.calls = 0
async def save_final_result(self, session: AsyncSession, **kwargs: object) -> None:
self.calls += 1
if self.calls == 1:
raise RuntimeError("db unavailable")
return await super().save_final_result(session, **kwargs)
@pytest.mark.asyncio
async def test_stop_surgery_failed_persist_goes_to_archive_then_retry_persists(
sqlite_session_factory: async_sessionmaker[AsyncSession],
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_db_sessions(sqlite_session_factory, monkeypatch)
repo = _FlakyResultRepo()
settings = Settings()
mgr = CameraSessionManager(
settings=settings,
consumable_classifier=MagicMock(),
tear_action=MagicMock(),
hikvision_runtime=None,
result_repository=repo,
)
ts = datetime(2026, 4, 21, 12, 0, tzinfo=timezone.utc)
st = SurgerySessionState(candidate_consumables=[])
st.details.append(
SurgeryConsumptionDetail(
item_id="缝线",
item_name="缝线",
quantity=1,
doctor_id="vision",
timestamp=ts,
source="vision",
)
)
mgr._active["654321"] = RunningSurgery(
stop_event=asyncio.Event(), state=st, tasks=[]
)
await mgr.stop_surgery("654321", require_active=True)
assert "654321" in mgr._archive
assert repo.calls == 1
ok = await mgr._try_persist_archive("654321")
assert ok is True
assert "654321" not in mgr._archive
assert repo.calls == 2
async with sqlite_session_factory() as session:
async with session.begin():
loaded = await repo.load_final_details(session, "654321")
assert loaded is not None
assert len(loaded) == 1
assert loaded[0].item_id == "缝线"
@pytest.mark.asyncio
async def test_pipeline_prefers_live_then_db_then_archive(
sqlite_session_factory: async_sessionmaker[AsyncSession],
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_db_sessions(sqlite_session_factory, monkeypatch)
repo = SurgeryResultRepository()
settings = Settings()
mgr = CameraSessionManager(
settings=settings,
consumable_classifier=MagicMock(),
tear_action=MagicMock(),
hikvision_runtime=None,
result_repository=repo,
)
voice = MagicMock(spec=VoiceConfirmationService)
pipeline = SurgeryPipeline(
mgr,
result_repository=repo,
voice_confirmation=voice,
)
ts = datetime(2026, 4, 21, 12, 0, tzinfo=timezone.utc)
st = SurgerySessionState(candidate_consumables=["纱布"])
st.details.append(
SurgeryConsumptionDetail(
item_id="纱布",
item_name="纱布",
quantity=1,
doctor_id="vision",
timestamp=ts,
source="vision",
)
)
st.ready.set()
mgr._active["111111"] = RunningSurgery(
stop_event=asyncio.Event(), state=st, tasks=[]
)
live = await pipeline.get_consumption_details_for_client("111111")
assert live is not None
assert live[0].item_id == "纱布"
await mgr.stop_surgery("111111", require_active=True)
from_db = await pipeline.get_consumption_details_for_client("111111")
assert from_db is not None
assert len(from_db) == 1
assert from_db[0].item_id == "纱布"
mgr._archive["333333"] = ArchivedSurgery(
details=[
SurgeryConsumptionDetail(
item_id="归档项",
item_name="归档项",
quantity=1,
doctor_id="vision",
timestamp=ts,
source="vision",
)
]
)
only_archive = await pipeline.get_consumption_details_for_client("333333")
assert only_archive is not None
assert only_archive[0].item_id == "归档项"