Andrey Epifancev 6f6df01cbd docs: record decisions #16-17 for Task 3/6 deviations from plan's literal code
R-1 (design #16): OpenAiTranscriber.transcribe's redact() call moved out of
the plan's literal post-try/catch position into a single guarded call site,
closing a never-throws gap the plan's own code left open (Task 3 fix loop).

R-2 (design #17): main.ts's settings field needed an override modifier to
satisfy noImplicitOverride against obsidian 1.13.1's new base Plugin.settings
field; the plan's literal code did not compile without it (Task 6).

Refs #5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
2026-09-28 01:11:03 +04:00

111 lines
23 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.

# 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`). Пересмотрено: человек на ревью спеки добавил `trim` и для `model` — обрезаются все четыре настройки. *Строка из пробелов иначе ушла бы как значение поля и дала бы 400.* **Нет.**
12. 2026-09-27 — Интеграционный тест не сверяет текст дословно; таймаут теста vitest — 2 минуты. Число совпадений (≥ 4 из 5–6 слов) задано человеком. *Модель недетерминирована; 86 КБ аудио распознаются за секунды, 2 минуты — запас на медленную сеть.* **Редкий ложный провал при сильном расхождении модели.**
13. 2026-09-27 (план) — `redact` дополнительно обрезает причину с первого вхождения boundary тела запроса (`--<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.**
16. 2026-09-27 (исполнение, контроллер SDD, R-1) — В `OpenAiTranscriber.transcribe` (Task 3) единственный вызов `redact()` вынесен из `try/catch` в отдельный, тоже защищённый `try/catch` с запасным вариантом — жёстко заданной строкой `'внутренняя ошибка'` без интерполяции — если `redact()` сам бросит исключение. Это отступление от буквального кода плана (Task 3, Step 5): план дословно ставил единственный вызов `redact()` ПОСЛЕ `try/catch`, что ревьюер финального ревью задачи 3 нашёл как реальный (Important) пробел. *Дословный код плана проходил все тесты и typecheck, но противоречил собственному Global Constraint плана «`OpenAiTranscriber.transcribe` никогда не бросает»: гипотетическое будущее изменение `classify()`/`fail()`, из-за которого `message` перестанет быть строкой, привело бы к необработанному исключению за пределами метода — ровно то, что этот метод существует, чтобы предотвратить.* **Цена ошибки: крайне маловероятный, сейчас недостижимый при текущих типах путь; если рулинг был не нужен — цена лишь две защитные строки кода без изменения поведения ни для одного существующего пути.**
17. 2026-09-27 (исполнение, Task 6 implementer, R-2) — В `src/main.ts` поле `settings` класса `VoiceNotePlugin` объявлено с модификатором `override`, которого нет в буквальном коде плана (Task 6, Step 3). *Дословный код плана не проходил typecheck (TS4114): установленный пакет `obsidian` (1.13.1) с версии 1.13.0 объявляет базовое поле `Plugin.settings?: unknown`, а `tsconfig.json` репозитория требует `noImplicitOverride: true` — любое поле подкласса, перекрывающее поле базового класса, обязано нести `override`.* **Цена ошибки: ноль — чисто синтаксический модификатор без изменения поведения; без него код не компилируется, других исходов нет.**