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
+54 -2
View File
@@ -17,17 +17,19 @@ issued by the server instead of handing out the pseudonymous token itself.
import logging
import secrets
import time as monotonic_time
from collections import defaultdict
from datetime import date, datetime, time, timedelta
from typing import Any, Literal
from zoneinfo import ZoneInfo
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select
from app.api import fixtures
from app.api.schemas import DemoPersona
from app.config import get_settings
from app.core import clock
from app.core.instrument_catalog import Catalog, catalog
from app.core.intake_questions import DIARY_KINDS
@@ -42,6 +44,7 @@ from app.db.identity import TOKEN_LEN
from app.db.intake import QuestionnaireRecord
from app.db.session import get_clinical_session, get_identity_session
from app.integrations.emias_mock import CODE_MAX_LEN, EmiasMock
from app.llm.stt import InvalidAudio, SttError, SttNoSpeech, get_stt, wav_seconds
from app.services import anonymous_care
log = logging.getLogger(__name__)
@@ -219,7 +222,56 @@ async def auth_anonymous(body: AnonymousIn | None = None) -> AuthOut:
@router.get("/patient/capabilities")
async def patient_capabilities() -> dict[str, bool]:
"""Expose availability without leaking clinical policy or server configuration."""
return {"primary_screening": intake_enabled()}
return {"primary_screening": intake_enabled(), "voice_input": get_stt(get_settings()) is not None}
class SttOut(BaseModel):
text: str
provider: str
# 16 kHz mono 16-bit PCM = 32 000 bytes/s, + header slack.
_WAV_BYTES_PER_SECOND = 32_000
@router.post("/me/{token}/stt", response_model=SttOut)
async def transcribe_voice(token: str, request: Request) -> SttOut:
"""Voice → text for the patient to check and edit (#116). Body: raw `audio/wav`, 16 kHz mono.
Only transcribes: nothing is written to the dialog and the agent is not called — the patient sends the
checked text as an ordinary message, where safety and `scrub()` apply. The audio is neither stored nor logged.
"""
settings = get_settings()
stt = get_stt(settings)
if stt is None:
raise HTTPException(404, "voice input is off")
async with get_clinical_session() as s:
if await repo.get_profile(s, token) is None:
raise HTTPException(404, "patient not found")
max_bytes = (settings.stt_max_seconds + 2) * _WAV_BYTES_PER_SECOND + 4096
declared = request.headers.get("content-length")
if declared and declared.isdigit() and int(declared) > max_bytes:
raise HTTPException(413, "recording too long")
audio = await request.body()
if len(audio) > max_bytes:
raise HTTPException(413, "recording too long")
try:
seconds = wav_seconds(audio)
except InvalidAudio as exc:
raise HTTPException(415, "expected audio/wav") from exc
if seconds > settings.stt_max_seconds + 1:
raise HTTPException(413, "recording too long")
started = monotonic_time.monotonic() # latency only, not domain time (that is app.core.clock)
try:
text = await stt.transcribe(audio, "wav")
except SttNoSpeech as exc:
raise HTTPException(422, "no speech recognised") from exc
except SttError as exc:
log.warning("stt failed tok=%s provider=%s: %s", token[:6], stt.name, exc)
raise HTTPException(503, "speech recognition unavailable") from exc
ms = int((monotonic_time.monotonic() - started) * 1000)
log.info("stt tok=%s provider=%s secs=%.1f ms=%d chars=%d", token[:6], stt.name, seconds, ms, len(text))
return SttOut(text=text, provider=stt.name)
@router.post("/me/{token}/doctor/connect")