feat(llm): speech-to-text for patient voice input via OpenRouter (+ offline mock)
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:
2026-10-31 11:19:55 +03:00
co-authored by Claude Opus 5.5
parent 4b166cf488
commit 7443b61974
9 changed files with 430 additions and 4 deletions
+2 -2
View File
@@ -267,9 +267,9 @@ async def test_connect_care_does_not_reassign_or_create_patients(client: AsyncCl
async def test_screening_capability_respects_release_gate(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("INTAKE_DEMO", "0")
assert (await client.get("/api/v1/patient/capabilities")).json() == {"primary_screening": False}
assert (await client.get("/api/v1/patient/capabilities")).json()["primary_screening"] is False
monkeypatch.setenv("INTAKE_DEMO", "1")
assert (await client.get("/api/v1/patient/capabilities")).json() == {"primary_screening": True}
assert (await client.get("/api/v1/patient/capabilities")).json()["primary_screening"] is True
async def test_anonymous_contact_request_reaches_doctor(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
+71
View File
@@ -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
+106
View File
@@ -0,0 +1,106 @@
"""Speech-to-text providers (#116): OpenRouter request shape, retries, silence, WAV parsing, provider choice."""
import base64
import io
import json
import wave
import httpx
import pytest
from app.config import Settings
from app.llm.stt import (
InvalidAudio,
MockSTT,
OpenRouterSTT,
SttError,
SttNoSpeech,
get_stt,
load_texts,
wav_seconds,
)
def make_wav(seconds: float, rate: int = 16_000) -> bytes:
buf = io.BytesIO()
with wave.open(buf, "wb") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(rate)
w.writeframes(b"\x00\x00" * int(rate * seconds))
return buf.getvalue()
def _settings(**kw) -> Settings:
base = {"openrouter_api_key": "test", "stt_provider": "openrouter", "stt_timeout_s": 5.0}
return Settings(_env_file=None, **{**base, **kw})
def _fake(responses: list[httpx.Response]) -> tuple[OpenRouterSTT, list[dict]]:
sent: list[dict] = []
replies = iter(responses)
def handler(request: httpx.Request) -> httpx.Response:
sent.append(json.loads(request.content))
return next(replies)
stt = OpenRouterSTT(_settings(), client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
return stt, sent
def _ok(text: str) -> httpx.Response:
return httpx.Response(200, json={"choices": [{"message": {"content": text}}]})
def test_wav_seconds() -> None:
assert wav_seconds(make_wav(2.5)) == pytest.approx(2.5)
with pytest.raises(InvalidAudio):
wav_seconds(b"OggS" + b"\x00" * 100)
with pytest.raises(InvalidAudio):
wav_seconds(b"")
async def test_openrouter_sends_audio_and_returns_transcript() -> None:
audio = make_wav(1)
stt, sent = _fake([_ok("«Сегодня плохо спал.»")])
assert await stt.transcribe(audio) == "Сегодня плохо спал."
body = sent[0]
assert body["model"] == "google/gemini-2.5-flash" and body["temperature"] == 0
part = body["messages"][1]["content"][1]
assert part["type"] == "input_audio" and part["input_audio"]["format"] == "wav"
assert base64.b64decode(part["input_audio"]["data"]) == audio
async def test_silence_marker_is_no_speech() -> None:
stt, _ = _fake([_ok(load_texts().silence_marker.upper())])
with pytest.raises(SttNoSpeech):
await stt.transcribe(make_wav(1))
async def test_retries_then_succeeds(monkeypatch) -> None:
monkeypatch.setattr("app.llm.stt.asyncio.sleep", _no_sleep)
stt, sent = _fake([httpx.Response(503), _ok("текст")])
assert await stt.transcribe(make_wav(1)) == "текст"
assert len(sent) == 2
async def test_http_error_raises(monkeypatch) -> None:
monkeypatch.setattr("app.llm.stt.asyncio.sleep", _no_sleep)
stt, _ = _fake([httpx.Response(401, json={"error": {"message": "bad key"}})])
with pytest.raises(SttError):
await stt.transcribe(make_wav(1))
async def _no_sleep(_: float) -> None:
return None
async def test_mock_is_offline() -> None:
assert await MockSTT().transcribe(b"") == load_texts().mock_transcript
def test_provider_choice() -> None:
assert isinstance(get_stt(_settings(stt_provider="auto")), OpenRouterSTT)
assert isinstance(get_stt(_settings(stt_provider="auto", openrouter_api_key="")), MockSTT)
assert get_stt(_settings(stt_provider="openrouter", openrouter_api_key="")) is None
assert get_stt(_settings(stt_provider="off")) is None