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>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user