[backend][B] STT: распознавание голоса через OpenRouter (+mock), POST /me/{token}/stt #116

Closed
opened 2026-10-31 11:10:13 +03:00 by Eleboba · 0 comments
Owner

Описание

Часть эпика «Демо к гранту ВШЭ и пилоту „Династия“». Слайд 11 презентации: пациент рассказывает голосом, видит распознанный текст и может его исправить. Нужен серверный шаг «аудио → текст».

Образец — vobot (backend/app/core/openrouter_llm.py): httpx-клиент к OpenAI-совместимому /chat/completions, ретраи на 429/5xx, HTTP-прокси, потому что OpenRouter режет РФ-IP. В нашем репо такой же клиент уже есть: app/llm/agent/providers.py::OpenRouterProvider. Ключ, прокси и base_url переиспользуем (OPENROUTER_* в app/config.py).

OpenRouter принимает аудио в chat completions как часть сообщения: {"type":"input_audio","input_audio":{"data":"<base64>","format":"wav"}}. Модель берётся с аудиовходом, по умолчанию google/gemini-2.5-flash. В промпте только «дословно расшифруй русскую речь, не отвечай на неё».

Желаемый результат

  • app/llm/stt.py с протоколом SpeechToText.transcribe(audio: bytes, fmt: str) -> str и провайдерами:
    • OpenRouterSTT: input_audio, temperature 0, таймаут, прокси, ретраи по образцу vobot;
    • MockSTT: офлайн, возвращает фиксированную демо-фразу из config/texts/ (ADR-004: демо работает без сети).
  • Настройки STT_PROVIDER=off|mock|openrouter (по умолчанию mock), STT_OPENROUTER_MODEL, STT_MAX_SECONDS=60, STT_MAX_BYTES.
  • POST /api/v1/me/{token}/stt принимает multipart с аудио (WAV 16 кГц mono) и возвращает {"text": "...", "provider": "openrouter"}. При off ручка отвечает 404, а /patient/capabilities отдаёт voice_input: false, по этому флагу web скрывает кнопку.
  • Ручка только расшифровывает. Текст в диалог не пишет и LLM-агента не вызывает: пациент сначала проверяет текст (слайд 11), потом отправляет его обычным /dialog/{token}/events, где работают safety и scrub().

Подзадачи

  • app/llm/stt.py: протокол, OpenRouterSTT, MockSTT, фабрика по настройке
  • app/config.py и .env.example: STT_* (контрактные файлы, мелкий PR с пингом)
  • app/api/patient_web.py: ручка /me/{token}/stt и флаг voice_input в capabilities
  • лимиты: размер, длительность, формат; 413/415 при нарушении
  • docs/DECISIONS.md: ADR-014 «STT через OpenRouter только на синтетике, в пилоте РФ-провайдер»
  • тесты tests/test_llm_stt.py, tests/test_api_stt.py, OpenRouter мокается через httpx.MockTransport

Анализ

Затрагиваемые файлы

Файл Что меняется
app/llm/stt.py новый модуль
app/config.py, .env.example STT_*
app/api/patient_web.py ручка и capability
docs/DECISIONS.md ADR-014

Ограничения и риски

  • 152-ФЗ: аудио нельзя обезличить через scrub(), оно уходит за рубеж целиком. Разрешено только на синтетике (демо, жюри, грант). Реальный пациент получает голос только через РФ-провайдера (задача SaluteSpeech). Это фиксируется в ADR-014 и в комментарии к провайдеру, как уже сделано для OpenRouterProvider.
  • Аудио не сохраняется ни в БД, ни на диск. Логируются только длительность, размер, провайдер и время ответа, без текста расшифровки (guardrail 8).
  • Ответ модели может быть «ответом на вопрос», а не расшифровкой. Промпт жёсткий, temperature 0; при пустом или явно нерелевантном выводе возвращаем 422 «не удалось распознать».

Архитектурный выбор

Формат аудио принимает только WAV 16 кГц mono, кодирует его браузер. Альтернатива: принимать webm/opus и mp4 и перекодировать на сервере через ffmpeg. Отказались, потому что это лишняя зависимость в образе, а webm OpenRouter поддерживает не у всех моделей. Запись в 60 с весит около 1,9 МБ, это приемлемо.

Как проверить

STT_PROVIDER=mock make up → curl -F audio=@sample.wav localhost:8000/api/v1/me/<token>/stt → демо-фраза. С STT_PROVIDER=openrouter и ключом: реальная расшифровка синтетической записи. make lint test.

2026-10-31

  • Что сделано: app/llm/stt.py (OpenRouter input_audio + mock), ручка /me/{token}/stt, capability voice_input, config/texts/stt.yaml, ADR-014, тесты.
  • Результат: 1078 тестов зелёные. Живой прогон заблокирован: ключ OpenRouter в .env отвечает 401 User not found; пользователь заменит ключ.
