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