From 7443b61974dc3cfc971b3e10966cb4187840f83e Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Sat, 31 Oct 2026 11:19:55 +0300 Subject: [PATCH 1/2] feat(llm): speech-to-text for patient voice input via OpenRouter (+ offline mock) 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 --- .env.example | 4 + app/api/patient_web.py | 56 +++++++++++- app/config.py | 7 ++ app/llm/stt.py | 160 ++++++++++++++++++++++++++++++++++ config/texts/stt.yaml | 17 ++++ docs/DECISIONS.md | 9 ++ tests/test_api_patient_web.py | 4 +- tests/test_api_stt.py | 71 +++++++++++++++ tests/test_llm_stt.py | 106 ++++++++++++++++++++++ 9 files changed, 430 insertions(+), 4 deletions(-) create mode 100644 app/llm/stt.py create mode 100644 config/texts/stt.yaml create mode 100644 tests/test_api_stt.py create mode 100644 tests/test_llm_stt.py 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 -- 2.54.0 From d7006088253b776b434fac0e206cfb7738f2691d Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Sat, 31 Oct 2026 11:29:40 +0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(llm):=20STT=20prompt=20that=20keeps=20u?= =?UTF-8?q?nclear=20speech;=20reasoning=20off=20(~60=20s=20=E2=86=92=20~3?= =?UTF-8?q?=20s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/llm/stt.py | 2 ++ config/texts/stt.yaml | 6 +++--- tests/test_llm_stt.py | 1 + 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/llm/stt.py b/app/llm/stt.py index e933030..ea0a17e 100644 --- a/app/llm/stt.py +++ b/app/llm/stt.py @@ -108,6 +108,8 @@ class OpenRouterSTT: "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: diff --git a/config/texts/stt.yaml b/config/texts/stt.yaml index e187374..31a90a5 100644 --- a/config/texts/stt.yaml +++ b/config/texts/stt.yaml @@ -4,11 +4,11 @@ # Системная инструкция модели распознавания. system: >- Ты — система распознавания речи. Дословно расшифруй русскую речь из аудио. - Выведи только расшифровку: без кавычек, пояснений, перевода и ответа на сказанное. - Ставь знаки препинания. Если речи нет или её не разобрать — выведи ровно [тишина]. + Выведи только расшифровку: без кавычек, пояснений и ответа на сказанное. + Ставь знаки препинания. Если в записи совсем нет речи — выведи ровно [тишина]. # Текст рядом с аудио в сообщении пользователя. -user: "Расшифруй запись." +user: "Расшифруй эту аудиозапись." # Что модель возвращает, если речи нет (сравнивается без учёта регистра). silence_marker: "[тишина]" diff --git a/tests/test_llm_stt.py b/tests/test_llm_stt.py index 8f978d1..fc28054 100644 --- a/tests/test_llm_stt.py +++ b/tests/test_llm_stt.py @@ -66,6 +66,7 @@ async def test_openrouter_sends_audio_and_returns_transcript() -> None: 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 -- 2.54.0