diff --git a/openspec/changes/openai-transcriber/.openspec.yaml b/openspec/changes/openai-transcriber/.openspec.yaml new file mode 100644 index 0000000..7f2ad57 --- /dev/null +++ b/openspec/changes/openai-transcriber/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/openai-transcriber/README.md b/openspec/changes/openai-transcriber/README.md new file mode 100644 index 0000000..d2c43fe --- /dev/null +++ b/openspec/changes/openai-transcriber/README.md @@ -0,0 +1,3 @@ +# openai-transcriber + +Провайдер распознавания: OpenAI-совместимый `/v1/audio/transcriptions` (AITUNNEL, gpt-4o-transcribe) (issue #5) diff --git a/openspec/changes/openai-transcriber/design.md b/openspec/changes/openai-transcriber/design.md new file mode 100644 index 0000000..0526baa --- /dev/null +++ b/openspec/changes/openai-transcriber/design.md @@ -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; + contentType: string; + body: ArrayBuffer; +} +export interface HttpResponse { status: number; text: string } +// Любой HTTP-статус — ответ, не исключение. Исключение — только сетевой сбой. +export interface HttpClient { post(req: HttpRequest): Promise } +``` + +`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.` через существующий `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 минуты — запас на медленную сеть.* **Редкий ложный провал при сильном расхождении модели.** diff --git a/openspec/changes/openai-transcriber/proposal.md b/openspec/changes/openai-transcriber/proposal.md new file mode 100644 index 0000000..6011fe2 --- /dev/null +++ b/openspec/changes/openai-transcriber/proposal.md @@ -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` — запрос к `/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` из настроек, только при отправке записи и по команде проверки. diff --git a/openspec/changes/openai-transcriber/specs/transcription-provider/spec.md b/openspec/changes/openai-transcriber/specs/transcription-provider/spec.md new file mode 100644 index 0000000..19c2c99 --- /dev/null +++ b/openspec/changes/openai-transcriber/specs/transcription-provider/spec.md @@ -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-запрос на `/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** уведомление сообщает, что проверять нечем, сетевого запроса нет