Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
18 KiB
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.
- 2026-09-27 — Таймаут запроса — 5 минут, константа
REQUEST_TIMEOUT_MSв core, не настройка; инвариант «меньше 10-минутного порога зависшегоsent» проверяется тестом.requestUrlтаймаута не даёт; при таймауте ≥ 10 мин восстановление зависшей отправки сработало бы раньше таймаута. 5 минут с запасом покрывают длинные диктовки. Если сервис легитимно отвечает дольше 5 минут на длинных записях — каждая такая запись тратит попытки и уходит вfailed; лечится увеличением константы (до < 10 мин) или предварительной нарезкой. (Принято человеком в brainstorming.) - 2026-09-27 — Прочие 4xx (кроме 408/429) →
permanent; 1xx и неожиданные коды →temporary. Пересмотрено: 3xx →permanent(адаптеры сами проходят редиректы, дошедший 3xx — ошибкаbaseUrl); 2xx без строковогоtext→permanentс первыми 200 символами тела (ошибка URL или модели). 4xx — ошибка конфигурации или запроса, повтор не поможет. Если какой-то сервис отдаёт 4xx на временный сбой — запись сразу вfailed, лечится «Повторить распознавание». - 2026-09-27 — Имя файла в multipart —
audio.<ext>через существующийextFromMime,Content-Typeчасти — mime без параметров. OpenAI определяет формат по расширению имени файла; iOSaudio/mp4должен уйти как.m4a. Если совместимый сервис определяет формат иначе — 400/415,permanent. (Принято человеком.) - 2026-09-27 — Пустой ключ →
permanent«не задан API-ключ» без сети. Без ключа запрос гарантированно 401. Нет. (Принято человеком.) - 2026-09-27 — Пустой
baseUrlпослеtrim→permanent«не задан адрес сервиса» без сети; проверяется после ключа и до проверки пустого аудио. Иначе запрос ушёл бы на относительный URL/audio/transcriptionsи упал бы непредсказуемой ошибкой адаптера, классифицированной какtemporary— пять бесполезных попыток. Лишняя строка кода и тест, если пользователь никогда не стирает адрес. - 2026-09-27 — Формат причины:
HTTP <код>: <деталь>, деталь —error.messageиз OpenAI-формата ошибки или первые 200 символов тела; пустое тело →HTTP <код>; сетевое исключение —сеть: <сообщение>; итоговая причина обрезается до 300 символов после очистки. Причина попадает во frontmatter и в callout заметки — она должна быть короткой и понятной;error.messageу OpenAI-совместимых сервисов человекочитаемый. Длинная причина обрезана, и детали теряются везде, включая уведомление команды проверки; для полной диагностики понадобится curl. - 2026-09-27 — Очистка секретов заменяет каждое вхождение ключа на
***иBearer <непробельные>наBearer ***. Некоторые сервисы эхом возвращают ключ в тексте 401;Bearer-шаблон ловит ключ в изменённом виде в заголовке. Если сервис вернёт ключ в изменённой форме безBearer(например, частично замаскированныйsk-abc…xyz) — фрагмент ключа попадёт в заметку; полного ключа там не будет. - 2026-09-27 — Успех с пустым текстом провайдер возвращает как
okбез изменений; «нет речи» решает очередь. Это поведение уже закреплено вrecording-lifecycle; дублировать его в провайдере — две точки правды. Нет. - 2026-09-27 — Настройки в
data.jsonлежат под ключомopenai({ "openai": {...} }), а не плоско. Обёртка добавит свои настройки (устройство, папки); вложенность не даст полям провайдера смешаться с ними. Одна лишняя вложенность, если других настроек так и не появится. - 2026-09-27 — Команда «Проверить провайдер распознавания» берёт последний по имени файл
voice/source/*.{m4a,webm,ogg}(имена начинаются с UTC-времени, последний по имени = самый свежий), mime — по расширению через новуюmimeFromExt. Файлы.binне берутся. Сама команда и её граница «только Notice, без записи в заметки и очередь» приняты человеком; выбор файла — нет. Не нужен UI выбора файла; свежая запись — самый показательный образец. Если последняя запись — тишина или битый файл, проверка покажет «речь не распознана» или ошибку сервиса, хотя провайдер исправен; пользователь увидит это по тексту уведомления. - 2026-09-27 —
languageтоже обрезается от пробелов (человек назвалtrimдляapiKeyиbaseUrl). Пересмотрено: человек на ревью спеки добавилtrimи дляmodel— обрезаются все четыре настройки. Строка из пробелов иначе ушла бы как значение поля и дала бы 400. Нет. - 2026-09-27 — Интеграционный тест не сверяет текст дословно; таймаут теста vitest — 2 минуты. Число совпадений (≥ 4 из 5–6 слов) задано человеком. Модель недетерминирована; 86 КБ аудио распознаются за секунды, 2 минуты — запас на медленную сеть. Редкий ложный провал при сильном расхождении модели.