diff --git a/docs/craftvia/lanes/lotse.md b/docs/craftvia/lanes/lotse.md new file mode 100644 index 0000000..d6cadec --- /dev/null +++ b/docs/craftvia/lanes/lotse.md @@ -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. diff --git a/messages/de/lotse.json b/messages/de/lotse.json new file mode 100644 index 0000000..a879388 --- /dev/null +++ b/messages/de/lotse.json @@ -0,0 +1,152 @@ +{ + "name": "Lotse", + "suggestionBadge": "Vorschlag vom Lotsen – bitte prüfen", + "draft": { + "button": "Bericht mit Lotse vorbereiten", + "again": "Neu vorbereiten", + "running": "Lotse bereitet den Bericht vor …", + "hint": "Der Lotse formuliert aus Notizen, Sprachnotizen, Zeiten, Material, Fotos und Checkliste einen Vorschlag. In den Bericht kommt nur, was bestätigt wird.", + "notConfigured": "Lotse ist nicht eingerichtet. Das Büro kann den KI-Anbieter hinterlegen.", + "done": "{count, plural, =0 {Keine Vorschläge – dafür fehlen noch Daten.} one {# Vorschlag bereit.} other {# Vorschläge bereit.}}", + "draftedAt": "Vorbereitet am {date}", + "missingTitle": "Das fehlt dem Lotsen noch", + "unsavedHint": "Ungespeicherte Änderungen im Bericht vorher speichern.", + "allDecided": "Alle Vorschläge sind bearbeitet.", + "reviewedAt": "Vorschlag geprüft und bestätigt am {date}" + }, + "suggestion": { + "current": "Bisheriger Text", + "empty": "(leer)", + "text": "Vorschlag", + "accept": "Übernehmen", + "discard": "Verwerfen", + "accepted": "Übernommen", + "discarded": "Verworfen" + }, + "review": { + "label": "Ich habe den Vorschlag vom Lotsen geprüft.", + "hint": "Pflicht vor dem Absenden: Dieser Bericht wurde mit dem Lotsen vorbereitet." + }, + "completeness": { + "title": "{count, plural, one {# Angabe fehlt} other {# Angaben fehlen}}", + "check": "Lotse prüfen lassen", + "fix": "Ergänzen", + "fromLotse": "Hinweis vom Lotsen", + "item": { + "photo_requirement": "Pflichtfoto fehlt: {label}", + "checklist_item": "Pflichtpunkt der Checkliste offen: {label}", + "material_reason": "Materialabweichung ohne Grund: {label}", + "material_unconfirmed": "Geplantes Material nicht bestätigt: {label}", + "no_work_time": "Keine Arbeitszeit erfasst", + "no_description": "Keine Tätigkeitsbeschreibung", + "signature_missing": "Unterschrift fehlt", + "lotse_hint": "{text}" + } + }, + "voice": { + "status": { + "pending": "Transkription läuft", + "done": "Transkribiert", + "failed": "Transkription fehlgeschlagen", + "disabled": "Ohne Transkription" + }, + "transcript": "Transkript", + "noTranscript": "Noch kein Text. Text kann von Hand eingetragen werden.", + "edit": "Transkript bearbeiten", + "cancel": "Abbrechen", + "save": "Transkript speichern", + "saved": "Transkript gespeichert.", + "summarize": "Sprachnotiz zusammenfassen", + "summarizing": "Lotse fasst zusammen …", + "summaryTitle": "Zusammenfassung", + "adopt": "Als Notiz übernehmen", + "adopted": "Als Notiz gespeichert.", + "dismiss": "Ausblenden", + "fromVoice": "aus Sprachnotiz" + }, + "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.", + "not_configured": "Lotse ist nicht eingerichtet.", + "provider_failed": "Lotse ist gerade nicht erreichbar. Bitte später erneut versuchen.", + "not_editable": "Der Bericht ist eingereicht und kann nicht mehr geändert werden.", + "no_suggestion": "Dieser Vorschlag ist schon bearbeitet. Bitte Seite neu laden.", + "no_transcript": "Die Sprachnotiz hat noch keinen Text.", + "pending": "Die Transkription läuft noch.", + "conflict": "Inzwischen geändert. Bitte Seite neu laden.", + "invalid": "Bitte Eingaben prüfen." + }, + "settings": { + "back": "Einstellungen", + "crumb": "Organisation", + "title": "Lotse (KI-Assistent)", + "sub": "Einschalten, Anrede und Transparenz", + "saved": "Einstellungen gespeichert.", + "error": "Speichern nicht möglich.", + "use": "Verwendung", + "enabled": "Lotse für diesen Betrieb verwenden", + "enabledHint": "Ausgeschaltet: keine Berichtsentwürfe, keine Zusammenfassungen, Sprachnotizen ohne Transkription.", + "addressForm": "Anrede des Lotsen", + "addressFormHint": "Gilt einheitlich für alle Texte des Lotsen in diesem Betrieb.", + "address": { + "neutral": "Neutral, ohne Anrede", + "sie": "Sie", + "du": "du" + }, + "save": "Speichern", + "dataTitle": "Welche Daten an wen gehen", + "draftProvider": "Berichtsentwurf und Zusammenfassung", + "transcriptionProvider": "Transkription von Sprachnotizen", + "providerLine": "{provider} · Modell {model}", + "transcriptionLine": "Server {host} · Modell {model}", + "configured": "eingerichtet", + "notConfigured": "nicht eingerichtet", + "sentTitle": "Gesendet wird", + "sent": { + "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" + }, + "notSentTitle": "Nicht gesendet wird", + "notSent": { + "contact": "Telefonnummern, E-Mail-Adressen und Anschriften (werden durch Platzhalter ersetzt)", + "names": "Namen: Mitarbeitende nur als Initialen, Ansprechpartner und Privatkunden als Rolle", + "customer": "Kundenname, Kundennummer, Fotos und Unterschriften" + }, + "principle": "Vorschläge des Lotsen werden gekennzeichnet und erst nach Bestätigung übernommen. Ein vorbereiteter Bericht kann nur mit der Bestätigung „Ich habe den Vorschlag geprüft“ abgesendet werden. Die ursprünglichen Notizen bleiben unverändert.", + "protocolLink": "KI-Protokoll ansehen" + }, + "protocol": { + "back": "Lotse-Einstellungen", + "crumb": "Organisation · Lotse", + "title": "KI-Protokoll", + "sub": "Jede Nutzung des Lotsen: Zeitpunkt, Art, Modell, Tokens, Nutzer", + "empty": "Noch keine KI-Nutzung.", + "time": "Zeitpunkt", + "kind": "Art", + "model": "Modell", + "tokens": "Tokens (ein/aus)", + "user": "Nutzer", + "system": "System", + "content": "Inhalt", + "show": "Anzeigen", + "contentTitle": "Gesendete Daten und Antwort", + "input": "An den Anbieter gesendet", + "output": "Antwort", + "close": "Schließen", + "contentHint": "Inhalte sehen nur Mandantenadministratoren.", + "prev": "Zurück", + "next": "Weiter", + "pageOf": "Seite {page} von {pages}", + "kinds": { + "report_draft": "Berichtsentwurf", + "voice_summary": "Zusammenfassung Sprachnotiz", + "transcription": "Transkription", + "import_extraction": "Auftragsimport", + "completeness_check": "Vollständigkeitsprüfung" + } + } +} diff --git a/messages/de/nav.json b/messages/de/nav.json index 5a215f3..72a2338 100644 --- a/messages/de/nav.json +++ b/messages/de/nav.json @@ -13,5 +13,6 @@ "notifications": "Benachrichtigungen", "audit": "Audit-Protokoll", "email": "E-Mail-Versand", + "lotse": "Lotse (KI)", "admin": "Admin-Konsole" } diff --git a/messages/de/reports.json b/messages/de/reports.json index 6007564..c97594c 100644 --- a/messages/de/reports.json +++ b/messages/de/reports.json @@ -177,7 +177,8 @@ "reasonRequired": "Bitte einen Grund angeben.", "signerRequired": "Bitte den Namen des Unterzeichners angeben.", "imageRequired": "Bitte zuerst unterschreiben lassen.", - "notRequiredForbidden": "Für diesen Auftrag ist eine Unterschrift erforderlich." + "notRequiredForbidden": "Für diesen Auftrag ist eine Unterschrift erforderlich.", + "aiReviewRequired": "Bitte bestätigen, dass der Vorschlag vom Lotsen geprüft wurde." }, "blocker": { "checklist_item": "Checkliste offen: {label}", diff --git a/messages/en/lotse.json b/messages/en/lotse.json new file mode 100644 index 0000000..104753c --- /dev/null +++ b/messages/en/lotse.json @@ -0,0 +1,152 @@ +{ + "name": "Lotse", + "suggestionBadge": "Suggestion from Lotse – please review", + "draft": { + "button": "Prepare report with Lotse", + "again": "Prepare again", + "running": "Lotse is preparing the report …", + "hint": "Lotse drafts a suggestion from notes, voice notes, times, material, photos and checklist. Only what is confirmed goes into the report.", + "notConfigured": "Lotse is not set up. The office can configure the AI provider.", + "done": "{count, plural, =0 {No suggestions – data is still missing.} one {# suggestion ready.} other {# suggestions ready.}}", + "draftedAt": "Prepared on {date}", + "missingTitle": "Lotse is still missing", + "unsavedHint": "Save unsaved changes in the report first.", + "allDecided": "All suggestions have been handled.", + "reviewedAt": "Suggestion reviewed and confirmed on {date}" + }, + "suggestion": { + "current": "Current text", + "empty": "(empty)", + "text": "Suggestion", + "accept": "Accept", + "discard": "Discard", + "accepted": "Accepted", + "discarded": "Discarded" + }, + "review": { + "label": "I have reviewed the Lotse suggestion.", + "hint": "Required before submitting: this report was prepared with Lotse." + }, + "completeness": { + "title": "{count, plural, one {# item missing} other {# items missing}}", + "check": "Let Lotse check", + "fix": "Complete", + "fromLotse": "Hint from Lotse", + "item": { + "photo_requirement": "Required photo missing: {label}", + "checklist_item": "Required checklist item open: {label}", + "material_reason": "Material deviation without reason: {label}", + "material_unconfirmed": "Planned material not confirmed: {label}", + "no_work_time": "No working time recorded", + "no_description": "No description of the work", + "signature_missing": "Signature missing", + "lotse_hint": "{text}" + } + }, + "voice": { + "status": { + "pending": "Transcription running", + "done": "Transcribed", + "failed": "Transcription failed", + "disabled": "No transcription" + }, + "transcript": "Transcript", + "noTranscript": "No text yet. Text can be entered manually.", + "edit": "Edit transcript", + "cancel": "Cancel", + "save": "Save transcript", + "saved": "Transcript saved.", + "summarize": "Summarise voice note", + "summarizing": "Lotse is summarising …", + "summaryTitle": "Summary", + "adopt": "Save as note", + "adopted": "Saved as note.", + "dismiss": "Hide", + "fromVoice": "from voice note" + }, + "errors": { + "generic": "That did not work. Please try again.", + "not_found": "Not found.", + "forbidden": "You are not allowed to do this.", + "disabled": "Lotse is switched off for this business.", + "not_configured": "Lotse is not set up.", + "provider_failed": "Lotse is currently unavailable. Please try again later.", + "not_editable": "The report has been submitted and can no longer be changed.", + "no_suggestion": "This suggestion has already been handled. Please reload the page.", + "no_transcript": "The voice note has no text yet.", + "pending": "The transcription is still running.", + "conflict": "Changed in the meantime. Please reload the page.", + "invalid": "Please check your input." + }, + "settings": { + "back": "Settings", + "crumb": "Organisation", + "title": "Lotse (AI assistant)", + "sub": "Switch on, form of address and transparency", + "saved": "Settings saved.", + "error": "Could not save.", + "use": "Usage", + "enabled": "Use Lotse in this business", + "enabledHint": "Switched off: no report drafts, no summaries, voice notes without transcription.", + "addressForm": "Lotse form of address", + "addressFormHint": "Applies consistently to all Lotse texts in this business.", + "address": { + "neutral": "Neutral, no form of address", + "sie": "Formal (Sie)", + "du": "Informal (du)" + }, + "save": "Save", + "dataTitle": "Which data goes where", + "draftProvider": "Report draft and summary", + "transcriptionProvider": "Voice note transcription", + "providerLine": "{provider} · model {model}", + "transcriptionLine": "Server {host} · model {model}", + "configured": "set up", + "notConfigured": "not set up", + "sentTitle": "Sent", + "sent": { + "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" + }, + "notSentTitle": "Not sent", + "notSent": { + "contact": "Phone numbers, e-mail addresses and postal addresses (replaced by placeholders)", + "names": "Names: employees as initials only, contacts and private customers as roles", + "customer": "Customer name, customer number, photos and signatures" + }, + "principle": "Lotse suggestions are marked and only applied after confirmation. A prepared report can only be submitted with the confirmation “I have reviewed the suggestion”. The original notes stay unchanged.", + "protocolLink": "View AI log" + }, + "protocol": { + "back": "Lotse settings", + "crumb": "Organisation · Lotse", + "title": "AI log", + "sub": "Every use of Lotse: time, kind, model, tokens, user", + "empty": "No AI usage yet.", + "time": "Time", + "kind": "Kind", + "model": "Model", + "tokens": "Tokens (in/out)", + "user": "User", + "system": "System", + "content": "Content", + "show": "Show", + "contentTitle": "Data sent and response", + "input": "Sent to the provider", + "output": "Response", + "close": "Close", + "contentHint": "Only tenant administrators can see contents.", + "prev": "Previous", + "next": "Next", + "pageOf": "Page {page} of {pages}", + "kinds": { + "report_draft": "Report draft", + "voice_summary": "Voice note summary", + "transcription": "Transcription", + "import_extraction": "Order import", + "completeness_check": "Completeness check" + } + } +} diff --git a/messages/en/nav.json b/messages/en/nav.json index a78fd64..46a6776 100644 --- a/messages/en/nav.json +++ b/messages/en/nav.json @@ -13,5 +13,6 @@ "notifications": "Notifications", "audit": "Audit log", "email": "E-mail delivery", + "lotse": "Lotse (AI)", "admin": "Admin console" } diff --git a/messages/en/reports.json b/messages/en/reports.json index df3a928..4dbc881 100644 --- a/messages/en/reports.json +++ b/messages/en/reports.json @@ -177,7 +177,8 @@ "reasonRequired": "Please enter a reason.", "signerRequired": "Please enter the name of the signer.", "imageRequired": "Please have the customer sign first.", - "notRequiredForbidden": "A signature is required for this work order." + "notRequiredForbidden": "A signature is required for this work order.", + "aiReviewRequired": "Please confirm that you have reviewed the Lotse suggestion." }, "blocker": { "checklist_item": "Checklist open: {label}", diff --git a/prisma/migrations/20260914180000_lotse_address_form/migration.sql b/prisma/migrations/20260914180000_lotse_address_form/migration.sql new file mode 100644 index 0000000..1261a56 --- /dev/null +++ b/prisma/migrations/20260914180000_lotse_address_form/migration.sql @@ -0,0 +1,6 @@ +-- L9 Lotse: Anrede des KI-Assistenten je Mandant (Brandbook §9.2: "Sie" oder "du", innerhalb eines +-- Mandanten konsistent). NULL = keine Einstellung → neutrale Formulierung ohne Pronomen. +-- Nur eine neue Spalte an tenant_settings (bereits mandantengebunden/RLS), keine neue Tabelle. + +-- AlterTable +ALTER TABLE "tenant_settings" ADD COLUMN "lotse_address_form" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cf52408..a89235a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -75,6 +75,8 @@ model TenantSettings { mailReplyTo String? @map("mail_reply_to") emergencyRecipients String[] @default([]) @map("emergency_recipients") billingRecipients String[] @default([]) @map("billing_recipients") + // Lotse (L9): "sie" | "du"; null = neutral without pronouns (Brandbook §9.2) + lotseAddressForm String? @map("lotse_address_form") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/scripts/check-module-guards.ts b/scripts/check-module-guards.ts index fdebb15..02ff65f 100644 --- a/scripts/check-module-guards.ts +++ b/scripts/check-module-guards.ts @@ -52,6 +52,8 @@ const ACTION_MODULE: Record = { // Mandanten-Fundament (requireSession/requirePermission) "tenant-users.ts": "EXEMPT", "tenant-settings.ts": "EXEMPT", + // L9 Lotse: Modul-Toggle "lotse" selbst (darf nicht vom Modul-Guard abhängen) + Anrede; requireSession/requirePermission("tenant:manage") + "lotse-settings.ts": "EXEMPT", "account.ts": "EXEMPT", "tenant-switch.ts": "EXEMPT", "webauthn.ts": "EXEMPT", diff --git a/scripts/test-lotse-draft.ts b/scripts/test-lotse-draft.ts new file mode 100644 index 0000000..1ea78c7 --- /dev/null +++ b/scripts/test-lotse-draft.ts @@ -0,0 +1,294 @@ +// L9 Lotse – Berichtsentwurf, Datenminimierung, Vollständigkeitsprüfung, Freigabeprinzip, Einstellungen, Protokoll. +// +// Deckt ab: Provider-Fake (Mapping Output → Vorschläge im Report-content, missingInformation → Liste), +// Datenminimierung (KI-Input ohne Telefon/E-Mail/Adresse/Namen – Snapshot-Assertion), Rechte/Scope/Status +// (approved → blocked, Monteur ohne Zuweisung → not_found, ohne lotse:use → forbidden, Lotse aus → forbidden), +// Mandantentrennung (B liest/ändert nichts von A), Submit ohne Prüfbestätigung → invalid, Audit. +// +// Lauf: npx tsx scripts/test-lotse-draft.ts + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { Prisma } from "@prisma/client"; +import { prisma, dbForTenant } from "../src/server/db"; +import { ROLE_DEFS, type RoleKey } from "../src/server/rbac"; +import { ServiceError, type ServiceCtx } from "../src/server/services/context"; +import { buildReportContent, tenantTimeZone } from "../src/server/services/reports/build-content"; +import { dateKeyToDbDate, localDateKey } from "../src/lib/reports/dates"; +import { parseReportContent } from "../src/lib/reports/content"; +import { submitReport } from "../src/server/services/reports/submit"; +import { updateReportTexts } from "../src/server/services/reports/edit"; +import { FakeLotseProvider } from "../src/server/ai/lotse/fake"; +import { reportDraftSystemPrompt } from "../src/server/ai/lotse/prompt"; +import { draftReportWithLotse, prepareDraftInput } from "../src/server/services/lotse/draft-report"; +import { decideLotseSuggestion } from "../src/server/services/lotse/suggestions"; +import { checkCompleteness } from "../src/server/services/lotse/completeness"; +import { applyLotseReview } from "../src/server/services/lotse/review"; +import { initials, scrubText, type MinimizationContext } from "../src/server/services/lotse/minimize"; +import { isLotseEnabled, lotseVoice, updateLotseSettings } from "../src/server/services/lotse/settings"; +import { getAiGenerationContent, listAiGenerations } from "../src/server/services/lotse/protocol"; + +let failures = 0; +const ok = (cond: boolean, msg: string) => { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failures++; +}; + +async function expectErr(fn: () => Promise, code: ServiceError["code"], msg: string, detail?: { reason?: string; field?: string }) { + try { + await fn(); + ok(false, `${msg} — kein Fehler`); + } catch (err) { + if (!(err instanceof ServiceError)) return ok(false, `${msg} — ${(err as Error).message}`); + const d = (err.details ?? {}) as { reason?: string; field?: string }; + const match = err.code === code && (!detail?.reason || d.reason === detail.reason) && (!detail?.field || d.field === detail.field); + ok(match, `${msg} (${err.code}${d.reason ? `/${d.reason}` : ""}${d.field ? `/${d.field}` : ""})`); + } +} + +/** Key-order independent JSON (Postgres jsonb reorders object keys). */ +const canon = (v: unknown): string => + JSON.stringify(v, (_k, val) => (val && typeof val === "object" && !Array.isArray(val) ? Object.fromEntries(Object.entries(val).sort(([a], [b]) => a.localeCompare(b))) : val)); + +const SLUGS = ["zz-lotse-a", "zz-lotse-b"]; +const DOMAIN = "@zz-lotse.test"; + +async function cleanup() { + const tenants = await prisma.tenant.findMany({ where: { slug: { in: SLUGS } }, select: { id: true } }); + const ids = tenants.map((t) => t.id); + if (ids.length) { + const w = { where: { tenantId: { in: ids } } }; + await prisma.notification.deleteMany(w); + await prisma.aiGeneration.deleteMany(w); + await prisma.signature.deleteMany(w); + await prisma.report.deleteMany(w); + await prisma.activityNote.deleteMany(w); + await prisma.voiceNote.deleteMany(w); + await prisma.materialUsage.deleteMany(w); + await prisma.materialPlan.deleteMany(w); + await prisma.timeEntry.deleteMany(w); + await prisma.workSession.deleteMany(w); + await prisma.photo.deleteMany(w); + await prisma.checklistItem.deleteMany(w); + await prisma.photoRequirement.deleteMany(w); + await prisma.workOrderStatusChange.deleteMany(w); + await prisma.workOrderAssignee.deleteMany(w); + await prisma.document.deleteMany(w); + await prisma.workOrder.deleteMany(w); + await prisma.site.deleteMany(w); + await prisma.contact.deleteMany(w); + await prisma.customer.deleteMany(w); + await prisma.numberSequence.deleteMany(w); + await prisma.auditLog.deleteMany(w); + await prisma.tenantModule.deleteMany(w); + await prisma.tenantSettings.deleteMany(w); + await prisma.user.deleteMany(w); + await prisma.tenant.deleteMany({ where: { id: { in: ids } } }); + } + await prisma.identity.deleteMany({ where: { email: { endsWith: DOMAIN } } }); +} + +async function mkUser(tenantId: string, key: string, name: string) { + const identity = await prisma.identity.create({ data: { email: `${key}${DOMAIN}`, passwordHash: "x" } }); + return prisma.user.create({ data: { tenantId, identityId: identity.id, email: identity.email, name } }); +} + +const ctxOf = (tenantId: string, userId: string, role: RoleKey, drop: string[] = []): ServiceCtx => ({ + db: dbForTenant(tenantId), + tenantId, + userId, + permissions: new Set(ROLE_DEFS[role].permissions.filter((p) => !drop.includes(p))), +}); + +async function main() { + await cleanup(); + + // ---------- fixtures ---------- + const tA = await prisma.tenant.create({ data: { name: "Lotse-Test A", slug: SLUGS[0] } }); + const tB = await prisma.tenant.create({ data: { name: "Lotse-Test B", slug: SLUGS[1] } }); + await prisma.tenantSettings.create({ data: { tenantId: tA.id, orgName: "Lotse Test A GmbH", phone: "040 1234567", email: "buero@lotse-a.test", address: "Werftstraße 5, 20457 Hamburg" } }); + await prisma.tenantSettings.create({ data: { tenantId: tB.id, orgName: "Lotse Test B" } }); + + const tech = await mkUser(tA.id, "tech", "Max Monteur"); + const outsider = await mkUser(tA.id, "outsider", "Otto Fremd"); + const office = await mkUser(tA.id, "office", "Bernd Büro"); + const admin = await mkUser(tA.id, "admin", "Anna Admin"); + const techB = await mkUser(tB.id, "techb", "Tom Bader"); + const adminB = await mkUser(tB.id, "adminb", "Bea Chefin"); + + const techCtx = ctxOf(tA.id, tech.id, "technician"); + const techNoLotse = ctxOf(tA.id, tech.id, "technician", ["lotse:use"]); + const outsiderCtx = ctxOf(tA.id, outsider.id, "technician"); + const officeCtx = ctxOf(tA.id, office.id, "backoffice"); + const adminCtx = ctxOf(tA.id, admin.id, "tenant-admin"); + const ctxB = ctxOf(tB.id, techB.id, "technician"); + const adminBCtx = ctxOf(tB.id, adminB.id, "tenant-admin"); + + const customer = await prisma.customer.create({ + data: { tenantId: tA.id, firstName: "Erika", lastName: "Mustermann", phone: "0171 9876543", email: "erika@kunde.test", street: "Lindenallee", houseNumber: "7", postalCode: "22111", city: "Hamburg" }, + }); + const contact = await prisma.contact.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Klaus Kontakt", phone: "040 555666", email: "klaus@kunde.test" } }); + const site = await prisma.site.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Haus Mustermann", street: "Lindenallee", houseNumber: "7", postalCode: "22111", city: "Hamburg" } }); + const wo = await prisma.workOrder.create({ + data: { + tenantId: tA.id, + number: "A-LOTSE-1", + customerId: customer.id, + siteId: site.id, + contactId: contact.id, + title: "Heizkörper tauschen", + description: "Kundin erreichbar unter 0171 9876543 oder erika@kunde.test, Lindenallee 7, 22111 Hamburg.", + scope: "Heizkörper im Bad ersetzen", + status: "in_progress", + signatureRequired: true, + assignees: { create: [{ tenantId: tA.id, userId: tech.id }] }, + checklistItems: { create: [{ tenantId: tA.id, key: "vent", label: "Anlage entlüftet", required: true }] }, + photoRequirements: { create: [{ tenantId: tA.id, key: "typ", label: "Typenschild" }] }, + materialPlans: { + create: [ + { tenantId: tA.id, name: "Heizkörper 600x1000", plannedQuantity: new Prisma.Decimal(2), unit: "Stk", sortOrder: 1 }, + { tenantId: tA.id, name: "Thermostatventil", plannedQuantity: new Prisma.Decimal(1), unit: "Stk", sortOrder: 2 }, + ], + }, + }, + include: { materialPlans: { orderBy: { sortOrder: "asc" } } }, + }); + await prisma.materialUsage.create({ + data: { tenantId: tA.id, workOrderId: wo.id, materialPlanId: wo.materialPlans[0].id, name: wo.materialPlans[0].name, actualQuantity: new Prisma.Decimal(1), unit: "Stk", usageStatus: "partially_used", recordedById: tech.id }, + }); + const noteText = "Max Monteur hat den alten Heizkörper demontiert. Rückruf an Klaus Kontakt unter 040 555666, Mail klaus@kunde.test."; + const note = await prisma.activityNote.create({ data: { tenantId: tA.id, workOrderId: wo.id, authorId: tech.id, kind: "work_done", text: noteText } }); + + const tz = await tenantTimeZone(techCtx); + const today = localDateKey(new Date(), tz); + const content = await buildReportContent(techCtx, { workOrderId: wo.id, type: "daily", reportDate: today, reportNumber: "B-LOTSE-1", version: 1, technicianUserId: tech.id }); + const report = await prisma.report.create({ + data: { tenantId: tA.id, workOrderId: wo.id, type: "daily", reportDate: dateKeyToDbDate(today), lineageId: randomUUID(), status: "draft", content: content as unknown as Prisma.InputJsonValue, createdById: tech.id }, + }); + const approved = await prisma.report.create({ + data: { tenantId: tA.id, workOrderId: wo.id, type: "completion", reportDate: dateKeyToDbDate(today), lineageId: randomUUID(), status: "approved", content: content as unknown as Prisma.InputJsonValue, createdById: tech.id }, + }); + + // ---------- 1. Datenminimierung ---------- + const m0: MinimizationContext = { employees: [], contacts: [], customerPersons: [], phones: [], emails: [], addressParts: [] }; + ok(scrubText("Bitte 0151-12345678 anrufen", m0) === "Bitte [Telefon] anrufen", "Muster: Mobilnummer → [Telefon]"); + ok(scrubText("Rückruf +49 40 1234567", m0) === "Rückruf [Telefon]", "Muster: internationale Nummer → [Telefon]"); + ok(scrubText("Mail an x.y@firma-nord.de", m0) === "Mail an [E-Mail]", "Muster: E-Mail → [E-Mail]"); + ok(scrubText("Baustelle Am Kaiserkai 30, 20457 Hamburg", m0) === "Baustelle Am [Adresse]", "Muster: Straße + PLZ/Ort → [Adresse]"); + ok(scrubText("Termin 12.10.2026, 24 m Kupferrohr, Auftrag A-2026-0001", m0) === "Termin 12.10.2026, 24 m Kupferrohr, Auftrag A-2026-0001", "Datum, Mengen, Auftragsnummern bleiben erhalten"); + ok(initials("Anna-Lena Bauer") === "A. B.", "Initialen aus Namen"); + + const input = await prepareDraftInput(techCtx, report, parseReportContent(report.content)); + const json = JSON.stringify(input); + const secrets = ["0171 9876543", "erika@kunde.test", "Lindenallee", "22111", "040 555666", "klaus@kunde.test", "Max Monteur", "Monteur hat", "Mustermann", "Klaus Kontakt", "040 1234567", "buero@lotse-a.test", "Werftstraße", "Hamburg"]; + const leaked = secrets.filter((s) => json.includes(s)); + ok(leaked.length === 0, `Snapshot KI-Input enthält keine Telefon/E-Mail/Adresse/Namen${leaked.length ? ` — gefunden: ${leaked.join(", ")}` : ""}`); + ok(input.workOrder.description === "Kundin erreichbar unter [Telefon] oder [E-Mail], [Adresse].", `Snapshot Auftragsbeschreibung minimiert: „${input.workOrder.description}“`); + ok(input.notes.length === 1 && input.notes[0].text === "M. M. hat den alten Heizkörper demontiert. Rückruf an Ansprechpartner unter [Telefon], Mail [E-Mail].", `Snapshot Notiz: Mitarbeiter → Initialen, Ansprechpartner → Rolle: „${input.notes[0]?.text}“`); + ok(!("customer" in input) && input.materials.length === 1 && input.materials[0].status === "partially_used" && input.materials[0].planned === "2", "Kein Kundenblock im Input; Material des Tages mit Plan-/Istmenge"); + ok(input.addressForm === "neutral" && input.locale === "de", "Ohne Einstellung: neutrale Anrede"); + ok(reportDraftSystemPrompt({ addressForm: "neutral", locale: "de" }).includes("ohne Anrede-Pronomen"), "System-Prompt neutral ohne Pronomen"); + ok(reportDraftSystemPrompt({ addressForm: "sie", locale: "de" }).includes("Sie-Form") && reportDraftSystemPrompt({ addressForm: "du", locale: "de" }).includes("du-Form"), "System-Prompt Sie/du je Einstellung"); + + // ---------- 2. Einstellungen ---------- + await expectErr(() => updateLotseSettings(techCtx, { enabled: false, addressForm: "du" }), "forbidden", "Monteur darf Lotse-Einstellungen nicht ändern"); + await updateLotseSettings(adminCtx, { enabled: false, addressForm: "sie" }); + ok(!(await isLotseEnabled(adminCtx)) && (await lotseVoice(adminCtx)).addressForm === "sie", "Admin: Lotse aus + Anrede Sie gespeichert"); + ok(await isLotseEnabled(adminBCtx), "Mandant B bleibt unberührt (Lotse an)"); + const fake = new FakeLotseProvider({ + output: { workPerformed: "- Alten Heizkörper demontiert", hints: "Thermostatventil noch montieren", missingInformation: ["Grund für die Mindermenge Heizkörper fehlt"] }, + }); + await expectErr(() => draftReportWithLotse(techCtx, report.id, { provider: fake }), "forbidden", "Lotse aus → Entwurf gesperrt", { reason: "disabled" }); + await expectErr(() => checkCompleteness(techCtx, wo.id), "forbidden", "Lotse aus → Vollständigkeitsprüfung gesperrt", { reason: "disabled" }); + await updateLotseSettings(adminCtx, { enabled: true, addressForm: "sie" }); + ok((await prepareDraftInput(techCtx, report, parseReportContent(report.content))).addressForm === "sie", "Anrede aus Mandanteneinstellung im KI-Input"); + const audit = await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "lotse_settings" }, orderBy: { createdAt: "desc" } }); + ok(Boolean(audit?.before) && Boolean(audit?.after), "Einstellungsänderung im Audit (before/after)"); + + // ---------- 3. Rechte / Scope / Status / Mandant ---------- + await expectErr(() => draftReportWithLotse(techNoLotse, report.id, { provider: fake }), "forbidden", "Ohne lotse:use → forbidden"); + await expectErr(() => draftReportWithLotse(outsiderCtx, report.id, { provider: fake }), "not_found", "Monteur ohne Zuweisung → not_found"); + await expectErr(() => draftReportWithLotse(ctxB, report.id, { provider: fake }), "not_found", "Mandant B → not_found"); + await expectErr(() => draftReportWithLotse(techCtx, approved.id, { provider: fake }), "blocked", "Freigegebener Bericht → blocked", { reason: "not_editable" }); + await expectErr(() => draftReportWithLotse(techCtx, report.id, { provider: null }), "invalid", "Ohne KI-Konfiguration → Hinweis nicht eingerichtet", { reason: "not_configured" }); + await expectErr(() => draftReportWithLotse(techCtx, report.id, { provider: new FakeLotseProvider({ fail: new Error("boom") }) }), "conflict", "Provider-Fehler → provider_failed", { reason: "provider_failed" }); + const untouched = await prisma.report.findUniqueOrThrow({ where: { id: report.id } }); + ok(!untouched.aiDrafted && !untouched.aiGenerationId && (await prisma.aiGeneration.count({ where: { tenantId: tA.id } })) === 0, "Fehlschläge ändern nichts (kein aiDrafted, keine AiGeneration)"); + ok(fake.draftCalls.length === 0, "Bei Rechte-/Scope-/Statusfehlern wird der Provider nie aufgerufen"); + + // ---------- 4. Entwurf ---------- + const res = await draftReportWithLotse(techCtx, report.id, { provider: fake }); + const drafted = await prisma.report.findUniqueOrThrow({ where: { id: report.id } }); + const dc = parseReportContent(drafted.content); + ok(res.suggestions === 2 && drafted.aiDrafted && drafted.aiGenerationId === res.generationId, "Entwurf: aiDrafted + aiGenerationId gesetzt"); + ok(dc.lotse?.suggestions.map((s) => `${s.field}:${s.state}`).join(",") === "workPerformed:pending,hints:pending", "Mapping Output → Vorschläge (nur nicht-leere Felder, Status pending)"); + ok(dc.lotse?.suggestions[0].text === "- Alten Heizkörper demontiert", "Vorschlagstext übernommen"); + ok(JSON.stringify(dc.texts) === JSON.stringify(parseReportContent(report.content).texts), "Berichtstexte bleiben unverändert (nur Vorschlag)"); + ok((await prisma.activityNote.findUniqueOrThrow({ where: { id: note.id } })).text === noteText, "Originalnotiz bleibt unverändert"); + const gen = await prisma.aiGeneration.findUniqueOrThrow({ where: { id: res.generationId } }); + ok(gen.kind === "report_draft" && gen.entityType === "report" && gen.entityId === report.id && gen.model === "fake-lotse-1" && gen.inputTokens === 1200 && gen.createdById === tech.id, "AiGeneration protokolliert (Art, Modell, Tokens, Nutzer)"); + ok(canon(gen.input) === canon(fake.draftCalls[0]), "AiGeneration.input = exakt der an den Provider gesendete (minimierte) Input"); + const draftAudit = await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "report", entityId: report.id }, orderBy: { createdAt: "desc" } }); + ok((draftAudit?.after as { aiGenerationId?: string } | null)?.aiGenerationId === res.generationId, "Audit-Eintrag zum Entwurf"); + + // ---------- 5. Vollständigkeitsprüfung ---------- + const items = await checkCompleteness(techCtx, wo.id); + const codes = items.map((i) => i.code); + for (const c of ["photo_requirement", "checklist_item", "material_reason", "material_unconfirmed", "no_work_time", "signature_missing"] as const) ok(codes.includes(c), `Regel ${c} erkannt`); + ok(!codes.includes("no_description"), "Tätigkeitsbeschreibung vorhanden → keine Meldung"); + const hint = items.find((i) => i.code === "lotse_hint"); + ok(hint?.text === "Grund für die Mindermenge Heizkörper fehlt" && hint.source === "lotse" && hint.href === `/m/orders/${wo.id}/report?type=daily`, "missingInformation → Liste mit Sprungziel"); + ok(items.every((i) => i.href.startsWith(`/m/orders/${wo.id}/`)), "Jeder Punkt hat einen Deep-Link in die mobile Unterseite"); + ok(items.find((i) => i.code === "material_reason")?.label === "Heizkörper 600x1000" && items.find((i) => i.code === "photo_requirement")?.href.endsWith("/photos") === true, "Klartext-Label + passende Unterseite"); + const rulesOnly = codes.indexOf("lotse_hint") === codes.length - 1; + ok(rulesOnly, "Deterministische Regeln vor KI-Hinweisen"); + await expectErr(() => checkCompleteness(outsiderCtx, wo.id), "not_found", "Vollständigkeit: Monteur ohne Zuweisung → not_found"); + await expectErr(() => checkCompleteness(ctxB, wo.id), "not_found", "Vollständigkeit: Mandant B → not_found"); + await expectErr(() => checkCompleteness(techNoLotse, wo.id), "forbidden", "Vollständigkeit ohne lotse:use → forbidden"); + + // ---------- 6. Vorschläge übernehmen / verwerfen ---------- + await expectErr(() => decideLotseSuggestion(ctxB, { reportId: report.id, field: "workPerformed", decision: "accept" }), "not_found", "Mandant B kann Vorschlag nicht übernehmen"); + await expectErr(() => decideLotseSuggestion(outsiderCtx, { reportId: report.id, field: "workPerformed", decision: "accept" }), "not_found", "Monteur ohne Zuweisung kann Vorschlag nicht übernehmen"); + await decideLotseSuggestion(techCtx, { reportId: report.id, field: "workPerformed", decision: "accept", text: "- Alten Heizkörper im Bad demontiert" }); + await decideLotseSuggestion(techCtx, { reportId: report.id, field: "hints", decision: "discard" }); + const decided = parseReportContent((await prisma.report.findUniqueOrThrow({ where: { id: report.id } })).content); + ok(decided.texts.workPerformed === "- Alten Heizkörper im Bad demontiert", "Übernehmen (bearbeitet) → Berichtstext gesetzt"); + ok(decided.texts.hints === content.texts.hints, "Verwerfen → Text unverändert"); + ok(decided.lotse?.suggestions.map((s) => s.state).join(",") === "accepted,discarded", "Vorschlagsstatus accepted/discarded"); + await expectErr(() => decideLotseSuggestion(techCtx, { reportId: report.id, field: "hints", decision: "accept" }), "conflict", "Bereits entschiedener Vorschlag → conflict", { reason: "no_suggestion" }); + + // ---------- 7. Freigabeprinzip ---------- + ok(applyLotseReview(techCtx, { aiDrafted: false }, decided, undefined) === decided, "Bericht ohne Lotse: keine Prüfbestätigung nötig"); + await expectErr(() => submitReport(techCtx, { reportId: report.id }), "invalid", "Submit ohne Prüfbestätigung → invalid", { field: "aiReviewed" }); + await expectErr(() => submitReport(techCtx, { reportId: report.id, aiReviewed: false }), "invalid", "Submit mit aiReviewed=false → invalid", { field: "aiReviewed" }); + ok((await prisma.report.findUniqueOrThrow({ where: { id: report.id } })).status === "draft", "Bericht bleibt Entwurf"); + await updateReportTexts(techCtx, { reportId: report.id, texts: { nextSteps: "Ventil montieren" } }); + ok(parseReportContent((await prisma.report.findUniqueOrThrow({ where: { id: report.id } })).content).lotse?.generationId === res.generationId, "Texte speichern behält den Lotse-Block"); + const submitted = await submitReport(techCtx, { reportId: report.id, aiReviewed: true }); + const sc = parseReportContent(submitted.content); + ok(submitted.status === "submitted" && Boolean(sc.lotse?.reviewedAt) && sc.lotse?.reviewedById === tech.id, "Submit mit Bestätigung → submitted + Prüfnachweis (reviewedAt/reviewedById) im Snapshot"); + await expectErr(() => draftReportWithLotse(techCtx, report.id, { provider: fake }), "blocked", "Eingereichter Bericht → kein neuer Entwurf", { reason: "not_editable" }); + await expectErr(() => decideLotseSuggestion(techCtx, { reportId: report.id, field: "workPerformed", decision: "discard" }), "blocked", "Eingereichter Bericht → keine Vorschlagsentscheidung"); + + // ---------- 8. Protokoll ---------- + await expectErr(() => listAiGenerations(techCtx), "forbidden", "Monteur sieht das KI-Protokoll nicht"); + const officeList = await listAiGenerations(officeCtx); + ok(officeList.total === 1 && !officeList.canSeeContent && !("input" in officeList.items[0]) && officeList.items[0].userName === "Max Monteur", "Backoffice: Liste ohne Inhalte (Zeit, Art, Modell, Tokens, Nutzer)"); + await expectErr(() => getAiGenerationContent(officeCtx, res.generationId), "forbidden", "Inhalte nur für Mandantenadministratoren"); + ok(canon((await getAiGenerationContent(adminCtx, res.generationId)).input) === canon(fake.draftCalls[0]), "Admin sieht gesendete Daten"); + const listB = await listAiGenerations(adminBCtx); + ok(listB.total === 0, "Mandant B sieht keine KI-Nutzung von A"); + await expectErr(() => getAiGenerationContent(adminBCtx, res.generationId), "not_found", "Mandant B kann Inhalte von A nicht lesen"); +} + +main() + .catch((err) => { + console.error(err); + failures++; + }) + .finally(async () => { + await cleanup().catch((e) => console.error("cleanup failed", e)); + await prisma.$disconnect(); + console.log(failures ? `\n✗ ${failures} Prüfung(en) fehlgeschlagen` : "\n✓ Alle Lotse-Entwurfsprüfungen grün"); + process.exit(failures ? 1 : 0); + }); diff --git a/scripts/test-lotse-live.ts b/scripts/test-lotse-live.ts new file mode 100644 index 0000000..09f662e --- /dev/null +++ b/scripts/test-lotse-live.ts @@ -0,0 +1,58 @@ +// L9 Lotse — optional live test of the Claude report draft and voice summary against a synthetic, +// already minimised input. Runs ONLY when ANTHROPIC_API_KEY is set (costs tokens); otherwise it skips +// with exit 0 so the default gate stays offline. +// +// Lauf: ANTHROPIC_API_KEY=… npx tsx scripts/test-lotse-live.ts + +import "dotenv/config"; +import { getLotseProvider } from "../src/server/ai/lotse/anthropic"; +import type { ReportDraftInput } from "../src/server/ai/providers"; + +async function main() { + if (!process.env.ANTHROPIC_API_KEY?.trim()) { + console.log("↷ übersprungen: kein ANTHROPIC_API_KEY gesetzt (Live-Berichtsentwurf gegen Claude)."); + process.exit(0); + } + const provider = getLotseProvider(); + if (!provider) { + console.log("↷ übersprungen: Lotse nicht konfiguriert."); + process.exit(0); + } + + let failures = 0; + const ok = (cond: boolean, msg: string) => { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failures++; + }; + + const input: ReportDraftInput = { + locale: "de", + addressForm: "neutral", + workOrder: { title: "Heizkörper tauschen", description: "Kundin erreichbar unter [Telefon].", scope: "Heizkörper im Bad ersetzen", orderType: "Reparatur" }, + notes: [ + { kind: "work_done", text: "M. M. hat den alten Heizkörper demontiert und den neuen montiert.", at: "2026-09-14T08:30:00+02:00" }, + { kind: "deviation (Sprachnotiz)", text: "Nur ein Heizkörper verbaut, zweiter war beschädigt.", at: "2026-09-14T10:00:00+02:00" }, + ], + checklist: [{ label: "Anlage entlüftet", checked: false, comment: null }], + materials: [{ name: "Heizkörper 600x1000", planned: "2", actual: "1", unit: "Stk", status: "partially_used", reason: null }], + photos: [{ phase: "after", comment: "Neuer Heizkörper montiert", requirement: null }], + time: [{ type: "work", minutes: 95, user: "M. M." }], + }; + + const started = Date.now(); + const out = await provider.draftReport(input); + console.log(` Modell ${out.meta.model}, Tokens ${out.meta.inputTokens}/${out.meta.outputTokens}, ${Date.now() - started} ms`); + ok(out.workPerformed.trim().length > 0, "workPerformed nicht leer"); + ok(Array.isArray(out.missingInformation), "missingInformation ist eine Liste"); + ok(!/\[Telefon\]/.test(`${out.workPerformed}${out.hints}`), "Platzhalter nicht in den Bericht übernommen"); + ok(!/\b(Sie|du)\b/.test(out.missingInformation.join(" ")), "Neutrale Anrede in missingInformation"); + + const summary = await provider.summarizeTranscript({ locale: "de", addressForm: "neutral", transcript: "Also, der alte Heizkörper ist raus, der neue hängt, entlüften mach ich morgen." }); + ok(summary.summary.trim().length > 0, "Zusammenfassung nicht leer"); + process.exit(failures ? 1 : 0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/test-lotse-transcription.ts b/scripts/test-lotse-transcription.ts new file mode 100644 index 0000000..45400a9 --- /dev/null +++ b/scripts/test-lotse-transcription.ts @@ -0,0 +1,257 @@ +// L9 Lotse – Transkription + Sprachnotiz-Funktionen. +// +// Deckt ab: Processor-Logik (Fake: done; Provider null: disabled; Fehler: failed; Lotse aus: disabled; +// idempotent), Anhängen an verknüpfte Notiz bzw. neue Notiz „aus Sprachnotiz“, AiGeneration, Mandantentrennung +// im Job, Transkript bearbeiten (Scope/Rechte), „Sprachnotiz zusammenfassen“ (Datenminimierung, Übernahme als +// Notiz, idempotent), OpenAI-kompatibler Provider gegen lokalen HTTP-Server (Multipart, Auth, Fehler, +// Größenlimit, Timeout), Factory ohne Key → null, Processor-Registrierung. +// +// Lauf: npx tsx scripts/test-lotse-transcription.ts + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { createServer, type IncomingMessage } from "node:http"; +import type { AddressInfo } from "node:net"; +import { prisma, dbForTenant } from "../src/server/db"; +import { ROLE_DEFS, type RoleKey } from "../src/server/rbac"; +import { ServiceError, type ServiceCtx } from "../src/server/services/context"; +import { FakeTranscriptionProvider } from "../src/server/ai/transcription/fake"; +import { getTranscriptionProvider, OpenAiCompatibleTranscriptionProvider } from "../src/server/ai/transcription/openai-compatible"; +import { FakeLotseProvider } from "../src/server/ai/lotse/fake"; +import { processTranscription, type TranscriptionDeps } from "../src/server/services/lotse/transcription"; +import { adoptVoiceSummary, summarizeVoiceNote, updateTranscript } from "../src/server/services/lotse/voice"; +import { PROCESSORS } from "../src/server/jobs/processors"; + +let failures = 0; +const ok = (cond: boolean, msg: string) => { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failures++; +}; + +async function expectErr(fn: () => Promise, code: ServiceError["code"], msg: string, reason?: string) { + try { + await fn(); + ok(false, `${msg} — kein Fehler`); + } catch (err) { + if (!(err instanceof ServiceError)) return ok(false, `${msg} — ${(err as Error).message}`); + const r = (err.details as { reason?: string } | undefined)?.reason; + ok(err.code === code && (!reason || r === reason), `${msg} (${err.code}${r ? `/${r}` : ""})`); + } +} + +async function expectThrow(fn: () => Promise, pattern: RegExp, msg: string) { + try { + await fn(); + ok(false, `${msg} — kein Fehler`); + } catch (err) { + ok(pattern.test((err as Error).message), `${msg} (${(err as Error).message})`); + } +} + +const SLUGS = ["zz-lotse-tr-a", "zz-lotse-tr-b"]; +const DOMAIN = "@zz-lotse-tr.test"; + +async function cleanup() { + const tenants = await prisma.tenant.findMany({ where: { slug: { in: SLUGS } }, select: { id: true } }); + const ids = tenants.map((t) => t.id); + if (ids.length) { + const w = { where: { tenantId: { in: ids } } }; + await prisma.aiGeneration.deleteMany(w); + await prisma.activityNote.deleteMany(w); + await prisma.voiceNote.deleteMany(w); + await prisma.document.deleteMany(w); + await prisma.workOrderAssignee.deleteMany(w); + await prisma.workOrder.deleteMany(w); + await prisma.customer.deleteMany(w); + await prisma.auditLog.deleteMany(w); + await prisma.tenantModule.deleteMany(w); + await prisma.tenantSettings.deleteMany(w); + await prisma.user.deleteMany(w); + await prisma.tenant.deleteMany({ where: { id: { in: ids } } }); + } + await prisma.identity.deleteMany({ where: { email: { endsWith: DOMAIN } } }); +} + +async function mkUser(tenantId: string, key: string, name: string) { + const identity = await prisma.identity.create({ data: { email: `${key}${DOMAIN}`, passwordHash: "x" } }); + return prisma.user.create({ data: { tenantId, identityId: identity.id, email: identity.email, name } }); +} + +const ctxOf = (tenantId: string, userId: string, role: RoleKey, drop: string[] = []): ServiceCtx => ({ + db: dbForTenant(tenantId), + tenantId, + userId, + permissions: new Set(ROLE_DEFS[role].permissions.filter((p) => !drop.includes(p))), +}); +const sysCtx = (tenantId: string, actorId = ""): ServiceCtx => ({ db: dbForTenant(tenantId), tenantId, userId: actorId, permissions: new Set() }); + +async function mkVoice(tenantId: string, workOrderId: string, userId: string) { + const doc = await prisma.document.create({ + data: { tenantId, workOrderId, category: "voice_note", fileName: "sprachnotiz.webm", storageKey: `${tenantId}/voice/${randomUUID()}.webm`, mimeType: "audio/webm", fileSize: 600, checksum: "0".repeat(64), lineageId: randomUUID(), visibility: "team", uploadedById: userId }, + }); + return prisma.voiceNote.create({ data: { tenantId, workOrderId, documentId: doc.id, durationSeconds: 12, transcriptionStatus: "pending", recordedById: userId, recordedAt: new Date() } }); +} + +const readBody = (req: IncomingMessage) => + new Promise((resolve) => { + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => resolve(Buffer.concat(chunks).toString("latin1"))); + }); + +async function providerTests() { + let last: { auth?: string; type?: string; body: string } = { body: "" }; + let mode: "ok" | "error" | "slow" = "ok"; + const server = createServer(async (req, res) => { + last = { auth: req.headers.authorization, type: req.headers["content-type"], body: await readBody(req) }; + if (mode === "error") { + res.writeHead(500, { "content-type": "application/json" }).end(JSON.stringify({ error: "secret transcript leak" })); + } else if (mode === "slow") { + setTimeout(() => res.writeHead(200, { "content-type": "application/json" }).end('{"text":"zu spät"}'), 800); + } else { + res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ text: " Heizkörper getauscht. " })); + } + }); + await new Promise((r) => server.listen(0, "127.0.0.1", r)); + const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1/audio/transcriptions`; + try { + const p = new OpenAiCompatibleTranscriptionProvider({ url, apiKey: "test-key", model: "whisper-test" }); + const audio = Buffer.concat([Buffer.from([0x1a, 0x45, 0xdf, 0xa3]), Buffer.alloc(256, 1)]); + const out = await p.transcribe({ bytes: audio, mimeType: "audio/webm;codecs=opus", language: "de" }); + ok(out.text === "Heizkörper getauscht." && out.meta.model === "whisper-test" && out.meta.provider === "openai-compatible", "OpenAI-kompatibel: Text + Modell"); + ok(last.auth === "Bearer test-key" && /^multipart\/form-data; boundary=/.test(last.type ?? ""), "Multipart-POST mit Bearer-Key"); + ok(/name="model"\r\n\r\nwhisper-test\r\n/.test(last.body) && /name="language"\r\n\r\nde\r\n/.test(last.body) && /name="file"; filename="voice-note\.webm"/.test(last.body), "Felder model, language=de, file (webm)"); + mode = "error"; + try { + await p.transcribe({ bytes: audio, mimeType: "audio/webm", language: "de" }); + ok(false, "HTTP 500 → Fehler"); + } catch (err) { + const msg = (err as Error).message; + ok(msg === "transcription API error 500", `HTTP 500 → Fehler ohne Antwortinhalt (${msg})`); + } + await expectThrow(() => new OpenAiCompatibleTranscriptionProvider({ url, apiKey: "k", maxBytes: 100 }).transcribe({ bytes: audio, mimeType: "audio/webm", language: "de" }), /too large/, "Größenlimit wird vor dem Senden geprüft"); + await expectThrow(() => p.transcribe({ bytes: audio, mimeType: "video/mp4", language: "de" }), /unsupported audio type/, "Nicht-Audio-Typ abgelehnt"); + mode = "slow"; + await expectThrow(() => new OpenAiCompatibleTranscriptionProvider({ url, apiKey: "k", timeoutMs: 150 }).transcribe({ bytes: audio, mimeType: "audio/mp4", language: "de" }), /timed out/, "Timeout greift"); + } finally { + server.closeAllConnections(); + await new Promise((r) => server.close(() => r())); + } + + const saved = { key: process.env.TRANSCRIPTION_API_KEY, provider: process.env.TRANSCRIPTION_PROVIDER }; + delete process.env.TRANSCRIPTION_API_KEY; + ok(getTranscriptionProvider() === null, "Factory ohne TRANSCRIPTION_API_KEY → null"); + process.env.TRANSCRIPTION_API_KEY = "x"; + process.env.TRANSCRIPTION_PROVIDER = "other"; + ok(getTranscriptionProvider() === null, "Factory mit fremdem TRANSCRIPTION_PROVIDER → null"); + delete process.env.TRANSCRIPTION_PROVIDER; + ok(getTranscriptionProvider()?.model === (process.env.TRANSCRIPTION_MODEL?.trim() || "whisper-1"), "Factory mit Key → Provider (Default-Modell whisper-1)"); + if (saved.key === undefined) delete process.env.TRANSCRIPTION_API_KEY; + else process.env.TRANSCRIPTION_API_KEY = saved.key; + if (saved.provider !== undefined) process.env.TRANSCRIPTION_PROVIDER = saved.provider; +} + +async function main() { + await cleanup(); + await providerTests(); + + ok(typeof (await PROCESSORS.transcription?.()) === "function", "Processor „transcription“ registriert"); + + const tA = await prisma.tenant.create({ data: { name: "Lotse-TR A", slug: SLUGS[0] } }); + const tB = await prisma.tenant.create({ data: { name: "Lotse-TR B", slug: SLUGS[1] } }); + const tech = await mkUser(tA.id, "tech", "Max Monteur"); + const outsider = await mkUser(tA.id, "outsider", "Otto Fremd"); + const techB = await mkUser(tB.id, "techb", "Tom Bader"); + const customer = await prisma.customer.create({ data: { tenantId: tA.id, companyName: "Kunde A GmbH", phone: "040 998877" } }); + const customerB = await prisma.customer.create({ data: { tenantId: tB.id, companyName: "Kunde B" } }); + const wo = await prisma.workOrder.create({ data: { tenantId: tA.id, number: "A-TR-1", customerId: customer.id, title: "Bad", status: "in_progress", assignees: { create: [{ tenantId: tA.id, userId: tech.id }] } } }); + const woB = await prisma.workOrder.create({ data: { tenantId: tB.id, number: "B-TR-1", customerId: customerB.id, title: "B", status: "in_progress", assignees: { create: [{ tenantId: tB.id, userId: techB.id }] } } }); + + const techCtx = ctxOf(tA.id, tech.id, "technician"); + const techNoField = ctxOf(tA.id, tech.id, "technician", ["field:execute"]); + const outsiderCtx = ctxOf(tA.id, outsider.id, "technician"); + const ctxB = ctxOf(tB.id, techB.id, "technician"); + const loadBytes: TranscriptionDeps["loadBytes"] = async () => ({ bytes: Buffer.alloc(600, 1), mimeType: "audio/webm;codecs=opus" }); + + // ---------- Processor ---------- + const v1 = await mkVoice(tA.id, wo.id, tech.id); + const fake = new FakeTranscriptionProvider({ text: "Heizkörper im Bad getauscht." }); + ok((await processTranscription(sysCtx(tA.id, tech.id), v1.id, { provider: fake, loadBytes })) === "done", "Fake-Provider → done"); + const v1db = await prisma.voiceNote.findUniqueOrThrow({ where: { id: v1.id }, include: { activityNote: true } }); + ok(v1db.transcript === "Heizkörper im Bad getauscht." && v1db.transcriptionModel === "fake-whisper-1", "Transkript + transcriptionModel gespeichert"); + ok(fake.calls[0]?.language === "de" && fake.calls[0].mimeType.startsWith("audio/webm"), "Sprache de + MIME an Provider"); + ok(v1db.activityNote?.kind === "general" && v1db.activityNote.text === "Heizkörper im Bad getauscht." && v1db.activityNote.authorId === tech.id, "Neue Notiz „aus Sprachnotiz“ (kind general, voiceNoteId)"); + const g1 = await prisma.aiGeneration.findFirst({ where: { tenantId: tA.id, kind: "transcription", entityId: v1.id } }); + ok(g1?.model === "fake-whisper-1" && g1.entityType === "voice_note" && !JSON.stringify(g1.output).includes("Heizkörper"), "AiGeneration transcription (ohne Transkript-Inhalt im Protokoll)"); + ok((await processTranscription(sysCtx(tA.id), v1.id, { provider: fake, loadBytes })) === "done" && fake.calls.length === 1 && (await prisma.activityNote.count({ where: { voiceNoteId: v1.id } })) === 1, "Idempotent: zweiter Lauf ruft Provider nicht erneut auf"); + + const v2 = await mkVoice(tA.id, wo.id, tech.id); + const linked = await prisma.activityNote.create({ data: { tenantId: tA.id, workOrderId: wo.id, authorId: tech.id, kind: "problem", text: "Ventil klemmt.", voiceNoteId: v2.id } }); + await processTranscription(sysCtx(tA.id), v2.id, { provider: new FakeTranscriptionProvider({ text: "Neues Ventil bestellt." }), loadBytes }); + ok((await prisma.activityNote.findUniqueOrThrow({ where: { id: linked.id } })).text === "Ventil klemmt.\n\nNeues Ventil bestellt.", "Transkript an verknüpfte Notiz angehängt"); + + const v3 = await mkVoice(tA.id, wo.id, tech.id); + ok((await processTranscription(sysCtx(tA.id), v3.id, { provider: null, loadBytes })) === "disabled", "Provider null → disabled"); + const v4 = await mkVoice(tA.id, wo.id, tech.id); + ok((await processTranscription(sysCtx(tA.id), v4.id, { provider: new FakeTranscriptionProvider({ fail: new Error("API 503") }), loadBytes })) === "failed", "Provider-Fehler → failed"); + ok((await prisma.voiceNote.findUniqueOrThrow({ where: { id: v4.id } })).transcript === null && (await prisma.aiGeneration.count({ where: { entityId: v4.id } })) === 0, "failed: kein Transkript, keine AiGeneration"); + const v5 = await mkVoice(tA.id, wo.id, tech.id); + ok((await processTranscription(sysCtx(tA.id), v5.id, { provider: fake, loadBytes: async () => { throw new Error("storage down"); } })) === "failed", "Speicherfehler → failed"); + + await prisma.tenantModule.create({ data: { tenantId: tA.id, moduleKey: "lotse", enabled: false } }); + const v6 = await mkVoice(tA.id, wo.id, tech.id); + ok((await processTranscription(sysCtx(tA.id), v6.id, { provider: fake, loadBytes })) === "disabled" && fake.calls.length === 1, "Lotse für Mandant aus → disabled, keine Übertragung"); + await prisma.tenantModule.deleteMany({ where: { tenantId: tA.id } }); + + const v7 = await mkVoice(tA.id, wo.id, tech.id); + ok((await processTranscription(sysCtx(tB.id), v7.id, { provider: fake, loadBytes })) === null, "Job mit Mandant B findet Sprachnotiz von A nicht"); + ok((await prisma.voiceNote.findUniqueOrThrow({ where: { id: v7.id } })).transcriptionStatus === "pending" && fake.calls.length === 1, "Sprachnotiz von A unverändert"); + const vB = await mkVoice(tB.id, woB.id, techB.id); + + // ---------- Transkript bearbeiten ---------- + await updateTranscript(techCtx, { voiceNoteId: v1.id, transcript: "Max Monteur hat den Heizkörper getauscht, Rückruf unter 0171 1234567." }); + const v1e = await prisma.voiceNote.findUniqueOrThrow({ where: { id: v1.id }, include: { activityNote: true } }); + ok(v1e.transcript?.startsWith("Max Monteur hat") === true && v1e.activityNote?.text === v1e.transcript, "Transkript korrigiert, verknüpfte Notiz synchron"); + await updateTranscript(techCtx, { voiceNoteId: v2.id, transcript: "Neues Thermostatventil bestellt." }); + ok((await prisma.activityNote.findUniqueOrThrow({ where: { id: linked.id } })).text === "Ventil klemmt.\n\nNeues Thermostatventil bestellt.", "Angehängtes Transkript in der Notiz ersetzt, eigener Text bleibt"); + await updateTranscript(techCtx, { voiceNoteId: v3.id, transcript: "Manuell: Rohr isoliert." }); + ok((await prisma.activityNote.findFirst({ where: { voiceNoteId: v3.id } }))?.text === "Manuell: Rohr isoliert.", "Manuelles Transkript (disabled) → Notiz „aus Sprachnotiz“"); + await expectErr(() => updateTranscript(techCtx, { voiceNoteId: v7.id, transcript: "x" }), "conflict", "Laufende Transkription nicht bearbeitbar", "pending"); + await expectErr(() => updateTranscript(outsiderCtx, { voiceNoteId: v1.id, transcript: "x" }), "not_found", "Monteur ohne Zuweisung → not_found"); + await expectErr(() => updateTranscript(ctxB, { voiceNoteId: v1.id, transcript: "x" }), "not_found", "Mandant B → not_found"); + await expectErr(() => updateTranscript(techNoField, { voiceNoteId: v1.id, transcript: "x" }), "forbidden", "Ohne field:execute → forbidden"); + const audit = await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "voice_note", entityId: v1.id, actorId: tech.id }, orderBy: { createdAt: "desc" } }); + ok(Boolean(audit?.before) && Boolean(audit?.after), "Transkriptänderung im Audit (before/after)"); + + // ---------- Sprachnotiz zusammenfassen ---------- + const lotse = new FakeLotseProvider({ summary: "- Heizkörper getauscht" }); + const sum = await summarizeVoiceNote(techCtx, v1.id, { provider: lotse }); + const sentTranscript = lotse.summaryCalls[0]?.transcript ?? ""; + ok(!sentTranscript.includes("Max Monteur") && !sentTranscript.includes("0171 1234567") && sentTranscript.includes("[Telefon]") && sentTranscript.includes("M. M."), `Zusammenfassung: Transkript minimiert gesendet („${sentTranscript}“)`); + const g2 = await prisma.aiGeneration.findUniqueOrThrow({ where: { id: sum.generationId } }); + ok(g2.kind === "voice_summary" && g2.entityId === v1.id && sum.summary === "- Heizkörper getauscht", "AiGeneration voice_summary"); + const adopted = await adoptVoiceSummary(techCtx, sum.generationId); + const again = await adoptVoiceSummary(techCtx, sum.generationId); + const adoptedNote = await prisma.activityNote.findUniqueOrThrow({ where: { id: adopted.noteId } }); + ok(adoptedNote.text === "- Heizkörper getauscht" && adoptedNote.kind === "work_done" && again.noteId === adopted.noteId, "Als Notiz übernommen (idempotent)"); + ok((await prisma.voiceNote.findUniqueOrThrow({ where: { id: v1.id } })).transcript === v1e.transcript, "Transkript bleibt nach Übernahme unverändert"); + await expectErr(() => summarizeVoiceNote(outsiderCtx, v1.id, { provider: lotse }), "not_found", "Zusammenfassen: Monteur ohne Zuweisung → not_found"); + await expectErr(() => summarizeVoiceNote(ctxB, v1.id, { provider: lotse }), "not_found", "Zusammenfassen: Mandant B → not_found"); + await expectErr(() => adoptVoiceSummary(ctxB, sum.generationId), "not_found", "Übernahme: Mandant B → not_found"); + await expectErr(() => summarizeVoiceNote(techCtx, v4.id, { provider: lotse }), "invalid", "Ohne Transkript → Hinweis", "no_transcript"); + await expectErr(() => summarizeVoiceNote(techCtx, v1.id, { provider: null }), "invalid", "Ohne KI-Konfiguration → Hinweis", "not_configured"); + await expectErr(() => summarizeVoiceNote(ctxOf(tA.id, tech.id, "technician", ["lotse:use"]), v1.id, { provider: lotse }), "forbidden", "Ohne lotse:use → forbidden"); + await expectErr(() => summarizeVoiceNote(ctxB, vB.id, { provider: new FakeLotseProvider({ fail: new Error("x") }) }), "invalid", "Mandant B: eigene Notiz ohne Transkript → Hinweis (kein Fremdzugriff nötig)", "no_transcript"); +} + +main() + .catch((err) => { + console.error(err); + failures++; + }) + .finally(async () => { + await cleanup().catch((e) => console.error("cleanup failed", e)); + await prisma.$disconnect(); + console.log(failures ? `\n✗ ${failures} Prüfung(en) fehlgeschlagen` : "\n✓ Alle Transkriptionsprüfungen grün"); + process.exit(failures ? 1 : 0); + }); diff --git a/src/app/(app)/reports/[id]/page.tsx b/src/app/(app)/reports/[id]/page.tsx index 69f65f1..53268ce 100644 --- a/src/app/(app)/reports/[id]/page.tsx +++ b/src/app/(app)/reports/[id]/page.tsx @@ -8,6 +8,7 @@ import { RejectForm } from "@/components/reports/reject-form"; import { ReportView } from "@/components/reports/report-view"; import { ReviewActions } from "@/components/reports/review-actions"; import { ReportStatusBadge } from "@/components/reports/status-badge"; +import { LotseReportPanel } from "@/components/lotse/report-panel"; import { Button } from "@/components/ui/button"; import { ServiceError } from "@/server/services/context"; import { tenantTimeZone } from "@/server/services/reports/build-content"; @@ -83,6 +84,9 @@ export default async function ReportDetailPage({ params, searchParams }: { param
+
+ +
diff --git a/src/app/(app)/settings/lotse/page.tsx b/src/app/(app)/settings/lotse/page.tsx new file mode 100644 index 0000000..19f5d59 --- /dev/null +++ b/src/app/(app)/settings/lotse/page.tsx @@ -0,0 +1,124 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { ArrowLeft, CheckCircle2, ListChecks, MinusCircle, ShieldCheck, XCircle } from "lucide-react"; +import { LotseMark } from "@/components/lotse/lotse-mark"; +import { PageHead } from "@/components/mockup-ui"; +import { Button } from "@/components/ui/button"; +import { saveLotseSettings } from "@/server/actions/lotse-settings"; +import { can } from "@/server/services/context"; +import { getLotseSettings } from "@/server/services/lotse/settings"; +import { readCtx } from "@/server/services/reports/read-ctx"; + +/** Configured / not configured — text + icon, never colour alone. */ +function ProviderStatus({ ok, labels }: { ok: boolean; labels: { ok: string; off: string } }) { + return ( + + {ok ? : } + {ok ? labels.ok : labels.off} + + ); +} + +/** + * /settings/lotse (tenant:manage): Lotse on/off (module toggle `lotse`), address form, transparency on + * which data goes to which provider, link to the AI log. Deliberately not module-gated (re-enabling). + */ +export default async function LotseSettingsPage({ searchParams }: { searchParams: Promise<{ saved?: string; error?: string }> }) { + const ctx = await readCtx(); + if (!can(ctx, "tenant:manage")) redirect("/dashboard"); + const [sp, s, t] = await Promise.all([searchParams, getLotseSettings(ctx), getTranslations("lotse")]); + const statusLabels = { ok: t("settings.configured"), off: t("settings.notConfigured") }; + + return ( +
+ + {t("settings.back")} + + + + {sp.saved && ( +

+ {t("settings.saved")} +

+ )} + {sp.error && ( +

+ {t("settings.error")} +

+ )} + +
+
+

+ · {t("settings.use")} +

+ +
+ {t("settings.addressForm")} +

{t("settings.addressFormHint")}

+
+ {(["neutral", "sie", "du"] as const).map((v) => ( + + ))} +
+
+ +
+ +
+

{t("settings.dataTitle")}

+
+
+
+ {t("settings.draftProvider")} +
+
{t("settings.providerLine", { provider: s.draft.provider, model: s.draft.model })}
+
+
+
+ {t("settings.transcriptionProvider")} +
+
{t("settings.transcriptionLine", { host: s.transcription.host ?? "—", model: s.transcription.model })}
+
+
+
+

{t("settings.sentTitle")}

+
    + {(["order", "notes", "work", "audio"] as const).map((k) => ( +
  • {t(`settings.sent.${k}`)}
  • + ))} +
+
+
+

{t("settings.notSentTitle")}

+
    + {(["contact", "names", "customer"] as const).map((k) => ( +
  • {t(`settings.notSent.${k}`)}
  • + ))} +
+
+

+ + {t("settings.principle")} +

+ + + {t("settings.protocolLink")} + +
+
+
+ ); +} diff --git a/src/app/(app)/settings/lotse/protocol/page.tsx b/src/app/(app)/settings/lotse/protocol/page.tsx new file mode 100644 index 0000000..896f9fe --- /dev/null +++ b/src/app/(app)/settings/lotse/protocol/page.tsx @@ -0,0 +1,114 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { getFormatter, getTranslations } from "next-intl/server"; +import { ArrowLeft } from "lucide-react"; +import { Modal } from "@/components/modal"; +import { PageHead } from "@/components/mockup-ui"; +import { ServiceError } from "@/server/services/context"; +import { canReadProtocol, getAiGenerationContent, listAiGenerations } from "@/server/services/lotse/protocol"; +import { readCtx } from "@/server/services/reports/read-ctx"; + +/** /settings/lotse/protocol — AiGeneration log (tenant:manage or audit:read; contents only tenant:manage). */ +export default async function LotseProtocolPage({ searchParams }: { searchParams: Promise<{ page?: string; detail?: string }> }) { + const ctx = await readCtx(); + if (!canReadProtocol(ctx)) redirect("/dashboard"); + const sp = await searchParams; + const page = Math.max(1, Number(sp.page) || 1); + const [list, t, format] = await Promise.all([listAiGenerations(ctx, { page }), getTranslations("lotse"), getFormatter()]); + let detail: Awaited> | null = null; + if (sp.detail && list.canSeeContent) { + try { + detail = await getAiGenerationContent(ctx, sp.detail); + } catch (err) { + if (!(err instanceof ServiceError)) throw err; + } + } + const pages = Math.max(1, Math.ceil(list.total / list.pageSize)); + const base = `/settings/lotse/protocol?page=${page}`; + const kind = (k: string) => (t.has(`protocol.kinds.${k}`) ? t(`protocol.kinds.${k}`) : k); + const th = "px-3 py-2 text-left text-[11.5px] font-semibold text-muted-foreground"; + const td = "px-3 py-2 align-top"; + + return ( +
+ + {t("protocol.back")} + + + {!list.canSeeContent &&

{t("protocol.contentHint")}

} + +
+ {list.items.length === 0 ? ( +

{t("protocol.empty")}

+ ) : ( + + + + + + + + + {list.canSeeContent && } + + + + {list.items.map((r) => ( + + + + + + + {list.canSeeContent && ( + + )} + + ))} + +
{t("protocol.time")}{t("protocol.kind")}{t("protocol.model")}{t("protocol.tokens")}{t("protocol.user")}{t("protocol.content")}
{format.dateTime(r.createdAt, { dateStyle: "medium", timeStyle: "short" })}{kind(r.kind)} + {r.model} + {r.provider} + + {r.inputTokens ?? "—"} / {r.outputTokens ?? "—"} + {r.userName ?? t("protocol.system")} + + {t("protocol.show")} + +
+ )} +
+ + {pages > 1 && ( + + )} + + {detail && ( + +
+
+

{t("protocol.input")}

+
{JSON.stringify(detail.input, null, 2)}
+
+
+

{t("protocol.output")}

+
{JSON.stringify(detail.output, null, 2)}
+
+
+
+ )} +
+ ); +} diff --git a/src/app/(field)/m/(core)/orders/[id]/notes/page.tsx b/src/app/(field)/m/(core)/orders/[id]/notes/page.tsx index 37f1c87..7436ff8 100644 --- a/src/app/(field)/m/(core)/orders/[id]/notes/page.tsx +++ b/src/app/(field)/m/(core)/orders/[id]/notes/page.tsx @@ -5,12 +5,14 @@ import { SubPageHeader } from "@/components/field/sub-page-header"; import { VoiceRecorder } from "@/components/field/voice-recorder"; import { card } from "@/components/field/ui"; import { loadOrder } from "../load"; +import { LotseVoiceNote } from "@/components/lotse/voice-note-panel"; /** `/m/orders/[id]/notes` — activity notes + voice notes (Spec §12.3, §15.1). */ export default async function NotesPage({ params }: { params: Promise<{ id: string }> }) { const { order } = await loadOrder(params); const t = await getTranslations("field"); const locale = await getLocale(); + const tl = await getTranslations("lotse"); return (
@@ -33,8 +35,7 @@ export default async function NotesPage({ params }: { params: Promise<{ id: stri {v.durationSeconds ? ` · ${fmtDuration(v.durationSeconds)}` : ""}