Andrey Epifancev a7902222db docs(openspec): trim model setting too (#5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
2026-09-27 23:44:18 +04:00

135 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`, `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** уведомление сообщает, что проверять нечем, сетевого запроса нет