L9 Lotse – KI-Assistent: Tests (Entwurf, Datenminimierung, Rechte/Mandant, Transkription, Live)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 17:19:38 +02:00
co-authored by Claude Opus 5
parent 9a3d472682
commit f42ead3ced
3 changed files with 609 additions and 0 deletions
+257
View File
@@ -0,0 +1,257 @@
// L9 Lotse – Transkription + Sprachnotiz-Funktionen.
//
// Deckt ab: Processor-Logik (Fake: done; Provider null: disabled; Fehler: failed; Lotse aus: disabled;
// idempotent), Anhängen an verknüpfte Notiz bzw. neue Notiz „aus Sprachnotiz“, AiGeneration, Mandantentrennung
// im Job, Transkript bearbeiten (Scope/Rechte), „Sprachnotiz zusammenfassen“ (Datenminimierung, Übernahme als
// Notiz, idempotent), OpenAI-kompatibler Provider gegen lokalen HTTP-Server (Multipart, Auth, Fehler,
// Größenlimit, Timeout), Factory ohne Key → null, Processor-Registrierung.
//
// Lauf: npx tsx scripts/test-lotse-transcription.ts
import "dotenv/config";
import { randomUUID } from "node:crypto";
import { createServer, type IncomingMessage } from "node:http";
import type { AddressInfo } from "node:net";
import { prisma, dbForTenant } from "../src/server/db";
import { ROLE_DEFS, type RoleKey } from "../src/server/rbac";
import { ServiceError, type ServiceCtx } from "../src/server/services/context";
import { FakeTranscriptionProvider } from "../src/server/ai/transcription/fake";
import { getTranscriptionProvider, OpenAiCompatibleTranscriptionProvider } from "../src/server/ai/transcription/openai-compatible";
import { FakeLotseProvider } from "../src/server/ai/lotse/fake";
import { processTranscription, type TranscriptionDeps } from "../src/server/services/lotse/transcription";
import { adoptVoiceSummary, summarizeVoiceNote, updateTranscript } from "../src/server/services/lotse/voice";
import { PROCESSORS } from "../src/server/jobs/processors";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
async function expectErr(fn: () => Promise<unknown>, code: ServiceError["code"], msg: string, reason?: string) {
try {
await fn();
ok(false, `${msg} — kein Fehler`);
} catch (err) {
if (!(err instanceof ServiceError)) return ok(false, `${msg} — ${(err as Error).message}`);
const r = (err.details as { reason?: string } | undefined)?.reason;
ok(err.code === code && (!reason || r === reason), `${msg} (${err.code}${r ? `/${r}` : ""})`);
}
}
async function expectThrow(fn: () => Promise<unknown>, pattern: RegExp, msg: string) {
try {
await fn();
ok(false, `${msg} — kein Fehler`);
} catch (err) {
ok(pattern.test((err as Error).message), `${msg} (${(err as Error).message})`);
}
}
const SLUGS = ["zz-lotse-tr-a", "zz-lotse-tr-b"];
const DOMAIN = "@zz-lotse-tr.test";
async function cleanup() {
const tenants = await prisma.tenant.findMany({ where: { slug: { in: SLUGS } }, select: { id: true } });
const ids = tenants.map((t) => t.id);
if (ids.length) {
const w = { where: { tenantId: { in: ids } } };
await prisma.aiGeneration.deleteMany(w);
await prisma.activityNote.deleteMany(w);
await prisma.voiceNote.deleteMany(w);
await prisma.document.deleteMany(w);
await prisma.workOrderAssignee.deleteMany(w);
await prisma.workOrder.deleteMany(w);
await prisma.customer.deleteMany(w);
await prisma.auditLog.deleteMany(w);
await prisma.tenantModule.deleteMany(w);
await prisma.tenantSettings.deleteMany(w);
await prisma.user.deleteMany(w);
await prisma.tenant.deleteMany({ where: { id: { in: ids } } });
}
await prisma.identity.deleteMany({ where: { email: { endsWith: DOMAIN } } });
}
async function mkUser(tenantId: string, key: string, name: string) {
const identity = await prisma.identity.create({ data: { email: `${key}${DOMAIN}`, passwordHash: "x" } });
return prisma.user.create({ data: { tenantId, identityId: identity.id, email: identity.email, name } });
}
const ctxOf = (tenantId: string, userId: string, role: RoleKey, drop: string[] = []): ServiceCtx => ({
db: dbForTenant(tenantId),
tenantId,
userId,
permissions: new Set(ROLE_DEFS[role].permissions.filter((p) => !drop.includes(p))),
});
const sysCtx = (tenantId: string, actorId = ""): ServiceCtx => ({ db: dbForTenant(tenantId), tenantId, userId: actorId, permissions: new Set() });
async function mkVoice(tenantId: string, workOrderId: string, userId: string) {
const doc = await prisma.document.create({
data: { tenantId, workOrderId, category: "voice_note", fileName: "sprachnotiz.webm", storageKey: `${tenantId}/voice/${randomUUID()}.webm`, mimeType: "audio/webm", fileSize: 600, checksum: "0".repeat(64), lineageId: randomUUID(), visibility: "team", uploadedById: userId },
});
return prisma.voiceNote.create({ data: { tenantId, workOrderId, documentId: doc.id, durationSeconds: 12, transcriptionStatus: "pending", recordedById: userId, recordedAt: new Date() } });
}
const readBody = (req: IncomingMessage) =>
new Promise<string>((resolve) => {
const chunks: Buffer[] = [];
req.on("data", (c: Buffer) => chunks.push(c));
req.on("end", () => resolve(Buffer.concat(chunks).toString("latin1")));
});
async function providerTests() {
let last: { auth?: string; type?: string; body: string } = { body: "" };
let mode: "ok" | "error" | "slow" = "ok";
const server = createServer(async (req, res) => {
last = { auth: req.headers.authorization, type: req.headers["content-type"], body: await readBody(req) };
if (mode === "error") {
res.writeHead(500, { "content-type": "application/json" }).end(JSON.stringify({ error: "secret transcript leak" }));
} else if (mode === "slow") {
setTimeout(() => res.writeHead(200, { "content-type": "application/json" }).end('{"text":"zu spät"}'), 800);
} else {
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ text: " Heizkörper getauscht. " }));
}
});
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1/audio/transcriptions`;
try {
const p = new OpenAiCompatibleTranscriptionProvider({ url, apiKey: "test-key", model: "whisper-test" });
const audio = Buffer.concat([Buffer.from([0x1a, 0x45, 0xdf, 0xa3]), Buffer.alloc(256, 1)]);
const out = await p.transcribe({ bytes: audio, mimeType: "audio/webm;codecs=opus", language: "de" });
ok(out.text === "Heizkörper getauscht." && out.meta.model === "whisper-test" && out.meta.provider === "openai-compatible", "OpenAI-kompatibel: Text + Modell");
ok(last.auth === "Bearer test-key" && /^multipart\/form-data; boundary=/.test(last.type ?? ""), "Multipart-POST mit Bearer-Key");
ok(/name="model"\r\n\r\nwhisper-test\r\n/.test(last.body) && /name="language"\r\n\r\nde\r\n/.test(last.body) && /name="file"; filename="voice-note\.webm"/.test(last.body), "Felder model, language=de, file (webm)");
mode = "error";
try {
await p.transcribe({ bytes: audio, mimeType: "audio/webm", language: "de" });
ok(false, "HTTP 500 → Fehler");
} catch (err) {
const msg = (err as Error).message;
ok(msg === "transcription API error 500", `HTTP 500 → Fehler ohne Antwortinhalt (${msg})`);
}
await expectThrow(() => new OpenAiCompatibleTranscriptionProvider({ url, apiKey: "k", maxBytes: 100 }).transcribe({ bytes: audio, mimeType: "audio/webm", language: "de" }), /too large/, "Größenlimit wird vor dem Senden geprüft");
await expectThrow(() => p.transcribe({ bytes: audio, mimeType: "video/mp4", language: "de" }), /unsupported audio type/, "Nicht-Audio-Typ abgelehnt");
mode = "slow";
await expectThrow(() => new OpenAiCompatibleTranscriptionProvider({ url, apiKey: "k", timeoutMs: 150 }).transcribe({ bytes: audio, mimeType: "audio/mp4", language: "de" }), /timed out/, "Timeout greift");
} finally {
server.closeAllConnections();
await new Promise<void>((r) => server.close(() => r()));
}
const saved = { key: process.env.TRANSCRIPTION_API_KEY, provider: process.env.TRANSCRIPTION_PROVIDER };
delete process.env.TRANSCRIPTION_API_KEY;
ok(getTranscriptionProvider() === null, "Factory ohne TRANSCRIPTION_API_KEY → null");
process.env.TRANSCRIPTION_API_KEY = "x";
process.env.TRANSCRIPTION_PROVIDER = "other";
ok(getTranscriptionProvider() === null, "Factory mit fremdem TRANSCRIPTION_PROVIDER → null");
delete process.env.TRANSCRIPTION_PROVIDER;
ok(getTranscriptionProvider()?.model === (process.env.TRANSCRIPTION_MODEL?.trim() || "whisper-1"), "Factory mit Key → Provider (Default-Modell whisper-1)");
if (saved.key === undefined) delete process.env.TRANSCRIPTION_API_KEY;
else process.env.TRANSCRIPTION_API_KEY = saved.key;
if (saved.provider !== undefined) process.env.TRANSCRIPTION_PROVIDER = saved.provider;
}
async function main() {
await cleanup();
await providerTests();
ok(typeof (await PROCESSORS.transcription?.()) === "function", "Processor „transcription“ registriert");
const tA = await prisma.tenant.create({ data: { name: "Lotse-TR A", slug: SLUGS[0] } });
const tB = await prisma.tenant.create({ data: { name: "Lotse-TR B", slug: SLUGS[1] } });
const tech = await mkUser(tA.id, "tech", "Max Monteur");
const outsider = await mkUser(tA.id, "outsider", "Otto Fremd");
const techB = await mkUser(tB.id, "techb", "Tom Bader");
const customer = await prisma.customer.create({ data: { tenantId: tA.id, companyName: "Kunde A GmbH", phone: "040 998877" } });
const customerB = await prisma.customer.create({ data: { tenantId: tB.id, companyName: "Kunde B" } });
const wo = await prisma.workOrder.create({ data: { tenantId: tA.id, number: "A-TR-1", customerId: customer.id, title: "Bad", status: "in_progress", assignees: { create: [{ tenantId: tA.id, userId: tech.id }] } } });
const woB = await prisma.workOrder.create({ data: { tenantId: tB.id, number: "B-TR-1", customerId: customerB.id, title: "B", status: "in_progress", assignees: { create: [{ tenantId: tB.id, userId: techB.id }] } } });
const techCtx = ctxOf(tA.id, tech.id, "technician");
const techNoField = ctxOf(tA.id, tech.id, "technician", ["field:execute"]);
const outsiderCtx = ctxOf(tA.id, outsider.id, "technician");
const ctxB = ctxOf(tB.id, techB.id, "technician");
const loadBytes: TranscriptionDeps["loadBytes"] = async () => ({ bytes: Buffer.alloc(600, 1), mimeType: "audio/webm;codecs=opus" });
// ---------- Processor ----------
const v1 = await mkVoice(tA.id, wo.id, tech.id);
const fake = new FakeTranscriptionProvider({ text: "Heizkörper im Bad getauscht." });
ok((await processTranscription(sysCtx(tA.id, tech.id), v1.id, { provider: fake, loadBytes })) === "done", "Fake-Provider → done");
const v1db = await prisma.voiceNote.findUniqueOrThrow({ where: { id: v1.id }, include: { activityNote: true } });
ok(v1db.transcript === "Heizkörper im Bad getauscht." && v1db.transcriptionModel === "fake-whisper-1", "Transkript + transcriptionModel gespeichert");
ok(fake.calls[0]?.language === "de" && fake.calls[0].mimeType.startsWith("audio/webm"), "Sprache de + MIME an Provider");
ok(v1db.activityNote?.kind === "general" && v1db.activityNote.text === "Heizkörper im Bad getauscht." && v1db.activityNote.authorId === tech.id, "Neue Notiz „aus Sprachnotiz“ (kind general, voiceNoteId)");
const g1 = await prisma.aiGeneration.findFirst({ where: { tenantId: tA.id, kind: "transcription", entityId: v1.id } });
ok(g1?.model === "fake-whisper-1" && g1.entityType === "voice_note" && !JSON.stringify(g1.output).includes("Heizkörper"), "AiGeneration transcription (ohne Transkript-Inhalt im Protokoll)");
ok((await processTranscription(sysCtx(tA.id), v1.id, { provider: fake, loadBytes })) === "done" && fake.calls.length === 1 && (await prisma.activityNote.count({ where: { voiceNoteId: v1.id } })) === 1, "Idempotent: zweiter Lauf ruft Provider nicht erneut auf");
const v2 = await mkVoice(tA.id, wo.id, tech.id);
const linked = await prisma.activityNote.create({ data: { tenantId: tA.id, workOrderId: wo.id, authorId: tech.id, kind: "problem", text: "Ventil klemmt.", voiceNoteId: v2.id } });
await processTranscription(sysCtx(tA.id), v2.id, { provider: new FakeTranscriptionProvider({ text: "Neues Ventil bestellt." }), loadBytes });
ok((await prisma.activityNote.findUniqueOrThrow({ where: { id: linked.id } })).text === "Ventil klemmt.\n\nNeues Ventil bestellt.", "Transkript an verknüpfte Notiz angehängt");
const v3 = await mkVoice(tA.id, wo.id, tech.id);
ok((await processTranscription(sysCtx(tA.id), v3.id, { provider: null, loadBytes })) === "disabled", "Provider null → disabled");
const v4 = await mkVoice(tA.id, wo.id, tech.id);
ok((await processTranscription(sysCtx(tA.id), v4.id, { provider: new FakeTranscriptionProvider({ fail: new Error("API 503") }), loadBytes })) === "failed", "Provider-Fehler → failed");
ok((await prisma.voiceNote.findUniqueOrThrow({ where: { id: v4.id } })).transcript === null && (await prisma.aiGeneration.count({ where: { entityId: v4.id } })) === 0, "failed: kein Transkript, keine AiGeneration");
const v5 = await mkVoice(tA.id, wo.id, tech.id);
ok((await processTranscription(sysCtx(tA.id), v5.id, { provider: fake, loadBytes: async () => { throw new Error("storage down"); } })) === "failed", "Speicherfehler → failed");
await prisma.tenantModule.create({ data: { tenantId: tA.id, moduleKey: "lotse", enabled: false } });
const v6 = await mkVoice(tA.id, wo.id, tech.id);
ok((await processTranscription(sysCtx(tA.id), v6.id, { provider: fake, loadBytes })) === "disabled" && fake.calls.length === 1, "Lotse für Mandant aus → disabled, keine Übertragung");
await prisma.tenantModule.deleteMany({ where: { tenantId: tA.id } });
const v7 = await mkVoice(tA.id, wo.id, tech.id);
ok((await processTranscription(sysCtx(tB.id), v7.id, { provider: fake, loadBytes })) === null, "Job mit Mandant B findet Sprachnotiz von A nicht");
ok((await prisma.voiceNote.findUniqueOrThrow({ where: { id: v7.id } })).transcriptionStatus === "pending" && fake.calls.length === 1, "Sprachnotiz von A unverändert");
const vB = await mkVoice(tB.id, woB.id, techB.id);
// ---------- Transkript bearbeiten ----------
await updateTranscript(techCtx, { voiceNoteId: v1.id, transcript: "Max Monteur hat den Heizkörper getauscht, Rückruf unter 0171 1234567." });
const v1e = await prisma.voiceNote.findUniqueOrThrow({ where: { id: v1.id }, include: { activityNote: true } });
ok(v1e.transcript?.startsWith("Max Monteur hat") === true && v1e.activityNote?.text === v1e.transcript, "Transkript korrigiert, verknüpfte Notiz synchron");
await updateTranscript(techCtx, { voiceNoteId: v2.id, transcript: "Neues Thermostatventil bestellt." });
ok((await prisma.activityNote.findUniqueOrThrow({ where: { id: linked.id } })).text === "Ventil klemmt.\n\nNeues Thermostatventil bestellt.", "Angehängtes Transkript in der Notiz ersetzt, eigener Text bleibt");
await updateTranscript(techCtx, { voiceNoteId: v3.id, transcript: "Manuell: Rohr isoliert." });
ok((await prisma.activityNote.findFirst({ where: { voiceNoteId: v3.id } }))?.text === "Manuell: Rohr isoliert.", "Manuelles Transkript (disabled) → Notiz „aus Sprachnotiz“");
await expectErr(() => updateTranscript(techCtx, { voiceNoteId: v7.id, transcript: "x" }), "conflict", "Laufende Transkription nicht bearbeitbar", "pending");
await expectErr(() => updateTranscript(outsiderCtx, { voiceNoteId: v1.id, transcript: "x" }), "not_found", "Monteur ohne Zuweisung → not_found");
await expectErr(() => updateTranscript(ctxB, { voiceNoteId: v1.id, transcript: "x" }), "not_found", "Mandant B → not_found");
await expectErr(() => updateTranscript(techNoField, { voiceNoteId: v1.id, transcript: "x" }), "forbidden", "Ohne field:execute → forbidden");
const audit = await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "voice_note", entityId: v1.id, actorId: tech.id }, orderBy: { createdAt: "desc" } });
ok(Boolean(audit?.before) && Boolean(audit?.after), "Transkriptänderung im Audit (before/after)");
// ---------- Sprachnotiz zusammenfassen ----------
const lotse = new FakeLotseProvider({ summary: "- Heizkörper getauscht" });
const sum = await summarizeVoiceNote(techCtx, v1.id, { provider: lotse });
const sentTranscript = lotse.summaryCalls[0]?.transcript ?? "";
ok(!sentTranscript.includes("Max Monteur") && !sentTranscript.includes("0171 1234567") && sentTranscript.includes("[Telefon]") && sentTranscript.includes("M. M."), `Zusammenfassung: Transkript minimiert gesendet („${sentTranscript}“)`);
const g2 = await prisma.aiGeneration.findUniqueOrThrow({ where: { id: sum.generationId } });
ok(g2.kind === "voice_summary" && g2.entityId === v1.id && sum.summary === "- Heizkörper getauscht", "AiGeneration voice_summary");
const adopted = await adoptVoiceSummary(techCtx, sum.generationId);
const again = await adoptVoiceSummary(techCtx, sum.generationId);
const adoptedNote = await prisma.activityNote.findUniqueOrThrow({ where: { id: adopted.noteId } });
ok(adoptedNote.text === "- Heizkörper getauscht" && adoptedNote.kind === "work_done" && again.noteId === adopted.noteId, "Als Notiz übernommen (idempotent)");
ok((await prisma.voiceNote.findUniqueOrThrow({ where: { id: v1.id } })).transcript === v1e.transcript, "Transkript bleibt nach Übernahme unverändert");
await expectErr(() => summarizeVoiceNote(outsiderCtx, v1.id, { provider: lotse }), "not_found", "Zusammenfassen: Monteur ohne Zuweisung → not_found");
await expectErr(() => summarizeVoiceNote(ctxB, v1.id, { provider: lotse }), "not_found", "Zusammenfassen: Mandant B → not_found");
await expectErr(() => adoptVoiceSummary(ctxB, sum.generationId), "not_found", "Übernahme: Mandant B → not_found");
await expectErr(() => summarizeVoiceNote(techCtx, v4.id, { provider: lotse }), "invalid", "Ohne Transkript → Hinweis", "no_transcript");
await expectErr(() => summarizeVoiceNote(techCtx, v1.id, { provider: null }), "invalid", "Ohne KI-Konfiguration → Hinweis", "not_configured");
await expectErr(() => summarizeVoiceNote(ctxOf(tA.id, tech.id, "technician", ["lotse:use"]), v1.id, { provider: lotse }), "forbidden", "Ohne lotse:use → forbidden");
await expectErr(() => summarizeVoiceNote(ctxB, vB.id, { provider: new FakeLotseProvider({ fail: new Error("x") }) }), "invalid", "Mandant B: eigene Notiz ohne Transkript → Hinweis (kein Fremdzugriff nötig)", "no_transcript");
}
main()
.catch((err) => {
console.error(err);
failures++;
})
.finally(async () => {
await cleanup().catch((e) => console.error("cleanup failed", e));
await prisma.$disconnect();
console.log(failures ? `\n✗ ${failures} Prüfung(en) fehlgeschlagen` : "\n✓ Alle Transkriptionsprüfungen grün");
process.exit(failures ? 1 : 0);
});