# 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`). Пересмотрено: человек на ревью спеки добавил `trim` и для `model` — обрезаются все четыре настройки. *Строка из пробелов иначе ушла бы как значение поля и дала бы 400.* **Нет.** 12. 2026-09-27 — Интеграционный тест не сверяет текст дословно; таймаут теста vitest — 2 минуты. Число совпадений (≥ 4 из 5–6 слов) задано человеком. *Модель недетерминирована; 86 КБ аудио распознаются за секунды, 2 минуты — запас на медленную сеть.* **Редкий ложный провал при сильном расхождении модели.** 13. 2026-09-27 (план) — `redact` дополнительно обрезает причину с первого вхождения boundary тела запроса (`--` или сам boundary) или строки `Content-Disposition` (без учёта регистра), затем убирает хвостовые пробелы и двоеточия. *Спека запрещает любую часть тела запроса в причине; единственный реалистичный путь туда — сервис или прокси, который эхом возвращает тело в тексте ошибки. Замена ключа и `Bearer` этого не ловит.* **Если легитимный текст ошибки случайно содержит `Content-Disposition` — деталь обрезается, остаётся `HTTP <код>`; диагностика беднее, данные не теряются.** Название модели или язык в тексте ошибки («model gpt-x not found») частью тела не считаются и не вырезаются. 14. 2026-09-27 (план) — `OpenAiTranscriber.transcribe` оборачивает всё тело в `try/catch`; непредвиденное исключение (например, бросил геттер настроек) → `temporary` «внутренняя ошибка: <сообщение>» после `redact`. Исключение HTTP-клиента, в том числе синхронное, по-прежнему `сеть: <сообщение>`. *Спека требует, чтобы провайдер никогда не бросал; очередь такое тоже поймала бы (решение 13 прошлой фичи), но контракт должен держаться сам.* **Ошибка программирования в провайдере маскируется под временную и тратит пять попыток, прежде чем запись уйдёт в `failed`.** 15. 2026-09-27 (план) — Boundary — `----voice-note-` + 24 hex-символа из `random` (`Math.random` в плагине). *Детерминированно в тестах; вероятность совпадения с байтами аудио пренебрежимо мала при 96 битах.* **Совпадение boundary с байтами аудио испортит тело — 400, `permanent`; «Повторить распознавание» сгенерирует новый boundary.**