## Описание Часть эпика «Демо к гранту ВШЭ и пилоту „Династия“». Слайд 11 презентации: пациент рассказывает голосом, видит распознанный текст и может его исправить. Нужен серверный шаг «аудио → текст». Образец — vobot (`backend/app/core/openrouter_llm.py`): httpx-клиент к OpenAI-совместимому `/chat/completions`, ретраи на 429/5xx, HTTP-прокси, потому что OpenRouter режет РФ-IP. В нашем репо такой же клиент уже есть: `app/llm/agent/providers.py::OpenRouterProvider`. Ключ, прокси и base_url переиспользуем (`OPENROUTER_*` в `app/config.py`). OpenRouter принимает аудио в chat completions как часть сообщения: `{"type":"input_audio","input_audio":{"data":"<base64>","format":"wav"}}`. Модель берётся с аудиовходом, по умолчанию `google/gemini-2.5-flash`. В промпте только «дословно расшифруй русскую речь, не отвечай на неё». ## Желаемый результат - `app/llm/stt.py` с протоколом `SpeechToText.transcribe(audio: bytes, fmt: str) -> str` и провайдерами: - `OpenRouterSTT`: input_audio, temperature 0, таймаут, прокси, ретраи по образцу vobot; - `MockSTT`: офлайн, возвращает фиксированную демо-фразу из `config/texts/` (ADR-004: демо работает без сети). - Настройки `STT_PROVIDER=off|mock|openrouter` (по умолчанию `mock`), `STT_OPENROUTER_MODEL`, `STT_MAX_SECONDS=60`, `STT_MAX_BYTES`. - `POST /api/v1/me/{token}/stt` принимает multipart с аудио (WAV 16 кГц mono) и возвращает `{"text": "...", "provider": "openrouter"}`. При `off` ручка отвечает 404, а `/patient/capabilities` отдаёт `voice_input: false`, по этому флагу web скрывает кнопку. - Ручка **только расшифровывает**. Текст в диалог не пишет и LLM-агента не вызывает: пациент сначала проверяет текст (слайд 11), потом отправляет его обычным `/dialog/{token}/events`, где работают safety и `scrub()`. ## Подзадачи - [ ] `app/llm/stt.py`: протокол, `OpenRouterSTT`, `MockSTT`, фабрика по настройке - [ ] `app/config.py` и `.env.example`: STT_* (контрактные файлы, мелкий PR с пингом) - [ ] `app/api/patient_web.py`: ручка `/me/{token}/stt` и флаг `voice_input` в capabilities - [ ] лимиты: размер, длительность, формат; 413/415 при нарушении - [ ] `docs/DECISIONS.md`: ADR-014 «STT через OpenRouter только на синтетике, в пилоте РФ-провайдер» - [ ] тесты `tests/test_llm_stt.py`, `tests/test_api_stt.py`, OpenRouter мокается через `httpx.MockTransport` ## Анализ ### Затрагиваемые файлы | Файл | Что меняется | |---|---| | `app/llm/stt.py` | новый модуль | | `app/config.py`, `.env.example` | STT_* | | `app/api/patient_web.py` | ручка и capability | | `docs/DECISIONS.md` | ADR-014 | ### Ограничения и риски - **152-ФЗ:** аудио нельзя обезличить через `scrub()`, оно уходит за рубеж целиком. Разрешено только на синтетике (демо, жюри, грант). Реальный пациент получает голос только через РФ-провайдера (задача SaluteSpeech). Это фиксируется в ADR-014 и в комментарии к провайдеру, как уже сделано для `OpenRouterProvider`. - Аудио **не сохраняется** ни в БД, ни на диск. Логируются только длительность, размер, провайдер и время ответа, без текста расшифровки (guardrail 8). - Ответ модели может быть «ответом на вопрос», а не расшифровкой. Промпт жёсткий, temperature 0; при пустом или явно нерелевантном выводе возвращаем 422 «не удалось распознать». ### Архитектурный выбор Формат аудио принимает только WAV 16 кГц mono, кодирует его браузер. Альтернатива: принимать webm/opus и mp4 и перекодировать на сервере через ffmpeg. Отказались, потому что это лишняя зависимость в образе, а webm OpenRouter поддерживает не у всех моделей. Запись в 60 с весит около 1,9 МБ, это приемлемо. ## Как проверить `STT_PROVIDER=mock make up` → `curl -F audio=@sample.wav localhost:8000/api/v1/me/<token>/stt` → демо-фраза. С `STT_PROVIDER=openrouter` и ключом: реальная расшифровка синтетической записи. `make lint test`. ### 2026-10-31 - **Что сделано:** `app/llm/stt.py` (OpenRouter `input_audio` + mock), ручка `/me/{token}/stt`, capability `voice_input`, `config/texts/stt.yaml`, ADR-014, тесты. - **Результат:** 1078 тестов зелёные. Живой прогон заблокирован: ключ OpenRouter в `.env` отвечает 401 `User not found`; пользователь заменит ключ.
Eleboba added this to the Грант ВШЭ · пилот «Династия» milestone 2026-10-31 11:10:13 +03:00
Eleboba added the lane/Bfeaturesize/1P0stage/veteransbackend labels 2026-10-31 11:10:13 +03:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Reference: MedITron/PTSRaiAgent#116