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>
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
Before Width: | Height: | Size: 224 KiB After Width: | Height: | Size: 223 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 262 KiB After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 431 KiB After Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 438 KiB After Width: | Height: | Size: 438 KiB |
|
Before Width: | Height: | Size: 808 KiB After Width: | Height: | Size: 808 KiB |
|
Before Width: | Height: | Size: 831 KiB After Width: | Height: | Size: 830 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 324 KiB After Width: | Height: | Size: 323 KiB |
|
Before Width: | Height: | Size: 333 KiB After Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 988 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 228 KiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 273 KiB |
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 888 KiB After Width: | Height: | Size: 930 KiB |
|
Before Width: | Height: | Size: 904 KiB After Width: | Height: | Size: 946 KiB |
|
Before Width: | Height: | Size: 288 KiB After Width: | Height: | Size: 287 KiB |
|
Before Width: | Height: | Size: 248 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 260 KiB |
|
Before Width: | Height: | Size: 266 KiB After Width: | Height: | Size: 265 KiB |
|
Before Width: | Height: | Size: 719 KiB After Width: | Height: | Size: 760 KiB |
|
Before Width: | Height: | Size: 724 KiB After Width: | Height: | Size: 766 KiB |
|
Before Width: | Height: | Size: 378 KiB After Width: | Height: | Size: 378 KiB |
|
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 263 KiB |
|
Before Width: | Height: | Size: 539 KiB After Width: | Height: | Size: 559 KiB |
|
Before Width: | Height: | Size: 541 KiB After Width: | Height: | Size: 563 KiB |
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 307 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 260 KiB |
|
Before Width: | Height: | Size: 317 KiB After Width: | Height: | Size: 316 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 268 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 204 KiB After Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 242 KiB |
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 888 KiB After Width: | Height: | Size: 991 KiB |
|
Before Width: | Height: | Size: 907 KiB After Width: | Height: | Size: 1012 KiB |
|
Before Width: | Height: | Size: 368 KiB After Width: | Height: | Size: 410 KiB |
|
Before Width: | Height: | Size: 261 KiB After Width: | Height: | Size: 242 KiB |
|
Before Width: | Height: | Size: 234 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 333 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 173 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 329 KiB |
@@ -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();
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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:");
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||