Files
craftvia/docs/craftvia/lanes/lotse-chat.md
T

104 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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=<id>` | 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=<DEMO-07>` mit Auftragsnummer; Auftragsdetail mit „Lotse fragen“; `/m/lotse?order=<fremder Auftrag DEMO-08>` → 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).