From f42ead3cedde3c00a7080a8bdf4c89f5e8e35277 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 17:19:38 +0200 Subject: [PATCH] =?UTF-8?q?L9=20Lotse=20=E2=80=93=20KI-Assistent:=20Tests?= =?UTF-8?q?=20(Entwurf,=20Datenminimierung,=20Rechte/Mandant,=20Transkript?= =?UTF-8?q?ion,=20Live)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- scripts/test-lotse-draft.ts | 294 ++++++++++++++++++++++++++++ scripts/test-lotse-live.ts | 58 ++++++ scripts/test-lotse-transcription.ts | 257 ++++++++++++++++++++++++ 3 files changed, 609 insertions(+) create mode 100644 scripts/test-lotse-draft.ts create mode 100644 scripts/test-lotse-live.ts create mode 100644 scripts/test-lotse-transcription.ts diff --git a/scripts/test-lotse-draft.ts b/scripts/test-lotse-draft.ts new file mode 100644 index 0000000..1ea78c7 --- /dev/null +++ b/scripts/test-lotse-draft.ts @@ -0,0 +1,294 @@ +// L9 Lotse – Berichtsentwurf, Datenminimierung, Vollständigkeitsprüfung, Freigabeprinzip, Einstellungen, Protokoll. +// +// Deckt ab: Provider-Fake (Mapping Output → Vorschläge im Report-content, missingInformation → Liste), +// Datenminimierung (KI-Input ohne Telefon/E-Mail/Adresse/Namen – Snapshot-Assertion), Rechte/Scope/Status +// (approved → blocked, Monteur ohne Zuweisung → not_found, ohne lotse:use → forbidden, Lotse aus → forbidden), +// Mandantentrennung (B liest/ändert nichts von A), Submit ohne Prüfbestätigung → invalid, Audit. +// +// Lauf: npx tsx scripts/test-lotse-draft.ts + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { Prisma } from "@prisma/client"; +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 { buildReportContent, tenantTimeZone } from "../src/server/services/reports/build-content"; +import { dateKeyToDbDate, localDateKey } from "../src/lib/reports/dates"; +import { parseReportContent } from "../src/lib/reports/content"; +import { submitReport } from "../src/server/services/reports/submit"; +import { updateReportTexts } from "../src/server/services/reports/edit"; +import { FakeLotseProvider } from "../src/server/ai/lotse/fake"; +import { reportDraftSystemPrompt } from "../src/server/ai/lotse/prompt"; +import { draftReportWithLotse, prepareDraftInput } from "../src/server/services/lotse/draft-report"; +import { decideLotseSuggestion } from "../src/server/services/lotse/suggestions"; +import { checkCompleteness } from "../src/server/services/lotse/completeness"; +import { applyLotseReview } from "../src/server/services/lotse/review"; +import { initials, scrubText, type MinimizationContext } from "../src/server/services/lotse/minimize"; +import { isLotseEnabled, lotseVoice, updateLotseSettings } from "../src/server/services/lotse/settings"; +import { getAiGenerationContent, listAiGenerations } from "../src/server/services/lotse/protocol"; + +let failures = 0; +const ok = (cond: boolean, msg: string) => { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failures++; +}; + +async function expectErr(fn: () => Promise, code: ServiceError["code"], msg: string, detail?: { reason?: string; field?: string }) { + try { + await fn(); + ok(false, `${msg} — kein Fehler`); + } catch (err) { + if (!(err instanceof ServiceError)) return ok(false, `${msg} — ${(err as Error).message}`); + const d = (err.details ?? {}) as { reason?: string; field?: string }; + const match = err.code === code && (!detail?.reason || d.reason === detail.reason) && (!detail?.field || d.field === detail.field); + ok(match, `${msg} (${err.code}${d.reason ? `/${d.reason}` : ""}${d.field ? `/${d.field}` : ""})`); + } +} + +/** Key-order independent JSON (Postgres jsonb reorders object keys). */ +const canon = (v: unknown): string => + JSON.stringify(v, (_k, val) => (val && typeof val === "object" && !Array.isArray(val) ? Object.fromEntries(Object.entries(val).sort(([a], [b]) => a.localeCompare(b))) : val)); + +const SLUGS = ["zz-lotse-a", "zz-lotse-b"]; +const DOMAIN = "@zz-lotse.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.notification.deleteMany(w); + await prisma.aiGeneration.deleteMany(w); + await prisma.signature.deleteMany(w); + await prisma.report.deleteMany(w); + await prisma.activityNote.deleteMany(w); + await prisma.voiceNote.deleteMany(w); + await prisma.materialUsage.deleteMany(w); + await prisma.materialPlan.deleteMany(w); + await prisma.timeEntry.deleteMany(w); + await prisma.workSession.deleteMany(w); + await prisma.photo.deleteMany(w); + await prisma.checklistItem.deleteMany(w); + await prisma.photoRequirement.deleteMany(w); + await prisma.workOrderStatusChange.deleteMany(w); + await prisma.workOrderAssignee.deleteMany(w); + await prisma.document.deleteMany(w); + await prisma.workOrder.deleteMany(w); + await prisma.site.deleteMany(w); + await prisma.contact.deleteMany(w); + await prisma.customer.deleteMany(w); + await prisma.numberSequence.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))), +}); + +async function main() { + await cleanup(); + + // ---------- fixtures ---------- + const tA = await prisma.tenant.create({ data: { name: "Lotse-Test A", slug: SLUGS[0] } }); + const tB = await prisma.tenant.create({ data: { name: "Lotse-Test B", slug: SLUGS[1] } }); + await prisma.tenantSettings.create({ data: { tenantId: tA.id, orgName: "Lotse Test A GmbH", phone: "040 1234567", email: "buero@lotse-a.test", address: "Werftstraße 5, 20457 Hamburg" } }); + await prisma.tenantSettings.create({ data: { tenantId: tB.id, orgName: "Lotse Test B" } }); + + const tech = await mkUser(tA.id, "tech", "Max Monteur"); + const outsider = await mkUser(tA.id, "outsider", "Otto Fremd"); + const office = await mkUser(tA.id, "office", "Bernd Büro"); + const admin = await mkUser(tA.id, "admin", "Anna Admin"); + const techB = await mkUser(tB.id, "techb", "Tom Bader"); + const adminB = await mkUser(tB.id, "adminb", "Bea Chefin"); + + const techCtx = ctxOf(tA.id, tech.id, "technician"); + const techNoLotse = ctxOf(tA.id, tech.id, "technician", ["lotse:use"]); + const outsiderCtx = ctxOf(tA.id, outsider.id, "technician"); + const officeCtx = ctxOf(tA.id, office.id, "backoffice"); + const adminCtx = ctxOf(tA.id, admin.id, "tenant-admin"); + const ctxB = ctxOf(tB.id, techB.id, "technician"); + const adminBCtx = ctxOf(tB.id, adminB.id, "tenant-admin"); + + const customer = await prisma.customer.create({ + data: { tenantId: tA.id, firstName: "Erika", lastName: "Mustermann", phone: "0171 9876543", email: "erika@kunde.test", street: "Lindenallee", houseNumber: "7", postalCode: "22111", city: "Hamburg" }, + }); + const contact = await prisma.contact.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Klaus Kontakt", phone: "040 555666", email: "klaus@kunde.test" } }); + const site = await prisma.site.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Haus Mustermann", street: "Lindenallee", houseNumber: "7", postalCode: "22111", city: "Hamburg" } }); + const wo = await prisma.workOrder.create({ + data: { + tenantId: tA.id, + number: "A-LOTSE-1", + customerId: customer.id, + siteId: site.id, + contactId: contact.id, + title: "Heizkörper tauschen", + description: "Kundin erreichbar unter 0171 9876543 oder erika@kunde.test, Lindenallee 7, 22111 Hamburg.", + scope: "Heizkörper im Bad ersetzen", + status: "in_progress", + signatureRequired: true, + assignees: { create: [{ tenantId: tA.id, userId: tech.id }] }, + checklistItems: { create: [{ tenantId: tA.id, key: "vent", label: "Anlage entlüftet", required: true }] }, + photoRequirements: { create: [{ tenantId: tA.id, key: "typ", label: "Typenschild" }] }, + materialPlans: { + create: [ + { tenantId: tA.id, name: "Heizkörper 600x1000", plannedQuantity: new Prisma.Decimal(2), unit: "Stk", sortOrder: 1 }, + { tenantId: tA.id, name: "Thermostatventil", plannedQuantity: new Prisma.Decimal(1), unit: "Stk", sortOrder: 2 }, + ], + }, + }, + include: { materialPlans: { orderBy: { sortOrder: "asc" } } }, + }); + await prisma.materialUsage.create({ + data: { tenantId: tA.id, workOrderId: wo.id, materialPlanId: wo.materialPlans[0].id, name: wo.materialPlans[0].name, actualQuantity: new Prisma.Decimal(1), unit: "Stk", usageStatus: "partially_used", recordedById: tech.id }, + }); + const noteText = "Max Monteur hat den alten Heizkörper demontiert. Rückruf an Klaus Kontakt unter 040 555666, Mail klaus@kunde.test."; + const note = await prisma.activityNote.create({ data: { tenantId: tA.id, workOrderId: wo.id, authorId: tech.id, kind: "work_done", text: noteText } }); + + const tz = await tenantTimeZone(techCtx); + const today = localDateKey(new Date(), tz); + const content = await buildReportContent(techCtx, { workOrderId: wo.id, type: "daily", reportDate: today, reportNumber: "B-LOTSE-1", version: 1, technicianUserId: tech.id }); + const report = await prisma.report.create({ + data: { tenantId: tA.id, workOrderId: wo.id, type: "daily", reportDate: dateKeyToDbDate(today), lineageId: randomUUID(), status: "draft", content: content as unknown as Prisma.InputJsonValue, createdById: tech.id }, + }); + const approved = await prisma.report.create({ + data: { tenantId: tA.id, workOrderId: wo.id, type: "completion", reportDate: dateKeyToDbDate(today), lineageId: randomUUID(), status: "approved", content: content as unknown as Prisma.InputJsonValue, createdById: tech.id }, + }); + + // ---------- 1. Datenminimierung ---------- + const m0: MinimizationContext = { employees: [], contacts: [], customerPersons: [], phones: [], emails: [], addressParts: [] }; + ok(scrubText("Bitte 0151-12345678 anrufen", m0) === "Bitte [Telefon] anrufen", "Muster: Mobilnummer → [Telefon]"); + ok(scrubText("Rückruf +49 40 1234567", m0) === "Rückruf [Telefon]", "Muster: internationale Nummer → [Telefon]"); + ok(scrubText("Mail an x.y@firma-nord.de", m0) === "Mail an [E-Mail]", "Muster: E-Mail → [E-Mail]"); + ok(scrubText("Baustelle Am Kaiserkai 30, 20457 Hamburg", m0) === "Baustelle Am [Adresse]", "Muster: Straße + PLZ/Ort → [Adresse]"); + ok(scrubText("Termin 12.10.2026, 24 m Kupferrohr, Auftrag A-2026-0001", m0) === "Termin 12.10.2026, 24 m Kupferrohr, Auftrag A-2026-0001", "Datum, Mengen, Auftragsnummern bleiben erhalten"); + ok(initials("Anna-Lena Bauer") === "A. B.", "Initialen aus Namen"); + + const input = await prepareDraftInput(techCtx, report, parseReportContent(report.content)); + const json = JSON.stringify(input); + const secrets = ["0171 9876543", "erika@kunde.test", "Lindenallee", "22111", "040 555666", "klaus@kunde.test", "Max Monteur", "Monteur hat", "Mustermann", "Klaus Kontakt", "040 1234567", "buero@lotse-a.test", "Werftstraße", "Hamburg"]; + const leaked = secrets.filter((s) => json.includes(s)); + ok(leaked.length === 0, `Snapshot KI-Input enthält keine Telefon/E-Mail/Adresse/Namen${leaked.length ? ` — gefunden: ${leaked.join(", ")}` : ""}`); + ok(input.workOrder.description === "Kundin erreichbar unter [Telefon] oder [E-Mail], [Adresse].", `Snapshot Auftragsbeschreibung minimiert: „${input.workOrder.description}“`); + ok(input.notes.length === 1 && input.notes[0].text === "M. M. hat den alten Heizkörper demontiert. Rückruf an Ansprechpartner unter [Telefon], Mail [E-Mail].", `Snapshot Notiz: Mitarbeiter → Initialen, Ansprechpartner → Rolle: „${input.notes[0]?.text}“`); + ok(!("customer" in input) && input.materials.length === 1 && input.materials[0].status === "partially_used" && input.materials[0].planned === "2", "Kein Kundenblock im Input; Material des Tages mit Plan-/Istmenge"); + ok(input.addressForm === "neutral" && input.locale === "de", "Ohne Einstellung: neutrale Anrede"); + ok(reportDraftSystemPrompt({ addressForm: "neutral", locale: "de" }).includes("ohne Anrede-Pronomen"), "System-Prompt neutral ohne Pronomen"); + ok(reportDraftSystemPrompt({ addressForm: "sie", locale: "de" }).includes("Sie-Form") && reportDraftSystemPrompt({ addressForm: "du", locale: "de" }).includes("du-Form"), "System-Prompt Sie/du je Einstellung"); + + // ---------- 2. Einstellungen ---------- + await expectErr(() => updateLotseSettings(techCtx, { enabled: false, addressForm: "du" }), "forbidden", "Monteur darf Lotse-Einstellungen nicht ändern"); + await updateLotseSettings(adminCtx, { enabled: false, addressForm: "sie" }); + ok(!(await isLotseEnabled(adminCtx)) && (await lotseVoice(adminCtx)).addressForm === "sie", "Admin: Lotse aus + Anrede Sie gespeichert"); + ok(await isLotseEnabled(adminBCtx), "Mandant B bleibt unberührt (Lotse an)"); + const fake = new FakeLotseProvider({ + output: { workPerformed: "- Alten Heizkörper demontiert", hints: "Thermostatventil noch montieren", missingInformation: ["Grund für die Mindermenge Heizkörper fehlt"] }, + }); + await expectErr(() => draftReportWithLotse(techCtx, report.id, { provider: fake }), "forbidden", "Lotse aus → Entwurf gesperrt", { reason: "disabled" }); + await expectErr(() => checkCompleteness(techCtx, wo.id), "forbidden", "Lotse aus → Vollständigkeitsprüfung gesperrt", { reason: "disabled" }); + await updateLotseSettings(adminCtx, { enabled: true, addressForm: "sie" }); + ok((await prepareDraftInput(techCtx, report, parseReportContent(report.content))).addressForm === "sie", "Anrede aus Mandanteneinstellung im KI-Input"); + const audit = await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "lotse_settings" }, orderBy: { createdAt: "desc" } }); + ok(Boolean(audit?.before) && Boolean(audit?.after), "Einstellungsänderung im Audit (before/after)"); + + // ---------- 3. Rechte / Scope / Status / Mandant ---------- + await expectErr(() => draftReportWithLotse(techNoLotse, report.id, { provider: fake }), "forbidden", "Ohne lotse:use → forbidden"); + await expectErr(() => draftReportWithLotse(outsiderCtx, report.id, { provider: fake }), "not_found", "Monteur ohne Zuweisung → not_found"); + await expectErr(() => draftReportWithLotse(ctxB, report.id, { provider: fake }), "not_found", "Mandant B → not_found"); + await expectErr(() => draftReportWithLotse(techCtx, approved.id, { provider: fake }), "blocked", "Freigegebener Bericht → blocked", { reason: "not_editable" }); + await expectErr(() => draftReportWithLotse(techCtx, report.id, { provider: null }), "invalid", "Ohne KI-Konfiguration → Hinweis nicht eingerichtet", { reason: "not_configured" }); + await expectErr(() => draftReportWithLotse(techCtx, report.id, { provider: new FakeLotseProvider({ fail: new Error("boom") }) }), "conflict", "Provider-Fehler → provider_failed", { reason: "provider_failed" }); + const untouched = await prisma.report.findUniqueOrThrow({ where: { id: report.id } }); + ok(!untouched.aiDrafted && !untouched.aiGenerationId && (await prisma.aiGeneration.count({ where: { tenantId: tA.id } })) === 0, "Fehlschläge ändern nichts (kein aiDrafted, keine AiGeneration)"); + ok(fake.draftCalls.length === 0, "Bei Rechte-/Scope-/Statusfehlern wird der Provider nie aufgerufen"); + + // ---------- 4. Entwurf ---------- + const res = await draftReportWithLotse(techCtx, report.id, { provider: fake }); + const drafted = await prisma.report.findUniqueOrThrow({ where: { id: report.id } }); + const dc = parseReportContent(drafted.content); + ok(res.suggestions === 2 && drafted.aiDrafted && drafted.aiGenerationId === res.generationId, "Entwurf: aiDrafted + aiGenerationId gesetzt"); + ok(dc.lotse?.suggestions.map((s) => `${s.field}:${s.state}`).join(",") === "workPerformed:pending,hints:pending", "Mapping Output → Vorschläge (nur nicht-leere Felder, Status pending)"); + ok(dc.lotse?.suggestions[0].text === "- Alten Heizkörper demontiert", "Vorschlagstext übernommen"); + ok(JSON.stringify(dc.texts) === JSON.stringify(parseReportContent(report.content).texts), "Berichtstexte bleiben unverändert (nur Vorschlag)"); + ok((await prisma.activityNote.findUniqueOrThrow({ where: { id: note.id } })).text === noteText, "Originalnotiz bleibt unverändert"); + const gen = await prisma.aiGeneration.findUniqueOrThrow({ where: { id: res.generationId } }); + ok(gen.kind === "report_draft" && gen.entityType === "report" && gen.entityId === report.id && gen.model === "fake-lotse-1" && gen.inputTokens === 1200 && gen.createdById === tech.id, "AiGeneration protokolliert (Art, Modell, Tokens, Nutzer)"); + ok(canon(gen.input) === canon(fake.draftCalls[0]), "AiGeneration.input = exakt der an den Provider gesendete (minimierte) Input"); + const draftAudit = await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "report", entityId: report.id }, orderBy: { createdAt: "desc" } }); + ok((draftAudit?.after as { aiGenerationId?: string } | null)?.aiGenerationId === res.generationId, "Audit-Eintrag zum Entwurf"); + + // ---------- 5. Vollständigkeitsprüfung ---------- + const items = await checkCompleteness(techCtx, wo.id); + const codes = items.map((i) => i.code); + for (const c of ["photo_requirement", "checklist_item", "material_reason", "material_unconfirmed", "no_work_time", "signature_missing"] as const) ok(codes.includes(c), `Regel ${c} erkannt`); + ok(!codes.includes("no_description"), "Tätigkeitsbeschreibung vorhanden → keine Meldung"); + const hint = items.find((i) => i.code === "lotse_hint"); + ok(hint?.text === "Grund für die Mindermenge Heizkörper fehlt" && hint.source === "lotse" && hint.href === `/m/orders/${wo.id}/report?type=daily`, "missingInformation → Liste mit Sprungziel"); + ok(items.every((i) => i.href.startsWith(`/m/orders/${wo.id}/`)), "Jeder Punkt hat einen Deep-Link in die mobile Unterseite"); + ok(items.find((i) => i.code === "material_reason")?.label === "Heizkörper 600x1000" && items.find((i) => i.code === "photo_requirement")?.href.endsWith("/photos") === true, "Klartext-Label + passende Unterseite"); + const rulesOnly = codes.indexOf("lotse_hint") === codes.length - 1; + ok(rulesOnly, "Deterministische Regeln vor KI-Hinweisen"); + await expectErr(() => checkCompleteness(outsiderCtx, wo.id), "not_found", "Vollständigkeit: Monteur ohne Zuweisung → not_found"); + await expectErr(() => checkCompleteness(ctxB, wo.id), "not_found", "Vollständigkeit: Mandant B → not_found"); + await expectErr(() => checkCompleteness(techNoLotse, wo.id), "forbidden", "Vollständigkeit ohne lotse:use → forbidden"); + + // ---------- 6. Vorschläge übernehmen / verwerfen ---------- + await expectErr(() => decideLotseSuggestion(ctxB, { reportId: report.id, field: "workPerformed", decision: "accept" }), "not_found", "Mandant B kann Vorschlag nicht übernehmen"); + await expectErr(() => decideLotseSuggestion(outsiderCtx, { reportId: report.id, field: "workPerformed", decision: "accept" }), "not_found", "Monteur ohne Zuweisung kann Vorschlag nicht übernehmen"); + await decideLotseSuggestion(techCtx, { reportId: report.id, field: "workPerformed", decision: "accept", text: "- Alten Heizkörper im Bad demontiert" }); + await decideLotseSuggestion(techCtx, { reportId: report.id, field: "hints", decision: "discard" }); + const decided = parseReportContent((await prisma.report.findUniqueOrThrow({ where: { id: report.id } })).content); + ok(decided.texts.workPerformed === "- Alten Heizkörper im Bad demontiert", "Übernehmen (bearbeitet) → Berichtstext gesetzt"); + ok(decided.texts.hints === content.texts.hints, "Verwerfen → Text unverändert"); + ok(decided.lotse?.suggestions.map((s) => s.state).join(",") === "accepted,discarded", "Vorschlagsstatus accepted/discarded"); + await expectErr(() => decideLotseSuggestion(techCtx, { reportId: report.id, field: "hints", decision: "accept" }), "conflict", "Bereits entschiedener Vorschlag → conflict", { reason: "no_suggestion" }); + + // ---------- 7. Freigabeprinzip ---------- + ok(applyLotseReview(techCtx, { aiDrafted: false }, decided, undefined) === decided, "Bericht ohne Lotse: keine Prüfbestätigung nötig"); + await expectErr(() => submitReport(techCtx, { reportId: report.id }), "invalid", "Submit ohne Prüfbestätigung → invalid", { field: "aiReviewed" }); + await expectErr(() => submitReport(techCtx, { reportId: report.id, aiReviewed: false }), "invalid", "Submit mit aiReviewed=false → invalid", { field: "aiReviewed" }); + ok((await prisma.report.findUniqueOrThrow({ where: { id: report.id } })).status === "draft", "Bericht bleibt Entwurf"); + await updateReportTexts(techCtx, { reportId: report.id, texts: { nextSteps: "Ventil montieren" } }); + ok(parseReportContent((await prisma.report.findUniqueOrThrow({ where: { id: report.id } })).content).lotse?.generationId === res.generationId, "Texte speichern behält den Lotse-Block"); + const submitted = await submitReport(techCtx, { reportId: report.id, aiReviewed: true }); + const sc = parseReportContent(submitted.content); + ok(submitted.status === "submitted" && Boolean(sc.lotse?.reviewedAt) && sc.lotse?.reviewedById === tech.id, "Submit mit Bestätigung → submitted + Prüfnachweis (reviewedAt/reviewedById) im Snapshot"); + await expectErr(() => draftReportWithLotse(techCtx, report.id, { provider: fake }), "blocked", "Eingereichter Bericht → kein neuer Entwurf", { reason: "not_editable" }); + await expectErr(() => decideLotseSuggestion(techCtx, { reportId: report.id, field: "workPerformed", decision: "discard" }), "blocked", "Eingereichter Bericht → keine Vorschlagsentscheidung"); + + // ---------- 8. Protokoll ---------- + await expectErr(() => listAiGenerations(techCtx), "forbidden", "Monteur sieht das KI-Protokoll nicht"); + const officeList = await listAiGenerations(officeCtx); + ok(officeList.total === 1 && !officeList.canSeeContent && !("input" in officeList.items[0]) && officeList.items[0].userName === "Max Monteur", "Backoffice: Liste ohne Inhalte (Zeit, Art, Modell, Tokens, Nutzer)"); + await expectErr(() => getAiGenerationContent(officeCtx, res.generationId), "forbidden", "Inhalte nur für Mandantenadministratoren"); + ok(canon((await getAiGenerationContent(adminCtx, res.generationId)).input) === canon(fake.draftCalls[0]), "Admin sieht gesendete Daten"); + const listB = await listAiGenerations(adminBCtx); + ok(listB.total === 0, "Mandant B sieht keine KI-Nutzung von A"); + await expectErr(() => getAiGenerationContent(adminBCtx, res.generationId), "not_found", "Mandant B kann Inhalte von A nicht lesen"); +} + +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 Lotse-Entwurfsprüfungen grün"); + process.exit(failures ? 1 : 0); + }); diff --git a/scripts/test-lotse-live.ts b/scripts/test-lotse-live.ts new file mode 100644 index 0000000..09f662e --- /dev/null +++ b/scripts/test-lotse-live.ts @@ -0,0 +1,58 @@ +// L9 Lotse — optional live test of the Claude report draft and voice summary against a synthetic, +// already minimised input. Runs ONLY when ANTHROPIC_API_KEY is set (costs tokens); otherwise it skips +// with exit 0 so the default gate stays offline. +// +// Lauf: ANTHROPIC_API_KEY=… npx tsx scripts/test-lotse-live.ts + +import "dotenv/config"; +import { getLotseProvider } from "../src/server/ai/lotse/anthropic"; +import type { ReportDraftInput } from "../src/server/ai/providers"; + +async function main() { + if (!process.env.ANTHROPIC_API_KEY?.trim()) { + console.log("↷ übersprungen: kein ANTHROPIC_API_KEY gesetzt (Live-Berichtsentwurf gegen Claude)."); + process.exit(0); + } + const provider = getLotseProvider(); + if (!provider) { + console.log("↷ übersprungen: Lotse nicht konfiguriert."); + process.exit(0); + } + + let failures = 0; + const ok = (cond: boolean, msg: string) => { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failures++; + }; + + const input: ReportDraftInput = { + locale: "de", + addressForm: "neutral", + workOrder: { title: "Heizkörper tauschen", description: "Kundin erreichbar unter [Telefon].", scope: "Heizkörper im Bad ersetzen", orderType: "Reparatur" }, + notes: [ + { kind: "work_done", text: "M. M. hat den alten Heizkörper demontiert und den neuen montiert.", at: "2026-09-14T08:30:00+02:00" }, + { kind: "deviation (Sprachnotiz)", text: "Nur ein Heizkörper verbaut, zweiter war beschädigt.", at: "2026-09-14T10:00:00+02:00" }, + ], + checklist: [{ label: "Anlage entlüftet", checked: false, comment: null }], + materials: [{ name: "Heizkörper 600x1000", planned: "2", actual: "1", unit: "Stk", status: "partially_used", reason: null }], + photos: [{ phase: "after", comment: "Neuer Heizkörper montiert", requirement: null }], + time: [{ type: "work", minutes: 95, user: "M. M." }], + }; + + const started = Date.now(); + const out = await provider.draftReport(input); + console.log(` Modell ${out.meta.model}, Tokens ${out.meta.inputTokens}/${out.meta.outputTokens}, ${Date.now() - started} ms`); + ok(out.workPerformed.trim().length > 0, "workPerformed nicht leer"); + ok(Array.isArray(out.missingInformation), "missingInformation ist eine Liste"); + ok(!/\[Telefon\]/.test(`${out.workPerformed}${out.hints}`), "Platzhalter nicht in den Bericht übernommen"); + ok(!/\b(Sie|du)\b/.test(out.missingInformation.join(" ")), "Neutrale Anrede in missingInformation"); + + const summary = await provider.summarizeTranscript({ locale: "de", addressForm: "neutral", transcript: "Also, der alte Heizkörper ist raus, der neue hängt, entlüften mach ich morgen." }); + ok(summary.summary.trim().length > 0, "Zusammenfassung nicht leer"); + process.exit(failures ? 1 : 0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/test-lotse-transcription.ts b/scripts/test-lotse-transcription.ts new file mode 100644 index 0000000..45400a9 --- /dev/null +++ b/scripts/test-lotse-transcription.ts @@ -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, 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, 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((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((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((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); + });