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

1505 lines
71 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`** (в конец файла):
```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`; в конец файла:
```ts
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`:**
```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 <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`:**
```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(['<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):
```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<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: всё зелёное.
```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 <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`:**
```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`:**
```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'`); затем:
```ts
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`.** Импорты в начале файла заменить на:
```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<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: всё зелёное.
```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 <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`:**
```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 <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'` и в конец файла:
```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 <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`:**
```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`:**
```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<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: Коммит**
```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 <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:
```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:** все пять строк привязаны к тестам или шагам конкретных задач.