Phase 8: Lokale KI

Wiki mit semantischer Suche ueber pgvector, Diktat ueber faster-whisper,
Berichtsassistent und Belegerfassung — alles gegen selbst betriebene
Modelle. Dazu zwei Protokollansichten: was die Maschine getan hat und was
Menschen getan haben.

Nichts, was ein Modell erzeugt, gelangt ungeprueft in einen Nachweis. Jede
Ausgabe ist zuerst ein eigener Datensatz, gekennzeichnet, mit zwei gleich
grossen Knoepfen davor. Die Datenbank haelt gegen: Eingabe, Ausgabe und
Modell sind unveraenderlich, geloescht wird nicht, entschieden wird einmal.

Kein Rueckfall auf einen Anbieter: fehlen die Container, fehlt die Funktion.
Dafuer bleibt alles andere ohne Modell vollstaendig benutzbar — geprueft von
vierzehn E2E-Tests, die ohne laufendes Modell durchlaufen.

Postgres bringt jetzt PostGIS und pgvector mit (eigenes Image), Ollama und
faster-whisper stehen im Compose-Profil "ki".

73 neue Unit-Tests, 14 neue E2E-Tests, Version 0.9.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-04 11:06:03 +02:00
co-authored by Claude Opus 5
parent 12243f177d
commit f7ccd9a908
134 changed files with 8624 additions and 72 deletions
+220 -10
View File
@@ -1,10 +1,9 @@
# Anleitung
Bedienung von FLORIAN, Stand Phase 7 (Version 0.8.0). Die Bilder zeigen die
Bedienung von FLORIAN, Stand Phase 8 (Version 0.9.0). Die Bilder zeigen die
Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt.
> Die lokale KI und die Kassenverwaltung kommen in den folgenden Phasen dazu.
> Siehe [ROADMAP.md](ROADMAP.md).
> Die Kassenverwaltung kommt in Phase 9 dazu. Siehe [ROADMAP.md](ROADMAP.md).
---
@@ -1502,7 +1501,210 @@ wenn sein Code technisch noch stimmt.
---
## 43. Rollen und was sie dürfen
## 43. Das Wiki der Wehr
![Wissen](screenshots/41-wissen-light.png)
Unter **Wissen** steht, was die Wehr nachschlagen soll: Dienstvorschriften,
Merkblätter, Ausbildungsunterlagen. Also das, was heute als PDF im Ordner steht
und im Einsatz niemand findet.
Lesen darf jeder. Eine Wehr, in der nur die Führung nachschlagen kann, hat kein
Wiki, sondern einen Aktenschrank.
### Zwei Suchen, und beide werden gebraucht
| Suche | Findet | Braucht ein Sprachmodell |
|---|---|---|
| **Wortsuche** (das Feld) | was dasteht — „FwDV 7" findet die Seite mit genau dieser Zeichenfolge | nein |
| **Frage** (der Kasten oben) | was gemeint ist — „wie lange hält ein Schlauch" findet den Abschnitt über Ablegereife | ja |
Die Wortsuche zeigt nicht nur den Seitennamen, sondern die **Textstelle**. So
sieht man beim Suchen, ob es die richtige Stelle ist, ohne die Seite zu öffnen.
### Eine Seite schreiben
**Seite anlegen**, dann Titel, Bereich und Inhalt. Der Inhalt ist Markdown:
```
## Eine Überschrift
Ein Absatz.
- Ein Aufzählungspunkt
- Noch einer
| Spalte | Spalte |
|---|---|
| Wert | Wert |
```
Die **Überschriften sind wichtiger, als sie aussehen**: an ihnen wird die Seite
für die Suche zerlegt, und sie stehen später als Fundstelle unter einer
Antwort. Eine Seite ohne Überschriften ist ein einziger großer Block und liefert
entsprechend grobe Fundstellen.
Unter dem Textfeld steht beim Bearbeiten, in wie viele Abschnitte zerlegt wird.
### Fundstelle angeben
Das Feld **Fundstelle** ist freiwillig und trotzdem das wichtigste: Es steht
unter jeder Antwort, die aus dieser Seite gebaut wird.
> „Zusammenfassung nach FwDV 7, Ausgabe 2002" sagt einem Leser, woran er ist.
> Eine Antwort ohne Herkunft ist eine Behauptung.
Abschreiben sollten Sie eine Dienstvorschrift übrigens nicht — die sind
urheberrechtlich geschützt. Was hier hineingehört, ist die **eigene
Zusammenfassung** mit Verweis auf die Fundstelle, so wie ein Merkblatt der Wehr.
### Entwurf oder veröffentlicht
Ein **Entwurf** wird nicht durchsucht und nicht für Antworten herangezogen. Das
ist der Zustand für eine Seite, an der noch gearbeitet wird — sie steht dann nur
für die, die bearbeiten dürfen, in der Liste.
### Jede Änderung wird aufgehoben
Beim Speichern wird die vorherige Fassung vollständig weggeschrieben. Über
**Verlauf** lässt sich jede frühere Fassung im Wortlaut ansehen.
Das ist keine Bequemlichkeit: eine Dienstvorschrift, die sich unbemerkt ändert,
ist schlimmer als keine. Deshalb lässt sich eine Fassung auch nicht löschen.
---
## 44. Fragen stellen
> **Beantwortet wird ausschließlich aus den hinterlegten Seiten.** Steht die
> Antwort dort nicht, sagt FLORIAN genau das — statt etwas Plausibles zu
> erfinden.
Im Kasten oben auf der Wissensseite eine Frage eintippen und **Fragen** drücken.
Nach ein paar Sekunden steht die Antwort da — in einem gestrichelten Rahmen mit
dem Vermerk **Maschinell erzeugt**.
Darunter stehen die **Fundstellen**, aus denen die Antwort gebaut wurde. Das
sind Verweise: ein Klick führt auf die Seite. Lesen Sie sie, bevor Sie sich auf
die Antwort verlassen — sie sind das Belegte, die Antwort ist die
Zusammenfassung.
Findet FLORIAN keine passende Stelle, wird das Modell **gar nicht erst gefragt**.
Dann steht dort, dass es in den Unterlagen nicht steht. Das ist die richtige
Antwort und keine Fehlermeldung.
### Wenn kein Modell eingerichtet ist
Dann steht statt des Kastens ein Hinweis. Die Wortsuche funktioniert weiter,
und die ist für „FwDV 7" oder „Ablegereife" ohnehin die bessere.
### Der Hinweis „Suchstand ist älter als der Text"
Steht auf einer Seite, die geändert, aber noch nicht neu eingebettet wurde. Bis
das nachgeholt ist, findet die Frage-Funktion noch die vorherige Fassung —
gelesen wird auf der Seite natürlich die aktuelle.
Der Hintergrundprozess holt das von selbst nach, spätestens im nächtlichen Lauf.
Wer nicht warten will, drückt **Jetzt nachziehen**.
---
## 45. Diktieren statt tippen
Auf der Einsatzseite unter **Bericht** → **Beim Schreiben helfen lassen**.
Der Weg, den es in jeder Wehr schon gibt: jemand spricht auf der Rückfahrt in
sein Telefon, und daraus wird abends ein Bericht. Neu ist nur, dass abends schon
ein Text dasteht.
1. **Diktat aufnehmen** drücken. Der Browser fragt nach dem Mikrofon.
2. Sprechen. Der Zähler läuft mit.
3. **Fertig** drücken. Die Aufnahme wird übertragen und erkannt — bei einer
langen Aufnahme dauert das ein bis zwei Minuten. Die Seite kann offen
bleiben, der Text erscheint von selbst.
4. Text lesen, dann **In den Bericht übernehmen** oder **Verwerfen**.
> **Bitte gegenlesen.** Fachbegriffe, Ortsnamen und Fahrzeugbezeichnungen
> erkennt kein Modell zuverlässig. Die Aufnahme bleibt abgelegt, bis Sie
> entschieden haben — wer dem Text nicht traut, hört nach.
Das Mikrofon fragt der Browser nur über **HTTPS** oder auf `localhost` ab.
Bleibt der Knopf ohne Wirkung, liegt es fast immer daran.
### Aus Stichpunkten einen Entwurf
Im selben Kasten. Stichpunkte eintippen — oder das Diktat dorthin übernehmen —
und **Entwurf erstellen** drücken.
```
Angriffstrupp unter PA ins EG
Brand in der Küche, Fettbrand
Löschdecke, kein Wasser
Belüftung mit Überdrucklüfter
Übergabe an Polizei
```
Daraus wird ein Fließtext für die Akte. Was das Modell **nicht** bekommt:
Zeitstempel, Einsatznummer und Fahrzeuge. Die stehen schon als Felder im Bericht
und stünden sonst zweimal da — beim zweiten Mal womöglich falsch.
Der Entwurf landet **nicht** im Berichtsfeld. Er steht daneben, gekennzeichnet,
mit zwei gleich großen Knöpfen. Erst **Übernehmen** schreibt ihn ins Feld — und
auch dann ist er nur ein Textvorschlag im Formular. Gespeichert wird er, wenn
Sie **Bericht speichern** drücken.
> Verantwortlich ist, wer freigibt. Ein Einsatzbericht ist ein Nachweis, und im
> Zweifel liest ihn ein Gericht.
---
## 46. Belege erfassen
![Belege](screenshots/42-belege-light.png)
Unter **Belege**: **Beleg fotografieren** drücken. Auf dem Handy öffnet sich
direkt die Kamera.
Was das Modell erkennt, steht danach in **änderbaren Feldern**. Was es nicht
erkannt hat, bleibt leer und ist **gelb hinterlegt** — das ist eine
Aufforderung. Ein geratener Betrag wäre eine falsche Buchung, die niemand
bemerkt.
Prüfen Sie gegen das Foto, das links daneben steht. Dann **Gegenzeichnen**.
> Mit dem Gegenzeichnen übernehmen Sie, was **dort steht** — nicht, was die
> Maschine gelesen hat. Wenn Sie den Betrag berichtigen, ist der berichtigte
> Betrag der Ihre. Beides bleibt nachvollziehbar: das KI-Protokoll hält fest,
> was ursprünglich erkannt wurde.
Ohne Vision-Modell funktioniert alles bis auf das Auslesen: der Beleg wird
fotografiert und abgelegt, die Angaben tragen Sie von Hand ein.
Die Buchung daraus kommt mit der Kasse in Phase 9. Hier entsteht erst einmal ein
sauber erfasster Beleg mit Foto — und der ist schon mehr wert als ein
Schuhkarton.
### Das KI-Protokoll
![KI-Protokoll](screenshots/43-ki-protokoll-light.png)
Für Kommandant und Administrator: unter **KI-Protokoll** steht jede Befragung
eines Modells — was hineinging, was herauskam, wer entschieden hat.
Die Zeile **Offen** ist die wichtigste. Sie zählt die Ausgaben, die erzeugt und
nie angesehen wurden. Steht dort eine große Zahl, benutzt jemand den Assistenten
und liest nicht gegen.
Eingabe, Ausgabe und Modell lassen sich nachträglich nicht ändern, und löschen
kann man einen Vorgang gar nicht. Das ist der Zweck der Liste: sie soll belegen,
was war.
Daneben unter **Protokoll** steht dasselbe für Menschen: jede Änderung an jedem
Datensatz, seit dem ersten Tag.
---
## 47. Rollen und was sie dürfen
Neun Rollen, jede mit einem festen Satz Rechte. Die Zuordnung steht zentral in
`src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr
@@ -1514,10 +1716,10 @@ kann sie also nachträglich zuschneiden, ohne dass jemand Code ändert.
| **Kommandant** | alles innerhalb der eigenen Wehr, einschließlich Rollen vergeben und Protokoll einsehen |
| **Gerätewart** | Fahrzeuge und Geräte anlegen, ändern, aussondern; Prüfungen planen und durchführen |
| **Atemschutzgerätewart** | zusätzlich Atemschutzgeräte, Träger und die Überwachung nach FwDV 7 |
| **Kassier** | Buchungen und Kassenabschluss (Modul noch nicht geplant, siehe Roadmap) |
| **Kassier** | Buchungen und Kassenabschluss (Modul in Phase 9), Belege erfassen und gegenzeichnen |
| **Schriftführer** | Mitglieder pflegen, Einsätze und Berichte erfassen, den Terminkalender führen |
| **Ausbilder** | Übungen und Übungstermine planen, Teilnahme, Anwesenheit und Qualifikationen erfassen |
| **Mitglied** | eigene Daten, Listen lesen, Termine sehen und sich zurückmelden, eigene Benachrichtigungswege einrichten |
| **Mitglied** | eigene Daten, Listen lesen, Termine sehen und sich zurückmelden, eigene Benachrichtigungswege einrichten, im Wiki lesen |
| **Terminal** | nur anmelden und erfassen — nie verwalten |
Zwei Dinge sind bewusst so gebaut:
@@ -1536,7 +1738,7 @@ sind nur die Textvorlagen, und die hängen an einem eigenen Recht.
---
## 44. Anmeldung über authentik einrichten
## 48. Anmeldung über authentik einrichten
authentik läuft **getrennt** von FLORIAN und ist nicht Teil dieses Stacks.
@@ -1589,7 +1791,7 @@ ganze Wehr aus.
---
## 45. PIN und Ausweiskarte verwalten
## 49. PIN und Ausweiskarte verwalten
Wie sich am Terminal angemeldet wird, steht in Kapitel 24. Hier geht es darum,
was dahinter liegt.
@@ -1612,7 +1814,7 @@ Huber), PIN `471203`.
---
## 46. Was protokolliert wird
## 50. Was protokolliert wird
Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich.
@@ -1637,7 +1839,7 @@ jeweiligen Detailseite sichtbar.
---
## 47. Wenn etwas nicht geht
## 51. Wenn etwas nicht geht
| Beobachtung | Wahrscheinliche Ursache | Was hilft |
|---|---|---|
@@ -1677,6 +1879,14 @@ jeweiligen Detailseite sichtbar.
| Eine Vorlage lässt sich nicht speichern | Ein Platzhalter gehört nicht zu diesem Anlass | Die Meldung nennt ihn und listet die erlaubten auf |
| Ein Termin lässt sich nicht löschen | Es liegen schon Rückmeldungen vor | Absagen statt löschen — dann sehen die Angemeldeten den Grund |
| Die Zusage wurde zur Warteliste | Die Plätze sind belegt | Sagt jemand ab, rücken Sie von selbst nach und werden benachrichtigt |
| Der Kasten „Frage an die Unterlagen" fehlt | Kein Sprachmodell eingerichtet | `LLM_ENABLED=true` und `LLM_BASE_URL`; Container mit `docker compose --profile ki up -d`. Die Wortsuche geht trotzdem |
| Die Frage antwortet „steht nicht in den Unterlagen" | Entweder steht es wirklich nicht drin — oder die Seite ist noch nicht eingebettet | Auf der Seite steht dann „Suchstand ist älter als der Text". **Jetzt nachziehen** drücken |
| „Das Modell hat nach 120 Sekunden nicht geantwortet" | Ohne Grafikkarte ist das normal | Kleineres Modell (`qwen2.5:7b`) oder `LLM_TIMEOUT` hochsetzen |
| „Das Modell liefert 1024 Dimensionen, die Datenbank erwartet 768" | Ein anderes Einbettungsmodell eingetragen | Entweder `nomic-embed-text` benutzen oder die Spalte per Migration ändern — siehe README, Phase 8 |
| Der Diktat-Knopf bewirkt nichts | Der Browser fragt das Mikrofon nur über HTTPS oder auf localhost ab | Über die richtige Adresse aufrufen und die Freigabe erteilen |
| Das Diktat bleibt bei „Wird erkannt …" stehen | Der Hintergrundprozess läuft nicht, oder Whisper ist nicht erreichbar | `docker compose ps`, dann `docker compose --profile ki up -d whisper`. Die Aufnahme bleibt erhalten |
| Beim Beleg bleiben alle Felder leer | Kein Vision-Modell, oder das Foto ist unscharf | Angaben von Hand eintragen. Für die Erkennung `LLM_VISION_MODELL` setzen und das Modell holen |
| Die Migration bricht mit „type vector does not exist" ab | Das Postgres-Image bringt pgvector noch nicht mit | `docker compose build postgres`, dann `docker compose up -d postgres` und erneut migrieren |
Zustand aller Randsysteme auf einen Blick:
+24 -6
View File
@@ -38,7 +38,7 @@ Faktoren, die in die Schätzung eingehen:
| 5 | Atemschutz (FwDV 7) | 14–18 PT | 3,5–4,5 h | **2:40 h** |
| 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | **2:50 h** |
| 7 | Benachrichtigungen und Termine | 10–13 PT | 2,5–3,5 h | **3:10 h** |
| 8 | Lokale KI | 9–12 PT | 2,5–3 h | offen |
| 8 | Lokale KI | 9–12 PT | 2,5–3 h | **3:40 h** |
| 9 | Kassenverwaltung | 8–11 PT | 2–3 h | offen |
| | **Summe** | **123–164 PT** | **28–37 h** | |
@@ -368,7 +368,7 @@ Zeilen samt Maskierung und Zeilenumbruch nach 75 Oktetten.
## Phase 8 — Lokale KI
**Vergleichsaufwand: 9–12 PT**
**Vergleichsaufwand: 9–12 PT** · **Tatsächlich: 3:40 h Sitzungszeit**
| Baustein | PT |
|---|---:|
@@ -378,10 +378,28 @@ Zeilen samt Maskierung und Zeilenumbruch nach 75 Oktetten.
| Wiki mit pgvector, semantische Suche, RAG mit Quellenangabe | 3,5 |
| Belegerfassung über ein Vision-Modell, immer bestätigen | 2,0 |
Der Client aus Phase 0 (`lib/integrations/llm.ts`) ist bereits vorhanden. Die
Kennzeichnungspflicht — jede KI-Ausgabe ist als solche erkennbar und muss
bestätigt werden, bevor sie in ein Dokument mit Nachweischarakter gelangt —
kostet wenig Zeit, ist aber nicht verhandelbar.
Der Client aus Phase 0 (`lib/integrations/llm.ts`) war bereits vorhanden — er
konnte allerdings nur Text und musste um Einbettungen und Bilder erweitert
werden.
Die Kennzeichnungspflicht war mit „kostet wenig Zeit" zu günstig eingeschätzt.
Sie kostet wenig, wenn man sie als Abzeichen an einer Ansicht versteht. Als
Zusicherung — eigener Datensatz je Ausgabe, unveränderlich, genau eine
Entscheidung, Trigger und REVOKE dahinter, zwei Protokollansichten — ist sie
ein guter halber Personentag, der in der Aufstellung gar nicht vorkommt.
Zwei weitere Posten fehlen in der Schätzung:
**Das eigene Postgres-Image.** pgvector und PostGIS gibt es nicht zusammen; es
gibt kein offizielles Image mit beidem. Ein Dockerfile, ein Neubau, ein Neustart
des Dienstes — eine halbe Stunde, aber sie stand nirgends.
**Der Betrieb ohne Modell.** Der größte Einzelposten dieser Phase, und in der
Aufstellung steht davon keine Zeile. Eine Wehr setzt FLORIAN auf, lange bevor
sie über eine Grafikkarte nachdenkt — also muss jede Ansicht ohne Modell
vollständig benutzbar bleiben und ehrlich sagen, was fehlt. Das sind vier
verschiedene Hinweise an vier Stellen, eine zweite Suche, die immer
funktioniert, und vierzehn E2E-Tests, die genau diesen Zustand absichern.
## Phase 9 — Kassenverwaltung
+114 -1
View File
@@ -19,7 +19,120 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
| Phase 5 — Atemschutz nach FwDV 7 | 2:40 h | 1 | 03.09.2026 |
| Phase 6 — Kartenmodule | 2:50 h | 1 | 04.09.2026 |
| Phase 7 — Benachrichtigungen und Termine | 3:10 h | 1 | 04.09.2026 |
| **Summe** | **19:20 h** | **9** | |
| Phase 8 — Lokale KI | 3:40 h | 1 | 04.09.2026 |
| **Summe** | **23:00 h** | **10** | |
---
## 2026-09-04 · Phase 8 — Lokale KI
**07:30 – 11:10 · 3:40 h · Version 0.9.0**
### Was entstanden ist
Ein Wiki, das man befragen kann, ein Diktat, ein Berichtsassistent, eine
Belegerfassung — und zwei Protokolle, die belegen, was davon eine Maschine
getan hat.
| Bereich | Dateien |
|---|---|
| Rechnen | `lib/ki/zerlegen.ts`, `beleg-lesen.ts`, `anweisungen.ts`, `wiki.ts`, `vorgang.ts` |
| Kanäle | `lib/integrations/llm.ts` (um Einbettungen und Bilder erweitert), `whisper.ts` |
| Server | `server/routers/ki/wiki.ts`, `assistent.ts` |
| Oberfläche | `app/(app)/wiki/`, `belege/`, `ki-protokoll/`, `protokoll/`; `components/ki/` |
| Hintergrund | `worker/ki.ts`, zwei neue Auftragsarten |
| Betrieb | `docker/postgres/Dockerfile` (PostGIS **und** pgvector), Ollama und faster-whisper im Profil `ki` |
| Tests | 73 Unit, 14 E2E je Browser |
### Entscheidungen
**Nichts Maschinelles gelangt ungeprüft in einen Nachweis.** Das ist die Regel
aus Phase-8.md, und sie bestimmt den ganzen Aufbau: jede KI-Ausgabe ist zuerst
ein eigener Datensatz und nicht ein Feld im Zieldokument. Es gibt keine
Funktion, die eine Ausgabe erzeugt und gleich übernimmt — wer das umgehen will,
muss Code schreiben, und das fällt in einer Durchsicht auf.
Die Datenbank hält gegen: Eingabe, Ausgabe, Modell und Art sind nach dem
Anlegen unveränderlich, gelöscht wird nicht, und über einen Vorschlag wird
genau einmal entschieden. Eine Absicht im Code ist eine Absicht; ein Trigger
ist eine Zusicherung.
**Kein Rückfall auf einen Anbieter.** Fehlt der eigene Endpunkt, fehlt die
Funktion. Ein Einsatzbericht spricht über Menschen in ihrer schlechtesten
Stunde — solche Texte gehören nirgendwo anders hin als ins Gerätehaus. Und ein
Rückfall, der „nur im Notfall" greift, ist genau der Fall, in dem niemand
hinsieht.
**Ohne Modell bleibt alles andere benutzbar.** Das war der größte Einzelposten
der Phase und steht in keiner Aufwandsschätzung. Vier Hinweise an vier Stellen,
eine zweite Suche, die immer funktioniert, und vierzehn E2E-Tests, die genau
diesen Zustand absichern — der Testlauf hat keinen Ollama daneben, und das ist
Absicht.
**Drei Modelle statt einem.** Text, Einbettungen und Bilder sind einzeln
konfigurierbar, weil ein Rechner ohne Grafikkarte das Einbettungsmodell noch
flott schafft, das Textmodell zäh und das Vision-Modell praktisch nicht. Eine
Wehr kann so ihr Wiki durchsuchbar machen, ohne Berichte schreiben zu lassen.
**Zerlegt wird an den Überschriften, nicht nach Zeichenzahl.** Weil die
Fundstelle später unter der Antwort steht, und eine Fundstelle soll ein Kapitel
sein. Dazu kommt die Überschriftenkette in den Einbettungstext: ein Abschnitt
über „Rückzugsdruck" ohne das Wort „Atemschutz" darüber findet sich sonst
nicht, wenn jemand nach Atemschutz fragt.
**Markdown wird ohne HTML gerendert.** Der Text kommt von Anwendern. Jeder Weg
über `dangerouslySetInnerHTML` wäre ein Weg für ein `<script>` in einer
Dienstvorschrift; der Renderer baut React-Elemente, und ein spitzes
Klammernpaar bleibt ein spitzes Klammernpaar.
**HNSW statt IVFFlat.** IVFFlat muss auf einem gefüllten Bestand trainiert
werden und liefert auf einer frisch angelegten Tabelle schlechte Treffer. Ein
Wiki wächst Seite für Seite — es gibt nie den Moment, an dem man den Index
einmal aufbaut.
**Ein eigenes Postgres-Image.** pgvector und PostGIS gibt es nicht zusammen.
Die Alternative wäre gewesen, den Kosinusabstand in TypeScript zu rechnen: geht
bei zweihundert Wiki-Seiten, wird bei zweitausend zäh, und verlegt eine Aufgabe
in die Anwendung, für die die Datenbank einen Index hat.
**Die Audit-Log-Ansicht kam mit dazu.** Sie stand seit Phase 0 als ausgegrauter
Eintrag in der Navigation, markiert mit „ab Phase 8". Mit dem KI-Protokoll
daneben wäre es schief geworden: die Wehr könnte nachlesen, was die Maschine
getan hat, aber nicht, was Menschen getan haben. Der Router dafür existierte
seit Phase 0 — es fehlte nur die Seite.
### Wo es gehakt hat
| Problem | Ursache | Lösung |
|---|---|---|
| Ein kurzer Abschnitt wurde unter eine fremde Überschrift gehängt | Die Regel „zu kurz → an den vorherigen anhängen" kannte keine Kapitelgrenze. Unter einer Antwort hätte dann eine Fundstelle gestanden, in der der zitierte Satz gar nicht vorkommt | Zusammengezogen wird nur innerhalb desselben Überschriftenpfads. Gefunden hat es der Test, nicht das Ausprobieren |
| `prisma migrate dev` wollte die Datenbank zurücksetzen | Die zurückgerollte Migration aus Phase 7 stand noch in `_prisma_migrations` und galt als „applied but missing" | Die Zeile entfernt. Ein `migrate resolve --rolled-back` räumt sie nicht weg |
| Prisma wollte wieder die Volltextindizes abräumen | Bekannt seit Phase 1, achte Migration in Folge | 27 DropIndex-Blöcke entfernt, im Kopf der Migration vermerkt |
| Die abgeleiteten Typen der Oberfläche waren `{}` | `ReturnType<typeof useQuery>["data"]` funktioniert bei tRPC nicht | `inferRouterOutputs<AppRouter>` — wie schon in Phase 7 |
### Abweichung von der Vorgabe
**Das Wiki hat keine Bilder.** Phase-8.md nennt sie nicht, aber man erwartet
sie in einem Wiki. Sie fehlen bewusst: ein Bild in einer Seite wäre entweder
eine Datei in MinIO mit eigener Rechteprüfung beim Ausliefern, oder ein Verweis
auf einen fremden Server — und Letzteres verbietet die Grundregel, dass FLORIAN
nichts von außen nachlädt. Nachrüstbar, wenn eine Wehr es vermisst.
**Kein Streaming der Modellantwort.** Der Bericht erscheint am Stück, nicht Wort
für Wort. Das sähe moderner aus, ist hier aber falsch: der Text soll gelesen und
gegengezeichnet werden, nicht beim Entstehen bewundert. Und ein halb geschriebener
Entwurf, den jemand vorzeitig übernimmt, wäre genau die Lücke, die diese Phase
schließen soll.
### Nachweise
- `npx tsc --noEmit` — ohne Ausgabe
- `npm run test` — 495 Tests grün, davon 21 neue gegen eine echte Datenbank
- `npx playwright test tests/e2e/wissen.spec.ts` — 14 grün, ohne laufendes Modell
- `npm run build` — 56 Routen
- `npm run db:seed` — vier Wiki-Seiten mit eigenem Text
- `docker compose build postgres` — PostGIS 3.5 und pgvector 0.8.6 im selben Image
- `docker compose config` — Ollama und faster-whisper im Profil `ki` prüfen durch
---
+39 -11
View File
@@ -17,8 +17,8 @@ Phase 4 ████████████████████ fertig
Phase 5 ████████████████████ fertig 0.6.0
Phase 6 ████████████████████ fertig 0.7.0
Phase 7 ████████████████████ fertig 0.8.0
Phase 8 ░░░░░░░░░░░░░░░░░░░░ als Nächstes 0.9.0
Phase 9 ░░░░░░░░░░░░░░░░░░░░ offen 0.10.0
Phase 8 ████████████████████ fertig 0.9.0
Phase 9 ░░░░░░░░░░░░░░░░░░░░ als Nächstes 0.10.0
Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.0
```
@@ -194,19 +194,47 @@ selbst gehostet.
Wer seine Wehr trotzdem über WhatsApp erreichen will, hat den Weg schon: die
Gruppe, die es ohnehin gibt. Ein Link zum Termin hineinkopiert kostet nichts.
## Phase 8 — Lokale KI · als Nächstes
## Phase 8 — Lokale KI · fertig
Alles gegen Ollama, kein externer Anbieter, komplett abschaltbar.
Alles gegen selbst betriebene Modelle, kein externer Anbieter, komplett
abschaltbar.
- Diktat: Aufnahme im Browser → faster-whisper → Transkript im Berichtsfeld
- Berichtsassistent: aus Stichpunkten einen Entwurf, immer als Vorschlag
- Wiki mit pgvector: semantische Suche mit Quellenangabe
- Belegerfassung: Foto einer Quittung → Vorschlag für Betrag, Datum, Händler
- Wiki mit pgvector: Dienstvorschriften und Ausbildungsunterlagen als Markdown,
Wortsuche und semantische Suche nebeneinander, Antwort mit Fundstellenangabe
- Diktat: Aufnahme im Browser, faster-whisper, Transkript als Vorschlag
- Berichtsassistent: aus Stichpunkten ein Einsatzberichtsentwurf, immer mit
Übernehmen/Verwerfen
- Belegerfassung: Foto einer Quittung, Vorschlag für Betrag, Datum, Händler —
nicht Erkanntes bleibt leer statt geraten zu werden
- KI-Protokoll und Audit-Log-Ansicht nebeneinander
- Eigenes Postgres-Image mit PostGIS **und** pgvector
Jede KI-Ausgabe ist gekennzeichnet und muss bestätigt werden, bevor sie in ein
Dokument mit Nachweischarakter gelangt.
**Danach kann die Wehr:** ihr Wissen ablegen und befragen, Berichte diktieren
statt tippen und Belege fotografieren statt abzuschreiben — ohne dass eine
Zeile Text das Gerätehaus verlässt.
## Phase 9 — Kassenverwaltung
### Die Regel, die den Aufbau bestimmt
Jede KI-Ausgabe ist zuerst ein eigener Datensatz und nicht ein Feld im
Zieldokument. Sie ist gekennzeichnet, sie steht daneben statt mittendrin, und
sie braucht eine Entscheidung, bevor etwas mit ihr geschieht. Die Datenbank
hält gegen: Eingabe, Ausgabe und Modell sind unveränderlich, gelöscht wird
nicht, und über einen Vorschlag wird genau einmal entschieden.
Ohne das ließe sich in einem Jahr nicht mehr sagen, welcher Satz in einem
Einsatzbericht von einem Menschen stammt.
### Kein Rückfall auf einen Anbieter
Fehlen die Container, fehlt die Funktion — sie wird nicht durch einen fremden
Dienst ersetzt. Das ist die einzige Bauart, bei der der Satz „die Daten bleiben
im Haus" auch dann noch stimmt, wenn der eigene Server aus ist.
Dafür bleibt **alles andere ohne Modell vollständig benutzbar**: Wiki lesen und
schreiben, Wortsuche, Belege ablegen, beide Protokolle. Wo etwas fehlt, steht
ein Satz, der sagt was fehlt und wie es ohne geht.
## Phase 9 — Kassenverwaltung · als Nächstes
Am 03.09.2026 ergänzt. Die Rolle `KASSIER` war seit Phase 0 angelegt, ein Modul
dazu fehlte in der ursprünglichen Planung. Spezifikation: `Phase-9.md`.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 431 KiB

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 808 KiB

After

Width:  |  Height:  |  Size: 808 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 831 KiB

After

Width:  |  Height:  |  Size: 830 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

After

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 333 KiB

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 988 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 888 KiB

After

Width:  |  Height:  |  Size: 930 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 904 KiB

After

Width:  |  Height:  |  Size: 946 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 288 KiB

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 719 KiB

After

Width:  |  Height:  |  Size: 760 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 724 KiB

After

Width:  |  Height:  |  Size: 766 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 KiB

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 539 KiB

After

Width:  |  Height:  |  Size: 559 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 KiB

After

Width:  |  Height:  |  Size: 563 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 888 KiB

After

Width:  |  Height:  |  Size: 991 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 907 KiB

After

Width:  |  Height:  |  Size: 1012 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 KiB

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB