Files
craftvia/scripts/test-lotse-transcription.ts

258 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
});