diff --git a/.env.example b/.env.example index 43c7fa1..6410483 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/app/api/patient_web.py b/app/api/patient_web.py index d5907ef..c296d08 100644 --- a/app/api/patient_web.py +++ b/app/api/patient_web.py @@ -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") diff --git a/app/config.py b/app/config.py index 27e5cb6..f58bca5 100644 --- a/app/config.py +++ b/app/config.py @@ -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). diff --git a/app/llm/stt.py b/app/llm/stt.py new file mode 100644 index 0000000..e933030 --- /dev/null +++ b/app/llm/stt.py @@ -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("= 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 diff --git a/config/texts/stt.yaml b/config/texts/stt.yaml new file mode 100644 index 0000000..e187374 --- /dev/null +++ b/config/texts/stt.yaml @@ -0,0 +1,17 @@ +# Распознавание голоса пациента (#116). Модель только расшифровывает речь — не отвечает и не советует. +# Расшифровка возвращается пациенту на проверку и уходит в диалог обычным текстом (safety → scrub → LLM). + +# Системная инструкция модели распознавания. +system: >- + Ты — система распознавания речи. Дословно расшифруй русскую речь из аудио. + Выведи только расшифровку: без кавычек, пояснений, перевода и ответа на сказанное. + Ставь знаки препинания. Если речи нет или её не разобрать — выведи ровно [тишина]. + +# Текст рядом с аудио в сообщении пользователя. +user: "Расшифруй запись." + +# Что модель возвращает, если речи нет (сравнивается без учёта регистра). +silence_marker: "[тишина]" + +# Офлайн-режим (STT_PROVIDER=mock): фиксированная фраза демо-пациента, синтетика. +mock_transcript: "Сегодня долго не мог уснуть. Хочу обсудить это на следующей встрече." diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 10c792c..c821d9b 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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: Правки по клиническому ревью — красные флаги, одно правило кризиса, звонок после кризиса, одна таблица номеров **Контекст:** ревью опросников: обязательны блок красных флагов и план безопасности со звонком после кризиса; маршрутизация должна сначала смотреть на риск и флаги, потом на шкалы; в документе два противоречащих правила кризисного экрана («любое Да» против «только высокий риск»); кризисные номера разбросаны по текстам (и чат-агент давал взрослым детский телефон доверия). **Решение:** diff --git a/tests/test_api_patient_web.py b/tests/test_api_patient_web.py index 0205057..8b31b9b 100644 --- a/tests/test_api_patient_web.py +++ b/tests/test_api_patient_web.py @@ -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: diff --git a/tests/test_api_stt.py b/tests/test_api_stt.py new file mode 100644 index 0000000..8e1055c --- /dev/null +++ b/tests/test_api_stt.py @@ -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 diff --git a/tests/test_llm_stt.py b/tests/test_llm_stt.py new file mode 100644 index 0000000..8f978d1 --- /dev/null +++ b/tests/test_llm_stt.py @@ -0,0 +1,106 @@ +"""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 + 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