Files
craftvia/src/server/services/lotse/transcription.ts
T
msolarczekandClaude Opus 5 ff5c57f276 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>
2026-09-14 17:19:38 +02:00

111 lines
5.0 KiB
TypeScript

import type { TranscriptionStatus } from "@prisma/client";
import type { TranscriptionProvider } from "@/server/ai/providers";
import { getTranscriptionProvider } from "@/server/ai/transcription/openai-compatible";
import { writeAuditLog } from "@/server/audit";
import { inTransaction, type ServiceCtx } from "@/server/services/context";
import { readDocumentBytes } from "@/server/services/documents/read";
import { isLotseEnabled } from "./settings";
export type TranscriptionDeps = {
provider: TranscriptionProvider | null;
loadBytes: (ctx: ServiceCtx, documentId: string) => Promise<{ bytes: Buffer; mimeType: string }>;
};
export const defaultTranscriptionDeps = (): TranscriptionDeps => ({
provider: getTranscriptionProvider(),
loadBytes: (ctx, documentId) => readDocumentBytes(ctx, documentId),
});
/** Separator when a transcript is appended to an existing activity note. */
export const TRANSCRIPT_SEPARATOR = "\n\n";
/**
* Transcribe one VoiceNote (job `transcription`, ARCHITEKTUR §4.4). System context: tenant from the
* job payload, no permission checks (the note was created by an authorised user).
*
* pending → done (transcript, transcriptionModel, AiGeneration; transcript appended to the linked
* ActivityNote or a new note kind `general` linked via voiceNoteId = „aus Sprachnotiz“)
* pending → disabled (no provider configured or Lotse switched off for the tenant)
* pending → failed (provider/storage error; no content in logs).
* Idempotent: notes that are no longer pending are left untouched.
*/
export async function processTranscription(ctx: ServiceCtx, voiceNoteId: string, deps: TranscriptionDeps = defaultTranscriptionDeps()): Promise<TranscriptionStatus | null> {
const voice = await ctx.db.voiceNote.findFirst({
where: { id: voiceNoteId },
select: { id: true, workOrderId: true, documentId: true, durationSeconds: true, recordedById: true, recordedAt: true, transcriptionStatus: true, activityNote: { select: { id: true, text: true } } },
});
if (!voice) return null;
if (voice.transcriptionStatus !== "pending") return voice.transcriptionStatus;
const actorId = ctx.userId || voice.recordedById || undefined;
const setStatus = async (status: TranscriptionStatus, reason: string) => {
await ctx.db.voiceNote.updateMany({ where: { id: voice.id, transcriptionStatus: "pending" }, data: { transcriptionStatus: status } });
await writeAuditLog({ tenantId: ctx.tenantId, actorId, action: "update", entity: "voice_note", entityId: voice.id, before: { transcriptionStatus: "pending" }, after: { transcriptionStatus: status, reason } });
return status;
};
if (!(await isLotseEnabled(ctx))) return setStatus("disabled", "lotse_disabled");
if (!deps.provider) return setStatus("disabled", "not_configured");
let text: string;
let meta;
let size = 0;
let mimeType = "";
try {
const file = await deps.loadBytes(ctx, voice.documentId);
size = file.bytes.byteLength;
mimeType = file.mimeType;
const res = await deps.provider.transcribe({ bytes: file.bytes, mimeType: file.mimeType, language: "de" });
text = res.text.trim();
meta = res.meta;
} catch (err) {
console.error(`[lotse] transcription of voice note ${voice.id} failed:`, (err as Error).message);
return setStatus("failed", "provider_failed");
}
return inTransaction(ctx, async (tx) => {
const res = await tx.db.voiceNote.updateMany({
where: { id: voice.id, transcriptionStatus: "pending" },
data: { transcript: text, transcriptionStatus: "done", transcriptionModel: meta.model },
});
if (res.count !== 1) return "done" as const; // processed concurrently
await tx.db.aiGeneration.create({
data: {
tenantId: tx.tenantId,
kind: "transcription",
provider: meta.provider,
model: meta.model,
entityType: "voice_note",
entityId: voice.id,
input: { documentId: voice.documentId, mimeType, size, durationSeconds: voice.durationSeconds },
output: { characters: text.length },
inputTokens: meta.inputTokens ?? null,
outputTokens: meta.outputTokens ?? null,
createdById: actorId ?? null,
},
});
let noteId: string | null = null;
if (text) {
if (voice.activityNote) {
await tx.db.activityNote.update({ where: { id: voice.activityNote.id }, data: { text: `${voice.activityNote.text}${TRANSCRIPT_SEPARATOR}${text}` } });
noteId = voice.activityNote.id;
} else {
const note = await tx.db.activityNote.create({
data: { tenantId: tx.tenantId, workOrderId: voice.workOrderId, authorId: voice.recordedById, kind: "general", text, voiceNoteId: voice.id, createdAt: voice.recordedAt },
});
noteId = note.id;
}
}
await writeAuditLog({
tenantId: tx.tenantId,
actorId,
action: "update",
entity: "voice_note",
entityId: voice.id,
before: { transcriptionStatus: "pending" },
after: { transcriptionStatus: "done", transcriptionModel: meta.model, activityNoteId: noteId },
});
return "done" as const;
});
}