Files
operating-room-monitor-server/tests/test_voice_resolution_service.py
Kevin 3d7bd70355 feat: 手术视频消耗、待确认与持久化改造
- 新增 Alembic 初始迁移、领域明细模型及归档持久化与重试链路\n- 拆分视频会话注册表、分类处理、推理时间窗聚合与流处理\n- 消耗日志:TSV/Markdown 含 top2/top3;item_id 优先产品编码;待确认记「待确认」行,语音确认后落正式行并更新汇总\n- 待确认时内存/DB 明细为占位行,确认后替换;拒绝时移除占位\n- 分类 probs 先 detach/cpu 再转 NumPy,修复 MPS/CUDA 上推理被静默跳过\n- 补充集成测试、归档与设备张量等单测

Made-with: Cursor
2026-04-23 20:42:21 +08:00

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"