feat(llm): speech-to-text for patient voice input via OpenRouter (+ offline mock)
CI / ruff · pytest (pull_request) Successful in 2m57s
CI / ruff · pytest (pull_request) Successful in 2m57s
POST /api/v1/me/{token}/stt takes a 16 kHz mono WAV and only transcribes:
the patient checks the text and sends it as an ordinary message, so safety,
scrub() and the LLM gateway still apply. Audio is neither stored nor logged.
OpenRouter STT is synthetic-data only (ADR-014); SaluteSpeech for the pilot is #121.
Refs #116
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
"""Voice input endpoint (#116): transcribes only, validates the audio, never touches the dialog."""
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api import patient_web
|
||||
from app.db import session
|
||||
from app.db.bootstrap import init_db
|
||||
from app.db.clinical import Message
|
||||
from app.llm.stt import MockSTT, SttError, SttNoSpeech, load_texts
|
||||
from app.main import app
|
||||
from tests.test_llm_stt import make_wav
|
||||
|
||||
MEM = "sqlite+aiosqlite:///:memory:"
|
||||
WAV = {"Content-Type": "audio/wav"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[AsyncClient]:
|
||||
monkeypatch.setattr(patient_web, "get_stt", lambda _settings: MockSTT()) # never the network in tests
|
||||
await session.configure(identity_url=MEM, clinical_url=MEM)
|
||||
await init_db()
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
|
||||
yield c
|
||||
await session.configure()
|
||||
|
||||
|
||||
async def _token(client: AsyncClient) -> str:
|
||||
return (await client.post("/api/v1/auth/anonymous")).json()["token"]
|
||||
|
||||
|
||||
async def test_transcribes_without_writing_to_dialog(client: AsyncClient) -> None:
|
||||
token = await _token(client)
|
||||
resp = await client.post(f"/api/v1/me/{token}/stt", content=make_wav(2), headers=WAV)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"text": load_texts().mock_transcript, "provider": "mock"}
|
||||
async with session.get_clinical_session() as s:
|
||||
assert (await s.execute(select(Message))).scalars().all() == []
|
||||
|
||||
|
||||
async def test_capability_and_off(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert (await client.get("/api/v1/patient/capabilities")).json()["voice_input"] is True
|
||||
monkeypatch.setattr(patient_web, "get_stt", lambda _settings: None)
|
||||
assert (await client.get("/api/v1/patient/capabilities")).json()["voice_input"] is False
|
||||
token = await _token(client)
|
||||
assert (await client.post(f"/api/v1/me/{token}/stt", content=make_wav(1), headers=WAV)).status_code == 404
|
||||
|
||||
|
||||
async def test_rejects_unknown_patient_bad_format_and_long_audio(client: AsyncClient) -> None:
|
||||
assert (await client.post("/api/v1/me/nobody/stt", content=make_wav(1), headers=WAV)).status_code == 404
|
||||
token = await _token(client)
|
||||
url = f"/api/v1/me/{token}/stt"
|
||||
assert (await client.post(url, content=b"OggS" + b"\0" * 200)).status_code == 415
|
||||
assert (await client.post(url, content=make_wav(70), headers=WAV)).status_code == 413
|
||||
assert (await client.post(url, content=make_wav(10, rate=48_000), headers=WAV)).status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("error", "status"), [(SttNoSpeech("x"), 422), (SttError("x"), 503)])
|
||||
async def test_provider_errors(client: AsyncClient, monkeypatch: pytest.MonkeyPatch, error, status) -> None:
|
||||
class Failing:
|
||||
name = "failing"
|
||||
|
||||
async def transcribe(self, audio: bytes, fmt: str = "wav") -> str:
|
||||
raise error
|
||||
|
||||
monkeypatch.setattr(patient_web, "get_stt", lambda _settings: Failing())
|
||||
token = await _token(client)
|
||||
assert (await client.post(f"/api/v1/me/{token}/stt", content=make_wav(1), headers=WAV)).status_code == status
|
||||
Reference in New Issue
Block a user