Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
14 KiB
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-запрос на <baseUrl>/audio/transcriptions, где у baseUrl отброшены завершающие /. Запрос MUST содержать заголовок Authorization: Bearer <apiKey> и тело multipart/form-data с полями model, response_format со значением json и file. Поле language MUST передаваться только если настройка не пустая. Часть file MUST иметь имя audio.<ext>, где расширение определяется по mime записи (audio/mp4 → m4a, audio/webm → webm, audio/ogg → ogg, иначе bin), и Content-Type, равный mime записи без параметров. Байты аудио MUST передаваться без изменений. Тип тела multipart/form-data; boundary=<boundary> MUST передаваться HTTP-клиенту отдельным полем contentType, а boundary в нём MUST совпадать с boundary тела.
Scenario: Запись с iPhone
- WHEN отправляется запись с mime
audio/mp4;codecs=mp4a.40.2,baseUrlhttps://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 уведомление сообщает, что проверять нечем, сетевого запроса нет