From f19ea339370039ef3a9f9d067dbd45472715937b Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Sat, 31 Oct 2026 17:11:44 +0300 Subject: [PATCH] feat: SaluteSpeech STT for the pilot + no foreign STT outside the demo (ADR-016) Refs #121 Co-Authored-By: Claude Opus 5.5 --- .env.example | 8 +- app/api/patient_web.py | 2 + app/config.py | 23 +++++- app/llm/stt.py | 181 +++++++++++++++++++++++++++++++++++++++-- docs/DECISIONS.md | 9 ++ tests/test_llm_stt.py | 107 +++++++++++++++++++++++- 6 files changed, 317 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index 6410483..8cf9b8c 100644 --- a/.env.example +++ b/.env.example @@ -11,9 +11,15 @@ 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). +# --- Voice input (speech-to-text): auto | off | mock | openrouter | salute. +# OpenRouter = synthetic data only (ADR-014), the app refuses to start with it when DEMO_CLOCK=false. +# Pilot / real patients: salute (SaluteSpeech, RF contour, ADR-016). auto picks salute when credentials are set. STT_PROVIDER=auto STT_MODEL=google/gemini-2.5-flash +SALUTE_SPEECH_CREDENTIALS= +SALUTE_SPEECH_SCOPE=SALUTE_SPEECH_PERS +# Path to the Russian Trusted Root CA (Минцифры) — not in certifi, needed to verify Sber's TLS. +SALUTE_SPEECH_CA= # --- 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 2f32999..6a0e6cb 100644 --- a/app/api/patient_web.py +++ b/app/api/patient_web.py @@ -265,6 +265,8 @@ async def transcribe_voice(token: str, request: Request) -> SttOut: started = monotonic_time.monotonic() # latency only, not domain time (that is app.core.clock) try: text = await stt.transcribe(audio, "wav") + except InvalidAudio as exc: + raise HTTPException(415, "expected 16-bit mono PCM WAV") from exc except SttNoSpeech as exc: raise HTTPException(422, "no speech recognised") from exc except SttError as exc: diff --git a/app/config.py b/app/config.py index f58bca5..6cf7555 100644 --- a/app/config.py +++ b/app/config.py @@ -42,12 +42,18 @@ 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" + # Voice input (#116, #121): speech-to-text for the patient's messages. `auto` = SaluteSpeech if credentials are + # set; otherwise, in the demo only, 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 — forbidden with DEMO_CLOCK=false (validator below). + stt_provider: Literal["auto", "off", "mock", "openrouter", "salute"] = "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) + # SaluteSpeech (Sber, RF contour) — the pilot provider (#121, ADR-016). Credentials = the base64 authorization key. + salute_speech_credentials: SecretStr = SecretStr("") + salute_speech_scope: str = "SALUTE_SPEECH_PERS" + # Russian Trusted Root CA (Минцифры) is not in certifi: point to the .crt/.pem to verify Sber's TLS chain. + salute_speech_ca: Path | None = None # Telegram is used only for the prototype on synthetic data; prod channel is MAX / Gosuslugi. tg_bot_token: str = "" @@ -65,6 +71,17 @@ class Settings(BaseSettings): max_ca_bundle: Path | None = None max_link_ttl_seconds: int = Field(default=600, ge=60, le=1800) + @model_validator(mode="after") + def validate_stt(self) -> "Settings": + if self.stt_provider == "openrouter" and not self.demo_clock: + raise ValueError( + "STT_PROVIDER=openrouter is for synthetic data only (ADR-014) and is forbidden with DEMO_CLOCK=false: " + "use STT_PROVIDER=salute" + ) + if self.salute_speech_ca is not None and not self.salute_speech_ca.is_file(): + raise ValueError("SALUTE_SPEECH_CA must point to an existing CA certificate file") + return self + @model_validator(mode="after") def validate_max(self) -> "Settings": if not self.max_enabled: diff --git a/app/llm/stt.py b/app/llm/stt.py index ea0a17e..5f21d05 100644 --- a/app/llm/stt.py +++ b/app/llm/stt.py @@ -6,13 +6,18 @@ ordinary text message — safety, `scrub()` and the LLM gateway apply there. Aud * `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. +* `SaluteSTT` — Sber SaluteSpeech, synchronous REST recognition of the whole file (RF contour, #121, ADR-016). + The pilot provider: the only one allowed for real patients. * `MockSTT` — offline, returns a fixed synthetic phrase (ADR-004: the demo works without network). """ import asyncio import base64 import logging +import ssl import struct +import time +import uuid from functools import lru_cache from pathlib import Path from typing import Protocol @@ -56,23 +61,48 @@ class InvalidAudio(ValueError): pass -def wav_seconds(audio: bytes) -> float: - """Duration of a PCM WAV file; raises `InvalidAudio` for anything else.""" +class WavInfo(BaseModel): + audio_format: int # 1 = PCM + channels: int + sample_rate: int + byte_rate: int + bits: int + data_offset: int + data_size: int + + +def wav_info(audio: bytes) -> WavInfo: + """Header of a 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 + pos, fmt = 12, None while pos + 8 <= len(audio): chunk_id, size = audio[pos : pos + 4], struct.unpack_from("= 16: - byte_rate = struct.unpack_from(" float: + """Duration of a PCM WAV file; raises `InvalidAudio` for anything else.""" + info = wav_info(audio) + return info.data_size / info.byte_rate + + class SpeechToText(Protocol): name: str @@ -150,11 +180,146 @@ class OpenRouterSTT: raise SttError("retries exhausted") +SALUTE_OAUTH_URL = "https://ngw.devices.sberbank.ru:9443/api/v2/oauth" +SALUTE_RECOGNIZE_URL = "https://smartspeech.sber.ru/rest/v1/speech:recognize" + + +class _SaluteToken: + """OAuth access token, cached until a minute before expiry (Sber tokens live 30 min) and shared per process: + a new token per request would burn the OAuth rate limit.""" + + def __init__(self) -> None: + self.value = "" + self.expires = 0.0 # time.monotonic() deadline + self.lock = asyncio.Lock() + + +@lru_cache(maxsize=4) +def _salute_token(credentials: str, scope: str) -> _SaluteToken: + return _SaluteToken() + + +class SaluteSTT: + name = "salute" + + def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None) -> None: + self._credentials = settings.salute_speech_credentials.get_secret_value() + self._scope = settings.salute_speech_scope + self._timeout = settings.stt_timeout_s + self._ca = settings.salute_speech_ca + self._client = client + self._token = _salute_token(self._credentials, self._scope) + + async def transcribe(self, audio: bytes, fmt: str = "wav") -> str: + info = wav_info(audio) + if info.audio_format != 1 or info.bits != 16 or info.channels != 1: + raise InvalidAudio("SaluteSpeech needs 16-bit mono PCM") + pcm = audio[info.data_offset : info.data_offset + info.data_size] + headers = {"Content-Type": f"audio/x-pcm;bit=16;rate={info.sample_rate}"} + data = await self._post(pcm, headers) + if data.get("status", 200) != 200 or not isinstance(data.get("result"), list): + raise SttError("bad response") + text = " ".join(str(part).strip() for part in data["result"] if str(part).strip()) + if not text: + raise SttNoSpeech("no speech") + return text + + async def _get_token(self, client: httpx.AsyncClient, *, force: bool = False) -> str: + tok = self._token + if not force and tok.value and time.monotonic() < tok.expires: + return tok.value + async with tok.lock: + if not force and tok.value and time.monotonic() < tok.expires: + return tok.value + headers = { + "Authorization": f"Basic {self._credentials}", + "RqUID": str(uuid.uuid4()), + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + } + try: + resp = await client.post(SALUTE_OAUTH_URL, headers=headers, data={"scope": self._scope}) + except httpx.HTTPError as exc: + raise SttError(f"oauth {type(exc).__name__}") from exc + if resp.status_code != 200: + log.error("stt salute oauth http %s", resp.status_code) + raise SttError(f"oauth http {resp.status_code}") + try: + body = resp.json() + value, expires_at_ms = body["access_token"], float(body["expires_at"]) + except (ValueError, KeyError, TypeError) as exc: + raise SttError("oauth bad response") from exc + # expires_at is wall-clock epoch ms from Sber; keep a monotonic deadline with a minute of margin. + ttl = max(0.0, expires_at_ms / 1000 - time.time() - 60) + tok.value, tok.expires = value, time.monotonic() + ttl + log.info("stt salute oauth token refreshed") + return value + + def _verify(self) -> ssl.SSLContext: + context = ssl.create_default_context() + if self._ca is not None: + context.load_verify_locations(cafile=str(self._ca)) + return context + + async def _post(self, pcm: bytes, headers: dict[str, str]) -> dict: + if self._client is not None: + return await self._recognize(self._client, pcm, headers) + async with httpx.AsyncClient(timeout=self._timeout, verify=self._verify()) as client: + return await self._recognize(client, pcm, headers) + + async def _recognize(self, client: httpx.AsyncClient, pcm: bytes, headers: dict[str, str]) -> dict: + refreshed = False + for attempt in range(_ATTEMPTS): + token = await self._get_token(client) + try: + resp = await client.post( + SALUTE_RECOGNIZE_URL, + content=pcm, + params={"language": "ru-RU"}, + headers={**headers, "Authorization": f"Bearer {token}"}, + timeout=self._timeout, + ) + 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 == 401 and not refreshed: + # The cached token was revoked or expired early: fetch a new one once. + refreshed = True + await self._get_token(client, force=True) + continue + if resp.status_code in _RETRY_STATUSES and attempt + 1 < _ATTEMPTS: + log.warning("stt salute http %s, retry %d", resp.status_code, attempt + 1) + await asyncio.sleep(0.5 * 2**attempt) + continue + if resp.status_code != 200: + log.error("stt salute 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.""" + """The configured provider; `None` = voice input is off. + + `auto` = SaluteSpeech if credentials are set. Otherwise only in the demo (synthetic data): OpenRouter if a key is + set, else mock. Outside the demo `auto` never picks a foreign service or a fake transcript — voice stays off. + """ mode = settings.stt_provider + has_salute = bool(settings.salute_speech_credentials.get_secret_value()) if mode == "auto": - mode = "openrouter" if settings.openrouter_api_key else "mock" + if has_salute: + mode = "salute" + elif not settings.demo_clock: + return None + else: + mode = "openrouter" if settings.openrouter_api_key else "mock" + if mode == "salute": + return SaluteSTT(settings) if has_salute else None if mode == "openrouter": return OpenRouterSTT(settings) if settings.openrouter_api_key else None if mode == "mock": diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index d822c36..7c4347e 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-016: Голос в пилоте — SaluteSpeech (REST), зарубежный STT вне демо запрещён +**Контекст:** с реальными пациентами голос — ПДн, и обрабатывать его можно только в РФ-контуре (152-ФЗ, ст. 12 и ст. 18 ч. 5). OpenRouter допустим лишь на синтетике (ADR-014). Пилот «Династия» стартует 01.02.2027. +**Решение:** +1. `SaluteSTT` (`app/llm/stt.py`) за тем же протоколом `SpeechToText`. Используется синхронное REST-распознавание файла (`speech:recognize`): для сообщений до 60 с потоковый gRPC (как в vobot) не нужен, protobuf-стабы и grpc в зависимостях не появляются. Из WAV браузера снимается заголовок, отправляется PCM 16 бит mono. +2. OAuth-токен (30 мин) кешируется на процесс и обновляется за минуту до истечения или по 401 — чтобы не выжигать лимит OAuth Сбера. +3. Креды — `SALUTE_SPEECH_CREDENTIALS` (секрет, только `.env`), `SALUTE_SPEECH_SCOPE`. TLS проверяется всегда; корневой сертификат Минцифры не входит в certifi, путь к нему — `SALUTE_SPEECH_CA` (как `MAX_CA_BUNDLE`). +4. **Guard:** при `DEMO_CLOCK=false` `STT_PROVIDER=openrouter` — ошибка валидации настроек, приложение не стартует. `auto` выбирает SaluteSpeech, если есть креды; вне демо без них голос выключен, а не уходит в OpenRouter или mock (выдуманный текст реальному пациенту). +**Последствия:** для пилота нужны договор SaluteSpeech, ключ и сертификат Минцифры на сервере. До получения ключа живой проверки нет — только тесты с подменой транспорта. + ## 2026-10-31 — ADR-015: Сад — растит честная отметка, после кризиса тишина, материалы не считаются **Контекст:** геймификация при ПТСР и депрессии клинически чувствительна (#120). Сад (#118, #119) не должен давить, подменять клиническую динамику и искажать данные для врача. Решение принято командой и **ждёт вычитки психиатром** (отметка в #120). **Решение:** diff --git a/tests/test_llm_stt.py b/tests/test_llm_stt.py index fc28054..e3a707a 100644 --- a/tests/test_llm_stt.py +++ b/tests/test_llm_stt.py @@ -1,18 +1,25 @@ -"""Speech-to-text providers (#116): OpenRouter request shape, retries, silence, WAV parsing, provider choice.""" +"""Speech-to-text providers (#116, #121): OpenRouter and SaluteSpeech request shape, retries, silence, WAV parsing, +provider choice and the «no foreign STT outside the demo» guard.""" import base64 import io import json +import time import wave import httpx import pytest +from pydantic import ValidationError from app.config import Settings +from app.llm import stt as stt_mod from app.llm.stt import ( + SALUTE_OAUTH_URL, + SALUTE_RECOGNIZE_URL, InvalidAudio, MockSTT, OpenRouterSTT, + SaluteSTT, SttError, SttNoSpeech, get_stt, @@ -105,3 +112,101 @@ def test_provider_choice() -> None: 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 + # SaluteSpeech wins `auto` whenever its credentials are set. + assert isinstance(get_stt(_settings(stt_provider="auto", salute_speech_credentials="c")), SaluteSTT) + assert isinstance(get_stt(_settings(stt_provider="salute", salute_speech_credentials="c")), SaluteSTT) + assert get_stt(_settings(stt_provider="salute")) is None + + +def test_outside_demo_no_foreign_stt() -> None: + """#121: with real patients (DEMO_CLOCK=false) OpenRouter refuses to start, `auto` never picks it or mock.""" + with pytest.raises(ValidationError, match="DEMO_CLOCK=false"): + _settings(stt_provider="openrouter", demo_clock=False) + assert get_stt(_settings(stt_provider="auto", demo_clock=False)) is None + salute = get_stt(_settings(stt_provider="auto", demo_clock=False, salute_speech_credentials="c")) + assert isinstance(salute, SaluteSTT) + + +def test_salute_ca_must_exist(tmp_path) -> None: + with pytest.raises(ValidationError, match="SALUTE_SPEECH_CA"): + _settings(salute_speech_ca=tmp_path / "missing.crt") + + +class _SaluteFake: + """Fake Sber: OAuth + recognize. `replies` are recognize responses in order.""" + + def __init__(self, replies: list[httpx.Response]) -> None: + self.replies = iter(replies) + self.oauth_calls = 0 + self.recognize: list[httpx.Request] = [] + + def __call__(self, request: httpx.Request) -> httpx.Response: + if str(request.url) == SALUTE_OAUTH_URL: + self.oauth_calls += 1 + assert request.headers["Authorization"] == "Basic cred-1" + assert request.headers["RqUID"] and b"scope=SALUTE_SPEECH_PERS" in request.content + exp_ms = int((time.time() + 1800) * 1000) + return httpx.Response(200, json={"access_token": f"tok-{self.oauth_calls}", "expires_at": exp_ms}) + assert str(request.url).startswith(SALUTE_RECOGNIZE_URL) + self.recognize.append(request) + return next(self.replies) + + +def _salute(replies: list[httpx.Response]) -> tuple[SaluteSTT, _SaluteFake]: + stt_mod._salute_token.cache_clear() + fake = _SaluteFake(replies) + settings = _settings(stt_provider="salute", salute_speech_credentials="cred-1") + return SaluteSTT(settings, client=httpx.AsyncClient(transport=httpx.MockTransport(fake))), fake + + +def _said(*parts: str) -> httpx.Response: + return httpx.Response(200, json={"status": 200, "result": list(parts), "emotions": []}) + + +async def test_salute_sends_raw_pcm_and_joins_result() -> None: + stt, fake = _salute([_said("сегодня плохо спал", "", "хочу обсудить")]) + wav = make_wav(1.0) + assert await stt.transcribe(wav) == "сегодня плохо спал хочу обсудить" + req = fake.recognize[0] + assert req.headers["Content-Type"] == "audio/x-pcm;bit=16;rate=16000" + assert req.headers["Authorization"] == "Bearer tok-1" + assert req.url.params["language"] == "ru-RU" + assert req.content == wav[44:] # the WAV header is stripped: PCM only + + +async def test_salute_token_cached_and_refreshed_on_401() -> None: + stt, fake = _salute([_said("раз"), _said("два"), httpx.Response(401), _said("три")]) + for _ in range(2): + await stt.transcribe(make_wav(0.5)) + assert fake.oauth_calls == 1 # one token for both requests + assert await stt.transcribe(make_wav(0.5)) == "три" + assert fake.oauth_calls == 2 and fake.recognize[-1].headers["Authorization"] == "Bearer tok-2" + + +async def test_salute_empty_result_is_no_speech() -> None: + stt, _ = _salute([_said()]) + with pytest.raises(SttNoSpeech): + await stt.transcribe(make_wav(0.5)) + + +async def test_salute_errors(monkeypatch) -> None: + monkeypatch.setattr("app.llm.stt.asyncio.sleep", _no_sleep) + stt, _ = _salute([httpx.Response(503), httpx.Response(503), httpx.Response(503)]) + with pytest.raises(SttError): + await stt.transcribe(make_wav(0.5)) + stt, _ = _salute([httpx.Response(400, json={"status": 400, "message": "bad audio"})]) + with pytest.raises(SttError): + await stt.transcribe(make_wav(0.5)) + + +async def test_salute_rejects_non_pcm_mono() -> None: + stt, fake = _salute([]) + buf = io.BytesIO() + with wave.open(buf, "wb") as w: + w.setnchannels(2) + w.setsampwidth(2) + w.setframerate(16_000) + w.writeframes(b"\x00\x00" * 3200) + with pytest.raises(InvalidAudio): + await stt.transcribe(buf.getvalue()) + assert fake.oauth_calls == 0