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,135 @@
"""解耦测试:用 fake PendingConfirmationStore 验证 VoiceConfirmationService 对端口的依赖。
该用例不构造完整的 CameraSessionManager验证 Phase 5 引入的协议可替换性。
"""
from __future__ import annotations
from dataclasses import dataclass, field
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.voice_audits import VoiceAuditRepository
from app.services.pending_confirmation_port import PendingConfirmationStore
from app.services.video.session_manager import PendingConsumableConfirmation
from app.services.voice_resolution import VoiceConfirmationService
from app.surgery_errors import SurgeryPipelineError
@dataclass
class _FakePendingStore:
"""与 PendingConfirmationStore 协议等价的可控 fake不依赖 CameraSessionManager。"""
pendings: dict[tuple[str, str], PendingConsumableConfirmation] = field(
default_factory=dict
)
candidates: dict[str, list[str]] = field(default_factory=dict)
resolved: list[tuple[str, str, str | None, bool]] = field(default_factory=list)
traces: list[dict[str, str | None]] = field(default_factory=list)
parse_failures: dict[str, int] = field(default_factory=dict)
def get_pending_confirmation_by_id(
self, surgery_id: str, confirmation_id: str
) -> PendingConsumableConfirmation | None:
return self.pendings.get((surgery_id, confirmation_id))
def get_surgery_candidate_consumables(self, surgery_id: str) -> list[str]:
return list(self.candidates.get(surgery_id, []))
async def record_voice_parse_failure(
self, surgery_id: str, confirmation_id: str
) -> tuple[int, int]:
key = f"{surgery_id}:{confirmation_id}"
self.parse_failures[key] = self.parse_failures.get(key, 0) + 1
remaining = max(0, 2 - self.parse_failures[key])
return self.parse_failures[key], remaining
async def resolve_pending_confirmation(
self,
surgery_id: str,
confirmation_id: str,
*,
chosen_label: str | None,
rejected: bool,
) -> None:
self.resolved.append((surgery_id, confirmation_id, chosen_label, rejected))
def record_voice_trace(
self,
surgery_id: str,
*,
asr_text: str | None,
error: str | None,
) -> None:
self.traces.append(
{"surgery_id": surgery_id, "asr_text": asr_text, "error": error}
)
def test_fake_store_satisfies_protocol() -> None:
"""_FakePendingStore 必须符合 PendingConfirmationStore 协议(静态/运行时同时验证)。"""
store = _FakePendingStore()
assert isinstance(store, PendingConfirmationStore)
@pytest.mark.asyncio
async def test_resolve_from_recognized_text_with_fake_store(
sqlite_session_factory: async_sessionmaker[AsyncSession],
) -> None:
store = _FakePendingStore()
surgery_id = "123456"
confirmation_id = "cid-a"
store.pendings[(surgery_id, confirmation_id)] = PendingConsumableConfirmation(
id=confirmation_id,
status="pending",
options=[("纱布", 0.4), ("缝线", 0.3)],
prompt_text="请确认",
created_at=datetime.now(timezone.utc),
model_top1_label="纱布",
model_top1_confidence=0.4,
)
store.candidates[surgery_id] = ["纱布", "缝线"]
svc = VoiceConfirmationService(
settings=Settings(),
sessions=store,
baidu=MagicMock(),
minio=MagicMock(),
audits=VoiceAuditRepository(),
session_factory=sqlite_session_factory,
)
result = await svc.resolve_from_recognized_text(
surgery_id=surgery_id,
confirmation_id=confirmation_id,
recognized_text="第一个",
)
assert result.resolved_label == "纱布"
assert result.rejected is False
assert store.resolved == [(surgery_id, confirmation_id, "纱布", False)]
@pytest.mark.asyncio
async def test_resolve_from_recognized_text_not_found_branch(
sqlite_session_factory: async_sessionmaker[AsyncSession],
) -> None:
store = _FakePendingStore()
svc = VoiceConfirmationService(
settings=Settings(),
sessions=store,
baidu=MagicMock(),
minio=MagicMock(),
audits=VoiceAuditRepository(),
session_factory=sqlite_session_factory,
)
with pytest.raises(SurgeryPipelineError) as excinfo:
await svc.resolve_from_recognized_text(
surgery_id="000000",
confirmation_id="missing",
recognized_text="第一个",
)
assert excinfo.value.code == "CONFIRMATION_NOT_FOUND"