From 9144427bbac66e51f901a28a63bee3589b08b55b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 10 Oct 2026 18:47:07 +0400 Subject: [PATCH] docs(openspec): archive openai-transcriber, promote transcription-provider spec (#5) Co-Authored-By: Claude Opus 5.5 --- .../.openspec.yaml | 0 .../2026-10-10-openai-transcriber}/README.md | 0 .../2026-10-10-openai-transcriber}/design.md | 0 .../proposal.md | 0 .../specs/transcription-provider/spec.md | 0 openspec/specs/transcription-provider/spec.md | 133 ++++++++++++++++++ 6 files changed, 133 insertions(+) rename openspec/changes/{openai-transcriber => archive/2026-10-10-openai-transcriber}/.openspec.yaml (100%) rename openspec/changes/{openai-transcriber => archive/2026-10-10-openai-transcriber}/README.md (100%) rename openspec/changes/{openai-transcriber => archive/2026-10-10-openai-transcriber}/design.md (100%) rename openspec/changes/{openai-transcriber => archive/2026-10-10-openai-transcriber}/proposal.md (100%) rename openspec/changes/{openai-transcriber => archive/2026-10-10-openai-transcriber}/specs/transcription-provider/spec.md (100%) create mode 100644 openspec/specs/transcription-provider/spec.md diff --git a/openspec/changes/openai-transcriber/.openspec.yaml b/openspec/changes/archive/2026-10-10-openai-transcriber/.openspec.yaml similarity index 100% rename from openspec/changes/openai-transcriber/.openspec.yaml rename to openspec/changes/archive/2026-10-10-openai-transcriber/.openspec.yaml diff --git a/openspec/changes/openai-transcriber/README.md b/openspec/changes/archive/2026-10-10-openai-transcriber/README.md similarity index 100% rename from openspec/changes/openai-transcriber/README.md rename to openspec/changes/archive/2026-10-10-openai-transcriber/README.md diff --git a/openspec/changes/openai-transcriber/design.md b/openspec/changes/archive/2026-10-10-openai-transcriber/design.md similarity index 100% rename from openspec/changes/openai-transcriber/design.md rename to openspec/changes/archive/2026-10-10-openai-transcriber/design.md diff --git a/openspec/changes/openai-transcriber/proposal.md b/openspec/changes/archive/2026-10-10-openai-transcriber/proposal.md similarity index 100% rename from openspec/changes/openai-transcriber/proposal.md rename to openspec/changes/archive/2026-10-10-openai-transcriber/proposal.md diff --git a/openspec/changes/openai-transcriber/specs/transcription-provider/spec.md b/openspec/changes/archive/2026-10-10-openai-transcriber/specs/transcription-provider/spec.md similarity index 100% rename from openspec/changes/openai-transcriber/specs/transcription-provider/spec.md rename to openspec/changes/archive/2026-10-10-openai-transcriber/specs/transcription-provider/spec.md diff --git a/openspec/specs/transcription-provider/spec.md b/openspec/specs/transcription-provider/spec.md new file mode 100644 index 0000000..731aeec --- /dev/null +++ b/openspec/specs/transcription-provider/spec.md @@ -0,0 +1,133 @@ +# transcription-provider Specification + +## Purpose +Определяет, как плагин отправляет аудиозапись в OpenAI-совместимый сервис распознавания (по умолчанию AITUNNEL, `gpt-4o-transcribe`), как раскладывает любой исход в результат `ok` / `temporary` / `permanent` для очереди и как хранит настройки провайдера, не допуская утечки API-ключа. + +## 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** уведомление сообщает, что проверять нечем, сетевого запроса нет