# Spec Delta: transcription-provider ## Purpose Определяет, как плагин отправляет аудиозапись в OpenAI-совместимый сервис распознавания (по умолчанию AITUNNEL, `gpt-4o-transcribe`), как раскладывает любой исход в результат `ok` / `temporary` / `permanent` для очереди и как хранит настройки провайдера, не допуская утечки API-ключа. ## ADDED Requirements ### Requirement: Настройки провайдера Провайдер MUST использовать настройки `baseUrl`, `model`, `apiKey`, `language` со значениями по умолчанию `https://api.aitunnel.ru/v1`, `gpt-4o-transcribe`, пустая строка и пустая строка соответственно. Настройки MUST храниться только в `data.json` плагина и MUST читаться заново при каждом запросе, так что изменённый ключ применяется к следующему запросу без перезапуска плагина. Значения `baseUrl`, `model`, `apiKey` и `language` MUST обрезаться от пробелов при чтении. Сохранённые настройки MUST накладываться поверх значений по умолчанию, чтобы отсутствующее поле получало умолчание. API-ключ MUST NOT попадать в репозиторий: ни в исходники, ни в тесты, ни в фикстуры. #### Scenario: Первый запуск - **WHEN** плагин загружается, а `data.json` отсутствует - **THEN** провайдер использует `https://api.aitunnel.ru/v1`, модель `gpt-4o-transcribe`, пустой ключ и пустой язык #### Scenario: Ключ исправлен между запросами - **WHEN** запрос завершился ошибкой 401, пользователь ввёл новый ключ во вкладке настроек, и провайдер вызван снова (очередью или командой проверки) - **THEN** следующий запрос уходит с новым ключом без перезапуска плагина #### Scenario: Ключ вставлен с пробелами - **WHEN** в настройке ключа сохранено `" sk-abc \n"` - **THEN** запрос уходит с заголовком `Authorization: Bearer sk-abc` ### Requirement: Вкладка настроек Плагин MUST предоставлять вкладку настроек с полями «Адрес сервиса» (`baseUrl`), «Модель», «API-ключ» и «Язык». Поле ключа MUST отображаться как поле пароля. Изменение любого поля MUST сохраняться в `data.json`. #### Scenario: Ввод ключа - **WHEN** пользователь вводит ключ во вкладке настроек - **THEN** символы ключа скрыты, а после ввода ключ сохранён в `data.json` ### Requirement: Формирование запроса Провайдер MUST отправлять один POST-запрос на `/audio/transcriptions`, где у `baseUrl` отброшены завершающие `/`. Запрос MUST содержать заголовок `Authorization: Bearer ` и тело `multipart/form-data` с полями `model`, `response_format` со значением `json` и `file`. Поле `language` MUST передаваться только если настройка не пустая. Часть `file` MUST иметь имя `audio.`, где расширение определяется по mime записи (`audio/mp4` → `m4a`, `audio/webm` → `webm`, `audio/ogg` → `ogg`, иначе `bin`), и `Content-Type`, равный mime записи без параметров. Байты аудио MUST передаваться без изменений. Тип тела `multipart/form-data; boundary=` MUST передаваться HTTP-клиенту отдельным полем `contentType`, а boundary в нём MUST совпадать с boundary тела. #### Scenario: Запись с iPhone - **WHEN** отправляется запись с mime `audio/mp4;codecs=mp4a.40.2`, `baseUrl` `https://api.aitunnel.ru/v1/`, язык не задан - **THEN** запрос уходит на `https://api.aitunnel.ru/v1/audio/transcriptions` с полями `model=gpt-4o-transcribe`, `response_format=json` и файлом `audio.m4a` с `Content-Type: audio/mp4`, поля `language` нет #### Scenario: Язык задан - **WHEN** в настройках язык `ru` - **THEN** в запросе есть поле `language=ru` ### Requirement: Проверки до запроса Провайдер MUST завершать распознавание без сетевого запроса в следующих случаях, проверяемых в этом порядке: пустой `apiKey` — постоянная ошибка «не задан API-ключ»; пустой `baseUrl` — постоянная ошибка «не задан адрес сервиса»; аудио длиной 0 байт — постоянная ошибка «пустая запись»; уже отменённый сигнал — временная ошибка «запрос отменён». #### Scenario: Ключ не задан - **WHEN** очередь отправляет запись, а ключ в настройках пустой - **THEN** сетевого запроса нет, результат — постоянная ошибка «не задан API-ключ» #### Scenario: Пустое аудио - **WHEN** провайдеру передано аудио длиной 0 байт - **THEN** сетевого запроса нет, результат — постоянная ошибка «пустая запись» ### Requirement: Классификация ответа Провайдер MUST раскладывать исход запроса так: | Исход | Результат | Причина | |---|---|---| | 2xx, тело — JSON со строковым полем `text` | успех с этим текстом без изменений | — | | 2xx, тело не JSON или без строкового поля `text` | постоянная ошибка | `HTTP <код>: ответ без поля text: <первые 200 символов тела>` | | 408, 429, 5xx | временная ошибка | `HTTP <код>: <деталь>` | | 3xx и 4xx, кроме 408 и 429 | постоянная ошибка | `HTTP <код>: <деталь>` | | 1xx и любой другой код | временная ошибка | `HTTP <код>: <деталь>` | | HTTP-клиент бросил исключение | временная ошибка | `сеть: <сообщение исключения>` | Деталь MUST браться из `error.message`, если тело — JSON вида `{"error":{"message":"…"}}` со строковым `message`, иначе MUST быть первыми 200 символами тела. Если тело пустое или из одних пробелов, MUST использоваться причина `HTTP <код>` без двоеточия и детали. Провайдер MUST NOT бросать исключения: любой исход MUST возвращаться как результат. #### Scenario: Неверный ключ - **WHEN** сервис ответил 401 с телом `{"error":{"message":"Invalid API key"}}` - **THEN** результат — постоянная ошибка `HTTP 401: Invalid API key` #### Scenario: Превышен лимит запросов - **WHEN** сервис ответил 429 - **THEN** результат — временная ошибка, очередь планирует повтор #### Scenario: Неверный адрес сервиса - **WHEN** сервис ответил 200 с HTML-страницей вместо JSON - **THEN** результат — постоянная ошибка, причина начинается с `HTTP 200: ответ без поля text:` и содержит не больше 200 символов тела #### Scenario: Редирект дошёл до провайдера - **WHEN** HTTP-клиент вернул статус 301 - **THEN** результат — постоянная ошибка #### Scenario: Сеть недоступна - **WHEN** HTTP-клиент бросил исключение `net::ERR_INTERNET_DISCONNECTED` - **THEN** результат — временная ошибка `сеть: net::ERR_INTERNET_DISCONNECTED` #### Scenario: Пустой текст - **WHEN** сервис ответил 200 с телом `{"text":" "}` - **THEN** результат — успех с текстом `" "`, а решение «нет речи» принимает очередь по `recording-lifecycle` ### Requirement: Таймаут и отмена Провайдер MUST завершать ожидание ответа через 5 минут временной ошибкой «таймаут запроса (5 мин)». Таймаут MUST быть меньше порога зависшей отправки из `recording-lifecycle` (10 минут). Если сигнал отмены срабатывает во время запроса, провайдер MUST сразу вернуть временную ошибку «запрос отменён». После возврата результата провайдер MUST NOT оставлять активных таймеров и слушателей сигнала. Ответ или исключение запроса, пришедшие после таймаута или отмены, MUST игнорироваться без необработанного отклонения промиса. #### Scenario: Сервис молчит - **WHEN** ответ на запрос не пришёл за 5 минут - **THEN** результат — временная ошибка «таймаут запроса (5 мин)», очередь планирует повтор #### Scenario: Плагин выгружен во время запроса - **WHEN** очередь отменяет сигнал, пока запрос в полёте - **THEN** провайдер сразу возвращает временную ошибку «запрос отменён», а пришедший позже ответ ни на что не влияет ### Requirement: Причина ошибки без секретов Причина любой ошибки MUST NOT содержать API-ключ, значение заголовка `Authorization` и какую-либо часть тела запроса. Перед возвратом каждая причина MUST проходить очистку: каждое вхождение ключа заменяется на `***`, каждая последовательность `Bearer <непробельные символы>` заменяется на `Bearer ***`, результат обрезается до 300 символов. #### Scenario: Сервис вернул ключ в тексте ошибки - **WHEN** сервис ответил 401 с телом, содержащим ключ и строку `Authorization: Bearer <ключ>` - **THEN** причина не содержит ключа, вместо него `***` #### Scenario: Ключ в сообщении сетевого исключения - **WHEN** HTTP-клиент бросил исключение, в сообщении которого есть ключ - **THEN** причина не содержит ключа #### Scenario: Тело запроса не попадает в причину - **WHEN** запрос завершился любой ошибкой - **THEN** причина не содержит boundary тела и строки `Content-Disposition` ### Requirement: Команда «Проверить провайдер распознавания» Команда MUST отправить провайдеру последний по имени аудиофайл из `voice/source/` с расширением `m4a`, `webm` или `ogg`, с mime по расширению, и показать исход во всплывающем уведомлении: при успехе — начало распознанного текста или сообщение, что речь не распознана; при ошибке — вид ошибки (временная или постоянная) и её причину. Если подходящего файла нет, команда MUST показать уведомление об этом без сетевого запроса. Команда MUST NOT изменять заметки, frontmatter и очередь. #### Scenario: Проверка после смены ключа - **WHEN** в `voice/source/` есть записи, пользователь выполнил команду - **THEN** последняя запись отправлена провайдеру, уведомление показывает начало текста, ни одна заметка не изменилась #### Scenario: Нет записей - **WHEN** папка `voice/source/` пуста или отсутствует - **THEN** уведомление сообщает, что проверять нечем, сетевого запроса нет