feat: surgery pipeline API, video inference, voice confirm, and tests
- Add FastAPI routes for surgery start/end, results, pending confirmation (WAV upload), and health checks. - Implement RTSP/Hikvision capture, consumable classification, session manager, MinIO/Baidu voice resolution, and DB persistence. - Add documentation (client API, video backends, staging checklist) and sample camera/RTSP config. - Add pytest suite (API contract, session manager, voice, repositories, pipeline persistence) and httpx dev dependency. - Replace deprecated HTTP_422_UNPROCESSABLE_ENTITY with HTTP_422_UNPROCESSABLE_CONTENT. - Fix SurgeryPipeline DB reads to use an explicit transaction with autobegin disabled. Made-with: Cursor
This commit is contained in:
416
tests/test_voice_resolution_service.py
Normal file
416
tests/test_voice_resolution_service.py
Normal file
@@ -0,0 +1,416 @@
|
||||
"""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,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> VoiceConfirmationService:
|
||||
monkeypatch.setattr(
|
||||
"app.services.voice_resolution.AsyncSessionLocal",
|
||||
sqlite_factory,
|
||||
)
|
||||
audits = VoiceAuditRepository()
|
||||
return VoiceConfirmationService(
|
||||
settings=settings,
|
||||
sessions=sessions,
|
||||
baidu=baidu,
|
||||
minio=minio,
|
||||
audits=audits,
|
||||
)
|
||||
|
||||
|
||||
def _active_session_with_pending(
|
||||
surgery_id: str = "123456",
|
||||
confirmation_id: str = "cid-a",
|
||||
) -> tuple[CameraSessionManager, str]:
|
||||
settings = Settings()
|
||||
mgr = CameraSessionManager(
|
||||
settings=settings,
|
||||
consumable_classifier=MagicMock(),
|
||||
tear_action=MagicMock(),
|
||||
hikvision_runtime=None,
|
||||
result_repository=None,
|
||||
)
|
||||
st = SurgerySessionState(candidate_consumables=["纱布", "缝线"])
|
||||
st.pending_by_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="x",
|
||||
model_top1_confidence=0.41,
|
||||
)
|
||||
st.pending_fifo.append(confirmation_id)
|
||||
|
||||
mgr._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,
|
||||
monkeypatch=monkeypatch,
|
||||
)
|
||||
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._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,
|
||||
monkeypatch=monkeypatch,
|
||||
)
|
||||
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._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_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,
|
||||
monkeypatch=monkeypatch,
|
||||
)
|
||||
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,
|
||||
monkeypatch=monkeypatch,
|
||||
)
|
||||
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,
|
||||
monkeypatch=monkeypatch,
|
||||
)
|
||||
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,
|
||||
monkeypatch=monkeypatch,
|
||||
)
|
||||
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,
|
||||
monkeypatch=monkeypatch,
|
||||
)
|
||||
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,
|
||||
monkeypatch=monkeypatch,
|
||||
)
|
||||
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"
|
||||
Reference in New Issue
Block a user