diff --git a/docs/craftvia/lanes/lotse-chat.md b/docs/craftvia/lanes/lotse-chat.md new file mode 100644 index 0000000..e97d05d --- /dev/null +++ b/docs/craftvia/lanes/lotse-chat.md @@ -0,0 +1,103 @@ +# Lane L16 – Lotse-Chat für Monteure (`lane/lotse-chat`) + +Stand: 2026-09-15 · Basis `6b8cdf5` (`feature/craftvia-mvp`, L1–L14 integriert) · Spec §15, §27, §31 · Brandbook §4.3, §9, §11.4, §12 · ARCHITEKTUR §4.5, §4.8 + +Ziel: In der mobilen Web-App schreibt oder spricht der Monteur wie in einem Chat („Auftrag fertig, Speicher installiert, 2 Std., 1 Filter verbaut“). Der Lotse ordnet den Auftrag zu, fragt fehlende Angaben nach und schlägt konkrete Aktionen vor. **Er führt nichts selbst aus** – jede Änderung braucht den Tipp des Monteurs auf „Bestätigen“. + +## 1. Umfang / erfüllte Auftragspunkte + +| Auftrag | Umsetzung | +|---|---| +| Datenmodell | `LotseConversation` (Mandant, Nutzer, optionaler Auftragskontext, `lastMessageAt`), `LotseMessage` (Rolle `user`/`assistant`/`tool`, Text, `content` JSON mit Chips, Karten-IDs, Sprungzielen, Modell-Fassung), `LotseActionProposal` (Art, Nutzlast, HMAC-`payloadHash`, Status `proposed/confirmed/discarded/expired/failed`, `expiresAt` = +30 min, Ergebnis/Fehlercode, `confirmedAt/By`, `decidedAt`). `TenantSettings.lotseChatEnabled` (Default an). Alle Tabellen mit `tenant_id` + RLS, in beiden `TENANT_MODELS`, Personenfelder in `pii-fields.ts`. Verlauf nur für den eigenen Nutzer (jede Abfrage filtert `userId = ctx.userId`). | +| Aufbewahrung | `services/lotse/retention.ts` (bestehender Job `ai-retention`): Gespräche mit `lastMessageAt` älter als `AI_GENERATION_RETENTION_DAYS` werden samt Nachrichten und Karten gelöscht (Chatverlauf = Protokolldaten eines Nutzers), offene abgelaufene Karten → `expired`; Audit `lotse_chat_retention`. | +| Provider | `ai/lotse/chat-anthropic.ts`: ein Schritt der Tool-Use-Schleife über das Anthropic SDK (`beta.messages.create`, Modell `ANTHROPIC_MODEL`), Opus 5 ohne Sampling-Parameter mit `effort: "medium"`, serverseitiger Refusal-Fallback wie L9, Timeout 60 s. Denkblöcke des Modells werden innerhalb einer Schleife unverändert zurückgegeben; über Nachrichten hinweg nur Text. `ai/lotse/chat-fake.ts` spielt geskriptete Tool-Aufrufe ab und zeichnet jeden Modell-Input auf. Ohne Key: „Lotse ist nicht eingerichtet“ (Senden gesperrt, Verlauf sichtbar). | +| Schleife & Budget | `services/lotse/chat/engine.ts`: höchstens 6 Modellschritte je Nachricht, Tokengrenze je Nachricht (`LOTSE_CHAT_TURN_TOKEN_LIMIT`, Default 80 000), Monatskontingent des Mandanten vor dem ersten Schritt (`budget.ts`, `blocked budget_exceeded`). Überschreitung → Klartext-Hinweis statt Absturz. Jede Nachricht mit Modellaufruf = **eine** `AiGeneration` (Art `lotse_chat`, Input = exakt gesendete minimierte Turns + System-Prompt + Werkzeugnamen, Output = alle Runden, Tokens summiert). | +| System-Prompt | `ai/lotse/chat-prompt.ts`: Deutsch, erfahrener Kollege, knapp (≤ 3 Sätze), Anrede aus Lotse-Einstellung (neutral/Sie/du), nie ausführen/nie „gebucht“ behaupten, nie raten → `ask_user` mit Auswahl, L12-Regeln (Grund, Freigabe, 7 Tage), Platzhalter unverändert übernehmen, Werkzeuginhalte sind Daten. Lage: Datum/Uhrzeit in Mandanten-Zeitzone, Kontext-Auftrag, laufende Uhr. | +| Datenminimierung | Nutzertext: Muster Telefon/E-Mail/Anschrift + Namen der Mitarbeitenden (`minimize.ts#scrubText`); Suchwörter wie „Müller“ bleiben, damit die Zuordnung funktioniert. Werkzeugergebnisse: alle Freitexte des Auftrags (Titel, Beschreibung, Hinweise, Checkliste, Pflichtfotos) über `scrubText` mit den Literalwerten der betroffenen Aufträge (Kunde, Kontakte, Objekt, Mandant); Kundennamen, Objektnamen, Telefon, E-Mail, Anschrift und Ansprechpartner **nur als Platzhalter** `{{phone:A-00042}}`. | +| Platzhalter-Technik (Begründung) | Fragt der Monteur ausdrücklich („Telefonnummer vom Ansprechpartner?“), übernimmt das Modell das Token in die Antwort; `chat/placeholders.ts#renderPlaceholders` setzt **serverseitig** den Wert ein – mit derselben Rangfolge wie das Auftragsdetail und erneuter Scope-Prüfung (fremd/unsichtbar → „—“). So ist die Frage im Chat beantwortbar, ohne dass der Wert je das Modell erreicht; das Modell kann einen Wert, den es nie gesehen hat, weder weitergeben noch verfälschen. Für den nächsten Turn wird die Token-Fassung (`content.modelText`) gesendet. | +| Lese-Werkzeuge | `list_my_orders` (heute + bis 7 Tage, laufende), `get_order_details` (Status, Zeitfenster, Beschreibung, Hinweise, Objekt-Hinweise inkl. Zugang, Checkliste, Plan-/Zusatzmaterial, Pflichtfotos, eigene Uhr, verfügbare Platzhalter), `get_running_clock`, `check_completeness` (L9 `completeness.ts`, Sprungziele), `search_material` (Planpositionen + bisher verwendete Bezeichnungen im Scope), `ask_user` (Rückfrage mit Chips, beendet die Schleife). Alles mit Monteur-ctx im Scope von `visibility.ts`. | +| Vorschlags-Werkzeuge | `transition_work_order` (annehmen, Anfahrt, Arbeit starten, Pause, fortsetzen, technisch abschließen; Abschluss prüft Blocker vorab → keine Karte, Sprungziele), `book_time` (work/travel/return_travel/material_procurement, Dauer bis jetzt oder von–bis, Datum; Grund Pflicht; 7-Tage-/Zukunft-/16-h-/Überschneidungsprüfung vorab), `record_material` (Planposition per Name, Status aus Planmenge, Abweichung/Zusatzmaterial mit Grund; `validateMaterialUsage` von L4), `add_note` (Art + Text), `suggest_report_fields` (nur mit offenem Bericht). Fehlende Pflichtwerte → `missing_values`, **keine Karte**. | +| Auftragszuordnung | `chat/orders.ts#resolveOrder`: Suchbegriff (Nummer auch als Ziffern, Kunde, Objekt, Ort, Titel) > Kontext-Auftrag > Auftrag der laufenden Uhr > einziger heutiger Auftrag; mehrdeutig → `order_ambiguous` + Auswahl-Chips (Beschriftung mit Kunde nur für den Monteur, Wert „Auftrag A-00042“). | +| Bestätigen | `chat/confirm.ts`: Eigentümer (sonst `not_found`), Status `proposed`, Ablauf (→ `expired`), HMAC-Hash (→ `failed tampered` + Audit `denied`), Bearbeiten nur für freigegebene Felder (Auftrag/IDs fix, Hash neu). Ein atomares Row-Update beansprucht die Karte (Doppeltipp/parallel → `idempotent`), danach Ausführung **über die bestehenden Services** mit Monteur-ctx: `transitionWorkOrder`, `startSession/pauseSession/resumeSession/endSession` (inkl. L12-Auto-Wechsel, auf der Karte angekündigt), `addManualTimeEntry` (eigene Zeit → `pending`, Freigabepflicht), `upsertMaterialUsage`, `createNote`, Berichtsvorschlag in `content.lotse` (L9-Mechanismus: im Bericht übernehmen/verwerfen, Prüfbestätigung beim Absenden). Service-Fehler → Karte `failed` mit Klartext-Code + Lotse-Hinweis mit Sprungzielen (Pflichtfoto, Checkliste, Unterschrift, Meine Zeiten, Material, Bericht). Audit `lotse_action_proposal` before/after mit `source: "lotse_chat"`. „Alle bestätigen“: feste Reihenfolge Zeit → Material → Notiz → Bericht → Status, Stopp beim ersten Fehler mit Hinweis „N Karten noch offen“. | +| Keine äußere Transaktion (Entscheidung) | Die Services öffnen eigene Transaktionen und senden Benachrichtigungen (Mail). Eine umschließende Transaktion hätte die DB-Transaktion über den Mailversand offen gehalten (5-s-Timeout, im Gate beobachtet). Deshalb: Karte atomar beanspruchen, Abschluss-Blocker vor jeder Änderung prüfen, dann Services einzeln. | +| UI mobil | `/m/lotse` (Verlauf, Eingabe, Senden, „Neuer Chat“, Mikrofon-Taste: Aufnahme ≤ 2 min → `POST /api/v1/lotse/transcribe` → Transkript im Eingabefeld editierbar; ohne Transkriptionsanbieter deaktiviert mit Hinweis). Aktionskarten mit klaren Werten (z. B. „Arbeitszeit · Auftrag A-00042 · Di., 15.09. · 13:05–15:05 Uhr · 2:00 Std. · Grund … · Zählt nach Freigabe“), Bestätigen/Bearbeiten/Verwerfen, Status als Text + Icon, „Gültig bis“. Chips nur an der letzten Antwort, Sprungziele als Liste. Offline: Hinweis, Eingabe bleibt (auch `localStorage`), nichts wird gesendet/ausgeführt. Einstieg: Bottom-Navigation „Lotse“ (nur wenn nutzbar) und „Lotse fragen“ im Auftragsdetail (Kontext). Touch ≥ 48 px, Farben nur Tokens, Lotse-Mark in Signalorange, Signalorange sonst nur an Primärknöpfen/offenen Karten. | +| Einstellungen | `/settings/lotse`: Schalter „Lotse-Chat für Monteure“ (wirkt nur bei eingeschaltetem Lotse), Datenfluss um Chat und Spracheingabe ergänzt, KI-Protokoll zeigt Art „Lotse-Chat“ (Transkriptionen der Spracheingabe: Art `transcription`, `entityType lotse_chat`, ohne Inhalt). | + +## 2. Routen / Screens + +| Route | Rolle | Inhalt | +|---|---|---| +| `/m/lotse` | Monteur, Teamleiter (`lotse:use` + `field:execute`) | Chat ohne Auftragsbezug; ausgeschaltet → Hinweisseite | +| `/m/lotse?order=` | dito | Chat mit Auftragskontext; fremder/unsichtbarer Auftrag → 404 | +| `/m/orders/[id]` | dito | Button „Lotse fragen“ unter der Vollständigkeits-Karte | +| Bottom-Navigation | dito | Eintrag „Lotse“ (6 Einträge, nur wenn Chat nutzbar) | +| `/settings/lotse` | Mandantenadministrator | Schalter + Datenfluss | +| `POST /api/v1/lotse/transcribe` | dito | multipart `file` → `{ text }` (Audio wird nicht gespeichert), OpenAPI ergänzt | + +Server Actions (`actions/lotse/chat.ts`, `moduleGuard("lotse")` + `lotse:use`, `field:execute`): senden, bestätigen (optional mit Änderungen), verwerfen, alle bestätigen, neuer Chat – jeweils mit frischer Chat-Ansicht als Ergebnis. + +## 3. Dateien + +**Neu (Ownership L16 / Lotse-Pfade)** +- `prisma/migrations/20260916200000_lotse_chat/migration.sql` +- `src/lib/lotse/chat.ts` (client-safe: Kartenarten, Zod-Schemas, Ansichtstypen) +- `src/server/ai/lotse/{chat-types,chat-prompt,chat-fake,chat-anthropic}.ts` +- `src/server/services/lotse/chat/{access,orders,placeholders,proposals,tools,engine,conversations,confirm,transcribe}.ts` +- `src/server/actions/lotse/{chat,_chat-state}.ts`, `src/app/api/v1/lotse/transcribe/route.ts` +- `src/app/(field)/m/(core)/lotse/{layout,page}.tsx` +- `src/components/lotse/chat/{lotse-chat,proposal-card,mic-button,ask-button}.tsx` +- `scripts/test-lotse-chat-engine.ts`, `scripts/test-lotse-chat-confirm.ts`, `scripts/test-lotse-chat-live.ts`, `scripts/lib/lotse-chat-fixture.ts` + +**Geändert in Lotse-Pfaden (erlaubt laut Auftrag)** +- `src/server/services/lotse/{settings,retention}.ts`, `src/server/actions/lotse-settings.ts`, `src/app/(app)/settings/lotse/page.tsx`, `messages/{de,en}/lotse.json` + +**Fremdeingriffe (je klein, markiert „L16“)** +- `prisma/schema.prisma` (3 Modelle, 2 Enums, 1 Spalte) · `src/server/db.ts` + `src/server/backup/topology.ts` (TENANT_MODELS) · `src/server/dsgvo/pii-fields.ts` (3 Felder) +- `src/components/field/bottom-nav.tsx` (Eintrag + Prop `lotse`), `src/app/(field)/m/layout.tsx` (Flag `lotseChat`), `messages/{de,en}/field.json` (`nav.lotse`) +- `src/app/(field)/m/(core)/orders/[id]/page.tsx` (Import + 1 Zeile Button) +- `src/lib/api/openapi.ts` (Pfad `/lotse/transcribe` – `test-betrieb-api` verlangt jede Route im Dokument) +- `src/components/audit-trail.tsx` (Entity-Labels) +- `scripts/test-e2e-tenant-isolation.ts` (je eine Zeile für die 3 neuen Tenant-Modelle – der Test verlangt vollständige Abdeckung aller TENANT_MODELS) +- `scripts/smoke-auth.ts` (Monteur-/Admin-Prüfungen) + +Keine Änderung an `rbac.ts` (bestehende Rechte `lotse:use` + `field:execute` + `field:record_own_time`/`report:write` genügen), keine neuen npm-Abhängigkeiten, keine Stubs. + +## 4. Schema / Migration + +`20260916200000_lotse_chat` (additiv): Enums `LotseMessageRole`, `LotseProposalStatus`; Tabellen `lotse_conversations`, `lotse_messages`, `lotse_action_proposals` (Kaskade von Gespräch zu Nachrichten/Karten), `tenant_settings.lotse_chat_enabled BOOLEAN DEFAULT true`; `SELECT enable_tenant_rls(...)` für alle drei Tabellen. **Begründung:** Chatverlauf und bestätigungspflichtige Vorschläge brauchen persistente, nutzerbezogene Datensätze mit Ablauf, Ergebnis und Audit-Bezug – weder `AiGeneration` (Protokoll, wird pseudonymisiert) noch Report-`content` passen. `workOrderId` ohne Fremdschlüssel (weiche Referenz, Auftrag kann soft-gelöscht werden; Sichtbarkeit wird bei jedem Lesen neu geprüft). Deploy: `prisma migrate deploy` (keine Rechte-Synchronisation nötig). + +## 5. Tests + +| Skript | Prüfungen | Ergebnis | +|---|---|---| +| `test-lotse-chat-engine.ts` | 75: System-Prompt, Suchwörter; Zuordnung Kontext > laufende Uhr, Suchbegriff Kunde/Objekt/Nummer, Mehrdeutigkeit → Chips (Beschriftung mit Kunde, Wert ohne Namen), `ask_user` beendet Schleife; vier Karten aus einer Nachricht ohne jede Änderung an Zeiten/Material/Notizen/Status; fehlende Pflichtwerte (Grund, Dauer, Zusatzmaterial-/Mindermengen-Grund) → keine Karte; Überschneidung mit laufender Uhr, Pflichtfoto-Blocker mit Sprungziel, kein offener Bericht → keine Karte; **Datenminimierung**: Modell-Input ohne Telefon/E-Mail/Anschrift/PLZ/Kunden-, Kontakt-, Firmen-, Mitarbeiternamen/Mandantenkontakt, Platzhalter-Token vorhanden, fachliche Hinweise erhalten, Wert serverseitig eingesetzt, AiGeneration minimiert; Scope (Monteur ohne Zuweisung → not_found / Auftrag nicht gefunden), fremder Verlauf im selben Mandanten und aus Mandant B → not_found, B findet A-Aufträge nicht, ohne `lotse:use` → forbidden; Chat-Schalter aus / Modul aus → `blocked`, kein Modellaufruf, Audit; ohne Anbieter → not_configured; Monatskontingent → `budget_exceeded`; Rundengrenze; Tokengrenze; Anbieterfehler → Hinweis; Protokoll zeigt `lotse_chat`; Aufbewahrung löscht Verläufe nur des Mandanten | grün | +| `test-lotse-chat-confirm.ts` | 52: Annehmen/Arbeit starten über Services (Statuswechsel, Session, Akteur), Doppeltipp idempotent, Audit mit Quelle; Zeitnachtrag `pending` mit Grund, Bearbeiten (nur erlaubte Felder, Hash aktualisiert, Audit `edited`), Überschneidung → `failed overlap`; Material, Notiz (paralleler Doppeltipp → genau eine Notiz); Abschluss mit fehlendem Pflichtfoto → `failed blocked`, Auftrag + Session unverändert, Sprungziel; erneutes Bestätigen → `already_decided`; Ablauf nach 30 min; fremder Monteur/Admin/Mandant B → not_found; manipulierte Nutzlast → `tampered`; Verwerfen (idempotent, danach nicht bestätigbar); Monteur ohne Zuweisung → Service verweigert; „Alle bestätigen“ Reihenfolge + Stopp + zweiter Lauf; Berichtsvorschlag in `content.lotse` und Übernahme per L9; Chat aus / Modul aus → blocked | grün | +| `test-lotse-chat-live.ts` | optional gegen Claude, nur mit `ANTHROPIC_API_KEY` | übersprungen (kein Key) | + +Angepasst: `test-e2e-tenant-isolation.ts` (Fixture um die 3 Tenant-Modelle ergänzt) – grün. + +**Gate:** `npm run gate` grün – prisma generate, tsc, lint (0 Fehler, 3 bestehende Warnungen außerhalb L16), build inkl. Modul-Guard-Check (37 Action-Dateien), **79/79 Testskripte**. Im ersten Lauf schlugen `test-e2e-tenant-isolation` (fehlende Fixture-Zeilen, behoben) und `test-einsatz-field` fehl (Transaktions-Timeout 5 s beim Mailversand innerhalb einer L12-Session-Transaktion unter Last paralleler Lanes; einzeln grün, im zweiten Gate grün – bestehendes Verhalten, siehe L12-Bericht §5.5). + +**RLS-Lauf** `RLS_ENFORCED=true` (Lane-DB `craftvia_lotsechat`, Rolle `craftvia_app`): `test-lotse-chat-engine`, `test-lotse-chat-confirm`, `test-e2e-tenant-isolation`, `test-tenant-isolation`, `test-rls-enforcement` – 5/5 grün. + +**HTTP-Smoke** (`npx next start -p 3116`, `scripts/smoke-auth.ts`, Demo-Seed): Monteur **37/37** grün (neu: `/m/lotse` mit „Lotse-Chat“, „Neuer Chat“, „Sprechen“; `/m/lotse?order=` mit Auftragsnummer; Auftragsdetail mit „Lotse fragen“; `/m/lotse?order=` → 404), Admin **12/12** grün (`/settings/lotse` mit „Lotse-Chat für Monteure“, Protokoll). Server danach beendet. + +Visuelle Browserprüfung nicht durchgeführt (Anmeldung hätte Passwort- oder Token-Eingabe durch den Agenten erfordert). + +## 6. Stubs & Abhängigkeiten + +Keine Stubs. Genutzt: L2 `transitionWorkOrder`/`computeCompletionBlockers`/`workOrderScope`, L4 `requireFieldOrder`/`createNote`/`upsertMaterialUsage`/`validateMaterialUsage`/`sniffMime`, L12 `startSession`/`pauseSession`/`resumeSession`/`endSession`/`getMyActiveSession`/`addManualTimeEntry`/`earliestStart`, L5 `requireVisibleReport`/`contentOf`, L9 `checkCompleteness`/`scrubText`/`lotseVoice`/`isLotseEnabled`/Transkriptionsanbieter, L10b `assertTokenBudget`/`requireApiContext`/Aufbewahrungsjob. + +## 7. Bekannte Lücken / Hinweise an den Architekten + +1. **Live-Verhalten mit Claude ungeprüft** (kein Key): Tool-Definitionen, Prompt und Denkblock-Rückgabe sind typgeprüft und gegen den Fake getestet; `scripts/test-lotse-chat-live.ts` läuft, sobald ein Key gesetzt ist. Prompt-Feinschliff (Tonalität, Rückfragen) nach ersten echten Gesprächen empfohlen. +2. **Kein `/api/v1` für Senden/Bestätigen** – die mobile UI nutzt Server Actions (wie L9); nur die Transkription ist eine API-Route (Upload > Server-Action-Limit). Für native Clients ggf. nachziehen. +3. **Doppeltipp während der Ausführung:** Der zweite Tipp erhält sofort `confirmed (idempotent)`, auch wenn die Ausführung danach noch scheitert; die Karte zeigt nach dem Neuladen `failed`. +4. **Abschluss nicht vollständig atomar:** Blocker werden vorab geprüft; scheitert der Statuswechsel danach aus anderem Grund (z. B. parallele Änderung), bleibt die eigene Uhr beendet – wie beim bestehenden Zwei-Schritt-Flow der Primäraktion. +5. **Audit der Fachentitäten ohne Quelle:** Die Services schreiben ihre Audits unverändert; die Verbindung zur Chat-Karte steht im Audit `lotse_action_proposal` (Ergebnis-IDs, `source: "lotse_chat"`). Ein `source`-Feld im Fundament-Audit wäre sauberer. +6. **Gespeicherte Antworten enthalten eingesetzte Werte** (z. B. Telefonnummer), sichtbar nur für den Nutzer, gelöscht mit der Aufbewahrung. Das Modell sieht sie nie. +7. **Namen im Freitext des Monteurs** werden (außer Mitarbeitenden) nicht ersetzt, damit die Suche „Objekt Müller“ funktioniert; Telefon/E-Mail/Anschrift schon. +8. **Zeit „2 Std.“ über Mitternacht** → Rückfrage nach der Uhrzeit (kein Raten des Vortags). +9. **Verlauf ans Modell:** letzte 20 Nachrichten als Text + Kartenstatus; Werkzeugergebnisse früherer Nachrichten werden nicht erneut gesendet (Kosten, Datenminimierung). +10. **Bottom-Navigation mit 6 Einträgen** (~62 px je Eintrag bei 375 px) – bei weiteren Einträgen Profil/Sync zusammenlegen. +11. Offline gibt es bewusst keine Warteschlange für Chat-Nachrichten (Eingabe bleibt lokal erhalten). diff --git a/messages/de/field.json b/messages/de/field.json index c1b3f21..f4cff5d 100644 --- a/messages/de/field.json +++ b/messages/de/field.json @@ -6,7 +6,8 @@ "emergency": "Notdienst", "sync": "Sync", "profile": "Profil", - "approvalsBadge": "{count} Zeiten zur Freigabe" + "approvalsBadge": "{count} Zeiten zur Freigabe", + "lotse": "Lotse" }, "connection": { "online": "Online", diff --git a/messages/de/lotse.json b/messages/de/lotse.json index 313ff19..be3cd64 100644 --- a/messages/de/lotse.json +++ b/messages/de/lotse.json @@ -89,6 +89,8 @@ "use": "Verwendung", "enabled": "Lotse für diesen Betrieb verwenden", "enabledHint": "Ausgeschaltet: keine Berichtsentwürfe, keine Zusammenfassungen, Sprachnotizen ohne Transkription.", + "chatEnabled": "Lotse-Chat für Monteure", + "chatEnabledHint": "Monteure schreiben oder sprechen mit dem Lotsen. Aktionen (Status, Zeiten, Material, Notizen) werden nur nach Bestätigung durch den Monteur ausgeführt.", "addressForm": "Anrede des Lotsen", "addressFormHint": "Gilt einheitlich für alle Texte des Lotsen in diesem Betrieb.", "address": { @@ -114,7 +116,9 @@ "order": "Auftragstitel, Beschreibung und Leistungsumfang", "notes": "Tätigkeitsnotizen und Texte aus Sprachnotizen", "work": "Checkliste, Material mit Abweichungsgründen, Fotokommentare, Arbeitszeiten", - "audio": "Für die Transkription: die Audiodatei der Sprachnotiz" + "audio": "Für die Transkription: die Audiodatei der Sprachnotiz", + "chat": "Lotse-Chat: Nachrichten des Monteurs (Telefonnummern, E-Mail-Adressen und Anschriften ersetzt) und die Auftragsdaten oben. Kundennamen und Kontaktdaten nur als Platzhalter – eingesetzt wird erst in der Antwort auf dem Gerät des Monteurs.", + "chatAudio": "Spracheingabe im Chat: die Aufnahme zur Transkription (wird nicht gespeichert)" }, "notSentTitle": "Nicht gesendet wird", "notSent": { @@ -152,7 +156,172 @@ "voice_summary": "Zusammenfassung Sprachnotiz", "transcription": "Transkription", "import_extraction": "Auftragsimport", - "completeness_check": "Vollständigkeitsprüfung" + "completeness_check": "Vollständigkeitsprüfung", + "lotse_chat": "Lotse-Chat" + } + }, + "chat": { + "title": "Lotse-Chat", + "intro": "Kurz schreiben oder sprechen, was erledigt ist – z. B. „Auftrag fertig, Speicher installiert, 2 Std., 1 Filter verbaut“. Der Lotse schlägt Aktionen vor. Gespeichert wird erst nach Bestätigung.", + "context": "Auftrag {number} · {title}", + "noContext": "Ohne Auftragsbezug", + "ask": "Lotse fragen", + "input": "Nachricht an den Lotsen", + "send": "Senden", + "sending": "Lotse denkt nach …", + "newChat": "Neuer Chat", + "userLabel": "Eigene Nachricht", + "lotseLabel": "Lotse", + "empty": "Noch keine Nachrichten.", + "notConfigured": "Lotse ist nicht eingerichtet. Das Büro kann den KI-Anbieter hinterlegen.", + "offline": "Keine Verbindung. Der Lotse braucht Internet – die Eingabe bleibt erhalten.", + "chipsLabel": "Antwortvorschläge", + "linksLabel": "Direkt ergänzen", + "confirmAll": "Alle bestätigen ({count})", + "mic": { + "record": "Sprechen", + "stop": "Aufnahme beenden", + "recording": "Aufnahme läuft · {time}", + "transcribing": "Wird in Text umgewandelt …", + "unavailable": "Spracheingabe ist nicht eingerichtet.", + "denied": "Kein Zugriff auf das Mikrofon.", + "unsupported": "Spracheingabe wird auf diesem Gerät nicht unterstützt.", + "failed": "Umwandlung in Text fehlgeschlagen. Bitte tippen.", + "hint": "Text vor dem Senden prüfen." + }, + "card": { + "confirm": "Bestätigen", + "edit": "Bearbeiten", + "discard": "Verwerfen", + "saveConfirm": "Übernehmen und bestätigen", + "cancel": "Abbrechen", + "busy": "Wird ausgeführt …", + "validUntil": "Gültig bis {time} Uhr", + "status": { + "proposed": "Offen – bitte bestätigen", + "confirmed": "Ausgeführt", + "discarded": "Verworfen", + "expired": "Abgelaufen", + "failed": "Nicht ausgeführt" + }, + "kind": { + "transition_work_order": "Status ändern", + "book_time": "Zeit buchen", + "record_material": "Material erfassen", + "add_note": "Notiz speichern", + "suggest_report_fields": "Berichtsvorschlag" + }, + "action": { + "accept": "Auftrag {number} annehmen", + "start_travel": "Anfahrt zu {number} starten", + "start_work": "Arbeit an {number} starten", + "pause": "Pause bei {number}", + "resume": "Arbeit an {number} fortsetzen", + "complete": "Auftrag {number} technisch abschließen" + }, + "switchFrom": "Die laufende Uhr von {number} wird pausiert.", + "completeHint": "Die eigene Uhr wird beendet. Fehlende Pflichtangaben verhindern den Abschluss.", + "order": "Auftrag {number}", + "timeLine": "{date} · {from}–{to} Uhr · {duration} Std.", + "timeType": { + "work": "Arbeitszeit", + "travel": "Anfahrt", + "return_travel": "Rückfahrt", + "material_procurement": "Material holen" + }, + "type": "Art", + "date": "Datum", + "from": "Von", + "to": "Bis", + "reason": "Grund", + "note": "Notiz", + "approvalHint": "Zählt nach Freigabe durch Teamleiter oder Büro.", + "pendingApproval": "Zur Freigabe eingereicht", + "planned": "Geplante Position", + "additional": "Zusatzmaterial", + "name": "Bezeichnung", + "quantity": "Menge", + "unit": "Einheit", + "deviationReason": "Grund der Abweichung", + "noteKind": "Art der Notiz", + "text": "Text", + "reportFields": "Vorschläge für den {type}", + "reportType": { + "daily": "Tagesbericht", + "completion": "Abschlussbericht" + }, + "reportHint": "Erscheint im Bericht als „Vorschlag vom Lotsen“ und wird dort übernommen oder verworfen.", + "openReport": "Bericht öffnen", + "field": { + "workPerformed": "Ausgeführte Leistungen", + "deviations": "Abweichungen", + "additionalWork": "Zusatzarbeiten", + "openItems": "Offene Punkte", + "nextSteps": "Nächste Schritte", + "hints": "Hinweise" + } + }, + "noteKind": { + "work_done": "Erledigte Arbeit", + "deviation": "Abweichung", + "problem": "Problem", + "additional_work": "Zusatzarbeit", + "not_executable": "Nicht ausführbar", + "follow_up": "Nacharbeit", + "recommendation": "Empfehlung", + "customer_note": "Hinweis vom Kunden", + "general": "Allgemein" + }, + "system": { + "failed": "Nicht ausgeführt ({number}): {reason}", + "stopped": "„Alle bestätigen“ gestoppt – {remaining, plural, one {# Karte ist} other {# Karten sind}} noch offen.", + "rounds_exceeded": "Das war zu umfangreich. Bitte kürzer oder in Schritten schreiben.", + "turn_budget": "Das war zu umfangreich. Bitte kürzer oder in Schritten schreiben.", + "refusal": "Dazu kann der Lotse nichts vorschlagen.", + "provider_failed": "Lotse ist gerade nicht erreichbar. Bitte später erneut versuchen.", + "proposals_ready": "Vorschläge liegen bereit. Bitte prüfen und bestätigen.", + "empty": "Keine Antwort erhalten. Bitte anders formulieren." + }, + "links": { + "order": "Auftrag öffnen", + "report": "Bericht öffnen", + "myTime": "Meine Zeiten öffnen", + "materials": "Material öffnen", + "otherOrder": "Laufenden Auftrag öffnen", + "runningSession": "Uhr eines Kollegen läuft noch" + }, + "errors": { + "generic": "Das hat nicht geklappt. Bitte erneut versuchen.", + "not_found": "Nicht gefunden.", + "forbidden": "Dafür fehlt die Berechtigung.", + "disabled": "Lotse ist für diesen Betrieb ausgeschaltet.", + "chat_disabled": "Der Lotse-Chat ist für diesen Betrieb ausgeschaltet.", + "not_configured": "Lotse ist nicht eingerichtet.", + "provider_failed": "Lotse ist gerade nicht erreichbar. Bitte später erneut versuchen.", + "budget_exceeded": "Das monatliche KI-Kontingent des Betriebs ist aufgebraucht.", + "invalid": "Bitte Eingaben prüfen.", + "conflict": "Inzwischen geändert. Bitte neu laden.", + "expired": "Vorschlag abgelaufen (nach 30 Minuten). Bitte neu anfragen.", + "already_decided": "Dieser Vorschlag ist schon erledigt.", + "tampered": "Der Vorschlag wurde verändert und nicht ausgeführt.", + "blocked": "Pflichtangaben fehlen.", + "offline": "Keine Verbindung – nichts gesendet.", + "overlap": "Der Zeitraum überschneidet sich mit einer erfassten Zeit.", + "too_old": "Nachträge sind nur 7 Tage rückwirkend möglich.", + "in_future": "Zeiten in der Zukunft können nicht gebucht werden.", + "too_long": "Ein Eintrag darf höchstens 16 Stunden lang sein.", + "end_before_start": "Das Ende liegt vor dem Beginn.", + "work_order_status": "Im aktuellen Auftragsstatus nicht möglich.", + "other_session_running": "Auf einem anderen Auftrag läuft noch die Uhr.", + "transition_not_allowed": "Dieser Statuswechsel ist gerade nicht möglich.", + "transition_forbidden": "Dafür fehlt die Berechtigung.", + "not_editable": "Der Bericht ist eingereicht und kann nicht mehr geändert werden.", + "reason_required": "Der Grund fehlt.", + "use_billing_overview": "Nur über die Abrechnungsübersicht möglich." + }, + "unavailable": { + "title": "Lotse-Chat nicht verfügbar", + "back": "Zur Übersicht" } } } diff --git a/messages/en/field.json b/messages/en/field.json index 57a6825..9863338 100644 --- a/messages/en/field.json +++ b/messages/en/field.json @@ -6,7 +6,8 @@ "emergency": "Emergency", "sync": "Sync", "profile": "Profile", - "approvalsBadge": "{count} time entries to approve" + "approvalsBadge": "{count} time entries to approve", + "lotse": "Lotse" }, "connection": { "online": "Online", diff --git a/messages/en/lotse.json b/messages/en/lotse.json index 00b78a4..aa89269 100644 --- a/messages/en/lotse.json +++ b/messages/en/lotse.json @@ -89,6 +89,8 @@ "use": "Usage", "enabled": "Use Lotse in this business", "enabledHint": "Switched off: no report drafts, no summaries, voice notes without transcription.", + "chatEnabled": "Lotse chat for technicians", + "chatEnabledHint": "Technicians write or talk to the Lotse. Actions (status, times, material, notes) are only executed after the technician confirms them.", "addressForm": "Lotse form of address", "addressFormHint": "Applies consistently to all Lotse texts in this business.", "address": { @@ -114,7 +116,9 @@ "order": "Work order title, description and scope", "notes": "Activity notes and voice note texts", "work": "Checklist, material with deviation reasons, photo comments, working times", - "audio": "For transcription: the audio file of the voice note" + "audio": "For transcription: the audio file of the voice note", + "chat": "Lotse chat: the technician's messages (phone numbers, e-mail and postal addresses replaced) and the order data above. Customer names and contact data only as placeholders – values are inserted only in the answer on the technician's device.", + "chatAudio": "Voice input in the chat: the recording for transcription (not stored)" }, "notSentTitle": "Not sent", "notSent": { @@ -152,7 +156,172 @@ "voice_summary": "Voice note summary", "transcription": "Transcription", "import_extraction": "Order import", - "completeness_check": "Completeness check" + "completeness_check": "Completeness check", + "lotse_chat": "Lotse chat" + } + }, + "chat": { + "title": "Lotse chat", + "intro": "Briefly write or say what is done – e.g. “Order finished, storage tank installed, 2 hrs, 1 filter fitted”. The Lotse proposes actions. Nothing is saved before confirmation.", + "context": "Order {number} · {title}", + "noContext": "No order context", + "ask": "Ask the Lotse", + "input": "Message to the Lotse", + "send": "Send", + "sending": "Lotse is thinking …", + "newChat": "New chat", + "userLabel": "Own message", + "lotseLabel": "Lotse", + "empty": "No messages yet.", + "notConfigured": "The Lotse is not set up. The office can configure the AI provider.", + "offline": "No connection. The Lotse needs internet – your input is kept.", + "chipsLabel": "Suggested answers", + "linksLabel": "Complete directly", + "confirmAll": "Confirm all ({count})", + "mic": { + "record": "Speak", + "stop": "Stop recording", + "recording": "Recording · {time}", + "transcribing": "Converting to text …", + "unavailable": "Voice input is not set up.", + "denied": "No access to the microphone.", + "unsupported": "Voice input is not supported on this device.", + "failed": "Conversion to text failed. Please type.", + "hint": "Check the text before sending." + }, + "card": { + "confirm": "Confirm", + "edit": "Edit", + "discard": "Discard", + "saveConfirm": "Apply and confirm", + "cancel": "Cancel", + "busy": "Running …", + "validUntil": "Valid until {time}", + "status": { + "proposed": "Open – please confirm", + "confirmed": "Done", + "discarded": "Discarded", + "expired": "Expired", + "failed": "Not executed" + }, + "kind": { + "transition_work_order": "Change status", + "book_time": "Book time", + "record_material": "Record material", + "add_note": "Save note", + "suggest_report_fields": "Report suggestion" + }, + "action": { + "accept": "Accept order {number}", + "start_travel": "Start travel to {number}", + "start_work": "Start work on {number}", + "pause": "Pause {number}", + "resume": "Resume work on {number}", + "complete": "Technically complete order {number}" + }, + "switchFrom": "The running clock of {number} will be paused.", + "completeHint": "Your own clock is stopped. Missing mandatory items prevent completion.", + "order": "Order {number}", + "timeLine": "{date} · {from}–{to} · {duration} hrs", + "timeType": { + "work": "Working time", + "travel": "Travel", + "return_travel": "Return travel", + "material_procurement": "Fetching material" + }, + "type": "Type", + "date": "Date", + "from": "From", + "to": "To", + "reason": "Reason", + "note": "Note", + "approvalHint": "Counts after approval by the team lead or office.", + "pendingApproval": "Submitted for approval", + "planned": "Planned item", + "additional": "Additional material", + "name": "Name", + "quantity": "Quantity", + "unit": "Unit", + "deviationReason": "Reason for deviation", + "noteKind": "Note type", + "text": "Text", + "reportFields": "Suggestions for the {type}", + "reportType": { + "daily": "daily report", + "completion": "completion report" + }, + "reportHint": "Appears in the report as “Suggestion from the Lotse” and is accepted or discarded there.", + "openReport": "Open report", + "field": { + "workPerformed": "Work performed", + "deviations": "Deviations", + "additionalWork": "Additional work", + "openItems": "Open items", + "nextSteps": "Next steps", + "hints": "Notes" + } + }, + "noteKind": { + "work_done": "Work done", + "deviation": "Deviation", + "problem": "Problem", + "additional_work": "Additional work", + "not_executable": "Not executable", + "follow_up": "Follow-up", + "recommendation": "Recommendation", + "customer_note": "Customer note", + "general": "General" + }, + "system": { + "failed": "Not executed ({number}): {reason}", + "stopped": "“Confirm all” stopped – {remaining, plural, one {# card is} other {# cards are}} still open.", + "rounds_exceeded": "That was too much at once. Please write shorter or in steps.", + "turn_budget": "That was too much at once. Please write shorter or in steps.", + "refusal": "The Lotse cannot propose anything for this.", + "provider_failed": "The Lotse is not reachable right now. Please try again later.", + "proposals_ready": "Proposals are ready. Please check and confirm.", + "empty": "No answer received. Please rephrase." + }, + "links": { + "order": "Open order", + "report": "Open report", + "myTime": "Open my times", + "materials": "Open material", + "otherOrder": "Open running order", + "runningSession": "A colleague's clock is still running" + }, + "errors": { + "generic": "That did not work. Please try again.", + "not_found": "Not found.", + "forbidden": "You lack the permission.", + "disabled": "The Lotse is switched off for this company.", + "chat_disabled": "The Lotse chat is switched off for this company.", + "not_configured": "The Lotse is not set up.", + "provider_failed": "The Lotse is not reachable right now. Please try again later.", + "budget_exceeded": "The company's monthly AI quota is used up.", + "invalid": "Please check your input.", + "conflict": "Changed in the meantime. Please reload.", + "expired": "Proposal expired (after 30 minutes). Please ask again.", + "already_decided": "This proposal has already been handled.", + "tampered": "The proposal was changed and not executed.", + "blocked": "Mandatory items are missing.", + "offline": "No connection – nothing sent.", + "overlap": "The period overlaps with recorded time.", + "too_old": "Entries can only be added up to 7 days back.", + "in_future": "Times in the future cannot be booked.", + "too_long": "An entry may be at most 16 hours long.", + "end_before_start": "The end is before the start.", + "work_order_status": "Not possible in the current order status.", + "other_session_running": "A clock is still running on another order.", + "transition_not_allowed": "This status change is not possible right now.", + "transition_forbidden": "You lack the permission.", + "not_editable": "The report has been submitted and can no longer be changed.", + "reason_required": "The reason is missing.", + "use_billing_overview": "Only possible via the billing overview." + }, + "unavailable": { + "title": "Lotse chat not available", + "back": "Back to overview" } } } diff --git a/prisma/migrations/20260916200000_lotse_chat/migration.sql b/prisma/migrations/20260916200000_lotse_chat/migration.sql new file mode 100644 index 0000000..7e31472 --- /dev/null +++ b/prisma/migrations/20260916200000_lotse_chat/migration.sql @@ -0,0 +1,88 @@ +-- L16 Lotse-Chat für Monteure: Chatverlauf, Aktionsvorschläge, Schalter je Mandant + +-- CreateEnum +CREATE TYPE "LotseMessageRole" AS ENUM ('user', 'assistant', 'tool'); + +-- CreateEnum +CREATE TYPE "LotseProposalStatus" AS ENUM ('proposed', 'confirmed', 'discarded', 'expired', 'failed'); + +-- AlterTable +ALTER TABLE "tenant_settings" ADD COLUMN "lotse_chat_enabled" BOOLEAN NOT NULL DEFAULT true; + +-- CreateTable +CREATE TABLE "lotse_conversations" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "work_order_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "last_message_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "lotse_conversations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "lotse_messages" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "conversation_id" TEXT NOT NULL, + "role" "LotseMessageRole" NOT NULL, + "text" TEXT NOT NULL, + "content" JSONB, + "ai_generation_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "lotse_messages_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "lotse_action_proposals" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "conversation_id" TEXT NOT NULL, + "message_id" TEXT, + "user_id" TEXT NOT NULL, + "work_order_id" TEXT, + "kind" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "payload_hash" TEXT NOT NULL, + "status" "LotseProposalStatus" NOT NULL DEFAULT 'proposed', + "expires_at" TIMESTAMP(3) NOT NULL, + "result" JSONB, + "error_code" TEXT, + "confirmed_at" TIMESTAMP(3), + "confirmed_by_id" TEXT, + "decided_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "lotse_action_proposals_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "lotse_conversations_tenant_id_user_id_last_message_at_idx" ON "lotse_conversations"("tenant_id", "user_id", "last_message_at"); + +-- CreateIndex +CREATE INDEX "lotse_conversations_tenant_id_last_message_at_idx" ON "lotse_conversations"("tenant_id", "last_message_at"); + +-- CreateIndex +CREATE INDEX "lotse_messages_tenant_id_conversation_id_created_at_idx" ON "lotse_messages"("tenant_id", "conversation_id", "created_at"); + +-- CreateIndex +CREATE INDEX "lotse_action_proposals_tenant_id_conversation_id_created_at_idx" ON "lotse_action_proposals"("tenant_id", "conversation_id", "created_at"); + +-- CreateIndex +CREATE INDEX "lotse_action_proposals_tenant_id_user_id_status_idx" ON "lotse_action_proposals"("tenant_id", "user_id", "status"); + +-- AddForeignKey +ALTER TABLE "lotse_messages" ADD CONSTRAINT "lotse_messages_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "lotse_conversations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "lotse_action_proposals" ADD CONSTRAINT "lotse_action_proposals_conversation_id_fkey" FOREIGN KEY ("conversation_id") REFERENCES "lotse_conversations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + + +-- L16: Mandantentrennung (RLS) für die neuen Tabellen +SELECT enable_tenant_rls('lotse_conversations'); +SELECT enable_tenant_rls('lotse_messages'); +SELECT enable_tenant_rls('lotse_action_proposals'); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7a2ff19..3b4d61e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -79,6 +79,8 @@ model TenantSettings { lotseAddressForm String? @map("lotse_address_form") // L10b (Spec §31): monthly AI token budget (input + output) per tenant; null = env AI_MONTHLY_TOKEN_LIMIT, 0 = unlimited aiMonthlyTokenLimit Int? @map("ai_monthly_token_limit") + // L16 Lotse-Chat für Monteure: switch per tenant (effective only while the Lotse module is on) + lotseChatEnabled Boolean @default(true) @map("lotse_chat_enabled") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -1433,3 +1435,85 @@ model BillingRecord { @@index([tenantId, workOrderId]) @@map("billing_records") } + +// ---------- L16 Lotse-Chat für Monteure ---------- + +enum LotseMessageRole { + user + assistant + tool +} + +enum LotseProposalStatus { + proposed + confirmed + discarded + expired + failed +} + +/// Chat of ONE user with the Lotse (only visible to that user). Optional work order context +/// (chat opened from the order detail). Deleted by the Lotse retention (services/lotse/retention.ts). +model LotseConversation { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + userId String @map("user_id") + workOrderId String? @map("work_order_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + lastMessageAt DateTime @default(now()) @map("last_message_at") + + messages LotseMessage[] + proposals LotseActionProposal[] + + @@index([tenantId, userId, lastMessageAt]) + @@index([tenantId, lastMessageAt]) + @@map("lotse_conversations") +} + +/// One chat message. `text` = what the user sees; `content` = structured parts (chips, proposal ids, +/// links, placeholder text sent to the model). +model LotseMessage { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + conversationId String @map("conversation_id") + role LotseMessageRole + text String + content Json? + aiGenerationId String? @map("ai_generation_id") + createdAt DateTime @default(now()) @map("created_at") + + conversation LotseConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) + + @@index([tenantId, conversationId, createdAt]) + @@map("lotse_messages") +} + +/// Action card proposed by the Lotse. Nothing is executed before the owner confirms it +/// (services/lotse/chat/confirm.ts runs the existing services with the owner's context). +model LotseActionProposal { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + conversationId String @map("conversation_id") + messageId String? @map("message_id") + userId String @map("user_id") + workOrderId String? @map("work_order_id") + kind String + payload Json + payloadHash String @map("payload_hash") + status LotseProposalStatus @default(proposed) + expiresAt DateTime @map("expires_at") + result Json? + errorCode String? @map("error_code") + confirmedAt DateTime? @map("confirmed_at") + confirmedById String? @map("confirmed_by_id") + decidedAt DateTime? @map("decided_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + conversation LotseConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) + + @@index([tenantId, conversationId, createdAt]) + @@index([tenantId, userId, status]) + @@map("lotse_action_proposals") +} diff --git a/scripts/lib/lotse-chat-fixture.ts b/scripts/lib/lotse-chat-fixture.ts new file mode 100644 index 0000000..e570c26 --- /dev/null +++ b/scripts/lib/lotse-chat-fixture.ts @@ -0,0 +1,143 @@ +// Shared fixture of the lane L16 tests (scripts/test-lotse-chat-*.ts): two zz test tenants with +// technicians (assigned / not assigned), admin, tenant B, customers with contact data (for the data +// minimisation checks) and work orders in different statuses. Not a test itself. + +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"; + +export let failures = 0; +export const ok = (cond: boolean, msg: string) => { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failures++; +}; + +export async function expectErr(fn: () => Promise, code: ServiceError["code"], msg: string, reason?: string) { + try { + await fn(); + ok(false, `${msg} — kein Fehler (erwartet ${code}${reason ? `/${reason}` : ""})`); + } 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}` : ""})`); + } +} + +export const ctxOf = (tenantId: string, userId: string, role: RoleKey): ServiceCtx => ({ + db: dbForTenant(tenantId), + tenantId, + userId, + permissions: new Set(ROLE_DEFS[role].permissions), +}); + +export async function cleanupChatTenants(prefix: string) { + const slugs = [`zz-${prefix}-a`, `zz-${prefix}-b`]; + 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.lotseActionProposal.deleteMany(w); + await prisma.lotseMessage.deleteMany(w); + await prisma.lotseConversation.deleteMany(w); + await prisma.notification.deleteMany(w); + await prisma.mailLog.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.syncOperation.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: `@zz-${prefix}.test` } } }); +} + +export async function createChatFixture(prefix: string) { + await cleanupChatTenants(prefix); + const domain = `@zz-${prefix}.test`; + const tA = await prisma.tenant.create({ data: { name: `Lotse-Chat ${prefix} A`, slug: `zz-${prefix}-a` } }); + const tB = await prisma.tenant.create({ data: { name: `Lotse-Chat ${prefix} B`, slug: `zz-${prefix}-b` } }); + await prisma.tenantSettings.create({ data: { tenantId: tA.id, orgName: "Chat Test A GmbH", phone: "040 1234567", email: "buero@lotsechat-a.test", address: "Werftstraße 5, 20457 Hamburg" } }); + await prisma.tenantSettings.create({ data: { tenantId: tB.id, orgName: "Chat Test B" } }); + + const mkUser = async (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 tech = await mkUser(tA.id, "tech", "Max Monteur"); + const tech2 = await mkUser(tA.id, "tech2", "Nora Nordmann"); + const outsider = await mkUser(tA.id, "outsider", "Otto Fremd"); + const admin = await mkUser(tA.id, "admin", "Anna Admin"); + const techB = await mkUser(tB.id, "techb", "Tom Bader"); + + const now = Date.now(); + const today = { plannedStart: new Date(now - 3_600_000), plannedEnd: new Date(now + 3 * 3_600_000) }; + const tomorrow = { plannedStart: new Date(now + 26 * 3_600_000), plannedEnd: new Date(now + 30 * 3_600_000) }; + + 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", accessNotes: "Schlüsselkasten Code 4711, Hausmeister 0170 1112223" }, + }); + const company = await prisma.customer.create({ data: { tenantId: tA.id, companyName: "Müller Haustechnik GmbH", city: "Buxtehude" } }); + const site2 = await prisma.site.create({ data: { tenantId: tA.id, customerId: company.id, name: "Objekt Müller", city: "Buxtehude" } }); + + const order = async (number: string, data: Record, assignees: string[]) => { + const wo = await prisma.workOrder.create({ data: { tenantId: tA.id, number, ...(data as object) } as never }); + for (const userId of assignees) await prisma.workOrderAssignee.create({ data: { tenantId: tA.id, workOrderId: wo.id, userId } }); + return wo as unknown as { id: string; number: string; status: string }; + }; + const wo1 = await order( + "A-LC-1", + { customerId: customer.id, siteId: site.id, contactId: contact.id, title: "Speicher tauschen", description: "Kundin unter 0171 9876543 oder erika@kunde.test erreichbar, Lindenallee 7.", status: "in_progress", signatureRequired: false, ...today }, + [tech.id, tech2.id], + ); + const plan = await prisma.materialPlan.create({ data: { tenantId: tA.id, workOrderId: wo1.id, name: "Filter", plannedQuantity: 1, unit: "Stk" } }); + const wo2 = await order("A-LC-2", { customerId: company.id, siteId: site2.id, title: "Wartung Heizung", status: "assigned", signatureRequired: false, ...today }, [tech.id, tech2.id]); + const wo3 = await order("A-LC-3", { customerId: company.id, siteId: site2.id, title: "Pumpe montieren", status: "in_progress", signatureRequired: false, ...tomorrow }, [tech.id]); + await prisma.photoRequirement.create({ data: { tenantId: tA.id, workOrderId: wo3.id, key: "fertige_montage", label: "Fertige Montage" } }); + const wo4 = await order("A-LC-4", { customerId: company.id, siteId: site2.id, title: "Regler einstellen", status: "in_progress", signatureRequired: false, ...tomorrow }, [tech.id]); + + const customerB = await prisma.customer.create({ data: { tenantId: tB.id, companyName: "Kunde B" } }); + const woB = await prisma.workOrder.create({ data: { tenantId: tB.id, number: "B-LC-1", customerId: customerB.id, title: "Auftrag B", status: "in_progress", ...today } }); + await prisma.workOrderAssignee.create({ data: { tenantId: tB.id, workOrderId: woB.id, userId: techB.id } }); + + return { + tA, + tB, + users: { tech, tech2, outsider, admin, techB }, + ctx: { + tech: ctxOf(tA.id, tech.id, "technician"), + tech2: ctxOf(tA.id, tech2.id, "technician"), + outsider: ctxOf(tA.id, outsider.id, "technician"), + admin: ctxOf(tA.id, admin.id, "tenant-admin"), + techB: ctxOf(tB.id, techB.id, "technician"), + }, + customer, + contact, + site, + plan, + orders: { wo1, wo2, wo3, wo4, woB }, + }; +} diff --git a/scripts/smoke-auth.ts b/scripts/smoke-auth.ts index 0a9cbfd..e97a54b 100644 --- a/scripts/smoke-auth.ts +++ b/scripts/smoke-auth.ts @@ -75,7 +75,7 @@ function plans(x: Awaited>): Plan[] { { path: "/settings/users", mustContain: "backoffice@demo.example" }, { path: "/settings/audit" }, { path: "/settings/email" }, - { path: "/settings/lotse" }, + { path: "/settings/lotse", mustContain: "Lotse-Chat für Monteure" }, { path: "/settings/lotse/protocol" }, { path: "/settings/order-types", mustContain: "Montage" }, { path: "/settings/checklists", mustContain: "Montage Heizung/Sanitär" }, @@ -185,6 +185,11 @@ function plans(x: Awaited>): Plan[] { { path: "/m/time/new", mustContain: ["Zeit nachtragen", "Begründung"] }, { path: "/m/approvals", mustContain: "nicht freigeschaltet" }, { path: m(x.d07, "/time"), mustContain: x.d07.number }, + // L16 Lotse-Chat für Monteure + { path: "/m/lotse", mustContain: ["Lotse-Chat", "Neuer Chat", "Sprechen"] }, + { path: `/m/lotse?order=${x.d07.id}`, mustContain: [x.d07.number, "Lotse-Chat"] }, + { path: m(x.d07), mustContain: "Lotse fragen" }, + ...(x.d08 ? [{ path: `/m/lotse?order=${x.d08.id}`, expect: [404] }] : []), // L14 Abrechnungsübersicht: Meilensteine mobil, Backoffice-Übersicht ohne Recht { path: m(x.d07), mustContain: ["Meilensteine", "Smoke-Meilenstein", "Erreicht melden"] }, // order numbers appear in the monteur's own notifications and all UI texts in the serialized messages → check the record link diff --git a/scripts/test-e2e-tenant-isolation.ts b/scripts/test-e2e-tenant-isolation.ts index f201d8f..d0d48a6 100644 --- a/scripts/test-e2e-tenant-isolation.ts +++ b/scripts/test-e2e-tenant-isolation.ts @@ -136,6 +136,11 @@ async function createModelRows(A: TenantFixture): Promise { // L14 Abrechnungsübersicht put("WorkOrderMilestone", (await prisma.workOrderMilestone.create({ data: { tenantId: t, workOrderId: wo.id, title: "ZZ" } })).id, { title: MARK }); put("BillingRecord", (await prisma.billingRecord.create({ data: { tenantId: t, workOrderId: wo.id, kind: "order_completion", periodFrom: new Date(Date.now() - 3600_000), periodTo: new Date() } })).id, { invoiceNumber: MARK }); + // L16 Lotse-Chat für Monteure + const lotseConversation = await prisma.lotseConversation.create({ data: { tenantId: t, userId: uid, workOrderId: wo.id } }); + put("LotseConversation", lotseConversation.id, { workOrderId: MARK }); + put("LotseMessage", (await prisma.lotseMessage.create({ data: { tenantId: t, conversationId: lotseConversation.id, role: "user", text: "ZZ" } })).id, { text: MARK }); + put("LotseActionProposal", (await prisma.lotseActionProposal.create({ data: { tenantId: t, conversationId: lotseConversation.id, userId: uid, kind: "add_note", payload: {}, payloadHash: "0".repeat(64), expiresAt: new Date(Date.now() + 1_800_000) } })).id, { kind: MARK }); return rows; } diff --git a/scripts/test-lotse-chat-confirm.ts b/scripts/test-lotse-chat-confirm.ts new file mode 100644 index 0000000..7a31829 --- /dev/null +++ b/scripts/test-lotse-chat-confirm.ts @@ -0,0 +1,186 @@ +// L16 Lotse-Chat für Monteure – Bestätigen, Bearbeiten, Verwerfen, „Alle bestätigen“. +// +// Deckt ab: Ausführung ausschließlich über bestehende Services mit dem Monteur-ctx (Status, Session, Zeitnachtrag mit +// Freigabepflicht, Material, Notiz, Berichtsvorschlag im L9-Mechanismus), Blocker (Pflichtfoto) → fehlgeschlagen mit +// Sprungziel und ohne Teiländerung, Einmaligkeit (Doppeltipp, parallel), Ablauf, fremder Nutzer / Mandant B → not_found, +// Monteur ohne Zuweisung → Service verweigert, manipulierte Nutzlast, Bearbeiten (nur erlaubte Felder), Audit mit Quelle, +// Reihenfolge und Stopp beim ersten Fehler, Einstellung aus → blocked. +// +// Lauf: npx tsx scripts/test-lotse-chat-confirm.ts + +import "dotenv/config"; +import { prisma, dbForTenant } from "../src/server/db"; +import type { ProposalKind } from "../src/lib/lotse/chat"; +import { sessionStartPayload } from "../src/lib/sync/ops"; +import { toWallTimeInput } from "../src/lib/work-orders/time"; +import { parseReportContent } from "../src/lib/reports/content"; +import type { ServiceCtx } from "../src/server/services/context"; +import { startSession } from "../src/server/services/field/sessions"; +import { createDailyReport } from "../src/server/services/reports/create"; +import { decideLotseSuggestion } from "../src/server/services/lotse/suggestions"; +import { updateLotseSettings } from "../src/server/services/lotse/settings"; +import { confirmAllProposals, confirmProposal, discardProposal } from "../src/server/services/lotse/chat/confirm"; +import { createProposal, hashMatches } from "../src/server/services/lotse/chat/proposals"; +import { getChatView } from "../src/server/services/lotse/chat/conversations"; +import { cleanupChatTenants, createChatFixture, expectErr, failures, ok } from "./lib/lotse-chat-fixture"; + +const PREFIX = "lotsechat-confirm"; +const TZ = "Europe/Berlin"; + +async function main() { + const f = await createChatFixture(PREFIX); + const { wo1, wo2, wo3, wo4 } = f.orders; + + const conversations = new Map(); + async function conversationOf(ctx: ServiceCtx) { + if (!conversations.has(ctx.userId)) { + const c = await prisma.lotseConversation.create({ data: { tenantId: ctx.tenantId, userId: ctx.userId } }); + conversations.set(ctx.userId, c.id); + } + return conversations.get(ctx.userId)!; + } + async function card(ctx: ServiceCtx, kind: ProposalKind, workOrderId: string, payload: Record, messageId?: string) { + const conversationId = await conversationOf(ctx); + const p = await createProposal(ctx, { conversationId, kind, payload: { workOrderId, ...payload }, workOrderId, now: new Date() }); + if (messageId) await prisma.lotseActionProposal.update({ where: { id: p.id }, data: { messageId } }); + return p; + } + const slot = (startMinutesAgo: number, endMinutesAgo: number) => { + const s = toWallTimeInput(new Date(Date.now() - startMinutesAgo * 60_000), TZ); + const e = toWallTimeInput(new Date(Date.now() - endMinutesAgo * 60_000), TZ); + return { date: s.slice(0, 10), from: s.slice(11, 16), to: e.slice(11, 16), durationMinutes: startMinutesAgo - endMinutesAgo }; + }; + + console.log("\n— Status über transitionWorkOrder / Sessions —"); + const accept = await card(f.ctx.tech2, "transition_work_order", wo2.id, { number: "A-LC-2", action: "accept" }); + const r1 = await confirmProposal(f.ctx.tech2, { proposalId: accept.id }); + ok(r1.status === "confirmed", "Annehmen bestätigt"); + ok((await prisma.workOrder.findUnique({ where: { id: wo2.id } }))?.status === "accepted", "Auftrag → accepted (Service)"); + ok((await prisma.workOrderStatusChange.count({ where: { workOrderId: wo2.id, toStatus: "accepted", actorId: f.users.tech2.id } })) === 1, "Statuswechsel mit Monteur als Akteur"); + const again = await confirmProposal(f.ctx.tech2, { proposalId: accept.id }); + ok(again.status === "confirmed" && again.idempotent === true, "Doppeltipp → idempotent"); + const auditRow = await prisma.auditLog.findFirst({ where: { tenantId: f.tA.id, entity: "lotse_action_proposal", entityId: accept.id } }); + ok((auditRow?.after as { source?: string; status?: string } | null)?.source === "lotse_chat" && (auditRow?.before as { status?: string } | null)?.status === "proposed", "Audit before/after mit Quelle lotse_chat"); + + const start = await card(f.ctx.tech2, "transition_work_order", wo2.id, { number: "A-LC-2", action: "start_work" }); + ok((await confirmProposal(f.ctx.tech2, { proposalId: start.id })).status === "confirmed", "Arbeit starten bestätigt"); + ok((await prisma.workSession.count({ where: { workOrderId: wo2.id, userId: f.users.tech2.id, status: "running" } })) === 1, "laufende Session angelegt"); + ok((await prisma.workOrder.findUnique({ where: { id: wo2.id } }))?.status === "in_progress", "Auftrag → in_progress"); + + console.log("\n— Zeitnachtrag nach L12-Regeln —"); + const time = await card(f.ctx.tech2, "book_time", wo2.id, { number: "A-LC-2", type: "work", reason: "Uhr vergessen", ...slot(95, 65) }); + ok((await confirmProposal(f.ctx.tech2, { proposalId: time.id })).status === "confirmed", "Zeit bestätigt"); + const entry = await prisma.timeEntry.findFirst({ where: { userId: f.users.tech2.id, source: "manual" }, orderBy: { createdAt: "desc" } }); + ok(entry?.approvalStatus === "pending" && entry.correctionReason === "Uhr vergessen", "Nachtrag pending (Freigabepflicht) mit Grund"); + ok(((await prisma.lotseActionProposal.findUnique({ where: { id: time.id } }))?.result as { approvalStatus?: string } | null)?.approvalStatus === "pending", "Ergebnis der Karte: zur Freigabe"); + + const edited = await card(f.ctx.tech2, "book_time", wo2.id, { number: "A-LC-2", type: "work", reason: "Uhr vergessen", ...slot(160, 130) }); + ok((await confirmProposal(f.ctx.tech2, { proposalId: edited.id, edits: { reason: "Kein Netz", workOrderId: wo1.id, number: "X" } })).status === "confirmed", "bearbeitete Zeit bestätigt"); + const editedEntry = await prisma.timeEntry.findFirst({ where: { userId: f.users.tech2.id, source: "manual" }, orderBy: { createdAt: "desc" }, include: { workSession: { select: { workOrderId: true } } } }); + ok(editedEntry?.correctionReason === "Kein Netz" && editedEntry.workSession.workOrderId === wo2.id, "Änderung übernommen, Auftrag nicht änderbar"); + const editedRow = await prisma.lotseActionProposal.findUniqueOrThrow({ where: { id: edited.id } }); + ok((editedRow.payload as { reason?: string }).reason === "Kein Netz" && hashMatches(editedRow), "Nutzlast + Hash nach Bearbeitung aktualisiert"); + const editAudit = await prisma.auditLog.findFirst({ where: { entity: "lotse_action_proposal", entityId: edited.id } }); + ok((editAudit?.after as { edited?: boolean } | null)?.edited === true, "Audit: bearbeitet"); + const overlapCard = await card(f.ctx.tech2, "book_time", wo2.id, { number: "A-LC-2", type: "work", reason: "Uhr vergessen", ...slot(90, 70) }); + const overlap = await confirmProposal(f.ctx.tech2, { proposalId: overlapCard.id }); + ok(overlap.status === "failed" && overlap.code === "overlap", `Überschneidung beim Bestätigen → fehlgeschlagen (${overlap.code})`); + + console.log("\n— Material, Notiz, Einmaligkeit —"); + const mat = await card(f.ctx.tech, "record_material", wo1.id, { number: "A-LC-1", materialPlanId: f.plan.id, name: "Filter", quantity: 1, unit: "Stk", usageStatus: "fully_used" }); + ok((await confirmProposal(f.ctx.tech, { proposalId: mat.id })).status === "confirmed", "Material bestätigt"); + const usage = await prisma.materialUsage.findFirst({ where: { workOrderId: wo1.id, materialPlanId: f.plan.id } }); + ok(usage?.usageStatus === "fully_used" && Number(usage.actualQuantity) === 1 && usage.recordedById === f.users.tech.id, "MaterialUsage über Service erfasst"); + + const note = await card(f.ctx.tech, "add_note", wo1.id, { number: "A-LC-1", kind: "work_done", text: "Speicher installiert" }); + const notesBefore = await prisma.activityNote.count({ where: { workOrderId: wo1.id } }); + const parallel = await Promise.all([confirmProposal(f.ctx.tech, { proposalId: note.id }), confirmProposal(f.ctx.tech, { proposalId: note.id })]); + ok(parallel.every((r) => r.status === "confirmed") && parallel.some((r) => r.idempotent), "paralleler Doppeltipp: einmal ausgeführt, einmal idempotent"); + ok((await prisma.activityNote.count({ where: { workOrderId: wo1.id } })) === notesBefore + 1, "genau eine Notiz angelegt"); + + console.log("\n— Blocker → Rückfrage mit Sprungziel —"); + await startSession(f.ctx.tech, sessionStartPayload.parse({ workOrderId: wo3.id, mode: "work" })); + const complete = await card(f.ctx.tech, "transition_work_order", wo3.id, { number: "A-LC-3", action: "complete" }); + const blocked = await confirmProposal(f.ctx.tech, { proposalId: complete.id }); + ok(blocked.status === "failed" && blocked.code === "blocked", `Abschluss mit fehlendem Pflichtfoto → fehlgeschlagen (${blocked.code})`); + ok((await prisma.workOrder.findUnique({ where: { id: wo3.id } }))?.status === "in_progress", "Auftrag unverändert"); + ok((await prisma.workSession.count({ where: { workOrderId: wo3.id, userId: f.users.tech.id, status: "running" } })) === 1, "Session nicht beendet (Blocker vor jeder Änderung geprüft)"); + const view = await getChatView(f.ctx.tech, { conversationId: await conversationOf(f.ctx.tech) }); + const failNotice = view.messages.find((m) => m.role === "tool" && m.content.noticeKey === "failed"); + ok(Boolean(failNotice?.content.links?.some((l) => l.href === `/m/orders/${wo3.id}/photos` && l.label === "Fertige Montage")), "Lotse-Antwort mit Sprungziel zum Pflichtfoto"); + await expectErr(() => confirmProposal(f.ctx.tech, { proposalId: complete.id }), "conflict", "fehlgeschlagene Karte nicht erneut ausführbar", "already_decided"); + + console.log("\n— Ablauf, fremde Nutzer, Manipulation, Verwerfen —"); + const late = await card(f.ctx.tech, "add_note", wo1.id, { number: "A-LC-1", kind: "general", text: "zu spät" }); + await expectErr(() => confirmProposal(f.ctx.tech, { proposalId: late.id }, new Date(Date.now() + 31 * 60_000)), "conflict", "nach 30 Minuten abgelaufen", "expired"); + ok((await prisma.lotseActionProposal.findUnique({ where: { id: late.id } }))?.status === "expired", "Status expired gespeichert"); + + const foreign = await card(f.ctx.tech, "add_note", wo1.id, { number: "A-LC-1", kind: "general", text: "fremd" }); + await expectErr(() => confirmProposal(f.ctx.tech2, { proposalId: foreign.id }), "not_found", "anderer Monteur (gleicher Auftrag) → not_found"); + await expectErr(() => confirmProposal(f.ctx.admin, { proposalId: foreign.id }), "not_found", "Admin bestätigt keine fremde Karte"); + await expectErr(() => confirmProposal(f.ctx.techB, { proposalId: foreign.id }), "not_found", "Mandant B → not_found"); + await expectErr(() => discardProposal(f.ctx.techB, { proposalId: foreign.id }), "not_found", "Mandant B verwirft nicht"); + ok((await dbForTenant(f.tB.id).lotseActionProposal.findFirst({ where: { id: foreign.id } })) === null, "B-Client sieht die Karte nicht"); + + await prisma.lotseActionProposal.update({ where: { id: foreign.id }, data: { payload: { ...(foreign.payload as object), text: "manipuliert" } } }); + await expectErr(() => confirmProposal(f.ctx.tech, { proposalId: foreign.id }), "conflict", "veränderte Nutzlast → nicht ausgeführt", "tampered"); + ok((await prisma.activityNote.count({ where: { text: "manipuliert" } })) === 0, "keine manipulierte Notiz"); + + const discard = await card(f.ctx.tech, "add_note", wo1.id, { number: "A-LC-1", kind: "general", text: "verwerfen" }); + ok((await discardProposal(f.ctx.tech, { proposalId: discard.id })).status === "discarded", "verworfen"); + ok((await discardProposal(f.ctx.tech, { proposalId: discard.id })).status === "discarded", "Verwerfen idempotent"); + await expectErr(() => confirmProposal(f.ctx.tech, { proposalId: discard.id }), "conflict", "verworfene Karte nicht bestätigbar", "already_decided"); + ok((await prisma.activityNote.count({ where: { text: "verwerfen" } })) === 0, "nichts ausgeführt"); + + const notAssigned = await card(f.ctx.outsider, "add_note", wo1.id, { number: "A-LC-1", kind: "general", text: "ohne Zuweisung" }); + const outsiderResult = await confirmProposal(f.ctx.outsider, { proposalId: notAssigned.id }); + ok(outsiderResult.status === "failed" && outsiderResult.code === "not_found", "Monteur ohne Zuweisung: Service verweigert (not_found)"); + ok((await prisma.activityNote.count({ where: { text: "ohne Zuweisung" } })) === 0, "keine Notiz ohne Zuweisung"); + + console.log("\n— Alle bestätigen —"); + const conv = await conversationOf(f.ctx.tech); + const msg = await prisma.lotseMessage.create({ data: { tenantId: f.tA.id, conversationId: conv, role: "assistant", text: "Drei Karten" } }); + const pause = await card(f.ctx.tech, "transition_work_order", wo3.id, { number: "A-LC-3", action: "pause" }, msg.id); + const noteAll = await card(f.ctx.tech, "add_note", wo3.id, { number: "A-LC-3", kind: "work_done", text: "Pumpe gesetzt" }, msg.id); + const badMaterial = await card(f.ctx.tech, "record_material", wo3.id, { number: "A-LC-3", name: "Dichtung", quantity: 2, unit: "Stk", usageStatus: "additional" }, msg.id); + const all1 = await confirmAllProposals(f.ctx.tech, { messageId: msg.id }); + ok(all1.stopped && all1.results.length === 1 && all1.results[0].proposalId === badMaterial.id && all1.remaining === 2, "Reihenfolge (Material vor Notiz vor Status), Stopp beim ersten Fehler"); + ok((await prisma.lotseActionProposal.findUnique({ where: { id: noteAll.id } }))?.status === "proposed" && (await prisma.workOrder.findUnique({ where: { id: wo3.id } }))?.status === "in_progress", "nach Fehler nichts weiter ausgeführt"); + const stoppedNotice = await prisma.lotseMessage.findFirst({ where: { conversationId: conv, role: "tool" }, orderBy: { createdAt: "desc" } }); + ok((stoppedNotice?.content as { noticeKey?: string } | null)?.noticeKey === "stopped", "Klartext-Hinweis „gestoppt“"); + const all2 = await confirmAllProposals(f.ctx.tech, { messageId: msg.id }); + ok(!all2.stopped && all2.results.map((r) => r.proposalId).join() === [noteAll.id, pause.id].join(), "zweiter Lauf: Notiz, dann Pause"); + ok((await prisma.workOrder.findUnique({ where: { id: wo3.id } }))?.status === "paused", "Auftrag pausiert"); + await expectErr(() => confirmAllProposals(f.ctx.tech2, { messageId: msg.id }), "not_found", "„Alle bestätigen“ fremder Nachricht → not_found"); + + console.log("\n— Berichtsfelder als Lotse-Vorschlag (L9) —"); + const { report } = await createDailyReport(f.ctx.tech, { workOrderId: wo4.id }); + const fields = await card(f.ctx.tech, "suggest_report_fields", wo4.id, { number: "A-LC-4", reportId: report.id, reportType: "daily", fields: [{ field: "workPerformed", text: "Regler eingestellt und geprüft" }] }); + ok((await confirmProposal(f.ctx.tech, { proposalId: fields.id })).status === "confirmed", "Berichtsvorschlag bestätigt"); + const updated = await prisma.report.findUniqueOrThrow({ where: { id: report.id } }); + const content = parseReportContent(updated.content); + ok(updated.aiDrafted && content.lotse?.suggestions.some((s) => s.field === "workPerformed" && s.state === "pending" && s.text === "Regler eingestellt und geprüft") === true, "Vorschlag in content.lotse (pending), aiDrafted"); + ok(content.texts.workPerformed === "", "Berichtstext selbst unverändert"); + await decideLotseSuggestion(f.ctx.tech, { reportId: report.id, field: "workPerformed", decision: "accept" }); + ok(parseReportContent((await prisma.report.findUniqueOrThrow({ where: { id: report.id } })).content).texts.workPerformed === "Regler eingestellt und geprüft", "im Bericht über den L9-Mechanismus übernommen"); + + console.log("\n— Einstellung aus —"); + const offCard = await card(f.ctx.tech, "add_note", wo1.id, { number: "A-LC-1", kind: "general", text: "aus" }); + await updateLotseSettings(f.ctx.admin, { enabled: true, addressForm: "neutral", chatEnabled: false }); + await expectErr(() => confirmProposal(f.ctx.tech, { proposalId: offCard.id }), "blocked", "Lotse-Chat aus → Bestätigen blocked", "chat_disabled"); + await updateLotseSettings(f.ctx.admin, { enabled: false, addressForm: "neutral", chatEnabled: true }); + await expectErr(() => discardProposal(f.ctx.tech, { proposalId: offCard.id }), "blocked", "Lotse-Modul aus → Verwerfen blocked", "disabled"); + await updateLotseSettings(f.ctx.admin, { enabled: true, addressForm: "neutral", chatEnabled: true }); + + 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); +}); diff --git a/scripts/test-lotse-chat-engine.ts b/scripts/test-lotse-chat-engine.ts new file mode 100644 index 0000000..6dcc2a6 --- /dev/null +++ b/scripts/test-lotse-chat-engine.ts @@ -0,0 +1,214 @@ +// 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); +}); diff --git a/scripts/test-lotse-chat-live.ts b/scripts/test-lotse-chat-live.ts new file mode 100644 index 0000000..43c07bd --- /dev/null +++ b/scripts/test-lotse-chat-live.ts @@ -0,0 +1,42 @@ +// L16 Lotse-Chat für Monteure – optionaler Live-Test gegen Claude. +// +// Läuft nur mit gesetztem ANTHROPIC_API_KEY (sonst Skip, Exit 0). Prüft, dass eine echte Tool-Use-Schleife eine +// Antwort liefert, als AiGeneration protokolliert wird und dabei nichts an Fachdaten ändert (nur Vorschläge). +// +// Lauf: npx tsx scripts/test-lotse-chat-live.ts + +import "dotenv/config"; +import { prisma } from "../src/server/db"; +import { getLotseChatProvider } from "../src/server/ai/lotse/chat-anthropic"; +import { sendLotseMessage } from "../src/server/services/lotse/chat/engine"; +import { cleanupChatTenants, createChatFixture, failures, ok } from "./lib/lotse-chat-fixture"; + +const PREFIX = "lotsechat-live"; + +async function main() { + if (!process.env.ANTHROPIC_API_KEY?.trim()) { + console.log("übersprungen: ANTHROPIC_API_KEY nicht gesetzt"); + await prisma.$disconnect(); + return; + } + const f = await createChatFixture(PREFIX); + const provider = getLotseChatProvider(); + ok(Boolean(provider), "Anbieter eingerichtet"); + const notesBefore = await prisma.activityNote.count({ where: { tenantId: f.tA.id } }); + const view = await sendLotseMessage(f.ctx.tech2, { text: "Was steht heute an? Und bei A-LC-1 habe ich den Filter getauscht." }, { provider }); + const last = view.messages[view.messages.length - 1]; + ok(last.role === "assistant" && (last.text.length > 0 || Boolean(last.content.noticeKey)), `Antwort erhalten: ${last.text.slice(0, 120)}`); + ok(!/\{\{(customer|contact|phone|email|address|site):/.test(last.text), "keine unaufgelösten Platzhalter in der Antwort"); + ok((await prisma.aiGeneration.count({ where: { tenantId: f.tA.id, kind: "lotse_chat" } })) === 1, "AiGeneration protokolliert"); + ok((await prisma.activityNote.count({ where: { tenantId: f.tA.id } })) === notesBefore, "keine Notiz ohne Bestätigung"); + await cleanupChatTenants(PREFIX); + 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); +}); diff --git a/src/app/(app)/settings/lotse/page.tsx b/src/app/(app)/settings/lotse/page.tsx index 449a264..a74f5e9 100644 --- a/src/app/(app)/settings/lotse/page.tsx +++ b/src/app/(app)/settings/lotse/page.tsx @@ -61,6 +61,15 @@ export default async function LotseSettingsPage({ searchParams }: { searchParams {t("settings.enabledHint")} + {/* L16 Lotse-Chat für Monteure */} + +
{t("settings.addressForm")}

