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

@@ -10,11 +10,10 @@ import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.config import Settings
from app.domain.consumption import SurgeryConsumptionStored
from app.repositories.surgery_results import SurgeryResultRepository
from app.schemas import SurgeryConsumptionStored
from app.services.surgery_pipeline import SurgeryPipeline
from app.services.video.session_manager import (
ArchivedSurgery,
CameraSessionManager,
RunningSurgery,
SurgerySessionState,
@@ -22,26 +21,17 @@ from app.services.video.session_manager import (
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,
def _install_active(mgr: CameraSessionManager, surgery_id: str, state: SurgerySessionState) -> None:
"""测试辅助:直接把一条 RunningSurgery 塞进注册表,跳过真实 camera worker。"""
mgr._registry._active[surgery_id] = RunningSurgery(
stop_event=asyncio.Event(), state=state, tasks=[]
)
@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(
@@ -49,6 +39,7 @@ async def test_stop_surgery_persists_final_result(
vision_algorithm=MagicMock(),
hikvision_runtime=None,
result_repository=repo,
session_factory=sqlite_session_factory,
)
ts = datetime(2026, 4, 21, 12, 0, tzinfo=timezone.utc)
st = SurgerySessionState(candidate_consumables=["纱布"])
@@ -63,9 +54,7 @@ async def test_stop_surgery_persists_final_result(
)
)
st.ready.set()
mgr._active["123456"] = RunningSurgery(
stop_event=asyncio.Event(), state=st, tasks=[]
)
_install_active(mgr, "123456", st)
await mgr.stop_surgery("123456", require_active=True)
@@ -75,7 +64,7 @@ async def test_stop_surgery_persists_final_result(
assert loaded is not None
assert len(loaded) == 1
assert loaded[0].item_id == "纱布"
assert mgr._archive.get("123456") is None
assert mgr.archived_consumption_fallback("123456") is None
class _FlakyResultRepo(SurgeryResultRepository):
@@ -93,16 +82,19 @@ class _FlakyResultRepo(SurgeryResultRepository):
@pytest.mark.asyncio
async def test_stop_surgery_failed_persist_goes_to_archive_then_retry_persists(
sqlite_session_factory: async_sessionmaker[AsyncSession],
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_db_sessions(sqlite_session_factory, monkeypatch)
repo = _FlakyResultRepo()
settings = Settings()
settings = Settings(
archive_persist_durable_fallback_dir=str(tmp_path / "pending_archive")
)
mgr = CameraSessionManager(
settings=settings,
vision_algorithm=MagicMock(),
hikvision_runtime=None,
result_repository=repo,
session_factory=sqlite_session_factory,
)
ts = datetime(2026, 4, 21, 12, 0, tzinfo=timezone.utc)
st = SurgerySessionState(candidate_consumables=[])
@@ -116,18 +108,21 @@ async def test_stop_surgery_failed_persist_goes_to_archive_then_retry_persists(
source="vision",
)
)
mgr._active["654321"] = RunningSurgery(
stop_event=asyncio.Event(), state=st, tasks=[]
)
_install_active(mgr, "654321", st)
await mgr.stop_surgery("654321", require_active=True)
assert "654321" in mgr._archive
assert mgr.archived_consumption_fallback("654321") is not None
assert repo.calls == 1
ok = await mgr._try_persist_archive("654321")
# durable fallback 文件应已写入
durable = tmp_path / "pending_archive" / "654321.json"
assert durable.exists()
ok = await mgr._archive.try_persist_archive("654321")
assert ok is True
assert "654321" not in mgr._archive
assert mgr.archived_consumption_fallback("654321") is None
assert repo.calls == 2
assert not durable.exists(), "成功落库后 durable 文件应被清理"
async with sqlite_session_factory() as session:
async with session.begin():
@@ -140,9 +135,7 @@ async def test_stop_surgery_failed_persist_goes_to_archive_then_retry_persists(
@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(
@@ -150,12 +143,14 @@ async def test_pipeline_prefers_live_then_db_then_archive(
vision_algorithm=MagicMock(),
hikvision_runtime=None,
result_repository=repo,
session_factory=sqlite_session_factory,
)
voice = MagicMock(spec=VoiceConfirmationService)
pipeline = SurgeryPipeline(
mgr,
result_repository=repo,
voice_confirmation=voice,
session_factory=sqlite_session_factory,
)
ts = datetime(2026, 4, 21, 12, 0, tzinfo=timezone.utc)
@@ -171,9 +166,7 @@ async def test_pipeline_prefers_live_then_db_then_archive(
)
)
st.ready.set()
mgr._active["111111"] = RunningSurgery(
stop_event=asyncio.Event(), state=st, tasks=[]
)
_install_active(mgr, "111111", st)
live = await pipeline.get_consumption_details_for_client("111111")
assert live is not None
@@ -186,8 +179,9 @@ async def test_pipeline_prefers_live_then_db_then_archive(
assert len(from_db) == 1
assert from_db[0].item_id == "纱布"
mgr._archive["333333"] = ArchivedSurgery(
details=[
await mgr._archive.restore(
"333333",
[
SurgeryConsumptionStored(
item_id="归档项",
item_name="归档项",
@@ -196,7 +190,7 @@ async def test_pipeline_prefers_live_then_db_then_archive(
timestamp=ts,
source="vision",
)
]
],
)
only_archive = await pipeline.get_consumption_details_for_client("333333")
assert only_archive is not None