Files
craftvia/src/server/services/lotse/chat/transcribe.ts
T
msolarczekandClaude Opus 5 68f4eb32dc L16 Lotse-Chat für Monteure: Datenmodell, Provider mit Tool-Schleife, Vorschläge und Bestätigen über bestehende Services
Chatverlauf (LotseConversation/-Message) und Aktionskarten (LotseActionProposal) als
Mandantentabellen mit RLS; Schalter lotseChatEnabled. Tool-Use-Schleife mit Runden- und
Tokengrenze, Datenminimierung per Platzhalter, Auftragszuordnung im Sichtbarkeits-Scope,
Bestätigen/Verwerfen/Alle bestätigen mit Hash, Ablauf und Idempotenz, Transkriptions-API.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 18:57:20 +02:00

43 lines
2.5 KiB
TypeScript

import type { TranscriptionProvider } from "@/server/ai/providers";
import { getTranscriptionProvider } from "@/server/ai/transcription/openai-compatible";
import { writeAuditLog } from "@/server/audit";
import { ServiceError, type ServiceCtx } from "@/server/services/context";
import { sniffMime } from "@/server/services/field/mime";
import { assertLotseChatUsable } from "./access";
/**
* Microphone key of the Lotse chat (lane L16): audio → text via the existing transcription provider
* (L9, Whisper-compatible). The audio is NOT stored — the transcript goes back into the input field
* where the technician can edit it before sending. Recorded as AiGeneration (kind `transcription`,
* entityType `lotse_chat`) without audio or transcript content, like the L9 voice note transcription.
*/
export const CHAT_AUDIO_MAX_BYTES = 10 * 1024 * 1024;
export type ChatTranscriptionDeps = { provider: TranscriptionProvider | null };
export const defaultChatTranscriptionDeps = (): ChatTranscriptionDeps => ({ provider: getTranscriptionProvider() });
export async function transcribeChatAudio(ctx: ServiceCtx, bytes: Buffer, deps: ChatTranscriptionDeps = defaultChatTranscriptionDeps()): Promise<{ text: string }> {
await assertLotseChatUsable(ctx);
if (!deps.provider) throw new ServiceError("invalid", "transcription not configured", { reason: "not_configured" });
if (bytes.byteLength === 0 || bytes.byteLength > CHAT_AUDIO_MAX_BYTES) throw new ServiceError("invalid", "audio size", { reason: "invalid" });
const sniffed = sniffMime(bytes);
if (!sniffed || sniffed.kind !== "audio") throw new ServiceError("invalid", "not an audio file", { reason: "invalid" });
let text: string;
let meta;
try {
const res = await deps.provider.transcribe({ bytes, mimeType: sniffed.mime, language: "de" });
text = res.text.trim();
meta = res.meta;
} catch (err) {
console.error("[lotse-chat] transcription failed:", (err as Error).message);
throw new ServiceError("conflict", "transcription failed", { reason: "provider_failed" });
}
const generation = await ctx.db.aiGeneration.create({
data: { tenantId: ctx.tenantId, kind: "transcription", provider: meta.provider, model: meta.model, entityType: "lotse_chat", entityId: null, input: undefined, output: undefined, createdById: ctx.userId },
});
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "create", entity: "ai_generation", entityId: generation.id, after: { kind: "transcription", source: "lotse_chat", bytes: bytes.byteLength } });
return { text };
}