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

@@ -4,7 +4,10 @@ from __future__ import annotations
import base64
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.database import AsyncSessionLocal
from app.domain.consumption import SurgeryConsumptionStored
from app.repositories.surgery_results import SurgeryResultRepository
from app.schemas import (
PendingConfirmationOption,
@@ -18,6 +21,20 @@ from app.services.voice_resolution import VoiceConfirmationService, VoiceResolve
from app.surgery_errors import SurgeryPipelineError
def _stored_to_response(rows: list[SurgeryConsumptionStored]) -> list[SurgeryConsumptionDetail]:
"""领域对象 → HTTP DTO 的单向转换,仅在返回给客户端的边界调用。"""
return [
SurgeryConsumptionDetail(
item_id=r.item_id,
item_name=r.item_name,
qty=r.qty,
doctor_id=r.doctor_id,
timestamp=r.timestamp,
)
for r in rows
]
class SurgeryPipeline:
"""协调开录、停录与算法产出。路由仅在子系统确认后返回 HTTP 200。"""
@@ -27,10 +44,12 @@ class SurgeryPipeline:
*,
result_repository: SurgeryResultRepository,
voice_confirmation: VoiceConfirmationService,
session_factory: async_sessionmaker | None = None,
) -> None:
self._sessions = sessions
self._repo = result_repository
self._voice = voice_confirmation
self._session_factory: async_sessionmaker = session_factory or AsyncSessionLocal
async def start_recording(
self,
@@ -72,13 +91,16 @@ class SurgeryPipeline:
"""进行中:返回内存明细;已结束:返回数据库最终结果;持久化失败时回退内存归档。"""
live = self._sessions.live_consumption_if_active(surgery_id)
if live is not None:
return live
async with AsyncSessionLocal() as session:
return _stored_to_response(live)
async with self._session_factory() as session:
async with session.begin():
persisted = await self._repo.load_final_details(session, surgery_id)
if persisted is not None:
return persisted
return self._sessions.archived_consumption_fallback(surgery_id)
return _stored_to_response(persisted)
archived = self._sessions.archived_consumption_fallback(surgery_id)
if archived is not None:
return _stored_to_response(archived)
return None
async def get_pending_confirmation_for_client(
self, surgery_id: str