# 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:** все пять строк привязаны к тестам или шагам конкретных задач.