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,121 @@
"""进程重启后的归档恢复集成测试。
场景:某次手术结束后写库失败 → ArchivePersister 将明细写入 durable fallback 目录。
之后 API 进程重启(相当于重新 ``create_app()``)时,``AppContainer.start()`` 会调用
``camera_session_manager.start_archive_retry_loop()`` → ``recover_from_durable_fallback()``
把磁盘上的待落库归档读回内存;随后走真实 DB 写入路径将其成功持久化。
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import AsyncGenerator
from datetime import datetime, timezone
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
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.archive_persister import _serialize_details
@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()
def _seed_durable_fallback(directory, surgery_id: str) -> None:
directory.mkdir(parents=True, exist_ok=True)
details = [
SurgeryConsumptionStored(
item_id="item-1",
item_name="纱布",
qty=2,
doctor_id="voice",
timestamp=datetime(2026, 4, 23, 12, 0, tzinfo=timezone.utc),
source="voice",
),
]
payload = {
"surgery_id": surgery_id,
"saved_at": datetime.now(timezone.utc).isoformat(),
"details": _serialize_details(details),
}
(directory / f"{surgery_id}.json").write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def test_durable_fallback_recovers_on_startup_and_persists(
monkeypatch: pytest.MonkeyPatch,
sqlite_factory: async_sessionmaker[AsyncSession],
tmp_path,
) -> None:
durable_dir = tmp_path / "pending_archive"
surgery_id = "200001"
_seed_durable_fallback(durable_dir, surgery_id)
assert (durable_dir / f"{surgery_id}.json").exists()
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(durable_dir)
)
monkeypatch.setattr(real_settings, "auto_create_schema", False)
monkeypatch.setattr(real_settings, "archive_persist_retry_interval_seconds", 5.0)
def _build(*_a, **_kw) -> AppContainer:
return build_container(real_settings, session_factory=sqlite_factory)
monkeypatch.setattr(main_module, "build_container", _build)
app = main_module.create_app()
with TestClient(app) as client:
container: AppContainer = client.app.state.container
archive = container.camera_session_manager._archive
assert archive.archived_details(surgery_id) is not None
ok = asyncio.get_event_loop().run_until_complete(
archive.try_persist_archive(surgery_id)
)
assert ok, "Expected immediate retry to persist against sqlite"
assert archive.archived_details(surgery_id) is None
assert not (durable_dir / f"{surgery_id}.json").exists()
r = client.get(f"/client/surgeries/{surgery_id}/result")
assert r.status_code == 200, r.text
body = r.json()
assert len(body["details"]) == 1
assert body["details"][0]["item_name"] == "纱布"
assert body["details"][0]["qty"] == 2