CI / ruff · pytest (pull_request) Successful in 2m23s
Live check on synthetic speech (espeak) via OpenRouter gemini-2.5-flash: 3–5 s per clip, silence → no speech. Refs #116 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
163 lines
6.1 KiB
Python
163 lines
6.1 KiB
Python
"""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,
|
|
# Transcription needs no thinking: with reasoning on, gemini-2.5-flash takes ~60 s instead of ~3 s.
|
|
"reasoning": {"enabled": False},
|
|
}
|
|
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
|