docs(openspec): propose openai-transcriber (#5)
Spec delta transcription-provider, proposal and design with decisions made without discussion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
This commit is contained in:
parent
114e5f35ca
commit
75b9b5e471
2
openspec/changes/openai-transcriber/.openspec.yaml
Normal file
2
openspec/changes/openai-transcriber/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-27
|
||||
3
openspec/changes/openai-transcriber/README.md
Normal file
3
openspec/changes/openai-transcriber/README.md
Normal file
@ -0,0 +1,3 @@
|
||||
# openai-transcriber
|
||||
|
||||
Провайдер распознавания: OpenAI-совместимый `/v1/audio/transcriptions` (AITUNNEL, gpt-4o-transcribe) (issue #5)
|
||||
105
openspec/changes/openai-transcriber/design.md
Normal file
105
openspec/changes/openai-transcriber/design.md
Normal file
@ -0,0 +1,105 @@
|
||||
# Design: openai-transcriber
|
||||
|
||||
## Context
|
||||
|
||||
Core (`src/core/`, issue #1) сохраняет запись и ведёт её через очередь `QueueRunner`, которая вызывает порт `Transcriber.transcribe(audio, mime, signal) → TranscribeResult` и по `ok` / `temporary` / `permanent` решает, вставлять ли текст, повторять или останавливаться. Реализации порта нет. Ограничения из CLAUDE.md: `isDesktopOnly: false`, в рантайме нет Node API, на мобильном HTTP только через `requestUrl` Obsidian; у него нет `FormData`, `AbortSignal` и таймаута. На вход приходят `audio/mp4` (iOS), `audio/webm` (Android), `audio/webm`/`audio/ogg` (десктоп). Vault и `data.json` синхронизируются через iCloud между Mac и iPhone.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Реализация `Transcriber` для любого OpenAI-совместимого `/v1/audio/transcriptions`, по умолчанию AITUNNEL + `gpt-4o-transcribe`.
|
||||
- Вся логика запроса, таймаута, отмены, классификации и очистки секретов — в core, под vitest.
|
||||
- Ключ только в `data.json`, никогда в репозитории и в причинах ошибок.
|
||||
- Критерий успеха фичи: интеграционный тест против живого AITUNNEL с `gpt-4o-transcribe` на `tests/fixtures/test-01.m4a` проходит.
|
||||
|
||||
**Non-Goals:**
|
||||
- Запуск очереди в плагине, команды записи и повтора, `MediaRecorder`, реализация `RecordingStore`/`NoteStore` на vault (issue обёртки).
|
||||
- Цепочка провайдеров с переключением по 403/таймауту, провайдер `yandex`.
|
||||
- Предварительная проверка размера файла (лимит 25 МБ у OpenAI): у совместимых сервисов лимиты разные, 413 классифицируется как постоянная ошибка.
|
||||
- Конвертация форматов.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Подход: провайдер целиком в core, HTTP-порт «тупой»
|
||||
|
||||
Порт `HttpClient` принимает запрос и возвращает `{status, text}`; сигнала и таймаута у него нет. Таймаут и отмену `OpenAiTranscriber` делает гонкой промисов в core. Отвергнуто: (2) таймаут и отмена внутри адаптера — та же логика уезжает в `src/obsidian/`, где нет юнит-тестов; (3) `fetch` вместо `requestUrl` — настоящая отмена, но на мобильном Obsidian `fetch` подчиняется CORS, а CLAUDE.md требует `requestUrl`.
|
||||
|
||||
Цена подхода: при таймауте или отмене запрос `requestUrl` не обрывается, а доживает в фоне; его результат игнорируется. Трафик брошенного запроса тратится до ответа сервера.
|
||||
|
||||
### Модули
|
||||
|
||||
| Модуль | Ответственность |
|
||||
|---|---|
|
||||
| `src/core/ports.ts` | + `HttpRequest`, `HttpResponse`, `HttpClient` |
|
||||
| `src/core/multipart.ts` | `buildMultipart(fields, file, boundary) → { body: ArrayBuffer, contentType }`, побайтно через `TextEncoder`/`Uint8Array` |
|
||||
| `src/core/openai.ts` | `OpenAiSettings`, `DEFAULT_OPENAI`, `REQUEST_TIMEOUT_MS`, `readSettings` (trim + наложение на умолчания), `classify(status, text)`, `redact(message, apiKey)`, `OpenAiTranscriber` |
|
||||
| `src/core/naming.ts` | + `mimeFromExt(ext)` для команды проверки |
|
||||
| `src/obsidian/http.ts` | `HttpClient` на `requestUrl({ url, method: 'POST', headers, contentType, body, throw: false })` |
|
||||
| `src/obsidian/settings.ts` | `PluginSettingTab`: четыре поля, ключ — `inputEl.type = 'password'` |
|
||||
| `src/main.ts` | `loadData`/`saveData`, вкладка, команда «Проверить провайдер распознавания» |
|
||||
|
||||
### Порт HTTP
|
||||
|
||||
```ts
|
||||
export interface HttpRequest {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
contentType: string;
|
||||
body: ArrayBuffer;
|
||||
}
|
||||
export interface HttpResponse { status: number; text: string }
|
||||
// Любой HTTP-статус — ответ, не исключение. Исключение — только сетевой сбой.
|
||||
export interface HttpClient { post(req: HttpRequest): Promise<HttpResponse> }
|
||||
```
|
||||
|
||||
`contentType` — отдельное поле, в `headers` core его не кладёт. Адаптер `requestUrl` передаёт его в параметр `contentType` (иначе boundary может потеряться), адаптер `fetch` из интеграционного теста — в заголовок `Content-Type`.
|
||||
|
||||
### Провайдер
|
||||
|
||||
```ts
|
||||
interface OpenAiSettings { baseUrl: string; model: string; apiKey: string; language: string }
|
||||
const DEFAULT_OPENAI = { baseUrl: 'https://api.aitunnel.ru/v1', model: 'gpt-4o-transcribe', apiKey: '', language: '' };
|
||||
const REQUEST_TIMEOUT_MS = 5 * 60_000;
|
||||
new OpenAiTranscriber(http: HttpClient, settings: () => OpenAiSettings, random: () => number)
|
||||
```
|
||||
|
||||
Настройки передаются геттером и читаются на каждом вызове: исправленный ключ применяется без пересоздания очереди. Boundary строится из `random` — детерминированно в тестах.
|
||||
|
||||
Поток `transcribe(audio, mime, signal)`: чтение и `trim` настроек → предпроверки (ключ, адрес, 0 байт, отменённый сигнал) → `buildMultipart` → `Promise.race(post, таймер, abort)` → `classify` → `redact` для любой причины. В `finally` снимаются таймер и слушатель `abort`; у промиса `post` висит пустой `.catch`, чтобы поздний reject не стал необработанным.
|
||||
|
||||
Таймер — глобальный `setTimeout`/`clearTimeout` (есть и в браузере, и в Node), в тестах — `vi.useFakeTimers()`; отдельный порт не нужен.
|
||||
|
||||
### Хранение настроек
|
||||
|
||||
`data.json` = `{ "openai": OpenAiSettings }`, при загрузке накладывается на `DEFAULT_OPENAI`. `data.json` в `.gitignore`. Синхронизация `data.json` через iCloud на телефон — ожидаемое поведение: ключ вводится один раз.
|
||||
|
||||
### Тестирование
|
||||
|
||||
- Юнит (vitest, фейковый `HttpClient`, fake timers): `multipart.test.ts` (байты, boundary в теле и в `contentType`, бинарные байты > 0x7F, `language` отсутствует при пустой настройке), `openai-request.test.ts` (URL с `/` и без, `Authorization`, `trim`, `response_format=json`, `audio.m4a` для `audio/mp4;codecs=…`, геттер читается на каждом вызове), `openai-classify.test.ts` (строка на каждый класс таблицы спеки, предпроверки без вызова `post`), `openai-timeout.test.ts` (таймаут 5 мин, abort в полёте, `vi.getTimerCount() === 0` после исхода, поздний reject без unhandled rejection, инвариант `REQUEST_TIMEOUT_MS < SENT_STALE_MS`), `openai-redact.test.ts` (ключ в теле 401, ключ в сообщении исключения, отсутствие boundary и `Content-Disposition` в причине).
|
||||
- Страж `tests/core-boundary.test.ts` и двойной `typecheck` покрывают новые файлы core без изменений.
|
||||
- Интеграционный: `tests/integration/aitunnel.test.ts`, `describe.skipIf(!process.env.AITUNNEL_API_KEY)`, ключ только из переменной окружения, адаптер на `fetch` в самом тесте, `test-01.m4a` → `ok`, текст непустой, из 5–6 характерных слов `test-01.txt` без учёта регистра совпадают не меньше 4. Таймаут теста — 2 минуты. Входит в `npm test`, без ключа пропускается.
|
||||
- Ручная проверка (отдельный пункт плана, результаты — в описание PR): вкладка настроек и `data.json` после ввода; команда «Проверить провайдер распознавания» через `requestUrl` на Mac и на iPhone.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **`requestUrl` не проверяется автоматически.** Интеграционный тест идёт через `fetch` под Node. Путь `requestUrl` (включая передачу `contentType`) проверяется только руками. Ошибка адаптера проявится как 400/415 → постоянная ошибка, записи не теряются, «Повторить распознавание» после исправления.
|
||||
- **Брошенный запрос доживает в фоне** после таймаута или отмены (см. «Подход»).
|
||||
- **Недетерминированность модели.** Сверка интеграционного теста по ключевым словам, а не дословно; редкие расхождения в словах возможны.
|
||||
- **Ключ в `data.json` в открытом виде** и синхронизируется через iCloud — принято человеком как ожидаемое поведение.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
Формат: дата — решение. *Обоснование.* **Цена ошибки.** Пометка «пересмотрено» — решение скорректировано человеком в brainstorming.
|
||||
|
||||
1. 2026-09-27 — Таймаут запроса — 5 минут, константа `REQUEST_TIMEOUT_MS` в core, не настройка; инвариант «меньше 10-минутного порога зависшего `sent`» проверяется тестом. *`requestUrl` таймаута не даёт; при таймауте ≥ 10 мин восстановление зависшей отправки сработало бы раньше таймаута. 5 минут с запасом покрывают длинные диктовки.* **Если сервис легитимно отвечает дольше 5 минут на длинных записях — каждая такая запись тратит попытки и уходит в `failed`; лечится увеличением константы (до < 10 мин) или предварительной нарезкой.** (Принято человеком в brainstorming.)
|
||||
2. 2026-09-27 — Прочие 4xx (кроме 408/429) → `permanent`; 1xx и неожиданные коды → `temporary`. Пересмотрено: 3xx → `permanent` (адаптеры сами проходят редиректы, дошедший 3xx — ошибка `baseUrl`); 2xx без строкового `text` → `permanent` с первыми 200 символами тела (ошибка URL или модели). *4xx — ошибка конфигурации или запроса, повтор не поможет.* **Если какой-то сервис отдаёт 4xx на временный сбой — запись сразу в `failed`, лечится «Повторить распознавание».**
|
||||
3. 2026-09-27 — Имя файла в multipart — `audio.<ext>` через существующий `extFromMime`, `Content-Type` части — mime без параметров. *OpenAI определяет формат по расширению имени файла; iOS `audio/mp4` должен уйти как `.m4a`.* **Если совместимый сервис определяет формат иначе — 400/415, `permanent`.** (Принято человеком.)
|
||||
4. 2026-09-27 — Пустой ключ → `permanent` «не задан API-ключ» без сети. *Без ключа запрос гарантированно 401.* **Нет.** (Принято человеком.)
|
||||
5. 2026-09-27 — Пустой `baseUrl` после `trim` → `permanent` «не задан адрес сервиса» без сети; проверяется после ключа и до проверки пустого аудио. *Иначе запрос ушёл бы на относительный URL `/audio/transcriptions` и упал бы непредсказуемой ошибкой адаптера, классифицированной как `temporary` — пять бесполезных попыток.* **Лишняя строка кода и тест, если пользователь никогда не стирает адрес.**
|
||||
6. 2026-09-27 — Формат причины: `HTTP <код>: <деталь>`, деталь — `error.message` из OpenAI-формата ошибки или первые 200 символов тела; пустое тело → `HTTP <код>`; сетевое исключение — `сеть: <сообщение>`; итоговая причина обрезается до 300 символов после очистки. *Причина попадает во frontmatter и в callout заметки — она должна быть короткой и понятной; `error.message` у OpenAI-совместимых сервисов человекочитаемый.* **Длинная причина обрезана, и детали теряются везде, включая уведомление команды проверки; для полной диагностики понадобится curl.**
|
||||
7. 2026-09-27 — Очистка секретов заменяет каждое вхождение ключа на `***` и `Bearer <непробельные>` на `Bearer ***`. *Некоторые сервисы эхом возвращают ключ в тексте 401; `Bearer`-шаблон ловит ключ в изменённом виде в заголовке.* **Если сервис вернёт ключ в изменённой форме без `Bearer` (например, частично замаскированный `sk-abc…xyz`) — фрагмент ключа попадёт в заметку; полного ключа там не будет.**
|
||||
8. 2026-09-27 — Успех с пустым текстом провайдер возвращает как `ok` без изменений; «нет речи» решает очередь. *Это поведение уже закреплено в `recording-lifecycle`; дублировать его в провайдере — две точки правды.* **Нет.**
|
||||
9. 2026-09-27 — Настройки в `data.json` лежат под ключом `openai` (`{ "openai": {...} }`), а не плоско. *Обёртка добавит свои настройки (устройство, папки); вложенность не даст полям провайдера смешаться с ними.* **Одна лишняя вложенность, если других настроек так и не появится.**
|
||||
10. 2026-09-27 — Команда «Проверить провайдер распознавания» берёт последний по имени файл `voice/source/*.{m4a,webm,ogg}` (имена начинаются с UTC-времени, последний по имени = самый свежий), mime — по расширению через новую `mimeFromExt`. Файлы `.bin` не берутся. Сама команда и её граница «только Notice, без записи в заметки и очередь» приняты человеком; выбор файла — нет. *Не нужен UI выбора файла; свежая запись — самый показательный образец.* **Если последняя запись — тишина или битый файл, проверка покажет «речь не распознана» или ошибку сервиса, хотя провайдер исправен; пользователь увидит это по тексту уведомления.**
|
||||
11. 2026-09-27 — `language` тоже обрезается от пробелов (человек назвал `trim` для `apiKey` и `baseUrl`). *Строка из пробелов иначе ушла бы как значение `language` и дала бы 400.* **Нет.**
|
||||
12. 2026-09-27 — Интеграционный тест не сверяет текст дословно; таймаут теста vitest — 2 минуты. Число совпадений (≥ 4 из 5–6 слов) задано человеком. *Модель недетерминирована; 86 КБ аудио распознаются за секунды, 2 минуты — запас на медленную сеть.* **Редкий ложный провал при сильном расхождении модели.**
|
||||
35
openspec/changes/openai-transcriber/proposal.md
Normal file
35
openspec/changes/openai-transcriber/proposal.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Proposal: openai-transcriber
|
||||
|
||||
Issue: #5 — Провайдер распознавания: OpenAI-совместимый `/v1/audio/transcriptions` (AITUNNEL, gpt-4o-transcribe).
|
||||
|
||||
## Why
|
||||
|
||||
Core умеет сохранять запись и доводить её до текста через очередь с повторами, но реализации порта `Transcriber` нет: распознавать нечем. Нужен провайдер, который отправляет аудио файлом во внешний сервис и раскладывает любой исход в контракт `TranscribeResult` (`ok` / `temporary` / `permanent`) так, чтобы правила повторов из `recording-lifecycle` работали, а API-ключ не утекал ни в репозиторий, ни в заметки.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Новый порт `HttpClient` в `src/core/ports.ts`: POST с телом `ArrayBuffer` и отдельным полем `contentType`; любой HTTP-статус — ответ, исключение — только сетевой сбой.
|
||||
- `src/core/multipart.ts`: побайтная сборка `multipart/form-data` (у `requestUrl` нет `FormData`).
|
||||
- `src/core/openai.ts`: `OpenAiTranscriber` — запрос к `<baseUrl>/audio/transcriptions`, предпроверки без сети, таймаут и отмена через гонку промисов, классификация ответа, очистка секретов в причине ошибки. Настройки и умолчания (AITUNNEL, `gpt-4o-transcribe`).
|
||||
- `src/obsidian/http.ts`: адаптер `HttpClient` на `requestUrl`.
|
||||
- `src/obsidian/settings.ts` и `src/main.ts`: загрузка и сохранение настроек провайдера в `data.json`, вкладка настроек, команда «Проверить провайдер распознавания».
|
||||
- Интеграционный тест против живого AITUNNEL, пропускаемый без `AITUNNEL_API_KEY`.
|
||||
- Вне объёма: запуск очереди в плагине, команды записи, `MediaRecorder`, UI записи (issue обёртки); цепочка провайдеров с переключением по 403/таймауту; провайдер `yandex`.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `transcription-provider`: запрос к OpenAI-совместимому сервису распознавания, предпроверки, таймаут и отмена, классификация ответов, причина ошибки без секретов, настройки провайдера и их хранение, команда проверки провайдера.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
Нет. Провайдер реализует контракт `Transcriber`, уже описанный в `recording-lifecycle`.
|
||||
|
||||
## Impact
|
||||
|
||||
- Новые файлы: `src/core/multipart.ts`, `src/core/openai.ts`, `src/obsidian/http.ts`, `src/obsidian/settings.ts`, `tests/multipart.test.ts`, `tests/openai-*.test.ts`, `tests/integration/aitunnel.test.ts`.
|
||||
- Изменяемые: `src/core/ports.ts` (порт `HttpClient`), `src/core/naming.ts` (mime по расширению для команды проверки), `src/main.ts`.
|
||||
- Зависимостей не добавляется: интеграционный тест использует встроенный `fetch` Node.
|
||||
- `data.json` уже в `.gitignore`; ключ синхронизируется через iCloud вместе с vault — это ожидаемое поведение.
|
||||
- Сетевые запросы появляются впервые: к `baseUrl` из настроек, только при отправке записи и по команде проверки.
|
||||
@ -0,0 +1,134 @@
|
||||
# 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`, `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** уведомление сообщает, что проверять нечем, сетевого запроса нет
|
||||
Loading…
x
Reference in New Issue
Block a user