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
+38
View File
@@ -328,11 +328,49 @@ BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE=90
# -----------------------------------------------------------------------------
# LLM-Anbindung (optional; nur selbstgehostete Endpunkte, kein Cloud-Zwang)
# -----------------------------------------------------------------------------
# ES GIBT KEINEN RUECKFALL AUF EINEN ANBIETER. Laeuft der eigene Endpunkt
# nicht, fehlen die KI-Funktionen — sie werden nicht durch einen fremden
# Dienst ersetzt. Ein Einsatzbericht spricht ueber Menschen in ihrer
# schlechtesten Stunde; solche Texte gehoeren nirgendwo anders hin.
#
# Container starten: docker compose --profile ki up -d
# Modelle holen: docker compose exec ollama ollama pull <modell>
LLM_ENABLED=false
# OpenAI-kompatibler Endpunkt, z. B. eine lokale Ollama- oder vLLM-Instanz.
LLM_BASE_URL=http://ollama:11434/v1
LLM_API_KEY=
LLM_MODELL=qwen2.5:14b
# Zeitlimit fuer eine Antwort. Ein Bericht darf dauern, aber nicht ewig.
LLM_TIMEOUT=120
# Modell fuer Einbettungen (semantische Suche im Wiki).
#
# ACHTUNG: Die Spalte wikiabschnitt.einbettung ist auf 768 Dimensionen
# festgelegt — das liefert nomic-embed-text. Ein Modell mit anderer Laenge
# braucht eine Migration; wie, steht im README unter Phase 8.
LLM_EINBETTUNG_MODELL=nomic-embed-text
LLM_EINBETTUNG_DIMENSIONEN=768
# Modell, das Bilder lesen kann — fuer die Belegerfassung. Ohne dieses Modell
# werden Belege trotzdem abgelegt, die Angaben nur von Hand eingetragen.
# LLM_VISION_MODELL=llama3.2-vision
# -----------------------------------------------------------------------------
# Spracherkennung fuers Diktat (Phase 8, optional)
#
# Selbst betriebener faster-whisper im Compose-Profil "ki". Groesse ueber
# WHISPER_MODELL: "tiny" laeuft auf jedem Buerorechner, "base" ist der
# brauchbare Kompromiss, "small" erkennt Fachbegriffe merklich besser und
# will dafuer Zeit oder eine Grafikkarte.
# -----------------------------------------------------------------------------
WHISPER_ENABLED=false
WHISPER_URL=http://whisper:8000
WHISPER_MODELL=base
WHISPER_SPRACHE=de
# Zeitlimit fuer eine Aufnahme. Zehn Minuten Sprache brauchen Zeit.
WHISPER_TIMEOUT=300
# Hoechstlaenge einer Aufnahme in Sekunden.
DIKTAT_MAX_SEKUNDEN=600
# -----------------------------------------------------------------------------
# Einsätze und Übungen (Phase 3)
+107 -1
View File
@@ -8,7 +8,113 @@ Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
## [Unveröffentlicht]
Nächster Schritt ist Phase 8 (Lokale KI).
Nächster Schritt ist Phase 9 (Kassenverwaltung).
---
## [0.9.0] — 2026-09-04
Phase 8: Lokale KI. Ein Wiki mit semantischer Suche, Diktat, Berichtsassistent
und Belegerfassung — alles gegen selbst betriebene Modelle.
**Nichts, was ein Modell erzeugt, gelangt ungeprüft in einen Nachweis.** 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 zwei gleich große Knöpfe, bevor etwas mit ihr geschieht.
**Es läuft alles im Haus.** Ollama und faster-whisper als Container daneben,
kein Anbieter, kein Schlüssel. Es gibt keinen Rückfall auf einen Cloud-Dienst:
fehlen die Container, fehlt die Funktion.
### Hinzugefügt
**Datenmodell**
- `KiVorgang` — jede Befragung eines Modells mit Eingabe, Ausgabe, Modell,
Dauer und der Entscheidung darüber. Auch Fehlschläge: ein Modell, das
dreimal am Tag in eine Zeitüberschreitung läuft, ist eine Information.
- `Wikiseite`, `Wikiabschnitt` (mit `vector(768)`) und `Wikiversion`.
- `Diktat` mit Aufnahme in MinIO, `Belegentwurf` mit allen Feldern optional —
ein Modell, das den Betrag nicht findet, soll nichts erfinden.
**Postgres mit pgvector**
- Eigenes Image (`docker/postgres/Dockerfile`), weil FLORIAN PostGIS **und**
pgvector braucht und es kein offizielles Image mit beidem gibt.
- HNSW-Index statt IVFFlat: IVFFlat muss auf gefülltem Bestand trainiert
werden, HNSW ist ab der ersten Zeile brauchbar. Ein Wiki wächst Seite für
Seite — es gibt nie den Moment, an dem man den Index einmal aufbaut.
**Wiki**
- Markdown, selbst gerendert **ohne HTML**: der Text kommt von Anwendern, und
jeder Weg über `dangerouslySetInnerHTML` wäre ein Weg für ein `<script>` in
einer Dienstvorschrift.
- Zwei Suchen: Wortsuche (immer, mit Textstelle) und Frage ans Modell
(semantisch, mit Fundstellen). Steht die Antwort nicht in den Unterlagen,
sagt FLORIAN das — ohne passende Auszüge wird gar nicht erst gefragt.
- Zerlegt wird an den Überschriften, mit der Überschriftenkette im
Einbettungstext: ein Abschnitt über „Rückzugsdruck" ohne das Wort
„Atemschutz" darüber findet sich sonst nicht.
- Jede Änderung schreibt die vorherige Fassung weg.
**Diktat, Bericht, Beleg**
- Aufnahme im Browser, Erkennung über faster-whisper im Hintergrund. Die
Aufnahme bleibt, solange nicht entschieden ist — sie ist das einzige
Original.
- Berichtsentwurf aus Stichpunkten. Zeitstempel und Einsatznummer bekommt das
Modell **nicht**: sie stehen schon als Felder im Bericht.
- Belegerfassung mit Vision-Modell. Nicht erkannte Felder bleiben leer und
gelb hinterlegt statt geraten zu werden.
**Zwei Protokolle**
- `/ki-protokoll` — was die Maschine getan hat, mit Kennzahlen und
aufklappbarer Eingabe/Ausgabe.
- `/protokoll` — das Audit-Log. Wird seit Phase 0 geschrieben; die Ansicht
dazu kam jetzt, weil beides nebeneinander gehört: was hat ein Mensch getan,
was die Maschine.
### Geändert
- `lib/integrations/llm.ts` kann jetzt drei Dinge statt einem: Text,
Einbettungen und Bilder — mit je eigenem Modell, weil ein Rechner ohne
Grafikkarte das Einbettungsmodell noch schafft und das Vision-Modell nicht.
- Der Datei-Upload nimmt Tonaufnahmen an (webm, ogg, mp4, wav).
- Neue Rechte: `wiki.lesen` als Grundrecht (eine Wehr, in der nur die Führung
nachschlagen darf, hat kein Wiki, sondern einen Aktenschrank),
`wiki.bearbeiten`, `ki.nutzen`, `ki.protokoll`, `beleg.erfassen`.
- Der Aufbewahrungslauf zieht nebenbei Wiki-Seiten nach, deren Suchstand
veraltet ist.
### Behoben
- Beim Zerlegen einer Wiki-Seite wurde ein kurzer Abschnitt an den vorherigen
gehängt, auch wenn er unter einer anderen Überschrift stand. Unter einer
Antwort hätte dann eine Fundstelle gestanden, in der der zitierte Satz gar
nicht vorkommt. Gefunden hat es der Test, nicht das Ausprobieren.
### Sicherheit
- Row Level Security für die sechs neuen Tabellen — auch für die Vektorsuche:
ein perfekt passender Abschnitt einer fremden Wehr taucht nicht auf.
- Eingabe, Ausgabe, Modell und Art eines KI-Vorgangs sind nach dem Anlegen
unveränderlich (Trigger). Über einen Vorschlag wird genau einmal
entschieden. `REVOKE DELETE ON "ki_vorgang"`.
- `REVOKE UPDATE, DELETE ON "wikiversion"`: eine Dienstvorschrift, deren
Vorgeschichte sich bereinigen lässt, ist als Nachweis wertlos.
- Prüfbedingungen: keine Entscheidung ohne Zeitpunkt, kein Fehlschlag ohne
Grund, kein negativer Betrag auf einer Quittung.
### Tests
- 73 neue Unit-Tests: Zerlegen einer Seite (Überschriftenketten, Codeblöcke,
Teilung an Absatzgrenzen), Auswerten einer Modellantwort auf ein Belegfoto
(deutsche Zahlen, Tausenderpunkt, unmögliche Daten), die Anweisungen an das
Modell und die Datenbankregeln samt Vektorsuche über Mandanten hinweg.
- 14 neue E2E-Tests, die **ohne laufendes Modell** prüfen, dass alles andere
vollständig benutzbar bleibt und ehrlich sagt, was fehlt.
---
+193 -2
View File
@@ -31,7 +31,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
| Phase 5 — Atemschutz (FwDV 7) | fertig |
| Phase 6 — Kartenmodule | fertig |
| Phase 7 — Benachrichtigungen und Termine | fertig |
| Phase 8 — Lokale KI | offen |
| Phase 8 — Lokale KI | fertig |
| Phase 9 — Kassenverwaltung | offen |
Vollständige Planung mit Aufwänden: [docs/ROADMAP.md](docs/ROADMAP.md) und
@@ -973,6 +973,188 @@ Meldungen bleiben einfach aus. Beide Schlüssel gehören deshalb ins Backup.
---
## Phase 8 — Lokale KI
Ein Satz bestimmt alles, was hier gebaut wurde:
> **Nichts, was ein Modell erzeugt, gelangt ungeprüft in einen Nachweis.**
Daraus folgt der Aufbau: jede KI-Ausgabe ist zuerst ein eigener Datensatz und
nicht ein Feld im Zieldokument. Ein Einsatzbericht bekommt seinen Text erst,
wenn ein Mensch „übernehmen" gedrückt hat — bis dahin liegt der Entwurf daneben
und ist als Entwurf erkennbar, mit gestricheltem Rahmen und dem Modellnamen
darüber. Das ist der Unterschied zwischen einer Hilfe und einer Fälschung.
Der zweite Grundsatz: **es läuft alles im Haus.** Ollama und faster-whisper als
Container daneben, kein Anbieter, kein Schlüssel, keine Zeile Text, die das
Gerätehaus verlässt. Es gibt **keinen Rückfall** auf einen Cloud-Dienst: fehlen
die Container, fehlt die Funktion. Das ist die einzige Bauart, bei der der Satz
„die Daten bleiben im Haus" auch dann noch stimmt, wenn der eigene Server aus
ist.
### Ohne Modelle geht alles andere weiter
Die wichtigste Eigenschaft der Phase, und die am meisten Arbeit gemacht hat:
**FLORIAN bleibt ohne Sprachmodell vollständig benutzbar.** Wiki lesen und
schreiben, Wortsuche, Belege ablegen und von Hand ausfüllen, beide Protokolle —
alles läuft. Wo etwas fehlt, steht ein Satz, der sagt *was* fehlt und *wie es
ohne geht*, statt eines kaputten Knopfes.
Das ist kein Zugeständnis, sondern der Normalfall: eine Wehr setzt FLORIAN auf,
lange bevor sie über eine Grafikkarte nachdenkt.
### Das Wiki
Dienstvorschriften, Ausbildungsunterlagen, Merkblätter — das, was sonst als PDF
im Ordner steht und im Einsatz niemand findet. Inhalt ist **Markdown**, weil es
sich ohne Werkzeug lesen lässt, auch in zwanzig Jahren, auch ohne FLORIAN.
**Zwei Suchen nebeneinander**, und beide werden gebraucht:
| Suche | Findet | Braucht ein Modell |
|---|---|---|
| **Wortsuche** | was dasteht — „FwDV 7" findet die Seite mit dieser Zeichenfolge | nein |
| **Frage** | was gemeint ist — „wie lange hält ein Schlauch" findet den Abschnitt über Ablegereife | ja |
Die Frage wird **ausschließlich aus den eigenen Seiten** beantwortet, mit
Fundstellen darunter. Steht die Antwort nicht drin, sagt FLORIAN genau das. Ein
Modell, das auf die Frage nach der Prüffrist von Steckleitern eine Zahl nennt,
die nirgends steht, wäre gefährlicher als gar keine Antwort — deshalb wird ohne
passende Auszüge gar nicht erst gefragt.
Zerlegt wird an den **Überschriften**, nicht nach Zeichenzahl: eine Fundstelle
soll ein Kapitel sein, kein aus der Mitte gerissener Absatz. Und ein kurzer
Abschnitt wird nie unter eine fremde Überschrift gehängt — sonst zitierte die
Antwort eine Fundstelle, in der der Satz gar nicht steht.
Jede Änderung schreibt die vorherige Fassung weg. Eine Dienstvorschrift, die
sich unbemerkt ändert, ist schlimmer als keine.
### Diktat
Der Weg, den es in jeder Wehr schon gibt: der Einsatzleiter spricht auf der
Rückfahrt in sein Telefon, und daraus wird abends ein Bericht. Neu ist nur, dass
abends schon ein Text dasteht.
Ein Knopf, keine Einstellungen. Die Aufnahme bleibt abgelegt, solange über den
Text nicht entschieden ist — wer dem Transkript nicht traut, hört nach. Und das
Modell darf **nichts verbessern**: „Hohlstrahlrohr" bleibt „Hohlstrahlrohr",
auch wenn es das Wort nicht kennt.
### Berichtsassistent
Stichpunkte hinein, Entwurf heraus. Was das Modell **nicht** bekommt, ist ebenso
wichtig: Zeitstempel und Einsatznummer 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 — „Übernehmen" ist nicht der Standardweg und sieht
auch nicht so aus.
### Belegerfassung
Quittung fotografieren, Angaben prüfen, gegenzeichnen. Alle Felder sind
änderbar, und was das Modell nicht erkannt hat, bleibt **leer und gelb
hinterlegt** statt geraten zu werden. Ein leeres Feld ist eine Aufforderung; ein
geratener Betrag ist eine falsche Buchung, die niemand bemerkt.
Die Buchung daraus kommt mit der Kasse in Phase 9 — hier entsteht erst einmal
ein sauber erfasster Beleg mit Foto.
### Zwei Protokolle nebeneinander
| Ansicht | Beantwortet |
|---|---|
| **KI-Protokoll** | Was hat die Maschine getan? Jede Befragung, jedes Modell, jede Entscheidung mit Namen und Zeitpunkt. |
| **Protokoll** (Audit-Log) | Was haben Menschen getan? Wird seit Phase 0 geschrieben; die Ansicht dazu kam jetzt. |
Eingabe, Ausgabe, Modell und Art eines KI-Vorgangs sind nach dem Anlegen
**unveränderlich** — ein Trigger hält dagegen. Löschen ist der Anwendungsrolle
entzogen, 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.
### Einrichtung
Postgres braucht seit dieser Phase **pgvector** neben PostGIS. Es gibt kein
offizielles Image mit beidem, deshalb baut FLORIAN eines
(`docker/postgres/Dockerfile`):
```bash
docker compose build postgres # bei einer bestehenden Installation
docker compose up -d postgres # Datenverzeichnis bleibt unberührt
npm run db:migrate
```
Dann die Modelle:
```bash
docker compose --profile ki up -d
docker compose exec ollama ollama pull qwen2.5:14b # Text
docker compose exec ollama ollama pull nomic-embed-text # Einbettungen
docker compose exec ollama ollama pull llama3.2-vision # Belege (optional)
```
Auf einem Rechner ohne Grafikkarte läuft das Einbettungsmodell noch flott, das
Textmodell zäh und das Vision-Modell praktisch nicht. Deshalb sind alle drei
einzeln konfigurierbar: eine Wehr kann das Wiki durchsuchbar machen, ohne
Berichte schreiben zu lassen.
Für eine Nvidia-Karte in die `docker-compose.override.yml`:
```yaml
services:
ollama:
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
```
### Ein anderes Einbettungsmodell
Die Spalte `wikiabschnitt.einbettung` ist auf **768 Dimensionen** festgelegt —
das liefert `nomic-embed-text`. Ein Modell mit anderer Länge braucht eine
Migration:
```sql
ALTER TABLE "wikiabschnitt" DROP COLUMN "einbettung";
ALTER TABLE "wikiabschnitt" ADD COLUMN "einbettung" vector(1024);
CREATE INDEX "wikiabschnitt_einbettung_idx"
ON "wikiabschnitt" USING hnsw ("einbettung" vector_cosine_ops);
```
Danach `LLM_EINBETTUNG_DIMENSIONEN` anpassen und alle Seiten neu einbetten. Der
nächtliche Lauf holt das von selbst nach; wer nicht warten will, drückt auf
jeder Seite „jetzt nachziehen".
Ohne Anpassung weist FLORIAN das Modell mit einer klaren Meldung ab, statt eine
Fehlermeldung der Datenbank durchzureichen, die niemand versteht.
### Zusätzliche Umgebungsvariablen
| Variable | Vorgabe | Bedeutung |
|---|---|---|
| `LLM_ENABLED` | `false` | Schaltet alle Textfunktionen ein |
| `LLM_BASE_URL` | — | OpenAI-kompatibler Endpunkt, z. B. `http://ollama:11434/v1` |
| `LLM_MODELL` | — | Modell für Berichte und Antworten |
| `LLM_TIMEOUT` | `120` | Zeitlimit in Sekunden |
| `LLM_EINBETTUNG_MODELL` | `nomic-embed-text` | Modell für die semantische Suche |
| `LLM_EINBETTUNG_DIMENSIONEN` | `768` | **Muss zur Spalte passen** |
| `LLM_VISION_MODELL` | — | Modell für die Belegerfassung |
| `WHISPER_ENABLED` | `false` | Schaltet das Diktat ein |
| `WHISPER_URL` | — | z. B. `http://whisper:8000` |
| `WHISPER_MODELL` | `base` | `tiny`, `base`, `small` |
| `DIKTAT_MAX_SEKUNDEN` | `600` | Höchstlänge einer Aufnahme |
| `OLLAMA_PORT` | `11435` | Port des Ollama-Containers (Profil `ki`) |
| `WHISPER_PORT` | `8087` | Port des Whisper-Containers (Profil `ki`) |
---
## Befehle
```bash
@@ -1003,6 +1185,9 @@ laufen kann. Alle Werte stehen in der `.env`.
| MinIO Weboberfläche | http://localhost:9011 |
| Gotenberg | http://localhost:3101 |
| Mailpit (nur Entwicklung) | http://localhost:8125 |
| ntfy (Profil `push`) | http://localhost:8086 |
| Ollama (Profil `ki`) | http://localhost:11435 |
| faster-whisper (Profil `ki`) | http://localhost:8087 |
| Adminer (nur Entwicklung) | http://localhost:8081 |
## Produktionsbetrieb
@@ -1025,9 +1210,15 @@ tut das bewusst nicht, damit nicht zwei Prozesse gleichzeitig migrieren.
Schriften verwendet, die auf dem Gerät vorhanden sind.
- Aufbewahrungsfristen konfigurierbar (`AUFBEWAHRUNG_JAHRE`,
`AUDIT_AUFBEWAHRUNG_TAGE`, `PERSON_ANONYMISIERUNG_TAGE`).
- Externe Dienste (Mail, Telegram, ntfy, Web-Push, LLM) sitzen hinter
- Externe Dienste (Mail, Telegram, ntfy, Web-Push) sitzen hinter
Schnittstellen in `src/lib/integrations/` und sind einzeln abschaltbar. Ohne
Freischaltung wird nur ins Log geschrieben.
- **Die KI-Funktionen laufen ausschließlich gegen selbst betriebene Modelle.**
Es gibt keinen Rückfall auf einen Anbieter: fehlt der eigene Endpunkt, fehlt
die Funktion. Kein Text verlässt das Gerätehaus.
- Jede KI-Ausgabe ist gekennzeichnet, wird als eigener Datensatz festgehalten
und muss von einem Menschen übernommen werden, bevor sie in einen Nachweis
gelangt. Nachvollziehbar im KI-Protokoll.
- Über Kanäle, die das Netz der Wehr verlassen (Telegram, Web-Push), gehen nur
Kurzfassungen ohne Namen, Gerätebezeichnungen und Einsatzdetails.
- Kein WhatsApp — die Begründung steht oben unter „Warum kein WhatsApp".
+100 -2
View File
@@ -87,6 +87,16 @@ x-app-env: &app-env
LLM_BASE_URL: ${LLM_BASE_URL:-}
LLM_API_KEY: ${LLM_API_KEY:-}
LLM_MODELL: ${LLM_MODELL:-}
LLM_TIMEOUT: ${LLM_TIMEOUT:-120}
LLM_EINBETTUNG_MODELL: ${LLM_EINBETTUNG_MODELL:-nomic-embed-text}
LLM_EINBETTUNG_DIMENSIONEN: ${LLM_EINBETTUNG_DIMENSIONEN:-768}
LLM_VISION_MODELL: ${LLM_VISION_MODELL:-}
WHISPER_ENABLED: ${WHISPER_ENABLED:-false}
WHISPER_URL: ${WHISPER_URL:-}
WHISPER_MODELL: ${WHISPER_MODELL:-base}
WHISPER_SPRACHE: ${WHISPER_SPRACHE:-de}
WHISPER_TIMEOUT: ${WHISPER_TIMEOUT:-300}
DIKTAT_MAX_SEKUNDEN: ${DIKTAT_MAX_SEKUNDEN:-600}
GEOCODING_ENABLED: ${GEOCODING_ENABLED:-false}
NOMINATIM_URL: ${NOMINATIM_URL:-}
GEOCODING_TIMEOUT: ${GEOCODING_TIMEOUT:-5}
@@ -144,10 +154,18 @@ services:
logging: *logging
# ---------------------------------------------------------------------------
# PostgreSQL 17 mit PostGIS. Einziger Ort für relationale Daten.
# PostgreSQL 17 mit PostGIS und pgvector. Einziger Ort für relationale Daten.
#
# Eigenes Image, weil FLORIAN beides braucht und es kein offizielles gibt,
# das beides mitbringt — siehe docker/postgres/Dockerfile. Beim ersten
# `docker compose up` wird es gebaut; bei einer bestehenden Installation
# genügt `docker compose build postgres` und ein Neustart des Dienstes. Das
# Datenverzeichnis bleibt unberührt, die Hauptversion ändert sich nicht.
# ---------------------------------------------------------------------------
postgres:
image: postgis/postgis:17-3.5
build:
context: ./docker/postgres
image: florian-postgres:${IMAGE_TAG:-17-3.5-pgvector}
container_name: florian-postgres
restart: unless-stopped
environment:
@@ -318,6 +336,82 @@ services:
- intern
logging: *logging
# ---------------------------------------------------------------------------
# Ollama — Sprachmodelle im eigenen Haus (Phase 8).
#
# Nur mit Profil: docker compose --profile ki up -d
#
# Es gibt bewusst KEINEN Rückfall auf einen Anbieter. Läuft dieser Container
# nicht, fehlen die KI-Funktionen — sie werden nicht durch einen fremden
# Dienst ersetzt. Ein Einsatzbericht spricht über Menschen in ihrer
# schlechtesten Stunde; solche Texte gehören nirgendwo anders hin.
#
# Nach dem ersten Start müssen die Modelle geholt werden:
#
# docker compose exec ollama ollama pull qwen2.5:14b # Text
# docker compose exec ollama ollama pull nomic-embed-text # Einbettungen
# docker compose exec ollama ollama pull llama3.2-vision # Belege (optional)
#
# Auf einem Rechner ohne Grafikkarte läuft das Einbettungsmodell noch flott,
# das Textmodell zäh und das Vision-Modell praktisch nicht. Deshalb sind alle
# drei einzeln konfigurierbar: eine Wehr kann das Wiki durchsuchbar machen,
# ohne Berichte schreiben zu lassen.
#
# Für eine Nvidia-Karte zusätzlich das Compose-Fragment aus dem README
# einbinden (deploy.resources.reservations.devices) — es steht nicht fest
# hier drin, weil es ohne Karte den Start verhindert.
# ---------------------------------------------------------------------------
ollama:
image: ollama/ollama:0.5.7
container_name: florian-ollama
restart: unless-stopped
profiles: [ki]
environment:
# Modelle bleiben geladen, statt nach fünf Minuten aus dem Speicher zu
# fallen. Wer zweimal am Tag einen Bericht schreibt, wartet sonst jedes
# Mal auf das Nachladen.
OLLAMA_KEEP_ALIVE: ${OLLAMA_KEEP_ALIVE:-30m}
OLLAMA_HOST: "0.0.0.0:11434"
volumes:
- ollama-data:/root/.ollama
ports:
- "${OLLAMA_PORT:-11435}:11434"
networks:
- intern
logging: *logging
# ---------------------------------------------------------------------------
# faster-whisper — Spracherkennung fürs Diktat (Phase 8).
#
# Nur mit Profil: docker compose --profile ki up -d
#
# OpenAI-kompatible Schnittstelle, aber vollständig lokal. Das Modell wird
# beim ersten Aufruf geholt und dann behalten.
#
# WHISPER_MODELL steuert die Größe: "tiny" läuft auf jedem Bürorechner,
# "base" ist der brauchbare Kompromiss, "small" erkennt Fachbegriffe
# merklich besser und will dafür Zeit oder eine Grafikkarte.
# ---------------------------------------------------------------------------
whisper:
image: fedirz/faster-whisper-server:latest-cpu
container_name: florian-whisper
restart: unless-stopped
profiles: [ki]
environment:
WHISPER__MODEL: ${WHISPER_MODELL:-base}
WHISPER__INFERENCE_DEVICE: ${WHISPER_GERAET:-cpu}
# Nach zehn Minuten ohne Anfrage den Speicher freigeben. Eine Wehr
# diktiert nicht stündlich.
WHISPER__TTL: ${WHISPER_TTL:-600}
ENABLE_UI: "false"
volumes:
- whisper-data:/root/.cache/huggingface
ports:
- "${WHISPER_PORT:-8087}:8000"
networks:
- intern
logging: *logging
# ---------------------------------------------------------------------------
# ntfy — Push aufs Handy, ohne dass etwas das Haus verlässt (Phase 7).
#
@@ -434,6 +528,10 @@ volumes:
redis-data:
minio-data:
traefik-acme:
# Phase 8: die Modelle. Sie sind mehrere Gigabyte groß und sollen einen
# Neustart des Containers überleben — sonst werden sie jedes Mal neu geholt.
ollama-data:
whisper-data:
networks:
# Nur was von außen erreichbar sein muss, hängt hier drin.
+3 -1
View File
@@ -2,7 +2,8 @@
# =============================================================================
# Läuft EINMALIG beim allerersten Start des Postgres-Containers (leeres
# Datenverzeichnis). Richtet ein:
# - die benötigten Erweiterungen (PostGIS, pgcrypto, citext, unaccent, pg_trgm)
# - die benötigten Erweiterungen (PostGIS, pgvector, pgcrypto, citext,
# unaccent, pg_trgm)
# - den Anwendungsbenutzer "florian_app" OHNE BYPASSRLS
# - Vorgabeprivilegien, damit künftige Tabellen automatisch nutzbar sind
#
@@ -28,6 +29,7 @@ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-'
CREATE EXTENSION IF NOT EXISTS citext; -- E-Mail ohne Gross/Klein
CREATE EXTENSION IF NOT EXISTS unaccent; -- Suche ueber Umlaute hinweg
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- unscharfe Namenssuche
CREATE EXTENSION IF NOT EXISTS vector; -- semantische Suche im Wiki
EOSQL
# Anwendungsrolle anlegen. Bewusst OHNE DO-Block: psql ersetzt seine Variablen
+33
View File
@@ -0,0 +1,33 @@
# =============================================================================
# PostgreSQL 17 mit PostGIS **und** pgvector.
#
# Warum ein eigenes Image: FLORIAN braucht beides. PostGIS seit Phase 6 für
# Hydranten und Umkreissuche, pgvector seit Phase 8 für die semantische Suche
# im Wiki. Es gibt kein offizielles Image, das beides mitbringt —
# postgis/postgis kennt kein vector, pgvector/pgvector kein PostGIS.
#
# Die Alternative wäre gewesen, die Ähnlichkeitssuche in der Anwendung zu
# rechnen: alle Abschnitte laden, Kosinusabstand in TypeScript. Das geht bei
# zweihundert Wiki-Seiten und wird bei zweitausend zäh — und es verlegt eine
# Aufgabe in die Anwendung, für die die Datenbank einen Index hat.
#
# Das Basisimage ist Debian mit eingerichtetem PGDG-Repository; die Erweiterung
# kommt als fertiges Paket daher, nicht als Übersetzungslauf.
# =============================================================================
FROM postgis/postgis:17-3.5
# Version festhalten: ein stillschweigender Sprung auf eine neue pgvector-
# Fassung würde beim nächsten Neubau des Images passieren, nicht dann, wenn
# jemand darüber entscheidet.
ARG PGVECTOR_PAKET=postgresql-17-pgvector
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends "${PGVECTOR_PAKET}"; \
rm -rf /var/lib/apt/lists/*
# Die Erweiterung selbst wird nicht hier angelegt, sondern in der Migration
# (CREATE EXTENSION IF NOT EXISTS vector). Grund: eine bestehende Installation
# bekommt zwar das neue Image, aber nicht noch einmal den Init-Lauf — das
# Datenverzeichnis ist ja nicht leer. Über die Migration greift es in beiden
# Fällen.
+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

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "florian",
"version": "0.8.0",
"version": "0.9.0",
"private": true,
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
"engines": {
@@ -0,0 +1,242 @@
-- =============================================================================
-- Phase 8 — Lokale KI
--
-- HINWEIS zu den entfernten DropIndex-Blöcken:
-- Prisma erzeugt bei jeder Migration erneut den Vorschlag, die von Hand
-- angelegten GiST- und Trigram-Indizes aus Phase 1 abzuräumen — es kennt sie
-- nicht, weil sie nicht aus dem Schema stammen, sondern aus einer eigenen
-- Migration. Die Blöcke sind deshalb hier entfernt worden, wie in jeder
-- Migration seit Phase 1. Ohne sie wäre die Volltext- und Umkreissuche nach
-- diesem Schritt ein vollständiger Tabellendurchlauf.
--
-- Neu ist die Erweiterung "vector": sie kommt aus dem eigenen Postgres-Image
-- (docker/postgres/Dockerfile). Bei einer bestehenden Installation muss es
-- vorher gebaut werden — `docker compose build postgres`, dann den Dienst neu
-- starten. Ohne die Erweiterung bricht diese Migration mit einer klaren
-- Meldung ab, und das ist besser als eine halb angelegte Tabelle.
-- =============================================================================
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "vector";
-- CreateEnum
CREATE TYPE "KiArt" AS ENUM ('DIKTAT', 'BERICHTSENTWURF', 'WIKI_ANTWORT', 'BELEGERFASSUNG', 'EINBETTUNG');
-- CreateEnum
CREATE TYPE "KiStand" AS ENUM ('ENTWURF', 'UEBERNOMMEN', 'VERWORFEN', 'FEHLGESCHLAGEN');
-- CreateEnum
CREATE TYPE "WikiKategorie" AS ENUM ('DIENSTVORSCHRIFT', 'AUSBILDUNG', 'GERAETE', 'EINSATZ', 'ORGANISATION', 'SONSTIGES');
-- CreateTable
CREATE TABLE "ki_vorgang" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"art" "KiArt" NOT NULL,
"stand" "KiStand" NOT NULL DEFAULT 'ENTWURF',
"modell" VARCHAR(120) NOT NULL,
"entitaet" VARCHAR(80),
"entitaetId" UUID,
"eingabe" VARCHAR(8000) NOT NULL,
"ausgabe" VARCHAR(40000),
"fehler" VARCHAR(1000),
"dauerMs" INTEGER,
"tokenEingabe" INTEGER,
"tokenAusgabe" INTEGER,
"erzeugtVonId" UUID,
"entschiedenVonId" UUID,
"entschiedenAm" TIMESTAMPTZ(3),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ki_vorgang_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "wikiseite" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"slug" VARCHAR(160) NOT NULL,
"titel" VARCHAR(200) NOT NULL,
"art" "WikiKategorie" NOT NULL DEFAULT 'SONSTIGES',
"inhalt" TEXT NOT NULL,
"quelle" VARCHAR(300),
"schlagworte" TEXT[],
"veroeffentlicht" BOOLEAN NOT NULL DEFAULT true,
"autorId" UUID,
"eingebettetAm" TIMESTAMPTZ(3),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "wikiseite_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "wikiabschnitt" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"seiteId" UUID NOT NULL,
"reihenfolge" INTEGER NOT NULL,
"ueberschrift" VARCHAR(300),
"text" TEXT NOT NULL,
"einbettung" vector(768),
"modell" VARCHAR(120),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "wikiabschnitt_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "wikiversion" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"seiteId" UUID NOT NULL,
"nummer" INTEGER NOT NULL,
"titel" VARCHAR(200) NOT NULL,
"inhalt" TEXT NOT NULL,
"bemerkung" VARCHAR(500),
"autorId" UUID,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "wikiversion_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "diktat" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"audioDateiId" UUID NOT NULL,
"dauerSekunden" INTEGER,
"sprache" VARCHAR(10) NOT NULL DEFAULT 'de',
"transkript" TEXT,
"stand" "KiStand" NOT NULL DEFAULT 'ENTWURF',
"entitaet" VARCHAR(80),
"entitaetId" UUID,
"kiVorgangId" UUID,
"erzeugtVonId" UUID,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "diktat_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "belegentwurf" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"fotoDateiId" UUID NOT NULL,
"betrag" DECIMAL(12,2),
"waehrung" VARCHAR(3) NOT NULL DEFAULT 'EUR',
"belegdatum" DATE,
"haendler" VARCHAR(200),
"kategorie" VARCHAR(80),
"steuersatz" DECIMAL(5,2),
"rohantwort" JSONB,
"stand" "KiStand" NOT NULL DEFAULT 'ENTWURF',
"kiVorgangId" UUID,
"erfasstVonId" UUID,
"bestaetigtVonId" UUID,
"bestaetigtAm" TIMESTAMPTZ(3),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "belegentwurf_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ki_vorgang_organisationId_art_erstelltAm_idx" ON "ki_vorgang"("organisationId", "art", "erstelltAm" DESC);
-- CreateIndex
CREATE INDEX "ki_vorgang_organisationId_entitaet_entitaetId_idx" ON "ki_vorgang"("organisationId", "entitaet", "entitaetId");
-- CreateIndex
CREATE INDEX "ki_vorgang_organisationId_stand_idx" ON "ki_vorgang"("organisationId", "stand");
-- CreateIndex
CREATE INDEX "wikiseite_organisationId_art_idx" ON "wikiseite"("organisationId", "art");
-- CreateIndex
CREATE UNIQUE INDEX "wikiseite_organisationId_slug_key" ON "wikiseite"("organisationId", "slug");
-- CreateIndex
CREATE INDEX "wikiabschnitt_organisationId_idx" ON "wikiabschnitt"("organisationId");
-- CreateIndex
CREATE UNIQUE INDEX "wikiabschnitt_seiteId_reihenfolge_key" ON "wikiabschnitt"("seiteId", "reihenfolge");
-- CreateIndex
CREATE INDEX "wikiversion_organisationId_seiteId_idx" ON "wikiversion"("organisationId", "seiteId");
-- CreateIndex
CREATE UNIQUE INDEX "wikiversion_seiteId_nummer_key" ON "wikiversion"("seiteId", "nummer");
-- CreateIndex
CREATE INDEX "diktat_organisationId_stand_idx" ON "diktat"("organisationId", "stand");
-- CreateIndex
CREATE INDEX "diktat_organisationId_entitaet_entitaetId_idx" ON "diktat"("organisationId", "entitaet", "entitaetId");
-- CreateIndex
CREATE INDEX "belegentwurf_organisationId_stand_idx" ON "belegentwurf"("organisationId", "stand");
-- CreateIndex
CREATE INDEX "belegentwurf_organisationId_belegdatum_idx" ON "belegentwurf"("organisationId", "belegdatum");
-- AddForeignKey
ALTER TABLE "ki_vorgang" ADD CONSTRAINT "ki_vorgang_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ki_vorgang" ADD CONSTRAINT "ki_vorgang_erzeugtVonId_fkey" FOREIGN KEY ("erzeugtVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ki_vorgang" ADD CONSTRAINT "ki_vorgang_entschiedenVonId_fkey" FOREIGN KEY ("entschiedenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wikiseite" ADD CONSTRAINT "wikiseite_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wikiseite" ADD CONSTRAINT "wikiseite_autorId_fkey" FOREIGN KEY ("autorId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wikiabschnitt" ADD CONSTRAINT "wikiabschnitt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wikiabschnitt" ADD CONSTRAINT "wikiabschnitt_seiteId_fkey" FOREIGN KEY ("seiteId") REFERENCES "wikiseite"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wikiversion" ADD CONSTRAINT "wikiversion_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wikiversion" ADD CONSTRAINT "wikiversion_seiteId_fkey" FOREIGN KEY ("seiteId") REFERENCES "wikiseite"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wikiversion" ADD CONSTRAINT "wikiversion_autorId_fkey" FOREIGN KEY ("autorId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "diktat" ADD CONSTRAINT "diktat_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "diktat" ADD CONSTRAINT "diktat_audioDateiId_fkey" FOREIGN KEY ("audioDateiId") REFERENCES "datei"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "diktat" ADD CONSTRAINT "diktat_kiVorgangId_fkey" FOREIGN KEY ("kiVorgangId") REFERENCES "ki_vorgang"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "diktat" ADD CONSTRAINT "diktat_erzeugtVonId_fkey" FOREIGN KEY ("erzeugtVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "belegentwurf" ADD CONSTRAINT "belegentwurf_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "belegentwurf" ADD CONSTRAINT "belegentwurf_fotoDateiId_fkey" FOREIGN KEY ("fotoDateiId") REFERENCES "datei"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "belegentwurf" ADD CONSTRAINT "belegentwurf_kiVorgangId_fkey" FOREIGN KEY ("kiVorgangId") REFERENCES "ki_vorgang"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "belegentwurf" ADD CONSTRAINT "belegentwurf_erfasstVonId_fkey" FOREIGN KEY ("erfasstVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "belegentwurf" ADD CONSTRAINT "belegentwurf_bestaetigtVonId_fkey" FOREIGN KEY ("bestaetigtVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,182 @@
-- =============================================================================
-- Phase 8 — Mandantentrennung, Prüfbedingungen und der Vektorindex
--
-- Drei Dinge, die nicht im Anwendungscode stehen dürfen:
--
-- 1. Row Level Security für die sechs neuen Tabellen. Wie überall: ohne
-- gesetztes app.current_organisation liefert die Anwendungsrolle nichts.
-- 2. Prüfbedingungen für das, was fachlich unmöglich ist — ein negativer
-- Betrag auf einer Quittung, eine Entscheidung ohne Entscheider.
-- 3. Der Vektorindex für die semantische Suche. Ohne ihn wäre jede Frage ans
-- Wiki ein vollständiger Tabellendurchlauf über alle Abschnitte.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- Row Level Security
-- -----------------------------------------------------------------------------
DO $$
DECLARE
tabelle text;
BEGIN
FOREACH tabelle IN ARRAY ARRAY[
'ki_vorgang',
'wikiseite',
'wikiabschnitt',
'wikiversion',
'diktat',
'belegentwurf'
]
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle);
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', tabelle);
EXECUTE format('DROP POLICY IF EXISTS %I ON %I', tabelle || '_mandant', tabelle);
EXECUTE format(
'CREATE POLICY %I ON %I FOR ALL '
'USING ("organisationId" = aktuelle_organisation()) '
'WITH CHECK ("organisationId" = aktuelle_organisation())',
tabelle || '_mandant', tabelle
);
END LOOP;
END $$;
-- -----------------------------------------------------------------------------
-- Prüfbedingungen
-- -----------------------------------------------------------------------------
-- Eine Entscheidung braucht einen Zeitpunkt und umgekehrt. Ein Vorgang, der
-- als übernommen gilt, ohne dass jemand ihn übernommen hat, wäre genau die
-- Lücke, die diese Tabelle schließen soll.
ALTER TABLE "ki_vorgang" ADD CONSTRAINT "ki_vorgang_entscheidung_vollstaendig" CHECK (
("stand" IN ('ENTWURF', 'FEHLGESCHLAGEN') AND "entschiedenAm" IS NULL)
OR ("stand" IN ('UEBERNOMMEN', 'VERWORFEN') AND "entschiedenAm" IS NOT NULL)
);
-- Ein fehlgeschlagener Vorgang hat einen Grund. Sonst steht dort "ging nicht"
-- und niemand weiß, woran es lag.
ALTER TABLE "ki_vorgang" ADD CONSTRAINT "ki_vorgang_fehler_begruendet" CHECK (
"stand" <> 'FEHLGESCHLAGEN' OR "fehler" IS NOT NULL
);
ALTER TABLE "ki_vorgang" ADD CONSTRAINT "ki_vorgang_dauer_plausibel" CHECK (
"dauerMs" IS NULL OR "dauerMs" >= 0
);
-- Eine Quittung über einen negativen Betrag gibt es nicht. Eine Gutschrift ist
-- ein eigener Vorgang und kommt mit der Kasse in Phase 9.
ALTER TABLE "belegentwurf" ADD CONSTRAINT "belegentwurf_betrag_positiv" CHECK (
"betrag" IS NULL OR "betrag" >= 0
);
ALTER TABLE "belegentwurf" ADD CONSTRAINT "belegentwurf_steuersatz_plausibel" CHECK (
"steuersatz" IS NULL OR ("steuersatz" >= 0 AND "steuersatz" <= 100)
);
ALTER TABLE "diktat" ADD CONSTRAINT "diktat_dauer_plausibel" CHECK (
"dauerSekunden" IS NULL OR "dauerSekunden" >= 0
);
ALTER TABLE "wikiversion" ADD CONSTRAINT "wikiversion_nummer_positiv" CHECK ("nummer" > 0);
-- -----------------------------------------------------------------------------
-- Suche
-- -----------------------------------------------------------------------------
-- Der Vektorindex.
--
-- HNSW und nicht IVFFlat: IVFFlat muss auf einem gefüllten Bestand trainiert
-- werden und liefert auf einer frisch angelegten Tabelle schlechte Treffer.
-- HNSW ist ab der ersten Zeile brauchbar — und ein Wiki wächst Seite für
-- Seite, es gibt nie den Moment, an dem man den Index einmal aufbaut.
--
-- Kosinusabstand, weil die Einbettungen normalisiert sind und es dann dasselbe
-- misst wie das Skalarprodukt, aber unabhängig von der Länge des Vektors.
CREATE INDEX IF NOT EXISTS "wikiabschnitt_einbettung_idx"
ON "wikiabschnitt" USING hnsw ("einbettung" vector_cosine_ops);
-- Volltext daneben. Die semantische Suche findet, was gemeint ist; die
-- Wortsuche findet, was dasteht. Wer "FwDV 7" eingibt, will die Seite mit
-- genau dieser Zeichenfolge — kein Modell der Welt ist da besser als LIKE.
CREATE INDEX IF NOT EXISTS "wikiseite_suche_titel"
ON "wikiseite" USING gin ("titel" gin_trgm_ops);
CREATE INDEX IF NOT EXISTS "wikiseite_suche_inhalt"
ON "wikiseite" USING gin ("inhalt" gin_trgm_ops);
-- Nur die veröffentlichten Seiten werden durchsucht und beantwortet.
CREATE INDEX IF NOT EXISTS "wikiseite_veroeffentlicht"
ON "wikiseite" ("organisationId", "art")
WHERE "veroeffentlicht" = true AND "geloeschtAm" IS NULL;
-- Offene Vorschläge — die Liste, die jemand abarbeitet.
CREATE INDEX IF NOT EXISTS "ki_vorgang_offen"
ON "ki_vorgang" ("organisationId", "erstelltAm" DESC)
WHERE "stand" = 'ENTWURF';
-- -----------------------------------------------------------------------------
-- Rechte
-- -----------------------------------------------------------------------------
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'florian_app') THEN
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app';
EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app';
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "kiosk_vorgang" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "druckmessung" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "atemschutz_ereignis" FROM florian_app';
EXECUTE 'REVOKE DELETE ON "benachrichtigung" FROM florian_app';
-- Ein KI-Vorgang lässt sich nicht löschen und nicht umschreiben, was
-- die Sache selbst betrifft. Geändert werden darf nur die
-- Entscheidung — deshalb kein REVOKE UPDATE, sondern der Trigger
-- darunter, der die Ausgabe festhält.
EXECUTE 'REVOKE DELETE ON "ki_vorgang" FROM florian_app';
-- Eine einmal weggeschriebene Fassung einer Wiki-Seite bleibt. Eine
-- Dienstvorschrift, deren Vorgeschichte sich bereinigen lässt, ist
-- als Nachweis wertlos.
EXECUTE 'REVOKE UPDATE, DELETE ON "wikiversion" FROM florian_app';
END IF;
END $$;
-- -----------------------------------------------------------------------------
-- Was ein Modell erzeugt hat, bleibt, wie es war
-- -----------------------------------------------------------------------------
-- Der Trigger schützt genau das, worauf es ankommt: die Ausgabe des Modells
-- und die Angabe, welches Modell sie erzeugt hat. Wer einen Entwurf
-- nachbessern will, tut das im Zieldokument — nicht im Vorgang, der belegen
-- soll, was die Maschine wirklich geliefert hat.
--
-- Die Entscheidung (stand, entschiedenVonId, entschiedenAm) darf sich ändern,
-- aber nur einmal: von ENTWURF weg, nicht wieder zurück.
CREATE OR REPLACE FUNCTION ki_vorgang_unveraenderlich()
RETURNS TRIGGER AS $$
BEGIN
IF NEW."ausgabe" IS DISTINCT FROM OLD."ausgabe"
OR NEW."eingabe" IS DISTINCT FROM OLD."eingabe"
OR NEW."modell" IS DISTINCT FROM OLD."modell"
OR NEW."art" IS DISTINCT FROM OLD."art" THEN
RAISE EXCEPTION
'Ein KI-Vorgang haelt fest, was das Modell geliefert hat. Eingabe, '
'Ausgabe, Modell und Art lassen sich nicht nachtraeglich aendern.';
END IF;
IF OLD."stand" <> 'ENTWURF' AND NEW."stand" <> OLD."stand" THEN
RAISE EXCEPTION
'Ueber diesen Vorschlag wurde bereits entschieden (%). Eine zweite '
'Entscheidung waere nicht nachvollziehbar.', OLD."stand";
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS ki_vorgang_unveraenderlich_trigger ON "ki_vorgang";
CREATE TRIGGER ki_vorgang_unveraenderlich_trigger
BEFORE UPDATE ON "ki_vorgang"
FOR EACH ROW
EXECUTE FUNCTION ki_vorgang_unveraenderlich();
+344 -9
View File
@@ -24,7 +24,7 @@ datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_DATABASE_URL")
extensions = [postgis, pgcrypto, citext, unaccent, pg_trgm(map: "pg_trgm")]
extensions = [postgis, pgcrypto, citext, unaccent, pg_trgm(map: "pg_trgm"), vector]
}
// =============================================================================
@@ -209,14 +209,16 @@ model Organisation {
belastungsuebungen Belastungsuebung[]
// Phase 6 — Kartenmodule
hydranten Hydrant[]
objekte Objekt[]
objektkontakte Objektkontakt[]
objektplaene Objektplan[]
lagekarten Lagekarte[]
lageelemente Lageelement[]
wasserfoerderungen Wasserfoerderung[]
foerderpunkte Foerderpunkt[]
hydranten Hydrant[]
objekte Objekt[]
objektkontakte Objektkontakt[]
objektplaene Objektplan[]
lagekarten Lagekarte[]
lageelemente Lageelement[]
wasserfoerderungen Wasserfoerderung[]
foerderpunkte Foerderpunkt[]
// Phase 7 — Benachrichtigungen und Termine
benachrichtigungsvorlages Benachrichtigungsvorlage[]
zustellkanals Zustellkanal[]
benachrichtigungs Benachrichtigung[]
@@ -224,6 +226,14 @@ model Organisation {
terminanmeldungs Terminanmeldung[]
kalenderzugangs Kalenderzugang[]
// Phase 8 — Lokale KI
kiVorgaenge KiVorgang[]
wikiseiten Wikiseite[]
wikiabschnitte Wikiabschnitt[]
wikiversionen Wikiversion[]
diktate Diktat[]
belegentwuerfe Belegentwurf[]
@@index([aktiv])
@@map("organisation")
}
@@ -337,6 +347,15 @@ model Person {
terminanmeldungen Terminanmeldung[] @relation("TerminanmeldungPerson")
kalenderzugaenge Kalenderzugang[] @relation("KalenderzugangPerson")
// Phase 8 — Lokale KI
angestosseneKiVorgaenge KiVorgang[] @relation("KiVorgangErzeuger")
entschiedeneKiVorgaenge KiVorgang[] @relation("KiVorgangEntscheider")
verfassteWikiseiten Wikiseite[] @relation("WikiseiteAutor")
wikiversionen Wikiversion[] @relation("WikiversionAutor")
diktate Diktat[] @relation("DiktatErzeuger")
erfassteBelege Belegentwurf[] @relation("BelegentwurfErfasser")
bestaetigteBelege Belegentwurf[] @relation("BelegentwurfBestaetiger")
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
/// Person kann in zwei Wehren Mitglied sein.
@@unique([organisationId, email])
@@ -579,6 +598,10 @@ model Datei {
alsHydrantenfoto Hydrant[] @relation("HydrantFoto")
alsFeuerwehrplan Objektplan[] @relation("ObjektplanDatei")
// Phase 8
alsDiktataufnahme Diktat[] @relation("DiktatAufnahme")
alsBelegfoto Belegentwurf[] @relation("BelegentwurfFoto")
@@unique([bucket, objektSchluessel])
@@index([organisationId, entitaet, entitaetId])
@@index([organisationId, kategorie])
@@ -3276,3 +3299,315 @@ model Kalenderzugang {
@@index([organisationId, personId])
@@map("kalenderzugang")
}
// =============================================================================
// Phase 8 — Lokale KI
//
// Der Satz, der über allen Tabellen hier steht und der ihren Aufbau bestimmt:
// **Nichts, was ein Modell erzeugt, gelangt ungeprüft in einen Nachweis.**
//
// Daraus folgt, dass jede KI-Ausgabe zuerst ein eigener Datensatz ist und
// nicht ein Feld im Zieldokument. Ein Einsatzbericht bekommt seinen Text erst,
// wenn ein Mensch „übernehmen" gedrückt hat — bis dahin liegt der Entwurf
// daneben und ist als Entwurf erkennbar. Das ist der Unterschied zwischen
// einer Hilfe und einer Fälschung.
//
// Der zweite Grundsatz: **es läuft alles im Haus.** Ollama und faster-whisper
// als Container daneben, kein Anbieter, kein Schlüssel, keine Zeile Text, die
// das Gerätehaus verlässt. Ohne die Container fehlt die Funktion — sie wird
// nicht durch einen fremden Dienst ersetzt.
// =============================================================================
/// Wofür ein Modell befragt wurde.
enum KiArt {
/// Sprache zu Text (faster-whisper).
DIKTAT
/// Aus Stichpunkten einen Einsatzberichtsentwurf.
BERICHTSENTWURF
/// Frage ans Wiki, beantwortet aus den eigenen Seiten.
WIKI_ANTWORT
/// Quittungsfoto zu Betrag, Datum, Händler.
BELEGERFASSUNG
/// Text für die semantische Suche einbetten.
EINBETTUNG
}
/// Was aus einem Vorschlag geworden ist.
///
/// ENTWURF ist der einzige Zustand, den die Maschine allein erreicht. Alles
/// andere setzt ein Mensch — und genau deshalb steht es hier und nicht als
/// Häkchen irgendwo.
enum KiStand {
ENTWURF
UEBERNOMMEN
VERWORFEN
FEHLGESCHLAGEN
}
/// Womit eine Wiki-Seite zu tun hat.
enum WikiKategorie {
DIENSTVORSCHRIFT
AUSBILDUNG
GERAETE
EINSATZ
ORGANISATION
SONSTIGES
}
/// Jede Befragung eines Modells, mit dem, was dabei herauskam.
///
/// Die Tabelle ist der Nachweis, dass die Regel eingehalten wurde: zu jedem
/// Text, der aus einem Modell stammt, steht hier, wer ihn wann übernommen oder
/// verworfen hat. Ohne sie ließe sich hinterher nicht mehr sagen, welcher Satz
/// in einem Einsatzbericht von einem Menschen stammt.
///
/// Gespeichert wird auch, was nicht geklappt hat. Ein Modell, das dreimal am
/// Tag in eine Zeitüberschreitung läuft, ist eine Information.
model KiVorgang {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
art KiArt
stand KiStand @default(ENTWURF)
/// Welches Modell — bei einem Wechsel soll nachvollziehbar bleiben, womit
/// ein alter Entwurf entstanden ist.
modell String @db.VarChar(120)
/// Worauf sich der Vorgang bezieht: "Einsatz", "Uebung", "Wikiseite".
entitaet String? @db.VarChar(80)
entitaetId String? @db.Uuid
/// Die Eingabe, gekürzt. Nicht das ganze Audio, nicht das ganze Foto —
/// die liegen als Datei in MinIO.
eingabe String @db.VarChar(8000)
ausgabe String? @db.VarChar(40000)
fehler String? @db.VarChar(1000)
dauerMs Int?
tokenEingabe Int?
tokenAusgabe Int?
erzeugtVonId String? @db.Uuid
/// Wer entschieden hat — und wann. Beides leer heißt: noch offen.
entschiedenVonId String? @db.Uuid
entschiedenAm DateTime? @db.Timestamptz(3)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
erzeugtVon Person? @relation("KiVorgangErzeuger", fields: [erzeugtVonId], references: [id], onDelete: SetNull)
entschiedenVon Person? @relation("KiVorgangEntscheider", fields: [entschiedenVonId], references: [id], onDelete: SetNull)
diktate Diktat[]
belegentwuerfe Belegentwurf[]
@@index([organisationId, art, erstelltAm(sort: Desc)])
@@index([organisationId, entitaet, entitaetId])
@@index([organisationId, stand])
@@map("ki_vorgang")
}
/// Eine Seite im Wiki der Wehr.
///
/// Inhalt ist Markdown, weil es sich ohne Werkzeug lesen lässt — auch in
/// zwanzig Jahren, auch ohne FLORIAN. Dienstvorschriften und
/// Ausbildungsunterlagen sollen die Anwendung überleben, die sie verwaltet.
model Wikiseite {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
slug String @db.VarChar(160)
titel String @db.VarChar(200)
art WikiKategorie @default(SONSTIGES)
inhalt String @db.Text
/// Woher es stammt: "FwDV 7, Ausgabe 2002", "Beschluss der Kommandanten".
/// Steht unter jeder Antwort, die aus dieser Seite gebaut wird.
quelle String? @db.VarChar(300)
schlagworte String[]
/// Unveröffentlichte Seiten sind Entwürfe: sie werden nicht durchsucht und
/// nicht zum Beantworten herangezogen.
veroeffentlicht Boolean @default(true)
autorId String? @db.Uuid
/// Wann die Abschnitte zuletzt eingebettet wurden. Liegt es vor
/// `geaendertAm`, ist die Seite seit der letzten Änderung nicht mehr
/// durchsuchbar — und das steht dann auch in der Ansicht.
eingebettetAm DateTime? @db.Timestamptz(3)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
autor Person? @relation("WikiseiteAutor", fields: [autorId], references: [id], onDelete: SetNull)
abschnitte Wikiabschnitt[]
versionen Wikiversion[]
@@unique([organisationId, slug])
@@index([organisationId, art])
@@map("wikiseite")
}
/// Ein Abschnitt einer Wiki-Seite samt seiner Einbettung.
///
/// Zerlegt wird an den Überschriften, nicht nach Zeichenzahl: ein Abschnitt
/// soll für sich verständlich sein, weil er für sich zitiert wird. Wer eine
/// Antwort bekommt, bekommt die Fundstelle dazu — und die ist dann ein
/// Kapitel, kein aus der Mitte gerissener Absatz.
///
/// `einbettung` ist für Prisma ein unbekannter Typ; gelesen und geschrieben
/// wird sie über `$queryRaw`. Das ist der übliche Weg mit pgvector und der
/// Grund, warum es in lib/ki/ eine eigene Datei dafür gibt.
model Wikiabschnitt {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
seiteId String @db.Uuid
reihenfolge Int
ueberschrift String? @db.VarChar(300)
text String @db.Text
/// 768 Dimensionen — das liefert nomic-embed-text, das Standardmodell für
/// Einbettungen unter Ollama. Ein anderes Modell mit anderer Länge braucht
/// eine Migration; das steht im README.
einbettung Unsupported("vector(768)")?
/// Womit eingebettet wurde. Nach einem Modellwechsel sind alte Einbettungen
/// nicht mehr vergleichbar und werden neu erzeugt.
modell String? @db.VarChar(120)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
seite Wikiseite @relation(fields: [seiteId], references: [id], onDelete: Cascade)
@@unique([seiteId, reihenfolge])
@@index([organisationId])
@@map("wikiabschnitt")
}
/// Der Stand einer Seite vor einer Änderung.
///
/// Eine Dienstvorschrift, die sich unbemerkt ändert, ist schlimmer als keine.
/// Deshalb wird bei jeder Änderung der vorherige Text weggeschrieben — schlicht
/// und vollständig, nicht als Differenz.
model Wikiversion {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
seiteId String @db.Uuid
nummer Int
titel String @db.VarChar(200)
inhalt String @db.Text
/// Was der Bearbeiter dazu gesagt hat.
bemerkung String? @db.VarChar(500)
autorId String? @db.Uuid
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
seite Wikiseite @relation(fields: [seiteId], references: [id], onDelete: Cascade)
autor Person? @relation("WikiversionAutor", fields: [autorId], references: [id], onDelete: SetNull)
@@unique([seiteId, nummer])
@@index([organisationId, seiteId])
@@map("wikiversion")
}
/// Eine Aufnahme und was daraus wurde.
///
/// Gedacht für den Weg, den es heute schon gibt: der Einsatzleiter spricht auf
/// der Rückfahrt in sein Telefon, und daraus wird abends ein Bericht. Nur dass
/// jetzt schon ein Text dasteht, den jemand nur noch geradezieht.
///
/// Die Aufnahme bleibt liegen, bis sie jemand entfernt: solange das Transkript
/// noch nicht bestätigt ist, ist sie das einzige Original.
model Diktat {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
/// Die Aufnahme in MinIO. Nie ein Pfad im Dateisystem.
audioDateiId String @db.Uuid
dauerSekunden Int?
sprache String @default("de") @db.VarChar(10)
transkript String? @db.Text
stand KiStand @default(ENTWURF)
/// Wohin der Text soll, wenn er übernommen wird.
entitaet String? @db.VarChar(80)
entitaetId String? @db.Uuid
kiVorgangId String? @db.Uuid
erzeugtVonId String? @db.Uuid
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
audioDatei Datei @relation("DiktatAufnahme", fields: [audioDateiId], references: [id], onDelete: Restrict)
kiVorgang KiVorgang? @relation(fields: [kiVorgangId], references: [id], onDelete: SetNull)
erzeugtVon Person? @relation("DiktatErzeuger", fields: [erzeugtVonId], references: [id], onDelete: SetNull)
@@index([organisationId, stand])
@@index([organisationId, entitaet, entitaetId])
@@map("diktat")
}
/// Was ein Modell auf einer Quittung gelesen hat — als Vorschlag.
///
/// Bewusst eine eigene Tabelle und keine Buchung: die Kasse kommt in Phase 9,
/// und ein Betrag, den eine Maschine aus einem Foto gelesen hat, ist keine
/// Buchung. Er ist ein ausgefülltes Formular, das jemand gegenzeichnet.
///
/// Alle Felder sind optional. Ein Modell, das den Betrag nicht findet, soll
/// nichts erfinden — dann bleibt das Feld leer und ein Mensch trägt es ein.
model Belegentwurf {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
fotoDateiId String @db.Uuid
betrag Decimal? @db.Decimal(12, 2)
waehrung String @default("EUR") @db.VarChar(3)
belegdatum DateTime? @db.Date
haendler String? @db.VarChar(200)
kategorie String? @db.VarChar(80)
/// Steuersatz in Prozent, falls erkannt.
steuersatz Decimal? @db.Decimal(5, 2)
/// Was das Modell geantwortet hat, unverändert. Für den Fall, dass die
/// Auswertung daneben lag und jemand nachsehen will, woran es lag.
rohantwort Json?
stand KiStand @default(ENTWURF)
kiVorgangId String? @db.Uuid
erfasstVonId String? @db.Uuid
bestaetigtVonId String? @db.Uuid
bestaetigtAm DateTime? @db.Timestamptz(3)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
fotoDatei Datei @relation("BelegentwurfFoto", fields: [fotoDateiId], references: [id], onDelete: Restrict)
kiVorgang KiVorgang? @relation(fields: [kiVorgangId], references: [id], onDelete: SetNull)
erfasstVon Person? @relation("BelegentwurfErfasser", fields: [erfasstVonId], references: [id], onDelete: SetNull)
bestaetigtVon Person? @relation("BelegentwurfBestaetiger", fields: [bestaetigtVonId], references: [id], onDelete: SetNull)
@@index([organisationId, stand])
@@index([organisationId, belegdatum])
@@map("belegentwurf")
}
+232
View File
@@ -19,6 +19,8 @@
* Lehrgang mit Warteliste, Arbeitsdienst, Jahreshauptversammlung, ein
* abgesagter Termin und eine abgehakte Anwesenheitsliste. Bewusst OHNE
* Zustellkanäle: niemand wird ungefragt benachrichtigt, auch nicht im Seed.
* Ab Phase 8 dazu: vier Wiki-Seiten mit eigenem Text — Prüffristen,
* Atemschutz-Merkblatt, Schlauchpflege und Alarmierungsordnung.
*
* Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang,
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
@@ -62,6 +64,7 @@ import {
ObjektKategorie,
Serienmuster,
StandortArt,
WikiKategorie,
Teilnahmestand,
TerminArt,
TruppStatus,
@@ -2999,6 +3002,232 @@ async function termineAnlegen(organisationId: string): Promise<void> {
);
}
// -----------------------------------------------------------------------------
// Phase 8 — Wissen
//
// Vier Seiten, wie eine Wehr sie wirklich hätte: eine Zusammenfassung der
// Prüffristen, ein Merkblatt zum Atemschutz, eine Anleitung zur Schlauchpflege
// und die Alarmierungsordnung.
//
// Der Inhalt ist bewusst **eigener Text** und keine abgeschriebene Vorschrift.
// Eine Dienstvorschrift ist urheberrechtlich geschützt; was hier steht, ist
// eine Zusammenfassung mit Fundstellenangabe — genau so, wie eine Wehr ihre
// eigenen Merkblätter schreibt.
//
// Ohne Sprachmodell bleiben die Seiten uneingebettet. Das ist kein Mangel: sie
// sind über die Wortsuche auffindbar, und die Seitenansicht sagt, dass der
// Suchstand fehlt.
// -----------------------------------------------------------------------------
async function wikiAnlegen(organisationId: string): Promise<void> {
const vorhanden = await prisma.wikiseite.count({ where: { organisationId } });
if (vorhanden > 0) {
log("Wiki bereits vorhanden — übersprungen.");
return;
}
const autor = await prisma.person.findFirst({
where: {
organisationId,
rollen: { some: { rolle: { schluessel: RolleSchluessel.KOMMANDANT } } },
},
select: { id: true },
});
const SEITEN: Array<{
slug: string;
titel: string;
art: WikiKategorie;
quelle: string;
schlagworte: string[];
inhalt: string;
}> = [
{
slug: "pruefristen-im-ueberblick",
titel: "Prüffristen im Überblick",
art: WikiKategorie.GERAETE,
quelle: "Zusammenfassung nach DGUV Vorschrift 49 und Herstellerangaben",
schlagworte: ["prüffrist", "dguv", "gerätewart", "wartung"],
inhalt: [
"Diese Seite fasst zusammen, was bei uns in welchem Abstand geprüft wird.",
"Verbindlich sind die Vorschrift und die Herstellerangabe — im Zweifel gilt",
"die kürzere Frist.",
"",
"## Jährlich",
"",
"- Pressluftatmer: Sicht- und Dichtprüfung",
"- Atemschutzmasken: Sichtprüfung, Dichtprüfung",
"- Tragbare Leitern: Sichtprüfung nach DGUV",
"- Rettungsgeräte und Sprungpolster",
"- Handscheinwerfer und Handfunkgeräte: Funktionsprüfung",
"",
"## Alle zwei Jahre",
"",
"- Feuerwehrschläuche: Druckprüfung",
"- Kupplungen und Armaturen: Dichtheit",
"",
"## Alle drei Jahre",
"",
"- Feuerlöscher: Wartung durch einen Sachkundigen",
"",
"## Alle sechs Jahre",
"",
"- Pressluftatmer: Grundüberholung durch die Werkstatt",
"- Atemluftflaschen: wiederkehrende Prüfung nach Druckgeräterichtlinie",
"",
"## Was passiert, wenn eine Frist abläuft",
"",
"Das Gerät wechselt in FLORIAN von selbst auf **Prüfung fällig**. Benutzen",
"darf man es weiter — es ist nicht gesperrt. Gesperrt wird nur von Hand und",
"immer mit Begründung.",
"",
"Wer eine Frist nicht halten kann, meldet das dem Gerätewart. Ein",
"stillschweigend abgelaufener Nachweis ist im Schadensfall das Problem des",
"Kommandanten, nicht des Geräts.",
].join("\n"),
},
{
slug: "atemschutz-merkblatt",
titel: "Atemschutz — was vor jedem Einsatz gilt",
art: WikiKategorie.DIENSTVORSCHRIFT,
quelle: "Zusammenfassung nach FwDV 7, Ausgabe 2002 mit Änderung 2005",
schlagworte: ["atemschutz", "fwdv 7", "überwachung", "rückzugsdruck"],
inhalt: [
"## Wer darf unter Atemschutz",
"",
"Drei Nachweise müssen gleichzeitig gültig sein:",
"",
"1. **G 26.3** — arbeitsmedizinische Vorsorge, in der Regel drei Jahre gültig,",
" ab 50 Jahren jährlich.",
"2. **Belastungsübung** — jährlich, in einer Atemschutzübungsanlage.",
"3. **Einsatz oder Übung unter Atemschutz** — jährlich.",
"",
"Fehlt einer davon, ist der Einsatz unter Atemschutz nicht zulässig. FLORIAN",
"zeigt das an, sperrt aber nicht: die Entscheidung trifft der",
"Atemschutzgerätewart, nicht die Software.",
"",
"## Rückzugsdruck",
"",
"Der Rückzugsdruck ist der Druck, bei dem der Trupp den Rückweg antreten",
"muss. Er wird aus dem **doppelten Anmarschverbrauch** plus einem",
"Sicherheitszuschlag gerechnet:",
"",
"```",
"Rückzugsdruck = Eingangsdruck - 2 x (Eingangsdruck - Druck am Ziel) + Zuschlag",
"```",
"",
"Ist der Anmarsch noch nicht gemessen, gilt die Zwei-Drittel-Regel: bei zwei",
"Dritteln des Eingangsdrucks wird zurückgegangen.",
"",
"## Überwachung",
"",
"Dokumentiert werden: Truppzusammensetzung, Einsatzbeginn, Druckwerte in",
"Abständen von höchstens zehn Minuten, Rückzugsdruck und Ende.",
"",
"> Die analoge Überwachung bleibt führend, solange die Wehr die digitale",
"> nicht ausdrücklich freigegeben hat. Das ist keine Formalie — eine",
"> Software, die im Einsatz ausfällt, darf keine Lücke im Nachweis",
"> hinterlassen.",
].join("\n"),
},
{
slug: "schlaeuche-pflegen",
titel: "Schläuche pflegen und prüfen",
art: WikiKategorie.GERAETE,
quelle: "Merkblatt der Wehr, beschlossen in der Kommandantensitzung",
schlagworte: ["schlauch", "pflege", "druckprüfung", "ablegereife"],
inhalt: [
"## Nach jedem Einsatz",
"",
"1. Schläuche vom Fahrzeug nehmen und durch neue ersetzen — nie einen",
" benutzten Schlauch wieder aufs Fahrzeug legen.",
"2. Grob reinigen, bei Verunreinigung mit Öl oder Chemikalien gesondert",
" behandeln und dem Gerätewart melden.",
"3. Trocknen, bevor sie gerollt werden. Ein feucht gerollter Schlauch",
" schimmelt von innen.",
"",
"## Ablegereife",
"",
"Ein Schlauch wird ausgesondert, wenn:",
"",
"- das Gewebe durchgescheuert oder eingerissen ist,",
"- die Einbindung sich gelöst hat,",
"- er die Druckprüfung nicht besteht,",
"- die Innenbeschichtung sich ablöst.",
"",
"Ein ausgesonderter Schlauch wird gekennzeichnet und **sofort** aus dem",
"Fahrzeug genommen. Ein abgelegter Schlauch, der noch im Regal liegt, kommt",
"irgendwann wieder aufs Fahrzeug.",
"",
"## Druckprüfung",
"",
"Alle zwei Jahre. Prüfdruck nach Herstellerangabe, in der Regel:",
"",
"| Größe | Prüfdruck |",
"|---|---|",
"| C 42 / C 52 | 12 bar |",
"| B 75 | 12 bar |",
"| A 110 | 8 bar |",
"",
"Das Ergebnis wird in FLORIAN am Schlauch eingetragen, nicht auf einem",
"Zettel im Schlauchturm.",
].join("\n"),
},
{
slug: "alarmierungsordnung",
titel: "Alarmierungsordnung",
art: WikiKategorie.ORGANISATION,
quelle: "Beschluss der Kommandantschaft",
schlagworte: ["alarm", "ausrücken", "einsatzleitung"],
inhalt: [
"## Wer rückt aus",
"",
"Alarmiert wird über Meldeempfänger und Sirene. Wer alarmiert wird, kommt",
"zum Gerätehaus — die Entscheidung, welches Fahrzeug besetzt wird, trifft",
"der Einsatzleiter vor Ort.",
"",
"Die Mindestbesetzung ist die **Staffel** (1/5). Kommt sie in fünf Minuten",
"nicht zusammen, wird mit dem nachrückenden Personal aufgefüllt und",
"ausgerückt.",
"",
"## Einsatzleitung",
"",
"Bis zum Eintreffen des Kommandanten führt der ranghöchste Anwesende. Die",
"Übergabe wird ausdrücklich ausgesprochen und in der Lagemeldung vermerkt —",
"ein stillschweigender Wechsel der Einsatzleitung ist im Nachhinein nicht",
"rekonstruierbar.",
"",
"## Was im Bericht stehen muss",
"",
"- Lage beim Eintreffen",
"- Getroffene Maßnahmen, in der Reihenfolge",
"- Übergabe: an wen, wann",
"",
"Nicht in den Bericht gehören Vermutungen über die Brandursache und",
"Bewertungen des Verhaltens Beteiligter. Beides ist Sache der Polizei.",
].join("\n"),
},
];
for (const seite of SEITEN) {
await prisma.wikiseite.create({
data: {
organisationId,
...seite,
veroeffentlicht: true,
autorId: autor?.id ?? null,
// Bewusst kein eingebettetAm: ohne Sprachmodell gibt es keine
// Einbettungen. Die Seitenansicht sagt das, und die Wortsuche
// funktioniert trotzdem.
},
});
}
log(
`Wissen: ${SEITEN.length} Seiten angelegt (noch nicht eingebettet — dafür braucht es ein Sprachmodell)`,
);
}
// -----------------------------------------------------------------------------
// Ablauf
// -----------------------------------------------------------------------------
@@ -3042,6 +3271,9 @@ async function main(): Promise<void> {
// Phase 7 — Benachrichtigungen und Termine
await termineAnlegen(organisation.id);
// Phase 8 — Lokale KI
await wikiAnlegen(organisation.id);
log("Fertig.");
log("");
log("Anmeldung für den Erststart:");
+26
View File
@@ -230,6 +230,32 @@ const AUFNAHMEN: Aufnahme[] = [
beschreibung: "Digitaler Dienstausweis mit QR-Code",
angemeldet: true,
},
// Phase 8 — Lokale KI
{
name: "41-wissen",
pfad: "/wiki",
beschreibung: "Wiki mit Wortsuche und Frage an die Unterlagen",
angemeldet: true,
},
{
name: "42-belege",
pfad: "/belege",
beschreibung: "Belege fotografieren und gegenzeichnen",
angemeldet: true,
},
{
name: "43-ki-protokoll",
pfad: "/ki-protokoll",
beschreibung: "Was die Maschine getan hat",
angemeldet: true,
},
{
name: "44-wiki-seite",
pfad: "/wiki/atemschutz-merkblatt",
beschreibung: "Eine Wiki-Seite mit Fundstelle und Verlauf",
angemeldet: true,
},
];
/**
+329
View File
@@ -0,0 +1,329 @@
"use client";
import * as React from "react";
import Image from "next/image";
import { Camera, Check, Trash2 } from "lucide-react";
import { KiStand } from "@prisma/client";
import type { inferRouterOutputs } from "@trpc/server";
import { trpc } from "@/lib/trpc/client";
import type { AppRouter } from "@/server/routers/_app";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { KiAbzeichen, KiFehltHinweis } from "@/components/ki/kennzeichnung";
import { dateiHochladen } from "@/lib/hochladen";
import { KI_STAND_TEXT, KI_STAND_VARIANTE } from "@/lib/ki/texte";
import { alsDatum, alsEuro } from "@/lib/utils";
/**
* Die Belegliste.
*
* Der ganze Ablauf in einer Ansicht: fotografieren, prüfen, gegenzeichnen.
*
* Was das Modell gelesen hat, steht in **änderbaren Feldern** — genau dafür
* ist es ein Entwurf. Wer den Betrag berichtigt und dann bestätigt, hat
* gegengezeichnet, was danach dasteht, nicht was die Maschine gelesen hat.
* Beides bleibt nachvollziehbar: der KI-Vorgang daneben hält die ursprüngliche
* Antwort fest.
*
* Felder, die das Modell nicht erkannt hat, sind hervorgehoben. Ein leeres
* Feld ist eine Aufforderung; ein geratener Wert wäre eine falsche Buchung,
* die niemand bemerkt.
*/
export function BelegListe() {
const werkzeuge = trpc.useUtils();
const [laedtHoch, setLaedtHoch] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(null);
const dateiRef = React.useRef<HTMLInputElement>(null);
const moeglichkeiten = trpc.assistent.moeglichkeiten.useQuery();
const belege = trpc.assistent.belege.useQuery({ anzahl: 50 });
const erfassen = trpc.assistent.belegErfassen.useMutation({
onSuccess: (ergebnis) => {
setHinweis(ergebnis.hinweis);
void werkzeuge.assistent.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
onSettled: () => setLaedtHoch(false),
});
async function fotoWaehlen(datei: File) {
setMeldung(null);
setHinweis(null);
setLaedtHoch(true);
try {
const abgelegt = await dateiHochladen(datei, "Belegentwurf", null, "beleg");
erfassen.mutate({ fotoDateiId: abgelegt.id });
} catch (fehler) {
setMeldung(fehler instanceof Error ? fehler.message : String(fehler));
setLaedtHoch(false);
}
}
const offen = belege.data?.filter((beleg) => beleg.stand === KiStand.ENTWURF) ?? [];
const erledigt = belege.data?.filter((beleg) => beleg.stand !== KiStand.ENTWURF) ?? [];
return (
<div className="space-y-6">
{!moeglichkeiten.data?.bild && !moeglichkeiten.isLoading && (
<KiFehltHinweis
was="Es ist kein Modell eingerichtet, das Bilder lesen kann (LLM_VISION_MODELL)."
ohneGeht="Belege lassen sich trotzdem fotografieren und ablegen — die Angaben werden dann von Hand eingetragen."
/>
)}
{meldung && <Fehlermeldung meldung={meldung} kennung="belegfehler" />}
{hinweis && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
{hinweis}
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<input
ref={dateiRef}
type="file"
accept="image/*"
// capture öffnet auf dem Handy direkt die Kamera — genau das will
// jemand, der mit einer Quittung in der Hand dasteht.
capture="environment"
className="hidden"
onChange={(ereignis) => {
const datei = ereignis.target.files?.[0];
if (datei) void fotoWaehlen(datei);
ereignis.target.value = "";
}}
/>
<Button onClick={() => dateiRef.current?.click()} disabled={laedtHoch}>
<Camera aria-hidden />
{laedtHoch ? "Wird gelesen …" : "Beleg fotografieren"}
</Button>
<span className="text-sm text-[var(--muted-foreground)]">
{offen.length} {offen.length === 1 ? "Beleg wartet" : "Belege warten"} auf
Gegenzeichnung.
</span>
</div>
{offen.length === 0 && erledigt.length === 0 && !belege.isLoading && (
<NichtsGefunden
titel="Noch keine Belege"
hinweis="Fotografieren Sie eine Quittung — die Angaben werden ausgelesen und zum Prüfen vorgelegt."
/>
)}
{offen.length > 0 && (
<section className="space-y-4">
<h2 className="text-lg font-semibold">Zu prüfen</h2>
{offen.map((beleg) => (
<BelegKarte key={beleg.id} beleg={beleg} fehler={setMeldung} />
))}
</section>
)}
{erledigt.length > 0 && (
<section className="space-y-3">
<h2 className="text-lg font-semibold">Erledigt</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
<th className="py-2 pr-4">Datum</th>
<th className="py-2 pr-4">Händler</th>
<th className="py-2 pr-4">Kategorie</th>
<th className="py-2 pr-4 text-right">Betrag</th>
<th className="py-2 pr-4">Stand</th>
<th className="py-2">Gegengezeichnet</th>
</tr>
</thead>
<tbody>
{erledigt.map((beleg) => (
<tr key={beleg.id} className="border-b border-[var(--border)]">
<td className="py-2 pr-4">{alsDatum(beleg.belegdatum)}</td>
<td className="py-2 pr-4">{beleg.haendler ?? "—"}</td>
<td className="py-2 pr-4">{beleg.kategorie ?? "—"}</td>
<td className="py-2 pr-4 text-right tabular-nums">
{beleg.betrag === null ? "—" : alsEuro(Number(beleg.betrag))}
</td>
<td className="py-2 pr-4">
<Badge variant={KI_STAND_VARIANTE[beleg.stand]}>
{KI_STAND_TEXT[beleg.stand]}
</Badge>
</td>
<td className="py-2">
{beleg.bestaetigtVon
? `${beleg.bestaetigtVon.vorname} ${beleg.bestaetigtVon.nachname}`
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
</div>
);
}
/** Ein Beleg, wie der Router ihn liefert — direkt von dort abgeleitet. */
type Beleg = inferRouterOutputs<AppRouter>["assistent"]["belege"][number];
/**
* Ein Beleg zum Gegenzeichnen.
*
* Foto links, Felder rechts. Das Foto ist nicht Zierde: es ist das, wogegen
* geprüft wird, und muss beim Prüfen sichtbar sein — nicht hinter einem Klick.
*/
function BelegKarte({ beleg, fehler }: { beleg: Beleg; fehler: (nachricht: string) => void }) {
const werkzeuge = trpc.useUtils();
const [betrag, setBetrag] = React.useState(beleg.betrag === null ? "" : String(beleg.betrag));
const [datum, setDatum] = React.useState(
beleg.belegdatum ? new Date(beleg.belegdatum).toISOString().slice(0, 10) : "",
);
const [haendler, setHaendler] = React.useState(beleg.haendler ?? "");
const [kategorie, setKategorie] = React.useState(beleg.kategorie ?? "");
const bestaetigen = trpc.assistent.belegBestaetigen.useMutation({
onSuccess: () => void werkzeuge.assistent.invalidate(),
onError: (aufruf) => fehler(aufruf.message),
});
const verwerfen = trpc.assistent.belegVerwerfen.useMutation({
onSuccess: () => void werkzeuge.assistent.invalidate(),
onError: (aufruf) => fehler(aufruf.message),
});
/** Ein Feld, das das Modell nicht gefüllt hat, wird hervorgehoben. */
const lueckeKlasse = (wert: string) =>
wert.trim() ? "" : "border-[var(--pruefung-faellig)] bg-[var(--pruefung-faellig)]/5";
return (
<Card>
<CardHeader>
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-base">
{beleg.haendler ?? "Beleg ohne erkannten Händler"}
</CardTitle>
{beleg.kiVorgangId && <KiAbzeichen />}
</div>
<CardDescription>
Erfasst am {alsDatum(beleg.erstelltAm)}
{beleg.erfasstVon && ` von ${beleg.erfasstVon.vorname} ${beleg.erfasstVon.nachname}`}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-[240px_1fr]">
<a
href={`/api/dateien/${beleg.fotoDatei.id}`}
target="_blank"
rel="noreferrer"
className="block"
>
<Image
src={`/api/dateien/${beleg.fotoDatei.id}`}
alt="Belegfoto"
width={240}
height={320}
unoptimized
className="h-auto w-full rounded-md border border-[var(--border)] object-contain"
/>
</a>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`betrag-${beleg.id}`}>Betrag</Label>
<Input
id={`betrag-${beleg.id}`}
type="number"
step="0.01"
min="0"
value={betrag}
onChange={(ereignis) => setBetrag(ereignis.target.value)}
className={lueckeKlasse(betrag)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`datum-${beleg.id}`}>Belegdatum</Label>
<Input
id={`datum-${beleg.id}`}
type="date"
value={datum}
onChange={(ereignis) => setDatum(ereignis.target.value)}
className={lueckeKlasse(datum)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`haendler-${beleg.id}`}>Händler</Label>
<Input
id={`haendler-${beleg.id}`}
value={haendler}
onChange={(ereignis) => setHaendler(ereignis.target.value)}
className={lueckeKlasse(haendler)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`kategorie-${beleg.id}`}>Kategorie</Label>
<Input
id={`kategorie-${beleg.id}`}
value={kategorie}
onChange={(ereignis) => setKategorie(ereignis.target.value)}
placeholder="Kraftstoff, Verpflegung, Material"
className={lueckeKlasse(kategorie)}
/>
</div>
</div>
</div>
<p className="text-xs text-[var(--muted-foreground)]">
Gelb hinterlegte Felder hat die Maschine nicht erkannt — bitte gegen das Foto prüfen
und eintragen. Mit dem Gegenzeichnen übernehmen Sie, was hier steht.
</p>
<div className="flex flex-wrap gap-2 border-t border-[var(--border)] pt-3">
<Button
size="sm"
onClick={() =>
bestaetigen.mutate({
id: beleg.id,
betrag: betrag ? Number(betrag) : null,
waehrung: beleg.waehrung,
belegdatum: datum ? new Date(datum) : null,
haendler: haendler || null,
kategorie: kategorie || null,
steuersatz: beleg.steuersatz === null ? null : Number(beleg.steuersatz),
})
}
disabled={!betrag || bestaetigen.isPending}
>
<Check aria-hidden />
Gegenzeichnen
</Button>
<Button
size="sm"
variant="outline"
onClick={() => verwerfen.mutate({ id: beleg.id })}
disabled={verwerfen.isPending}
>
<Trash2 aria-hidden />
Verwerfen
</Button>
</div>
</CardContent>
</Card>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { BelegListe } from "@/app/(app)/belege/beleg-liste";
export const metadata = { title: "Belege" };
/**
* Belegerfassung.
*
* Quittung fotografieren, Angaben prüfen, gegenzeichnen. Die Buchung daraus
* kommt mit der Kasse in Phase 9 — hier entsteht erst einmal ein sauber
* erfasster Beleg mit Foto.
*/
export default async function BelegeSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "beleg.erfassen")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Belege</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Quittung fotografieren, Angaben prüfen, gegenzeichnen.
</p>
</div>
<BelegListe />
</div>
);
}
@@ -39,6 +39,7 @@ import { FUNKTION_TEXT, QUELLE_TEXT } from "@/lib/einsatz/texte";
import { ZEITSTEMPEL, ZEITSTEMPEL_TEXT, type Zeitstempel } from "@/lib/einsatz/zeiten";
import { alsDatumZeit, alsUhrzeit } from "@/lib/utils";
import { taktischeEinheit } from "@/lib/einsatz/staerke";
import { Berichtsassistent } from "@/components/ki/berichtsassistent";
/**
* Einsatzdetail — die Arbeitsfläche während und nach dem Einsatz.
@@ -678,6 +679,23 @@ export function EinsatzDetail({
>
Bericht speichern
</Button>
{/* Phase 8: Diktat und Entwurf. Was hier herauskommt, landet im
Feld darüber — gespeichert wird es erst, wenn jemand
speichert. Zwei Entscheidungen, nicht eine. */}
<div className="border-t border-[var(--border)] pt-4">
<Berichtsassistent
einsatzId={einsatzId}
onUebernehmen={(text) =>
setBericht((bisher) => {
const vorhanden = bisher ?? einsatz.bericht ?? "";
return vorhanden.trim() ? `${vorhanden.trim()}
${text}` : text;
})
}
/>
</div>
</>
) : (
<p className="whitespace-pre-line text-sm">
+35
View File
@@ -0,0 +1,35 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { KiProtokoll } from "@/app/(app)/ki-protokoll/protokoll";
export const metadata = { title: "KI-Protokoll" };
/**
* Was die Maschine getan hat.
*
* Die Antwort auf die Frage, die irgendwann jemand stellt — in der
* Jahreshauptversammlung, beim Datenschutzbeauftragten der Gemeinde, oder
* wenn ein Bericht angezweifelt wird: **Was macht das Ding eigentlich?**
*
* Hier steht es. Jede Befragung, jedes Modell, jede Entscheidung, mit Namen
* und Zeitpunkt.
*/
export default async function KiProtokollSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "ki.protokoll")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">KI-Protokoll</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Jede Befragung eines Modells, was dabei herauskam und wer darüber entschieden hat.
</p>
</div>
<KiProtokoll />
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More