Andrey Epifancev a948a80e7a docs: record decision #18 (R-3) — final review fix scope
Fixing M-1 (redact() Content-Disposition cut index), T3.5 (errorText
defensive wrap), and M-2 (design.md stale signatures) per the final
whole-branch reviewer's own bundling recommendation. Deferring M-3, M-4,
M-5 as cosmetic/unreachable/forward-looking with no current impact.

Refs #5

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

24 KiB
Raw Blame History

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

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.

Провайдер

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. Цена ошибки: ноль — чисто синтаксический модификатор без изменения поведения; без него код не компилируется, других исходов нет.
  18. 2026-09-27 (исполнение, контроллер SDD, R-3) — Из находок финального ревью всей ветки (M-1..M-5, плюс дообследование отложенных T3.5) исправлены только M-1 (индекс среза Content-Disposition в redact() считался по строке в нижнем регистре, но применялся к оригиналу — некоторые Unicode-символы меняют длину при toLowerCase()), T3.5 (errorText в transcribe() теоретически может бросить на объекте без toString/valueOf/Symbol.toPrimitive) и M-2 (устаревшие сигнатуры classify(status,text)/redact(message,apiKey) и неточное описание пустого .catch в этом же design.md, строки 35 и 68). Ревьюер сам предложил объединить эти три в один фикс-коммит как однострочные, нулевого риска правки без изменения поведения ни на одном достижимом пути. Цена ошибки: не найдено критичных/важных находок; отложенные (M-3 стилистика недостижимого catch, M-4 косметическое Notice после unload, M-5 saveSettings перезаписывает только {openai} — задел на будущее для issue-обёртки) переносятся в описание PR и, если актуальны, в follow-up issue при закрытии. Отклонённые находки: M-3, M-4, M-5 (обоснование см. в отчёте финального ревью и в ledger SDD).