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>
43 lines
2.5 KiB
TypeScript
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 };
|
|
}
|