feat(llm): speech-to-text for patient voice input via OpenRouter (+ offline mock)
CI / ruff · pytest (pull_request) Successful in 2m57s
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>
This commit is contained in:
@@ -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
@@ -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")
|
||||
|
||||
@@ -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).
|
||||
|
||||
+160
@@ -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("<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,
|
||||
}
|
||||
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
|
||||
@@ -0,0 +1,17 @@
|
||||
# Распознавание голоса пациента (#116). Модель только расшифровывает речь — не отвечает и не советует.
|
||||
# Расшифровка возвращается пациенту на проверку и уходит в диалог обычным текстом (safety → scrub → LLM).
|
||||
|
||||
# Системная инструкция модели распознавания.
|
||||
system: >-
|
||||
Ты — система распознавания речи. Дословно расшифруй русскую речь из аудио.
|
||||
Выведи только расшифровку: без кавычек, пояснений, перевода и ответа на сказанное.
|
||||
Ставь знаки препинания. Если речи нет или её не разобрать — выведи ровно [тишина].
|
||||
|
||||
# Текст рядом с аудио в сообщении пользователя.
|
||||
user: "Расшифруй запись."
|
||||
|
||||
# Что модель возвращает, если речи нет (сравнивается без учёта регистра).
|
||||
silence_marker: "[тишина]"
|
||||
|
||||
# Офлайн-режим (STT_PROVIDER=mock): фиксированная фраза демо-пациента, синтетика.
|
||||
mock_transcript: "Сегодня долго не мог уснуть. Хочу обсудить это на следующей встрече."
|
||||
@@ -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: Правки по клиническому ревью — красные флаги, одно правило кризиса, звонок после кризиса, одна таблица номеров
|
||||
**Контекст:** ревью опросников: обязательны блок красных флагов и план безопасности со звонком после кризиса; маршрутизация должна сначала смотреть на риск и флаги, потом на шкалы; в документе два противоречащих правила кризисного экрана («любое Да» против «только высокий риск»); кризисные номера разбросаны по текстам (и чат-агент давал взрослым детский телефон доверия).
|
||||
**Решение:**
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user