@@ -328,11 +328,49 @@ BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE=90
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# LLM-Anbindung (optional; nur selbstgehostete Endpunkte, kein Cloud-Zwang)
|
# 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
|
LLM_ENABLED=false
|
||||||
# OpenAI-kompatibler Endpunkt, z. B. eine lokale Ollama- oder vLLM-Instanz.
|
# OpenAI-kompatibler Endpunkt, z. B. eine lokale Ollama- oder vLLM-Instanz.
|
||||||
LLM_BASE_URL=http://ollama:11434/v1
|
LLM_BASE_URL=http://ollama:11434/v1
|
||||||
LLM_API_KEY=
|
LLM_API_KEY=
|
||||||
LLM_MODELL=qwen2.5:14b
|
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)
|
# Einsätze und Übungen (Phase 3)
|
||||||
|
|||||||
@@ -8,7 +8,113 @@ Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [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 5 — Atemschutz (FwDV 7) | fertig |
|
||||||
| Phase 6 — Kartenmodule | fertig |
|
| Phase 6 — Kartenmodule | fertig |
|
||||||
| Phase 7 — Benachrichtigungen und Termine | fertig |
|
| Phase 7 — Benachrichtigungen und Termine | fertig |
|
||||||
| Phase 8 — Lokale KI | offen |
|
| Phase 8 — Lokale KI | fertig |
|
||||||
| Phase 9 — Kassenverwaltung | offen |
|
| Phase 9 — Kassenverwaltung | offen |
|
||||||
|
|
||||||
Vollständige Planung mit Aufwänden: [docs/ROADMAP.md](docs/ROADMAP.md) und
|
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
|
## Befehle
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -1003,6 +1185,9 @@ laufen kann. Alle Werte stehen in der `.env`.
|
|||||||
| MinIO Weboberfläche | http://localhost:9011 |
|
| MinIO Weboberfläche | http://localhost:9011 |
|
||||||
| Gotenberg | http://localhost:3101 |
|
| Gotenberg | http://localhost:3101 |
|
||||||
| Mailpit (nur Entwicklung) | http://localhost:8125 |
|
| 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 |
|
| Adminer (nur Entwicklung) | http://localhost:8081 |
|
||||||
|
|
||||||
## Produktionsbetrieb
|
## Produktionsbetrieb
|
||||||
@@ -1025,9 +1210,15 @@ tut das bewusst nicht, damit nicht zwei Prozesse gleichzeitig migrieren.
|
|||||||
Schriften verwendet, die auf dem Gerät vorhanden sind.
|
Schriften verwendet, die auf dem Gerät vorhanden sind.
|
||||||
- Aufbewahrungsfristen konfigurierbar (`AUFBEWAHRUNG_JAHRE`,
|
- Aufbewahrungsfristen konfigurierbar (`AUFBEWAHRUNG_JAHRE`,
|
||||||
`AUDIT_AUFBEWAHRUNG_TAGE`, `PERSON_ANONYMISIERUNG_TAGE`).
|
`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
|
Schnittstellen in `src/lib/integrations/` und sind einzeln abschaltbar. Ohne
|
||||||
Freischaltung wird nur ins Log geschrieben.
|
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
|
- Über Kanäle, die das Netz der Wehr verlassen (Telegram, Web-Push), gehen nur
|
||||||
Kurzfassungen ohne Namen, Gerätebezeichnungen und Einsatzdetails.
|
Kurzfassungen ohne Namen, Gerätebezeichnungen und Einsatzdetails.
|
||||||
- Kein WhatsApp — die Begründung steht oben unter „Warum kein WhatsApp".
|
- 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_BASE_URL: ${LLM_BASE_URL:-}
|
||||||
LLM_API_KEY: ${LLM_API_KEY:-}
|
LLM_API_KEY: ${LLM_API_KEY:-}
|
||||||
LLM_MODELL: ${LLM_MODELL:-}
|
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}
|
GEOCODING_ENABLED: ${GEOCODING_ENABLED:-false}
|
||||||
NOMINATIM_URL: ${NOMINATIM_URL:-}
|
NOMINATIM_URL: ${NOMINATIM_URL:-}
|
||||||
GEOCODING_TIMEOUT: ${GEOCODING_TIMEOUT:-5}
|
GEOCODING_TIMEOUT: ${GEOCODING_TIMEOUT:-5}
|
||||||
@@ -144,10 +154,18 @@ services:
|
|||||||
logging: *logging
|
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:
|
postgres:
|
||||||
image: postgis/postgis:17-3.5
|
build:
|
||||||
|
context: ./docker/postgres
|
||||||
|
image: florian-postgres:${IMAGE_TAG:-17-3.5-pgvector}
|
||||||
container_name: florian-postgres
|
container_name: florian-postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
@@ -318,6 +336,82 @@ services:
|
|||||||
- intern
|
- intern
|
||||||
logging: *logging
|
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).
|
# ntfy — Push aufs Handy, ohne dass etwas das Haus verlässt (Phase 7).
|
||||||
#
|
#
|
||||||
@@ -434,6 +528,10 @@ volumes:
|
|||||||
redis-data:
|
redis-data:
|
||||||
minio-data:
|
minio-data:
|
||||||
traefik-acme:
|
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:
|
networks:
|
||||||
# Nur was von außen erreichbar sein muss, hängt hier drin.
|
# 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
|
# Läuft EINMALIG beim allerersten Start des Postgres-Containers (leeres
|
||||||
# Datenverzeichnis). Richtet ein:
|
# 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
|
# - den Anwendungsbenutzer "florian_app" OHNE BYPASSRLS
|
||||||
# - Vorgabeprivilegien, damit künftige Tabellen automatisch nutzbar sind
|
# - 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 citext; -- E-Mail ohne Gross/Klein
|
||||||
CREATE EXTENSION IF NOT EXISTS unaccent; -- Suche ueber Umlaute hinweg
|
CREATE EXTENSION IF NOT EXISTS unaccent; -- Suche ueber Umlaute hinweg
|
||||||
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- unscharfe Namenssuche
|
CREATE EXTENSION IF NOT EXISTS pg_trgm; -- unscharfe Namenssuche
|
||||||
|
CREATE EXTENSION IF NOT EXISTS vector; -- semantische Suche im Wiki
|
||||||
EOSQL
|
EOSQL
|
||||||
|
|
||||||
# Anwendungsrolle anlegen. Bewusst OHNE DO-Block: psql ersetzt seine Variablen
|
# 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
|
# 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.
|
Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt.
|
||||||
|
|
||||||
> Die lokale KI und die Kassenverwaltung kommen in den folgenden Phasen dazu.
|
> Die Kassenverwaltung kommt in Phase 9 dazu. Siehe [ROADMAP.md](ROADMAP.md).
|
||||||
> 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
|
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
|
`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 |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **Schriftführer** | Mitglieder pflegen, Einsätze und Berichte erfassen, den Terminkalender führen |
|
||||||
| **Ausbilder** | Übungen und Übungstermine planen, Teilnahme, Anwesenheit und Qualifikationen erfassen |
|
| **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 |
|
| **Terminal** | nur anmelden und erfassen — nie verwalten |
|
||||||
|
|
||||||
Zwei Dinge sind bewusst so gebaut:
|
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.
|
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,
|
Wie sich am Terminal angemeldet wird, steht in Kapitel 24. Hier geht es darum,
|
||||||
was dahinter liegt.
|
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,
|
Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
|
||||||
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich.
|
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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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:
|
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** |
|
| 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** |
|
| 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** |
|
| 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 |
|
| 9 | Kassenverwaltung | 8–11 PT | 2–3 h | offen |
|
||||||
| | **Summe** | **123–164 PT** | **28–37 h** | |
|
| | **Summe** | **123–164 PT** | **28–37 h** | |
|
||||||
|
|
||||||
@@ -368,7 +368,7 @@ Zeilen samt Maskierung und Zeilenumbruch nach 75 Oktetten.
|
|||||||
|
|
||||||
## Phase 8 — Lokale KI
|
## Phase 8 — Lokale KI
|
||||||
|
|
||||||
**Vergleichsaufwand: 9–12 PT**
|
**Vergleichsaufwand: 9–12 PT** · **Tatsächlich: 3:40 h Sitzungszeit**
|
||||||
|
|
||||||
| Baustein | PT |
|
| Baustein | PT |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
@@ -378,10 +378,28 @@ Zeilen samt Maskierung und Zeilenumbruch nach 75 Oktetten.
|
|||||||
| Wiki mit pgvector, semantische Suche, RAG mit Quellenangabe | 3,5 |
|
| Wiki mit pgvector, semantische Suche, RAG mit Quellenangabe | 3,5 |
|
||||||
| Belegerfassung über ein Vision-Modell, immer bestätigen | 2,0 |
|
| Belegerfassung über ein Vision-Modell, immer bestätigen | 2,0 |
|
||||||
|
|
||||||
Der Client aus Phase 0 (`lib/integrations/llm.ts`) ist bereits vorhanden. Die
|
Der Client aus Phase 0 (`lib/integrations/llm.ts`) war bereits vorhanden — er
|
||||||
Kennzeichnungspflicht — jede KI-Ausgabe ist als solche erkennbar und muss
|
konnte allerdings nur Text und musste um Einbettungen und Bilder erweitert
|
||||||
bestätigt werden, bevor sie in ein Dokument mit Nachweischarakter gelangt —
|
werden.
|
||||||
kostet wenig Zeit, ist aber nicht verhandelbar.
|
|
||||||
|
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
|
## 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 5 — Atemschutz nach FwDV 7 | 2:40 h | 1 | 03.09.2026 |
|
||||||
| Phase 6 — Kartenmodule | 2:50 h | 1 | 04.09.2026 |
|
| Phase 6 — Kartenmodule | 2:50 h | 1 | 04.09.2026 |
|
||||||
| Phase 7 — Benachrichtigungen und Termine | 3:10 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 5 ████████████████████ fertig 0.6.0
|
||||||
Phase 6 ████████████████████ fertig 0.7.0
|
Phase 6 ████████████████████ fertig 0.7.0
|
||||||
Phase 7 ████████████████████ fertig 0.8.0
|
Phase 7 ████████████████████ fertig 0.8.0
|
||||||
Phase 8 ░░░░░░░░░░░░░░░░░░░░ als Nächstes 0.9.0
|
Phase 8 ████████████████████ fertig 0.9.0
|
||||||
Phase 9 ░░░░░░░░░░░░░░░░░░░░ offen 0.10.0
|
Phase 9 ░░░░░░░░░░░░░░░░░░░░ als Nächstes 0.10.0
|
||||||
Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.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
|
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.
|
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
|
- Wiki mit pgvector: Dienstvorschriften und Ausbildungsunterlagen als Markdown,
|
||||||
- Berichtsassistent: aus Stichpunkten einen Entwurf, immer als Vorschlag
|
Wortsuche und semantische Suche nebeneinander, Antwort mit Fundstellenangabe
|
||||||
- Wiki mit pgvector: semantische Suche mit Quellenangabe
|
- Diktat: Aufnahme im Browser, faster-whisper, Transkript als Vorschlag
|
||||||
- Belegerfassung: Foto einer Quittung → Vorschlag für Betrag, Datum, Händler
|
- 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
|
**Danach kann die Wehr:** ihr Wissen ablegen und befragen, Berichte diktieren
|
||||||
Dokument mit Nachweischarakter gelangt.
|
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
|
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`.
|
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",
|
"name": "florian",
|
||||||
"version": "0.8.0",
|
"version": "0.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
|
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
|
||||||
"engines": {
|
"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"
|
provider = "postgresql"
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
directUrl = env("DIRECT_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]
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -217,6 +217,8 @@ model Organisation {
|
|||||||
lageelemente Lageelement[]
|
lageelemente Lageelement[]
|
||||||
wasserfoerderungen Wasserfoerderung[]
|
wasserfoerderungen Wasserfoerderung[]
|
||||||
foerderpunkte Foerderpunkt[]
|
foerderpunkte Foerderpunkt[]
|
||||||
|
|
||||||
|
// Phase 7 — Benachrichtigungen und Termine
|
||||||
benachrichtigungsvorlages Benachrichtigungsvorlage[]
|
benachrichtigungsvorlages Benachrichtigungsvorlage[]
|
||||||
zustellkanals Zustellkanal[]
|
zustellkanals Zustellkanal[]
|
||||||
benachrichtigungs Benachrichtigung[]
|
benachrichtigungs Benachrichtigung[]
|
||||||
@@ -224,6 +226,14 @@ model Organisation {
|
|||||||
terminanmeldungs Terminanmeldung[]
|
terminanmeldungs Terminanmeldung[]
|
||||||
kalenderzugangs Kalenderzugang[]
|
kalenderzugangs Kalenderzugang[]
|
||||||
|
|
||||||
|
// Phase 8 — Lokale KI
|
||||||
|
kiVorgaenge KiVorgang[]
|
||||||
|
wikiseiten Wikiseite[]
|
||||||
|
wikiabschnitte Wikiabschnitt[]
|
||||||
|
wikiversionen Wikiversion[]
|
||||||
|
diktate Diktat[]
|
||||||
|
belegentwuerfe Belegentwurf[]
|
||||||
|
|
||||||
@@index([aktiv])
|
@@index([aktiv])
|
||||||
@@map("organisation")
|
@@map("organisation")
|
||||||
}
|
}
|
||||||
@@ -337,6 +347,15 @@ model Person {
|
|||||||
terminanmeldungen Terminanmeldung[] @relation("TerminanmeldungPerson")
|
terminanmeldungen Terminanmeldung[] @relation("TerminanmeldungPerson")
|
||||||
kalenderzugaenge Kalenderzugang[] @relation("KalenderzugangPerson")
|
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
|
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
|
||||||
/// Person kann in zwei Wehren Mitglied sein.
|
/// Person kann in zwei Wehren Mitglied sein.
|
||||||
@@unique([organisationId, email])
|
@@unique([organisationId, email])
|
||||||
@@ -579,6 +598,10 @@ model Datei {
|
|||||||
alsHydrantenfoto Hydrant[] @relation("HydrantFoto")
|
alsHydrantenfoto Hydrant[] @relation("HydrantFoto")
|
||||||
alsFeuerwehrplan Objektplan[] @relation("ObjektplanDatei")
|
alsFeuerwehrplan Objektplan[] @relation("ObjektplanDatei")
|
||||||
|
|
||||||
|
// Phase 8
|
||||||
|
alsDiktataufnahme Diktat[] @relation("DiktatAufnahme")
|
||||||
|
alsBelegfoto Belegentwurf[] @relation("BelegentwurfFoto")
|
||||||
|
|
||||||
@@unique([bucket, objektSchluessel])
|
@@unique([bucket, objektSchluessel])
|
||||||
@@index([organisationId, entitaet, entitaetId])
|
@@index([organisationId, entitaet, entitaetId])
|
||||||
@@index([organisationId, kategorie])
|
@@index([organisationId, kategorie])
|
||||||
@@ -3276,3 +3299,315 @@ model Kalenderzugang {
|
|||||||
@@index([organisationId, personId])
|
@@index([organisationId, personId])
|
||||||
@@map("kalenderzugang")
|
@@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
|
* Lehrgang mit Warteliste, Arbeitsdienst, Jahreshauptversammlung, ein
|
||||||
* abgesagter Termin und eine abgehakte Anwesenheitsliste. Bewusst OHNE
|
* abgesagter Termin und eine abgehakte Anwesenheitsliste. Bewusst OHNE
|
||||||
* Zustellkanäle: niemand wird ungefragt benachrichtigt, auch nicht im Seed.
|
* 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,
|
* 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
|
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
|
||||||
@@ -62,6 +64,7 @@ import {
|
|||||||
ObjektKategorie,
|
ObjektKategorie,
|
||||||
Serienmuster,
|
Serienmuster,
|
||||||
StandortArt,
|
StandortArt,
|
||||||
|
WikiKategorie,
|
||||||
Teilnahmestand,
|
Teilnahmestand,
|
||||||
TerminArt,
|
TerminArt,
|
||||||
TruppStatus,
|
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
|
// Ablauf
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -3042,6 +3271,9 @@ async function main(): Promise<void> {
|
|||||||
// Phase 7 — Benachrichtigungen und Termine
|
// Phase 7 — Benachrichtigungen und Termine
|
||||||
await termineAnlegen(organisation.id);
|
await termineAnlegen(organisation.id);
|
||||||
|
|
||||||
|
// Phase 8 — Lokale KI
|
||||||
|
await wikiAnlegen(organisation.id);
|
||||||
|
|
||||||
log("Fertig.");
|
log("Fertig.");
|
||||||
log("");
|
log("");
|
||||||
log("Anmeldung für den Erststart:");
|
log("Anmeldung für den Erststart:");
|
||||||
|
|||||||
@@ -230,6 +230,32 @@ const AUFNAHMEN: Aufnahme[] = [
|
|||||||
beschreibung: "Digitaler Dienstausweis mit QR-Code",
|
beschreibung: "Digitaler Dienstausweis mit QR-Code",
|
||||||
angemeldet: true,
|
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 { ZEITSTEMPEL, ZEITSTEMPEL_TEXT, type Zeitstempel } from "@/lib/einsatz/zeiten";
|
||||||
import { alsDatumZeit, alsUhrzeit } from "@/lib/utils";
|
import { alsDatumZeit, alsUhrzeit } from "@/lib/utils";
|
||||||
import { taktischeEinheit } from "@/lib/einsatz/staerke";
|
import { taktischeEinheit } from "@/lib/einsatz/staerke";
|
||||||
|
import { Berichtsassistent } from "@/components/ki/berichtsassistent";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Einsatzdetail — die Arbeitsfläche während und nach dem Einsatz.
|
* Einsatzdetail — die Arbeitsfläche während und nach dem Einsatz.
|
||||||
@@ -678,6 +679,23 @@ export function EinsatzDetail({
|
|||||||
>
|
>
|
||||||
Bericht speichern
|
Bericht speichern
|
||||||
</Button>
|
</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">
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||