// L16 Lotse-Chat für Monteure – Chat-Schleife mit Fake-Provider (geskriptete Tool-Aufrufe). // // Deckt ab: Auftragszuordnung (Kontext > laufende Uhr > heutige Aufträge, Suchbegriff, Mehrdeutigkeit → Chips), // Rückfrage (ask_user beendet die Schleife), Vorschläge schreiben nichts vor der Bestätigung, fehlende Pflichtwerte → // keine Karte, Datenminimierung (Modell-Input ohne Telefon/E-Mail/Adresse/Namen, Platzhalter serverseitig eingesetzt), // Monteur ohne Zuweisung → not_found, Mandantentrennung + nur eigener Verlauf, Einstellung/Modul aus → blocked, // Token-Kontingent, Runden- und Token-Grenze je Nachricht, Anbieterfehler, AiGeneration/Protokoll, Aufbewahrung. // // Lauf: npx tsx scripts/test-lotse-chat-engine.ts import "dotenv/config"; import { prisma, dbForTenant } from "../src/server/db"; import { FakeLotseChatProvider, type FakeChatStep } from "../src/server/ai/lotse/chat-fake"; import { chatSystemPrompt } from "../src/server/ai/lotse/chat-prompt"; import type { ServiceCtx } from "../src/server/services/context"; import { startSession } from "../src/server/services/field/sessions"; import { sessionStartPayload } from "../src/lib/sync/ops"; import { sendLotseMessage } from "../src/server/services/lotse/chat/engine"; import { getChatView, startNewConversation } from "../src/server/services/lotse/chat/conversations"; import { canUseLotseChat } from "../src/server/services/lotse/chat/access"; import { renderPlaceholders } from "../src/server/services/lotse/chat/placeholders"; import { queryWords } from "../src/server/services/lotse/chat/orders"; import { updateLotseSettings } from "../src/server/services/lotse/settings"; import { listAiGenerations } from "../src/server/services/lotse/protocol"; import { purgeExpiredAiGenerations } from "../src/server/services/lotse/retention"; import { cleanupChatTenants, createChatFixture, expectErr, failures, ok } from "./lib/lotse-chat-fixture"; const PREFIX = "lotsechat-engine"; async function send(ctx: ServiceCtx, text: string, script: FakeChatStep[], opts: { conversationId?: string | null; workOrderId?: string | null; maxRounds?: number; turnTokenLimit?: number; fail?: Error } = {}) { const provider = new FakeLotseChatProvider(script, opts.fail ? { fail: opts.fail } : {}); const view = await sendLotseMessage(ctx, { text, conversationId: opts.conversationId, workOrderId: opts.workOrderId }, { provider, maxRounds: opts.maxRounds, turnTokenLimit: opts.turnTokenLimit }); return { view, provider, last: view.messages[view.messages.length - 1] }; } const lastToolResult = (p: FakeLotseChatProvider): string => { const call = p.calls[p.calls.length - 1]; const turn = [...call.turns].reverse().find((t) => t.role === "tool_results"); return turn && turn.role === "tool_results" ? turn.results.map((r) => r.content).join("\n") : ""; }; async function main() { const f = await createChatFixture(PREFIX); const { wo1, wo2 } = f.orders; console.log("\n— Prompt & Hilfsfunktionen —"); const prompt = chatSystemPrompt({ addressForm: "du", now: "Dienstag", today: "2026-09-15", contextOrder: "A-1", runningClockOrder: null }); ok(prompt.includes("du führst nie selbst etwas aus") && prompt.includes("„du“") && prompt.includes("ask_user"), "System-Prompt: nie ausführen, Anrede, Rückfrage"); ok(queryWords("Auftrag bei Objekt Müller").join(",") === "müller", "Suchbegriff ohne Füllwörter"); // tech: laufende Uhr auf A-LC-1 await startSession(f.ctx.tech, sessionStartPayload.parse({ workOrderId: wo1.id, mode: "work", at: new Date(Date.now() - 20 * 60_000).toISOString() })); console.log("\n— Auftragszuordnung —"); const noteScript = (input: Record = {}): FakeChatStep[] => [{ toolCalls: [{ name: "add_note", input: { text: "Speicher installiert", ...input } }] }, { text: "Bitte prüfen und bestätigen." }]; const ctxTurn = await send(f.ctx.tech, "Speicher installiert", noteScript(), { workOrderId: wo2.id }); ok(ctxTurn.last.proposals.length === 1 && ctxTurn.last.proposals[0].workOrderId === wo2.id, "Kontext-Auftrag hat Vorrang vor der laufenden Uhr"); ok(ctxTurn.view.workOrder?.id === wo2.id && ctxTurn.provider.calls[0].system.includes("Kontext-Auftrag (Chat aus dem Auftrag geöffnet): A-LC-2"), "Kontext steht im System-Prompt"); const clockTurn = await send(f.ctx.tech, "Speicher installiert", noteScript()); ok(clockTurn.last.proposals[0]?.workOrderId === wo1.id, "ohne Kontext: Auftrag der laufenden Uhr"); const generalConversation = clockTurn.view.conversationId!; const ambiguous = await send(f.ctx.tech2, "Filter getauscht", [{ toolCalls: [{ name: "add_note", input: { text: "Filter getauscht" } }] }, { text: "Welcher Auftrag?" }]); ok(ambiguous.last.proposals.length === 0, "mehrdeutig (zwei heutige Aufträge, keine Uhr) → keine Karte"); const chipValues = (ambiguous.last.content.chips ?? []).map((c) => c.value).sort(); ok(chipValues.join("|") === "Auftrag A-LC-1|Auftrag A-LC-2", `Auswahl-Chips mit beiden Aufträgen (${chipValues.join(", ")})`); ok(lastToolResult(ambiguous.provider).includes("order_ambiguous"), "Modell erhält order_ambiguous"); ok(!(ambiguous.last.content.chips ?? []).some((c) => c.value.includes("Mustermann")) && ambiguous.last.content.chips!.some((c) => c.label.includes("Mustermann")), "Chip-Beschriftung für den Monteur mit Kunde, Wert ohne Namen"); const byQuery = await send(f.ctx.tech2, "Bei Müller Notiz", noteScript({ order: "Objekt Müller" })); ok(byQuery.last.proposals[0]?.workOrderId === wo2.id, "Suchbegriff Kunde/Objekt → eindeutiger Auftrag"); const byNumber = await send(f.ctx.tech2, "Notiz zu 1", noteScript({ order: "A-LC-1" })); ok(byNumber.last.proposals[0]?.workOrderId === wo1.id, "Suchbegriff Auftragsnummer"); const asked = await send(f.ctx.tech2, "2 Std. gebucht", [{ toolCalls: [{ name: "ask_user", input: { question: "Für welchen Auftrag?", options: ["A-LC-1", "A-LC-2"] } }] }, { text: "darf nicht kommen" }]); ok(asked.provider.calls.length === 1 && asked.last.text.includes("Für welchen Auftrag?"), "ask_user beendet die Schleife, Frage als Antwort"); ok((asked.last.content.chips ?? []).length === 2, "Antwortoptionen als Chips"); console.log("\n— Vorschläge schreiben nichts —"); const counts = async () => ({ time: await prisma.timeEntry.count({ where: { tenantId: f.tA.id, source: "manual" } }), material: await prisma.materialUsage.count({ where: { tenantId: f.tA.id } }), notes: await prisma.activityNote.count({ where: { tenantId: f.tA.id } }), status: (await prisma.workOrder.findMany({ where: { tenantId: f.tA.id }, orderBy: { number: "asc" }, select: { status: true } })).map((w) => w.status).join(","), changes: await prisma.workOrderStatusChange.count({ where: { tenantId: f.tA.id } }), }); const before = await counts(); const multi = await send(f.ctx.tech2, "Auftrag fertig, Speicher installiert, 30 Min., 1 Filter verbaut", [ { toolCalls: [ { name: "book_time", input: { order: "A-LC-2", duration_minutes: 30, reason: "Uhr vergessen" } }, { name: "record_material", input: { order: "A-LC-1", name: "Filter", quantity: 1 } }, { name: "add_note", input: { order: "A-LC-1", text: "Speicher installiert" } }, { name: "transition_work_order", input: { order: "A-LC-2", action: "accept" } }, ], }, { text: "Vier Karten liegen bereit. Die Zeit zählt nach Freigabe." }, ]); const kinds = multi.last.proposals.map((p) => p.kind).join(","); ok(kinds === "book_time,record_material,add_note,transition_work_order", `vier Aktionskarten (${kinds})`); ok(multi.last.proposals.every((p) => p.status === "proposed"), "alle Karten offen"); ok(JSON.stringify(await counts()) === JSON.stringify(before), "keine Zeit, kein Material, keine Notiz, kein Statuswechsel vor Bestätigung"); const timeCard = multi.last.proposals[0].payload; ok(timeCard.durationMinutes === 30 && typeof timeCard.from === "string" && typeof timeCard.to === "string" && timeCard.type === "work", "Zeitkarte: Dauer 30 Min. bis jetzt, von–bis gesetzt"); const materialCard = multi.last.proposals[1].payload; ok(materialCard.usageStatus === "fully_used" && materialCard.unit === "Stk" && materialCard.materialPlanId === f.plan.id, "Material der geplanten Position zugeordnet"); const proposalRows = await prisma.lotseActionProposal.findMany({ where: { id: { in: multi.last.proposals.map((p) => p.id) } } }); ok(proposalRows.every((p) => p.payloadHash.length === 64 && p.expiresAt.getTime() - p.createdAt.getTime() >= 29 * 60_000), "Hash + Ablauf nach 30 Minuten gespeichert"); const noReason = await send(f.ctx.tech2, "30 Min. auf Müller", [{ toolCalls: [{ name: "book_time", input: { order: "A-LC-2", duration_minutes: 30 } }] }, { text: "Grund?" }]); ok(noReason.last.proposals.length === 0 && lastToolResult(noReason.provider).includes("missing_values") && lastToolResult(noReason.provider).includes("reason"), "Zeit ohne Grund → Rückfrage statt Karte"); const noDuration = await send(f.ctx.tech2, "Zeit buchen", [{ toolCalls: [{ name: "book_time", input: { order: "A-LC-2", reason: "Uhr vergessen" } }] }, { text: "Wie lange?" }]); ok(noDuration.last.proposals.length === 0 && lastToolResult(noDuration.provider).includes("duration_minutes"), "Zeit ohne Dauer → Rückfrage"); const extra = await send(f.ctx.tech2, "2 Dichtungen", [{ toolCalls: [{ name: "record_material", input: { order: "A-LC-1", name: "Dichtung", quantity: 2, unit: "Stk" } }] }, { text: "Grund?" }]); ok(extra.last.proposals.length === 0 && lastToolResult(extra.provider).includes("reason"), "Zusatzmaterial ohne Grund → Rückfrage"); const partial = await send(f.ctx.tech2, "halber Filter", [{ toolCalls: [{ name: "record_material", input: { order: "A-LC-1", name: "Filter", quantity: 0.5 } }] }, { text: "Grund?" }]); ok(partial.last.proposals.length === 0 && lastToolResult(partial.provider).includes("missing_values"), "Mindermenge ohne Grund → Rückfrage"); const overlap = await send(f.ctx.tech, "30 Min. gearbeitet", [{ toolCalls: [{ name: "book_time", input: { order: "A-LC-1", duration_minutes: 30, reason: "Uhr vergessen" } }] }, { text: "Läuft schon." }]); ok(overlap.last.proposals.length === 0 && lastToolResult(overlap.provider).includes("overlap"), "Überschneidung mit laufender Uhr → keine Karte"); const blocked = await send(f.ctx.tech, "Pumpe fertig", [{ toolCalls: [{ name: "transition_work_order", input: { order: "A-LC-3", action: "complete" } }] }, { text: "Pflichtfoto fehlt." }]); ok(blocked.last.proposals.length === 0 && lastToolResult(blocked.provider).includes("blocked"), "Abschluss mit fehlendem Pflichtfoto → keine Karte"); ok((blocked.last.content.links ?? []).some((l) => l.href === `/m/orders/${f.orders.wo3.id}/photos`), "Sprungziel zum Pflichtfoto"); const noReport = await send(f.ctx.tech, "Bericht", [{ toolCalls: [{ name: "suggest_report_fields", input: { order: "A-LC-4", fields: { workPerformed: "Regler eingestellt" } } }] }, { text: "Kein Bericht." }]); ok(noReport.last.proposals.length === 0 && lastToolResult(noReport.provider).includes("no_open_report"), "Berichtsfelder ohne offenen Bericht → keine Karte"); console.log("\n— Datenminimierung —"); const privacy = await send(f.ctx.tech, "Kundin ruft unter 0171 9876543 an, Mail erika@kunde.test. Telefonnummer vom Ansprechpartner?", [ { toolCalls: [{ name: "get_order_details", input: { order: "A-LC-1" } }, { name: "list_my_orders", input: { days: 1 } }] }, { text: "Telefon: {{phone:A-LC-1}}" }, ]); const sent = JSON.stringify(privacy.provider.calls); for (const secret of ["9876543", "erika@kunde.test", "Mustermann", "Lindenallee", "22111", "555666", "klaus@kunde.test", "Klaus", "1112223", "Müller Haustechnik", "Max Monteur", "1234567", "buero@lotsechat-a.test"]) { ok(!sent.includes(secret), `Modell-Input ohne „${secret}“`); } ok(sent.includes("[Telefon]") && sent.includes("[E-Mail]"), "Nutzertext: Telefon/E-Mail durch Platzhalter ersetzt"); ok(sent.includes("{{phone:A-LC-1}}") && sent.includes("{{customer:A-LC-1}}"), "Kontaktdaten nur als Platzhalter-Token"); ok(sent.includes("4711") && sent.includes("Speicher tauschen"), "Fachliche Hinweise (Zugangscode, Titel) bleiben erhalten"); ok(privacy.last.text === "Telefon: 040 555666", `Platzhalter serverseitig eingesetzt (${privacy.last.text})`); const gen = await prisma.aiGeneration.findFirst({ where: { tenantId: f.tA.id, kind: "lotse_chat" }, orderBy: { createdAt: "desc" } }); ok(Boolean(gen) && !JSON.stringify(gen?.input).includes("555666") && JSON.stringify(gen?.output).includes("{{phone:A-LC-1}}"), "AiGeneration: Input minimiert, Output mit Token"); ok(gen?.entityType === "lotse_conversation" && gen?.createdById === f.users.tech.id && gen?.inputTokens === 1000, "AiGeneration: Gespräch, Nutzer, Tokens aller Runden"); ok((await renderPlaceholders(f.ctx.outsider, "{{phone:A-LC-1}}")) === "—", "Platzhalter für nicht sichtbaren Auftrag → „—“"); const userRow = await prisma.lotseMessage.findFirst({ where: { conversationId: privacy.view.conversationId!, role: "user" }, orderBy: { createdAt: "desc" } }); ok(JSON.stringify(userRow?.content).includes("[Telefon]"), "gesendete (minimierte) Fassung am Verlauf gespeichert"); console.log("\n— Rollen/Scope und Mandantentrennung —"); await expectErr(() => getChatView(f.ctx.outsider, { workOrderId: wo1.id }), "not_found", "Monteur ohne Zuweisung: Chat mit Auftragskontext → not_found"); const outsiderTurn = await send(f.ctx.outsider, "Notiz A-LC-1", noteScript({ order: "A-LC-1" })); ok(outsiderTurn.last.proposals.length === 0 && lastToolResult(outsiderTurn.provider).includes("order_not_found"), "Monteur ohne Zuweisung findet den Auftrag nicht"); await expectErr(() => getChatView(f.ctx.admin, { conversationId: generalConversation }), "not_found", "fremder Verlauf im selben Mandanten → not_found"); await expectErr(() => getChatView(f.ctx.techB, { conversationId: generalConversation }), "not_found", "Mandant B liest Verlauf von A nicht"); const bProvider = new FakeLotseChatProvider([{ text: "x" }]); await expectErr(() => sendLotseMessage(f.ctx.techB, { text: "hallo", conversationId: generalConversation }, { provider: bProvider }), "not_found", "Mandant B schreibt nicht in A-Verlauf"); await expectErr(() => sendLotseMessage(f.ctx.techB, { text: "hallo", workOrderId: wo1.id }, { provider: bProvider }), "not_found", "Mandant B: Auftrag von A als Kontext → not_found"); ok(bProvider.calls.length === 0, "Modell bei Fremdzugriff nie aufgerufen"); ok((await dbForTenant(f.tB.id).lotseMessage.count({ where: { conversationId: generalConversation } })) === 0, "B-Client sieht keine Nachrichten von A"); const bTurn = await send(f.ctx.techB, "Notiz", noteScript({ order: "A-LC-1" })); ok(bTurn.last.proposals.length === 0 && lastToolResult(bTurn.provider).includes("order_not_found"), "Mandant B findet Aufträge von A nicht"); const withoutField = { ...f.ctx.tech, permissions: new Set([...f.ctx.tech.permissions].filter((p) => p !== "lotse:use")) }; await expectErr(() => getChatView(withoutField), "forbidden", "ohne lotse:use → forbidden"); const fresh = await startNewConversation(f.ctx.tech, { workOrderId: null }); ok(fresh.messages.length === 0 && fresh.conversationId !== generalConversation, "„Neuer Chat“ beginnt leer"); console.log("\n— Einstellung, Modul, Kontingent, Grenzen —"); await updateLotseSettings(f.ctx.admin, { enabled: true, addressForm: "neutral", chatEnabled: false }); const offProvider = new FakeLotseChatProvider([{ text: "x" }]); await expectErr(() => sendLotseMessage(f.ctx.tech, { text: "hallo" }, { provider: offProvider }), "blocked", "Lotse-Chat ausgeschaltet → blocked", "chat_disabled"); ok(!(await canUseLotseChat(f.ctx.tech)) && offProvider.calls.length === 0, "ausgeschaltet: kein Einstieg, kein Modellaufruf"); const settingsAudit = await prisma.auditLog.findFirst({ where: { tenantId: f.tA.id, entity: "lotse_settings" }, orderBy: { createdAt: "desc" } }); ok((settingsAudit?.after as { chatEnabled?: boolean } | null)?.chatEnabled === false, "Audit der Einstellung enthält chatEnabled"); await updateLotseSettings(f.ctx.admin, { enabled: false, addressForm: "neutral", chatEnabled: true }); await expectErr(() => sendLotseMessage(f.ctx.tech, { text: "hallo" }, { provider: offProvider }), "blocked", "Lotse-Modul aus → blocked", "disabled"); await updateLotseSettings(f.ctx.admin, { enabled: true, addressForm: "du", chatEnabled: true }); ok(await canUseLotseChat(f.ctx.tech), "wieder eingeschaltet"); await expectErr(() => sendLotseMessage(f.ctx.tech, { text: "hallo" }, { provider: null }), "invalid", "ohne Anbieter → Lotse ist nicht eingerichtet", "not_configured"); await updateLotseSettings(f.ctx.admin, { enabled: true, addressForm: "du", monthlyTokenLimit: 100 }); await expectErr(() => sendLotseMessage(f.ctx.tech, { text: "hallo" }, { provider: offProvider }), "blocked", "Monatskontingent aufgebraucht → blocked", "budget_exceeded"); await updateLotseSettings(f.ctx.admin, { enabled: true, addressForm: "du", monthlyTokenLimit: null }); const loop: FakeChatStep[] = Array.from({ length: 10 }, () => ({ toolCalls: [{ name: "get_running_clock", input: {} }] })); const rounds = await send(f.ctx.tech, "Uhr?", loop, { maxRounds: 3 }); ok(rounds.provider.calls.length === 3 && rounds.last.content.noticeKey === "rounds_exceeded", "Rundengrenze: nach 3 Schritten Schluss mit Hinweis"); const heavy: FakeChatStep[] = Array.from({ length: 10 }, () => ({ toolCalls: [{ name: "get_running_clock", input: {} }], tokens: { input: 9000, output: 1000 } })); const tokens = await send(f.ctx.tech, "Uhr?", heavy, { turnTokenLimit: 15_000 }); ok(tokens.provider.calls.length === 2 && tokens.last.content.noticeKey === "turn_budget", "Token-Grenze je Nachricht greift"); ok(tokens.provider.calls[1].system.includes("„du“"), "Anrede aus der Lotse-Einstellung"); const failed = await send(f.ctx.tech, "hallo", [], { fail: new Error("503") }); ok(failed.last.role === "assistant" && failed.last.content.noticeKey === "provider_failed", "Anbieterfehler → Hinweis statt Absturz, Eingabe gespeichert"); const protocol = await listAiGenerations(f.ctx.admin); ok(protocol.items.some((i) => i.kind === "lotse_chat"), "KI-Protokoll zeigt Chat-Generierungen"); const audit = await prisma.auditLog.count({ where: { tenantId: f.tA.id, entity: "lotse_chat_message" } }); ok(audit > 10, "Audit je Chat-Antwort"); console.log("\n— Aufbewahrung —"); const future = new Date(Date.now() + 400 * 86_400_000); const purge = await purgeExpiredAiGenerations({ now: future, tenantIds: [f.tA.id] }); ok(purge.chatConversations > 0 && (await prisma.lotseConversation.count({ where: { tenantId: f.tA.id } })) === 0, `Chatverläufe nach Frist gelöscht (${purge.chatConversations})`); ok((await prisma.lotseMessage.count({ where: { tenantId: f.tA.id } })) === 0 && (await prisma.lotseActionProposal.count({ where: { tenantId: f.tA.id } })) === 0, "Nachrichten und Karten mitgelöscht"); ok((await prisma.lotseConversation.count({ where: { tenantId: f.tB.id } })) === 1, "Mandant B unberührt"); await cleanupChatTenants(PREFIX); console.log(failures ? `\n${failures} Fehler` : "\nAlle Prüfungen grün."); await prisma.$disconnect(); process.exit(failures ? 1 : 0); } main().catch(async (err) => { console.error(err); await cleanupChatTenants(PREFIX).catch(() => undefined); await prisma.$disconnect(); process.exit(1); });