feat: OpenAI-compatible transcriber provider (#5) #6
1504
docs/superpowers/plans/2026-09-27-openai-transcriber.md
Normal file
1504
docs/superpowers/plans/2026-09-27-openai-transcriber.md
Normal file
File diff suppressed because it is too large
Load Diff
2
openspec/changes/openai-transcriber/.openspec.yaml
Normal file
2
openspec/changes/openai-transcriber/.openspec.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-27
|
||||
3
openspec/changes/openai-transcriber/README.md
Normal file
3
openspec/changes/openai-transcriber/README.md
Normal file
@ -0,0 +1,3 @@
|
||||
# openai-transcriber
|
||||
|
||||
Провайдер распознавания: OpenAI-совместимый `/v1/audio/transcriptions` (AITUNNEL, gpt-4o-transcribe) (issue #5)
|
||||
112
openspec/changes/openai-transcriber/design.md
Normal file
112
openspec/changes/openai-transcriber/design.md
Normal file
@ -0,0 +1,112 @@
|
||||
# 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(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'` |
|
||||
| `src/main.ts` | `loadData`/`saveData`, вкладка, команда «Проверить провайдер распознавания» |
|
||||
|
||||
### Порт HTTP
|
||||
|
||||
```ts
|
||||
export interface HttpRequest {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
contentType: string;
|
||||
body: ArrayBuffer;
|
||||
}
|
||||
export interface HttpResponse { status: number; text: string }
|
||||
// Любой HTTP-статус — ответ, не исключение. Исключение — только сетевой сбой.
|
||||
export interface HttpClient { post(req: HttpRequest): Promise<HttpResponse> }
|
||||
```
|
||||
|
||||
`contentType` — отдельное поле, в `headers` core его не кладёт. Адаптер `requestUrl` передаёт его в параметр `contentType` (иначе boundary может потеряться), адаптер `fetch` из интеграционного теста — в заголовок `Content-Type`.
|
||||
|
||||
### Провайдер
|
||||
|
||||
```ts
|
||||
interface OpenAiSettings { baseUrl: string; model: string; apiKey: string; language: string }
|
||||
const DEFAULT_OPENAI = { baseUrl: 'https://api.aitunnel.ru/v1', model: 'gpt-4o-transcribe', apiKey: '', language: '' };
|
||||
const REQUEST_TIMEOUT_MS = 5 * 60_000;
|
||||
new OpenAiTranscriber(http: HttpClient, settings: () => OpenAiSettings, random: () => number)
|
||||
```
|
||||
|
||||
Настройки передаются геттером и читаются на каждом вызове: исправленный ключ применяется без пересоздания очереди. Boundary строится из `random` — детерминированно в тестах.
|
||||
|
||||
Поток `transcribe(audio, mime, signal)`: чтение и `trim` настроек → предпроверки (ключ, адрес, 0 байт, отменённый сигнал) → `buildMultipart` → `Promise.race(post, таймер, abort)` → `classify` → `redact` для любой причины. В `finally` снимаются таймер и слушатель `abort`; у промиса `post` есть обработчик отклонения (второй аргумент `.then`), который превращает любой сетевой сбой в обычный `TranscribeResult`, поэтому необработанного отклонения не возникает.
|
||||
|
||||
Таймер — глобальный `setTimeout`/`clearTimeout` (есть и в браузере, и в Node), в тестах — `vi.useFakeTimers()`; отдельный порт не нужен.
|
||||
|
||||
### Хранение настроек
|
||||
|
||||
`data.json` = `{ "openai": OpenAiSettings }`, при загрузке накладывается на `DEFAULT_OPENAI`. `data.json` в `.gitignore`. Синхронизация `data.json` через iCloud на телефон — ожидаемое поведение: ключ вводится один раз.
|
||||
|
||||
### Тестирование
|
||||
|
||||
- Юнит (vitest, фейковый `HttpClient`, fake timers): `multipart.test.ts` (байты, boundary в теле и в `contentType`, бинарные байты > 0x7F, `language` отсутствует при пустой настройке), `openai-request.test.ts` (URL с `/` и без, `Authorization`, `trim`, `response_format=json`, `audio.m4a` для `audio/mp4;codecs=…`, геттер читается на каждом вызове), `openai-classify.test.ts` (строка на каждый класс таблицы спеки, предпроверки без вызова `post`), `openai-timeout.test.ts` (таймаут 5 мин, abort в полёте, `vi.getTimerCount() === 0` после исхода, поздний reject без unhandled rejection, инвариант `REQUEST_TIMEOUT_MS < SENT_STALE_MS`), `openai-redact.test.ts` (ключ в теле 401, ключ в сообщении исключения, отсутствие boundary и `Content-Disposition` в причине).
|
||||
- Страж `tests/core-boundary.test.ts` и двойной `typecheck` покрывают новые файлы core без изменений.
|
||||
- Интеграционный: `tests/integration/aitunnel.test.ts`, `describe.skipIf(!process.env.AITUNNEL_API_KEY)`, ключ только из переменной окружения, адаптер на `fetch` в самом тесте, `test-01.m4a` → `ok`, текст непустой, из 5–6 характерных слов `test-01.txt` без учёта регистра совпадают не меньше 4. Таймаут теста — 2 минуты. Входит в `npm test`, без ключа пропускается.
|
||||
- Ручная проверка (отдельный пункт плана, результаты — в описание PR): вкладка настроек и `data.json` после ввода; команда «Проверить провайдер распознавания» через `requestUrl` на Mac и на iPhone.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **`requestUrl` не проверяется автоматически.** Интеграционный тест идёт через `fetch` под Node. Путь `requestUrl` (включая передачу `contentType`) проверяется только руками. Ошибка адаптера проявится как 400/415 → постоянная ошибка, записи не теряются, «Повторить распознавание» после исправления.
|
||||
- **Брошенный запрос доживает в фоне** после таймаута или отмены (см. «Подход»).
|
||||
- **Недетерминированность модели.** Сверка интеграционного теста по ключевым словам, а не дословно; редкие расхождения в словах возможны.
|
||||
- **Ключ в `data.json` в открытом виде** и синхронизируется через iCloud — принято человеком как ожидаемое поведение.
|
||||
|
||||
## Decisions made without discussion
|
||||
|
||||
Формат: дата — решение. *Обоснование.* **Цена ошибки.** Пометка «пересмотрено» — решение скорректировано человеком в brainstorming.
|
||||
|
||||
1. 2026-09-27 — Таймаут запроса — 5 минут, константа `REQUEST_TIMEOUT_MS` в core, не настройка; инвариант «меньше 10-минутного порога зависшего `sent`» проверяется тестом. *`requestUrl` таймаута не даёт; при таймауте ≥ 10 мин восстановление зависшей отправки сработало бы раньше таймаута. 5 минут с запасом покрывают длинные диктовки.* **Если сервис легитимно отвечает дольше 5 минут на длинных записях — каждая такая запись тратит попытки и уходит в `failed`; лечится увеличением константы (до < 10 мин) или предварительной нарезкой.** (Принято человеком в brainstorming.)
|
||||
2. 2026-09-27 — Прочие 4xx (кроме 408/429) → `permanent`; 1xx и неожиданные коды → `temporary`. Пересмотрено: 3xx → `permanent` (адаптеры сами проходят редиректы, дошедший 3xx — ошибка `baseUrl`); 2xx без строкового `text` → `permanent` с первыми 200 символами тела (ошибка URL или модели). *4xx — ошибка конфигурации или запроса, повтор не поможет.* **Если какой-то сервис отдаёт 4xx на временный сбой — запись сразу в `failed`, лечится «Повторить распознавание».**
|
||||
3. 2026-09-27 — Имя файла в multipart — `audio.<ext>` через существующий `extFromMime`, `Content-Type` части — mime без параметров. *OpenAI определяет формат по расширению имени файла; iOS `audio/mp4` должен уйти как `.m4a`.* **Если совместимый сервис определяет формат иначе — 400/415, `permanent`.** (Принято человеком.)
|
||||
4. 2026-09-27 — Пустой ключ → `permanent` «не задан API-ключ» без сети. *Без ключа запрос гарантированно 401.* **Нет.** (Принято человеком.)
|
||||
5. 2026-09-27 — Пустой `baseUrl` после `trim` → `permanent` «не задан адрес сервиса» без сети; проверяется после ключа и до проверки пустого аудио. *Иначе запрос ушёл бы на относительный URL `/audio/transcriptions` и упал бы непредсказуемой ошибкой адаптера, классифицированной как `temporary` — пять бесполезных попыток.* **Лишняя строка кода и тест, если пользователь никогда не стирает адрес.**
|
||||
6. 2026-09-27 — Формат причины: `HTTP <код>: <деталь>`, деталь — `error.message` из OpenAI-формата ошибки или первые 200 символов тела; пустое тело → `HTTP <код>`; сетевое исключение — `сеть: <сообщение>`; итоговая причина обрезается до 300 символов после очистки. *Причина попадает во frontmatter и в callout заметки — она должна быть короткой и понятной; `error.message` у OpenAI-совместимых сервисов человекочитаемый.* **Длинная причина обрезана, и детали теряются везде, включая уведомление команды проверки; для полной диагностики понадобится curl.**
|
||||
7. 2026-09-27 — Очистка секретов заменяет каждое вхождение ключа на `***` и `Bearer <непробельные>` на `Bearer ***`. *Некоторые сервисы эхом возвращают ключ в тексте 401; `Bearer`-шаблон ловит ключ в изменённом виде в заголовке.* **Если сервис вернёт ключ в изменённой форме без `Bearer` (например, частично замаскированный `sk-abc…xyz`) — фрагмент ключа попадёт в заметку; полного ключа там не будет.**
|
||||
8. 2026-09-27 — Успех с пустым текстом провайдер возвращает как `ok` без изменений; «нет речи» решает очередь. *Это поведение уже закреплено в `recording-lifecycle`; дублировать его в провайдере — две точки правды.* **Нет.**
|
||||
9. 2026-09-27 — Настройки в `data.json` лежат под ключом `openai` (`{ "openai": {...} }`), а не плоско. *Обёртка добавит свои настройки (устройство, папки); вложенность не даст полям провайдера смешаться с ними.* **Одна лишняя вложенность, если других настроек так и не появится.**
|
||||
10. 2026-09-27 — Команда «Проверить провайдер распознавания» берёт последний по имени файл `voice/source/*.{m4a,webm,ogg}` (имена начинаются с UTC-времени, последний по имени = самый свежий), mime — по расширению через новую `mimeFromExt`. Файлы `.bin` не берутся. Сама команда и её граница «только Notice, без записи в заметки и очередь» приняты человеком; выбор файла — нет. *Не нужен UI выбора файла; свежая запись — самый показательный образец.* **Если последняя запись — тишина или битый файл, проверка покажет «речь не распознана» или ошибку сервиса, хотя провайдер исправен; пользователь увидит это по тексту уведомления.**
|
||||
11. 2026-09-27 — `language` тоже обрезается от пробелов (человек назвал `trim` для `apiKey` и `baseUrl`). Пересмотрено: человек на ревью спеки добавил `trim` и для `model` — обрезаются все четыре настройки. *Строка из пробелов иначе ушла бы как значение поля и дала бы 400.* **Нет.**
|
||||
12. 2026-09-27 — Интеграционный тест не сверяет текст дословно; таймаут теста vitest — 2 минуты. Число совпадений (≥ 4 из 5–6 слов) задано человеком. *Модель недетерминирована; 86 КБ аудио распознаются за секунды, 2 минуты — запас на медленную сеть.* **Редкий ложный провал при сильном расхождении модели.**
|
||||
13. 2026-09-27 (план) — `redact` дополнительно обрезает причину с первого вхождения boundary тела запроса (`--<boundary>` или сам boundary) или строки `Content-Disposition` (без учёта регистра), затем убирает хвостовые пробелы и двоеточия. *Спека запрещает любую часть тела запроса в причине; единственный реалистичный путь туда — сервис или прокси, который эхом возвращает тело в тексте ошибки. Замена ключа и `Bearer` этого не ловит.* **Если легитимный текст ошибки случайно содержит `Content-Disposition` — деталь обрезается, остаётся `HTTP <код>`; диагностика беднее, данные не теряются.** Название модели или язык в тексте ошибки («model gpt-x not found») частью тела не считаются и не вырезаются.
|
||||
14. 2026-09-27 (план) — `OpenAiTranscriber.transcribe` оборачивает всё тело в `try/catch`; непредвиденное исключение (например, бросил геттер настроек) → `temporary` «внутренняя ошибка: <сообщение>» после `redact`. Исключение HTTP-клиента, в том числе синхронное, по-прежнему `сеть: <сообщение>`. *Спека требует, чтобы провайдер никогда не бросал; очередь такое тоже поймала бы (решение 13 прошлой фичи), но контракт должен держаться сам.* **Ошибка программирования в провайдере маскируется под временную и тратит пять попыток, прежде чем запись уйдёт в `failed`.**
|
||||
15. 2026-09-27 (план) — Boundary — `----voice-note-` + 24 hex-символа из `random` (`Math.random` в плагине). *Детерминированно в тестах; вероятность совпадения с байтами аудио пренебрежимо мала при 96 битах.* **Совпадение boundary с байтами аудио испортит тело — 400, `permanent`; «Повторить распознавание» сгенерирует новый boundary.**
|
||||
16. 2026-09-27 (исполнение, контроллер SDD, R-1) — В `OpenAiTranscriber.transcribe` (Task 3) единственный вызов `redact()` вынесен из `try/catch` в отдельный, тоже защищённый `try/catch` с запасным вариантом — жёстко заданной строкой `'внутренняя ошибка'` без интерполяции — если `redact()` сам бросит исключение. Это отступление от буквального кода плана (Task 3, Step 5): план дословно ставил единственный вызов `redact()` ПОСЛЕ `try/catch`, что ревьюер финального ревью задачи 3 нашёл как реальный (Important) пробел. *Дословный код плана проходил все тесты и typecheck, но противоречил собственному Global Constraint плана «`OpenAiTranscriber.transcribe` никогда не бросает»: гипотетическое будущее изменение `classify()`/`fail()`, из-за которого `message` перестанет быть строкой, привело бы к необработанному исключению за пределами метода — ровно то, что этот метод существует, чтобы предотвратить.* **Цена ошибки: крайне маловероятный, сейчас недостижимый при текущих типах путь; если рулинг был не нужен — цена лишь две защитные строки кода без изменения поведения ни для одного существующего пути.**
|
||||
17. 2026-09-27 (исполнение, Task 6 implementer, R-2) — В `src/main.ts` поле `settings` класса `VoiceNotePlugin` объявлено с модификатором `override`, которого нет в буквальном коде плана (Task 6, Step 3). *Дословный код плана не проходил typecheck (TS4114): установленный пакет `obsidian` (1.13.1) с версии 1.13.0 объявляет базовое поле `Plugin.settings?: unknown`, а `tsconfig.json` репозитория требует `noImplicitOverride: true` — любое поле подкласса, перекрывающее поле базового класса, обязано нести `override`.* **Цена ошибки: ноль — чисто синтаксический модификатор без изменения поведения; без него код не компилируется, других исходов нет.**
|
||||
18. 2026-09-27 (исполнение, контроллер SDD, R-3) — Из находок финального ревью всей ветки (M-1..M-5, плюс дообследование отложенных T3.5) исправлены только M-1 (индекс среза `Content-Disposition` в `redact()` считался по строке в нижнем регистре, но применялся к оригиналу — некоторые Unicode-символы меняют длину при `toLowerCase()`), T3.5 (`errorText` в `transcribe()` теоретически может бросить на объекте без `toString`/`valueOf`/`Symbol.toPrimitive`) и M-2 (устаревшие сигнатуры `classify(status,text)`/`redact(message,apiKey)` и неточное описание пустого `.catch` в этом же design.md, строки 35 и 68). *Ревьюер сам предложил объединить эти три в один фикс-коммит как однострочные, нулевого риска правки без изменения поведения ни на одном достижимом пути.* **Цена ошибки: не найдено критичных/важных находок; отложенные (M-3 стилистика недостижимого catch, M-4 косметическое Notice после unload, M-5 saveSettings перезаписывает только `{openai}` — задел на будущее для issue-обёртки) переносятся в описание PR и, если актуальны, в follow-up issue при закрытии.** Статус M-3, M-4, M-5 — `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 при появлении недоверенного вызывающего кода), её придётся завести заново; сейчас все вызывающие места передают фиксированные имена.**
|
||||
35
openspec/changes/openai-transcriber/proposal.md
Normal file
35
openspec/changes/openai-transcriber/proposal.md
Normal file
@ -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` — запрос к `<baseUrl>/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` из настроек, только при отправке записи и по команде проверки.
|
||||
@ -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`, `model`, `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-запрос на `<baseUrl>/audio/transcriptions`, где у `baseUrl` отброшены завершающие `/`. Запрос MUST содержать заголовок `Authorization: Bearer <apiKey>` и тело `multipart/form-data` с полями `model`, `response_format` со значением `json` и `file`. Поле `language` MUST передаваться только если настройка не пустая. Часть `file` MUST иметь имя `audio.<ext>`, где расширение определяется по mime записи (`audio/mp4` → `m4a`, `audio/webm` → `webm`, `audio/ogg` → `ogg`, иначе `bin`), и `Content-Type`, равный mime записи без параметров. Байты аудио MUST передаваться без изменений. Тип тела `multipart/form-data; boundary=<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** уведомление сообщает, что проверять нечем, сетевого запроса нет
|
||||
38
src/core/multipart.ts
Normal file
38
src/core/multipart.ts
Normal file
@ -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}` };
|
||||
}
|
||||
@ -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)}`;
|
||||
}
|
||||
|
||||
205
src/core/openai.ts
Normal file
205
src/core/openai.ts
Normal file
@ -0,0 +1,205 @@
|
||||
import { buildMultipart, makeBoundary } from './multipart';
|
||||
import { extFromMime } from './naming';
|
||||
import type { HttpClient, HttpResponse, TranscribeResult, Transcriber } from './ports';
|
||||
|
||||
export interface OpenAiSettings {
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
apiKey: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_OPENAI: Readonly<OpenAiSettings> = {
|
||||
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<string, unknown>) : {};
|
||||
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 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);
|
||||
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);
|
||||
}
|
||||
|
||||
const fail = (kind: 'temporary' | 'permanent', message: string): TranscribeResult => ({ ok: false, kind, message });
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
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<TranscribeResult> {
|
||||
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)}`);
|
||||
}
|
||||
if (result.ok) return result;
|
||||
try {
|
||||
return { ...result, message: redact(result.message, { apiKey, boundary }) };
|
||||
} catch {
|
||||
return { ...result, message: 'внутренняя ошибка' };
|
||||
}
|
||||
}
|
||||
|
||||
private request(
|
||||
s: OpenAiSettings,
|
||||
audio: ArrayBuffer,
|
||||
mime: string,
|
||||
signal: AbortSignal,
|
||||
boundary: string,
|
||||
): Promise<TranscribeResult> {
|
||||
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<HttpResponse>;
|
||||
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<TranscribeResult>, signal: AbortSignal): Promise<TranscribeResult> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let onAbort: (() => void) | undefined;
|
||||
const deadline = new Promise<TranscribeResult>((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);
|
||||
});
|
||||
}
|
||||
@ -36,3 +36,23 @@ export interface Env {
|
||||
random(): number;
|
||||
readonly deviceId: string;
|
||||
}
|
||||
|
||||
export interface HttpRequest {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
// Тип тела передаётся отдельно от 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<HttpResponse>;
|
||||
}
|
||||
|
||||
36
src/core/provider-check.ts
Normal file
36
src/core/provider-check.ts
Normal file
@ -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}`;
|
||||
}
|
||||
61
src/main.ts
61
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<void> {}
|
||||
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<void> {
|
||||
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<void> {
|
||||
const data = (await this.loadData()) as { openai?: unknown } | null;
|
||||
this.settings = { openai: readSettings(data?.openai) };
|
||||
}
|
||||
|
||||
async saveSettings(): Promise<void> {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
|
||||
// Только уведомление: заметки, frontmatter и очередь не трогаются.
|
||||
private async checkTranscriber(): Promise<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
24
src/obsidian/http.ts
Normal file
24
src/obsidian/http.ts
Normal file
@ -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<HttpResponse> {
|
||||
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 };
|
||||
},
|
||||
};
|
||||
35
src/obsidian/settings.ts
Normal file
35
src/obsidian/settings.ts
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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<HttpResponse> = () => ({
|
||||
status: 200,
|
||||
text: '{"text":"ok"}',
|
||||
}),
|
||||
) {}
|
||||
|
||||
// Не async: если reply бросает синхронно, post тоже бросает синхронно.
|
||||
post(req: HttpRequest): Promise<HttpResponse> {
|
||||
this.requests.push(req);
|
||||
return Promise.resolve(this.reply(req));
|
||||
}
|
||||
}
|
||||
|
||||
// Разбор multipart для проверок: обычное поле → значение, файл → "filename|Content-Type".
|
||||
export function formParts(req: HttpRequest): Record<string, string> {
|
||||
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<string, string> = {};
|
||||
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;
|
||||
}
|
||||
|
||||
39
tests/integration/aitunnel.test.ts
Normal file
39
tests/integration/aitunnel.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
71
tests/multipart.test.ts
Normal file
71
tests/multipart.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
75
tests/openai-classify.test.ts
Normal file
75
tests/openai-classify.test.ts
Normal file
@ -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(['<html>oops</html>', '{"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 = `<html>${'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 <code>"', (body) => {
|
||||
expect(classify(res(503, body))).toEqual({ ok: false, kind: 'temporary', message: 'HTTP 503' });
|
||||
});
|
||||
});
|
||||
84
tests/openai-redact.test.ts
Normal file
84
tests/openai-redact.test.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
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';
|
||||
|
||||
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-');
|
||||
});
|
||||
});
|
||||
|
||||
describe('secrets never reach the reason (end to end)', () => {
|
||||
const audio = () => new Uint8Array([1, 2, 3]).buffer;
|
||||
const run = async (reply: ConstructorParameters<typeof FakeHttp>[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');
|
||||
});
|
||||
});
|
||||
154
tests/openai-request.test.ts
Normal file
154
tests/openai-request.test.ts
Normal file
@ -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<OpenAiSettings> = {}) => () => ({ ...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<OpenAiSettings> = {}, 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 <baseUrl>/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<OpenAiSettings>, 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' });
|
||||
});
|
||||
});
|
||||
43
tests/openai-settings.test.ts
Normal file
43
tests/openai-settings.test.ts
Normal file
@ -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('');
|
||||
});
|
||||
});
|
||||
88
tests/openai-timeout.test.ts
Normal file
88
tests/openai-timeout.test.ts
Normal file
@ -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<HttpResponse>(() => {});
|
||||
|
||||
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<HttpResponse>((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<HttpResponse>((_, 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
56
tests/provider-check.test.ts
Normal file
56
tests/provider-check.test.ts
Normal file
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user