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