Files
craftvia/src/server/ai/transcription/openai-compatible.ts
T
msolarczekandClaude Opus 5 ff5c57f276 L9 Lotse – KI-Assistent: Transkription, Berichtsentwurf, Vollständigkeitsprüfung, Freigabeprinzip (Services)
- OpenAI-kompatible Transkription + Processor transcription (done/failed/disabled, AiGeneration, Notiz aus Sprachnotiz)
- Claude-Lotse (strukturierte Ausgabe, Refusal/Fallback), Datenminimierung, Vorschläge in content.lotse
- Vollständigkeitsprüfung (Regeln + KI-Hinweise mit Deep-Link), Einstellungen, KI-Protokoll
- Freigabeprinzip: Submit eines Lotse-Entwurfs nur mit Prüfbestätigung (serverseitig)
- Migration lotse_address_form (TenantSettings)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 17:19:38 +02:00

117 lines
4.7 KiB
TypeScript

import type { ProviderMeta, TranscriptionProvider } from "@/server/ai/providers";
/**
* Whisper-compatible speech-to-text (Spec §15.1, ARCHITEKTUR §4.5): multipart POST
* (`file`, `model`, `language`, `response_format=json`) to `TRANSCRIPTION_API_URL`, answer `{ text }`.
* Works with OpenAI `/v1/audio/transcriptions` and self-hosted compatible servers (faster-whisper,
* whisper.cpp server, LocalAI …).
*
* Errors never contain audio or transcript content — only status/kind for the VoiceNote status.
*/
export const TRANSCRIPTION_TIMEOUT_MS = 120_000;
/** Same as the audio upload limit of storeFile (ARCHITEKTUR §4.3); Whisper itself accepts 25 MB. */
export const TRANSCRIPTION_MAX_BYTES = 20 * 1024 * 1024;
const DEFAULT_URL = "https://api.openai.com/v1/audio/transcriptions";
const DEFAULT_MODEL = "whisper-1";
const EXTENSION: Record<string, string> = {
"audio/webm": "webm",
"audio/ogg": "ogg",
"audio/mp4": "m4a",
"audio/x-m4a": "m4a",
"audio/aac": "aac",
"audio/mpeg": "mp3",
"audio/wav": "wav",
"audio/x-wav": "wav",
};
type FetchLike = (url: string, init: RequestInit) => Promise<Response>;
export class OpenAiCompatibleTranscriptionProvider implements TranscriptionProvider {
readonly name = "openai-compatible";
readonly model: string;
private readonly url: string;
private readonly apiKey: string;
private readonly fetchImpl: FetchLike;
private readonly timeoutMs: number;
private readonly maxBytes: number;
constructor(opts: { url?: string; apiKey: string; model?: string; fetchImpl?: FetchLike; timeoutMs?: number; maxBytes?: number }) {
this.url = opts.url || DEFAULT_URL;
this.apiKey = opts.apiKey;
this.model = opts.model || DEFAULT_MODEL;
this.fetchImpl = opts.fetchImpl ?? ((url, init) => fetch(url, init));
this.timeoutMs = opts.timeoutMs ?? TRANSCRIPTION_TIMEOUT_MS;
this.maxBytes = opts.maxBytes ?? TRANSCRIPTION_MAX_BYTES;
}
async transcribe(input: { bytes: Buffer; mimeType: string; language: "de" | "en" }): Promise<{ text: string; meta: ProviderMeta }> {
if (input.bytes.byteLength === 0) throw new Error("audio is empty");
if (input.bytes.byteLength > this.maxBytes) throw new Error(`audio too large (${input.bytes.byteLength} bytes, limit ${this.maxBytes})`);
const mime = input.mimeType.split(";")[0].trim().toLowerCase();
const ext = EXTENSION[mime];
if (!ext) throw new Error(`unsupported audio type ${mime}`);
const form = new FormData();
form.append("file", new Blob([new Uint8Array(input.bytes)], { type: mime }), `voice-note.${ext}`);
form.append("model", this.model);
form.append("language", input.language);
form.append("response_format", "json");
let res: Response;
try {
res = await this.fetchImpl(this.url, {
method: "POST",
headers: { Authorization: `Bearer ${this.apiKey}` },
body: form,
signal: AbortSignal.timeout(this.timeoutMs),
});
} catch (err) {
const name = (err as Error).name;
if (name === "TimeoutError" || name === "AbortError") throw new Error(`transcription timed out after ${this.timeoutMs} ms`);
throw new Error(`transcription request failed (${name})`);
}
if (!res.ok) throw new Error(`transcription API error ${res.status}`);
let body: unknown;
try {
body = await res.json();
} catch {
throw new Error("transcription API returned no JSON");
}
const text = (body as { text?: unknown })?.text;
if (typeof text !== "string") throw new Error("transcription API response without text");
return { text: text.trim(), meta: { provider: this.name, model: this.model } };
}
}
/** Effective configuration (no secrets) for the transparency page. */
export function transcriptionConfig(): { configured: boolean; provider: string; model: string; host: string | null } {
const provider = process.env.TRANSCRIPTION_PROVIDER?.trim().toLowerCase() || "openai-compatible";
const url = process.env.TRANSCRIPTION_API_URL?.trim() || DEFAULT_URL;
let host: string | null = null;
try {
host = new URL(url).host;
} catch {
host = null;
}
return {
configured: provider === "openai-compatible" && Boolean(process.env.TRANSCRIPTION_API_KEY?.trim()) && host !== null,
provider,
model: process.env.TRANSCRIPTION_MODEL?.trim() || DEFAULT_MODEL,
host,
};
}
/** Configured transcription provider or `null` (no key / other provider → VoiceNote `disabled`). */
export function getTranscriptionProvider(): TranscriptionProvider | null {
const cfg = transcriptionConfig();
if (!cfg.configured) return null;
return new OpenAiCompatibleTranscriptionProvider({
url: process.env.TRANSCRIPTION_API_URL?.trim() || DEFAULT_URL,
apiKey: process.env.TRANSCRIPTION_API_KEY!.trim(),
model: cfg.model,
});
}