{t("settings.addressFormHint")}

@@ -127,7 +136,7 @@ export default async function LotseSettingsPage({ searchParams }: { searchParams

{t("settings.sentTitle")}

    - {(["order", "notes", "work", "audio"] as const).map((k) => ( + {(["order", "notes", "work", "audio", "chat", "chatAudio"] as const).map((k) => (
  • {t(`settings.sent.${k}`)}
  • ))}
diff --git a/src/app/(field)/m/(core)/lotse/layout.tsx b/src/app/(field)/m/(core)/lotse/layout.tsx new file mode 100644 index 0000000..c4faabe --- /dev/null +++ b/src/app/(field)/m/(core)/lotse/layout.tsx @@ -0,0 +1,7 @@ +import { requireModule } from "@/server/modules"; + +/** Modul-Gate „lotse" für den Lotse-Chat (L16); „field" gilt bereits über (core)/layout.tsx. */ +export default async function LotseChatLayout({ children }: Readonly<{ children: React.ReactNode }>) { + await requireModule("lotse"); + return <>{children}; +} diff --git a/src/app/(field)/m/(core)/lotse/page.tsx b/src/app/(field)/m/(core)/lotse/page.tsx new file mode 100644 index 0000000..0f67906 --- /dev/null +++ b/src/app/(field)/m/(core)/lotse/page.tsx @@ -0,0 +1,70 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { getLocale, getTranslations } from "next-intl/server"; +import { ChevronLeft, CircleSlash } from "lucide-react"; +import type { ChatView } from "@/lib/lotse/chat"; +import { isAiConfigured } from "@/server/ai/client"; +import { transcriptionConfig } from "@/server/ai/transcription/openai-compatible"; +import { ServiceError } from "@/server/services/context"; +import { fieldPageContext } from "@/server/services/field/page-context"; +import { getChatView } from "@/server/services/lotse/chat/conversations"; +import { tenantTimezone } from "@/server/services/work-orders/_shared"; +import { LotseMark } from "@/components/lotse/lotse-mark"; +import { LotseChat } from "@/components/lotse/chat/lotse-chat"; +import { card } from "@/components/field/ui"; + +/** + * `/m/lotse` — Lotse chat for technicians (lane L16). `?order=` opens the chat with order context + * (button „Lotse fragen" in the order detail). Nothing is executed without confirmation. + */ +export default async function LotseChatPage({ searchParams }: { searchParams: Promise<{ order?: string }> }) { + const [ctx, sp, t, locale] = await Promise.all([fieldPageContext(), searchParams, getTranslations("lotse"), getLocale()]); + + let view: ChatView; + try { + view = await getChatView(ctx, { workOrderId: sp.order?.trim() || null }); + } catch (err) { + if (!(err instanceof ServiceError)) throw err; + if (err.code === "not_found") notFound(); + const reason = (err.details as { reason?: string } | undefined)?.reason; + const code = reason === "disabled" || reason === "chat_disabled" ? reason : err.code === "forbidden" ? "forbidden" : "generic"; + return ( +
+
+

+ + {t("chat.unavailable.title")} +

+

{t(`chat.errors.${code}`)}

+ + {t("chat.unavailable.back")} + +
+
+ ); + } + + const timeZone = await tenantTimezone(ctx); + return ( +
+ {view.workOrder && ( + + + {t("chat.context", { number: view.workOrder.number, title: view.workOrder.title })} + + )} +

+ +

+ +
+ ); +} diff --git a/src/app/(field)/m/(core)/orders/[id]/page.tsx b/src/app/(field)/m/(core)/orders/[id]/page.tsx index de41be1..9f5a1fa 100644 --- a/src/app/(field)/m/(core)/orders/[id]/page.tsx +++ b/src/app/(field)/m/(core)/orders/[id]/page.tsx @@ -33,6 +33,7 @@ import { card, toneClasses } from "@/components/field/ui"; import { loadOrder } from "./load"; import { LotseCompletenessCard } from "@/components/lotse/completeness-card"; import { MilestonesSection } from "@/components/field/milestones"; +import { LotseAskButton } from "@/components/lotse/chat/ask-button"; function Section({ title, icon: Icon, children, href, summary }: { title: string; icon: LucideIcon; children?: React.ReactNode; href?: string; summary?: string }) { const head = ( @@ -168,6 +169,7 @@ export default async function OrderDetailPage({ params }: { params: Promise<{ id )} {editable && } + {editable && }{/* L16 Lotse-Chat */} {/* L14 Abrechnungsübersicht */} diff --git a/src/app/(field)/m/layout.tsx b/src/app/(field)/m/layout.tsx index ca6f601..170ff9e 100644 --- a/src/app/(field)/m/layout.tsx +++ b/src/app/(field)/m/layout.tsx @@ -5,6 +5,7 @@ import { can } from "@/server/services/context"; import { fieldPageContext } from "@/server/services/field/page-context"; import { getMyActiveSession } from "@/server/services/field/sessions"; import { countPendingTimeEntries } from "@/server/services/field/time-entries"; +import { canUseLotseChat } from "@/server/services/lotse/chat/access"; import { cn } from "@/lib/utils"; import { CraftviaLogo } from "@/components/brand/craftvia-logo"; import { AccountInactiveNotice } from "@/components/account-inactive-notice"; @@ -14,19 +15,20 @@ import { RunningClockBar, type ClockSession } from "@/components/field/running-c import { OfflineRuntime } from "@/components/offline/offline-runtime"; /** L12: own running/paused session + open approvals for the shell (never blocks the page). */ -async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number }> { +async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number; lotseChat: boolean }> { try { const ctx = await fieldPageContext(); - const [session, approvals] = await Promise.all([can(ctx, "field:execute") ? getMyActiveSession(ctx) : Promise.resolve(null), countPendingTimeEntries(ctx)]); + const [session, approvals, lotseChat] = await Promise.all([can(ctx, "field:execute") ? getMyActiveSession(ctx) : Promise.resolve(null), countPendingTimeEntries(ctx), canUseLotseChat(ctx)]); return { clock: session ? { status: session.status, workOrderId: session.workOrderId, number: session.number, title: session.title, segmentType: session.segmentType, segmentStartedAt: session.segmentStartedAt, closedSeconds: session.closedSeconds } : null, approvals, + lotseChat, // L16 }; } catch { // module "field" disabled or no field permissions: shell without clock/badge - return { clock: null, approvals: 0 }; + return { clock: null, approvals: 0, lotseChat: false }; } } @@ -55,7 +57,7 @@ export default async function FieldShell({ children }: Readonly<{ children: Reac
{children}
- +
); } diff --git a/src/app/api/v1/lotse/transcribe/route.ts b/src/app/api/v1/lotse/transcribe/route.ts new file mode 100644 index 0000000..c084832 --- /dev/null +++ b/src/app/api/v1/lotse/transcribe/route.ts @@ -0,0 +1,20 @@ +import { requireApiContext } from "@/server/api/context"; +import { ApiError, json, readFormData, withApi } from "@/server/api/respond"; +import { CHAT_AUDIO_MAX_BYTES, transcribeChatAudio } from "@/server/services/lotse/chat/transcribe"; + +/** + * POST /api/v1/lotse/transcribe — multipart `file` (audio, max. 10 MB) → `{ text }` (lane L16). + * Microphone key of the Lotse chat: the transcript is put into the input field and can be edited + * before sending. The audio is not stored. + */ +export const POST = withApi(async (req: Request) => { + const ctx = await requireApiContext("lotse", "lotse:use", "field:execute"); + const declared = Number(req.headers.get("content-length") ?? "0"); + if (declared > CHAT_AUDIO_MAX_BYTES + 1024 * 1024) throw new ApiError("payload_too_large", "audio too large"); + const form = await readFormData(req); + const file = form.get("file"); + if (!(file instanceof File)) throw new ApiError("invalid", "file missing"); + if (file.size > CHAT_AUDIO_MAX_BYTES) throw new ApiError("payload_too_large", "audio too large"); + const result = await transcribeChatAudio(ctx, Buffer.from(await file.arrayBuffer())); + return json(result); +}); diff --git a/src/components/audit-trail.tsx b/src/components/audit-trail.tsx index c1c0623..1a88009 100644 --- a/src/components/audit-trail.tsx +++ b/src/components/audit-trail.tsx @@ -88,6 +88,7 @@ const ENTITY_LABEL: Record = { number_sequence: "Nummernkreis", ai_generation: "Lotse (KI)", lotse_settings: "Lotse-Einstellungen", + lotse_chat_message: "Lotse-Chat", lotse_action_proposal: "Lotse-Aktionskarte", lotse_chat_retention: "Lotse-Chat (Aufbewahrung)", // L16 }; const fmt = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" }); diff --git a/src/components/field/bottom-nav.tsx b/src/components/field/bottom-nav.tsx index da311fc..bdd31a1 100644 --- a/src/components/field/bottom-nav.tsx +++ b/src/components/field/bottom-nav.tsx @@ -3,12 +3,13 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { useTranslations } from "next-intl"; -import { ClipboardList, House, RefreshCw, Siren, User } from "lucide-react"; +import { ClipboardList, Compass, House, RefreshCw, Siren, User } from "lucide-react"; import { cn } from "@/lib/utils"; const ITEMS = [ { href: "/m", key: "today", icon: House, exact: true }, { href: "/m/orders", key: "orders", icon: ClipboardList, exact: false }, + { href: "/m/lotse", key: "lotse", icon: Compass, exact: false }, // L16 Lotse-Chat (only when usable) { href: "/m/emergency", key: "emergency", icon: Siren, exact: false }, { href: "/m/sync", key: "sync", icon: RefreshCw, exact: false }, { href: "/m/profile", key: "profile", icon: User, exact: false }, @@ -18,13 +19,13 @@ const ITEMS = [ * Bottom navigation of the mobile shell (Spec §22): Heute · Aufträge · Notdienst · Sync · Profil. * L12: badge counter of open time approvals on „Profil" (number + accessible label). */ -export function BottomNav({ approvals = 0 }: { approvals?: number }) { +export function BottomNav({ approvals = 0, lotse = false }: { approvals?: number; lotse?: boolean }) { const t = useTranslations("field.nav"); const pathname = usePathname(); return (