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:
+160
@@ -0,0 +1,160 @@
|
||||
"""Speech-to-text for the patient's voice input (#116, slide 11: record → check the text → send).
|
||||
|
||||
The service only transcribes: the text goes back to the patient to check and edit, and reaches the dialog as an
|
||||
ordinary text message — safety, `scrub()` and the LLM gateway apply there. Audio is never stored or logged.
|
||||
|
||||
* `OpenRouterSTT` — an audio-capable model via OpenRouter `/chat/completions` (`input_audio`). TEMPORARY, synthetic
|
||||
data only (ADR-014): voice is personal data that `scrub()` cannot depersonalise, and OpenRouter is a foreign
|
||||
service (152-FZ). Real patients need an RF provider (SaluteSpeech, #121) behind the same `SpeechToText` contract.
|
||||
* `MockSTT` — offline, returns a fixed synthetic phrase (ADR-004: the demo works without network).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import struct
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
import httpx
|
||||
import yaml
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_RETRY_STATUSES = {429, 500, 502, 503, 504}
|
||||
_ATTEMPTS = 3
|
||||
|
||||
|
||||
class SttTexts(BaseModel):
|
||||
system: str
|
||||
user: str
|
||||
silence_marker: str
|
||||
mock_transcript: str
|
||||
|
||||
|
||||
@lru_cache
|
||||
def load_texts(path: Path | None = None) -> SttTexts:
|
||||
from app.config import get_settings
|
||||
|
||||
path = path or get_settings().config_dir / "texts" / "stt.yaml"
|
||||
return SttTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
class SttError(RuntimeError):
|
||||
"""The provider failed (network, HTTP error, bad response)."""
|
||||
|
||||
|
||||
class SttNoSpeech(SttError):
|
||||
"""The recording holds no recognisable speech."""
|
||||
|
||||
|
||||
class InvalidAudio(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def wav_seconds(audio: bytes) -> float:
|
||||
"""Duration of a PCM WAV file; raises `InvalidAudio` for anything else."""
|
||||
if len(audio) < 44 or audio[:4] != b"RIFF" or audio[8:12] != b"WAVE":
|
||||
raise InvalidAudio("not a WAV file")
|
||||
pos, byte_rate = 12, 0
|
||||
while pos + 8 <= len(audio):
|
||||
chunk_id, size = audio[pos : pos + 4], struct.unpack_from("<I", audio, pos + 4)[0]
|
||||
if chunk_id == b"fmt " and size >= 16:
|
||||
byte_rate = struct.unpack_from("<I", audio, pos + 16)[0]
|
||||
elif chunk_id == b"data":
|
||||
if not byte_rate:
|
||||
break
|
||||
return min(size, len(audio) - pos - 8) / byte_rate
|
||||
pos += 8 + size + (size & 1)
|
||||
raise InvalidAudio("malformed WAV")
|
||||
|
||||
|
||||
class SpeechToText(Protocol):
|
||||
name: str
|
||||
|
||||
async def transcribe(self, audio: bytes, fmt: str = "wav") -> str: ...
|
||||
|
||||
|
||||
class MockSTT:
|
||||
name = "mock"
|
||||
|
||||
async def transcribe(self, audio: bytes, fmt: str = "wav") -> str:
|
||||
return load_texts().mock_transcript
|
||||
|
||||
|
||||
class OpenRouterSTT:
|
||||
name = "openrouter"
|
||||
|
||||
def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None) -> None:
|
||||
self._key = settings.openrouter_api_key
|
||||
self._model = settings.stt_model
|
||||
self._url = settings.openrouter_base_url.rstrip("/") + "/chat/completions"
|
||||
self._timeout = settings.stt_timeout_s
|
||||
self._proxy = settings.openrouter_proxy_url or None
|
||||
self._client = client
|
||||
|
||||
async def transcribe(self, audio: bytes, fmt: str = "wav") -> str:
|
||||
texts = load_texts()
|
||||
content = [
|
||||
{"type": "text", "text": texts.user},
|
||||
{"type": "input_audio", "input_audio": {"data": base64.b64encode(audio).decode(), "format": fmt}},
|
||||
]
|
||||
body = {
|
||||
"model": self._model,
|
||||
"messages": [{"role": "system", "content": texts.system}, {"role": "user", "content": content}],
|
||||
"temperature": 0,
|
||||
"max_tokens": 800,
|
||||
}
|
||||
data = await self._post(body)
|
||||
try:
|
||||
text = (data["choices"][0]["message"]["content"] or "").strip()
|
||||
except (KeyError, IndexError, TypeError) as exc:
|
||||
raise SttError("bad response") from exc
|
||||
text = text.strip("«»\"' \n")
|
||||
if not text or text.lower() == texts.silence_marker.lower():
|
||||
raise SttNoSpeech("no speech")
|
||||
return text
|
||||
|
||||
async def _post(self, body: dict) -> dict:
|
||||
headers = {"Authorization": f"Bearer {self._key}", "X-Title": "PTSRaiAgent prototype (synthetic data)"}
|
||||
for attempt in range(_ATTEMPTS):
|
||||
try:
|
||||
if self._client is not None:
|
||||
resp = await self._client.post(self._url, json=body, headers=headers, timeout=self._timeout)
|
||||
else:
|
||||
async with httpx.AsyncClient(timeout=self._timeout, proxy=self._proxy) as client:
|
||||
resp = await client.post(self._url, json=body, headers=headers)
|
||||
except httpx.HTTPError as exc:
|
||||
if attempt + 1 == _ATTEMPTS:
|
||||
raise SttError(type(exc).__name__) from exc
|
||||
await asyncio.sleep(0.5 * 2**attempt)
|
||||
continue
|
||||
if resp.status_code in _RETRY_STATUSES and attempt + 1 < _ATTEMPTS:
|
||||
log.warning("stt openrouter http %s, retry %d", resp.status_code, attempt + 1)
|
||||
await asyncio.sleep(0.5 * 2**attempt)
|
||||
continue
|
||||
if resp.status_code != 200:
|
||||
# Provider error bodies carry no patient data — log the message to make failures diagnosable.
|
||||
log.error("stt openrouter http %s: %s", resp.status_code, resp.text[:200])
|
||||
raise SttError(f"http {resp.status_code}")
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError as exc:
|
||||
raise SttError("bad json") from exc
|
||||
raise SttError("retries exhausted")
|
||||
|
||||
|
||||
def get_stt(settings: Settings) -> SpeechToText | None:
|
||||
"""The configured provider; `None` = voice input is off. `auto` = OpenRouter if a key is set, else mock."""
|
||||
mode = settings.stt_provider
|
||||
if mode == "auto":
|
||||
mode = "openrouter" if settings.openrouter_api_key else "mock"
|
||||
if mode == "openrouter":
|
||||
return OpenRouterSTT(settings) if settings.openrouter_api_key else None
|
||||
if mode == "mock":
|
||||
return MockSTT()
|
||||
return None
|
||||
Reference in New Issue
Block a user