obsidian-voice-note/docs/superpowers/plans/2026-09-27-openai-transcriber.md
Andrey Epifancev 4059b72178 docs: implementation plan for openai-transcriber; plan-level decisions 13-15 (#5)
Refs #5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKHeGuAYh4W1jNdjMConZa
2026-09-27 23:52:39 +04:00

71 KiB
Raw Blame History

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 <baseUrl>/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 запроса: <baseUrl без завершающих '/'>/audio/transcriptions, метод POST, заголовок только Authorization: Bearer <apiKey>; тип тела — отдельным полем contentType = multipart/form-data; boundary=<boundary>, в headers его нет.
  • Поля формы по порядку: model, response_format=json, language (только если не пустой), file (filename="audio.<ext>" через 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 <noreply@anthropic.com>
    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<string, string>; contentType: string; body: ArrayBuffer }
    • interface HttpResponse { status: number; text: string }
    • interface HttpClient { post(req: HttpRequest): Promise<HttpResponse> }
    • 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<HttpResponse> }, formParts(req: HttpRequest): Record<string, string>, bytesIndexOf(hay: Uint8Array, needle: Uint8Array): number
  • Step 1: Добавить порт в src/core/ports.ts (в конец файла):

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>;
}
  • Step 2: Добавить фейк и хелперы в tests/fakes.ts. В импорт типов из '../src/core/ports' добавить HttpClient, HttpRequest, HttpResponse; в конец файла:
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;
}
  • Step 3: Написать падающий тест tests/multipart.test.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:
// Побайтная сборка 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: всё зелёное.

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 <noreply@anthropic.com>
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<OpenAiSettings>
    • 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:

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:
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' });
  });
});
  • Step 3: Падающий тест tests/openai-redact.test.ts (юнит-часть; сквозные тесты добавит Task 3):
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 (чистая часть):
import type { HttpResponse, TranscribeResult } 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 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: всё зелёное.

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 <noreply@anthropic.com>
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:

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' });
  });
});
  • Step 2: Падающий тест tests/openai-timeout.test.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<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);
    }
  });
});
  • Step 3: Дописать сквозные тесты секретов в конец tests/openai-redact.test.ts. Добавить импорты DEFAULT_OPENAI, OpenAiTranscriber (из '../src/core/openai'), type HttpRequest (из '../src/core/ports'), FakeHttp (из './fakes'); затем:
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');
  });
});
  • 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. Импорты в начале файла заменить на:
import { buildMultipart, makeBoundary } from './multipart';
import { extFromMime } from './naming';
import type { HttpClient, HttpResponse, TranscribeResult, Transcriber } from './ports';

В конец файла:

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<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)}`);
    }
    return result.ok ? result : { ...result, message: redact(result.message, { apiKey, boundary }) };
  }

  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);
  });
}
  • 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: всё зелёное.

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 <noreply@anthropic.com>
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:
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: Коммит
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 <noreply@anthropic.com>
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' и в конец файла:

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:

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:
// Обратное к 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:

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: всё зелёное.

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 <noreply@anthropic.com>
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<void>.

Юнит-тестов у src/obsidian/ нет по правилам проекта; проверка — typecheck, сборка и ручная Task 7.

  • Step 1: Создать src/obsidian/http.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<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 };
  },
};
  • Step 2: Создать src/obsidian/settings.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:
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<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);
    }
  }
}
  • 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: Коммит
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 <noreply@anthropic.com>
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:
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: все пять строк привязаны к тестам или шагам конкретных задач.