"""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("= 16: byte_rate = struct.unpack_from(" 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