feat: OpenAI-compatible transcriber provider (#5) #6

Merged
aep merged 15 commits from feat/5-openai-transcriber into main 2026-10-10 17:43:23 +03:00
4 changed files with 115 additions and 0 deletions
Showing only changes of commit 129e7c0b40 - Show all commits

View File

@ -24,6 +24,13 @@ export function extFromMime(mime: string): string {
return EXT[base] ?? 'bin';
}
// Обратное к extFromMime; .bin и неизвестные расширения → null.
export function mimeFromExt(ext: string): string | null {
const lower = ext.toLowerCase();
for (const [mime, e] of Object.entries(EXT)) if (e === lower) return mime;
return null;
}
export function audioPath(id: string, mime: string): string {
return `${SOURCE_DIR}/${id}.${extFromMime(mime)}`;
}

View File

@ -0,0 +1,36 @@
import { mimeFromExt, SOURCE_DIR } from './naming';
import type { TranscribeResult } from './ports';
// Команда «Проверить провайдер распознавания»: только уведомление,
// ни заметки, ни очередь не трогаются.
export const NO_CHECK_AUDIO = `Нет записей в ${SOURCE_DIR}/ для проверки провайдера`;
const PREVIEW_LIMIT = 100;
// Последний по имени файл прямо в voice/source/: имена начинаются с UTC-времени,
// значит это самая свежая запись.
export function pickCheckAudio(paths: readonly string[]): { path: string; mime: string } | null {
const prefix = `${SOURCE_DIR}/`;
let best: { path: string; mime: string } | null = null;
for (const path of paths) {
if (!path.startsWith(prefix)) continue;
const name = path.slice(prefix.length);
const dot = name.lastIndexOf('.');
if (name.includes('/') || dot < 0) continue;
const mime = mimeFromExt(name.slice(dot + 1));
if (mime && (!best || path > best.path)) best = { path, mime };
}
return best;
}
export function checkNotice(result: TranscribeResult): string {
if (result.ok) {
const text = result.text.trim();
if (!text) return 'Провайдер работает, но речь в записи не распознана';
const preview = text.length > PREVIEW_LIMIT ? `${text.slice(0, PREVIEW_LIMIT)}…` : text;
return `Провайдер работает: «${preview}»`;
}
const kind = result.kind === 'temporary' ? 'временная' : 'постоянная';
return `Провайдер: ${kind} ошибка — ${result.message}`;
}

View File

@ -5,6 +5,7 @@ import {
formatDuration,
formatLocalTime,
makeId,
mimeFromExt,
notePath,
} from '../src/core/naming';
@ -77,3 +78,18 @@ describe('formatting', () => {
expect(formatLocalTime('2026-09-27T11:17:03.000Z')).toBe('14:17');
});
});
describe('mimeFromExt', () => {
it.each([
['m4a', 'audio/mp4'],
['M4A', 'audio/mp4'],
['webm', 'audio/webm'],
['ogg', 'audio/ogg'],
])('%s → %s', (ext, mime) => {
expect(mimeFromExt(ext)).toBe(mime);
});
it.each(['bin', 'mp3', '', 'md'])('%j → null', (ext) => {
expect(mimeFromExt(ext)).toBeNull();
});
});

View File

@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { checkNotice, pickCheckAudio } from '../src/core/provider-check';
describe('pickCheckAudio', () => {
it('picks the last audio file by name directly in voice/source/', () => {
expect(
pickCheckAudio([
'voice/source/20260927-111503-0000.m4a',
'voice/source/20260927-120000-abcd.webm',
'voice/source/20260926-235959-ffff.ogg',
'voice/inbox/2026-09-27 15-00-00.md',
]),
).toEqual({ path: 'voice/source/20260927-120000-abcd.webm', mime: 'audio/webm' });
});
it('ignores .bin, unknown extensions, subfolders and look-alike folders', () => {
expect(
pickCheckAudio([
'voice/source/20260927-111503-0000.m4a',
'voice/source/20260928-000000-0000.bin',
'voice/source/20260928-000000-0001.mp3',
'voice/source/old/20260929-000000-0000.m4a',
'voice/sources/20260929-000000-0000.m4a',
'voice/source/noext',
]),
).toEqual({ path: 'voice/source/20260927-111503-0000.m4a', mime: 'audio/mp4' });
});
it('returns null when nothing fits', () => {
expect(pickCheckAudio([])).toBeNull();
expect(pickCheckAudio(['voice/source/a.bin', 'notes/x.m4a'])).toBeNull();
});
});
describe('checkNotice', () => {
it('shows the start of the text', () => {
expect(checkNotice({ ok: true, text: ' Привет, мир ' })).toBe('Провайдер работает: «Привет, мир»');
});
it('cuts long text to 100 chars with an ellipsis', () => {
expect(checkNotice({ ok: true, text: 'а'.repeat(150) })).toBe(`Провайдер работает: «${'а'.repeat(100)}…»`);
});
it('reports no speech for empty text', () => {
expect(checkNotice({ ok: true, text: ' ' })).toBe('Провайдер работает, но речь в записи не распознана');
});
it('shows kind and reason of an error', () => {
expect(checkNotice({ ok: false, kind: 'temporary', message: 'HTTP 429' })).toBe(
'Провайдер: временная ошибка — HTTP 429',
);
expect(checkNotice({ ok: false, kind: 'permanent', message: 'HTTP 401: Invalid API key' })).toBe(
'Провайдер: постоянная ошибка — HTTP 401: Invalid API key',
);
});
});