- 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>
117 lines
4.7 KiB
TypeScript
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,
|
|
});
|
|
}
|