- 新增 Alembic 初始迁移、领域明细模型及归档持久化与重试链路\n- 拆分视频会话注册表、分类处理、推理时间窗聚合与流处理\n- 消耗日志:TSV/Markdown 含 top2/top3;item_id 优先产品编码;待确认记「待确认」行,语音确认后落正式行并更新汇总\n- 待确认时内存/DB 明细为占位行,确认后替换;拒绝时移除占位\n- 分类 probs 先 detach/cpu 再转 NumPy,修复 MPS/CUDA 上推理被静默跳过\n- 补充集成测试、归档与设备张量等单测 Made-with: Cursor
457 lines
14 KiB
Python
457 lines
14 KiB
Python
"""Tests for VoiceConfirmationService branches and audit persistence."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import io
|
|
import wave
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from sqlalchemy import func, select
|
|
|
|
from app.config import Settings
|
|
from app.db.models import VoiceConfirmationAudit
|
|
from app.repositories.voice_audits import VoiceAuditRepository
|
|
from app.services.minio_audio_storage import StoredAudio
|
|
from app.services.video.session_manager import (
|
|
CameraSessionManager,
|
|
PendingConsumableConfirmation,
|
|
RunningSurgery,
|
|
SurgerySessionState,
|
|
)
|
|
from app.services.voice_resolution import VoiceConfirmationService
|
|
from app.surgery_errors import SurgeryPipelineError
|
|
|
|
|
|
def _minimal_wav_16k_mono() -> bytes:
|
|
buf = io.BytesIO()
|
|
with wave.open(buf, "wb") as wf:
|
|
wf.setnchannels(1)
|
|
wf.setsampwidth(2)
|
|
wf.setframerate(16000)
|
|
wf.writeframes(b"\x00\x00" * 200)
|
|
return buf.getvalue()
|
|
|
|
|
|
def _make_service(
|
|
*,
|
|
settings: Settings,
|
|
sessions: CameraSessionManager,
|
|
minio: MagicMock,
|
|
baidu: MagicMock,
|
|
sqlite_factory,
|
|
) -> VoiceConfirmationService:
|
|
audits = VoiceAuditRepository()
|
|
return VoiceConfirmationService(
|
|
settings=settings,
|
|
sessions=sessions,
|
|
baidu=baidu,
|
|
minio=minio,
|
|
audits=audits,
|
|
session_factory=sqlite_factory,
|
|
)
|
|
|
|
|
|
def _active_session_with_pending(
|
|
surgery_id: str = "123456",
|
|
confirmation_id: str = "cid-a",
|
|
*,
|
|
candidate_consumables: list[str] | None = None,
|
|
pending_options: list[tuple[str, float]] | None = None,
|
|
) -> tuple[CameraSessionManager, str]:
|
|
settings = Settings()
|
|
mgr = CameraSessionManager(
|
|
settings=settings,
|
|
vision_algorithm=MagicMock(),
|
|
hikvision_runtime=None,
|
|
result_repository=None,
|
|
)
|
|
cands = candidate_consumables or ["纱布", "缝线"]
|
|
opts = pending_options or [("纱布", 0.4), ("缝线", 0.3)]
|
|
st = SurgerySessionState(candidate_consumables=cands)
|
|
st.pending_by_id[confirmation_id] = PendingConsumableConfirmation(
|
|
id=confirmation_id,
|
|
status="pending",
|
|
options=opts,
|
|
prompt_text="请确认",
|
|
created_at=datetime.now(timezone.utc),
|
|
model_top1_label="x",
|
|
model_top1_confidence=0.41,
|
|
)
|
|
st.pending_fifo.append(confirmation_id)
|
|
|
|
mgr._registry._active[surgery_id] = RunningSurgery(
|
|
stop_event=asyncio.Event(), state=st, tasks=[]
|
|
)
|
|
return mgr, confirmation_id
|
|
|
|
|
|
async def _audit_count(sqlite_factory, *, surgery_id: str) -> int:
|
|
async with sqlite_factory() as session:
|
|
async with session.begin():
|
|
res = await session.execute(
|
|
select(func.count()).select_from(VoiceConfirmationAudit).where(
|
|
VoiceConfirmationAudit.surgery_id == surgery_id
|
|
)
|
|
)
|
|
return int(res.scalar_one())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_recognized_appends_voice_detail_and_audit(
|
|
sqlite_session_factory,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
settings = Settings()
|
|
settings.voice_upload_max_bytes = 10 * 1024 * 1024
|
|
sessions, cid = _active_session_with_pending()
|
|
minio = MagicMock()
|
|
minio.configured = True
|
|
minio.ensure_bucket = MagicMock()
|
|
minio.upload_voice_wav = MagicMock(
|
|
return_value=StoredAudio(
|
|
object_key="surgeries/123456/confirmations/cid-a/abc.wav",
|
|
sha256_hex="b" * 64,
|
|
size_bytes=100,
|
|
)
|
|
)
|
|
baidu = MagicMock()
|
|
baidu.configured = True
|
|
baidu.asr = MagicMock(return_value={"err_no": 0, "result": ["第一个"]})
|
|
|
|
svc = _make_service(
|
|
settings=settings,
|
|
sessions=sessions,
|
|
minio=minio,
|
|
baidu=baidu,
|
|
sqlite_factory=sqlite_session_factory,
|
|
)
|
|
wav = _minimal_wav_16k_mono()
|
|
result = await svc.resolve_from_wav(
|
|
surgery_id="123456",
|
|
confirmation_id=cid,
|
|
wav_bytes=wav,
|
|
filename="a.wav",
|
|
content_type="audio/wav",
|
|
)
|
|
assert result.rejected is False
|
|
assert result.resolved_label == "纱布"
|
|
assert result.asr_text == "第一个"
|
|
assert result.audio_object_key is not None
|
|
st = sessions._registry._active["123456"].state
|
|
assert len(st.details) == 1
|
|
assert st.details[0].source == "voice"
|
|
assert await _audit_count(sqlite_session_factory, surgery_id="123456") == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_rejected_audit(
|
|
sqlite_session_factory,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
settings = Settings()
|
|
sessions, cid = _active_session_with_pending()
|
|
minio = MagicMock()
|
|
minio.configured = True
|
|
minio.ensure_bucket = MagicMock()
|
|
minio.upload_voice_wav = MagicMock(
|
|
return_value=StoredAudio(
|
|
object_key="k.wav",
|
|
sha256_hex="c" * 64,
|
|
size_bytes=10,
|
|
)
|
|
)
|
|
baidu = MagicMock()
|
|
baidu.configured = True
|
|
baidu.asr = MagicMock(return_value={"err_no": 0, "result": ["不是"]})
|
|
|
|
svc = _make_service(
|
|
settings=settings,
|
|
sessions=sessions,
|
|
minio=minio,
|
|
baidu=baidu,
|
|
sqlite_factory=sqlite_session_factory,
|
|
)
|
|
result = await svc.resolve_from_wav(
|
|
surgery_id="123456",
|
|
confirmation_id=cid,
|
|
wav_bytes=_minimal_wav_16k_mono(),
|
|
filename="a.wav",
|
|
content_type="audio/wav",
|
|
)
|
|
assert result.rejected is True
|
|
assert result.resolved_label is None
|
|
assert len(sessions._registry._active["123456"].state.details) == 0
|
|
async with sqlite_session_factory() as session:
|
|
async with session.begin():
|
|
res = await session.execute(select(VoiceConfirmationAudit))
|
|
row = res.scalars().one()
|
|
assert row.status == "rejected"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_recognizes_label_not_in_topk_but_in_surgery_candidates(
|
|
sqlite_session_factory,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""医生说出候选清单中的耗材(未出现在本次 pending 的模型 topk 里)也应记账。"""
|
|
settings = Settings()
|
|
sessions, cid = _active_session_with_pending(
|
|
candidate_consumables=["纱布", "缝线", "止血钳"],
|
|
pending_options=[("纱布", 0.4), ("缝线", 0.3)],
|
|
)
|
|
minio = MagicMock()
|
|
minio.configured = True
|
|
minio.ensure_bucket = MagicMock()
|
|
minio.upload_voice_wav = MagicMock(
|
|
return_value=StoredAudio(
|
|
object_key="k2.wav",
|
|
sha256_hex="d" * 64,
|
|
size_bytes=10,
|
|
)
|
|
)
|
|
baidu = MagicMock()
|
|
baidu.configured = True
|
|
baidu.asr = MagicMock(
|
|
return_value={"err_no": 0, "result": ["刚才用的是止血钳"]}
|
|
)
|
|
svc = _make_service(
|
|
settings=settings,
|
|
sessions=sessions,
|
|
minio=minio,
|
|
baidu=baidu,
|
|
sqlite_factory=sqlite_session_factory,
|
|
)
|
|
result = await svc.resolve_from_wav(
|
|
surgery_id="123456",
|
|
confirmation_id=cid,
|
|
wav_bytes=_minimal_wav_16k_mono(),
|
|
filename="a.wav",
|
|
content_type="audio/wav",
|
|
)
|
|
assert result.rejected is False
|
|
assert result.resolved_label == "止血钳"
|
|
st = sessions._registry._active["123456"].state
|
|
assert len(st.details) == 1
|
|
assert st.details[0].item_name == "止血钳"
|
|
assert st.details[0].source == "voice"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_audio_too_large_audit(
|
|
sqlite_session_factory,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
settings = Settings()
|
|
settings.voice_upload_max_bytes = 10
|
|
sessions, cid = _active_session_with_pending()
|
|
minio = MagicMock()
|
|
minio.configured = True
|
|
baidu = MagicMock()
|
|
svc = _make_service(
|
|
settings=settings,
|
|
sessions=sessions,
|
|
minio=minio,
|
|
baidu=baidu,
|
|
sqlite_factory=sqlite_session_factory,
|
|
)
|
|
with pytest.raises(SurgeryPipelineError) as ei:
|
|
await svc.resolve_from_wav(
|
|
surgery_id="123456",
|
|
confirmation_id=cid,
|
|
wav_bytes=b"x" * 20,
|
|
filename="a.wav",
|
|
content_type="audio/wav",
|
|
)
|
|
assert ei.value.code == "VOICE_AUDIO_INVALID"
|
|
async with sqlite_session_factory() as session:
|
|
async with session.begin():
|
|
res = await session.execute(select(VoiceConfirmationAudit))
|
|
row = res.scalars().one()
|
|
assert row.status == "invalid_audio"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_minio_not_configured_no_audit(
|
|
sqlite_session_factory,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
settings = Settings()
|
|
sessions, cid = _active_session_with_pending()
|
|
minio = MagicMock()
|
|
minio.configured = False
|
|
baidu = MagicMock()
|
|
baidu.configured = True
|
|
svc = _make_service(
|
|
settings=settings,
|
|
sessions=sessions,
|
|
minio=minio,
|
|
baidu=baidu,
|
|
sqlite_factory=sqlite_session_factory,
|
|
)
|
|
with pytest.raises(SurgeryPipelineError) as ei:
|
|
await svc.resolve_from_wav(
|
|
surgery_id="123456",
|
|
confirmation_id=cid,
|
|
wav_bytes=_minimal_wav_16k_mono(),
|
|
filename="a.wav",
|
|
content_type="audio/wav",
|
|
)
|
|
assert ei.value.code == "MINIO_NOT_CONFIGURED"
|
|
assert await _audit_count(sqlite_session_factory, surgery_id="123456") == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_upload_failed_audit(
|
|
sqlite_session_factory,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
settings = Settings()
|
|
sessions, cid = _active_session_with_pending()
|
|
minio = MagicMock()
|
|
minio.configured = True
|
|
minio.ensure_bucket = MagicMock()
|
|
minio.upload_voice_wav = MagicMock(side_effect=RuntimeError("s3 down"))
|
|
baidu = MagicMock()
|
|
baidu.configured = True
|
|
svc = _make_service(
|
|
settings=settings,
|
|
sessions=sessions,
|
|
minio=minio,
|
|
baidu=baidu,
|
|
sqlite_factory=sqlite_session_factory,
|
|
)
|
|
with pytest.raises(SurgeryPipelineError) as ei:
|
|
await svc.resolve_from_wav(
|
|
surgery_id="123456",
|
|
confirmation_id=cid,
|
|
wav_bytes=_minimal_wav_16k_mono(),
|
|
filename="a.wav",
|
|
content_type="audio/wav",
|
|
)
|
|
assert ei.value.code == "MINIO_UPLOAD_FAILED"
|
|
async with sqlite_session_factory() as session:
|
|
async with session.begin():
|
|
res = await session.execute(select(VoiceConfirmationAudit))
|
|
row = res.scalars().one()
|
|
assert row.status == "upload_failed"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_asr_failed_audit(
|
|
sqlite_session_factory,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
settings = Settings()
|
|
sessions, cid = _active_session_with_pending()
|
|
minio = MagicMock()
|
|
minio.configured = True
|
|
minio.ensure_bucket = MagicMock()
|
|
minio.upload_voice_wav = MagicMock(
|
|
return_value=StoredAudio(object_key="k", sha256_hex="d" * 64, size_bytes=1)
|
|
)
|
|
baidu = MagicMock()
|
|
baidu.configured = True
|
|
baidu.asr = MagicMock(return_value={"err_no": 3300, "err_msg": "bad"})
|
|
svc = _make_service(
|
|
settings=settings,
|
|
sessions=sessions,
|
|
minio=minio,
|
|
baidu=baidu,
|
|
sqlite_factory=sqlite_session_factory,
|
|
)
|
|
with pytest.raises(SurgeryPipelineError) as ei:
|
|
await svc.resolve_from_wav(
|
|
surgery_id="123456",
|
|
confirmation_id=cid,
|
|
wav_bytes=_minimal_wav_16k_mono(),
|
|
filename="a.wav",
|
|
content_type="audio/wav",
|
|
)
|
|
assert ei.value.code == "VOICE_ASR_FAILED"
|
|
async with sqlite_session_factory() as session:
|
|
async with session.begin():
|
|
res = await session.execute(select(VoiceConfirmationAudit))
|
|
row = res.scalars().one()
|
|
assert row.status == "asr_failed"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parse_failed_audit(
|
|
sqlite_session_factory,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
settings = Settings()
|
|
sessions, cid = _active_session_with_pending()
|
|
minio = MagicMock()
|
|
minio.configured = True
|
|
minio.ensure_bucket = MagicMock()
|
|
minio.upload_voice_wav = MagicMock(
|
|
return_value=StoredAudio(object_key="k", sha256_hex="e" * 64, size_bytes=1)
|
|
)
|
|
baidu = MagicMock()
|
|
baidu.configured = True
|
|
# Avoid substrings like 「无」that trigger `is_rejection_phrase`.
|
|
baidu.asr = MagicMock(return_value={"err_no": 0, "result": ["西红柿土豆"]})
|
|
svc = _make_service(
|
|
settings=settings,
|
|
sessions=sessions,
|
|
minio=minio,
|
|
baidu=baidu,
|
|
sqlite_factory=sqlite_session_factory,
|
|
)
|
|
with pytest.raises(SurgeryPipelineError) as ei:
|
|
await svc.resolve_from_wav(
|
|
surgery_id="123456",
|
|
confirmation_id=cid,
|
|
wav_bytes=_minimal_wav_16k_mono(),
|
|
filename="a.wav",
|
|
content_type="audio/wav",
|
|
)
|
|
assert ei.value.code == "VOICE_PARSE_FAILED"
|
|
async with sqlite_session_factory() as session:
|
|
async with session.begin():
|
|
res = await session.execute(select(VoiceConfirmationAudit))
|
|
row = res.scalars().one()
|
|
assert row.status == "parse_failed"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_wav_decode_audit(
|
|
sqlite_session_factory,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
settings = Settings()
|
|
sessions, cid = _active_session_with_pending()
|
|
minio = MagicMock()
|
|
minio.configured = True
|
|
minio.ensure_bucket = MagicMock()
|
|
minio.upload_voice_wav = MagicMock(
|
|
return_value=StoredAudio(object_key="k", sha256_hex="f" * 64, size_bytes=1)
|
|
)
|
|
baidu = MagicMock()
|
|
baidu.configured = True
|
|
svc = _make_service(
|
|
settings=settings,
|
|
sessions=sessions,
|
|
minio=minio,
|
|
baidu=baidu,
|
|
sqlite_factory=sqlite_session_factory,
|
|
)
|
|
with pytest.raises(SurgeryPipelineError) as ei:
|
|
await svc.resolve_from_wav(
|
|
surgery_id="123456",
|
|
confirmation_id=cid,
|
|
wav_bytes=b"not a wav",
|
|
filename="a.wav",
|
|
content_type="audio/wav",
|
|
)
|
|
assert ei.value.code == "VOICE_AUDIO_INVALID"
|
|
async with sqlite_session_factory() as session:
|
|
async with session.begin():
|
|
res = await session.execute(select(VoiceConfirmationAudit))
|
|
row = res.scalars().one()
|
|
assert row.status == "invalid_audio"
|