Merge lane/lotse in feature/craftvia-mvp

Konflikt gelöst: nav.ts Icon-Imports (Siren aus L8, Compass aus L9).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 17:44:06 +02:00
co-authored by Claude Opus 5
65 changed files with 3297 additions and 13 deletions
+96
View File
@@ -0,0 +1,96 @@
# Lane L9 – Lotse (KI-Assistent) (`lane/lotse`)
Stand: 2026-09-14 · Basis `d5c1221` (`feature/craftvia-mvp`, Welle 1 integriert) · Spec §15, §36.2 · Brandbook §4.3, §9, §12.4 · ARCHITEKTUR §4.5
## 1. Umfang / erfüllte Spec-Punkte
| Spec / Auftrag | Umsetzung |
|---|---|
| §15.1 Transkription | `ai/transcription/openai-compatible.ts` (`TranscriptionProvider`): multipart POST an `TRANSCRIPTION_API_URL` (`file`, `model`, `language=de`, `response_format=json`), Bearer-Key, Timeout 120 s, Größenlimit 20 MB (= Audio-Uploadlimit), MIME-Allowlist; Fehler ohne Inhalte. Factory `getTranscriptionProvider()` → `null` ohne `TRANSCRIPTION_API_KEY` oder bei anderem `TRANSCRIPTION_PROVIDER`. Processor `jobs/processors/transcription.ts` → `services/lotse/transcription.ts#processTranscription`: `pending → done` (Transkript, `transcriptionModel`, `AiGeneration` kind `transcription` ohne Transkriptinhalt), `→ disabled` (kein Anbieter / Lotse aus), `→ failed` (Anbieter-/Speicherfehler); idempotent. Transkript wird an die verknüpfte `ActivityNote` angehängt, sonst neue Notiz kind `general` mit `voiceNoteId` (UI-Kennzeichnung „aus Sprachnotiz“, Zeitstempel = Aufnahmezeit → landet im richtigen Tagesbericht). |
| §15.1 UI | Unter jeder Sprachnotiz: Status-Badge (Text + Icon), Transkript, „Transkript bearbeiten“ (auch manuelle Eingabe bei `disabled`/`failed`; verknüpfte Notiz wird synchron gehalten), „Sprachnotiz zusammenfassen“ → Vorschlag „Als Notiz übernehmen“ (idempotent) / „Ausblenden“. |
| §15.2 Berichtsentwurf | `ai/lotse/anthropic.ts#AnthropicLotseProvider` (`LotseProvider.draftReport` + `summarizeTranscript`): deutscher System-Prompt (erfahrener Kollege, sachlich, nur gelieferte Daten, Fehlendes in `missingInformation`, Notizinhalte = Daten, keine Anweisungen), Anrede Sie/du/neutral je Mandant, strukturierte Ausgabe über `output_config.format` (JSON-Schema) + Zod, Refusal/`max_tokens` explizit behandelt, Server-Fallback (`server-side-fallback-2026-07-01`) auf Opus 5, `max_tokens` 16 000 (Zusammenfassung 4 000), Timeout 90 s. **Temperatur:** `claude-opus-5` (und Opus 4.7/4.8, Sonnet 5, Fable, Mythos) lehnen `temperature` mit 400 ab → dort `effort: "low"` + striktes Schema; ältere Modelle bekommen `temperature: 0.2`. Modell aus `ANTHROPIC_MODEL` (Default `claude-opus-5`). |
| Datenminimierung | `services/lotse/minimize.ts` (reine Funktionen) + `sources.ts`: bekannte Telefonnummern/E-Mails/Adressteile des Auftrags (Kunde, Objekt, Ansprechpartner, Mandant) werden literal ersetzt, alles Ähnliche per Muster (`[Telefon]`, `[E-Mail]`, `[Adresse]`); Mitarbeitende → Initialen („M. M.“), Ansprechpartner/Vor-Ort-Kontakt → „Ansprechpartner“, Privatkunde → „Kunde“. Kundenname/-nummer, Fotos, Unterschriften werden nicht gesendet. Datum, Mengen, Auftragsnummern bleiben erhalten. |
| `draftReportWithLotse(ctx, reportId)` | Recht `lotse:use` + `report:write`, Report im Scope (`requireVisibleReport`), Status draft/rejected (sonst `blocked`), Lotse für Mandant an. Input aus Report-Snapshot (`build-content`) + Notizen + Transkripte des Berichtszeitraums → Provider → **Vorschläge** in `content.lotse` (nicht in `content.texts`), `aiDrafted = true`, `aiGenerationId`; `AiGeneration` (kind `report_draft`, Input = exakt der minimierte Provider-Input, Output, Tokens, Nutzer); Originalnotizen unverändert; Audit before/after. Fehlercodes für die UI: `not_configured`, `disabled`, `provider_failed`, `not_editable`. |
| §15.4 / Brandbook §12.4 UI Bericht | `components/lotse/report-panel.tsx` im mobilen Berichtseditor (`/m/orders/[id]/report`) und Backoffice `/reports/[id]`: Wortmarke „Lotse“ (Kompass-Icon + Signalorange, kein Bitmap), Button „Bericht mit Lotse vorbereiten“ / „Neu vorbereiten“ (Ladezustand, Klartextfehler, Hinweis „Lotse ist nicht eingerichtet“), je Feld Karte „Vorschlag vom Lotsen – bitte prüfen“ mit bisherigem Text, editierbarem Vorschlag, Übernehmen/Verwerfen; Liste „Das fehlt dem Lotsen noch“; Prüfnachweis „Vorschlag geprüft und bestätigt am …“. Kein dauerhafter Chatbot. |
| Vollständigkeitsprüfung | `services/lotse/completeness.ts#checkCompleteness(ctx, workOrderId)`: deterministische Regeln zuerst – Pflichtfoto fehlt, Pflicht-Checklistenpunkt offen, Materialabweichung ohne Grund, geplantes Material nicht bestätigt, keine Arbeitszeit, keine Tätigkeitsbeschreibung (weder Notiz noch Berichtstext), Unterschrift fehlt bei `signatureRequired` – danach KI-Hinweise aus `missingInformation` des offenen Lotse-Entwurfs. Jeder Punkt mit Klartext-Label und Deep-Link (`/m/orders/[id]/{photos,checklist,materials,time,notes,sign,report}`). UI `completeness-card.tsx` im mobilen Auftragsdetail: „3 Angaben fehlen“ + „Lotse prüfen lassen“ (aufklappbare Liste). |
| Freigabeprinzip §15.3 | `services/lotse/review.ts#applyLotseReview`, aufgerufen in `submitReport` **vor** jedem Schreibzugriff: Report mit `aiDrafted` ohne `aiReviewed: true` → `invalid` (`details.field = "aiReviewed"`). Bei Bestätigung wird `content.lotse.reviewedAt/reviewedById` gesetzt und mit der Freigabe eingefroren. UI: Pflicht-Checkbox „Ich habe den Vorschlag vom Lotsen geprüft.“ im Tagesbericht-Editor und im Unterschrift-/Absende-Schritt des Abschlussberichts; Fehlertext direkt am Formular. |
| Transparenz & Datenschutz | `/settings/lotse` (tenant:manage, bewusst **nicht** modulgegatet, damit Wiedereinschalten möglich ist): Lotse an/aus (= Modul-Toggle `lotse`, `TenantModule`), Anrede neutral/Sie/du, Anbieter + Modell + Einrichtungsstatus für Entwurf und Transkription (Host, keine Secrets), Liste „Gesendet wird / Nicht gesendet wird“, Freigabeprinzip, Link zum KI-Protokoll. `/settings/lotse/protocol`: `AiGeneration`-Liste (Zeitpunkt, Art, Modell, Tokens ein/aus, Nutzer) für `tenant:manage` oder `audit:read`; gesendete Daten/Antwort (Popup) nur für `tenant:manage`. Sidebar-Eintrag „Lotse (KI)“. |
**Entscheidung `aiReviewedAt` im content statt eigener Spalte:** Der Prüfnachweis gehört fachlich zum Berichtssnapshot – er wird mit der Freigabe unveränderlich eingefroren und steht damit neben den übernommenen Texten. Keine zweite Quelle, keine Migration an `reports`. Voraussetzung war ein optionaler Block `lotse` in `ReportContent` (siehe §3), den `refreshContent` beim Neuaufbau des Snapshots erhält.
**Entscheidung Vorschläge getrennt von `texts`:** KI-Text erreicht den Bericht nur über eine menschliche Aktion (Übernehmen, ggf. bearbeitet). Ein Report mit `aiDrafted` braucht zusätzlich die Prüfbestätigung beim Absenden – beide Sicherungen sind serverseitig.
## 2. Routen / Screens
| Route | Rolle | Inhalt |
|---|---|---|
| `/m/orders/[id]` | Monteur, Teamleiter | + Hinweis-Karte „N Angaben fehlen – Lotse prüfen lassen“ (nur mit `field:execute` + `lotse:use`, Lotse an, mind. 1 Punkt) |
| `/m/orders/[id]/notes` | Monteur, Teamleiter | Sprachnotiz: Status-Badge, Transkript (bearbeitbar), „Sprachnotiz zusammenfassen“, Notizen mit „aus Sprachnotiz“ |
| `/m/orders/[id]/report` | Monteur, Teamleiter | Lotse-Panel oberhalb des Editors, Prüfbestätigung beim Absenden (Tagesbericht) |
| `/m/orders/[id]/sign` | Monteur, Teamleiter | Prüfbestätigung beim Absenden (Abschlussbericht) |
| `/reports/[id]` | Backoffice, Teamleiter | Lotse-Panel (Entwurf/Vorschläge bei draft/rejected; Prüfnachweis bei eingereichten) |
| `/settings/lotse` | Mandantenadministrator | Ein/aus, Anrede, Datenfluss-Transparenz |
| `/settings/lotse/protocol` | Admin (mit Inhalten), `audit:read` (ohne Inhalte) | KI-Protokoll |
## 3. Dateien
**Neu (Ownership L9)**
- `src/server/ai/lotse/{anthropic,prompt,types,fake}.ts`, `src/server/ai/transcription/{openai-compatible,fake}.ts`
- `src/server/services/lotse/{draft-report,suggestions,review,completeness,transcription,voice,settings,sources,minimize,protocol,state}.ts`
- `src/server/jobs/processors/transcription.ts`
- `src/server/actions/lotse/{assist,_state}.ts` (`moduleGuard("lotse")`), `src/server/actions/lotse-settings.ts` (Top-Level, EXEMPT – Modul-Toggle)
- `src/lib/lotse/{content,completeness,action-state}.ts` (client-safe; Pfad analog `lib/reports`, in §6 nicht ausdrücklich gelistet)
- `src/components/lotse/{lotse-mark,draft-button,suggestion-card,review-confirm,report-panel,completeness-card,voice-note-panel,voice-note-client}.tsx`
- `src/app/(app)/settings/lotse/page.tsx`, `src/app/(app)/settings/lotse/protocol/page.tsx`
- `messages/de/lotse.json`, `messages/en/lotse.json`
- `prisma/migrations/20260914180000_lotse_address_form/migration.sql`
- `scripts/test-lotse-draft.ts`, `scripts/test-lotse-transcription.ts`, `scripts/test-lotse-live.ts`
**Fremd-Einzeiler / Integrationspunkte (je 1–3 Zeilen, markiert mit „L9“)**
- `src/server/jobs/processors/index.ts`: Registrierung `transcription`
- `src/lib/nav.ts` + `messages/{de,en}/nav.json`: Eintrag „Lotse (KI)“ → `/settings/lotse`
- `scripts/check-module-guards.ts`: `"lotse-settings.ts": "EXEMPT"`
- `src/components/audit-trail.tsx`: Entity-Labels `ai_generation`, `lotse_settings`
- `src/server/ai/providers.ts` (Vertrag): `ReportDraftInput.addressForm` um `"neutral"` erweitert (abwärtskompatibel; Anforderung „ohne Einstellung neutral ohne Pronomen“)
- L5 `src/lib/reports/content.ts`: optionales Feld `lotse: lotseBlockSchema.optional()` (alte Snapshots bleiben gültig)
- L5 `src/server/services/reports/common.ts#refreshContent`: Lotse-Block beim Neuaufbau erhalten
- L5 `src/server/services/reports/submit.ts`: Schemafeld `aiReviewed` + Aufruf `applyLotseReview`
- L5 `src/server/actions/reports/workflow.ts#submitReportAction`: Checkbox `aiReviewed` durchreichen
- L5 `src/components/reports/action-message.tsx` + `messages/{de,en}/reports.json`: Fehlertext `errors.aiReviewRequired`
- L5 `src/components/reports/mobile/{report-screen,report-editor,sign-flow,sign-screen}.tsx`: Panel + Prüfbestätigung einbinden, `key` am Editor (neu laden nach Übernahme)
- L5 `src/app/(app)/reports/[id]/page.tsx`: Lotse-Panel
- L4 `src/app/(field)/m/(core)/orders/[id]/page.tsx`: Hinweis-Karte; `.../notes/page.tsx`: Sprachnotiz-Panel + „aus Sprachnotiz“
- L4 `src/server/services/field/queries.ts`: `voiceNoteId` im Notiz-Select
- L4 `src/server/services/field/voice.ts`: ohne konfigurierten Transkriptionsanbieter direkt `disabled` statt Job einreihen (sonst bliebe die Sprachnotiz bei Redis ohne Worker `pending`; hält außerdem L4-Test „Sprachnotiz ohne Transkriptions-Processor → disabled“ grün)
## 4. Schema / Migration
`20260914180000_lotse_address_form`: `ALTER TABLE tenant_settings ADD COLUMN lotse_address_form TEXT` (`TenantSettings.lotseAddressForm`, `"sie" | "du" | NULL`). **Begründung:** Der Auftrag nennt TenantSettings als Speicherort; es gab kein passendes Feld. Die Tabelle ist bereits mandantengebunden (RLS, TENANT_MODELS) → keine neue Tenant-Tabelle, keine RLS-/Topologie-Änderung. Keine weiteren Schemaänderungen (Lotse ein/aus = bestehender `TenantModule`-Toggle, Vorschläge/Prüfnachweis im Report-`content`). Keine neuen npm-Abhängigkeiten (`@anthropic-ai/sdk` war vorhanden; Transkription nutzt `fetch`/`FormData` von Node).
## 5. Tests
| Skript | Prüfungen | Ergebnis |
|---|---|---|
| `scripts/test-lotse-draft.ts` | 71: Muster der Datenminimierung (Telefon, E-Mail, Adresse; Datum/Mengen/Auftragsnummer bleiben), **Snapshot-Assertion** des Provider-Inputs (keine Telefon/E-Mail/Adresse/Namen von Kunde, Kontakt, Mandant, Mitarbeitenden; exakte minimierte Beschreibung und Notiz), Prompt je Anrede, Einstellungen (nur `tenant:manage`, Lotse aus sperrt Entwurf/Prüfung, Mandant B unberührt, Audit), Rechte/Scope/Status (ohne `lotse:use` → forbidden, Monteur ohne Zuweisung → not_found, Mandant B → not_found, approved → blocked, ohne Anbieter → not_configured, Anbieterfehler → provider_failed ohne Änderungen, Provider bei Fehlern nie aufgerufen), Fake-Mapping Output → Vorschläge, Texte/Notizen unverändert, AiGeneration = gesendeter Input, Audit; Vollständigkeitsregeln + missingInformation → Liste mit Deep-Links; Übernehmen (bearbeitet)/Verwerfen, Mandant B/Fremder kann nicht entscheiden; Submit ohne/mit `aiReviewed=false` → invalid, mit Bestätigung → submitted + Prüfnachweis; Protokoll (Monteur forbidden, Backoffice ohne Inhalte, Admin mit Inhalten, Mandant B sieht nichts) | grün |
| `scripts/test-lotse-transcription.ts` | 45: OpenAI-kompatibler Provider gegen lokalen HTTP-Server (Bearer, multipart `model`/`language=de`/`file`, HTTP 500 ohne Antwortinhalt, Größenlimit, MIME, Timeout), Factory ohne Key/fremder Provider → null, Processor-Registrierung; Processor Fake → done (Transkript, Modell, Notiz „aus Sprachnotiz“, AiGeneration ohne Inhalt), idempotent, Anhängen an verknüpfte Notiz, Provider null → disabled, Fehler → failed, Speicherfehler → failed, Lotse aus → disabled ohne Übertragung, Job mit Mandant B findet A nicht; Transkript bearbeiten (Notiz synchron, manuell bei disabled, pending → conflict, Fremder/Mandant B → not_found, ohne `field:execute` → forbidden, Audit); Zusammenfassen (Transkript minimiert gesendet, AiGeneration, Übernahme idempotent, Scope/Mandant, ohne Transkript/Anbieter/Recht) | grün |
| `scripts/test-lotse-live.ts` | optional gegen Claude, nur mit `ANTHROPIC_API_KEY` (sonst Skip, Exit 0) | übersprungen (kein Key) |
**Gate:** `npm run gate` grün – prisma generate, tsc, lint (0 Fehler, 2 Warnungen außerhalb L9: `services/field/mime.ts` u. a.), build inkl. Modul-Guard-Check (29 Action-Dateien), **45/45 Testskripte**.
**HTTP-Smoke** (Dev-Server :3109, Lane-DB `craftvia_lotse`, Seed-Logins über Auth.js-Credentials, temporärer Smoke-Auftrag im Demo-Mandanten – wieder entfernt): Monteur `/m/orders/[id]` 200 mit „Angaben fehlen“, „Lotse prüfen lassen“, Pflichtfoto-, Arbeitszeit- und Lotse-Hinweis; `/notes` 200 mit Status „Transkribiert“, Transkript, „aus Sprachnotiz“, „Transkript bearbeiten“; `/report?type=daily` 200 mit „Vorschlag vom Lotsen – bitte prüfen“, Prüf-Checkbox, „Neu vorbereiten“, „Lotse ist nicht eingerichtet“, fehlende Angaben, Übernehmen/Verwerfen; Monteur `/settings/lotse` → Redirect. Admin `/settings/lotse`, `/settings/lotse/protocol` (+ Inhalt-Popup) und `/reports/[id]` 200 mit erwarteten Inhalten. Backoffice: Protokoll 200 ohne Inhaltslink, `/settings/lotse` → Redirect. Mandant B (`admin2`): `/reports/[A]` 404, Protokoll ohne A-Einträge. Visuelle Prüfung im Browser nicht durchgeführt (Login würde Passworteingabe im Browser erfordern); Layouts nutzen die L4/L5-Klassen (Touch-Ziele ≥ 48 px mobil, ≥ 44 px Backoffice, Status immer mit Text + Icon, Farben nur über Tokens).
## 6. Stubs & Abhängigkeiten
Keine neuen Stubs. Genutzt: L5 `requireVisibleReport`/`contentOf`/`buildReportContent`/`submitReport`, L4 `requireFieldOrder`/`createNote`, L2 `workOrderScope`/`requireVisibleWorkOrder`, `services/documents/read.ts#readDocumentBytes`, `api/context.ts#requireApiContext`.
## 7. Bekannte Lücken / Hinweise an den Architekten
1. **Offline-Submit (L7):** Die Sync-Op `report.submit` ist noch nicht registriert (`services/sync/external-ops.ts`). Sobald sie kommt, muss `aiReviewed` im Payload an `submitReport` durchgereicht werden – sonst werden Lotse-Entwürfe offline korrekt, aber mit `invalid` abgelehnt.
2. **`/dashboard` 500 (L2, nicht L9):** `src/app/(app)/dashboard/page.tsx:134` ruft die Client-Funktion `buttonCls()` im Server-Component auf („Attempted to call buttonCls() from the server“). Tritt für Admin und Backoffice auf; Build/Gate bleiben grün, weil es ein Laufzeitfehler ist.
3. **DSGVO `pii-fields.ts` (Fundament):** `AiGeneration.createdById` eintragen; `AiGeneration.input/output` enthalten minimierte Einsatzdaten → beim DSGVO-Export/-Löschen mitberücksichtigen.
4. **Aufbewahrung `AiGeneration`:** Kein Löschkonzept für KI-Protokolleinträge (Spec §31 „Aufbewahrung“); Vorschlag: Inhalte nach X Monaten auf `null` setzen, Metadaten behalten.
5. **Mustererkennung** der Datenminimierung ist bewusst konservativ (Telefon nur mit führender 0/+, ≥ 7 Ziffern; Straßen über gängige Suffixe). Unbekannte Personennamen im Freitext (z. B. Nachbarn) werden nicht erkannt; alle Mitarbeitenden des Mandanten, Kontakte und Privatkunden des Auftrags schon.
6. **Unterbrechung beim Übernehmen:** Ungespeicherte Eingaben im Berichtseditor gehen verloren, wenn ein Vorschlag übernommen wird (Editor wird neu geladen); Hinweis steht am Button.
7. **`lotse-settings.ts`** liegt als Top-Level-Action (EXEMPT), weil ein Modul-Guard auf `lotse` das Wiedereinschalten verhindern würde; Auth = `requireSession` + `requirePermission` + DB-autoritativ `requireApiContext(null, "tenant:manage")`.
8. **Kosten-/Mengenlimit je Mandant** (Spec §31) nicht umgesetzt; `AiGeneration` liefert die Datenbasis (Tokens je Nutzung).
9. Kein `/api/v1`-Endpunkt für Lotse (mobile UI nutzt Server Actions); für Offline/Integrationen ggf. nachziehen.