Compare commits

...
Author SHA1 Message Date
Eleboba 4eb506bf65 Merge pull request 'feat: распознавание голоса пациента (STT через OpenRouter + mock)' (#122) from feat/116-stt into dev
CI / ruff · pytest (push) Successful in 2m25s
CD / ruff · pytest (push) Successful in 2m29s
CD / deploy dev (push) Successful in 3m8s
Reviewed-on: #122
2026-10-31 11:37:05 +03:00
ElebobaandClaude Opus 5.5 d700608825 fix(llm): STT prompt that keeps unclear speech; reasoning off (~60 s → ~3 s)
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>
2026-10-31 11:29:40 +03:00
ElebobaandClaude Opus 5.5 7443b61974 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>
2026-10-31 11:19:55 +03:00
9 changed files with 433 additions and 4 deletions
+4
View File
@@ -11,6 +11,10 @@ CHAT_AGENT=auto
OPENROUTER_API_KEY=
OPENROUTER_MODEL=google/gemini-2.5-flash-lite
# --- Voice input (speech-to-text): auto | off | mock | openrouter. OpenRouter = synthetic data only (ADR-014).
STT_PROVIDER=auto
STT_MODEL=google/gemini-2.5-flash
# --- Pseudonymisation key: 32+ random bytes, hex. Generate: python -c "import secrets;print(secrets.token_hex(32))"
PSEUDO_KEY=
+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")
+7
View File
@@ -42,6 +42,13 @@ class Settings(BaseSettings):
chat_agent_timeout_s: float = 25.0
chat_agent_max_steps: int = 5
# Voice input (#116): speech-to-text for the patient's messages. `auto` = OpenRouter if a key is set, else mock.
# OpenRouter STT is synthetic data only (ADR-014): the audio leaves the RF and cannot be scrubbed.
stt_provider: Literal["auto", "off", "mock", "openrouter"] = "auto"
stt_model: str = "google/gemini-2.5-flash" # needs audio input
stt_timeout_s: float = 30.0
stt_max_seconds: int = Field(default=60, ge=5, le=300)
# Telegram is used only for the prototype on synthetic data; prod channel is MAX / Gosuslugi.
tg_bot_token: str = ""
# The RF server reaches api.telegram.org over IPv6 only; docker has IPv4 only → go through a proxy (http/socks).
+162
View File
@@ -0,0 +1,162 @@
"""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
+17
View File
@@ -0,0 +1,17 @@
# Распознавание голоса пациента (#116). Модель только расшифровывает речь — не отвечает и не советует.
# Расшифровка возвращается пациенту на проверку и уходит в диалог обычным текстом (safety → scrub → LLM).
# Системная инструкция модели распознавания.
system: >-
Ты — система распознавания речи. Дословно расшифруй русскую речь из аудио.
Выведи только расшифровку: без кавычек, пояснений и ответа на сказанное.
Ставь знаки препинания. Если в записи совсем нет речи — выведи ровно [тишина].
# Текст рядом с аудио в сообщении пользователя.
user: "Расшифруй эту аудиозапись."
# Что модель возвращает, если речи нет (сравнивается без учёта регистра).
silence_marker: "[тишина]"
# Офлайн-режим (STT_PROVIDER=mock): фиксированная фраза демо-пациента, синтетика.
mock_transcript: "Сегодня долго не мог уснуть. Хочу обсудить это на следующей встрече."
+9
View File
@@ -3,6 +3,15 @@
> Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение.
> При конфликте с [`docs/info/SPEC.md`](info/SPEC.md) действует ADR.
## 2026-10-31 — ADR-014: Голосовой ввод — расшифровка отдельно от диалога; OpenRouter только на синтетике
**Контекст:** для гранта ВШЭ и пилота «Династия» нужен голосовой ввод (слайд 11: «Записать → Проверить → Сводка»). Голос — ПДн, а `scrub()` его не обезличивает. OpenRouter — зарубежный сервис (ADR-004, guardrail 9).
**Решение:**
1. `POST /me/{token}/stt` **только расшифровывает**. Текст возвращается пациенту на проверку и правку, в диалог уходит обычным сообщением, поэтому safety, `scrub()` и шлюз LLM работают как с набранным текстом. Автоотправки нет.
2. Аудио не сохраняется и не логируется. В лог пишутся только длительность, провайдер, время ответа и длина текста.
3. Формат — WAV 16 кГц mono, его кодирует браузер: ffmpeg на сервере не нужен, одинаково работает в Chrome и iOS Safari. Лимит — `STT_MAX_SECONDS` (60 с).
4. Провайдеры за одним протоколом `SpeechToText` (`app/llm/stt.py`): `mock` (офлайн), `openrouter` (модель с аудиовходом, `input_audio`). **OpenRouter допустим только на синтетических данных** (демо, жюри, грант). До первого реального пациента нужен РФ-провайдер, SaluteSpeech (#121).
**Последствия:** голос в демо работает без новых зависимостей. Для пилота нужен договор с SaluteSpeech.
## 2026-10-04 — ADR-013: Правки по клиническому ревью — красные флаги, одно правило кризиса, звонок после кризиса, одна таблица номеров
**Контекст:** ревью опросников: обязательны блок красных флагов и план безопасности со звонком после кризиса; маршрутизация должна сначала смотреть на риск и флаги, потом на шкалы; в документе два противоречащих правила кризисного экрана («любое Да» против «только высокий риск»); кризисные номера разбросаны по текстам (и чат-агент давал взрослым детский телефон доверия).
**Решение:**
+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
+107
View File
@@ -0,0 +1,107 @@
"""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
assert body["reasoning"] == {"enabled": False}
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