- 新增 app/baked/algorithm|pipeline,非部署参数不再走 env;Settings 保留 DB/HTTP/RTSP/海康/百度/MinIO/Demo - 移除 init_db_schema 与 reload 配置;main 仅 check_database;start*.sh 在 uvicorn 前执行 alembic upgrade head - 依赖 psycopg[binary] 供 Alembic 同步 URL;alembic/env 注释与预发清单更新 - 撕段门控消费管线、各视频/语音/归档调用改为 baked - 百度环境变量仅 BAIDU_APP_ID、BAIDU_API_KEY、BAIDU_SECRET_KEY 与 BAIDU_* 超时/ASR;人脸脚本与 baidu_speech 文案同步 - 全量单测与 .env.example 更新;.gitignore 忽略 refs/(本地权重/视频不入库) Made-with: Cursor
458 lines
14 KiB
Python
458 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.baked import pipeline as bp
|
|
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()
|
|
monkeypatch.setattr(bp, "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()
|
|
monkeypatch.setattr(bp, "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"
|