- 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>
111 lines
5.0 KiB
TypeScript
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;
|
|
});
|
|
}
|