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 { 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; }); }