From 75b9b5e471dd6849edfea0c6322487199bb5a9c4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 23:39:58 +0400 Subject: [PATCH 01/15] docs(openspec): propose openai-transcriber (#5) Spec delta transcription-provider, proposal and design with decisions made without discussion. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa --- .../changes/openai-transcriber/.openspec.yaml | 2 + openspec/changes/openai-transcriber/README.md | 3 + openspec/changes/openai-transcriber/design.md | 105 ++++++++++++++ .../changes/openai-transcriber/proposal.md | 35 +++++ .../specs/transcription-provider/spec.md | 134 ++++++++++++++++++ 5 files changed, 279 insertions(+) create mode 100644 openspec/changes/openai-transcriber/.openspec.yaml create mode 100644 openspec/changes/openai-transcriber/README.md create mode 100644 openspec/changes/openai-transcriber/design.md create mode 100644 openspec/changes/openai-transcriber/proposal.md create mode 100644 openspec/changes/openai-transcriber/specs/transcription-provider/spec.md diff --git a/openspec/changes/openai-transcriber/.openspec.yaml b/openspec/changes/openai-transcriber/.openspec.yaml new file mode 100644 index 0000000..7f2ad57 --- /dev/null +++ b/openspec/changes/openai-transcriber/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-27 diff --git a/openspec/changes/openai-transcriber/README.md b/openspec/changes/openai-transcriber/README.md new file mode 100644 index 0000000..d2c43fe --- /dev/null +++ b/openspec/changes/openai-transcriber/README.md @@ -0,0 +1,3 @@ +# openai-transcriber + +Провайдер распознавания: OpenAI-совместимый `/v1/audio/transcriptions` (AITUNNEL, gpt-4o-transcribe) (issue #5) diff --git a/openspec/changes/openai-transcriber/design.md b/openspec/changes/openai-transcriber/design.md new file mode 100644 index 0000000..0526baa --- /dev/null +++ b/openspec/changes/openai-transcriber/design.md @@ -0,0 +1,105 @@ +# 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`). *Строка из пробелов иначе ушла бы как значение `language` и дала бы 400.* **Нет.** +12. 2026-09-27 — Интеграционный тест не сверяет текст дословно; таймаут теста vitest — 2 минуты. Число совпадений (≥ 4 из 5–6 слов) задано человеком. *Модель недетерминирована; 86 КБ аудио распознаются за секунды, 2 минуты — запас на медленную сеть.* **Редкий ложный провал при сильном расхождении модели.** diff --git a/openspec/changes/openai-transcriber/proposal.md b/openspec/changes/openai-transcriber/proposal.md new file mode 100644 index 0000000..6011fe2 --- /dev/null +++ b/openspec/changes/openai-transcriber/proposal.md @@ -0,0 +1,35 @@ +# Proposal: openai-transcriber + +Issue: #5 — Провайдер распознавания: OpenAI-совместимый `/v1/audio/transcriptions` (AITUNNEL, gpt-4o-transcribe). + +## Why + +Core умеет сохранять запись и доводить её до текста через очередь с повторами, но реализации порта `Transcriber` нет: распознавать нечем. Нужен провайдер, который отправляет аудио файлом во внешний сервис и раскладывает любой исход в контракт `TranscribeResult` (`ok` / `temporary` / `permanent`) так, чтобы правила повторов из `recording-lifecycle` работали, а API-ключ не утекал ни в репозиторий, ни в заметки. + +## What Changes + +- Новый порт `HttpClient` в `src/core/ports.ts`: POST с телом `ArrayBuffer` и отдельным полем `contentType`; любой HTTP-статус — ответ, исключение — только сетевой сбой. +- `src/core/multipart.ts`: побайтная сборка `multipart/form-data` (у `requestUrl` нет `FormData`). +- `src/core/openai.ts`: `OpenAiTranscriber` — запрос к `/audio/transcriptions`, предпроверки без сети, таймаут и отмена через гонку промисов, классификация ответа, очистка секретов в причине ошибки. Настройки и умолчания (AITUNNEL, `gpt-4o-transcribe`). +- `src/obsidian/http.ts`: адаптер `HttpClient` на `requestUrl`. +- `src/obsidian/settings.ts` и `src/main.ts`: загрузка и сохранение настроек провайдера в `data.json`, вкладка настроек, команда «Проверить провайдер распознавания». +- Интеграционный тест против живого AITUNNEL, пропускаемый без `AITUNNEL_API_KEY`. +- Вне объёма: запуск очереди в плагине, команды записи, `MediaRecorder`, UI записи (issue обёртки); цепочка провайдеров с переключением по 403/таймауту; провайдер `yandex`. + +## Capabilities + +### New Capabilities + +- `transcription-provider`: запрос к OpenAI-совместимому сервису распознавания, предпроверки, таймаут и отмена, классификация ответов, причина ошибки без секретов, настройки провайдера и их хранение, команда проверки провайдера. + +### Modified Capabilities + +Нет. Провайдер реализует контракт `Transcriber`, уже описанный в `recording-lifecycle`. + +## Impact + +- Новые файлы: `src/core/multipart.ts`, `src/core/openai.ts`, `src/obsidian/http.ts`, `src/obsidian/settings.ts`, `tests/multipart.test.ts`, `tests/openai-*.test.ts`, `tests/integration/aitunnel.test.ts`. +- Изменяемые: `src/core/ports.ts` (порт `HttpClient`), `src/core/naming.ts` (mime по расширению для команды проверки), `src/main.ts`. +- Зависимостей не добавляется: интеграционный тест использует встроенный `fetch` Node. +- `data.json` уже в `.gitignore`; ключ синхронизируется через iCloud вместе с vault — это ожидаемое поведение. +- Сетевые запросы появляются впервые: к `baseUrl` из настроек, только при отправке записи и по команде проверки. diff --git a/openspec/changes/openai-transcriber/specs/transcription-provider/spec.md b/openspec/changes/openai-transcriber/specs/transcription-provider/spec.md new file mode 100644 index 0000000..19c2c99 --- /dev/null +++ b/openspec/changes/openai-transcriber/specs/transcription-provider/spec.md @@ -0,0 +1,134 @@ +# Spec Delta: transcription-provider + +## Purpose + +Определяет, как плагин отправляет аудиозапись в OpenAI-совместимый сервис распознавания (по умолчанию AITUNNEL, `gpt-4o-transcribe`), как раскладывает любой исход в результат `ok` / `temporary` / `permanent` для очереди и как хранит настройки провайдера, не допуская утечки API-ключа. + +## ADDED Requirements + +### Requirement: Настройки провайдера + +Провайдер MUST использовать настройки `baseUrl`, `model`, `apiKey`, `language` со значениями по умолчанию `https://api.aitunnel.ru/v1`, `gpt-4o-transcribe`, пустая строка и пустая строка соответственно. Настройки MUST храниться только в `data.json` плагина и MUST читаться заново при каждом запросе, так что изменённый ключ применяется к следующему запросу без перезапуска плагина. Значения `baseUrl`, `apiKey` и `language` MUST обрезаться от пробелов при чтении. Сохранённые настройки MUST накладываться поверх значений по умолчанию, чтобы отсутствующее поле получало умолчание. API-ключ MUST NOT попадать в репозиторий: ни в исходники, ни в тесты, ни в фикстуры. + +#### Scenario: Первый запуск +- **WHEN** плагин загружается, а `data.json` отсутствует +- **THEN** провайдер использует `https://api.aitunnel.ru/v1`, модель `gpt-4o-transcribe`, пустой ключ и пустой язык + +#### Scenario: Ключ исправлен между запросами +- **WHEN** запрос завершился ошибкой 401, пользователь ввёл новый ключ во вкладке настроек, и провайдер вызван снова (очередью или командой проверки) +- **THEN** следующий запрос уходит с новым ключом без перезапуска плагина + +#### Scenario: Ключ вставлен с пробелами +- **WHEN** в настройке ключа сохранено `" sk-abc \n"` +- **THEN** запрос уходит с заголовком `Authorization: Bearer sk-abc` + +### Requirement: Вкладка настроек + +Плагин MUST предоставлять вкладку настроек с полями «Адрес сервиса» (`baseUrl`), «Модель», «API-ключ» и «Язык». Поле ключа MUST отображаться как поле пароля. Изменение любого поля MUST сохраняться в `data.json`. + +#### Scenario: Ввод ключа +- **WHEN** пользователь вводит ключ во вкладке настроек +- **THEN** символы ключа скрыты, а после ввода ключ сохранён в `data.json` + +### Requirement: Формирование запроса + +Провайдер MUST отправлять один POST-запрос на `/audio/transcriptions`, где у `baseUrl` отброшены завершающие `/`. Запрос MUST содержать заголовок `Authorization: Bearer ` и тело `multipart/form-data` с полями `model`, `response_format` со значением `json` и `file`. Поле `language` MUST передаваться только если настройка не пустая. Часть `file` MUST иметь имя `audio.`, где расширение определяется по mime записи (`audio/mp4` → `m4a`, `audio/webm` → `webm`, `audio/ogg` → `ogg`, иначе `bin`), и `Content-Type`, равный mime записи без параметров. Байты аудио MUST передаваться без изменений. Тип тела `multipart/form-data; boundary=` MUST передаваться HTTP-клиенту отдельным полем `contentType`, а boundary в нём MUST совпадать с boundary тела. + +#### Scenario: Запись с iPhone +- **WHEN** отправляется запись с mime `audio/mp4;codecs=mp4a.40.2`, `baseUrl` `https://api.aitunnel.ru/v1/`, язык не задан +- **THEN** запрос уходит на `https://api.aitunnel.ru/v1/audio/transcriptions` с полями `model=gpt-4o-transcribe`, `response_format=json` и файлом `audio.m4a` с `Content-Type: audio/mp4`, поля `language` нет + +#### Scenario: Язык задан +- **WHEN** в настройках язык `ru` +- **THEN** в запросе есть поле `language=ru` + +### Requirement: Проверки до запроса + +Провайдер MUST завершать распознавание без сетевого запроса в следующих случаях, проверяемых в этом порядке: пустой `apiKey` — постоянная ошибка «не задан API-ключ»; пустой `baseUrl` — постоянная ошибка «не задан адрес сервиса»; аудио длиной 0 байт — постоянная ошибка «пустая запись»; уже отменённый сигнал — временная ошибка «запрос отменён». + +#### Scenario: Ключ не задан +- **WHEN** очередь отправляет запись, а ключ в настройках пустой +- **THEN** сетевого запроса нет, результат — постоянная ошибка «не задан API-ключ» + +#### Scenario: Пустое аудио +- **WHEN** провайдеру передано аудио длиной 0 байт +- **THEN** сетевого запроса нет, результат — постоянная ошибка «пустая запись» + +### Requirement: Классификация ответа + +Провайдер MUST раскладывать исход запроса так: + +| Исход | Результат | Причина | +|---|---|---| +| 2xx, тело — JSON со строковым полем `text` | успех с этим текстом без изменений | — | +| 2xx, тело не JSON или без строкового поля `text` | постоянная ошибка | `HTTP <код>: ответ без поля text: <первые 200 символов тела>` | +| 408, 429, 5xx | временная ошибка | `HTTP <код>: <деталь>` | +| 3xx и 4xx, кроме 408 и 429 | постоянная ошибка | `HTTP <код>: <деталь>` | +| 1xx и любой другой код | временная ошибка | `HTTP <код>: <деталь>` | +| HTTP-клиент бросил исключение | временная ошибка | `сеть: <сообщение исключения>` | + +Деталь MUST браться из `error.message`, если тело — JSON вида `{"error":{"message":"…"}}` со строковым `message`, иначе MUST быть первыми 200 символами тела. Если тело пустое или из одних пробелов, MUST использоваться причина `HTTP <код>` без двоеточия и детали. Провайдер MUST NOT бросать исключения: любой исход MUST возвращаться как результат. + +#### Scenario: Неверный ключ +- **WHEN** сервис ответил 401 с телом `{"error":{"message":"Invalid API key"}}` +- **THEN** результат — постоянная ошибка `HTTP 401: Invalid API key` + +#### Scenario: Превышен лимит запросов +- **WHEN** сервис ответил 429 +- **THEN** результат — временная ошибка, очередь планирует повтор + +#### Scenario: Неверный адрес сервиса +- **WHEN** сервис ответил 200 с HTML-страницей вместо JSON +- **THEN** результат — постоянная ошибка, причина начинается с `HTTP 200: ответ без поля text:` и содержит не больше 200 символов тела + +#### Scenario: Редирект дошёл до провайдера +- **WHEN** HTTP-клиент вернул статус 301 +- **THEN** результат — постоянная ошибка + +#### Scenario: Сеть недоступна +- **WHEN** HTTP-клиент бросил исключение `net::ERR_INTERNET_DISCONNECTED` +- **THEN** результат — временная ошибка `сеть: net::ERR_INTERNET_DISCONNECTED` + +#### Scenario: Пустой текст +- **WHEN** сервис ответил 200 с телом `{"text":" "}` +- **THEN** результат — успех с текстом `" "`, а решение «нет речи» принимает очередь по `recording-lifecycle` + +### Requirement: Таймаут и отмена + +Провайдер MUST завершать ожидание ответа через 5 минут временной ошибкой «таймаут запроса (5 мин)». Таймаут MUST быть меньше порога зависшей отправки из `recording-lifecycle` (10 минут). Если сигнал отмены срабатывает во время запроса, провайдер MUST сразу вернуть временную ошибку «запрос отменён». После возврата результата провайдер MUST NOT оставлять активных таймеров и слушателей сигнала. Ответ или исключение запроса, пришедшие после таймаута или отмены, MUST игнорироваться без необработанного отклонения промиса. + +#### Scenario: Сервис молчит +- **WHEN** ответ на запрос не пришёл за 5 минут +- **THEN** результат — временная ошибка «таймаут запроса (5 мин)», очередь планирует повтор + +#### Scenario: Плагин выгружен во время запроса +- **WHEN** очередь отменяет сигнал, пока запрос в полёте +- **THEN** провайдер сразу возвращает временную ошибку «запрос отменён», а пришедший позже ответ ни на что не влияет + +### Requirement: Причина ошибки без секретов + +Причина любой ошибки MUST NOT содержать API-ключ, значение заголовка `Authorization` и какую-либо часть тела запроса. Перед возвратом каждая причина MUST проходить очистку: каждое вхождение ключа заменяется на `***`, каждая последовательность `Bearer <непробельные символы>` заменяется на `Bearer ***`, результат обрезается до 300 символов. + +#### Scenario: Сервис вернул ключ в тексте ошибки +- **WHEN** сервис ответил 401 с телом, содержащим ключ и строку `Authorization: Bearer <ключ>` +- **THEN** причина не содержит ключа, вместо него `***` + +#### Scenario: Ключ в сообщении сетевого исключения +- **WHEN** HTTP-клиент бросил исключение, в сообщении которого есть ключ +- **THEN** причина не содержит ключа + +#### Scenario: Тело запроса не попадает в причину +- **WHEN** запрос завершился любой ошибкой +- **THEN** причина не содержит boundary тела и строки `Content-Disposition` + +### Requirement: Команда «Проверить провайдер распознавания» + +Команда MUST отправить провайдеру последний по имени аудиофайл из `voice/source/` с расширением `m4a`, `webm` или `ogg`, с mime по расширению, и показать исход во всплывающем уведомлении: при успехе — начало распознанного текста или сообщение, что речь не распознана; при ошибке — вид ошибки (временная или постоянная) и её причину. Если подходящего файла нет, команда MUST показать уведомление об этом без сетевого запроса. Команда MUST NOT изменять заметки, frontmatter и очередь. + +#### Scenario: Проверка после смены ключа +- **WHEN** в `voice/source/` есть записи, пользователь выполнил команду +- **THEN** последняя запись отправлена провайдеру, уведомление показывает начало текста, ни одна заметка не изменилась + +#### Scenario: Нет записей +- **WHEN** папка `voice/source/` пуста или отсутствует +- **THEN** уведомление сообщает, что проверять нечем, сетевого запроса нет -- 2.47.2 From a7902222db4d46a1e97414245d1e4c33613deccc Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 23:44:18 +0400 Subject: [PATCH 02/15] docs(openspec): trim model setting too (#5) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa --- openspec/changes/openai-transcriber/design.md | 4 ++-- .../openai-transcriber/specs/transcription-provider/spec.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openspec/changes/openai-transcriber/design.md b/openspec/changes/openai-transcriber/design.md index 0526baa..dc52c01 100644 --- a/openspec/changes/openai-transcriber/design.md +++ b/openspec/changes/openai-transcriber/design.md @@ -32,7 +32,7 @@ Core (`src/core/`, issue #1) сохраняет запись и ведёт её |---|---| | `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/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'` | @@ -101,5 +101,5 @@ new OpenAiTranscriber(http: HttpClient, settings: () => OpenAiSettings, random: 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`). *Строка из пробелов иначе ушла бы как значение `language` и дала бы 400.* **Нет.** +11. 2026-09-27 — `language` тоже обрезается от пробелов (человек назвал `trim` для `apiKey` и `baseUrl`). Пересмотрено: человек на ревью спеки добавил `trim` и для `model` — обрезаются все четыре настройки. *Строка из пробелов иначе ушла бы как значение поля и дала бы 400.* **Нет.** 12. 2026-09-27 — Интеграционный тест не сверяет текст дословно; таймаут теста vitest — 2 минуты. Число совпадений (≥ 4 из 5–6 слов) задано человеком. *Модель недетерминирована; 86 КБ аудио распознаются за секунды, 2 минуты — запас на медленную сеть.* **Редкий ложный провал при сильном расхождении модели.** diff --git a/openspec/changes/openai-transcriber/specs/transcription-provider/spec.md b/openspec/changes/openai-transcriber/specs/transcription-provider/spec.md index 19c2c99..ad6d8e8 100644 --- a/openspec/changes/openai-transcriber/specs/transcription-provider/spec.md +++ b/openspec/changes/openai-transcriber/specs/transcription-provider/spec.md @@ -8,7 +8,7 @@ ### Requirement: Настройки провайдера -Провайдер MUST использовать настройки `baseUrl`, `model`, `apiKey`, `language` со значениями по умолчанию `https://api.aitunnel.ru/v1`, `gpt-4o-transcribe`, пустая строка и пустая строка соответственно. Настройки MUST храниться только в `data.json` плагина и MUST читаться заново при каждом запросе, так что изменённый ключ применяется к следующему запросу без перезапуска плагина. Значения `baseUrl`, `apiKey` и `language` MUST обрезаться от пробелов при чтении. Сохранённые настройки MUST накладываться поверх значений по умолчанию, чтобы отсутствующее поле получало умолчание. API-ключ MUST NOT попадать в репозиторий: ни в исходники, ни в тесты, ни в фикстуры. +Провайдер MUST использовать настройки `baseUrl`, `model`, `apiKey`, `language` со значениями по умолчанию `https://api.aitunnel.ru/v1`, `gpt-4o-transcribe`, пустая строка и пустая строка соответственно. Настройки MUST храниться только в `data.json` плагина и MUST читаться заново при каждом запросе, так что изменённый ключ применяется к следующему запросу без перезапуска плагина. Значения `baseUrl`, `model`, `apiKey` и `language` MUST обрезаться от пробелов при чтении. Сохранённые настройки MUST накладываться поверх значений по умолчанию, чтобы отсутствующее поле получало умолчание. API-ключ MUST NOT попадать в репозиторий: ни в исходники, ни в тесты, ни в фикстуры. #### Scenario: Первый запуск - **WHEN** плагин загружается, а `data.json` отсутствует -- 2.47.2 From 4059b721782af5fd4023b8f09e74b2ff5c798f62 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 23:52:39 +0400 Subject: [PATCH 03/15] docs: implementation plan for openai-transcriber; plan-level decisions 13-15 (#5) Refs #5 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa --- .../plans/2026-09-27-openai-transcriber.md | 1504 +++++++++++++++++ openspec/changes/openai-transcriber/design.md | 3 + 2 files changed, 1507 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-27-openai-transcriber.md diff --git a/docs/superpowers/plans/2026-09-27-openai-transcriber.md b/docs/superpowers/plans/2026-09-27-openai-transcriber.md new file mode 100644 index 0000000..11360d2 --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-openai-transcriber.md @@ -0,0 +1,1504 @@ +# OpenAI-compatible Transcriber Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Реализовать порт `Transcriber` через OpenAI-совместимый `POST /audio/transcriptions` (по умолчанию AITUNNEL + `gpt-4o-transcribe`), с настройками в `data.json`, вкладкой настроек и командой «Проверить провайдер распознавания». + +**Architecture:** Вся логика в `src/core/`: новый «тупой» порт `HttpClient` (без сигнала и таймаута), побайтная сборка multipart, `OpenAiTranscriber` с предпроверками, гонкой `post` × таймер × `abort`, классификацией ответа и очисткой секретов в причине. `src/obsidian/` — адаптер `requestUrl`, вкладка настроек; `src/main.ts` — загрузка/сохранение настроек и команда проверки. Интеграционный тест против живого AITUNNEL — через адаптер на `fetch`. + +**Tech Stack:** TypeScript (strict), vitest (fake timers), esbuild, Obsidian API (`requestUrl`, `PluginSettingTab`, `Notice`), Node 24 только для тестов. + +**Spec:** `openspec/changes/openai-transcriber/` — `proposal.md`, `design.md` (включая «Decisions made without discussion», решения 1–15), `specs/transcription-provider/spec.md`. Issue: #5. Ветка: `feat/5-openai-transcriber`. Worktree: `.claude/worktrees/feat-transcriber-provider`. + +## Global Constraints + +- `src/core/**` MUST NOT импортировать `obsidian`, `fs`, `path`, `child_process`, `node:*` и MUST NOT использовать Node-глобалы (`Buffer`, `process`). Проверяют `tests/core-boundary.test.ts` и второй проход `npm run typecheck` (`tsconfig.core.json`, `types: []`). Байты — только `TextEncoder`/`TextDecoder`/`Uint8Array`/`ArrayBuffer`; таймер — глобальный `setTimeout`/`clearTimeout`. +- Умолчания: `baseUrl` `https://api.aitunnel.ru/v1`, `model` `gpt-4o-transcribe`, `apiKey` `''`, `language` `''`. Все четыре поля обрезаются (`trim`) при чтении. +- URL запроса: `/audio/transcriptions`, метод POST, заголовок только `Authorization: Bearer `; тип тела — отдельным полем `contentType` = `multipart/form-data; boundary=`, в `headers` его нет. +- Поля формы по порядку: `model`, `response_format=json`, `language` (только если не пустой), `file` (`filename="audio."` через `extFromMime`, `Content-Type` = mime без параметров в нижнем регистре, пустой mime → `application/octet-stream`). +- Предпроверки без сети, в этом порядке: пустой ключ → `permanent` `не задан API-ключ`; пустой `baseUrl` → `permanent` `не задан адрес сервиса`; 0 байт → `permanent` `пустая запись`; отменённый сигнал → `temporary` `запрос отменён`. +- Классификация: 2xx + JSON со строковым `text` → `ok` с текстом без изменений; 2xx иначе → `permanent` `HTTP <код>: ответ без поля text: <первые 200 символов тела после trim>` (при пустом теле — без `: <…>`); 3xx и 4xx кроме 408/429 → `permanent`; 408, 429, 5xx, 1xx и любые прочие → `temporary`; причина `HTTP <код>: <деталь>`, деталь = непустой `error.message` из `{"error":{"message":…}}`, иначе первые 200 символов тела после `trim`; пустое тело → `HTTP <код>`. Исключение HTTP-клиента → `temporary` `сеть: <сообщение>`. +- `REQUEST_TIMEOUT_MS = 300_000` (5 мин), MUST быть `< SENT_STALE_MS` (600 000). Таймаут → `temporary` `таймаут запроса (5 мин)`; abort в полёте → сразу `temporary` `запрос отменён`. После исхода — ни таймеров, ни слушателей `abort`; поздний ответ/reject игнорируется без unhandled rejection. +- Каждая причина проходит `redact`: обрезка с первого boundary/`--boundary`/`Content-Disposition` (регистр не важен) + снятие хвостовых пробелов и `:`; каждое вхождение ключа → `***`; `Bearer <непробельные>` → `Bearer ***`; итог ≤ 300 символов. +- `OpenAiTranscriber.transcribe` никогда не бросает; непредвиденное исключение → `temporary` `внутренняя ошибка: <сообщение>` (после `redact`). +- Boundary: `----voice-note-` + 24 hex из `random()` (`Math.min(15, Math.floor(random() * 16))`). +- `data.json` = `{ "openai": OpenAiSettings }`, в `.gitignore`. Ключ MUST NOT появляться в репозитории: ни в коде, ни в тестах (в тестах — только фиктивные `sk-test-…`), ни в фикстурах; интеграционный тест берёт ключ только из `process.env.AITUNNEL_API_KEY`. +- `npm test` — весь набор, зелёным считается только весь набор. `npm run typecheck` — оба прохода. Lock-файл не перегенерировать. +- Любое решение, принятое исполнителем без обсуждения, дописывается в `openspec/changes/openai-transcriber/design.md`, раздел `## Decisions made without discussion`, следующим номером (сейчас последний — 15): дата, решение, *обоснование*, **цена ошибки**. +- Коммиты заканчиваются строками: + ``` + Refs #5 + + Co-Authored-By: Claude Opus 5.5 + Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa + ``` + +## Review Focus + +1. **Секреты в причине ошибки.** Сервис эхом возвращает ключ в 401 (JSON и plain text), ключ в сообщении сетевого исключения, прокси эхом возвращает тело запроса — причина не содержит ключа, `Bearer <ключ>`, boundary и `Content-Disposition` (Task 2: юнит-тесты `redact`; Task 3: сквозные тесты «secrets» через `OpenAiTranscriber`). +2. **Инвариант таймаута против порога `sent`.** `REQUEST_TIMEOUT_MS < SENT_STALE_MS` импортом настоящей константы из `record.ts`, а не числом в тесте; после таймаута, abort и обычного ответа `vi.getTimerCount() === 0`, слушатель `abort` снят (Task 3: `tests/openai-timeout.test.ts`). +3. **Нет Node API в `src/core`.** Соблазн — `Buffer.concat` в multipart или `process.env` в провайдере. Каждая core-задача (1, 2, 3, 5) завершается `npm run typecheck` (проход `tsconfig.core.json` ловит `Buffer`/`process`) и `npx vitest run tests/core-boundary.test.ts`. +4. **`requestUrl` и вкладка настроек проверяются только руками** — на Mac и на iPhone, отдельной задачей человека (Task 7); результаты — в описание PR. Особо: `contentType` доходит до сервиса с правильным boundary (успешный ответ), поле ключа скрыто, `data.json` содержит введённое. +5. **Старый или испорченный `data.json`.** Файла нет, `null`, нет ключа `openai`, поле не строка, ключ с пробелами и переводом строки — провайдер получает умолчания/обрезанные значения и не падает (Task 2: тесты `readSettings`; Task 3: тест «trims key with newline»). + +## File Structure + +| Файл | Действие | Ответственность | +|---|---|---| +| `src/core/ports.ts` | Modify | + `HttpRequest`, `HttpResponse`, `HttpClient` | +| `src/core/multipart.ts` | Create | `makeBoundary(random)`, `buildMultipart(fields, file, boundary)` | +| `src/core/openai.ts` | Create | `OpenAiSettings`, `DEFAULT_OPENAI`, `REQUEST_TIMEOUT_MS`, тексты причин, `readSettings`, `classify`, `redact`, `OpenAiTranscriber` | +| `src/core/naming.ts` | Modify | + `mimeFromExt(ext)` | +| `src/core/provider-check.ts` | Create | `pickCheckAudio(paths)`, `checkNotice(result)`, `NO_CHECK_AUDIO` | +| `src/obsidian/http.ts` | Create | `obsidianHttp: HttpClient` на `requestUrl` | +| `src/obsidian/settings.ts` | Create | `VoiceNoteSettingTab` | +| `src/main.ts` | Modify | настройки, вкладка, команда проверки | +| `tests/fakes.ts` | Modify | + `FakeHttp`, `formParts`, `bytesIndexOf` | +| `tests/multipart.test.ts` | Create | Task 1 | +| `tests/openai-settings.test.ts`, `tests/openai-classify.test.ts`, `tests/openai-redact.test.ts` | Create | Task 2 (redact дополняется в Task 3) | +| `tests/openai-request.test.ts`, `tests/openai-timeout.test.ts` | Create | Task 3 | +| `tests/integration/aitunnel.test.ts` | Create | Task 4 | +| `tests/provider-check.test.ts` | Create | Task 5 (+ `mimeFromExt` в `tests/naming.test.ts`) | + +--- + +### Task 1: HTTP-порт и сборка multipart + +**Files:** +- Modify: `src/core/ports.ts` (добавить в конец) +- Create: `src/core/multipart.ts` +- Modify: `tests/fakes.ts` (добавить в конец + импорт типов) +- Test: `tests/multipart.test.ts` + +**Interfaces:** +- Consumes: — +- Produces: + - `interface HttpRequest { url: string; headers: Record; contentType: string; body: ArrayBuffer }` + - `interface HttpResponse { status: number; text: string }` + - `interface HttpClient { post(req: HttpRequest): Promise }` + - `interface MultipartFile { name: string; filename: string; contentType: string; data: ArrayBuffer }` + - `makeBoundary(random: () => number): string` + - `buildMultipart(fields: readonly (readonly [string, string])[], file: MultipartFile, boundary: string): { body: ArrayBuffer; contentType: string }` + - в `tests/fakes.ts`: `class FakeHttp implements HttpClient { requests: HttpRequest[]; reply: (req) => HttpResponse | Promise }`, `formParts(req: HttpRequest): Record`, `bytesIndexOf(hay: Uint8Array, needle: Uint8Array): number` + +- [ ] **Step 1: Добавить порт в `src/core/ports.ts`** (в конец файла): + +```ts +export interface HttpRequest { + url: string; + headers: Record; + // Тип тела передаётся отдельно от headers: адаптер requestUrl кладёт его в + // параметр contentType, иначе boundary может потеряться. + contentType: string; + body: ArrayBuffer; +} + +export interface HttpResponse { + status: number; + text: string; +} + +// Любой HTTP-статус — ответ, не исключение. Исключение — только сетевой сбой. +// Сигнала и таймаута нет: requestUrl их не умеет, это делает провайдер в core. +export interface HttpClient { + post(req: HttpRequest): Promise; +} +``` + +- [ ] **Step 2: Добавить фейк и хелперы в `tests/fakes.ts`.** В импорт типов из `'../src/core/ports'` добавить `HttpClient`, `HttpRequest`, `HttpResponse`; в конец файла: + +```ts +export class FakeHttp implements HttpClient { + requests: HttpRequest[] = []; + + constructor( + public reply: (req: HttpRequest) => HttpResponse | Promise = () => ({ + status: 200, + text: '{"text":"ok"}', + }), + ) {} + + // Не async: если reply бросает синхронно, post тоже бросает синхронно. + post(req: HttpRequest): Promise { + this.requests.push(req); + return Promise.resolve(this.reply(req)); + } +} + +// Разбор multipart для проверок: обычное поле → значение, файл → "filename|Content-Type". +export function formParts(req: HttpRequest): Record { + const match = /boundary=(.+)$/.exec(req.contentType); + if (!match) throw new Error(`no boundary in ${req.contentType}`); + const text = new TextDecoder().decode(req.body); + const parts: Record = {}; + for (const part of text.split(`--${match[1]}`)) { + const m = /name="([^"]+)"(?:; filename="([^"]+)")?\r\n(?:Content-Type: ([^\r]+)\r\n)?\r\n([\s\S]*)\r\n$/.exec(part); + if (m) parts[m[1]] = m[2] ? `${m[2]}|${m[3]}` : m[4]; + } + return parts; +} + +export function bytesIndexOf(hay: Uint8Array, needle: Uint8Array): number { + outer: for (let i = 0; i + needle.length <= hay.length; i++) { + for (let j = 0; j < needle.length; j++) if (hay[i + j] !== needle[j]) continue outer; + return i; + } + return -1; +} +``` + +- [ ] **Step 3: Написать падающий тест `tests/multipart.test.ts`:** + +```ts +import { describe, expect, it } from 'vitest'; +import { buildMultipart, makeBoundary } from '../src/core/multipart'; +import { bytesIndexOf } from './fakes'; + +const enc = new TextEncoder(); + +function concat(...parts: Uint8Array[]): Uint8Array { + const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); + let at = 0; + for (const p of parts) { + out.set(p, at); + at += p.length; + } + return out; +} + +describe('makeBoundary', () => { + it('builds 24 hex chars from random()', () => { + expect(makeBoundary(() => 0)).toBe(`----voice-note-${'0'.repeat(24)}`); + expect(makeBoundary(() => 0.99)).toBe(`----voice-note-${'f'.repeat(24)}`); + }); + + it('clamps random() === 1 to f', () => { + expect(makeBoundary(() => 1)).toBe(`----voice-note-${'f'.repeat(24)}`); + }); +}); + +describe('buildMultipart', () => { + // Байты, которые сломались бы при прогоне через строку: 0x00, >0x7F, CRLF, "--". + const audio = new Uint8Array([0x00, 0xff, 0x80, 0x0d, 0x0a, 0x2d, 0x2d]).buffer; + const file = { name: 'file', filename: 'audio.m4a', contentType: 'audio/mp4', data: audio }; + + it('builds exact bytes with the boundary from contentType', () => { + const { body, contentType } = buildMultipart( + [ + ['model', 'm1'], + ['response_format', 'json'], + ], + file, + 'BND', + ); + expect(contentType).toBe('multipart/form-data; boundary=BND'); + const expected = concat( + enc.encode( + '--BND\r\nContent-Disposition: form-data; name="model"\r\n\r\nm1\r\n' + + '--BND\r\nContent-Disposition: form-data; name="response_format"\r\n\r\njson\r\n' + + '--BND\r\nContent-Disposition: form-data; name="file"; filename="audio.m4a"\r\nContent-Type: audio/mp4\r\n\r\n', + ), + new Uint8Array(audio), + enc.encode('\r\n--BND--\r\n'), + ); + expect(new Uint8Array(body)).toEqual(expected); + }); + + it('keeps binary audio bytes unchanged', () => { + const { body } = buildMultipart([], file, 'BND'); + expect(bytesIndexOf(new Uint8Array(body), new Uint8Array(audio))).toBeGreaterThan(0); + }); + + it('encodes non-ASCII field values as UTF-8', () => { + const { body } = buildMultipart([['language', 'ру']], file, 'BND'); + expect(bytesIndexOf(new Uint8Array(body), enc.encode('\r\n\r\nру\r\n'))).toBeGreaterThan(0); + }); + + it('does not share memory with the input audio', () => { + const data = new Uint8Array([1, 2, 3]); + const { body } = buildMultipart([], { ...file, data: data.buffer }, 'BND'); + data[0] = 9; + expect(bytesIndexOf(new Uint8Array(body), new Uint8Array([1, 2, 3]))).toBeGreaterThan(0); + }); +}); +``` + +- [ ] **Step 4: Запустить — должен упасть** + +Run: `npx vitest run tests/multipart.test.ts` +Expected: FAIL — `Failed to resolve import "../src/core/multipart"`. + +- [ ] **Step 5: Реализовать `src/core/multipart.ts`:** + +```ts +// Побайтная сборка multipart/form-data: у requestUrl нет FormData. +// Только Web API (TextEncoder, Uint8Array) — src/core работает и на телефоне. + +export interface MultipartFile { + name: string; + filename: string; + contentType: string; + data: ArrayBuffer; +} + +export function makeBoundary(random: () => number): string { + let hex = ''; + for (let i = 0; i < 24; i++) hex += Math.min(15, Math.floor(random() * 16)).toString(16); + return `----voice-note-${hex}`; +} + +export function buildMultipart( + fields: readonly (readonly [string, string])[], + file: MultipartFile, + boundary: string, +): { body: ArrayBuffer; contentType: string } { + const enc = new TextEncoder(); + let head = ''; + for (const [name, value] of fields) { + head += `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`; + } + head += + `--${boundary}\r\nContent-Disposition: form-data; name="${file.name}"; filename="${file.filename}"\r\n` + + `Content-Type: ${file.contentType}\r\n\r\n`; + const start = enc.encode(head); + const data = new Uint8Array(file.data); + const end = enc.encode(`\r\n--${boundary}--\r\n`); + const out = new Uint8Array(start.length + data.length + end.length); + out.set(start, 0); + out.set(data, start.length); + out.set(end, start.length + data.length); + return { body: out.buffer, contentType: `multipart/form-data; boundary=${boundary}` }; +} +``` + +- [ ] **Step 6: Запустить тест, typecheck и страж** + +Run: `npx vitest run tests/multipart.test.ts tests/core-boundary.test.ts && npm run typecheck` +Expected: PASS, typecheck без ошибок (оба прохода). + +- [ ] **Step 7: Полный набор и коммит** + +Run: `npm test` — Expected: всё зелёное. + +```bash +git add src/core/ports.ts src/core/multipart.ts tests/fakes.ts tests/multipart.test.ts +git commit -m "feat(core): HttpClient port and byte-exact multipart builder + +Refs #5 + +Co-Authored-By: Claude Opus 5.5 +Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa" +``` + +--- + +### Task 2: Настройки, классификация ответа и очистка секретов (чистые функции) + +**Files:** +- Create: `src/core/openai.ts` +- Test: `tests/openai-settings.test.ts`, `tests/openai-classify.test.ts`, `tests/openai-redact.test.ts` + +**Interfaces:** +- Consumes: `HttpResponse`, `TranscribeResult` из `src/core/ports.ts` +- Produces (все экспортируются из `src/core/openai.ts`): + - `interface OpenAiSettings { baseUrl: string; model: string; apiKey: string; language: string }` + - `const DEFAULT_OPENAI: Readonly` + - `const REQUEST_TIMEOUT_MS = 300_000` + - `const NO_API_KEY = 'не задан API-ключ'`, `NO_BASE_URL = 'не задан адрес сервиса'`, `EMPTY_AUDIO = 'пустая запись'`, `CANCELLED = 'запрос отменён'`, `TIMED_OUT = 'таймаут запроса (5 мин)'` + - `readSettings(saved: unknown): OpenAiSettings` + - `classify(res: HttpResponse): TranscribeResult` (причины ещё не очищены) + - `redact(message: string, secrets: { apiKey: string; boundary?: string }): string` + +- [ ] **Step 1: Падающий тест `tests/openai-settings.test.ts`:** + +```ts +import { describe, expect, it } from 'vitest'; +import { DEFAULT_OPENAI, readSettings } from '../src/core/openai'; + +describe('DEFAULT_OPENAI', () => { + it('points to AITUNNEL gpt-4o-transcribe with no key and auto language', () => { + expect(DEFAULT_OPENAI).toEqual({ + baseUrl: 'https://api.aitunnel.ru/v1', + model: 'gpt-4o-transcribe', + apiKey: '', + language: '', + }); + }); +}); + +describe('readSettings', () => { + it.each([undefined, null, 'str', 42, []])('falls back to defaults for %j', (saved) => { + expect(readSettings(saved)).toEqual(DEFAULT_OPENAI); + }); + + it('overlays saved fields on defaults', () => { + expect(readSettings({ apiKey: 'sk-test-1' })).toEqual({ ...DEFAULT_OPENAI, apiKey: 'sk-test-1' }); + }); + + it('trims all four fields', () => { + expect( + readSettings({ baseUrl: ' https://x/v1 ', model: ' m \n', apiKey: ' sk-test-1 \n', language: ' ru ' }), + ).toEqual({ baseUrl: 'https://x/v1', model: 'm', apiKey: 'sk-test-1', language: 'ru' }); + }); + + it('uses the default for a non-string field', () => { + expect(readSettings({ model: 5, apiKey: null })).toEqual(DEFAULT_OPENAI); + }); + + it('keeps an explicitly blanked baseUrl blank (precheck reports it)', () => { + expect(readSettings({ baseUrl: ' ' }).baseUrl).toBe(''); + }); + + it('returns a fresh object', () => { + const s = readSettings(undefined); + s.apiKey = 'changed'; + expect(DEFAULT_OPENAI.apiKey).toBe(''); + }); +}); +``` + +- [ ] **Step 2: Падающий тест `tests/openai-classify.test.ts`:** + +```ts +import { describe, expect, it } from 'vitest'; +import { classify } from '../src/core/openai'; + +const res = (status: number, text = '') => ({ status, text }); + +describe('classify: 2xx', () => { + it('returns the text as is', () => { + expect(classify(res(200, '{"text":"привет"}'))).toEqual({ ok: true, text: 'привет' }); + }); + + it('returns whitespace-only text unchanged (queue decides "no speech")', () => { + expect(classify(res(200, '{"text":" "}'))).toEqual({ ok: true, text: ' ' }); + }); + + it.each(['oops', '{"foo":1}', '{"text":5}', 'null', '[]', '"str"'])( + '200 with %s → permanent', + (body) => { + expect(classify(res(200, body))).toEqual({ + ok: false, + kind: 'permanent', + message: `HTTP 200: ответ без поля text: ${body}`, + }); + }, + ); + + it('cuts the body to 200 chars', () => { + const body = `${'x'.repeat(1000)}`; + expect(classify(res(200, body))).toEqual({ + ok: false, + kind: 'permanent', + message: `HTTP 200: ответ без поля text: ${body.slice(0, 200)}`, + }); + }); + + it('empty 2xx body → permanent without snippet', () => { + expect(classify(res(204, ''))).toEqual({ ok: false, kind: 'permanent', message: 'HTTP 204: ответ без поля text' }); + }); +}); + +describe('classify: errors', () => { + it.each([301, 302, 399, 400, 401, 403, 404, 413, 415, 422, 499])('%i → permanent', (status) => { + expect(classify(res(status, 'x'))).toEqual({ ok: false, kind: 'permanent', message: `HTTP ${status}: x` }); + }); + + it.each([100, 101, 408, 429, 500, 502, 503, 599, 600])('%i → temporary', (status) => { + expect(classify(res(status, 'x'))).toEqual({ ok: false, kind: 'temporary', message: `HTTP ${status}: x` }); + }); + + it('takes error.message from an OpenAI-style body', () => { + expect(classify(res(401, '{"error":{"message":"Invalid API key"}}'))).toEqual({ + ok: false, + kind: 'permanent', + message: 'HTTP 401: Invalid API key', + }); + }); + + it.each(['{"error":{"message":""}}', '{"error":"bad"}', '{"error":null}'])( + 'falls back to the body when error.message is unusable: %s', + (body) => { + expect(classify(res(400, body))).toEqual({ ok: false, kind: 'permanent', message: `HTTP 400: ${body}` }); + }, + ); + + it('cuts a plain body to 200 chars after trim', () => { + expect(classify(res(500, ` ${'y'.repeat(1000)}`))).toEqual({ + ok: false, + kind: 'temporary', + message: `HTTP 500: ${'y'.repeat(200)}`, + }); + }); + + it.each(['', ' \n '])('empty body %j → "HTTP "', (body) => { + expect(classify(res(503, body))).toEqual({ ok: false, kind: 'temporary', message: 'HTTP 503' }); + }); +}); +``` + +- [ ] **Step 3: Падающий тест `tests/openai-redact.test.ts`** (юнит-часть; сквозные тесты добавит Task 3): + +```ts +import { describe, expect, it } from 'vitest'; +import { redact } from '../src/core/openai'; + +const KEY = 'sk-test-123'; + +describe('redact', () => { + it('replaces every occurrence of the key', () => { + expect(redact(`a ${KEY} b ${KEY}`, { apiKey: KEY })).toBe('a *** b ***'); + }); + + it('masks any Bearer token', () => { + expect(redact('Authorization: Bearer abc.def-1', { apiKey: KEY })).toBe('Authorization: Bearer ***'); + }); + + it('leaves the message alone for an empty key', () => { + expect(redact('HTTP 401: nope', { apiKey: '' })).toBe('HTTP 401: nope'); + }); + + it('cuts at the multipart boundary and trims trailing ": "', () => { + const b = '----voice-note-0000'; + expect(redact(`HTTP 400: echo --${b}\r\nContent-Disposition: form-data`, { apiKey: KEY, boundary: b })).toBe( + 'HTTP 400: echo', + ); + expect(redact(`HTTP 400: --${b}\r\n`, { apiKey: KEY, boundary: b })).toBe('HTTP 400'); + }); + + it('cuts at Content-Disposition regardless of case', () => { + expect(redact('HTTP 400: got content-disposition: form-data; name="file"', { apiKey: KEY })).toBe('HTTP 400: got'); + }); + + it('caps the result at 300 chars', () => { + expect(redact('x'.repeat(500), { apiKey: KEY })).toHaveLength(300); + }); + + it('replaces the key before capping, so no key fragment survives the cut', () => { + const out = redact(`${'y'.repeat(295)}${KEY}`, { apiKey: KEY }); + expect(out).toBe(`${'y'.repeat(295)}***`); + expect(out).not.toContain('sk-'); + }); +}); +``` + +- [ ] **Step 4: Запустить — должны упасть** + +Run: `npx vitest run tests/openai-settings.test.ts tests/openai-classify.test.ts tests/openai-redact.test.ts` +Expected: FAIL — `Failed to resolve import "../src/core/openai"`. + +- [ ] **Step 5: Реализовать `src/core/openai.ts` (чистая часть):** + +```ts +import type { HttpResponse, TranscribeResult } from './ports'; + +export interface OpenAiSettings { + baseUrl: string; + model: string; + apiKey: string; + language: string; +} + +export const DEFAULT_OPENAI: Readonly = { + baseUrl: 'https://api.aitunnel.ru/v1', + model: 'gpt-4o-transcribe', + apiKey: '', + language: '', +}; + +// MUST быть меньше SENT_STALE_MS (record.ts): иначе зависший sent +// восстановится раньше, чем провайдер сдастся по таймауту. +export const REQUEST_TIMEOUT_MS = 5 * 60_000; + +export const NO_API_KEY = 'не задан API-ключ'; +export const NO_BASE_URL = 'не задан адрес сервиса'; +export const EMPTY_AUDIO = 'пустая запись'; +export const CANCELLED = 'запрос отменён'; +export const TIMED_OUT = 'таймаут запроса (5 мин)'; + +const DETAIL_LIMIT = 200; +const REASON_LIMIT = 300; + +const FIELDS = ['baseUrl', 'model', 'apiKey', 'language'] as const; + +// Сохранённое поверх умолчаний; нестроковое поле → умолчание; всё обрезается. +export function readSettings(saved: unknown): OpenAiSettings { + const src = saved !== null && typeof saved === 'object' ? (saved as Record) : {}; + const out: OpenAiSettings = { ...DEFAULT_OPENAI }; + for (const key of FIELDS) { + const value = src[key]; + if (typeof value === 'string') out[key] = value.trim(); + } + return out; +} + +function parseJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +function textField(body: string): string | null { + const json = parseJson(body); + if (json === null || typeof json !== 'object') return null; + const text = (json as { text?: unknown }).text; + return typeof text === 'string' ? text : null; +} + +function detail(body: string): string { + const json = parseJson(body); + if (json !== null && typeof json === 'object') { + const error = (json as { error?: unknown }).error; + if (error !== null && typeof error === 'object') { + const message = (error as { message?: unknown }).message; + if (typeof message === 'string' && message.trim() !== '') return message.trim(); + } + } + return body.trim().slice(0, DETAIL_LIMIT); +} + +// Причины здесь ещё не очищены: redact применяет OpenAiTranscriber. +export function classify(res: HttpResponse): TranscribeResult { + const { status, text } = res; + if (status >= 200 && status < 300) { + const value = textField(text); + if (value !== null) return { ok: true, text: value }; + const snippet = text.trim().slice(0, DETAIL_LIMIT); + return { + ok: false, + kind: 'permanent', + message: `HTTP ${status}: ответ без поля text${snippet ? `: ${snippet}` : ''}`, + }; + } + const info = detail(text); + const message = info ? `HTTP ${status}: ${info}` : `HTTP ${status}`; + // 3xx до нас доходит только при неверном baseUrl (редиректы адаптер проходит сам). + const permanent = status >= 300 && status < 500 && status !== 408 && status !== 429; + return { ok: false, kind: permanent ? 'permanent' : 'temporary', message }; +} + +// Причина попадает во frontmatter и в заметку: ни ключа, ни тела запроса. +export function redact(message: string, secrets: { apiKey: string; boundary?: string }): string { + let out = message; + const lower = out.toLowerCase(); + const cuts = [lower.indexOf('content-disposition')]; + if (secrets.boundary) cuts.push(out.indexOf(`--${secrets.boundary}`), out.indexOf(secrets.boundary)); + const cut = Math.min(...cuts.filter((i) => i >= 0), out.length); + out = out.slice(0, cut); + if (cut < message.length) out = out.replace(/[\s:]+$/, ''); + if (secrets.apiKey) out = out.split(secrets.apiKey).join('***'); + out = out.replace(/Bearer\s+\S+/g, 'Bearer ***'); + return out.slice(0, REASON_LIMIT); +} +``` + +- [ ] **Step 6: Запустить тесты, typecheck и страж** + +Run: `npx vitest run tests/openai-settings.test.ts tests/openai-classify.test.ts tests/openai-redact.test.ts tests/core-boundary.test.ts && npm run typecheck` +Expected: PASS, typecheck без ошибок. + +- [ ] **Step 7: Полный набор и коммит** + +Run: `npm test` — Expected: всё зелёное. + +```bash +git add src/core/openai.ts tests/openai-settings.test.ts tests/openai-classify.test.ts tests/openai-redact.test.ts +git commit -m "feat(core): provider settings, response classification, secret redaction + +Refs #5 + +Co-Authored-By: Claude Opus 5.5 +Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa" +``` + +--- + +### Task 3: `OpenAiTranscriber` — запрос, предпроверки, таймаут, отмена + +**Files:** +- Modify: `src/core/openai.ts` (добавить импорты и класс) +- Modify: `tests/openai-redact.test.ts` (добавить сквозные тесты) +- Test: `tests/openai-request.test.ts`, `tests/openai-timeout.test.ts` + +**Interfaces:** +- Consumes: `HttpClient`, `HttpRequest`, `HttpResponse`, `Transcriber`, `TranscribeResult` (ports); `buildMultipart`, `makeBoundary` (Task 1); `readSettings`, `classify`, `redact`, константы (Task 2); `extFromMime` (`src/core/naming.ts`); `SENT_STALE_MS` (`src/core/record.ts`, только в тесте); `FakeHttp`, `formParts`, `bytesIndexOf` (`tests/fakes.ts`). +- Produces: `class OpenAiTranscriber implements Transcriber { constructor(http: HttpClient, settings: () => OpenAiSettings, random: () => number) }` + +- [ ] **Step 1: Падающий тест `tests/openai-request.test.ts`:** + +```ts +import { describe, expect, it } from 'vitest'; +import { + CANCELLED, + DEFAULT_OPENAI, + EMPTY_AUDIO, + NO_API_KEY, + NO_BASE_URL, + OpenAiTranscriber, + type OpenAiSettings, +} from '../src/core/openai'; +import { bytesIndexOf, FakeHttp, formParts } from './fakes'; + +const KEY = 'sk-test-123'; +const settings = (over: Partial = {}) => () => ({ ...DEFAULT_OPENAI, apiKey: KEY, ...over }); +const audio = () => new Uint8Array([0x00, 0xff, 0x80, 0x0d, 0x0a]).buffer; +const live = () => new AbortController().signal; + +async function send(over: Partial = {}, mime = 'audio/mp4') { + const http = new FakeHttp(); + const result = await new OpenAiTranscriber(http, settings(over), () => 0).transcribe(audio(), mime, live()); + return { http, result, req: http.requests[0] }; +} + +describe('request', () => { + it.each([ + 'https://api.aitunnel.ru/v1', + 'https://api.aitunnel.ru/v1/', + 'https://api.aitunnel.ru/v1//', + ' https://api.aitunnel.ru/v1/ ', + ])('posts to /audio/transcriptions for %j', async (baseUrl) => { + const { req } = await send({ baseUrl }); + expect(req.url).toBe('https://api.aitunnel.ru/v1/audio/transcriptions'); + }); + + it('trims key with newline and sends only Authorization in headers', async () => { + const { req } = await send({ apiKey: ` ${KEY} \n` }); + expect(req.headers).toEqual({ Authorization: `Bearer ${KEY}` }); + }); + + it('passes multipart contentType separately, boundary from random() matches the body', async () => { + const { req } = await send(); + const boundary = `----voice-note-${'0'.repeat(24)}`; + expect(req.contentType).toBe(`multipart/form-data; boundary=${boundary}`); + expect(new TextDecoder().decode(req.body).startsWith(`--${boundary}\r\n`)).toBe(true); + }); + + it('sends model, response_format=json and no language by default', async () => { + const { req } = await send(); + expect(formParts(req)).toEqual({ model: 'gpt-4o-transcribe', response_format: 'json', file: 'audio.m4a|audio/mp4' }); + }); + + it('sends trimmed model and language when set', async () => { + const { req } = await send({ model: ' whisper-1 ', language: ' ru ' }); + expect(formParts(req)).toMatchObject({ model: 'whisper-1', language: 'ru' }); + }); + + it.each([ + ['audio/mp4;codecs=mp4a.40.2', 'audio.m4a|audio/mp4'], + ['audio/webm;codecs=opus', 'audio.webm|audio/webm'], + ['audio/ogg', 'audio.ogg|audio/ogg'], + ['AUDIO/MP4', 'audio.m4a|audio/mp4'], + ['audio/x-foo', 'audio.bin|audio/x-foo'], + ['', 'audio.bin|application/octet-stream'], + ])('names the file for mime %j', async (mime, expected) => { + const { req } = await send({}, mime); + expect(formParts(req).file).toBe(expected); + }); + + it('sends audio bytes unchanged', async () => { + const { req } = await send(); + expect(bytesIndexOf(new Uint8Array(req.body), new Uint8Array(audio()))).toBeGreaterThan(0); + }); + + it('reads settings on every call (fixed key applies without restart)', async () => { + const http = new FakeHttp(); + const current: OpenAiSettings = { ...DEFAULT_OPENAI, apiKey: 'sk-test-old' }; + const t = new OpenAiTranscriber(http, () => current, () => 0); + await t.transcribe(audio(), 'audio/mp4', live()); + current.apiKey = 'sk-test-new'; + await t.transcribe(audio(), 'audio/mp4', live()); + expect(http.requests.map((r) => r.headers.Authorization)).toEqual(['Bearer sk-test-old', 'Bearer sk-test-new']); + }); + + it('returns the service text on success', async () => { + const { result } = await send(); + expect(result).toEqual({ ok: true, text: 'ok' }); + }); +}); + +describe('prechecks (no network)', () => { + const run = async (over: Partial, data: ArrayBuffer, signal: AbortSignal) => { + const http = new FakeHttp(); + const result = await new OpenAiTranscriber(http, settings(over), () => 0).transcribe(data, 'audio/mp4', signal); + expect(http.requests).toHaveLength(0); + return result; + }; + const aborted = () => { + const c = new AbortController(); + c.abort(); + return c.signal; + }; + + it.each(['', ' \n'])('empty key %j → permanent', async (apiKey) => { + expect(await run({ apiKey }, audio(), live())).toEqual({ ok: false, kind: 'permanent', message: NO_API_KEY }); + }); + + it('empty baseUrl → permanent', async () => { + expect(await run({ baseUrl: ' ' }, audio(), live())).toEqual({ ok: false, kind: 'permanent', message: NO_BASE_URL }); + }); + + it('0 bytes → permanent', async () => { + expect(await run({}, new ArrayBuffer(0), live())).toEqual({ ok: false, kind: 'permanent', message: EMPTY_AUDIO }); + }); + + it('already aborted → temporary', async () => { + expect(await run({}, audio(), aborted())).toEqual({ ok: false, kind: 'temporary', message: CANCELLED }); + }); + + it('checks in order: key, baseUrl, audio, signal', async () => { + expect(await run({ apiKey: '', baseUrl: '' }, new ArrayBuffer(0), aborted())).toMatchObject({ message: NO_API_KEY }); + expect(await run({ baseUrl: '' }, new ArrayBuffer(0), aborted())).toMatchObject({ message: NO_BASE_URL }); + expect(await run({}, new ArrayBuffer(0), aborted())).toMatchObject({ message: EMPTY_AUDIO }); + }); +}); + +describe('never throws', () => { + it('network rejection → temporary "сеть: …"', async () => { + const http = new FakeHttp(() => Promise.reject(new Error('net::ERR_INTERNET_DISCONNECTED'))); + const result = await new OpenAiTranscriber(http, settings(), () => 0).transcribe(audio(), 'audio/mp4', live()); + expect(result).toEqual({ ok: false, kind: 'temporary', message: 'сеть: net::ERR_INTERNET_DISCONNECTED' }); + }); + + it('synchronous throw from the client → temporary "сеть: …"', async () => { + const http = new FakeHttp(() => { + throw new Error('boom'); + }); + const result = await new OpenAiTranscriber(http, settings(), () => 0).transcribe(audio(), 'audio/mp4', live()); + expect(result).toEqual({ ok: false, kind: 'temporary', message: 'сеть: boom' }); + }); + + it('non-Error rejection is stringified', async () => { + const http = new FakeHttp(() => Promise.reject('offline')); + const result = await new OpenAiTranscriber(http, settings(), () => 0).transcribe(audio(), 'audio/mp4', live()); + expect(result).toEqual({ ok: false, kind: 'temporary', message: 'сеть: offline' }); + }); + + it('unexpected exception → temporary "внутренняя ошибка: …"', async () => { + const broken = () => { + throw new Error('settings exploded'); + }; + const result = await new OpenAiTranscriber(new FakeHttp(), broken, () => 0).transcribe(audio(), 'audio/mp4', live()); + expect(result).toEqual({ ok: false, kind: 'temporary', message: 'внутренняя ошибка: settings exploded' }); + }); +}); +``` + +- [ ] **Step 2: Падающий тест `tests/openai-timeout.test.ts`:** + +```ts +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CANCELLED, DEFAULT_OPENAI, OpenAiTranscriber, REQUEST_TIMEOUT_MS, TIMED_OUT } from '../src/core/openai'; +import type { HttpResponse, TranscribeResult } from '../src/core/ports'; +import { SENT_STALE_MS } from '../src/core/record'; +import { FakeHttp } from './fakes'; + +const settings = () => ({ ...DEFAULT_OPENAI, apiKey: 'sk-test-123' }); +const audio = () => new Uint8Array([1, 2, 3]).buffer; +const never = () => new Promise(() => {}); + +describe('timeout invariant', () => { + it('is 5 minutes and below the stale-sent threshold', () => { + expect(REQUEST_TIMEOUT_MS).toBe(300_000); + expect(REQUEST_TIMEOUT_MS).toBeLessThan(SENT_STALE_MS); + }); +}); + +describe('timeout and abort', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('gives up after exactly REQUEST_TIMEOUT_MS and leaves no timers', async () => { + const t = new OpenAiTranscriber(new FakeHttp(never), settings, () => 0); + let done: TranscribeResult | undefined; + const p = t.transcribe(audio(), 'audio/mp4', new AbortController().signal).then((r) => (done = r)); + await vi.advanceTimersByTimeAsync(REQUEST_TIMEOUT_MS - 1); + expect(done).toBeUndefined(); + await vi.advanceTimersByTimeAsync(1); + await p; + expect(done).toEqual({ ok: false, kind: 'temporary', message: TIMED_OUT }); + expect(vi.getTimerCount()).toBe(0); + }); + + it('returns "cancelled" right away when aborted in flight', async () => { + const c = new AbortController(); + const remove = vi.spyOn(c.signal, 'removeEventListener'); + const p = new OpenAiTranscriber(new FakeHttp(never), settings, () => 0).transcribe(audio(), 'audio/mp4', c.signal); + await vi.advanceTimersByTimeAsync(1000); + c.abort(); + expect(await p).toEqual({ ok: false, kind: 'temporary', message: CANCELLED }); + expect(vi.getTimerCount()).toBe(0); + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)); + }); + + it('clears the timer and the abort listener after a normal answer', async () => { + const c = new AbortController(); + const remove = vi.spyOn(c.signal, 'removeEventListener'); + const result = await new OpenAiTranscriber(new FakeHttp(), settings, () => 0).transcribe(audio(), 'audio/mp4', c.signal); + expect(result).toEqual({ ok: true, text: 'ok' }); + expect(vi.getTimerCount()).toBe(0); + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)); + }); + + it('ignores a late answer after timeout', async () => { + let resolve!: (r: HttpResponse) => void; + const http = new FakeHttp(() => new Promise((r) => (resolve = r))); + const p = new OpenAiTranscriber(http, settings, () => 0).transcribe(audio(), 'audio/mp4', new AbortController().signal); + await vi.advanceTimersByTimeAsync(REQUEST_TIMEOUT_MS); + expect(await p).toEqual({ ok: false, kind: 'temporary', message: TIMED_OUT }); + resolve({ status: 200, text: '{"text":"late"}' }); + await vi.advanceTimersByTimeAsync(0); + expect(await p).toEqual({ ok: false, kind: 'temporary', message: TIMED_OUT }); + }); + + it('does not leak an unhandled rejection from a late failure', async () => { + const unhandled: unknown[] = []; + const onUnhandled = (e: unknown) => unhandled.push(e); + process.on('unhandledRejection', onUnhandled); + try { + let reject!: (e: unknown) => void; + const http = new FakeHttp(() => new Promise((_, r) => (reject = r))); + const c = new AbortController(); + const p = new OpenAiTranscriber(http, settings, () => 0).transcribe(audio(), 'audio/mp4', c.signal); + c.abort(); + expect(await p).toEqual({ ok: false, kind: 'temporary', message: CANCELLED }); + reject(new Error('late failure')); + vi.useRealTimers(); + await new Promise((r) => setTimeout(r, 10)); + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', onUnhandled); + } + }); +}); +``` + +- [ ] **Step 3: Дописать сквозные тесты секретов в конец `tests/openai-redact.test.ts`.** Добавить импорты `DEFAULT_OPENAI`, `OpenAiTranscriber` (из `'../src/core/openai'`), `type HttpRequest` (из `'../src/core/ports'`), `FakeHttp` (из `'./fakes'`); затем: + +```ts +describe('secrets never reach the reason (end to end)', () => { + const audio = () => new Uint8Array([1, 2, 3]).buffer; + const run = async (reply: ConstructorParameters[0]) => { + const http = new FakeHttp(reply); + const t = new OpenAiTranscriber(http, () => ({ ...DEFAULT_OPENAI, apiKey: KEY }), () => 0); + const result = await t.transcribe(audio(), 'audio/mp4', new AbortController().signal); + if (result.ok) throw new Error('expected an error'); + return { message: result.message, req: http.requests[0] }; + }; + const echo = (status: number) => (req: HttpRequest) => ({ status, text: new TextDecoder().decode(req.body) }); + + it('401 JSON echoing the key and Authorization header', async () => { + const body = JSON.stringify({ + error: { message: `Incorrect API key provided: ${KEY}. Authorization: Bearer ${KEY}` }, + }); + const { message } = await run(() => ({ status: 401, text: body })); + expect(message).toBe('HTTP 401: Incorrect API key provided: ***. Authorization: Bearer ***'); + }); + + it('401 plain text echoing the Authorization header', async () => { + const { message } = await run(() => ({ status: 401, text: `Authorization: Bearer ${KEY}` })); + expect(message).not.toContain(KEY); + expect(message).toBe('HTTP 401: Authorization: Bearer ***'); + }); + + it('network exception mentioning the key', async () => { + const { message } = await run(() => + Promise.reject(new Error(`connect failed for Bearer ${KEY} at https://x/?key=${KEY}`)), + ); + expect(message).not.toContain(KEY); + expect(message).toBe('сеть: connect failed for Bearer *** at https://x/?key=***'); + }); + + it.each([200, 400, 401, 500])('service echoing the request body with status %i', async (status) => { + const { message, req } = await run(echo(status)); + const boundary = /boundary=(.+)$/.exec(req.contentType)![1]; + expect(message).not.toContain(KEY); + expect(message).not.toContain(boundary); + expect(message.toLowerCase()).not.toContain('content-disposition'); + }); +}); +``` + +- [ ] **Step 4: Запустить — должны упасть** + +Run: `npx vitest run tests/openai-request.test.ts tests/openai-timeout.test.ts tests/openai-redact.test.ts` +Expected: FAIL — `OpenAiTranscriber` не экспортируется (`is not a constructor` / import error). + +- [ ] **Step 5: Реализовать класс в `src/core/openai.ts`.** Импорты в начале файла заменить на: + +```ts +import { buildMultipart, makeBoundary } from './multipart'; +import { extFromMime } from './naming'; +import type { HttpClient, HttpResponse, TranscribeResult, Transcriber } from './ports'; +``` + +В конец файла: + +```ts +const fail = (kind: 'temporary' | 'permanent', message: string): TranscribeResult => ({ ok: false, kind, message }); + +const errorText = (e: unknown): string => (e instanceof Error ? e.message : String(e)); + +function partType(mime: string): string { + return mime.split(';')[0].trim().toLowerCase() || 'application/octet-stream'; +} + +export class OpenAiTranscriber implements Transcriber { + // settings — геттер: исправленный во вкладке ключ применяется к следующему + // запросу без пересоздания очереди. + constructor( + private readonly http: HttpClient, + private readonly settings: () => OpenAiSettings, + private readonly random: () => number, + ) {} + + // Никогда не бросает: любой исход — TranscribeResult с очищенной причиной. + async transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise { + let apiKey = ''; + let boundary: string | undefined; + let result: TranscribeResult; + try { + const s = readSettings(this.settings()); + apiKey = s.apiKey; + boundary = makeBoundary(this.random); + result = await this.request(s, audio, mime, signal, boundary); + } catch (e) { + result = fail('temporary', `внутренняя ошибка: ${errorText(e)}`); + } + return result.ok ? result : { ...result, message: redact(result.message, { apiKey, boundary }) }; + } + + private request( + s: OpenAiSettings, + audio: ArrayBuffer, + mime: string, + signal: AbortSignal, + boundary: string, + ): Promise { + if (!s.apiKey) return Promise.resolve(fail('permanent', NO_API_KEY)); + if (!s.baseUrl) return Promise.resolve(fail('permanent', NO_BASE_URL)); + if (audio.byteLength === 0) return Promise.resolve(fail('permanent', EMPTY_AUDIO)); + if (signal.aborted) return Promise.resolve(fail('temporary', CANCELLED)); + + const fields: [string, string][] = [ + ['model', s.model], + ['response_format', 'json'], + ]; + if (s.language) fields.push(['language', s.language]); + const { body, contentType } = buildMultipart( + fields, + { name: 'file', filename: `audio.${extFromMime(mime)}`, contentType: partType(mime), data: audio }, + boundary, + ); + const url = `${s.baseUrl.replace(/\/+$/, '')}/audio/transcriptions`; + // post вызывается синхронно (до того, как abort успеет сработать); + // синхронный throw клиента превращается в rejection. + let sent: Promise; + try { + sent = Promise.resolve( + this.http.post({ url, headers: { Authorization: `Bearer ${s.apiKey}` }, contentType, body }), + ); + } catch (e) { + sent = Promise.reject(e); + } + const answer = sent.then( + (res) => classify(res), + (e: unknown) => fail('temporary', `сеть: ${errorText(e)}`), + ); + return raceWithDeadline(answer, signal); + } +} + +// requestUrl нельзя оборвать: при таймауте или отмене запрос доживает в фоне, +// его исход игнорируется. answer никогда не отклоняется — unhandled rejection нет. +function raceWithDeadline(answer: Promise, signal: AbortSignal): Promise { + let timer: ReturnType | undefined; + let onAbort: (() => void) | undefined; + const deadline = new Promise((resolve) => { + timer = setTimeout(() => resolve(fail('temporary', TIMED_OUT)), REQUEST_TIMEOUT_MS); + onAbort = () => resolve(fail('temporary', CANCELLED)); + signal.addEventListener('abort', onAbort, { once: true }); + }); + return Promise.race([answer, deadline]).finally(() => { + clearTimeout(timer); + if (onAbort) signal.removeEventListener('abort', onAbort); + }); +} +``` + +- [ ] **Step 6: Запустить тесты, typecheck и страж** + +Run: `npx vitest run tests/openai-request.test.ts tests/openai-timeout.test.ts tests/openai-redact.test.ts tests/core-boundary.test.ts && npm run typecheck` +Expected: PASS, typecheck без ошибок (в `src/core` нет `Buffer`/`process`). + +- [ ] **Step 7: Полный набор и коммит** + +Run: `npm test` — Expected: всё зелёное. + +```bash +git add src/core/openai.ts tests/openai-request.test.ts tests/openai-timeout.test.ts tests/openai-redact.test.ts +git commit -m "feat(core): OpenAI-compatible transcriber with prechecks, timeout and abort + +Refs #5 + +Co-Authored-By: Claude Opus 5.5 +Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa" +``` + +--- + +### Task 4: Интеграционный тест против AITUNNEL + +**Files:** +- Test: `tests/integration/aitunnel.test.ts` + +**Interfaces:** +- Consumes: `OpenAiTranscriber`, `DEFAULT_OPENAI` (Task 2–3), `HttpClient` (Task 1), `tests/fixtures/test-01.m4a`, `tests/fixtures/test-01.txt`. +- Produces: — + +Ключ берётся только из `process.env.AITUNNEL_API_KEY`. Без переменной тест пропускается, и `npm test` остаётся зелёным. Исполнитель-сабагент ключа не имеет: он проверяет только пропуск; прогон с ключом делает человек в Task 7. + +- [ ] **Step 1: Написать тест `tests/integration/aitunnel.test.ts`:** + +```ts +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { DEFAULT_OPENAI, OpenAiTranscriber } from '../../src/core/openai'; +import type { HttpClient } from '../../src/core/ports'; + +// Ключ — только из окружения, никогда в репозитории. +const KEY = process.env.AITUNNEL_API_KEY ?? ''; + +// Под Node нет requestUrl: тот же порт на fetch. contentType уходит заголовком. +const fetchHttp: HttpClient = { + async post(req) { + const res = await fetch(req.url, { + method: 'POST', + headers: { ...req.headers, 'Content-Type': req.contentType }, + body: req.body, + }); + return { status: res.status, text: await res.text() }; + }, +}; + +// Характерные слова эталона tests/fixtures/test-01.txt. +const WORDS = ['тестовая', 'запись', 'проверки', 'распознавания', 'речи', 'whisper']; + +describe.skipIf(!KEY)('AITUNNEL gpt-4o-transcribe (live)', () => { + it('transcribes test-01.m4a', { timeout: 120_000 }, async () => { + const audio = new Uint8Array(readFileSync(new URL('../fixtures/test-01.m4a', import.meta.url))).buffer; + const reference = readFileSync(new URL('../fixtures/test-01.txt', import.meta.url), 'utf8').toLowerCase(); + for (const word of WORDS) expect(reference).toContain(word); + + const t = new OpenAiTranscriber(fetchHttp, () => ({ ...DEFAULT_OPENAI, apiKey: KEY }), Math.random); + const result = await t.transcribe(audio, 'audio/mp4', new AbortController().signal); + + expect(result).toMatchObject({ ok: true }); + const text = result.ok ? result.text.toLowerCase() : ''; + expect(text.trim()).not.toBe(''); + const hits = WORDS.filter((w) => text.includes(w)); + expect(hits.length, `распознано: ${text}`).toBeGreaterThanOrEqual(4); + }); +}); +``` + +- [ ] **Step 2: Проверить пропуск без ключа** + +Run: `env -u AITUNNEL_API_KEY npx vitest run tests/integration/aitunnel.test.ts` +Expected: `1 skipped`, exit 0. + +- [ ] **Step 3: Typecheck и полный набор** + +Run: `npm run typecheck && npm test` +Expected: без ошибок, набор зелёный (интеграционный — skipped). + +- [ ] **Step 4: Коммит** + +```bash +git add tests/integration/aitunnel.test.ts +git commit -m "test: live AITUNNEL integration test, skipped without AITUNNEL_API_KEY + +Refs #5 + +Co-Authored-By: Claude Opus 5.5 +Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa" +``` + +--- + +### Task 5: Логика команды проверки (core) + +**Files:** +- Modify: `src/core/naming.ts` (добавить `mimeFromExt` после `extFromMime`) +- Create: `src/core/provider-check.ts` +- Modify: `tests/naming.test.ts` (добавить `describe('mimeFromExt')`, импорт) +- Test: `tests/provider-check.test.ts` + +**Interfaces:** +- Consumes: `SOURCE_DIR`, `EXT` (naming.ts), `TranscribeResult`. +- Produces: + - `mimeFromExt(ext: string): string | null` + - `NO_CHECK_AUDIO: string` + - `pickCheckAudio(paths: readonly string[]): { path: string; mime: string } | null` + - `checkNotice(result: TranscribeResult): string` + +- [ ] **Step 1: Падающие тесты.** В `tests/naming.test.ts` добавить `mimeFromExt` в импорт из `'../src/core/naming'` и в конец файла: + +```ts +describe('mimeFromExt', () => { + it.each([ + ['m4a', 'audio/mp4'], + ['M4A', 'audio/mp4'], + ['webm', 'audio/webm'], + ['ogg', 'audio/ogg'], + ])('%s → %s', (ext, mime) => { + expect(mimeFromExt(ext)).toBe(mime); + }); + + it.each(['bin', 'mp3', '', 'md'])('%j → null', (ext) => { + expect(mimeFromExt(ext)).toBeNull(); + }); +}); +``` + +Создать `tests/provider-check.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { checkNotice, pickCheckAudio } from '../src/core/provider-check'; + +describe('pickCheckAudio', () => { + it('picks the last audio file by name directly in voice/source/', () => { + expect( + pickCheckAudio([ + 'voice/source/20260927-111503-0000.m4a', + 'voice/source/20260927-120000-abcd.webm', + 'voice/source/20260926-235959-ffff.ogg', + 'voice/inbox/2026-09-27 15-00-00.md', + ]), + ).toEqual({ path: 'voice/source/20260927-120000-abcd.webm', mime: 'audio/webm' }); + }); + + it('ignores .bin, unknown extensions, subfolders and look-alike folders', () => { + expect( + pickCheckAudio([ + 'voice/source/20260927-111503-0000.m4a', + 'voice/source/20260928-000000-0000.bin', + 'voice/source/20260928-000000-0001.mp3', + 'voice/source/old/20260929-000000-0000.m4a', + 'voice/sources/20260929-000000-0000.m4a', + 'voice/source/noext', + ]), + ).toEqual({ path: 'voice/source/20260927-111503-0000.m4a', mime: 'audio/mp4' }); + }); + + it('returns null when nothing fits', () => { + expect(pickCheckAudio([])).toBeNull(); + expect(pickCheckAudio(['voice/source/a.bin', 'notes/x.m4a'])).toBeNull(); + }); +}); + +describe('checkNotice', () => { + it('shows the start of the text', () => { + expect(checkNotice({ ok: true, text: ' Привет, мир ' })).toBe('Провайдер работает: «Привет, мир»'); + }); + + it('cuts long text to 100 chars with an ellipsis', () => { + expect(checkNotice({ ok: true, text: 'а'.repeat(150) })).toBe(`Провайдер работает: «${'а'.repeat(100)}…»`); + }); + + it('reports no speech for empty text', () => { + expect(checkNotice({ ok: true, text: ' ' })).toBe('Провайдер работает, но речь в записи не распознана'); + }); + + it('shows kind and reason of an error', () => { + expect(checkNotice({ ok: false, kind: 'temporary', message: 'HTTP 429' })).toBe( + 'Провайдер: временная ошибка — HTTP 429', + ); + expect(checkNotice({ ok: false, kind: 'permanent', message: 'HTTP 401: Invalid API key' })).toBe( + 'Провайдер: постоянная ошибка — HTTP 401: Invalid API key', + ); + }); +}); +``` + +- [ ] **Step 2: Запустить — должны упасть** + +Run: `npx vitest run tests/naming.test.ts tests/provider-check.test.ts` +Expected: FAIL — `mimeFromExt is not a function` / import `provider-check` не найден. + +- [ ] **Step 3: Реализовать.** В `src/core/naming.ts` после `extFromMime`: + +```ts +// Обратное к extFromMime; .bin и неизвестные расширения → null. +export function mimeFromExt(ext: string): string | null { + const lower = ext.toLowerCase(); + for (const [mime, e] of Object.entries(EXT)) if (e === lower) return mime; + return null; +} +``` + +Создать `src/core/provider-check.ts`: + +```ts +import { mimeFromExt, SOURCE_DIR } from './naming'; +import type { TranscribeResult } from './ports'; + +// Команда «Проверить провайдер распознавания»: только уведомление, +// ни заметки, ни очередь не трогаются. + +export const NO_CHECK_AUDIO = `Нет записей в ${SOURCE_DIR}/ для проверки провайдера`; + +const PREVIEW_LIMIT = 100; + +// Последний по имени файл прямо в voice/source/: имена начинаются с UTC-времени, +// значит это самая свежая запись. +export function pickCheckAudio(paths: readonly string[]): { path: string; mime: string } | null { + const prefix = `${SOURCE_DIR}/`; + let best: { path: string; mime: string } | null = null; + for (const path of paths) { + if (!path.startsWith(prefix)) continue; + const name = path.slice(prefix.length); + const dot = name.lastIndexOf('.'); + if (name.includes('/') || dot < 0) continue; + const mime = mimeFromExt(name.slice(dot + 1)); + if (mime && (!best || path > best.path)) best = { path, mime }; + } + return best; +} + +export function checkNotice(result: TranscribeResult): string { + if (result.ok) { + const text = result.text.trim(); + if (!text) return 'Провайдер работает, но речь в записи не распознана'; + const preview = text.length > PREVIEW_LIMIT ? `${text.slice(0, PREVIEW_LIMIT)}…` : text; + return `Провайдер работает: «${preview}»`; + } + const kind = result.kind === 'temporary' ? 'временная' : 'постоянная'; + return `Провайдер: ${kind} ошибка — ${result.message}`; +} +``` + +- [ ] **Step 4: Запустить тесты, typecheck и страж** + +Run: `npx vitest run tests/naming.test.ts tests/provider-check.test.ts tests/core-boundary.test.ts && npm run typecheck` +Expected: PASS, typecheck без ошибок. + +- [ ] **Step 5: Полный набор и коммит** + +Run: `npm test` — Expected: всё зелёное. + +```bash +git add src/core/naming.ts src/core/provider-check.ts tests/naming.test.ts tests/provider-check.test.ts +git commit -m "feat(core): provider check helpers — pick latest audio, format notice + +Refs #5 + +Co-Authored-By: Claude Opus 5.5 +Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa" +``` + +--- + +### Task 6: Обёртка Obsidian — адаптер `requestUrl`, вкладка настроек, команда + +**Files:** +- Create: `src/obsidian/http.ts`, `src/obsidian/settings.ts` +- Modify: `src/main.ts` (заменить целиком) + +**Interfaces:** +- Consumes: `HttpClient`, `HttpRequest`, `HttpResponse` (Task 1); `OpenAiSettings`, `OpenAiTranscriber`, `readSettings` (Task 2–3); `pickCheckAudio`, `checkNotice`, `NO_CHECK_AUDIO` (Task 5). +- Produces: `obsidianHttp: HttpClient`; `class VoiceNoteSettingTab`; `VoiceNotePlugin.settings: { openai: OpenAiSettings }`, `VoiceNotePlugin.saveSettings(): Promise`. + +Юнит-тестов у `src/obsidian/` нет по правилам проекта; проверка — typecheck, сборка и ручная Task 7. + +- [ ] **Step 1: Создать `src/obsidian/http.ts`:** + +```ts +import { requestUrl } from 'obsidian'; +import type { HttpClient, HttpRequest, HttpResponse } from '../core/ports'; + +// requestUrl обходит CORS на мобильном. contentType — отдельным параметром, +// иначе boundary multipart может потеряться. throw: false — любой статус это ответ. +export const obsidianHttp: HttpClient = { + async post(req: HttpRequest): Promise { + const res = await requestUrl({ + url: req.url, + method: 'POST', + headers: req.headers, + contentType: req.contentType, + body: req.body, + throw: false, + }); + let text = ''; + try { + text = res.text; + } catch { + // Тело не декодируется как текст — классификация пойдёт по статусу. + } + return { status: res.status, text }; + }, +}; +``` + +- [ ] **Step 2: Создать `src/obsidian/settings.ts`:** + +```ts +import { type App, PluginSettingTab, Setting } from 'obsidian'; +import type { OpenAiSettings } from '../core/openai'; +import type VoiceNotePlugin from '../main'; + +export class VoiceNoteSettingTab extends PluginSettingTab { + constructor( + app: App, + private readonly plugin: VoiceNotePlugin, + ) { + super(app, plugin); + } + + override display(): void { + const { containerEl } = this; + containerEl.empty(); + new Setting(containerEl).setName('Распознавание речи').setHeading(); + this.field('Адрес сервиса', 'OpenAI-совместимый base URL. По умолчанию AITUNNEL: https://api.aitunnel.ru/v1', 'baseUrl'); + this.field('Модель', 'Например, gpt-4o-transcribe', 'model'); + this.field('API-ключ', 'Хранится только в data.json плагина и синхронизируется вместе с vault', 'apiKey', true); + this.field('Язык', 'Код языка (ru, en). Пусто — модель определяет язык сама', 'language'); + } + + private field(name: string, desc: string, key: keyof OpenAiSettings, secret = false): void { + new Setting(this.containerEl) + .setName(name) + .setDesc(desc) + .addText((text) => { + if (secret) text.inputEl.type = 'password'; + text.setValue(this.plugin.settings.openai[key]).onChange(async (value) => { + this.plugin.settings.openai[key] = value; + await this.plugin.saveSettings(); + }); + }); + } +} +``` + +- [ ] **Step 3: Заменить `src/main.ts`:** + +```ts +import { Notice, Plugin } from 'obsidian'; +import { OpenAiTranscriber, readSettings, type OpenAiSettings } from './core/openai'; +import { checkNotice, NO_CHECK_AUDIO, pickCheckAudio } from './core/provider-check'; +import { obsidianHttp } from './obsidian/http'; +import { VoiceNoteSettingTab } from './obsidian/settings'; + +interface VoiceNoteData { + openai: OpenAiSettings; +} + +// Обёртка Obsidian. Очередь и запись подключит issue обёртки; здесь — настройки +// провайдера и команда его проверки. +export default class VoiceNotePlugin extends Plugin { + settings: VoiceNoteData = { openai: readSettings(undefined) }; + private readonly controller = new AbortController(); + private transcriber = new OpenAiTranscriber(obsidianHttp, () => this.settings.openai, Math.random); + + override async onload(): Promise { + await this.loadSettings(); + this.addSettingTab(new VoiceNoteSettingTab(this.app, this)); + this.addCommand({ + id: 'check-transcriber', + name: 'Проверить провайдер распознавания', + callback: () => void this.checkTranscriber(), + }); + } + + override onunload(): void { + this.controller.abort(); + } + + async loadSettings(): Promise { + const data = (await this.loadData()) as { openai?: unknown } | null; + this.settings = { openai: readSettings(data?.openai) }; + } + + async saveSettings(): Promise { + await this.saveData(this.settings); + } + + // Только уведомление: заметки, frontmatter и очередь не трогаются. + private async checkTranscriber(): Promise { + const files = this.app.vault.getFiles(); + const picked = pickCheckAudio(files.map((f) => f.path)); + const file = picked && files.find((f) => f.path === picked.path); + if (!picked || !file) { + new Notice(NO_CHECK_AUDIO); + return; + } + new Notice(`Проверка провайдера: ${picked.path}…`); + try { + const audio = await this.app.vault.readBinary(file); + const result = await this.transcriber.transcribe(audio, picked.mime, this.controller.signal); + new Notice(checkNotice(result), 10_000); + } catch (e) { + new Notice(`Не удалось прочитать ${picked.path}: ${e instanceof Error ? e.message : String(e)}`, 10_000); + } + } +} +``` + +- [ ] **Step 4: Typecheck, сборка, тесты** + +Run: `npm run typecheck && node esbuild.config.mjs production && npm test` +Expected: typecheck без ошибок; `main.js` собран; набор зелёный. (`npm run build` = typecheck + та же сборка.) Проверить, что `main.js` не закоммичен (он в `.gitignore`). + +- [ ] **Step 5: Коммит** + +```bash +git add src/obsidian/http.ts src/obsidian/settings.ts src/main.ts +git commit -m "feat(obsidian): requestUrl adapter, settings tab, provider check command + +Refs #5 + +Co-Authored-By: Claude Opus 5.5 +Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa" +``` + +--- + +### Task 7: Ручная проверка на Mac и iPhone + живой AITUNNEL (выполняет человек) + +**Files:** — (код не меняется; результаты — в описание PR) + +**Interfaces:** Consumes: всё из Task 1–6. + +Контроллер SDD эту задачу не делегирует сабагенту: он останавливается и просит человека пройти чек-лист, затем записывает ответы человека в ledger и в черновик описания PR. Ключ не вводится через `!` в сессии агента — только в собственном терминале человека, чтобы он не попал в транскрипт. + +- [ ] **Step 1: Живой тест (критерий успеха фичи).** Человек в своём терминале в worktree: + +```bash +AITUNNEL_API_KEY=<ключ> npx vitest run tests/integration/aitunnel.test.ts +``` + +Expected: `1 passed`. В PR: результат и распознанный текст (он печатается только при провале; при успехе — «passed»). + +- [ ] **Step 2: Сборка в тестовый vault.** `npm run dev` (копирует `main.js` и `manifest.json` в `voice-note-test/.obsidian/plugins/voice-note/`), остановить после первой сборки. Скопировать фикстуру в vault: `mkdir -p ".../voice-note-test/voice/source" && cp tests/fixtures/test-01.m4a ".../voice-note-test/voice/source/20260927-000000-0000.m4a"`. + +- [ ] **Step 3: Mac — вкладка настроек.** Включить плагин, открыть его настройки: четыре поля, адрес и модель заполнены умолчаниями, язык пуст. Ввести ключ — символы скрыты. Открыть `voice-note-test/.obsidian/plugins/voice-note/data.json`: `{"openai":{…,"apiKey":"<ключ>",…}}`. Убедиться, что `git status` в репозитории не показывает `data.json`. + +- [ ] **Step 4: Mac — команда через `requestUrl`.** Выполнить «Проверить провайдер распознавания»: уведомление `Провайдер работает: «Это тестовая запись…»` — значит, `contentType` с boundary дошёл через `requestUrl`. Затем испортить ключ (добавить символ) → `Провайдер: постоянная ошибка — HTTP 401: …` без ключа в тексте. Вернуть ключ. Пустой ключ → `Провайдер: постоянная ошибка — не задан API-ключ`. + +- [ ] **Step 5: iPhone.** Дождаться синхронизации iCloud (плагин, `data.json`, аудио). Включить плагин, открыть настройки — ключ на месте и скрыт. Выполнить команду: успех с текстом. Включить авиарежим, выполнить команду: `Провайдер: временная ошибка — сеть: …`. Выключить авиарежим. + +- [ ] **Step 6: Записать результаты в описание PR** — таблица: шаг, Mac, iPhone, фактический текст уведомления (без ключа). Любое расхождение с ожидаемым — находка `O-n` или `H-n` в финальном отчёте. + +--- + +## Self-Review (выполнено при написании плана) + +- **Покрытие спеки:** настройки и умолчания, `trim` четырёх полей, наложение на умолчания → Task 2 (`readSettings`), Task 3 (геттер на каждый вызов); вкладка настроек → Task 6, Task 7; формирование запроса → Task 1, Task 3; предпроверки и их порядок → Task 3; классификация (все строки таблицы, деталь, пустое тело, 200 символов) → Task 2; таймаут, инвариант, отмена, отсутствие таймеров и слушателей, поздний ответ и reject → Task 3; причина без секретов (ключ, `Bearer`, тело запроса, 300 символов) → Task 2 + Task 3; команда проверки (выбор файла, уведомления, нет файла, не трогает заметки) → Task 5 + Task 6 + Task 7; живой критерий успеха → Task 4 + Task 7. +- **Типы:** `HttpRequest.contentType`, `OpenAiSettings`, `readSettings(unknown)`, `classify(HttpResponse)`, `redact(message, { apiKey, boundary? })`, `OpenAiTranscriber(http, settings, random)`, `pickCheckAudio`, `checkNotice`, `mimeFromExt` — одинаковы во всех задачах. +- **Review Focus:** все пять строк привязаны к тестам или шагам конкретных задач. diff --git a/openspec/changes/openai-transcriber/design.md b/openspec/changes/openai-transcriber/design.md index dc52c01..6c0f9b1 100644 --- a/openspec/changes/openai-transcriber/design.md +++ b/openspec/changes/openai-transcriber/design.md @@ -103,3 +103,6 @@ new OpenAiTranscriber(http: HttpClient, settings: () => OpenAiSettings, random: 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.** -- 2.47.2 From 3f6debc18b4176d2cd409eb3c825131c6475bf1b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 28 Sep 2026 00:00:08 +0400 Subject: [PATCH 04/15] feat(core): HttpClient port and byte-exact multipart builder Refs #5 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa --- src/core/multipart.ts | 38 ++++++++++++++++++++++ src/core/ports.ts | 20 ++++++++++++ tests/fakes.ts | 41 ++++++++++++++++++++++++ tests/multipart.test.ts | 71 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 170 insertions(+) create mode 100644 src/core/multipart.ts create mode 100644 tests/multipart.test.ts diff --git a/src/core/multipart.ts b/src/core/multipart.ts new file mode 100644 index 0000000..178451e --- /dev/null +++ b/src/core/multipart.ts @@ -0,0 +1,38 @@ +// Побайтная сборка multipart/form-data: у requestUrl нет FormData. +// Только Web API (TextEncoder, Uint8Array) — src/core работает и на телефоне. + +export interface MultipartFile { + name: string; + filename: string; + contentType: string; + data: ArrayBuffer; +} + +export function makeBoundary(random: () => number): string { + let hex = ''; + for (let i = 0; i < 24; i++) hex += Math.min(15, Math.floor(random() * 16)).toString(16); + return `----voice-note-${hex}`; +} + +export function buildMultipart( + fields: readonly (readonly [string, string])[], + file: MultipartFile, + boundary: string, +): { body: ArrayBuffer; contentType: string } { + const enc = new TextEncoder(); + let head = ''; + for (const [name, value] of fields) { + head += `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`; + } + head += + `--${boundary}\r\nContent-Disposition: form-data; name="${file.name}"; filename="${file.filename}"\r\n` + + `Content-Type: ${file.contentType}\r\n\r\n`; + const start = enc.encode(head); + const data = new Uint8Array(file.data); + const end = enc.encode(`\r\n--${boundary}--\r\n`); + const out = new Uint8Array(start.length + data.length + end.length); + out.set(start, 0); + out.set(data, start.length); + out.set(end, start.length + data.length); + return { body: out.buffer, contentType: `multipart/form-data; boundary=${boundary}` }; +} diff --git a/src/core/ports.ts b/src/core/ports.ts index 478f157..f8af15c 100644 --- a/src/core/ports.ts +++ b/src/core/ports.ts @@ -36,3 +36,23 @@ export interface Env { random(): number; readonly deviceId: string; } + +export interface HttpRequest { + url: string; + headers: Record; + // Тип тела передаётся отдельно от headers: адаптер requestUrl кладёт его в + // параметр contentType, иначе boundary может потеряться. + contentType: string; + body: ArrayBuffer; +} + +export interface HttpResponse { + status: number; + text: string; +} + +// Любой HTTP-статус — ответ, не исключение. Исключение — только сетевой сбой. +// Сигнала и таймаута нет: requestUrl их не умеет, это делает провайдер в core. +export interface HttpClient { + post(req: HttpRequest): Promise; +} diff --git a/tests/fakes.ts b/tests/fakes.ts index acbed02..efed3cd 100644 --- a/tests/fakes.ts +++ b/tests/fakes.ts @@ -1,6 +1,9 @@ import { readVoiceFields } from '../src/core/note'; import type { Env, + HttpClient, + HttpRequest, + HttpResponse, ListedNote, NoteStore, RecordingStore, @@ -88,3 +91,41 @@ export class TestEnv implements Env { this.time = new Date(this.time.getTime() + ms); } } + +export class FakeHttp implements HttpClient { + requests: HttpRequest[] = []; + + constructor( + public reply: (req: HttpRequest) => HttpResponse | Promise = () => ({ + status: 200, + text: '{"text":"ok"}', + }), + ) {} + + // Не async: если reply бросает синхронно, post тоже бросает синхронно. + post(req: HttpRequest): Promise { + this.requests.push(req); + return Promise.resolve(this.reply(req)); + } +} + +// Разбор multipart для проверок: обычное поле → значение, файл → "filename|Content-Type". +export function formParts(req: HttpRequest): Record { + const match = /boundary=(.+)$/.exec(req.contentType); + if (!match) throw new Error(`no boundary in ${req.contentType}`); + const text = new TextDecoder().decode(req.body); + const parts: Record = {}; + for (const part of text.split(`--${match[1]}`)) { + const m = /name="([^"]+)"(?:; filename="([^"]+)")?\r\n(?:Content-Type: ([^\r]+)\r\n)?\r\n([\s\S]*)\r\n$/.exec(part); + if (m) parts[m[1]] = m[2] ? `${m[2]}|${m[3]}` : m[4]; + } + return parts; +} + +export function bytesIndexOf(hay: Uint8Array, needle: Uint8Array): number { + outer: for (let i = 0; i + needle.length <= hay.length; i++) { + for (let j = 0; j < needle.length; j++) if (hay[i + j] !== needle[j]) continue outer; + return i; + } + return -1; +} diff --git a/tests/multipart.test.ts b/tests/multipart.test.ts new file mode 100644 index 0000000..9ab9609 --- /dev/null +++ b/tests/multipart.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { buildMultipart, makeBoundary } from '../src/core/multipart'; +import { bytesIndexOf } from './fakes'; + +const enc = new TextEncoder(); + +function concat(...parts: Uint8Array[]): Uint8Array { + const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); + let at = 0; + for (const p of parts) { + out.set(p, at); + at += p.length; + } + return out; +} + +describe('makeBoundary', () => { + it('builds 24 hex chars from random()', () => { + expect(makeBoundary(() => 0)).toBe(`----voice-note-${'0'.repeat(24)}`); + expect(makeBoundary(() => 0.99)).toBe(`----voice-note-${'f'.repeat(24)}`); + }); + + it('clamps random() === 1 to f', () => { + expect(makeBoundary(() => 1)).toBe(`----voice-note-${'f'.repeat(24)}`); + }); +}); + +describe('buildMultipart', () => { + // Байты, которые сломались бы при прогоне через строку: 0x00, >0x7F, CRLF, "--". + const audio = new Uint8Array([0x00, 0xff, 0x80, 0x0d, 0x0a, 0x2d, 0x2d]).buffer; + const file = { name: 'file', filename: 'audio.m4a', contentType: 'audio/mp4', data: audio }; + + it('builds exact bytes with the boundary from contentType', () => { + const { body, contentType } = buildMultipart( + [ + ['model', 'm1'], + ['response_format', 'json'], + ], + file, + 'BND', + ); + expect(contentType).toBe('multipart/form-data; boundary=BND'); + const expected = concat( + enc.encode( + '--BND\r\nContent-Disposition: form-data; name="model"\r\n\r\nm1\r\n' + + '--BND\r\nContent-Disposition: form-data; name="response_format"\r\n\r\njson\r\n' + + '--BND\r\nContent-Disposition: form-data; name="file"; filename="audio.m4a"\r\nContent-Type: audio/mp4\r\n\r\n', + ), + new Uint8Array(audio), + enc.encode('\r\n--BND--\r\n'), + ); + expect(new Uint8Array(body)).toEqual(expected); + }); + + it('keeps binary audio bytes unchanged', () => { + const { body } = buildMultipart([], file, 'BND'); + expect(bytesIndexOf(new Uint8Array(body), new Uint8Array(audio))).toBeGreaterThan(0); + }); + + it('encodes non-ASCII field values as UTF-8', () => { + const { body } = buildMultipart([['language', 'ру']], file, 'BND'); + expect(bytesIndexOf(new Uint8Array(body), enc.encode('\r\n\r\nру\r\n'))).toBeGreaterThan(0); + }); + + it('does not share memory with the input audio', () => { + const data = new Uint8Array([1, 2, 3]); + const { body } = buildMultipart([], { ...file, data: data.buffer }, 'BND'); + data[0] = 9; + expect(bytesIndexOf(new Uint8Array(body), new Uint8Array([1, 2, 3]))).toBeGreaterThan(0); + }); +}); -- 2.47.2 From bfe44fa648636d29293edd95c337e993f908576f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 28 Sep 2026 00:18:33 +0400 Subject: [PATCH 05/15] feat(core): provider settings, response classification, secret redaction Refs #5 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa --- src/core/openai.ts | 102 ++++++++++++++++++++++++++++++++++ tests/openai-classify.test.ts | 75 +++++++++++++++++++++++++ tests/openai-redact.test.ts | 40 +++++++++++++ tests/openai-settings.test.ts | 43 ++++++++++++++ 4 files changed, 260 insertions(+) create mode 100644 src/core/openai.ts create mode 100644 tests/openai-classify.test.ts create mode 100644 tests/openai-redact.test.ts create mode 100644 tests/openai-settings.test.ts diff --git a/src/core/openai.ts b/src/core/openai.ts new file mode 100644 index 0000000..404afed --- /dev/null +++ b/src/core/openai.ts @@ -0,0 +1,102 @@ +import type { HttpResponse, TranscribeResult } from './ports'; + +export interface OpenAiSettings { + baseUrl: string; + model: string; + apiKey: string; + language: string; +} + +export const DEFAULT_OPENAI: Readonly = { + baseUrl: 'https://api.aitunnel.ru/v1', + model: 'gpt-4o-transcribe', + apiKey: '', + language: '', +}; + +// MUST быть меньше SENT_STALE_MS (record.ts): иначе зависший sent +// восстановится раньше, чем провайдер сдастся по таймауту. +export const REQUEST_TIMEOUT_MS = 5 * 60_000; + +export const NO_API_KEY = 'не задан API-ключ'; +export const NO_BASE_URL = 'не задан адрес сервиса'; +export const EMPTY_AUDIO = 'пустая запись'; +export const CANCELLED = 'запрос отменён'; +export const TIMED_OUT = 'таймаут запроса (5 мин)'; + +const DETAIL_LIMIT = 200; +const REASON_LIMIT = 300; + +const FIELDS = ['baseUrl', 'model', 'apiKey', 'language'] as const; + +// Сохранённое поверх умолчаний; нестроковое поле → умолчание; всё обрезается. +export function readSettings(saved: unknown): OpenAiSettings { + const src = saved !== null && typeof saved === 'object' ? (saved as Record) : {}; + const out: OpenAiSettings = { ...DEFAULT_OPENAI }; + for (const key of FIELDS) { + const value = src[key]; + if (typeof value === 'string') out[key] = value.trim(); + } + return out; +} + +function parseJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +function textField(body: string): string | null { + const json = parseJson(body); + if (json === null || typeof json !== 'object') return null; + const text = (json as { text?: unknown }).text; + return typeof text === 'string' ? text : null; +} + +function detail(body: string): string { + const json = parseJson(body); + if (json !== null && typeof json === 'object') { + const error = (json as { error?: unknown }).error; + if (error !== null && typeof error === 'object') { + const message = (error as { message?: unknown }).message; + if (typeof message === 'string' && message.trim() !== '') return message.trim(); + } + } + return body.trim().slice(0, DETAIL_LIMIT); +} + +// Причины здесь ещё не очищены: redact применяет OpenAiTranscriber. +export function classify(res: HttpResponse): TranscribeResult { + const { status, text } = res; + if (status >= 200 && status < 300) { + const value = textField(text); + if (value !== null) return { ok: true, text: value }; + const snippet = text.trim().slice(0, DETAIL_LIMIT); + return { + ok: false, + kind: 'permanent', + message: `HTTP ${status}: ответ без поля text${snippet ? `: ${snippet}` : ''}`, + }; + } + const info = detail(text); + const message = info ? `HTTP ${status}: ${info}` : `HTTP ${status}`; + // 3xx до нас доходит только при неверном baseUrl (редиректы адаптер проходит сам). + const permanent = status >= 300 && status < 500 && status !== 408 && status !== 429; + return { ok: false, kind: permanent ? 'permanent' : 'temporary', message }; +} + +// Причина попадает во frontmatter и в заметку: ни ключа, ни тела запроса. +export function redact(message: string, secrets: { apiKey: string; boundary?: string }): string { + let out = message; + const lower = out.toLowerCase(); + const cuts = [lower.indexOf('content-disposition')]; + if (secrets.boundary) cuts.push(out.indexOf(`--${secrets.boundary}`), out.indexOf(secrets.boundary)); + const cut = Math.min(...cuts.filter((i) => i >= 0), out.length); + out = out.slice(0, cut); + if (cut < message.length) out = out.replace(/[\s:]+$/, ''); + if (secrets.apiKey) out = out.split(secrets.apiKey).join('***'); + out = out.replace(/Bearer\s+\S+/g, 'Bearer ***'); + return out.slice(0, REASON_LIMIT); +} diff --git a/tests/openai-classify.test.ts b/tests/openai-classify.test.ts new file mode 100644 index 0000000..2fda343 --- /dev/null +++ b/tests/openai-classify.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { classify } from '../src/core/openai'; + +const res = (status: number, text = '') => ({ status, text }); + +describe('classify: 2xx', () => { + it('returns the text as is', () => { + expect(classify(res(200, '{"text":"привет"}'))).toEqual({ ok: true, text: 'привет' }); + }); + + it('returns whitespace-only text unchanged (queue decides "no speech")', () => { + expect(classify(res(200, '{"text":" "}'))).toEqual({ ok: true, text: ' ' }); + }); + + it.each(['oops', '{"foo":1}', '{"text":5}', 'null', '[]', '"str"'])( + '200 with %s → permanent', + (body) => { + expect(classify(res(200, body))).toEqual({ + ok: false, + kind: 'permanent', + message: `HTTP 200: ответ без поля text: ${body}`, + }); + }, + ); + + it('cuts the body to 200 chars', () => { + const body = `${'x'.repeat(1000)}`; + expect(classify(res(200, body))).toEqual({ + ok: false, + kind: 'permanent', + message: `HTTP 200: ответ без поля text: ${body.slice(0, 200)}`, + }); + }); + + it('empty 2xx body → permanent without snippet', () => { + expect(classify(res(204, ''))).toEqual({ ok: false, kind: 'permanent', message: 'HTTP 204: ответ без поля text' }); + }); +}); + +describe('classify: errors', () => { + it.each([301, 302, 399, 400, 401, 403, 404, 413, 415, 422, 499])('%i → permanent', (status) => { + expect(classify(res(status, 'x'))).toEqual({ ok: false, kind: 'permanent', message: `HTTP ${status}: x` }); + }); + + it.each([100, 101, 408, 429, 500, 502, 503, 599, 600])('%i → temporary', (status) => { + expect(classify(res(status, 'x'))).toEqual({ ok: false, kind: 'temporary', message: `HTTP ${status}: x` }); + }); + + it('takes error.message from an OpenAI-style body', () => { + expect(classify(res(401, '{"error":{"message":"Invalid API key"}}'))).toEqual({ + ok: false, + kind: 'permanent', + message: 'HTTP 401: Invalid API key', + }); + }); + + it.each(['{"error":{"message":""}}', '{"error":"bad"}', '{"error":null}'])( + 'falls back to the body when error.message is unusable: %s', + (body) => { + expect(classify(res(400, body))).toEqual({ ok: false, kind: 'permanent', message: `HTTP 400: ${body}` }); + }, + ); + + it('cuts a plain body to 200 chars after trim', () => { + expect(classify(res(500, ` ${'y'.repeat(1000)}`))).toEqual({ + ok: false, + kind: 'temporary', + message: `HTTP 500: ${'y'.repeat(200)}`, + }); + }); + + it.each(['', ' \n '])('empty body %j → "HTTP "', (body) => { + expect(classify(res(503, body))).toEqual({ ok: false, kind: 'temporary', message: 'HTTP 503' }); + }); +}); diff --git a/tests/openai-redact.test.ts b/tests/openai-redact.test.ts new file mode 100644 index 0000000..d1a0f97 --- /dev/null +++ b/tests/openai-redact.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { redact } from '../src/core/openai'; + +const KEY = 'sk-test-123'; + +describe('redact', () => { + it('replaces every occurrence of the key', () => { + expect(redact(`a ${KEY} b ${KEY}`, { apiKey: KEY })).toBe('a *** b ***'); + }); + + it('masks any Bearer token', () => { + expect(redact('Authorization: Bearer abc.def-1', { apiKey: KEY })).toBe('Authorization: Bearer ***'); + }); + + it('leaves the message alone for an empty key', () => { + expect(redact('HTTP 401: nope', { apiKey: '' })).toBe('HTTP 401: nope'); + }); + + it('cuts at the multipart boundary and trims trailing ": "', () => { + const b = '----voice-note-0000'; + expect(redact(`HTTP 400: echo --${b}\r\nContent-Disposition: form-data`, { apiKey: KEY, boundary: b })).toBe( + 'HTTP 400: echo', + ); + expect(redact(`HTTP 400: --${b}\r\n`, { apiKey: KEY, boundary: b })).toBe('HTTP 400'); + }); + + it('cuts at Content-Disposition regardless of case', () => { + expect(redact('HTTP 400: got content-disposition: form-data; name="file"', { apiKey: KEY })).toBe('HTTP 400: got'); + }); + + it('caps the result at 300 chars', () => { + expect(redact('x'.repeat(500), { apiKey: KEY })).toHaveLength(300); + }); + + it('replaces the key before capping, so no key fragment survives the cut', () => { + const out = redact(`${'y'.repeat(295)}${KEY}`, { apiKey: KEY }); + expect(out).toBe(`${'y'.repeat(295)}***`); + expect(out).not.toContain('sk-'); + }); +}); diff --git a/tests/openai-settings.test.ts b/tests/openai-settings.test.ts new file mode 100644 index 0000000..85d6b45 --- /dev/null +++ b/tests/openai-settings.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_OPENAI, readSettings } from '../src/core/openai'; + +describe('DEFAULT_OPENAI', () => { + it('points to AITUNNEL gpt-4o-transcribe with no key and auto language', () => { + expect(DEFAULT_OPENAI).toEqual({ + baseUrl: 'https://api.aitunnel.ru/v1', + model: 'gpt-4o-transcribe', + apiKey: '', + language: '', + }); + }); +}); + +describe('readSettings', () => { + it.each([undefined, null, 'str', 42, []])('falls back to defaults for %j', (saved) => { + expect(readSettings(saved)).toEqual(DEFAULT_OPENAI); + }); + + it('overlays saved fields on defaults', () => { + expect(readSettings({ apiKey: 'sk-test-1' })).toEqual({ ...DEFAULT_OPENAI, apiKey: 'sk-test-1' }); + }); + + it('trims all four fields', () => { + expect( + readSettings({ baseUrl: ' https://x/v1 ', model: ' m \n', apiKey: ' sk-test-1 \n', language: ' ru ' }), + ).toEqual({ baseUrl: 'https://x/v1', model: 'm', apiKey: 'sk-test-1', language: 'ru' }); + }); + + it('uses the default for a non-string field', () => { + expect(readSettings({ model: 5, apiKey: null })).toEqual(DEFAULT_OPENAI); + }); + + it('keeps an explicitly blanked baseUrl blank (precheck reports it)', () => { + expect(readSettings({ baseUrl: ' ' }).baseUrl).toBe(''); + }); + + it('returns a fresh object', () => { + const s = readSettings(undefined); + s.apiKey = 'changed'; + expect(DEFAULT_OPENAI.apiKey).toBe(''); + }); +}); -- 2.47.2 From 06d3528efdba46684dbbdc52dfd09b6e5aaa142c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 28 Sep 2026 00:31:01 +0400 Subject: [PATCH 06/15] feat(core): OpenAI-compatible transcriber with prechecks, timeout and abort Refs #5 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa --- src/core/openai.ts | 94 ++++++++++++++++++++- tests/openai-redact.test.ts | 46 ++++++++++- tests/openai-request.test.ts | 154 +++++++++++++++++++++++++++++++++++ tests/openai-timeout.test.ts | 88 ++++++++++++++++++++ 4 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 tests/openai-request.test.ts create mode 100644 tests/openai-timeout.test.ts diff --git a/src/core/openai.ts b/src/core/openai.ts index 404afed..ecfd6d4 100644 --- a/src/core/openai.ts +++ b/src/core/openai.ts @@ -1,4 +1,6 @@ -import type { HttpResponse, TranscribeResult } from './ports'; +import { buildMultipart, makeBoundary } from './multipart'; +import { extFromMime } from './naming'; +import type { HttpClient, HttpResponse, TranscribeResult, Transcriber } from './ports'; export interface OpenAiSettings { baseUrl: string; @@ -100,3 +102,93 @@ export function redact(message: string, secrets: { apiKey: string; boundary?: st out = out.replace(/Bearer\s+\S+/g, 'Bearer ***'); return out.slice(0, REASON_LIMIT); } + +const fail = (kind: 'temporary' | 'permanent', message: string): TranscribeResult => ({ ok: false, kind, message }); + +const errorText = (e: unknown): string => (e instanceof Error ? e.message : String(e)); + +function partType(mime: string): string { + return mime.split(';')[0].trim().toLowerCase() || 'application/octet-stream'; +} + +export class OpenAiTranscriber implements Transcriber { + // settings — геттер: исправленный во вкладке ключ применяется к следующему + // запросу без пересоздания очереди. + constructor( + private readonly http: HttpClient, + private readonly settings: () => OpenAiSettings, + private readonly random: () => number, + ) {} + + // Никогда не бросает: любой исход — TranscribeResult с очищенной причиной. + async transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise { + let apiKey = ''; + let boundary: string | undefined; + let result: TranscribeResult; + try { + const s = readSettings(this.settings()); + apiKey = s.apiKey; + boundary = makeBoundary(this.random); + result = await this.request(s, audio, mime, signal, boundary); + } catch (e) { + result = fail('temporary', `внутренняя ошибка: ${errorText(e)}`); + } + return result.ok ? result : { ...result, message: redact(result.message, { apiKey, boundary }) }; + } + + private request( + s: OpenAiSettings, + audio: ArrayBuffer, + mime: string, + signal: AbortSignal, + boundary: string, + ): Promise { + if (!s.apiKey) return Promise.resolve(fail('permanent', NO_API_KEY)); + if (!s.baseUrl) return Promise.resolve(fail('permanent', NO_BASE_URL)); + if (audio.byteLength === 0) return Promise.resolve(fail('permanent', EMPTY_AUDIO)); + if (signal.aborted) return Promise.resolve(fail('temporary', CANCELLED)); + + const fields: [string, string][] = [ + ['model', s.model], + ['response_format', 'json'], + ]; + if (s.language) fields.push(['language', s.language]); + const { body, contentType } = buildMultipart( + fields, + { name: 'file', filename: `audio.${extFromMime(mime)}`, contentType: partType(mime), data: audio }, + boundary, + ); + const url = `${s.baseUrl.replace(/\/+$/, '')}/audio/transcriptions`; + // post вызывается синхронно (до того, как abort успеет сработать); + // синхронный throw клиента превращается в rejection. + let sent: Promise; + try { + sent = Promise.resolve( + this.http.post({ url, headers: { Authorization: `Bearer ${s.apiKey}` }, contentType, body }), + ); + } catch (e) { + sent = Promise.reject(e); + } + const answer = sent.then( + (res) => classify(res), + (e: unknown) => fail('temporary', `сеть: ${errorText(e)}`), + ); + return raceWithDeadline(answer, signal); + } +} + +// requestUrl нельзя оборвать: при таймауте или отмене запрос доживает в фоне, +// его исход игнорируется. answer никогда не отклоняется — unhandled rejection нет. +function raceWithDeadline(answer: Promise, signal: AbortSignal): Promise { + let timer: ReturnType | undefined; + let onAbort: (() => void) | undefined; + const deadline = new Promise((resolve) => { + timer = setTimeout(() => resolve(fail('temporary', TIMED_OUT)), REQUEST_TIMEOUT_MS); + onAbort = () => resolve(fail('temporary', CANCELLED)); + signal.addEventListener('abort', onAbort, { once: true }); + }); + return Promise.race([answer, deadline]).finally(() => { + clearTimeout(timer); + if (onAbort) signal.removeEventListener('abort', onAbort); + }); +} diff --git a/tests/openai-redact.test.ts b/tests/openai-redact.test.ts index d1a0f97..fb9efcf 100644 --- a/tests/openai-redact.test.ts +++ b/tests/openai-redact.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { redact } from '../src/core/openai'; +import { DEFAULT_OPENAI, OpenAiTranscriber, redact } from '../src/core/openai'; +import type { HttpRequest } from '../src/core/ports'; +import { FakeHttp } from './fakes'; const KEY = 'sk-test-123'; @@ -38,3 +40,45 @@ describe('redact', () => { expect(out).not.toContain('sk-'); }); }); + +describe('secrets never reach the reason (end to end)', () => { + const audio = () => new Uint8Array([1, 2, 3]).buffer; + const run = async (reply: ConstructorParameters[0]) => { + const http = new FakeHttp(reply); + const t = new OpenAiTranscriber(http, () => ({ ...DEFAULT_OPENAI, apiKey: KEY }), () => 0); + const result = await t.transcribe(audio(), 'audio/mp4', new AbortController().signal); + if (result.ok) throw new Error('expected an error'); + return { message: result.message, req: http.requests[0] }; + }; + const echo = (status: number) => (req: HttpRequest) => ({ status, text: new TextDecoder().decode(req.body) }); + + it('401 JSON echoing the key and Authorization header', async () => { + const body = JSON.stringify({ + error: { message: `Incorrect API key provided: ${KEY}. Authorization: Bearer ${KEY}` }, + }); + const { message } = await run(() => ({ status: 401, text: body })); + expect(message).toBe('HTTP 401: Incorrect API key provided: ***. Authorization: Bearer ***'); + }); + + it('401 plain text echoing the Authorization header', async () => { + const { message } = await run(() => ({ status: 401, text: `Authorization: Bearer ${KEY}` })); + expect(message).not.toContain(KEY); + expect(message).toBe('HTTP 401: Authorization: Bearer ***'); + }); + + it('network exception mentioning the key', async () => { + const { message } = await run(() => + Promise.reject(new Error(`connect failed for Bearer ${KEY} at https://x/?key=${KEY}`)), + ); + expect(message).not.toContain(KEY); + expect(message).toBe('сеть: connect failed for Bearer *** at https://x/?key=***'); + }); + + it.each([200, 400, 401, 500])('service echoing the request body with status %i', async (status) => { + const { message, req } = await run(echo(status)); + const boundary = /boundary=(.+)$/.exec(req.contentType)![1]; + expect(message).not.toContain(KEY); + expect(message).not.toContain(boundary); + expect(message.toLowerCase()).not.toContain('content-disposition'); + }); +}); diff --git a/tests/openai-request.test.ts b/tests/openai-request.test.ts new file mode 100644 index 0000000..c75d619 --- /dev/null +++ b/tests/openai-request.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; +import { + CANCELLED, + DEFAULT_OPENAI, + EMPTY_AUDIO, + NO_API_KEY, + NO_BASE_URL, + OpenAiTranscriber, + type OpenAiSettings, +} from '../src/core/openai'; +import { bytesIndexOf, FakeHttp, formParts } from './fakes'; + +const KEY = 'sk-test-123'; +const settings = (over: Partial = {}) => () => ({ ...DEFAULT_OPENAI, apiKey: KEY, ...over }); +const audio = () => new Uint8Array([0x00, 0xff, 0x80, 0x0d, 0x0a]).buffer; +const live = () => new AbortController().signal; + +async function send(over: Partial = {}, mime = 'audio/mp4') { + const http = new FakeHttp(); + const result = await new OpenAiTranscriber(http, settings(over), () => 0).transcribe(audio(), mime, live()); + return { http, result, req: http.requests[0] }; +} + +describe('request', () => { + it.each([ + 'https://api.aitunnel.ru/v1', + 'https://api.aitunnel.ru/v1/', + 'https://api.aitunnel.ru/v1//', + ' https://api.aitunnel.ru/v1/ ', + ])('posts to /audio/transcriptions for %j', async (baseUrl) => { + const { req } = await send({ baseUrl }); + expect(req.url).toBe('https://api.aitunnel.ru/v1/audio/transcriptions'); + }); + + it('trims key with newline and sends only Authorization in headers', async () => { + const { req } = await send({ apiKey: ` ${KEY} \n` }); + expect(req.headers).toEqual({ Authorization: `Bearer ${KEY}` }); + }); + + it('passes multipart contentType separately, boundary from random() matches the body', async () => { + const { req } = await send(); + const boundary = `----voice-note-${'0'.repeat(24)}`; + expect(req.contentType).toBe(`multipart/form-data; boundary=${boundary}`); + expect(new TextDecoder().decode(req.body).startsWith(`--${boundary}\r\n`)).toBe(true); + }); + + it('sends model, response_format=json and no language by default', async () => { + const { req } = await send(); + expect(formParts(req)).toEqual({ model: 'gpt-4o-transcribe', response_format: 'json', file: 'audio.m4a|audio/mp4' }); + }); + + it('sends trimmed model and language when set', async () => { + const { req } = await send({ model: ' whisper-1 ', language: ' ru ' }); + expect(formParts(req)).toMatchObject({ model: 'whisper-1', language: 'ru' }); + }); + + it.each([ + ['audio/mp4;codecs=mp4a.40.2', 'audio.m4a|audio/mp4'], + ['audio/webm;codecs=opus', 'audio.webm|audio/webm'], + ['audio/ogg', 'audio.ogg|audio/ogg'], + ['AUDIO/MP4', 'audio.m4a|audio/mp4'], + ['audio/x-foo', 'audio.bin|audio/x-foo'], + ['', 'audio.bin|application/octet-stream'], + ])('names the file for mime %j', async (mime, expected) => { + const { req } = await send({}, mime); + expect(formParts(req).file).toBe(expected); + }); + + it('sends audio bytes unchanged', async () => { + const { req } = await send(); + expect(bytesIndexOf(new Uint8Array(req.body), new Uint8Array(audio()))).toBeGreaterThan(0); + }); + + it('reads settings on every call (fixed key applies without restart)', async () => { + const http = new FakeHttp(); + const current: OpenAiSettings = { ...DEFAULT_OPENAI, apiKey: 'sk-test-old' }; + const t = new OpenAiTranscriber(http, () => current, () => 0); + await t.transcribe(audio(), 'audio/mp4', live()); + current.apiKey = 'sk-test-new'; + await t.transcribe(audio(), 'audio/mp4', live()); + expect(http.requests.map((r) => r.headers.Authorization)).toEqual(['Bearer sk-test-old', 'Bearer sk-test-new']); + }); + + it('returns the service text on success', async () => { + const { result } = await send(); + expect(result).toEqual({ ok: true, text: 'ok' }); + }); +}); + +describe('prechecks (no network)', () => { + const run = async (over: Partial, data: ArrayBuffer, signal: AbortSignal) => { + const http = new FakeHttp(); + const result = await new OpenAiTranscriber(http, settings(over), () => 0).transcribe(data, 'audio/mp4', signal); + expect(http.requests).toHaveLength(0); + return result; + }; + const aborted = () => { + const c = new AbortController(); + c.abort(); + return c.signal; + }; + + it.each(['', ' \n'])('empty key %j → permanent', async (apiKey) => { + expect(await run({ apiKey }, audio(), live())).toEqual({ ok: false, kind: 'permanent', message: NO_API_KEY }); + }); + + it('empty baseUrl → permanent', async () => { + expect(await run({ baseUrl: ' ' }, audio(), live())).toEqual({ ok: false, kind: 'permanent', message: NO_BASE_URL }); + }); + + it('0 bytes → permanent', async () => { + expect(await run({}, new ArrayBuffer(0), live())).toEqual({ ok: false, kind: 'permanent', message: EMPTY_AUDIO }); + }); + + it('already aborted → temporary', async () => { + expect(await run({}, audio(), aborted())).toEqual({ ok: false, kind: 'temporary', message: CANCELLED }); + }); + + it('checks in order: key, baseUrl, audio, signal', async () => { + expect(await run({ apiKey: '', baseUrl: '' }, new ArrayBuffer(0), aborted())).toMatchObject({ message: NO_API_KEY }); + expect(await run({ baseUrl: '' }, new ArrayBuffer(0), aborted())).toMatchObject({ message: NO_BASE_URL }); + expect(await run({}, new ArrayBuffer(0), aborted())).toMatchObject({ message: EMPTY_AUDIO }); + }); +}); + +describe('never throws', () => { + it('network rejection → temporary "сеть: …"', async () => { + const http = new FakeHttp(() => Promise.reject(new Error('net::ERR_INTERNET_DISCONNECTED'))); + const result = await new OpenAiTranscriber(http, settings(), () => 0).transcribe(audio(), 'audio/mp4', live()); + expect(result).toEqual({ ok: false, kind: 'temporary', message: 'сеть: net::ERR_INTERNET_DISCONNECTED' }); + }); + + it('synchronous throw from the client → temporary "сеть: …"', async () => { + const http = new FakeHttp(() => { + throw new Error('boom'); + }); + const result = await new OpenAiTranscriber(http, settings(), () => 0).transcribe(audio(), 'audio/mp4', live()); + expect(result).toEqual({ ok: false, kind: 'temporary', message: 'сеть: boom' }); + }); + + it('non-Error rejection is stringified', async () => { + const http = new FakeHttp(() => Promise.reject('offline')); + const result = await new OpenAiTranscriber(http, settings(), () => 0).transcribe(audio(), 'audio/mp4', live()); + expect(result).toEqual({ ok: false, kind: 'temporary', message: 'сеть: offline' }); + }); + + it('unexpected exception → temporary "внутренняя ошибка: …"', async () => { + const broken = () => { + throw new Error('settings exploded'); + }; + const result = await new OpenAiTranscriber(new FakeHttp(), broken, () => 0).transcribe(audio(), 'audio/mp4', live()); + expect(result).toEqual({ ok: false, kind: 'temporary', message: 'внутренняя ошибка: settings exploded' }); + }); +}); diff --git a/tests/openai-timeout.test.ts b/tests/openai-timeout.test.ts new file mode 100644 index 0000000..096bc5c --- /dev/null +++ b/tests/openai-timeout.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CANCELLED, DEFAULT_OPENAI, OpenAiTranscriber, REQUEST_TIMEOUT_MS, TIMED_OUT } from '../src/core/openai'; +import type { HttpResponse, TranscribeResult } from '../src/core/ports'; +import { SENT_STALE_MS } from '../src/core/record'; +import { FakeHttp } from './fakes'; + +const settings = () => ({ ...DEFAULT_OPENAI, apiKey: 'sk-test-123' }); +const audio = () => new Uint8Array([1, 2, 3]).buffer; +const never = () => new Promise(() => {}); + +describe('timeout invariant', () => { + it('is 5 minutes and below the stale-sent threshold', () => { + expect(REQUEST_TIMEOUT_MS).toBe(300_000); + expect(REQUEST_TIMEOUT_MS).toBeLessThan(SENT_STALE_MS); + }); +}); + +describe('timeout and abort', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('gives up after exactly REQUEST_TIMEOUT_MS and leaves no timers', async () => { + const t = new OpenAiTranscriber(new FakeHttp(never), settings, () => 0); + let done: TranscribeResult | undefined; + const p = t.transcribe(audio(), 'audio/mp4', new AbortController().signal).then((r) => (done = r)); + await vi.advanceTimersByTimeAsync(REQUEST_TIMEOUT_MS - 1); + expect(done).toBeUndefined(); + await vi.advanceTimersByTimeAsync(1); + await p; + expect(done).toEqual({ ok: false, kind: 'temporary', message: TIMED_OUT }); + expect(vi.getTimerCount()).toBe(0); + }); + + it('returns "cancelled" right away when aborted in flight', async () => { + const c = new AbortController(); + const remove = vi.spyOn(c.signal, 'removeEventListener'); + const p = new OpenAiTranscriber(new FakeHttp(never), settings, () => 0).transcribe(audio(), 'audio/mp4', c.signal); + await vi.advanceTimersByTimeAsync(1000); + c.abort(); + expect(await p).toEqual({ ok: false, kind: 'temporary', message: CANCELLED }); + expect(vi.getTimerCount()).toBe(0); + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)); + }); + + it('clears the timer and the abort listener after a normal answer', async () => { + const c = new AbortController(); + const remove = vi.spyOn(c.signal, 'removeEventListener'); + const result = await new OpenAiTranscriber(new FakeHttp(), settings, () => 0).transcribe(audio(), 'audio/mp4', c.signal); + expect(result).toEqual({ ok: true, text: 'ok' }); + expect(vi.getTimerCount()).toBe(0); + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)); + }); + + it('ignores a late answer after timeout', async () => { + let resolve!: (r: HttpResponse) => void; + const http = new FakeHttp(() => new Promise((r) => (resolve = r))); + const p = new OpenAiTranscriber(http, settings, () => 0).transcribe(audio(), 'audio/mp4', new AbortController().signal); + await vi.advanceTimersByTimeAsync(REQUEST_TIMEOUT_MS); + expect(await p).toEqual({ ok: false, kind: 'temporary', message: TIMED_OUT }); + resolve({ status: 200, text: '{"text":"late"}' }); + await vi.advanceTimersByTimeAsync(0); + expect(await p).toEqual({ ok: false, kind: 'temporary', message: TIMED_OUT }); + }); + + it('does not leak an unhandled rejection from a late failure', async () => { + const unhandled: unknown[] = []; + const onUnhandled = (e: unknown) => unhandled.push(e); + process.on('unhandledRejection', onUnhandled); + try { + let reject!: (e: unknown) => void; + const http = new FakeHttp(() => new Promise((_, r) => (reject = r))); + const c = new AbortController(); + const p = new OpenAiTranscriber(http, settings, () => 0).transcribe(audio(), 'audio/mp4', c.signal); + c.abort(); + expect(await p).toEqual({ ok: false, kind: 'temporary', message: CANCELLED }); + reject(new Error('late failure')); + vi.useRealTimers(); + await new Promise((r) => setTimeout(r, 10)); + expect(unhandled).toEqual([]); + } finally { + process.off('unhandledRejection', onUnhandled); + } + }); +}); -- 2.47.2 From b38e16007380c18dc2a7ada91bc4859a8c702818 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 28 Sep 2026 00:46:02 +0400 Subject: [PATCH 07/15] fix(core): wrap redact() in transcribe()'s try/catch so it never throws Refs #5 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa --- src/core/openai.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/core/openai.ts b/src/core/openai.ts index ecfd6d4..c93d63a 100644 --- a/src/core/openai.ts +++ b/src/core/openai.ts @@ -130,10 +130,11 @@ export class OpenAiTranscriber implements Transcriber { apiKey = s.apiKey; boundary = makeBoundary(this.random); result = await this.request(s, audio, mime, signal, boundary); + if (!result.ok) result = { ...result, message: redact(result.message, { apiKey, boundary }) }; } catch (e) { - result = fail('temporary', `внутренняя ошибка: ${errorText(e)}`); + result = fail('temporary', redact(`внутренняя ошибка: ${errorText(e)}`, { apiKey, boundary })); } - return result.ok ? result : { ...result, message: redact(result.message, { apiKey, boundary }) }; + return result; } private request( -- 2.47.2 From 812ace7f3c69e7bc95dcddde1aaaf0353aa139fc Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 28 Sep 2026 00:49:12 +0400 Subject: [PATCH 08/15] fix(core): guard the only redact() call in transcribe() so it truly cannot throw Refs #5 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa --- src/core/openai.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/core/openai.ts b/src/core/openai.ts index c93d63a..220d57f 100644 --- a/src/core/openai.ts +++ b/src/core/openai.ts @@ -130,11 +130,15 @@ export class OpenAiTranscriber implements Transcriber { apiKey = s.apiKey; boundary = makeBoundary(this.random); result = await this.request(s, audio, mime, signal, boundary); - if (!result.ok) result = { ...result, message: redact(result.message, { apiKey, boundary }) }; } catch (e) { - result = fail('temporary', redact(`внутренняя ошибка: ${errorText(e)}`, { apiKey, boundary })); + result = fail('temporary', `внутренняя ошибка: ${errorText(e)}`); + } + if (result.ok) return result; + try { + return { ...result, message: redact(result.message, { apiKey, boundary }) }; + } catch { + return { ...result, message: 'внутренняя ошибка' }; } - return result; } private request( -- 2.47.2 From 87826bc427ae975c8d748bfc5f6bd6d59f5df148 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 28 Sep 2026 00:54:20 +0400 Subject: [PATCH 09/15] test: live AITUNNEL integration test, skipped without AITUNNEL_API_KEY Refs #5 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa --- tests/integration/aitunnel.test.ts | 39 ++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/integration/aitunnel.test.ts diff --git a/tests/integration/aitunnel.test.ts b/tests/integration/aitunnel.test.ts new file mode 100644 index 0000000..1151638 --- /dev/null +++ b/tests/integration/aitunnel.test.ts @@ -0,0 +1,39 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { DEFAULT_OPENAI, OpenAiTranscriber } from '../../src/core/openai'; +import type { HttpClient } from '../../src/core/ports'; + +// Ключ — только из окружения, никогда в репозитории. +const KEY = process.env.AITUNNEL_API_KEY ?? ''; + +// Под Node нет requestUrl: тот же порт на fetch. contentType уходит заголовком. +const fetchHttp: HttpClient = { + async post(req) { + const res = await fetch(req.url, { + method: 'POST', + headers: { ...req.headers, 'Content-Type': req.contentType }, + body: req.body, + }); + return { status: res.status, text: await res.text() }; + }, +}; + +// Характерные слова эталона tests/fixtures/test-01.txt. +const WORDS = ['тестовая', 'запись', 'проверки', 'распознавания', 'речи', 'whisper']; + +describe.skipIf(!KEY)('AITUNNEL gpt-4o-transcribe (live)', () => { + it('transcribes test-01.m4a', { timeout: 120_000 }, async () => { + const audio = new Uint8Array(readFileSync(new URL('../fixtures/test-01.m4a', import.meta.url))).buffer; + const reference = readFileSync(new URL('../fixtures/test-01.txt', import.meta.url), 'utf8').toLowerCase(); + for (const word of WORDS) expect(reference).toContain(word); + + const t = new OpenAiTranscriber(fetchHttp, () => ({ ...DEFAULT_OPENAI, apiKey: KEY }), Math.random); + const result = await t.transcribe(audio, 'audio/mp4', new AbortController().signal); + + expect(result).toMatchObject({ ok: true }); + const text = result.ok ? result.text.toLowerCase() : ''; + expect(text.trim()).not.toBe(''); + const hits = WORDS.filter((w) => text.includes(w)); + expect(hits.length, `распознано: ${text}`).toBeGreaterThanOrEqual(4); + }); +}); -- 2.47.2 From 129e7c0b4039cc8a371ae5944e16303ec90852fa Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 28 Sep 2026 01:01:29 +0400 Subject: [PATCH 10/15] =?UTF-8?q?feat(core):=20provider=20check=20helpers?= =?UTF-8?q?=20=E2=80=94=20pick=20latest=20audio,=20format=20notice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #5 Co-Authored-By: Claude Haiku 4.5 Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa --- src/core/naming.ts | 7 +++++ src/core/provider-check.ts | 36 +++++++++++++++++++++++ tests/naming.test.ts | 16 +++++++++++ tests/provider-check.test.ts | 56 ++++++++++++++++++++++++++++++++++++ 4 files changed, 115 insertions(+) create mode 100644 src/core/provider-check.ts create mode 100644 tests/provider-check.test.ts diff --git a/src/core/naming.ts b/src/core/naming.ts index d7e750a..91ddefb 100644 --- a/src/core/naming.ts +++ b/src/core/naming.ts @@ -24,6 +24,13 @@ export function extFromMime(mime: string): string { return EXT[base] ?? 'bin'; } +// Обратное к extFromMime; .bin и неизвестные расширения → null. +export function mimeFromExt(ext: string): string | null { + const lower = ext.toLowerCase(); + for (const [mime, e] of Object.entries(EXT)) if (e === lower) return mime; + return null; +} + export function audioPath(id: string, mime: string): string { return `${SOURCE_DIR}/${id}.${extFromMime(mime)}`; } diff --git a/src/core/provider-check.ts b/src/core/provider-check.ts new file mode 100644 index 0000000..57344fa --- /dev/null +++ b/src/core/provider-check.ts @@ -0,0 +1,36 @@ +import { mimeFromExt, SOURCE_DIR } from './naming'; +import type { TranscribeResult } from './ports'; + +// Команда «Проверить провайдер распознавания»: только уведомление, +// ни заметки, ни очередь не трогаются. + +export const NO_CHECK_AUDIO = `Нет записей в ${SOURCE_DIR}/ для проверки провайдера`; + +const PREVIEW_LIMIT = 100; + +// Последний по имени файл прямо в voice/source/: имена начинаются с UTC-времени, +// значит это самая свежая запись. +export function pickCheckAudio(paths: readonly string[]): { path: string; mime: string } | null { + const prefix = `${SOURCE_DIR}/`; + let best: { path: string; mime: string } | null = null; + for (const path of paths) { + if (!path.startsWith(prefix)) continue; + const name = path.slice(prefix.length); + const dot = name.lastIndexOf('.'); + if (name.includes('/') || dot < 0) continue; + const mime = mimeFromExt(name.slice(dot + 1)); + if (mime && (!best || path > best.path)) best = { path, mime }; + } + return best; +} + +export function checkNotice(result: TranscribeResult): string { + if (result.ok) { + const text = result.text.trim(); + if (!text) return 'Провайдер работает, но речь в записи не распознана'; + const preview = text.length > PREVIEW_LIMIT ? `${text.slice(0, PREVIEW_LIMIT)}…` : text; + return `Провайдер работает: «${preview}»`; + } + const kind = result.kind === 'temporary' ? 'временная' : 'постоянная'; + return `Провайдер: ${kind} ошибка — ${result.message}`; +} diff --git a/tests/naming.test.ts b/tests/naming.test.ts index 8315bf7..15d97cf 100644 --- a/tests/naming.test.ts +++ b/tests/naming.test.ts @@ -5,6 +5,7 @@ import { formatDuration, formatLocalTime, makeId, + mimeFromExt, notePath, } from '../src/core/naming'; @@ -77,3 +78,18 @@ describe('formatting', () => { expect(formatLocalTime('2026-09-27T11:17:03.000Z')).toBe('14:17'); }); }); + +describe('mimeFromExt', () => { + it.each([ + ['m4a', 'audio/mp4'], + ['M4A', 'audio/mp4'], + ['webm', 'audio/webm'], + ['ogg', 'audio/ogg'], + ])('%s → %s', (ext, mime) => { + expect(mimeFromExt(ext)).toBe(mime); + }); + + it.each(['bin', 'mp3', '', 'md'])('%j → null', (ext) => { + expect(mimeFromExt(ext)).toBeNull(); + }); +}); diff --git a/tests/provider-check.test.ts b/tests/provider-check.test.ts new file mode 100644 index 0000000..3c3cb10 --- /dev/null +++ b/tests/provider-check.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { checkNotice, pickCheckAudio } from '../src/core/provider-check'; + +describe('pickCheckAudio', () => { + it('picks the last audio file by name directly in voice/source/', () => { + expect( + pickCheckAudio([ + 'voice/source/20260927-111503-0000.m4a', + 'voice/source/20260927-120000-abcd.webm', + 'voice/source/20260926-235959-ffff.ogg', + 'voice/inbox/2026-09-27 15-00-00.md', + ]), + ).toEqual({ path: 'voice/source/20260927-120000-abcd.webm', mime: 'audio/webm' }); + }); + + it('ignores .bin, unknown extensions, subfolders and look-alike folders', () => { + expect( + pickCheckAudio([ + 'voice/source/20260927-111503-0000.m4a', + 'voice/source/20260928-000000-0000.bin', + 'voice/source/20260928-000000-0001.mp3', + 'voice/source/old/20260929-000000-0000.m4a', + 'voice/sources/20260929-000000-0000.m4a', + 'voice/source/noext', + ]), + ).toEqual({ path: 'voice/source/20260927-111503-0000.m4a', mime: 'audio/mp4' }); + }); + + it('returns null when nothing fits', () => { + expect(pickCheckAudio([])).toBeNull(); + expect(pickCheckAudio(['voice/source/a.bin', 'notes/x.m4a'])).toBeNull(); + }); +}); + +describe('checkNotice', () => { + it('shows the start of the text', () => { + expect(checkNotice({ ok: true, text: ' Привет, мир ' })).toBe('Провайдер работает: «Привет, мир»'); + }); + + it('cuts long text to 100 chars with an ellipsis', () => { + expect(checkNotice({ ok: true, text: 'а'.repeat(150) })).toBe(`Провайдер работает: «${'а'.repeat(100)}…»`); + }); + + it('reports no speech for empty text', () => { + expect(checkNotice({ ok: true, text: ' ' })).toBe('Провайдер работает, но речь в записи не распознана'); + }); + + it('shows kind and reason of an error', () => { + expect(checkNotice({ ok: false, kind: 'temporary', message: 'HTTP 429' })).toBe( + 'Провайдер: временная ошибка — HTTP 429', + ); + expect(checkNotice({ ok: false, kind: 'permanent', message: 'HTTP 401: Invalid API key' })).toBe( + 'Провайдер: постоянная ошибка — HTTP 401: Invalid API key', + ); + }); +}); -- 2.47.2 From 4f6f202eda479c312dcd3d4f034253072b5c4d0b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 28 Sep 2026 01:08:50 +0400 Subject: [PATCH 11/15] feat(obsidian): requestUrl adapter, settings tab, provider check command Refs #5 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa --- src/main.ts | 61 +++++++++++++++++++++++++++++++++++++--- src/obsidian/http.ts | 24 ++++++++++++++++ src/obsidian/settings.ts | 35 +++++++++++++++++++++++ 3 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 src/obsidian/http.ts create mode 100644 src/obsidian/settings.ts diff --git a/src/main.ts b/src/main.ts index 358a51f..ebaf5cc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,59 @@ -import { Plugin } from 'obsidian'; +import { Notice, Plugin } from 'obsidian'; +import { OpenAiTranscriber, readSettings, type OpenAiSettings } from './core/openai'; +import { checkNotice, NO_CHECK_AUDIO, pickCheckAudio } from './core/provider-check'; +import { obsidianHttp } from './obsidian/http'; +import { VoiceNoteSettingTab } from './obsidian/settings'; -// Обёртка Obsidian — отдельная подсистема. Пока пустой плагин, чтобы сборка работала. -export default class VoiceNotePlugin extends Plugin { - override async onload(): Promise {} +interface VoiceNoteData { + openai: OpenAiSettings; +} + +// Обёртка Obsidian. Очередь и запись подключит issue обёртки; здесь — настройки +// провайдера и команда его проверки. +export default class VoiceNotePlugin extends Plugin { + override settings: VoiceNoteData = { openai: readSettings(undefined) }; + private readonly controller = new AbortController(); + private transcriber = new OpenAiTranscriber(obsidianHttp, () => this.settings.openai, Math.random); + + override async onload(): Promise { + await this.loadSettings(); + this.addSettingTab(new VoiceNoteSettingTab(this.app, this)); + this.addCommand({ + id: 'check-transcriber', + name: 'Проверить провайдер распознавания', + callback: () => void this.checkTranscriber(), + }); + } + + override onunload(): void { + this.controller.abort(); + } + + async loadSettings(): Promise { + const data = (await this.loadData()) as { openai?: unknown } | null; + this.settings = { openai: readSettings(data?.openai) }; + } + + async saveSettings(): Promise { + await this.saveData(this.settings); + } + + // Только уведомление: заметки, frontmatter и очередь не трогаются. + private async checkTranscriber(): Promise { + const files = this.app.vault.getFiles(); + const picked = pickCheckAudio(files.map((f) => f.path)); + const file = picked && files.find((f) => f.path === picked.path); + if (!picked || !file) { + new Notice(NO_CHECK_AUDIO); + return; + } + new Notice(`Проверка провайдера: ${picked.path}…`); + try { + const audio = await this.app.vault.readBinary(file); + const result = await this.transcriber.transcribe(audio, picked.mime, this.controller.signal); + new Notice(checkNotice(result), 10_000); + } catch (e) { + new Notice(`Не удалось прочитать ${picked.path}: ${e instanceof Error ? e.message : String(e)}`, 10_000); + } + } } diff --git a/src/obsidian/http.ts b/src/obsidian/http.ts new file mode 100644 index 0000000..3710a3d --- /dev/null +++ b/src/obsidian/http.ts @@ -0,0 +1,24 @@ +import { requestUrl } from 'obsidian'; +import type { HttpClient, HttpRequest, HttpResponse } from '../core/ports'; + +// requestUrl обходит CORS на мобильном. contentType — отдельным параметром, +// иначе boundary multipart может потеряться. throw: false — любой статус это ответ. +export const obsidianHttp: HttpClient = { + async post(req: HttpRequest): Promise { + const res = await requestUrl({ + url: req.url, + method: 'POST', + headers: req.headers, + contentType: req.contentType, + body: req.body, + throw: false, + }); + let text = ''; + try { + text = res.text; + } catch { + // Тело не декодируется как текст — классификация пойдёт по статусу. + } + return { status: res.status, text }; + }, +}; diff --git a/src/obsidian/settings.ts b/src/obsidian/settings.ts new file mode 100644 index 0000000..71c8760 --- /dev/null +++ b/src/obsidian/settings.ts @@ -0,0 +1,35 @@ +import { type App, PluginSettingTab, Setting } from 'obsidian'; +import type { OpenAiSettings } from '../core/openai'; +import type VoiceNotePlugin from '../main'; + +export class VoiceNoteSettingTab extends PluginSettingTab { + constructor( + app: App, + private readonly plugin: VoiceNotePlugin, + ) { + super(app, plugin); + } + + override display(): void { + const { containerEl } = this; + containerEl.empty(); + new Setting(containerEl).setName('Распознавание речи').setHeading(); + this.field('Адрес сервиса', 'OpenAI-совместимый base URL. По умолчанию AITUNNEL: https://api.aitunnel.ru/v1', 'baseUrl'); + this.field('Модель', 'Например, gpt-4o-transcribe', 'model'); + this.field('API-ключ', 'Хранится только в data.json плагина и синхронизируется вместе с vault', 'apiKey', true); + this.field('Язык', 'Код языка (ru, en). Пусто — модель определяет язык сама', 'language'); + } + + private field(name: string, desc: string, key: keyof OpenAiSettings, secret = false): void { + new Setting(this.containerEl) + .setName(name) + .setDesc(desc) + .addText((text) => { + if (secret) text.inputEl.type = 'password'; + text.setValue(this.plugin.settings.openai[key]).onChange(async (value) => { + this.plugin.settings.openai[key] = value; + await this.plugin.saveSettings(); + }); + }); + } +} -- 2.47.2 From 6f6df01cbdbbf2dbf43dbde9e522edeee59203dd Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 28 Sep 2026 01:11:03 +0400 Subject: [PATCH 12/15] 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 Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa --- openspec/changes/openai-transcriber/design.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openspec/changes/openai-transcriber/design.md b/openspec/changes/openai-transcriber/design.md index 6c0f9b1..05b5df6 100644 --- a/openspec/changes/openai-transcriber/design.md +++ b/openspec/changes/openai-transcriber/design.md @@ -106,3 +106,5 @@ new OpenAiTranscriber(http: HttpClient, settings: () => OpenAiSettings, random: 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.** +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`.* **Цена ошибки: ноль — чисто синтаксический модификатор без изменения поведения; без него код не компилируется, других исходов нет.** -- 2.47.2 From a948a80e7a0ea3954224d1e4cbf22473f4b40d85 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 28 Sep 2026 01:20:17 +0400 Subject: [PATCH 13/15] =?UTF-8?q?docs:=20record=20decision=20#18=20(R-3)?= =?UTF-8?q?=20=E2=80=94=20final=20review=20fix=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa --- openspec/changes/openai-transcriber/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/openai-transcriber/design.md b/openspec/changes/openai-transcriber/design.md index 05b5df6..25e711d 100644 --- a/openspec/changes/openai-transcriber/design.md +++ b/openspec/changes/openai-transcriber/design.md @@ -108,3 +108,4 @@ new OpenAiTranscriber(http: HttpClient, settings: () => OpenAiSettings, random: 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). -- 2.47.2 From ab698d00f32f0f625abd9f74fbf050d394517186 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 28 Sep 2026 01:24:58 +0400 Subject: [PATCH 14/15] fix(core): redact() Content-Disposition cut index; errorText never throws; design.md accuracy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa --- openspec/changes/openai-transcriber/design.md | 4 ++-- src/core/openai.ts | 12 +++++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/openspec/changes/openai-transcriber/design.md b/openspec/changes/openai-transcriber/design.md index 25e711d..830e548 100644 --- a/openspec/changes/openai-transcriber/design.md +++ b/openspec/changes/openai-transcriber/design.md @@ -32,7 +32,7 @@ Core (`src/core/`, issue #1) сохраняет запись и ведёт её |---|---| | `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/openai.ts` | `OpenAiSettings`, `DEFAULT_OPENAI`, `REQUEST_TIMEOUT_MS`, `readSettings` (trim всех четырёх полей + наложение на умолчания), `classify(res: HttpResponse)`, `redact(message, { apiKey, boundary })`, `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'` | @@ -65,7 +65,7 @@ new OpenAiTranscriber(http: HttpClient, settings: () => OpenAiSettings, random: Настройки передаются геттером и читаются на каждом вызове: исправленный ключ применяется без пересоздания очереди. Boundary строится из `random` — детерминированно в тестах. -Поток `transcribe(audio, mime, signal)`: чтение и `trim` настроек → предпроверки (ключ, адрес, 0 байт, отменённый сигнал) → `buildMultipart` → `Promise.race(post, таймер, abort)` → `classify` → `redact` для любой причины. В `finally` снимаются таймер и слушатель `abort`; у промиса `post` висит пустой `.catch`, чтобы поздний reject не стал необработанным. +Поток `transcribe(audio, mime, signal)`: чтение и `trim` настроек → предпроверки (ключ, адрес, 0 байт, отменённый сигнал) → `buildMultipart` → `Promise.race(post, таймер, abort)` → `classify` → `redact` для любой причины. В `finally` снимаются таймер и слушатель `abort`; у промиса `post` есть обработчик отклонения (второй аргумент `.then`), который превращает любой сетевой сбой в обычный `TranscribeResult`, поэтому необработанного отклонения не возникает. Таймер — глобальный `setTimeout`/`clearTimeout` (есть и в браузере, и в Node), в тестах — `vi.useFakeTimers()`; отдельный порт не нужен. diff --git a/src/core/openai.ts b/src/core/openai.ts index 220d57f..b6a5433 100644 --- a/src/core/openai.ts +++ b/src/core/openai.ts @@ -92,8 +92,8 @@ export function classify(res: HttpResponse): TranscribeResult { // Причина попадает во frontmatter и в заметку: ни ключа, ни тела запроса. export function redact(message: string, secrets: { apiKey: string; boundary?: string }): string { let out = message; - const lower = out.toLowerCase(); - const cuts = [lower.indexOf('content-disposition')]; + const cdMatch = /content-disposition/i.exec(out); + const cuts = [cdMatch ? cdMatch.index : -1]; if (secrets.boundary) cuts.push(out.indexOf(`--${secrets.boundary}`), out.indexOf(secrets.boundary)); const cut = Math.min(...cuts.filter((i) => i >= 0), out.length); out = out.slice(0, cut); @@ -105,7 +105,13 @@ export function redact(message: string, secrets: { apiKey: string; boundary?: st const fail = (kind: 'temporary' | 'permanent', message: string): TranscribeResult => ({ ok: false, kind, message }); -const errorText = (e: unknown): string => (e instanceof Error ? e.message : String(e)); +const errorText = (e: unknown): string => { + try { + return e instanceof Error ? e.message : String(e); + } catch { + return 'неизвестная ошибка'; + } +}; function partType(mime: string): string { return mime.split(';')[0].trim().toLowerCase() || 'application/octet-stream'; -- 2.47.2 From 17a5156cc6f4d26f5e1ff88c4a9e2d9c4d4b093b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 10 Oct 2026 18:10:07 +0400 Subject: [PATCH 15/15] =?UTF-8?q?docs(openspec):=20decision=20#19=20(R-4)?= =?UTF-8?q?=20=E2=80=94=20final=20statuses=20for=20task-review=20minors;?= =?UTF-8?q?=20fix=20#18=20wording=20(#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/openai-transcriber/design.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openspec/changes/openai-transcriber/design.md b/openspec/changes/openai-transcriber/design.md index 830e548..561dc42 100644 --- a/openspec/changes/openai-transcriber/design.md +++ b/openspec/changes/openai-transcriber/design.md @@ -108,4 +108,5 @@ new OpenAiTranscriber(http: HttpClient, settings: () => OpenAiSettings, random: 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). +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 — `deferred` (обоснование см. в отчёте финального ревью и в ledger SDD). +19. 2026-10-10 (завершение ветки, основная сессия, R-4) — Мелкие находки ревью задач T1.1, T1.2, T3.1–T3.4, T5.1, T5.2 закрыты статусом `rejected`: финальное ревью ветки заново рассмотрело каждую и признало приемлемой как есть, T3.5 исправлена в R-3. Попутно исправлена формулировка решения 18: M-3, M-4, M-5 — `deferred`, а не «отклонённые», как и записано в ledger (R-3). *У каждой находки должен быть итоговый статус. Вывод финального ревьюера (opus) — единственная независимая оценка этих находок; ни одна не затрагивает поведение на достижимых путях.* **Если какая-то из них окажется значимой (например, T1.1 — экранирование `"`/CRLF в имени части multipart при появлении недоверенного вызывающего кода), её придётся завести заново; сейчас все вызывающие места передают фиксированные имена.** -- 2.47.2