From 2e6ea91d315d8911513057f1b9a927ef6dcadf15 Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Sun, 19 Jul 2026 18:58:53 +0000 Subject: [PATCH 1/4] docs: plan Deepgram Nova-3 transcription provider --- ...07-19-deepgram-nova3-transcription-plan.md | 616 ++++++++++++++++++ 1 file changed, 616 insertions(+) create mode 100644 docs/plans/2026-07-19-deepgram-nova3-transcription-plan.md diff --git a/docs/plans/2026-07-19-deepgram-nova3-transcription-plan.md b/docs/plans/2026-07-19-deepgram-nova3-transcription-plan.md new file mode 100644 index 0000000..dac2ff3 --- /dev/null +++ b/docs/plans/2026-07-19-deepgram-nova3-transcription-plan.md @@ -0,0 +1,616 @@ +# Deepgram Nova-3 как второй STT-провайдер — план реализации + +**Статус:** готов к реализации после ревью плана +**Дата:** 2026-07-19 +**Ветка:** `plan/deepgram-nova3-transcription` +**База:** `origin/dev` @ `77082c331c1cf00cdb0d706fa1b099a982a5ab4a` + +## 1. Цель + +Добавить Deepgram Nova-3 как второй провайдер транскрибации аудио в текст, не ломая +существующий Groq Whisper-путь и внешний контракт LectureLog: + +- вход стадии — локальный аудиофайл; +- выход стадии — `transcript.srt` с корректными абсолютными таймкодами; +- downstream-стадии (`structurize`, `video_slides`, нарезка медиа) не знают о провайдере; +- `GET /tasks/{id}/transcript?srt|txt` сохраняет текущий wire-контракт; +- `usage.transcribe` продолжает содержать `audio_seconds`, `provider`, `model`, `raw`; +- провайдер переключается конфигурацией и откатывается без миграции БД или API. + +Первая реализация должна быть пригодна для Pay-As-You-Go/free-credit тестов, но не должна +автоматически включать платные Deepgram add-on'ы. + +## 2. Не входит в первую реализацию + +- автоматический fallback Deepgram -> Groq внутри одной задачи; +- одновременная транскрибация одной задачи двумя провайдерами в production; +- streaming/WebSocket STT; +- speaker diarization, redaction, summarization и Audio Intelligence; +- per-task выбор провайдера или языка через публичный API; +- сохранение полного сырого ответа Deepgram в БД или S3; +- динамические keyterms из содержимого слайдов/названия лекции; +- изменение публичного OpenAPI-контракта. + +Автоматический fallback намеренно откладывается: он может незаметно удвоить расходы, +породить разные транскрипты при одинаковом входе и затруднить диагностику качества. +На первом этапе провайдер выбирается один раз при старте процесса. + +## 3. Как транскрибация устроена сейчас + +### 3.1. Контракт и оркестрация + +- `lecturelog/domain/ports.py::Transcriber` принимает `audio_path`, `output_dir`, + `on_progress`, `on_usage` и возвращает путь к SRT. +- `lecturelog/application/pipeline_service.py` передаёт адаптеру исходное аудио либо + MP3, извлечённый из видео, затем сразу персистит `usage`. +- Один и тот же SRT используется для: + - тематического разбиения и рендера конспекта; + - привязки кадров видео по времени; + - `GET /tasks/{id}/transcript` в форматах SRT/TXT. +- `lecturelog/infrastructure/srt.py` ожидает стандартные блоки с таймкодами + `HH:MM:SS,mmm --> HH:MM:SS,mmm`. + +Следствие: Deepgram-адаптер нельзя ограничить выдачей plain text или vendor JSON — он +должен сформировать валидный, монотонный SRT в общей временной шкале исходного аудио. + +### 3.2. Текущий Groq-адаптер + +`lecturelog/infrastructure/transcribe/groq_transcriber.py`: + +1. best-effort получает длительность через `ffprobe`; +2. перекодирует вход в MP3 128 kbps и режет на 20-минутные чанки; +3. последовательно отправляет чанки в Groq `whisper-large-v3`; +4. запрашивает word timestamps; +5. добавляет к словам offset `index * 1200`; +6. собирает SRT механическими группами по семь слов; +7. ретраит timeout/429/503/524 и умеет переключать Groq-ключи. + +### 3.3. Текущие жёсткие связи с Groq/Whisper + +- `lecturelog/api/lifespan.py` напрямую создаёт только `GroqTranscriber`. +- `lecturelog/config/settings.py` всегда требует `GROQ_API_KEYS`, даже если будет выбран + другой провайдер. +- `.env.example`, `deploy/env.core.example` и README описывают только Groq STT. +- `UsageAccumulator.record_transcribe()` имеет неявный default `provider="groq"`. +- `prompts/section_v1.md` утверждает, что транскрипт всегда получен из Whisper. +- комментарии и имена части error-classifier тестов привязаны к Groq, хотя сам + `httpx.HTTPStatusError` уже провайдер-нейтрален. + +## 4. Что подтверждено документацией Deepgram + +Основной API для готовых файлов — `POST https://api.deepgram.com/v1/listen` с +`Authorization: Token ` и бинарным телом локального файла. + +Для LectureLog нужны параметры: + +| Параметр | Решение | Причина | +|---|---|---| +| `model` | `nova-3` | Явно фиксируем нужное семейство; без параметра API использует `base`. | +| `language` | конфиг, стартовое значение `ru` | Deepgram по умолчанию использует `en`; русский Nova-3 поддерживает явно. | +| `smart_format` | `true` | Даёт пунктуацию/регистр/paragraphs и включён в цену. | +| `utterances` | `true` | Даёт пауза-ориентированные сегменты и word timestamps для построения SRT. | +| `utt_split` | конфиг, default `0.8` | Документированный default; позволяет настроить слишком мелкие/крупные блоки без релиза кода. | +| `mip_opt_out` | всегда `true` | Исключает аудио лекций из Model Improvement Program; с 2026-03-05 для Pay-As-You-Go это не меняет публичную цену. | + +Существенные ограничения и свойства: + +- поддерживаются MP3, MP4, AAC, WAV, FLAC, M4A, Ogg, Opus, WebM и многие другие + контейнеры/кодеки; +- максимальный размер файла — 2 GB; +- синхронная обработка Nova имеет серверный processing-time limit 10 минут; после него + API возвращает 504; +- актуальный лимит Pay-As-You-Go для pre-recorded Nova-3 — до 50 одновременных запросов + на проект; внутренний `MAX_CONCURRENT_TASKS=2` существенно ниже; +- при 429 Deepgram рекомендует exponential backoff; +- API возвращает `metadata.duration`, сведения о фактической модели, channel alternatives, + `words`, а с `utterances=true` — ещё и смысловые/пауза-ориентированные сегменты; +- Deepgram не хранит транскрипт для последующего получения: успешный JSON-ответ нужно + преобразовать и сохранить сразу; +- без `mip_opt_out=true` запрос по умолчанию участвует в Model Improvement Program; для + LectureLog privacy-safe default должен быть opt-out, при котором данные удерживаются только + на время, необходимое для обработки запроса; +- `language=ru` ограничивает распознавание выбранным языком; для лекций с настоящим + переключением между русским и английским доступен `language=multi`. + +### Официальные источники + +- [Pre-recorded audio: начало работы](https://developers.deepgram.com/docs/pre-recorded-audio) +- [API `POST /v1/listen`](https://developers.deepgram.com/reference/speech-to-text/listen-pre-recorded) +- [Nova-3: модели и языки](https://developers.deepgram.com/docs/models-languages-overview) +- [Language и ограничение выбранным языком](https://developers.deepgram.com/docs/language) +- [Smart Format](https://developers.deepgram.com/docs/smart-format) +- [Utterances](https://developers.deepgram.com/docs/utterances) +- [Utterance Split](https://developers.deepgram.com/docs/utterance-split) +- [Форматы аудио](https://developers.deepgram.com/docs/supported-audio-formats) +- [Rate limits](https://developers.deepgram.com/reference/api-rate-limits) +- [Ошибки и retry-рекомендации](https://developers.deepgram.com/docs/errors) +- [Keyterm Prompting](https://developers.deepgram.com/docs/keyterm) +- [Model Improvement Program и `mip_opt_out`](https://developers.deepgram.com/docs/the-deepgram-model-improvement-partnership-program) +- [Изменение MIP pricing от 2026-03-05](https://developers.deepgram.com/changelog/2026/3/5) +- [Актуальный pricing](https://deepgram.com/pricing) + +## 5. Экономика free credit + +На момент подготовки плана Deepgram показывает $200 бесплатного Pay-As-You-Go кредита, +без обязательного минимального платежа; цену нужно перепроверить перед production rollout, +так как тарифы являются внешним изменяемым контрактом. + +Для pre-recorded: + +| Режим | Текущая цена | Цена часа | Примерно часов на $200 | +|---|---:|---:|---:| +| Nova-3 monolingual (`language=ru`) | $0.0077/мин | $0.462 | 433 ч | +| Nova-3 multilingual (`language=multi`) | $0.0092/мин | $0.552 | 362 ч | + +`smart_format` включён. `mip_opt_out=true` не меняет опубликованную цену для +Pay-As-You-Go/Growth по changelog от 2026-03-05; это всё равно повторно проверяется в console +перед длинным бенчем. Не включаем по умолчанию: + +- Keyterm Prompting — отдельная доплата; к тому же пока нет надёжного источника + per-lecture словаря; +- Speaker Diarization — отдельная доплата и новый продуктовый контракт speaker labels; +- Redaction — отдельная доплата и потенциально меняет исходный смысл лекции. + +Полный A/B-бенч из трёх 10-минутных отрывков и одной 90-минутной лекции будет стоить +около $0.92 в `ru` или $1.10 в `multi` по текущему прайсу. + +## 6. Проведённые smoke-тесты + +Ключ использовался только через переменную окружения интерактивного shell; в worktree, +Git, команды с выводом и тестовые артефакты он не записывался. Все аудиофайлы и JSON-ответы +находятся только под `/tmp/deepgram-nova3-smoke/`. + +### 6.1. Русский binary-upload + +Из четырёх открытых CC BY аудиосэмплов Wikimedia/Shtooka собран WAV длительностью +7.351 с: «русский», «язык», «пример», «правда» с паузами. + +- запрос: `model=nova-3`, `language=ru`, `smart_format=true`, `utterances=true`; +- статус: HTTP 200; +- фактическая архитектура: `nova-3` (`general-nova-3`); +- распознано: `Русский Язык Пример Правда` — 4/4 слова; +- получены `words`, `punctuated_word`, confidence и `utterances`. + +Тест выполнялся на публичных CC BY образцах; в нём `mip_opt_out` ещё не был передан. Все +следующие тесты, особенно с пользовательским материалом, обязаны передавать +`mip_opt_out=true`. + +Обнаружен важный крайний случай: `metadata.duration=7.351`, но `end` последнего слова и +utterance был `9.52`. Искусственная склейка не является quality benchmark, однако ответ +доказывает, что vendor timestamps нельзя безусловно доверять: перед записью SRT нужны clamp, +проверка конечности чисел, сортировка и обеспечение монотонности. + +### 6.2. Естественная английская речь + +Официальный sample `spacewalk.wav`, 25.933 с: + +- HTTP 200 за 2.07 с клиентского времени; +- 62 слова и 7 utterances; +- первый timestamp `0.0`, последний `25.355`, то есть внутри длительности; +- smart formatting, пунктуация и регистр присутствуют; +- фактическая архитектура — Nova-3. + +### 6.3. Что smoke-тесты не доказывают + +- качество на длинных русских лекциях; +- сохранность англоязычных терминов в режиме `ru`; +- преимущество `ru` или `multi` для реальных материалов LectureLog; +- поведение на шуме, нескольких спикерах и границах очень длинного файла; +- billing и latency на типичной 60–120-минутной лекции. + +Это проверяется отдельным A/B-этапом до включения Deepgram по умолчанию. + +## 7. Архитектурные решения + +### 7.1. Выбор провайдера + +Добавить процессный конфиг: + +```env +TRANSCRIBE_PROVIDER=groq # groq | deepgram +GROQ_API_KEYS= + +DEEPGRAM_API_KEY= +DEEPGRAM_BASE_URL=https://api.deepgram.com +DEEPGRAM_MODEL=nova-3 +DEEPGRAM_LANGUAGE=ru # ru | multi; технически допускается любой поддержанный код +DEEPGRAM_UTT_SPLIT=0.8 +``` + +Правила: + +- default остаётся `groq`, чтобы merge/deploy не переключил production случайно; +- обязательным становится ключ только выбранного провайдера; +- если выбран `deepgram`, отсутствие/пустота `DEEPGRAM_API_KEY` валит приложение на старте; +- если выбран `groq`, текущая CSV-семантика `GROQ_API_KEYS` сохраняется; +- `DEEPGRAM_API_KEY` хранить как `SecretStr`, не сериализовать и не логировать; +- `DEEPGRAM_BASE_URL` оставляет возможность EU endpoint + (`https://api.eu.deepgram.com`) без изменения кода; +- base URL обязан быть HTTPS-хостом из allowlist официальных hosted endpoints + (`api.deepgram.com`, `api.eu.deepgram.com`, `api.au.deepgram.com`), без userinfo/query; + custom Dedicated/self-hosted endpoint не входит в эту фазу; +- HTTP redirects не follow'ить, чтобы Authorization не мог уйти на другой host; +- каждый STT-запрос без конфигурационного переключателя передаёт `mip_opt_out=true`; +- в лог старта выводить provider/model/language/base host, но никогда ключ. + +### 7.2. `ru` против `multi` + +Код не должен зашивать окончательный продуктовый выбор. Начальный безопасный default — `ru`, +потому что продукт и промпты ориентированы на русские лекции, monolingual дешевле, а отдельная +русская модель обычно предсказуемее. Перед rollout обязательно сравнить `ru` и `multi` на +лекции с русской речью и англоязычными техническими терминами. + +Если `ru` пропускает или транслитерирует значимые английские фразы, production env переводится +на `multi` без изменения кода. Доплата по текущему прайсу — около $0.09 за час аудио. + +### 7.3. HTTP напрямую, без нового SDK + +Использовать уже имеющийся `httpx`, а не добавлять Deepgram SDK: + +- контракт — один стабильный REST endpoint; +- в проекте уже есть асинхронные httpx-паттерны и MockTransport-тесты; +- проще контролировать streaming body, таймауты, ретраи, progress и отсутствие утечки ключа; +- меньше зависимостей и lockfile churn. + +SDK можно пересмотреть, если REST-контракт усложнится или понадобится streaming/callback API. + +### 7.4. Один файл вместо 20-минутных STT-чанков + +Для Deepgram сначала отправлять один локальный файл целиком: + +- сохраняется контекст длинной лекции и code-switching; +- нет обрыва слов и потери контекста на границах 20 минут; +- нет вычисляемых offsets и накопления ошибки времени; +- Deepgram принимает файлы до 2 GB; +- для видео pipeline уже извлекает отдельную аудиодорожку. + +Тело нельзя собирать через `Path.read_bytes()` для больших WAV. Реализовать повторно +открываемый async file stream с фиксированным `Content-Length`; каждый retry открывает файл +заново. Перед запросом проверить `stat().st_size <= 2 GB`, иначе выдать понятную ошибку до сети. + +Если реальный full-lecture тест стабильно упирается в 504 processing timeout, запасной путь — +не немедленно включать callback API, а добавить provider-specific chunking с реальными +`ffprobe`-длительностями чанков и измеренными offsets. Callback потребовал бы нового входящего +API, персистентного request state и корректного resume после рестарта, поэтому это отдельная фаза. + +### 7.5. Построение SRT + +Источник текста по приоритету: + +1. `results.utterances` — сохраняет smart-formatted transcript и паузы; +2. fallback: `results.channels[0].alternatives[0].words`; +3. пустой transcript — пустой SRT, как сейчас у Groq, без искусственной ошибки. + +Для каждого caption: + +- короткий utterance отдавать как `utterance.transcript`, чтобы сохранить smart formatting; +- слишком длинный utterance делить по его `words` с ограничением, например, 12 слов или + 8 секунд; текст дочерних блоков собирать из `punctuated_word` с fallback на `word`, потому + что исходный `utterance.transcript` нельзя корректно разрезать по индексам сырых слов; +- пустые/нечисловые элементы пропускать; +- ограничить start/end диапазоном `[0, effective_duration]`, где effective duration — + конечная положительная `metadata.duration`, а при её отсутствии — точная float-длительность + `ffprobe`; если обе неизвестны, сохранить монотонность без верхнего clamp и залогировать warning; +- обеспечить `end >= start` и неубывающий порядок блоков; +- после нормализации заново пронумеровать блоки; +- писать атомарно в `output_dir/transcript.srt`; +- не включать confidence, speaker labels или vendor metadata в пользовательский SRT. + +Порог 12 слов/8 секунд не считать окончательной истиной: его покрыть тестами и проверить на +реальной лекции. Он нужен, чтобы один длинный Deepgram utterance не превращался в огромный +SRT-блок, который ухудшает тематический split и пользовательские субтитры. + +### 7.6. Progress и usage + +Progress должен оставаться монотонным даже при повторной загрузке и не создавать DB write +на каждый сетевой chunk. `PipelineService.transcribe_progress()` синхронно вызывает +`repository.update`, поэтому adapter эмитит только фиксированные пороги: + +- 5% — файл проверен, длительность определена; +- 10, 20, ..., 70% — streaming upload по переданным байтам, максимум семь callback'ов; +- 90% — успешный ответ получен и провалидирован; +- 100% — SRT записан. + +На retry нельзя эмитить меньше уже выданного процента. + +До сетевого запроса вызвать `on_usage` с best-effort `ffprobe`: + +```json +{"audio_seconds": 123, "provider": "deepgram", "model": "nova-3"} +``` + +Это сохраняет частичный usage при последующей ошибке, как сейчас. После успешного ответа +повторно вызвать `on_usage` с `int(metadata.duration)`, если duration конечная и положительная: +`UsageAccumulator.record_transcribe()` перезапишет предварительное зерно, и success usage будет +опираться на authoritative provider metadata. Если metadata отсутствует/невалидна, оставить +ffprobe-значение. Публичная usage-схема не меняется. + +### 7.7. Таймауты и ошибки + +Клиентский read timeout должен быть немного больше документированного 10-минутного серверного +лимита, чтобы клиент не обрывал ещё допустимый запрос. Предлагаемый старт: + +- connect 30 с; +- write/upload 300 с; +- read 660 с; +- pool 30 с. + +Retry policy (с jitter и ограниченным числом попыток): + +- network timeout/reset, 408, 429, 500, 502, 503 — exponential backoff; +- 504 — не более одного повтора, затем понятная ошибка с предложением chunk/callback path; +- 400/401/403/413/415/422 — без бессмысленного retry; +- логировать HTTP status, Deepgram `err_code` и `request_id`, но не Authorization, тело аудио + или полный ответ; +- 429/503 после исчерпания попыток должны по текущему `classify_error` стать + `ErrorCode.RATE_LIMIT`; +- adapter должен разбирать безопасные `err_code`/status и переводить только input-сигналы + (413/415, `ASR_UNPROCESSABLE_ENTITY`, подтверждённый corrupt/unsupported media) в `ValueError` + -> `BAD_INPUT`; generic 400 нельзя автоматически считать пользовательской ошибкой, потому что + он также покрывает неверные model/language/query настройки; +- 401/403, неверные model/language/base URL и malformed successful JSON/response shape должны + стать `INTERNAL` как ошибка конфигурации или контракта провайдера; +- текст исключения ограничить безопасным сообщением; не прикладывать полный vendor body. + +## 8. План реализации по шагам + +Каждый шаг делать test-first; после шага запускать указанный узкий набор, после всей серии — +полный unit/integration suite. + +### Шаг 1. Provider-neutral конфигурация + +**Файлы:** + +- `lecturelog/config/settings.py` +- `tests/unit/test_config.py` + +**Изменения:** + +1. Добавить `TranscribeConfig` с provider и provider-specific полями. +2. Сделать `GROQ_API_KEYS` условно обязательным только для `groq`. +3. Сделать `DEEPGRAM_API_KEY` условно обязательным только для `deepgram`. +4. Провалидировать `utt_split > 0`, непустые model/language, допустимый provider и HTTPS + hosted endpoint из allowlist. +5. Сохранить совместимость существующего production env без новых переменных. + +**Тесты:** + +- старый env создаёт Groq config; +- CSV Groq keys парсится как раньше; +- Deepgram config создаётся без `GROQ_API_KEYS`; +- отсутствующий ключ выбранного провайдера вызывает startup validation error; +- ключ не появляется в `repr`/`model_dump` открытым текстом; +- неизвестный provider, неположительный `utt_split`, HTTP/userinfo/query и неизвестный host + Deepgram endpoint отклоняются. + +### Шаг 2. Общие безопасные STT/SRT helpers + +**Файлы:** + +- новый `lecturelog/infrastructure/transcribe/common.py` +- `lecturelog/infrastructure/transcribe/groq_transcriber.py` +- `tests/unit/test_groq_transcriber.py` +- новый `tests/unit/test_transcribe_common.py` + +**Изменения:** + +1. Вынести без изменения поведения `_emit_progress`, `_emit_usage`, timestamp formatting и + best-effort `ffprobe`; общий probe возвращает float, а Groq при формировании usage продолжает + приводить его к int, чтобы не менять существующий контракт. +2. Добавить provider-neutral функции нормализации времени и записи SRT-caption'ов. +3. Перевести Groq на общие helpers без изменения его HTTP/chunk/retry поведения. + +**Тесты:** + +- sync/async/no-op callbacks; +- отрицательные, NaN/Inf и выходящие за duration таймкоды; +- сортировка, монотонность, перенумерация; +- SRT timestamp >24h остаётся валидным; +- весь существующий `test_groq_transcriber.py` зелёный. + +### Шаг 3. Deepgram Nova-3 адаптер + +**Файлы:** + +- новый `lecturelog/infrastructure/transcribe/deepgram_transcriber.py` +- новый `tests/unit/test_deepgram_transcriber.py` + +**Изменения:** + +1. Реализовать `DeepgramTranscriber(Transcriber)` с инъекцией transport/client factory для тестов. +2. Проверять существование и размер файла до запроса. +3. Стримить бинарное тело с `Content-Length` и подходящим Content-Type. +4. Передавать model/language/smart_format/utterances/utt_split и обязательный + `mip_opt_out=true`; redirects отключить. +5. Реализовать таймауты, reopen-on-retry, backoff+jitter и безопасные ошибки. +6. Валидировать response shape и строить нормализованный SRT. +7. Эмитить монотонный progress и provider-neutral usage. + +**Обязательные тесты:** + +- точный URL/query и `Authorization: Token ...`, при этом секрет не попадает в исключение; +- query всегда содержит `mip_opt_out=true`, redirect не пересылает Authorization; +- binary body передан без multipart-обёртки; +- retry заново читает файл с начала; +- 429/network/503 ретраятся, 401/403/413 — нет; +- malformed JSON/нет channels/нет alternatives обрабатываются явно; +- utterance transcript сохраняет пунктуацию; +- длинный utterance делится; +- fallback на words работает; +- пустой transcript создаёт пустой SRT; +- timestamp больше `metadata.duration` clamp'ится (регресс по smoke-тесту); +- progress не убывает при retry и вызывает callback не более семи раз на upload; +- при failure usage сохраняет ffprobe duration, при success уточняется из metadata.duration; +- input vendor errors мапятся в `BAD_INPUT`, auth/config/malformed JSON — в `INTERNAL`; +- файл >2 GB отклоняется до HTTP (через mock `stat`, без создания гигантского fixture). + +Ни один unit test не вызывает реальный Deepgram API. + +### Шаг 4. Factory и wiring + +**Файлы:** + +- `lecturelog/application/factories.py` +- `lecturelog/api/lifespan.py` +- `tests/unit/test_factories.py` +- при необходимости новый `tests/unit/test_lifespan_transcriber_wiring.py` + +**Изменения:** + +1. Добавить `transcriber_factory(config) -> Transcriber`. +2. Убрать прямой `GroqTranscriber(...)` из lifespan. +3. Логировать выбранные provider/model/language без секрета. +4. Не менять `PipelineService` и domain port. + +**Тесты:** + +- factory создаёт Groq и Deepgram по конфигу; +- неизвестный provider невозможен после validation; +- оба объекта являются `Transcriber`; +- lifespan не требует Groq key при выбранном Deepgram. + +### Шаг 5. Usage, errors и prompt neutrality + +**Файлы:** + +- `lecturelog/application/usage_accumulator.py` +- `lecturelog/application/error_classifier.py` +- `prompts/section_v1.md` +- `tests/unit/test_usage_accumulator.py` +- `tests/unit/test_error_classifier.py` +- `tests/unit/test_pipeline_service.py` + +**Изменения:** + +1. Убрать default `provider="groq"`; provider обязан прийти от адаптера, fallback — `unknown`. +2. Переименовать Groq-only комментарии/тесты в provider-neutral. +3. Добавить Deepgram URL/status fixtures для 429/503 и provider config/input cases. +4. Заменить «получен из Whisper STT» в промпте на «получен системой распознавания речи»; + оставить инструкцию исправлять ASR-артефакты. +5. Проверить инкрементальный `usage` для `provider=deepgram`. + +Публичная schema `TranscribeUsage` уже содержит provider/model и не требует миграции/OpenAPI diff. + +### Шаг 6. Env, deploy и README + +**Файлы:** + +- `.env.example` +- `deploy/env.core.example` +- `README.md` +- при необходимости `docs/api-contract.md` только если там обнаружится Groq-only утверждение + +**Изменения:** + +1. Документировать переключатель и обе группы ключей. +2. Объяснить `ru` vs `multi`, стоимость, обязательный MIP opt-out и отсутствие add-on'ов + по умолчанию. +3. Обновить секцию запуска: обязательным является ключ выбранного STT provider. +4. Сохранить Groq pool документацию как отдельный provider-specific раздел. +5. Добавить Deepgram limits/retry/rollback и ссылку на текущий pricing. +6. Не помещать реальный тестовый ключ ни в пример, ни в историю Git. + +Compose-файлы используют `env_file`, поэтому явного проброса новых переменных в services не нужно; +это подтвердить через `docker compose config` с placeholder env. + +### Шаг 7. Автоматическая проверка + +Команды из корня worktree. На текущем хосте `/snap/bin/uv` падает при создании transient +scope через DBus, поэтому использовать уже существующий Python 3.12 venv репозитория: + +```bash +/root/lecturelog-core/.venv/bin/python -m pytest \ + tests/unit/test_transcribe_common.py \ + tests/unit/test_groq_transcriber.py \ + tests/unit/test_deepgram_transcriber.py \ + tests/unit/test_config.py \ + tests/unit/test_factories.py \ + tests/unit/test_usage_accumulator.py \ + tests/unit/test_error_classifier.py -q + +/root/lecturelog-core/.venv/bin/python -m pytest tests/unit -q +/root/lecturelog-core/.venv/bin/python -m pytest tests/integration -q +/root/lecturelog-core/.venv/bin/ruff check . +git diff --check +``` + +Если OpenAPI не меняется, `scripts/export_openapi.py` не должен давать diff. Если даёт — остановиться +и выяснить непреднамеренное изменение контракта, а не обновлять snapshot автоматически. + +## 9. A/B-бенч перед включением + +### 9.1. Набор + +Использовать только материал, разрешённый для отправки внешнему STT-провайдеру: + +1. 10–15 минут чистой русской лекции; +2. 10–15 минут русской технической лекции с английскими терминами/названиями; +3. 10–15 минут шумной записи или диалога с аудиторией; +4. одна полная 60–90-минутная лекция для end-to-end проверки. + +Секретные/чувствительные записи нельзя отправлять только потому, что они найдены на диске; +для них нужно отдельное подтверждение допустимости внешней обработки. + +### 9.2. Варианты + +На одинаковом аудио сравнить: + +- текущий Groq Whisper; +- Deepgram Nova-3 `language=ru`; +- Deepgram Nova-3 `language=multi` на техническом отрывке. + +### 9.3. Метрики + +- WER/CER на вручную проверенных 3–5 мин каждого отрывка; +- recall важных русских и английских терминов; +- пунктуация и читаемость; +- доля пустых/явно галлюцинированных сегментов; +- корректность и монотонность SRT, последний end <= duration; +- наличие `mip_opt_out=true` в Deepgram request log/console; +- wall-clock latency; +- фактический billed duration/cost в Deepgram console; +- end-to-end: задача `done`, структуризация не теряет текст, кадры/нарезка совпадают по времени; +- `usage.transcribe.provider/model/audio_seconds` в статусе задачи. + +### 9.4. Критерий выбора языка + +- оставить `ru`, если он сохраняет значимые английские термины и не хуже `multi` на русском; +- выбрать `multi`, если он заметно улучшает code-switching/термины без существенной деградации + русской части; +- решение и примеры ошибок записать в progress/report, а не принимать по одному smoke sample. + +## 10. Rollout и rollback + +1. Merge кода с default `TRANSCRIBE_PROVIDER=groq`. +2. Добавить новый production-grade Deepgram key в секретный env, не меняя provider. +3. На dev/canary переключить `TRANSCRIBE_PROVIDER=deepgram` и выбранный язык. +4. Прогнать набор из раздела 9 и 5–10 обычных задач. +5. Мониторить HTTP 4xx/429/5xx, latency, пустые SRT, timestamp clamp warnings и расход кредита. +6. После успешного canary изменить только env основного инстанса. +7. Rollback: вернуть `TRANSCRIBE_PROVIDER=groq` и перезапустить API; БД, S3 и API не меняются. + +Уже начатая задача при рестарте, как и сейчас, станет `interrupted`; бесшовного переключения +провайдера посреди задачи не предполагается. + +## 11. Безопасность ключа + +- Тестовый ключ был передан в чате и использован для smoke-тестов; считать его временным. +- После завершения экспериментов отозвать/ротировать его в Deepgram console. +- Для dev/prod создать отдельные ключи с понятными именами и минимально нужными правами. +- Хранить ключ только в серверном `.env`/secret manager; не в Git, plan, CI artifacts или логах. +- Не логировать request headers, полный config dump или vendor response целиком. +- При ошибке сохранять только status, `err_code`, `request_id` и безопасное короткое сообщение. + +## 12. Definition of Done + +- Groq остаётся рабочим и default после merge. +- Deepgram Nova-3 выбирается одним env-переключателем и не требует Groq key. +- Каждый Deepgram-запрос использует `mip_opt_out=true`; ключ и аудио не попадают в логи. +- Реальный binary-upload возвращает SRT, совместимый с текущими structurize/frames/API путями. +- Таймкоды валидны, монотонны и не выходят за duration. +- Retry/progress/usage работают и покрыты детерминированными unit tests без сети. +- Внешний API и БД не меняются; OpenAPI snapshot не имеет непреднамеренного diff. +- README и deploy env описывают provider, язык, free-credit экономику и rollback. +- Проведён A/B на разрешённых реальных материалах, отдельно принято решение `ru` или `multi`. +- Dev-canary завершает полную аудио- и видео-задачу. +- Тестовый ключ ротирован до production rollout. From 4129132fb4786ccf80c0f3ebb62fb27d49f88819 Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Fri, 24 Jul 2026 13:20:12 +0000 Subject: [PATCH 2/4] feat: add Deepgram Nova-3 transcription provider --- .env.example | 6 + README.md | 33 ++- deploy/env.core.example | 8 +- lecturelog/api/lifespan.py | 18 +- lecturelog/application/factories.py | 25 +- lecturelog/application/usage_accumulator.py | 2 +- lecturelog/config/settings.py | 55 +++- .../infrastructure/transcribe/common.py | 106 +++++++ .../transcribe/deepgram_transcriber.py | 264 ++++++++++++++++++ .../transcribe/groq_transcriber.py | 66 +---- prompts/section_v1.md | 6 +- tests/unit/test_config.py | 47 +++- tests/unit/test_deepgram_transcriber.py | 161 +++++++++++ tests/unit/test_factories.py | 19 +- 14 files changed, 734 insertions(+), 82 deletions(-) create mode 100644 lecturelog/infrastructure/transcribe/common.py create mode 100644 lecturelog/infrastructure/transcribe/deepgram_transcriber.py create mode 100644 tests/unit/test_deepgram_transcriber.py diff --git a/.env.example b/.env.example index bb0af75..12103c8 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,11 @@ # Ключи внешних сервисов +TRANSCRIBE_PROVIDER=groq GROQ_API_KEYS= +DEEPGRAM_API_KEY= +DEEPGRAM_BASE_URL=https://api.deepgram.com +DEEPGRAM_MODEL=nova-3 +DEEPGRAM_LANGUAGE=ru +DEEPGRAM_UTT_SPLIT=0.8 OPENROUTER_API_KEY= # OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 diff --git a/README.md b/README.md index 566f043..3516e2e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ HTTP-сервис обработки лекций: на вход — лекци 1. **Приём медиа**: аудиофайл, видеофайл или URL видео (скачивается через yt-dlp); для видео извлекается аудиодорожка для транскрибации. -2. **Транскрибация** аудио в SRT через Groq Whisper (нарезка на чанки, ротация ключей при rate limit). +2. **Транскрибация** аудио в SRT через Groq Whisper или Deepgram Nova-3. 3. **Слайды** — три источника: из приложенного PDF/PPTX (pymupdf + LibreOffice), автоматически из видеоряда через Gemini Vision, либо без слайдов (флаг `no_slides`). 4. **Структуризация** транскрипта на темы и подтемы через Gemini, с привязкой слайдов. @@ -85,7 +85,8 @@ Obsidian (Settings → Community plugins), иначе вместо плеера ```bash cp .env.example .env -# Заполните GROQ_API_KEYS, OPENROUTER_API_KEY, CORE_POSTGRES_PASSWORD, +# Выберите TRANSCRIBE_PROVIDER, заполните ключ выбранного STT-провайдера, +# OPENROUTER_API_KEY, CORE_POSTGRES_PASSWORD, # S3_ACCESS_KEY и S3_SECRET_KEY. docker compose up --build ``` @@ -141,7 +142,8 @@ chmod +x minio-init.sh docker network create lecturelog-shared || true ``` -Отредактируйте `.env`: задайте `GROQ_API_KEYS`, `OPENROUTER_API_KEY`, +Отредактируйте `.env`: выберите `TRANSCRIBE_PROVIDER`, задайте ключ выбранного +STT-провайдера, `OPENROUTER_API_KEY`, `CORE_POSTGRES_PASSWORD`, `S3_SECRET_KEY`, публичный `S3_PUBLIC_ENDPOINT` и общий с web `LECTURELOG_WEBHOOK_SECRET`. Для связки с web укажите: @@ -327,7 +329,13 @@ python scripts/submit_task.py --base http://my-host:8000/api/v1 status | Переменная | Назначение | | ---------------------- | --------------------------------------------------------- | -| `GROQ_API_KEYS` | Ключи Groq (через запятую), для транскрибации (см. раздел про лимиты бесплатных тиров). | +| `TRANSCRIBE_PROVIDER` | STT-провайдер: `groq` (по умолчанию) или `deepgram`. | +| `GROQ_API_KEYS` | Ключи Groq через запятую; обязательны только для провайдера `groq`. | +| `DEEPGRAM_API_KEY` | Ключ Deepgram; обязателен только для провайдера `deepgram`. | +| `DEEPGRAM_BASE_URL` | Официальный HTTPS endpoint Deepgram. | +| `DEEPGRAM_MODEL` | Модель Deepgram (по умолчанию `nova-3`). | +| `DEEPGRAM_LANGUAGE` | Язык Deepgram (по умолчанию `ru`). | +| `DEEPGRAM_UTT_SPLIT` | Порог паузы utterance в секундах (по умолчанию `0.8`). | | `OPENROUTER_API_KEY` | Ключ OpenRouter; LLM-вызовы идут через BYOK Google AI Studio. | | `OPENROUTER_BASE_URL` | Base URL OpenRouter (по умолчанию `https://openrouter.ai/api/v1`). | | `LLM_MODELS_*` | Приоритетные списки моделей по этапам структуризации (fallback при 429). | @@ -352,14 +360,15 @@ python scripts/submit_task.py --base http://my-host:8000/api/v1 status ## Ключи API и лимиты бесплатных тиров -Сервис рассчитан на работу на **бесплатных тарифах** Groq и Google AI Studio через -OpenRouter BYOK. Для Groq можно указывать несколько ключей через запятую в +Сервис поддерживает Groq и Deepgram для STT, а LLM вызывает через OpenRouter BYOK. +Для Groq можно указывать несколько ключей через запятую в `GROQ_API_KEYS`; LLM-вызовы используют один `OPENROUTER_API_KEY`, а fallback идёт по приоритетному списку моделей. Получить бесплатные ключи: - Groq — [console.groq.com/keys](https://console.groq.com/keys) +- Deepgram — [console.deepgram.com](https://console.deepgram.com/) - Google AI Studio key для OpenRouter BYOK — [aistudio.google.com/apikey](https://aistudio.google.com/apikey) ### Groq (транскрибация, Whisper large-v3) @@ -371,6 +380,16 @@ OpenRouter BYOK. Для Groq можно указывать несколько к освобождения ближайшего ключа. - Чем больше ключей — тем выше суммарная пропускная способность транскрибации. +### Deepgram (транскрибация, Nova-3) + +- Задайте `TRANSCRIBE_PROVIDER=deepgram` и `DEEPGRAM_API_KEY`. +- Файл отправляется одним потоковым запросом, без полной загрузки в память. +- Каждый запрос содержит `mip_opt_out=true`; автоматического fallback на Groq нет. +- Разрешены только официальные HTTPS endpoint'ы Deepgram. Дефолты: модель `nova-3`, + язык `ru`, `utt_split=0.8`. +- Временные сетевые и серверные ошибки повторяются с backoff; неподдерживаемое или + повреждённое аудио классифицируется как `bad_input`. + ### LLM через OpenRouter BYOK (структуризация и VLM) OpenRouter вызывается в режиме BYOK с провайдером `google-ai-studio`, без fallback на @@ -399,7 +418,7 @@ pytest ``` Юнит-тесты гоняют репозиторий на SQLite in-memory, а инфраструктурные зависимости -(Groq/Gemini/ffmpeg) мокаются — реальные ключи и внешние сервисы для тестов не нужны. +(Groq/Deepgram/LLM/ffmpeg) мокаются — реальные ключи и внешние сервисы для тестов не нужны. ## Линтер и форматтер diff --git a/deploy/env.core.example b/deploy/env.core.example index 23b44ff..76a6190 100644 --- a/deploy/env.core.example +++ b/deploy/env.core.example @@ -9,8 +9,14 @@ CORE_MINIO_CONSOLE_PORT=9001 # Пароль Postgres ядра. Заполните на сервере: openssl rand -hex 24 CORE_POSTGRES_PASSWORD= -# Ключи внешних сервисов. +# Ключи внешних сервисов. Для STT обязателен только ключ выбранного провайдера. +TRANSCRIBE_PROVIDER=groq GROQ_API_KEYS= +DEEPGRAM_API_KEY= +DEEPGRAM_BASE_URL=https://api.deepgram.com +DEEPGRAM_MODEL=nova-3 +DEEPGRAM_LANGUAGE=ru +DEEPGRAM_UTT_SPLIT=0.8 OPENROUTER_API_KEY= # OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 diff --git a/lecturelog/api/lifespan.py b/lecturelog/api/lifespan.py index f0715b8..ae2ce6b 100644 --- a/lecturelog/api/lifespan.py +++ b/lecturelog/api/lifespan.py @@ -8,7 +8,11 @@ from fastapi import FastAPI from openai import AsyncOpenAI -from lecturelog.application.factories import storage_factory, webhook_notifier_factory +from lecturelog.application.factories import ( + storage_factory, + transcriber_factory, + webhook_notifier_factory, +) from lecturelog.application.pipeline_service import PipelineService from lecturelog.application.progress_plan import ProgressPlan from lecturelog.application.worker import PipelineWorker @@ -23,7 +27,6 @@ from lecturelog.infrastructure.persistence.engine import make_engine, make_session_factory from lecturelog.infrastructure.persistence.task_repository import PostgresTaskRepository from lecturelog.infrastructure.structurize.gemini_structurizer import GeminiStructurizer -from lecturelog.infrastructure.transcribe.groq_transcriber import GroqTranscriber logger = logging.getLogger(__name__) @@ -49,7 +52,16 @@ async def lifespan(app: FastAPI): cooldown = ModelCooldown() llm = LlmClient(openai_client, cooldown) - transcriber = GroqTranscriber(groq_api_keys=cfg.groq.keys) + transcriber = transcriber_factory(cfg.transcribe) + transcribe_model = ( + "whisper-large-v3" if cfg.transcribe.provider == "groq" else cfg.transcribe.deepgram_model + ) + logger.info( + "STT включён: provider=%s model=%s language=%s", + cfg.transcribe.provider, + transcribe_model, + cfg.transcribe.deepgram_language if cfg.transcribe.provider == "deepgram" else "auto", + ) structurizer = GeminiStructurizer( gemini_client=llm, split_models=cfg.llm.split_models, diff --git a/lecturelog/application/factories.py b/lecturelog/application/factories.py index b6b10bb..a82130a 100644 --- a/lecturelog/application/factories.py +++ b/lecturelog/application/factories.py @@ -1,9 +1,17 @@ from __future__ import annotations -from lecturelog.config.settings import S3Config +from lecturelog.config.settings import S3Config, TranscribeConfig from lecturelog.domain.media_source import MediaSource, is_video_source -from lecturelog.domain.ports import MediaCutter, SlideProvider, Storage, WebhookNotifier +from lecturelog.domain.ports import ( + MediaCutter, + SlideProvider, + Storage, + Transcriber, + WebhookNotifier, +) from lecturelog.infrastructure.storage.s3_storage import S3Storage +from lecturelog.infrastructure.transcribe.deepgram_transcriber import DeepgramTranscriber +from lecturelog.infrastructure.transcribe.groq_transcriber import GroqTranscriber from lecturelog.infrastructure.webhook.http_notifier import HttpWebhookNotifier @@ -47,6 +55,19 @@ def storage_factory(s3: S3Config) -> Storage: ) +def transcriber_factory(config: TranscribeConfig) -> Transcriber: + if config.provider == "groq": + return GroqTranscriber(groq_api_keys=config.groq_keys) + assert config.deepgram_api_key is not None + return DeepgramTranscriber( + api_key=config.deepgram_api_key.get_secret_value(), + base_url=config.deepgram_base_url, + model=config.deepgram_model, + language=config.deepgram_language, + utt_split=config.deepgram_utt_split, + ) + + def webhook_notifier_factory( callback_url: str | None, secret: str | None ) -> WebhookNotifier | None: diff --git a/lecturelog/application/usage_accumulator.py b/lecturelog/application/usage_accumulator.py index cc320bf..8afb18c 100644 --- a/lecturelog/application/usage_accumulator.py +++ b/lecturelog/application/usage_accumulator.py @@ -20,7 +20,7 @@ def record_transcribe(self, payload: dict) -> None: """Зерно транскрибации: audio_seconds (из ffprobe), provider, model.""" self.usage["transcribe"] = { "audio_seconds": int(payload.get("audio_seconds", 0)), - "provider": payload.get("provider", "groq"), + "provider": payload.get("provider", "unknown"), "model": payload.get("model"), "raw": {}, } diff --git a/lecturelog/config/settings.py b/lecturelog/config/settings.py index c404ce7..d245b55 100644 --- a/lecturelog/config/settings.py +++ b/lecturelog/config/settings.py @@ -1,8 +1,10 @@ from __future__ import annotations from functools import cached_property, lru_cache +from typing import Literal +from urllib.parse import urlsplit -from pydantic import Field, computed_field +from pydantic import Field, SecretStr, computed_field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict _BASE = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") @@ -12,13 +14,46 @@ def _split_csv(raw: str) -> list[str]: return [item.strip() for item in raw.split(",") if item.strip()] -class GroqConfig(BaseSettings): +class TranscribeConfig(BaseSettings): model_config = _BASE - api_keys_raw: str = Field(alias="GROQ_API_KEYS") + provider: Literal["groq", "deepgram"] = Field("groq", alias="TRANSCRIBE_PROVIDER") + groq_api_keys_raw: str = Field("", alias="GROQ_API_KEYS") + deepgram_api_key: SecretStr | None = Field(None, alias="DEEPGRAM_API_KEY") + deepgram_base_url: str = Field("https://api.deepgram.com", alias="DEEPGRAM_BASE_URL") + deepgram_model: str = Field("nova-3", alias="DEEPGRAM_MODEL") + deepgram_language: str = Field("ru", alias="DEEPGRAM_LANGUAGE") + deepgram_utt_split: float = Field(0.8, alias="DEEPGRAM_UTT_SPLIT", gt=0) + + @model_validator(mode="after") + def validate_provider(self) -> TranscribeConfig: + parts = urlsplit(self.deepgram_base_url) + allowed_hosts = { + "api.deepgram.com", + "api.eu.deepgram.com", + "api.au.deepgram.com", + } + if ( + parts.scheme != "https" + or parts.hostname not in allowed_hosts + or parts.username is not None + or parts.password is not None + or parts.query + or parts.fragment + or parts.path not in ("", "/") + ): + raise ValueError("DEEPGRAM_BASE_URL должен быть официальным HTTPS endpoint Deepgram") + self.deepgram_base_url = self.deepgram_base_url.rstrip("/") + if self.provider == "groq" and not self.groq_keys: + raise ValueError("GROQ_API_KEYS обязателен при TRANSCRIBE_PROVIDER=groq") + if self.provider == "deepgram" and ( + self.deepgram_api_key is None or not self.deepgram_api_key.get_secret_value().strip() + ): + raise ValueError("DEEPGRAM_API_KEY обязателен при TRANSCRIBE_PROVIDER=deepgram") + return self @property - def keys(self) -> list[str]: - return _split_csv(self.api_keys_raw) + def groq_keys(self) -> list[str]: + return _split_csv(self.groq_api_keys_raw) class LlmConfig(BaseSettings): @@ -127,10 +162,10 @@ class AppConfig(BaseSettings): model_config = _BASE def model_post_init(self, __context: object) -> None: - # Форсируем создание под-конфигов сразу, чтобы required-поля - # (GROQ_API_KEYS и т.д.) валидировались в момент построения AppConfig. + # Форсируем создание под-конфигов сразу, чтобы ключ выбранного + # STT-провайдера и остальные required-поля проверялись при построении AppConfig. _ = ( - self.groq, + self.transcribe, self.llm, self.database, self.s3, @@ -141,8 +176,8 @@ def model_post_init(self, __context: object) -> None: @computed_field # type: ignore[prop-decorator] @cached_property - def groq(self) -> GroqConfig: - return GroqConfig() + def transcribe(self) -> TranscribeConfig: + return TranscribeConfig() @computed_field # type: ignore[prop-decorator] @cached_property diff --git a/lecturelog/infrastructure/transcribe/common.py b/lecturelog/infrastructure/transcribe/common.py new file mode 100644 index 0000000..b04c6ad --- /dev/null +++ b/lecturelog/infrastructure/transcribe/common.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import asyncio +import inspect +import logging +import math +from dataclasses import dataclass +from pathlib import Path + +from lecturelog.domain.ports import ProgressCallback, UsageCallback + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class Caption: + start: float + end: float + text: str + + +async def emit_progress(callback: ProgressCallback | None, value: int) -> None: + if callback is not None: + result = callback(value) + if inspect.isawaitable(result): + await result + + +async def emit_usage(callback: UsageCallback | None, payload: dict) -> None: + if callback is not None: + result = callback(payload) + if inspect.isawaitable(result): + await result + + +async def probe_audio_seconds(audio_path: Path) -> float: + """Best-effort ffprobe duration; zero means unknown.""" + try: + proc = await asyncio.create_subprocess_exec( + "ffprobe", + "-v", + "quiet", + "-show_entries", + "format=duration", + "-of", + "default=noprint_wrappers=1:nokey=1", + str(audio_path), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + out, _ = await proc.communicate() + if proc.returncode != 0: + logger.warning("ffprobe завершился с кодом %s", proc.returncode) + return 0.0 + value = float(out.decode().strip()) + return value if math.isfinite(value) and value > 0 else 0.0 + except (OSError, ValueError) as exc: + logger.warning("Не удалось определить длительность аудио через ffprobe: %s", exc) + return 0.0 + + +def format_srt_timestamp(seconds: float) -> str: + total_ms = max(0, int(round(seconds * 1000))) + hours = total_ms // 3_600_000 + minutes = (total_ms % 3_600_000) // 60_000 + secs = (total_ms % 60_000) // 1000 + millis = total_ms % 1000 + return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}" + + +def normalize_captions(captions: list[Caption], *, duration: float | None = None) -> list[Caption]: + limit = duration if duration and math.isfinite(duration) and duration > 0 else None + normalized: list[Caption] = [] + cursor = 0.0 + for caption in sorted(captions, key=lambda item: item.start): + timestamps_are_finite = all(math.isfinite(value) for value in (caption.start, caption.end)) + if not caption.text.strip() or not timestamps_are_finite: + continue + start = max(cursor, caption.start, 0.0) + end = max(start, caption.end) + if limit is not None: + start = min(start, limit) + end = min(end, limit) + if end <= start: + continue + normalized.append(Caption(start, end, caption.text.strip())) + cursor = end + return normalized + + +def render_srt(captions: list[Caption]) -> str: + blocks = [ + f"{index}\n{format_srt_timestamp(item.start)} --> " + f"{format_srt_timestamp(item.end)}\n{item.text}" + for index, item in enumerate(captions, 1) + ] + return "\n\n".join(blocks) + + +def write_srt_atomic(output_dir: Path, content: str) -> Path: + output_dir.mkdir(parents=True, exist_ok=True) + target = output_dir / "transcript.srt" + temporary = output_dir / ".transcript.srt.tmp" + temporary.write_text(content, encoding="utf-8") + temporary.replace(target) + return target diff --git a/lecturelog/infrastructure/transcribe/deepgram_transcriber.py b/lecturelog/infrastructure/transcribe/deepgram_transcriber.py new file mode 100644 index 0000000..d9c2fbf --- /dev/null +++ b/lecturelog/infrastructure/transcribe/deepgram_transcriber.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import asyncio +import logging +import math +import mimetypes +import random +from collections.abc import AsyncIterator, Awaitable, Callable +from pathlib import Path +from typing import Any + +import httpx + +from lecturelog.domain.ports import ProgressCallback, Transcriber, UsageCallback +from lecturelog.infrastructure.transcribe.common import ( + Caption, + emit_progress, + emit_usage, + normalize_captions, + probe_audio_seconds, + render_srt, + write_srt_atomic, +) + +logger = logging.getLogger(__name__) + +MAX_AUDIO_BYTES = 2 * 1024 * 1024 * 1024 +UPLOAD_CHUNK_BYTES = 256 * 1024 +RETRYABLE_STATUSES = {408, 429, 500, 502, 503} +BAD_INPUT_STATUSES = {413, 415, 422} +BAD_INPUT_CODES = {"ASR_UNPROCESSABLE", "INVALID_AUDIO", "CORRUPT_AUDIO"} + + +def _positive_float(value: Any) -> float | None: + try: + result = float(value) + except (TypeError, ValueError): + return None + return result if math.isfinite(result) and result > 0 else None + + +def _word_text(word: dict[str, Any]) -> str: + return str(word.get("punctuated_word") or word.get("word") or "").strip() + + +def _caption_from_words(words: list[dict[str, Any]]) -> Caption | None: + valid = [ + word + for word in words + if _word_text(word) + and _positive_float(word.get("end")) is not None + and word.get("start") is not None + ] + if not valid: + return None + try: + start = float(valid[0]["start"]) + end = float(valid[-1]["end"]) + except (TypeError, ValueError): + return None + return Caption(start=start, end=end, text=" ".join(_word_text(word) for word in valid)) + + +def _split_words(words: list[dict[str, Any]], words_per_caption: int = 12) -> list[Caption]: + captions: list[Caption] = [] + for index in range(0, len(words), words_per_caption): + caption = _caption_from_words(words[index : index + words_per_caption]) + if caption is not None: + captions.append(caption) + return captions + + +def build_captions( + payload: dict[str, Any], fallback_duration: float +) -> tuple[list[Caption], float]: + results = payload.get("results") + metadata = payload.get("metadata") + if not isinstance(results, dict) or not isinstance(metadata, dict): + raise RuntimeError("Deepgram вернул ответ без results/metadata") + duration = _positive_float(metadata.get("duration")) or fallback_duration + utterances = results.get("utterances") + captions: list[Caption] = [] + if isinstance(utterances, list) and utterances: + for utterance in utterances: + if not isinstance(utterance, dict): + continue + words = utterance.get("words") if isinstance(utterance.get("words"), list) else [] + start = _positive_float(utterance.get("start")) + raw_start = 0.0 if utterance.get("start") == 0 else start + end = _positive_float(utterance.get("end")) + transcript = str(utterance.get("transcript") or "").strip() + long_utterance = len(words) > 12 or ( + raw_start is not None and end is not None and end - raw_start > 8 + ) + if long_utterance and words: + captions.extend(_split_words(words)) + elif transcript and raw_start is not None and end is not None: + captions.append(Caption(raw_start, end, transcript)) + elif words: + caption = _caption_from_words(words) + if caption is not None: + captions.append(caption) + if not captions: + channels = results.get("channels") + if isinstance(channels, list) and channels: + alternatives = channels[0].get("alternatives", []) + if alternatives and isinstance(alternatives[0], dict): + words = alternatives[0].get("words") + if isinstance(words, list): + captions = _split_words(words) + return normalize_captions(captions, duration=duration or None), duration + + +class _UploadProgress: + def __init__(self, size: int, callback: ProgressCallback | None) -> None: + self.size = size + self.callback = callback + self.highest = 0 + + async def update(self, sent: int) -> None: + if self.size <= 0: + return + percent = int(sent * 100 / self.size) + for threshold in range(10, 71, 10): + if percent >= threshold > self.highest: + self.highest = threshold + await emit_progress(self.callback, threshold) + + +class DeepgramTranscriber(Transcriber): + def __init__( + self, + *, + api_key: str, + base_url: str = "https://api.deepgram.com", + model: str = "nova-3", + language: str = "ru", + utt_split: float = 0.8, + transport: httpx.AsyncBaseTransport | None = None, + sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, + ) -> None: + self._api_key = api_key + self._base_url = base_url.rstrip("/") + self._model = model + self._language = language + self._utt_split = utt_split + self._transport = transport + self._sleep = sleep + + async def _stream(self, path: Path, tracker: _UploadProgress) -> AsyncIterator[bytes]: + with path.open("rb") as source: + sent = 0 + while chunk := await asyncio.to_thread(source.read, UPLOAD_CHUNK_BYTES): + sent += len(chunk) + await tracker.update(sent) + yield chunk + + async def _request( + self, client: httpx.AsyncClient, audio_path: Path, tracker: _UploadProgress + ) -> httpx.Response: + params = { + "model": self._model, + "language": self._language, + "smart_format": "true", + "utterances": "true", + "utt_split": str(self._utt_split), + "mip_opt_out": "true", + } + content_type = mimetypes.guess_type(audio_path.name)[0] or "application/octet-stream" + headers = { + "Authorization": f"Token {self._api_key}", + "Content-Type": content_type, + "Content-Length": str(audio_path.stat().st_size), + } + last_response: httpx.Response | None = None + for attempt in range(5): + try: + response = await client.post( + "/v1/listen", + params=params, + headers=headers, + content=self._stream(audio_path, tracker), + ) + last_response = response + if response.status_code == 504 and attempt < 1: + await self._sleep(1.0) + continue + if response.status_code in RETRYABLE_STATUSES and attempt < 4: + await self._sleep((2**attempt) + random.random()) + continue + response.raise_for_status() + return response + except (httpx.TimeoutException, httpx.NetworkError): + if attempt >= 4: + raise + await self._sleep((2**attempt) + random.random()) + assert last_response is not None + last_response.raise_for_status() + return last_response + + async def transcribe( + self, + audio_path: Path, + output_dir: Path, + on_progress: ProgressCallback | None = None, + on_usage: UsageCallback | None = None, + ) -> Path: + if not audio_path.is_file(): + raise FileNotFoundError(audio_path) + size = audio_path.stat().st_size + if size > MAX_AUDIO_BYTES: + raise ValueError("Deepgram принимает файлы размером не более 2 ГБ") + await emit_progress(on_progress, 5) + probed_duration = await probe_audio_seconds(audio_path) + usage = { + "audio_seconds": int(probed_duration), + "provider": "deepgram", + "model": self._model, + } + await emit_usage(on_usage, usage) + tracker = _UploadProgress(size, on_progress) + timeout = httpx.Timeout(connect=30, write=300, read=660, pool=30) + async with httpx.AsyncClient( + base_url=self._base_url, + timeout=timeout, + transport=self._transport, + follow_redirects=False, + ) as client: + try: + response = await self._request(client, audio_path, tracker) + except httpx.HTTPStatusError as exc: + code = "" + try: + body = exc.response.json() + code = str(body.get("err_code") or body.get("code") or "") + except (ValueError, AttributeError): + pass + if exc.response.status_code in BAD_INPUT_STATUSES or code in BAD_INPUT_CODES: + raise ValueError("Deepgram не смог обработать входное аудио") from exc + if exc.response.status_code in {400, 401, 403}: + raise RuntimeError( + f"Deepgram отклонил запрос (status={exc.response.status_code})" + ) from exc + raise + try: + payload = response.json() + except ValueError as exc: + raise RuntimeError("Deepgram вернул некорректный JSON") from exc + if not isinstance(payload, dict): + raise RuntimeError("Deepgram вернул некорректный JSON") + captions, effective_duration = build_captions(payload, probed_duration) + await emit_progress(on_progress, 90) + if effective_duration > 0: + await emit_usage( + on_usage, + { + "audio_seconds": int(effective_duration), + "provider": "deepgram", + "model": self._model, + }, + ) + srt_path = write_srt_atomic(output_dir, render_srt(captions)) + await emit_progress(on_progress, 100) + return srt_path diff --git a/lecturelog/infrastructure/transcribe/groq_transcriber.py b/lecturelog/infrastructure/transcribe/groq_transcriber.py index 7c89dc1..523dd81 100644 --- a/lecturelog/infrastructure/transcribe/groq_transcriber.py +++ b/lecturelog/infrastructure/transcribe/groq_transcriber.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -import inspect import logging import time from pathlib import Path @@ -10,6 +9,18 @@ import httpx from lecturelog.domain.ports import ProgressCallback, Transcriber, UsageCallback +from lecturelog.infrastructure.transcribe.common import ( + emit_progress as _emit_progress, +) +from lecturelog.infrastructure.transcribe.common import ( + emit_usage as _emit_usage, +) +from lecturelog.infrastructure.transcribe.common import ( + format_srt_timestamp as _format_srt_timestamp, +) +from lecturelog.infrastructure.transcribe.common import ( + probe_audio_seconds, +) logger = logging.getLogger(__name__) @@ -56,15 +67,6 @@ def key_index(self, key: str) -> int: return self._keys.index(key) -def _format_srt_timestamp(seconds: float) -> str: - total_ms = max(0, int(round(seconds * 1000))) - hours = total_ms // 3_600_000 - minutes = (total_ms % 3_600_000) // 60_000 - secs = (total_ms % 60_000) // 1000 - millis = total_ms % 1000 - return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}" - - def _build_srt_from_words(words: list[dict[str, Any]], words_per_caption: int = 7) -> str: if not words: return "" @@ -89,22 +91,6 @@ def _build_srt_from_words(words: list[dict[str, Any]], words_per_caption: int = return "\n".join(lines).strip() -async def _emit_progress(on_progress: ProgressCallback | None, value: int) -> None: - if on_progress is None: - return - maybe_awaitable = on_progress(value) - if inspect.isawaitable(maybe_awaitable): - await maybe_awaitable - - -async def _emit_usage(on_usage: UsageCallback | None, payload: dict) -> None: - if on_usage is None: - return - maybe_awaitable = on_usage(payload) - if inspect.isawaitable(maybe_awaitable): - await maybe_awaitable - - async def _probe_audio_seconds(audio_path: Path) -> int: """Длительность аудио через ffprobe (паттерн из VideoSlideProvider). @@ -112,33 +98,7 @@ async def _probe_audio_seconds(audio_path: Path) -> int: ронять основную транскрибацию. При любом сбое (ffprobe отсутствует, ненулевой returncode, нечисловой вывод) возвращаем 0 и продолжаем работу. """ - try: - proc = await asyncio.create_subprocess_exec( - "ffprobe", - "-v", - "quiet", - "-show_entries", - "format=duration", - "-of", - "default=noprint_wrappers=1:nokey=1", - str(audio_path), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - ) - out, _ = await proc.communicate() - if proc.returncode != 0: - # ffprobe завершился с ошибкой — длительность считаем неизвестной (0) - logger.warning( - "ffprobe завершился с кодом %s, длительность аудио считаем равной 0", - proc.returncode, - ) - return 0 - return int(float(out.decode().strip())) - except (OSError, ValueError) as exc: - # OSError покрывает отсутствие ffprobe (FileNotFoundError), - # ValueError — нечисловой/пустой вывод. Usage best-effort: не падаем. - logger.warning("Не удалось определить длительность аудио через ffprobe: %s", exc) - return 0 + return int(await probe_audio_seconds(audio_path)) def _retry_delay(attempt: int) -> int: diff --git a/prompts/section_v1.md b/prompts/section_v1.md index 36b5ab5..2b1065b 100644 --- a/prompts/section_v1.md +++ b/prompts/section_v1.md @@ -48,12 +48,13 @@ ## Исправление ошибок распознавания речи -Транскрипт получен из Whisper STT и содержит ошибки. Ты ДОЛЖЕН исправлять +Транскрипт получен системой автоматического распознавания речи (ASR) и содержит ошибки. +Ты ДОЛЖЕН исправлять очевидные артефакты распознавания, опираясь на контекст лекции: ### Типы ошибок: -1. **Слова не по контексту** — Whisper подставляет фонетически похожее, +1. **Слова не по контексту** — ASR подставляет фонетически похожее, но бессмысленное слово. Пример: "программного изучения" → "программного обеспечения", "право на испечение" → "программное обеспечение". Восстанови правильное слово по смыслу предложения. @@ -80,4 +81,3 @@ они будут добавлены автоматически). Начинай сразу с содержания. ## Фрагмент транскрипта (SRT): - diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index e40f998..60dfcb2 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -29,7 +29,52 @@ def test_groq_keys_parsed_and_trimmed(monkeypatch): for k, v in _env().items(): monkeypatch.setenv(k, v) cfg = AppConfig() - assert cfg.groq.keys == ["g1", "g2"] + assert cfg.transcribe.groq_keys == ["g1", "g2"] + + +def test_deepgram_provider_needs_only_deepgram_key(monkeypatch): + for k, v in _env( + TRANSCRIBE_PROVIDER="deepgram", + GROQ_API_KEYS="", + DEEPGRAM_API_KEY="dg-secret", + ).items(): + monkeypatch.setenv(k, v) + cfg = AppConfig() + assert cfg.transcribe.provider == "deepgram" + assert cfg.transcribe.deepgram_model == "nova-3" + assert cfg.transcribe.deepgram_api_key.get_secret_value() == "dg-secret" + assert "dg-secret" not in repr(cfg.transcribe) + + +def test_deepgram_provider_rejects_empty_key(monkeypatch): + for k, v in _env( + TRANSCRIBE_PROVIDER="deepgram", + GROQ_API_KEYS="", + DEEPGRAM_API_KEY="", + ).items(): + monkeypatch.setenv(k, v) + with pytest.raises(Exception, match="DEEPGRAM_API_KEY"): # noqa: B017 + AppConfig() + + +@pytest.mark.parametrize( + "url", + [ + "http://api.deepgram.com", + "https://evil.example", + "https://user@api.deepgram.com", + "https://api.deepgram.com?x=1", + ], +) +def test_deepgram_base_url_rejects_unsafe_endpoints(monkeypatch, url): + for k, v in _env( + TRANSCRIBE_PROVIDER="deepgram", + DEEPGRAM_API_KEY="dg-secret", + DEEPGRAM_BASE_URL=url, + ).items(): + monkeypatch.setenv(k, v) + with pytest.raises(Exception, match="DEEPGRAM_BASE_URL"): # noqa: B017 + AppConfig() def test_llm_models_split_into_lists(monkeypatch): diff --git a/tests/unit/test_deepgram_transcriber.py b/tests/unit/test_deepgram_transcriber.py new file mode 100644 index 0000000..251a56a --- /dev/null +++ b/tests/unit/test_deepgram_transcriber.py @@ -0,0 +1,161 @@ +from __future__ import annotations + +from pathlib import Path + +import httpx +import pytest + +from lecturelog.infrastructure.transcribe import deepgram_transcriber as mod + + +def _payload() -> dict: + return { + "metadata": {"duration": 3.5, "request_id": "req"}, + "results": { + "utterances": [ + { + "start": 0, + "end": 3.5, + "transcript": "Привет, мир.", + "words": [ + { + "start": 0, + "end": 1, + "word": "привет", + "punctuated_word": "Привет,", + }, + {"start": 1, "end": 3.5, "word": "мир", "punctuated_word": "мир."}, + ], + } + ], + "channels": [{"alternatives": [{"transcript": "Привет, мир."}]}], + }, + } + + +async def test_streams_file_and_builds_srt(tmp_path, monkeypatch): + seen: dict = {} + + async def handler(request: httpx.Request) -> httpx.Response: + seen["query"] = dict(request.url.params) + seen["headers"] = request.headers + seen["body"] = await request.aread() + return httpx.Response(200, json=_payload()) + + async def fake_probe(path: Path) -> float: + return 4.0 + + monkeypatch.setattr(mod, "probe_audio_seconds", fake_probe) + audio = tmp_path / "lecture.mp3" + audio.write_bytes(b"audio-data") + progress: list[int] = [] + usage: list[dict] = [] + transcriber = mod.DeepgramTranscriber( + api_key="test-secret", + transport=httpx.MockTransport(handler), + ) + result = await transcriber.transcribe( + audio, + tmp_path / "out", + on_progress=progress.append, + on_usage=usage.append, + ) + assert seen["body"] == b"audio-data" + assert seen["headers"]["authorization"] == "Token test-secret" + assert seen["headers"]["content-length"] == str(len(b"audio-data")) + assert seen["query"]["model"] == "nova-3" + assert seen["query"]["mip_opt_out"] == "true" + assert seen["query"]["utterances"] == "true" + assert result.read_text() == "1\n00:00:00,000 --> 00:00:03,500\nПривет, мир." + assert progress == [5, 10, 20, 30, 40, 50, 60, 70, 90, 100] + assert [item["audio_seconds"] for item in usage] == [4, 3] + + +async def test_retries_503_with_repeatable_body(tmp_path, monkeypatch): + bodies: list[bytes] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + bodies.append(await request.aread()) + if len(bodies) == 1: + return httpx.Response(503, json={"err_code": "UNAVAILABLE"}) + return httpx.Response(200, json=_payload()) + + async def no_sleep(delay: float) -> None: + return None + + async def fake_probe(path: Path) -> float: + return 1.0 + + monkeypatch.setattr(mod, "probe_audio_seconds", fake_probe) + audio = tmp_path / "lecture.wav" + audio.write_bytes(b"repeat-me") + transcriber = mod.DeepgramTranscriber( + api_key="secret", + transport=httpx.MockTransport(handler), + sleep=no_sleep, + ) + await transcriber.transcribe(audio, tmp_path / "out") + assert bodies == [b"repeat-me", b"repeat-me"] + + +@pytest.mark.parametrize("status", [413, 415, 422]) +async def test_input_errors_become_value_error(tmp_path, monkeypatch, status): + async def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(status, json={"err_code": "ASR_UNPROCESSABLE"}) + + async def fake_probe(path: Path) -> float: + return 1.0 + + monkeypatch.setattr(mod, "probe_audio_seconds", fake_probe) + audio = tmp_path / "bad.mp3" + audio.write_bytes(b"bad") + transcriber = mod.DeepgramTranscriber( + api_key="secret", + transport=httpx.MockTransport(handler), + ) + with pytest.raises(ValueError, match="входное аудио"): + await transcriber.transcribe(audio, tmp_path / "out") + + +async def test_auth_error_is_sanitized_runtime_error(tmp_path, monkeypatch): + async def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(401, text="secret provider body") + + async def fake_probe(path: Path) -> float: + return 1.0 + + monkeypatch.setattr(mod, "probe_audio_seconds", fake_probe) + audio = tmp_path / "lecture.mp3" + audio.write_bytes(b"audio") + transcriber = mod.DeepgramTranscriber( + api_key="secret", + transport=httpx.MockTransport(handler), + ) + with pytest.raises(RuntimeError) as caught: + await transcriber.transcribe(audio, tmp_path / "out") + assert "secret" not in str(caught.value) + + +def test_long_utterance_splits_by_words_and_clamps_duration(): + words = [ + {"start": i, "end": i + 0.8, "word": f"w{i}", "punctuated_word": f"W{i}"} for i in range(13) + ] + payload = { + "metadata": {"duration": 10}, + "results": { + "utterances": [{"start": 0, "end": 13, "transcript": "ignored", "words": words}] + }, + } + captions, duration = mod.build_captions(payload, 20) + assert duration == 10 + assert len(captions) == 1 + assert captions[0].end == 10 + assert captions[0].text.startswith("W0") + + +def test_empty_transcript_produces_empty_srt(): + captions, _ = mod.build_captions( + {"metadata": {"duration": 1}, "results": {"channels": [{"alternatives": [{}]}]}}, + 1, + ) + assert captions == [] diff --git a/tests/unit/test_factories.py b/tests/unit/test_factories.py index 80345b6..37e311b 100644 --- a/tests/unit/test_factories.py +++ b/tests/unit/test_factories.py @@ -4,10 +4,13 @@ cutter_factory, slide_provider_factory, storage_factory, + transcriber_factory, ) -from lecturelog.config.settings import S3Config +from lecturelog.config.settings import S3Config, TranscribeConfig from lecturelog.domain.media_source import AudioSource, VideoFileSource from lecturelog.infrastructure.storage.s3_storage import S3Storage +from lecturelog.infrastructure.transcribe.deepgram_transcriber import DeepgramTranscriber +from lecturelog.infrastructure.transcribe.groq_transcriber import GroqTranscriber class _A: # маркеры, чтобы различать выбранную реализацию @@ -77,3 +80,17 @@ def test_storage_factory_no_public_keeps_presign_off(monkeypatch): cfg = _s3_config(monkeypatch, public=None) storage = storage_factory(cfg) assert storage._public_endpoint is None + + +def test_transcriber_factory_selects_groq(): + config = TranscribeConfig(_env_file=None, GROQ_API_KEYS="g1") + assert isinstance(transcriber_factory(config), GroqTranscriber) + + +def test_transcriber_factory_selects_deepgram(): + config = TranscribeConfig( + _env_file=None, + TRANSCRIBE_PROVIDER="deepgram", + DEEPGRAM_API_KEY="secret", + ) + assert isinstance(transcriber_factory(config), DeepgramTranscriber) From 36c95653db900f689fe2f84d7e12a0f8147eb03e Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Fri, 24 Jul 2026 13:31:18 +0000 Subject: [PATCH 3/4] feat: add Deepgram language detection --- .env.example | 1 + README.md | 4 +++ deploy/env.core.example | 1 + ...07-19-deepgram-nova3-transcription-plan.md | 1 + lecturelog/api/lifespan.py | 5 +++- lecturelog/application/factories.py | 1 + lecturelog/config/settings.py | 1 + .../transcribe/deepgram_transcriber.py | 7 ++++- tests/unit/test_config.py | 11 ++++++++ tests/unit/test_deepgram_transcriber.py | 27 +++++++++++++++++++ tests/unit/test_factories.py | 12 +++++++++ 11 files changed, 69 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 12103c8..066d222 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ DEEPGRAM_API_KEY= DEEPGRAM_BASE_URL=https://api.deepgram.com DEEPGRAM_MODEL=nova-3 DEEPGRAM_LANGUAGE=ru +DEEPGRAM_DETECT_LANGUAGE=false DEEPGRAM_UTT_SPLIT=0.8 OPENROUTER_API_KEY= # OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 diff --git a/README.md b/README.md index 3516e2e..7837f76 100644 --- a/README.md +++ b/README.md @@ -335,6 +335,7 @@ python scripts/submit_task.py --base http://my-host:8000/api/v1 status | `DEEPGRAM_BASE_URL` | Официальный HTTPS endpoint Deepgram. | | `DEEPGRAM_MODEL` | Модель Deepgram (по умолчанию `nova-3`). | | `DEEPGRAM_LANGUAGE` | Язык Deepgram (по умолчанию `ru`). | +| `DEEPGRAM_DETECT_LANGUAGE` | Автоопределение доминирующего языка (`true`/`false`). При `true` фиксированный `DEEPGRAM_LANGUAGE` не отправляется. | | `DEEPGRAM_UTT_SPLIT` | Порог паузы utterance в секундах (по умолчанию `0.8`). | | `OPENROUTER_API_KEY` | Ключ OpenRouter; LLM-вызовы идут через BYOK Google AI Studio. | | `OPENROUTER_BASE_URL` | Base URL OpenRouter (по умолчанию `https://openrouter.ai/api/v1`). | @@ -387,6 +388,9 @@ python scripts/submit_task.py --base http://my-host:8000/api/v1 status - Каждый запрос содержит `mip_opt_out=true`; автоматического fallback на Groq нет. - Разрешены только официальные HTTPS endpoint'ы Deepgram. Дефолты: модель `nova-3`, язык `ru`, `utt_split=0.8`. +- Для автоматического определения доминирующего языка задайте + `DEEPGRAM_DETECT_LANGUAGE=true`. Для смешанной речи с переключением языков + используйте `DEEPGRAM_LANGUAGE=multi` при выключенном автоопределении. - Временные сетевые и серверные ошибки повторяются с backoff; неподдерживаемое или повреждённое аудио классифицируется как `bad_input`. diff --git a/deploy/env.core.example b/deploy/env.core.example index 76a6190..79c0220 100644 --- a/deploy/env.core.example +++ b/deploy/env.core.example @@ -16,6 +16,7 @@ DEEPGRAM_API_KEY= DEEPGRAM_BASE_URL=https://api.deepgram.com DEEPGRAM_MODEL=nova-3 DEEPGRAM_LANGUAGE=ru +DEEPGRAM_DETECT_LANGUAGE=false DEEPGRAM_UTT_SPLIT=0.8 OPENROUTER_API_KEY= # OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 diff --git a/docs/plans/2026-07-19-deepgram-nova3-transcription-plan.md b/docs/plans/2026-07-19-deepgram-nova3-transcription-plan.md index dac2ff3..4418b60 100644 --- a/docs/plans/2026-07-19-deepgram-nova3-transcription-plan.md +++ b/docs/plans/2026-07-19-deepgram-nova3-transcription-plan.md @@ -214,6 +214,7 @@ DEEPGRAM_API_KEY= DEEPGRAM_BASE_URL=https://api.deepgram.com DEEPGRAM_MODEL=nova-3 DEEPGRAM_LANGUAGE=ru # ru | multi; технически допускается любой поддержанный код +DEEPGRAM_DETECT_LANGUAGE=false # true: определить доминирующий язык, language не отправлять DEEPGRAM_UTT_SPLIT=0.8 ``` diff --git a/lecturelog/api/lifespan.py b/lecturelog/api/lifespan.py index ae2ce6b..2ceb680 100644 --- a/lecturelog/api/lifespan.py +++ b/lecturelog/api/lifespan.py @@ -56,11 +56,14 @@ async def lifespan(app: FastAPI): transcribe_model = ( "whisper-large-v3" if cfg.transcribe.provider == "groq" else cfg.transcribe.deepgram_model ) + transcribe_language = "auto" + if cfg.transcribe.provider == "deepgram" and not cfg.transcribe.deepgram_detect_language: + transcribe_language = cfg.transcribe.deepgram_language logger.info( "STT включён: provider=%s model=%s language=%s", cfg.transcribe.provider, transcribe_model, - cfg.transcribe.deepgram_language if cfg.transcribe.provider == "deepgram" else "auto", + transcribe_language, ) structurizer = GeminiStructurizer( gemini_client=llm, diff --git a/lecturelog/application/factories.py b/lecturelog/application/factories.py index a82130a..7807b2b 100644 --- a/lecturelog/application/factories.py +++ b/lecturelog/application/factories.py @@ -64,6 +64,7 @@ def transcriber_factory(config: TranscribeConfig) -> Transcriber: base_url=config.deepgram_base_url, model=config.deepgram_model, language=config.deepgram_language, + detect_language=config.deepgram_detect_language, utt_split=config.deepgram_utt_split, ) diff --git a/lecturelog/config/settings.py b/lecturelog/config/settings.py index d245b55..69b6cbc 100644 --- a/lecturelog/config/settings.py +++ b/lecturelog/config/settings.py @@ -22,6 +22,7 @@ class TranscribeConfig(BaseSettings): deepgram_base_url: str = Field("https://api.deepgram.com", alias="DEEPGRAM_BASE_URL") deepgram_model: str = Field("nova-3", alias="DEEPGRAM_MODEL") deepgram_language: str = Field("ru", alias="DEEPGRAM_LANGUAGE") + deepgram_detect_language: bool = Field(False, alias="DEEPGRAM_DETECT_LANGUAGE") deepgram_utt_split: float = Field(0.8, alias="DEEPGRAM_UTT_SPLIT", gt=0) @model_validator(mode="after") diff --git a/lecturelog/infrastructure/transcribe/deepgram_transcriber.py b/lecturelog/infrastructure/transcribe/deepgram_transcriber.py index d9c2fbf..5ef02d8 100644 --- a/lecturelog/infrastructure/transcribe/deepgram_transcriber.py +++ b/lecturelog/infrastructure/transcribe/deepgram_transcriber.py @@ -135,6 +135,7 @@ def __init__( base_url: str = "https://api.deepgram.com", model: str = "nova-3", language: str = "ru", + detect_language: bool = False, utt_split: float = 0.8, transport: httpx.AsyncBaseTransport | None = None, sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, @@ -143,6 +144,7 @@ def __init__( self._base_url = base_url.rstrip("/") self._model = model self._language = language + self._detect_language = detect_language self._utt_split = utt_split self._transport = transport self._sleep = sleep @@ -160,12 +162,15 @@ async def _request( ) -> httpx.Response: params = { "model": self._model, - "language": self._language, "smart_format": "true", "utterances": "true", "utt_split": str(self._utt_split), "mip_opt_out": "true", } + if self._detect_language: + params["detect_language"] = "true" + else: + params["language"] = self._language content_type = mimetypes.guess_type(audio_path.name)[0] or "application/octet-stream" headers = { "Authorization": f"Token {self._api_key}", diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 60dfcb2..3b07b32 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -42,10 +42,21 @@ def test_deepgram_provider_needs_only_deepgram_key(monkeypatch): cfg = AppConfig() assert cfg.transcribe.provider == "deepgram" assert cfg.transcribe.deepgram_model == "nova-3" + assert cfg.transcribe.deepgram_detect_language is False assert cfg.transcribe.deepgram_api_key.get_secret_value() == "dg-secret" assert "dg-secret" not in repr(cfg.transcribe) +def test_deepgram_language_detection_reads_boolean(monkeypatch): + for k, v in _env( + TRANSCRIBE_PROVIDER="deepgram", + DEEPGRAM_API_KEY="dg-secret", + DEEPGRAM_DETECT_LANGUAGE="true", + ).items(): + monkeypatch.setenv(k, v) + assert AppConfig().transcribe.deepgram_detect_language is True + + def test_deepgram_provider_rejects_empty_key(monkeypatch): for k, v in _env( TRANSCRIBE_PROVIDER="deepgram", diff --git a/tests/unit/test_deepgram_transcriber.py b/tests/unit/test_deepgram_transcriber.py index 251a56a..b2d1f5f 100644 --- a/tests/unit/test_deepgram_transcriber.py +++ b/tests/unit/test_deepgram_transcriber.py @@ -64,6 +64,8 @@ async def fake_probe(path: Path) -> float: assert seen["headers"]["authorization"] == "Token test-secret" assert seen["headers"]["content-length"] == str(len(b"audio-data")) assert seen["query"]["model"] == "nova-3" + assert seen["query"]["language"] == "ru" + assert "detect_language" not in seen["query"] assert seen["query"]["mip_opt_out"] == "true" assert seen["query"]["utterances"] == "true" assert result.read_text() == "1\n00:00:00,000 --> 00:00:03,500\nПривет, мир." @@ -71,6 +73,31 @@ async def fake_probe(path: Path) -> float: assert [item["audio_seconds"] for item in usage] == [4, 3] +async def test_detect_language_omits_fixed_language(tmp_path, monkeypatch): + seen: dict[str, str] = {} + + async def handler(request: httpx.Request) -> httpx.Response: + seen.update(dict(request.url.params)) + await request.aread() + return httpx.Response(200, json=_payload()) + + async def fake_probe(path: Path) -> float: + return 4.0 + + monkeypatch.setattr(mod, "probe_audio_seconds", fake_probe) + audio = tmp_path / "lecture.mp3" + audio.write_bytes(b"audio") + transcriber = mod.DeepgramTranscriber( + api_key="secret", + language="ru", + detect_language=True, + transport=httpx.MockTransport(handler), + ) + await transcriber.transcribe(audio, tmp_path / "out") + assert seen["detect_language"] == "true" + assert "language" not in seen + + async def test_retries_503_with_repeatable_body(tmp_path, monkeypatch): bodies: list[bytes] = [] diff --git a/tests/unit/test_factories.py b/tests/unit/test_factories.py index 37e311b..d31e705 100644 --- a/tests/unit/test_factories.py +++ b/tests/unit/test_factories.py @@ -94,3 +94,15 @@ def test_transcriber_factory_selects_deepgram(): DEEPGRAM_API_KEY="secret", ) assert isinstance(transcriber_factory(config), DeepgramTranscriber) + + +def test_transcriber_factory_passes_language_detection(): + config = TranscribeConfig( + _env_file=None, + TRANSCRIBE_PROVIDER="deepgram", + DEEPGRAM_API_KEY="secret", + DEEPGRAM_DETECT_LANGUAGE=True, + ) + transcriber = transcriber_factory(config) + assert isinstance(transcriber, DeepgramTranscriber) + assert transcriber._detect_language is True From e88ec2322035fc32d77091c004879b1ccb0ae68b Mon Sep 17 00:00:00 2001 From: fUS1ONd Date: Fri, 24 Jul 2026 13:37:35 +0000 Subject: [PATCH 4/4] docs: clarify Deepgram language modes --- .env.example | 4 ++++ deploy/env.core.example | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.env.example b/.env.example index 066d222..448f27c 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,11 @@ GROQ_API_KEYS= DEEPGRAM_API_KEY= DEEPGRAM_BASE_URL=https://api.deepgram.com DEEPGRAM_MODEL=nova-3 +# Фиксированный язык. Используется только при DEEPGRAM_DETECT_LANGUAGE=false. +# Для речи с переключением между несколькими языками можно задать multi. DEEPGRAM_LANGUAGE=ru +# true: Deepgram определяет один доминирующий язык и DEEPGRAM_LANGUAGE игнорируется. +# Это не режим mixed-language/code-switching; для него используйте language=multi выше. DEEPGRAM_DETECT_LANGUAGE=false DEEPGRAM_UTT_SPLIT=0.8 OPENROUTER_API_KEY= diff --git a/deploy/env.core.example b/deploy/env.core.example index 79c0220..d5dec21 100644 --- a/deploy/env.core.example +++ b/deploy/env.core.example @@ -15,7 +15,11 @@ GROQ_API_KEYS= DEEPGRAM_API_KEY= DEEPGRAM_BASE_URL=https://api.deepgram.com DEEPGRAM_MODEL=nova-3 +# Фиксированный язык. Используется только при DEEPGRAM_DETECT_LANGUAGE=false. +# Для речи с переключением между несколькими языками можно задать multi. DEEPGRAM_LANGUAGE=ru +# true: Deepgram определяет один доминирующий язык и DEEPGRAM_LANGUAGE игнорируется. +# Это не режим mixed-language/code-switching; для него используйте language=multi выше. DEEPGRAM_DETECT_LANGUAGE=false DEEPGRAM_UTT_SPLIT=0.8 OPENROUTER_API_KEY=