feat: 手术视频消耗、待确认与持久化改造

- 新增 Alembic 初始迁移、领域明细模型及归档持久化与重试链路\n- 拆分视频会话注册表、分类处理、推理时间窗聚合与流处理\n- 消耗日志:TSV/Markdown 含 top2/top3;item_id 优先产品编码;待确认记「待确认」行,语音确认后落正式行并更新汇总\n- 待确认时内存/DB 明细为占位行,确认后替换;拒绝时移除占位\n- 分类 probs 先 detach/cpu 再转 NumPy,修复 MPS/CUDA 上推理被静默跳过\n- 补充集成测试、归档与设备张量等单测

Made-with: Cursor
This commit is contained in:
Kevin
2026-04-23 20:42:21 +08:00
parent 69980d8073
commit 3d7bd70355
55 changed files with 4544 additions and 2050 deletions

View File

@@ -0,0 +1,305 @@
"""集成测试:通过真实的 ``create_app()`` + ``TestClient`` 走通 start/pending/resolve/end/result 全链路。
与 ``tests/test_api_contract.py`` 不同,这里不用 ``dependency_overrides`` 替换整个
``SurgeryPipeline``;而是通过 ``app.state.container`` 注入一个「stubbed session manager」
其余 pipeline/voice/repository 组件都保持真实实现,并使用 in-memory SQLite 作为会话工厂。
这样可以覆盖:
1. `create_app()` 的 CORS / demo_orchestrator 路径挂载、lifespan 启动/关闭流程。
2. API → Pipeline → Session Registry → Repository 的真实调用链。
3. durable fallback 目录被 ``ArchivePersister`` 写入/清理的真实路径。
所有会与外界交互的边界RTSP/海康/MinIO/百度)通过容器中的 stub 对象隔离。
"""
from __future__ import annotations
import asyncio
from collections.abc import AsyncGenerator
from datetime import datetime, timezone
from typing import Any
import pytest
import pytest_asyncio
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
import app.db.models # noqa: F401 # register ORM tables on Base.metadata
import main as main_module
from app.db.base import Base
from app.dependencies import AppContainer, build_container
from app.domain.consumption import SurgeryConsumptionStored
from app.services.video.session_registry import (
PendingConsumableConfirmation,
RunningSurgery,
SurgerySessionState,
)
class _StubCameraSessionManager:
"""仅实现测试链路必需方法;其余方法委托 ``_registry`` / ``_archive``(由真实组件提供)。
这样 ``SurgeryPipeline`` / ``VoiceConfirmationService`` 读到的接口与真实 CameraSessionManager
等价,不需要真实 RTSP 或推理流水线。
"""
def __init__(self, real: Any) -> None:
self._real = real
self._registry = real._registry
self._archive = real._archive
async def start_surgery(
self,
surgery_id: str,
camera_ids: list[str],
candidate_consumables: list[str],
) -> None:
if self._registry.has_active(surgery_id):
from app.surgery_errors import SurgeryPipelineError
raise SurgeryPipelineError(
"RECORDING_CANNOT_START",
"该手术已在录制中,请勿重复开始。",
)
state = SurgerySessionState(
candidate_consumables=list(candidate_consumables),
name_to_code={},
)
state.ready.set()
run = RunningSurgery(
stop_event=asyncio.Event(), state=state, tasks=[]
)
await self._registry.register(surgery_id, run)
async def stop_surgery(
self, surgery_id: str, *, require_active: bool = True
) -> None:
run = await self._registry.unregister(surgery_id)
if run is None:
if require_active:
from app.surgery_errors import SurgeryPipelineError
raise SurgeryPipelineError(
"RECORDING_NOT_STOPPED",
"停录未能完成:当前没有该手术的活跃录制会话。",
)
return
details = list(run.state.details)
await self._archive.persist_or_archive(surgery_id, details)
def __getattr__(self, name: str) -> Any:
return getattr(self._real, name)
class _StubVoiceService:
"""屏蔽 MinIO/百度调用;保留 ``synthesize_prompt_to_mp3`` 与 ``resolve_from_wav`` 的最小语义。"""
def __init__(self, real: Any) -> None:
self._real = real
self._sessions = real._sessions
def synthesize_prompt_to_mp3(self, prompt_text: str) -> bytes:
return b"MP3-FAKE-" + prompt_text.encode("utf-8", errors="replace")
async def resolve_from_wav(
self,
*,
surgery_id: str,
confirmation_id: str,
wav_bytes: bytes,
filename: str,
content_type: str | None,
) -> Any:
from app.services.voice_resolution import VoiceResolveResult
from app.surgery_errors import SurgeryPipelineError
pending = self._sessions.get_pending_confirmation_by_id(
surgery_id, confirmation_id
)
if pending is None:
raise SurgeryPipelineError(
"CONFIRMATION_NOT_FOUND",
"未找到该待确认项或已处理。",
)
label = (pending.options[0][0] if pending.options else None) or (
pending.model_top1_label
)
await self._sessions.resolve_pending_confirmation(
surgery_id,
confirmation_id,
chosen_label=label,
rejected=False,
)
return VoiceResolveResult(
resolved_label=label,
rejected=False,
asr_text="第一个",
audio_object_key=f"stub/{surgery_id}/{confirmation_id}.wav",
message="确认成功stub",
)
@pytest_asyncio.fixture
async def sqlite_factory() -> AsyncGenerator[async_sessionmaker[AsyncSession], None]:
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
autobegin=False,
)
yield factory
await engine.dispose()
@pytest.fixture
def integration_client(
monkeypatch: pytest.MonkeyPatch,
sqlite_factory: async_sessionmaker[AsyncSession],
tmp_path,
) -> TestClient:
async def _noop() -> None:
return None
monkeypatch.setattr(main_module, "check_database", _noop)
monkeypatch.setattr(main_module, "init_db_schema", _noop)
class _FakeEngine:
async def dispose(self) -> None:
return None
monkeypatch.setattr(main_module, "engine", _FakeEngine())
from app.config import settings as real_settings
monkeypatch.setattr(
real_settings,
"archive_persist_durable_fallback_dir",
str(tmp_path / "pending_archive"),
)
monkeypatch.setattr(real_settings, "auto_create_schema", False)
def _stubbed_build_container(*args, **kwargs) -> AppContainer:
container = build_container(real_settings, session_factory=sqlite_factory)
container.camera_session_manager = _StubCameraSessionManager(
container.camera_session_manager
)
container.surgery_pipeline._sessions = container.camera_session_manager
container.voice_confirmation_service._sessions = (
container.camera_session_manager._registry
)
container.surgery_pipeline._voice = _StubVoiceService(
container.surgery_pipeline
)
return container
monkeypatch.setattr(main_module, "build_container", _stubbed_build_container)
async def _instant_sleep(_d: float) -> None:
return None
monkeypatch.setattr("app.api.asyncio.sleep", _instant_sleep)
app = main_module.create_app()
with TestClient(app) as client:
yield client
def _enqueue_pending(
client: TestClient, *, surgery_id: str
) -> str:
container: AppContainer = client.app.state.container
run = container.camera_session_manager._registry.get_running(surgery_id)
assert run is not None
cid = "cid-integration-1"
pending = PendingConsumableConfirmation(
id=cid,
status="pending",
options=[("纱布", 0.42)],
prompt_text="请确认:是否为纱布",
created_at=datetime.now(timezone.utc),
model_top1_label="纱布",
model_top1_confidence=0.42,
)
run.state.pending_fifo.append(cid)
run.state.pending_by_id[cid] = pending
return cid
def test_full_flow_start_pending_resolve_end_result(
integration_client: TestClient,
) -> None:
client = integration_client
surgery_id = "100001"
r = client.post(
"/client/surgeries/start",
json={
"surgery_id": surgery_id,
"camera_ids": ["cam1"],
"candidate_consumables": ["纱布"],
},
)
assert r.status_code == 200, r.text
assert r.json()["status"] == "accepted"
r2 = client.get(f"/client/surgeries/{surgery_id}/pending-confirmation")
assert r2.status_code == 404, r2.text
cid = _enqueue_pending(client, surgery_id=surgery_id)
r3 = client.get(f"/client/surgeries/{surgery_id}/pending-confirmation")
assert r3.status_code == 200, r3.text
body3 = r3.json()
assert body3["confirmation_id"] == cid
import base64
decoded = base64.b64decode(body3["prompt_audio_mp3_base64"].encode("ascii"))
assert decoded.startswith(b"MP3-FAKE-")
r4 = client.post(
f"/client/surgeries/{surgery_id}/pending-confirmation/{cid}/resolve",
files={"audio": ("voice.wav", b"RIFFxxxx", "audio/wav")},
)
assert r4.status_code == 200, r4.text
body4 = r4.json()
assert body4["resolved_label"] == "纱布"
assert body4["rejected"] is False
r5 = client.get(f"/client/surgeries/{surgery_id}/result")
assert r5.status_code == 200, r5.text
body5 = r5.json()
assert body5["surgery_id"] == surgery_id
assert len(body5["details"]) == 1
row = body5["details"][0]
assert row["item_name"] == "纱布"
assert row["qty"] == 1
assert row["doctor_id"] == "voice"
r6 = client.post(
"/client/surgeries/end", json={"surgery_id": surgery_id}
)
assert r6.status_code == 200, r6.text
r7 = client.get(f"/client/surgeries/{surgery_id}/result")
assert r7.status_code == 200, r7.text
body7 = r7.json()
assert len(body7["details"]) == 1
assert body7["details"][0]["item_name"] == "纱布"
def test_result_not_ready_before_start(integration_client: TestClient) -> None:
r = integration_client.get("/client/surgeries/999999/result")
assert r.status_code == 503
assert r.json()["detail"]["code"] == "RESULT_NOT_READY"
def test_health_endpoint_ok_via_real_app(integration_client: TestClient) -> None:
r = integration_client.get("/health")
assert r.status_code == 200
body = r.json()
assert body["status"] == "ok"
assert body["database"] == "connected"