feat: OpenAI-compatible transcriber provider (#5) #6

Merged
aep merged 15 commits from feat/5-openai-transcriber into main 2026-10-10 17:43:23 +03:00
Owner

Closes #5.

Что сделано

Провайдер распознавания OpenAiTranscriber для OpenAI-совместимого /v1/audio/transcriptions (по умолчанию AITUNNEL, gpt-4o-transcribe); другой сервис — тот же код с другим base URL.

  • src/core/ — порт HttpClient, побайтовый multipart-билдер, чтение и trim настроек, классификация ответов в temporary/permanent, очистка секретов (redact), транскрайбер с предпроверками, таймаутом 5 мин и отменой; логика команды проверки (pickCheckAudio, checkNotice, mimeFromExt).
  • src/obsidian/ — адаптер requestUrl, вкладка настроек (4 поля, ключ скрыт), команда «Проверить провайдер распознавания» (только Notice, без записи в заметки и очередь).
  • tests/integration/aitunnel.test.ts — живой тест, запускается только при заданном AITUNNEL_API_KEY.
  • Спека: openspec/changes/openai-transcriber/ (proposal, specs, design.md с решениями 1–19).

Проверка

  • npm test (без AITUNNEL_API_KEY): 18 файлов passed | 1 skipped; 243 tests passed | 1 skipped.
  • npm run typecheck — чисто (оба прохода); npm run build — exit 0.
  • Ручная проверка (Task 7), 2026-10-10:
Шаг Mac iPhone Примечание
1. Живой тест AITUNNEL ✅ 1 passed — запущен агентом, ключ из окружения не выводился
2. Сборка в тестовый vault + фикстура ✅ — test-01.m4a → voice/source/20260927-000000-0000.m4a
3. Вкладка настроек / data.json ✅ ✅ ключ на месте после синхронизации iCloud {openai:{baseUrl, model, apiKey, language}}, data.json не в git
4a. Команда, верный ключ ✅ успех с текстом ✅ успех multipart с boundary доходит через requestUrl
4b. Испорченный ключ ✅ HTTP 401, ключа в тексте нет —
4c. Пустой ключ ✅ «не задан API-ключ» —
5. Авиарежим — ✅ временная ошибка «сеть: …»

Фактические тексты уведомлений человек не приводил — подтверждено «все ок».

Rulings I made

  • R-1 (design #16) — единственный вызов redact() в transcribe() обёрнут в отдельный try/catch с фиксированной строкой-заглушкой; отступление от буквального кода плана, который нарушал собственный инвариант «никогда не бросает».
  • R-2 (design #17) — override у поля settings в VoiceNotePlugin: obsidian 1.13.1 объявляет Plugin.settings, а noImplicitOverride без модификатора не даёт скомпилировать.
  • R-3 (design #18) — из финального ревью исправлены M-1, M-2, T3.5; M-3, M-4, M-5 отложены.
  • R-4 (design #19) — minor-находки ревью задач (T1.1, T1.2, T3.1–T3.4, T5.1, T5.2) закрыты как rejected по оценке финального ревью «acceptable as-is»; исправлена формулировка #18.

Находки

Маркер Суть Статус
M-1 redact(): индекс среза Content-Disposition считался по строке в нижнем регистре fixed ab698d0
M-2 устаревшие сигнатуры и описание в design.md fixed ab698d0
M-3 catch в команде всегда пишет «Не удалось прочитать», хотя оборачивает и transcribe() (недостижимо) deferred
M-4 Notice может появиться после выгрузки плагина, если проверка в полёте deferred
M-5 saveSettings сохраняет только {openai} — сотрёт другие ключи data.json, когда они появятся deferred
T1.1 multipart: нет экранирования "/CRLF в name/filename rejected — вызывающие места передают фиксированные имена (R-4)
T1.2 tests/fakes.ts formParts декодирует бинарное тело как UTF-8 rejected — достаточно для фикстур (R-4)
T3.1 тест таймаута не проверяет removeEventListener отдельно rejected — общий finally (R-4)
T3.2 not.toContain(KEY) в e2e-тесте эха тела вакуумен rejected — ключ проверяют 3 других теста (R-4)
T3.3 нет e2e-теста синхронного throw с ключом rejected — тот же путь errorText → redact (R-4)
T3.4 makeBoundary вызывается до предпроверок rejected — безвредно (R-4)
T3.5 errorText() мог бросить на патологическом значении fixed ab698d0
T5.1 нет прямого теста строки NO_CHECK_AUDIO rejected — acceptable as-is (R-4)
T5.2 обрезка до 100 символов по UTF-16 может разрезать суррогатную пару rejected — информационно (R-4)
O-1 codegraph в worktree — проблема пустого индекса не воспроизвелась rejected — не воспроизводится
O-2 неточные поштучные счётчики в отчёте исполнителя Task 1 rejected — агрегаты проверены контроллером
O-3 то же в отчёте Task 2 rejected — агрегаты проверены контроллером
O-4 AITUNNEL_API_KEY в окружении → обычный npm test делает живой запрос rejected как дефект кода — тест корректен; агенты запускают (unset AITUNNEL_API_KEY; npm test)
O-5 ключ AITUNNEL выведен в транскрипт сессии (echo $AITUNNEL_API_KEY) deferred — перевыпустить ключ (действие человека)
O-6 команду проверки можно найти только через палитру команд (нет иконки и подсказки в настройках) deferred

Deferred

M-3, M-4, M-5, O-5, O-6 — попадут в follow-up issue при закрытии.

🤖 Generated with Claude Code

Closes #5. ## Что сделано Провайдер распознавания `OpenAiTranscriber` для OpenAI-совместимого `/v1/audio/transcriptions` (по умолчанию AITUNNEL, `gpt-4o-transcribe`); другой сервис — тот же код с другим base URL. - `src/core/` — порт `HttpClient`, побайтовый multipart-билдер, чтение и `trim` настроек, классификация ответов в `temporary`/`permanent`, очистка секретов (`redact`), транскрайбер с предпроверками, таймаутом 5 мин и отменой; логика команды проверки (`pickCheckAudio`, `checkNotice`, `mimeFromExt`). - `src/obsidian/` — адаптер `requestUrl`, вкладка настроек (4 поля, ключ скрыт), команда «Проверить провайдер распознавания» (только Notice, без записи в заметки и очередь). - `tests/integration/aitunnel.test.ts` — живой тест, запускается только при заданном `AITUNNEL_API_KEY`. - Спека: `openspec/changes/openai-transcriber/` (proposal, specs, design.md с решениями 1–19). ## Проверка - `npm test` (без `AITUNNEL_API_KEY`): 18 файлов passed | 1 skipped; 243 tests passed | 1 skipped. - `npm run typecheck` — чисто (оба прохода); `npm run build` — exit 0. - Ручная проверка (Task 7), 2026-10-10: | Шаг | Mac | iPhone | Примечание | |---|---|---|---| | 1. Живой тест AITUNNEL | ✅ `1 passed` | — | запущен агентом, ключ из окружения не выводился | | 2. Сборка в тестовый vault + фикстура | ✅ | — | `test-01.m4a` → `voice/source/20260927-000000-0000.m4a` | | 3. Вкладка настроек / `data.json` | ✅ | ✅ ключ на месте после синхронизации iCloud | `{openai:{baseUrl, model, apiKey, language}}`, `data.json` не в git | | 4a. Команда, верный ключ | ✅ успех с текстом | ✅ успех | multipart с boundary доходит через `requestUrl` | | 4b. Испорченный ключ | ✅ HTTP 401, ключа в тексте нет | — | | | 4c. Пустой ключ | ✅ «не задан API-ключ» | — | | | 5. Авиарежим | — | ✅ временная ошибка «сеть: …» | | Фактические тексты уведомлений человек не приводил — подтверждено «все ок». ## Rulings I made - **R-1 (design #16)** — единственный вызов `redact()` в `transcribe()` обёрнут в отдельный `try/catch` с фиксированной строкой-заглушкой; отступление от буквального кода плана, который нарушал собственный инвариант «никогда не бросает». - **R-2 (design #17)** — `override` у поля `settings` в `VoiceNotePlugin`: obsidian 1.13.1 объявляет `Plugin.settings`, а `noImplicitOverride` без модификатора не даёт скомпилировать. - **R-3 (design #18)** — из финального ревью исправлены M-1, M-2, T3.5; M-3, M-4, M-5 отложены. - **R-4 (design #19)** — minor-находки ревью задач (T1.1, T1.2, T3.1–T3.4, T5.1, T5.2) закрыты как `rejected` по оценке финального ревью «acceptable as-is»; исправлена формулировка #18. ## Находки | Маркер | Суть | Статус | |---|---|---| | M-1 | `redact()`: индекс среза `Content-Disposition` считался по строке в нижнем регистре | fixed ab698d0 | | M-2 | устаревшие сигнатуры и описание в design.md | fixed ab698d0 | | M-3 | catch в команде всегда пишет «Не удалось прочитать», хотя оборачивает и `transcribe()` (недостижимо) | deferred | | M-4 | Notice может появиться после выгрузки плагина, если проверка в полёте | deferred | | M-5 | `saveSettings` сохраняет только `{openai}` — сотрёт другие ключи `data.json`, когда они появятся | deferred | | T1.1 | multipart: нет экранирования `"`/CRLF в name/filename | rejected — вызывающие места передают фиксированные имена (R-4) | | T1.2 | `tests/fakes.ts formParts` декодирует бинарное тело как UTF-8 | rejected — достаточно для фикстур (R-4) | | T3.1 | тест таймаута не проверяет `removeEventListener` отдельно | rejected — общий `finally` (R-4) | | T3.2 | `not.toContain(KEY)` в e2e-тесте эха тела вакуумен | rejected — ключ проверяют 3 других теста (R-4) | | T3.3 | нет e2e-теста синхронного throw с ключом | rejected — тот же путь `errorText → redact` (R-4) | | T3.4 | `makeBoundary` вызывается до предпроверок | rejected — безвредно (R-4) | | T3.5 | `errorText()` мог бросить на патологическом значении | fixed ab698d0 | | T5.1 | нет прямого теста строки `NO_CHECK_AUDIO` | rejected — acceptable as-is (R-4) | | T5.2 | обрезка до 100 символов по UTF-16 может разрезать суррогатную пару | rejected — информационно (R-4) | | O-1 | codegraph в worktree — проблема пустого индекса не воспроизвелась | rejected — не воспроизводится | | O-2 | неточные поштучные счётчики в отчёте исполнителя Task 1 | rejected — агрегаты проверены контроллером | | O-3 | то же в отчёте Task 2 | rejected — агрегаты проверены контроллером | | O-4 | `AITUNNEL_API_KEY` в окружении → обычный `npm test` делает живой запрос | rejected как дефект кода — тест корректен; агенты запускают `(unset AITUNNEL_API_KEY; npm test)` | | O-5 | ключ AITUNNEL выведен в транскрипт сессии (`echo $AITUNNEL_API_KEY`) | deferred — перевыпустить ключ (действие человека) | | O-6 | команду проверки можно найти только через палитру команд (нет иконки и подсказки в настройках) | deferred | ### Deferred M-3, M-4, M-5, O-5, O-6 — попадут в follow-up issue при закрытии. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
aep added 15 commits 2026-10-10 17:10:40 +03:00
Spec delta transcription-provider, proposal and design with decisions
made without discussion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
Refs #5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
Refs #5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
Refs #5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
Refs #5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
Refs #5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
Refs #5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
Refs #5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
Refs #5

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
Refs #5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
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
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
M-1: redact()'s Content-Disposition cut computed its index against a
lowercased copy but sliced the original string — some Unicode characters
change length under toLowerCase(), which could let a fragment of
"Content-Disposition" survive the cut. Now uses a case-insensitive regex
matched directly against the original string.

T3.5: errorText() could itself throw on a pathological thrown value (a
null-prototype object with no toString/valueOf/Symbol.toPrimitive),
defeating transcribe()'s "never throws" guarantee in an already-caught
branch. Now wrapped defensively with a generic fallback message.

Also corrects two stale descriptions in design.md (classify/redact
signatures, and the post-promise rejection handling) so the archived
design document matches the shipped code.

Refs #5

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
aep merged commit d2e7659ae8 into main 2026-10-10 17:43:23 +03:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: aep/obsidian-voice-note#6
No description provided.