Phase 8: Lokale KI

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-04 11:06:03 +02:00
co-authored by Claude Opus 5
parent 12243f177d
commit f7ccd9a908
134 changed files with 8624 additions and 72 deletions
+344 -9
View File
@@ -24,7 +24,7 @@ datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_DATABASE_URL")
extensions = [postgis, pgcrypto, citext, unaccent, pg_trgm(map: "pg_trgm")]
extensions = [postgis, pgcrypto, citext, unaccent, pg_trgm(map: "pg_trgm"), vector]
}
// =============================================================================
@@ -209,14 +209,16 @@ model Organisation {
belastungsuebungen Belastungsuebung[]
// Phase 6 — Kartenmodule
hydranten Hydrant[]
objekte Objekt[]
objektkontakte Objektkontakt[]
objektplaene Objektplan[]
lagekarten Lagekarte[]
lageelemente Lageelement[]
wasserfoerderungen Wasserfoerderung[]
foerderpunkte Foerderpunkt[]
hydranten Hydrant[]
objekte Objekt[]
objektkontakte Objektkontakt[]
objektplaene Objektplan[]
lagekarten Lagekarte[]
lageelemente Lageelement[]
wasserfoerderungen Wasserfoerderung[]
foerderpunkte Foerderpunkt[]
// Phase 7 — Benachrichtigungen und Termine
benachrichtigungsvorlages Benachrichtigungsvorlage[]
zustellkanals Zustellkanal[]
benachrichtigungs Benachrichtigung[]
@@ -224,6 +226,14 @@ model Organisation {
terminanmeldungs Terminanmeldung[]
kalenderzugangs Kalenderzugang[]
// Phase 8 — Lokale KI
kiVorgaenge KiVorgang[]
wikiseiten Wikiseite[]
wikiabschnitte Wikiabschnitt[]
wikiversionen Wikiversion[]
diktate Diktat[]
belegentwuerfe Belegentwurf[]
@@index([aktiv])
@@map("organisation")
}
@@ -337,6 +347,15 @@ model Person {
terminanmeldungen Terminanmeldung[] @relation("TerminanmeldungPerson")
kalenderzugaenge Kalenderzugang[] @relation("KalenderzugangPerson")
// Phase 8 — Lokale KI
angestosseneKiVorgaenge KiVorgang[] @relation("KiVorgangErzeuger")
entschiedeneKiVorgaenge KiVorgang[] @relation("KiVorgangEntscheider")
verfassteWikiseiten Wikiseite[] @relation("WikiseiteAutor")
wikiversionen Wikiversion[] @relation("WikiversionAutor")
diktate Diktat[] @relation("DiktatErzeuger")
erfassteBelege Belegentwurf[] @relation("BelegentwurfErfasser")
bestaetigteBelege Belegentwurf[] @relation("BelegentwurfBestaetiger")
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
/// Person kann in zwei Wehren Mitglied sein.
@@unique([organisationId, email])
@@ -579,6 +598,10 @@ model Datei {
alsHydrantenfoto Hydrant[] @relation("HydrantFoto")
alsFeuerwehrplan Objektplan[] @relation("ObjektplanDatei")
// Phase 8
alsDiktataufnahme Diktat[] @relation("DiktatAufnahme")
alsBelegfoto Belegentwurf[] @relation("BelegentwurfFoto")
@@unique([bucket, objektSchluessel])
@@index([organisationId, entitaet, entitaetId])
@@index([organisationId, kategorie])
@@ -3276,3 +3299,315 @@ model Kalenderzugang {
@@index([organisationId, personId])
@@map("kalenderzugang")
}
// =============================================================================
// Phase 8 — Lokale KI
//
// Der Satz, der über allen Tabellen hier steht und der ihren Aufbau bestimmt:
// **Nichts, was ein Modell erzeugt, gelangt ungeprüft in einen Nachweis.**
//
// Daraus folgt, dass jede KI-Ausgabe zuerst ein eigener Datensatz ist und
// nicht ein Feld im Zieldokument. Ein Einsatzbericht bekommt seinen Text erst,
// wenn ein Mensch „übernehmen" gedrückt hat — bis dahin liegt der Entwurf
// daneben und ist als Entwurf erkennbar. Das ist der Unterschied zwischen
// einer Hilfe und einer Fälschung.
//
// Der zweite Grundsatz: **es läuft alles im Haus.** Ollama und faster-whisper
// als Container daneben, kein Anbieter, kein Schlüssel, keine Zeile Text, die
// das Gerätehaus verlässt. Ohne die Container fehlt die Funktion — sie wird
// nicht durch einen fremden Dienst ersetzt.
// =============================================================================
/// Wofür ein Modell befragt wurde.
enum KiArt {
/// Sprache zu Text (faster-whisper).
DIKTAT
/// Aus Stichpunkten einen Einsatzberichtsentwurf.
BERICHTSENTWURF
/// Frage ans Wiki, beantwortet aus den eigenen Seiten.
WIKI_ANTWORT
/// Quittungsfoto zu Betrag, Datum, Händler.
BELEGERFASSUNG
/// Text für die semantische Suche einbetten.
EINBETTUNG
}
/// Was aus einem Vorschlag geworden ist.
///
/// ENTWURF ist der einzige Zustand, den die Maschine allein erreicht. Alles
/// andere setzt ein Mensch — und genau deshalb steht es hier und nicht als
/// Häkchen irgendwo.
enum KiStand {
ENTWURF
UEBERNOMMEN
VERWORFEN
FEHLGESCHLAGEN
}
/// Womit eine Wiki-Seite zu tun hat.
enum WikiKategorie {
DIENSTVORSCHRIFT
AUSBILDUNG
GERAETE
EINSATZ
ORGANISATION
SONSTIGES
}
/// Jede Befragung eines Modells, mit dem, was dabei herauskam.
///
/// Die Tabelle ist der Nachweis, dass die Regel eingehalten wurde: zu jedem
/// Text, der aus einem Modell stammt, steht hier, wer ihn wann übernommen oder
/// verworfen hat. Ohne sie ließe sich hinterher nicht mehr sagen, welcher Satz
/// in einem Einsatzbericht von einem Menschen stammt.
///
/// Gespeichert wird auch, was nicht geklappt hat. Ein Modell, das dreimal am
/// Tag in eine Zeitüberschreitung läuft, ist eine Information.
model KiVorgang {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
art KiArt
stand KiStand @default(ENTWURF)
/// Welches Modell — bei einem Wechsel soll nachvollziehbar bleiben, womit
/// ein alter Entwurf entstanden ist.
modell String @db.VarChar(120)
/// Worauf sich der Vorgang bezieht: "Einsatz", "Uebung", "Wikiseite".
entitaet String? @db.VarChar(80)
entitaetId String? @db.Uuid
/// Die Eingabe, gekürzt. Nicht das ganze Audio, nicht das ganze Foto —
/// die liegen als Datei in MinIO.
eingabe String @db.VarChar(8000)
ausgabe String? @db.VarChar(40000)
fehler String? @db.VarChar(1000)
dauerMs Int?
tokenEingabe Int?
tokenAusgabe Int?
erzeugtVonId String? @db.Uuid
/// Wer entschieden hat — und wann. Beides leer heißt: noch offen.
entschiedenVonId String? @db.Uuid
entschiedenAm DateTime? @db.Timestamptz(3)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
erzeugtVon Person? @relation("KiVorgangErzeuger", fields: [erzeugtVonId], references: [id], onDelete: SetNull)
entschiedenVon Person? @relation("KiVorgangEntscheider", fields: [entschiedenVonId], references: [id], onDelete: SetNull)
diktate Diktat[]
belegentwuerfe Belegentwurf[]
@@index([organisationId, art, erstelltAm(sort: Desc)])
@@index([organisationId, entitaet, entitaetId])
@@index([organisationId, stand])
@@map("ki_vorgang")
}
/// Eine Seite im Wiki der Wehr.
///
/// Inhalt ist Markdown, weil es sich ohne Werkzeug lesen lässt — auch in
/// zwanzig Jahren, auch ohne FLORIAN. Dienstvorschriften und
/// Ausbildungsunterlagen sollen die Anwendung überleben, die sie verwaltet.
model Wikiseite {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
slug String @db.VarChar(160)
titel String @db.VarChar(200)
art WikiKategorie @default(SONSTIGES)
inhalt String @db.Text
/// Woher es stammt: "FwDV 7, Ausgabe 2002", "Beschluss der Kommandanten".
/// Steht unter jeder Antwort, die aus dieser Seite gebaut wird.
quelle String? @db.VarChar(300)
schlagworte String[]
/// Unveröffentlichte Seiten sind Entwürfe: sie werden nicht durchsucht und
/// nicht zum Beantworten herangezogen.
veroeffentlicht Boolean @default(true)
autorId String? @db.Uuid
/// Wann die Abschnitte zuletzt eingebettet wurden. Liegt es vor
/// `geaendertAm`, ist die Seite seit der letzten Änderung nicht mehr
/// durchsuchbar — und das steht dann auch in der Ansicht.
eingebettetAm DateTime? @db.Timestamptz(3)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
autor Person? @relation("WikiseiteAutor", fields: [autorId], references: [id], onDelete: SetNull)
abschnitte Wikiabschnitt[]
versionen Wikiversion[]
@@unique([organisationId, slug])
@@index([organisationId, art])
@@map("wikiseite")
}
/// Ein Abschnitt einer Wiki-Seite samt seiner Einbettung.
///
/// Zerlegt wird an den Überschriften, nicht nach Zeichenzahl: ein Abschnitt
/// soll für sich verständlich sein, weil er für sich zitiert wird. Wer eine
/// Antwort bekommt, bekommt die Fundstelle dazu — und die ist dann ein
/// Kapitel, kein aus der Mitte gerissener Absatz.
///
/// `einbettung` ist für Prisma ein unbekannter Typ; gelesen und geschrieben
/// wird sie über `$queryRaw`. Das ist der übliche Weg mit pgvector und der
/// Grund, warum es in lib/ki/ eine eigene Datei dafür gibt.
model Wikiabschnitt {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
seiteId String @db.Uuid
reihenfolge Int
ueberschrift String? @db.VarChar(300)
text String @db.Text
/// 768 Dimensionen — das liefert nomic-embed-text, das Standardmodell für
/// Einbettungen unter Ollama. Ein anderes Modell mit anderer Länge braucht
/// eine Migration; das steht im README.
einbettung Unsupported("vector(768)")?
/// Womit eingebettet wurde. Nach einem Modellwechsel sind alte Einbettungen
/// nicht mehr vergleichbar und werden neu erzeugt.
modell String? @db.VarChar(120)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
seite Wikiseite @relation(fields: [seiteId], references: [id], onDelete: Cascade)
@@unique([seiteId, reihenfolge])
@@index([organisationId])
@@map("wikiabschnitt")
}
/// Der Stand einer Seite vor einer Änderung.
///
/// Eine Dienstvorschrift, die sich unbemerkt ändert, ist schlimmer als keine.
/// Deshalb wird bei jeder Änderung der vorherige Text weggeschrieben — schlicht
/// und vollständig, nicht als Differenz.
model Wikiversion {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
seiteId String @db.Uuid
nummer Int
titel String @db.VarChar(200)
inhalt String @db.Text
/// Was der Bearbeiter dazu gesagt hat.
bemerkung String? @db.VarChar(500)
autorId String? @db.Uuid
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
seite Wikiseite @relation(fields: [seiteId], references: [id], onDelete: Cascade)
autor Person? @relation("WikiversionAutor", fields: [autorId], references: [id], onDelete: SetNull)
@@unique([seiteId, nummer])
@@index([organisationId, seiteId])
@@map("wikiversion")
}
/// Eine Aufnahme und was daraus wurde.
///
/// Gedacht für den Weg, den es heute schon gibt: der Einsatzleiter spricht auf
/// der Rückfahrt in sein Telefon, und daraus wird abends ein Bericht. Nur dass
/// jetzt schon ein Text dasteht, den jemand nur noch geradezieht.
///
/// Die Aufnahme bleibt liegen, bis sie jemand entfernt: solange das Transkript
/// noch nicht bestätigt ist, ist sie das einzige Original.
model Diktat {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
/// Die Aufnahme in MinIO. Nie ein Pfad im Dateisystem.
audioDateiId String @db.Uuid
dauerSekunden Int?
sprache String @default("de") @db.VarChar(10)
transkript String? @db.Text
stand KiStand @default(ENTWURF)
/// Wohin der Text soll, wenn er übernommen wird.
entitaet String? @db.VarChar(80)
entitaetId String? @db.Uuid
kiVorgangId String? @db.Uuid
erzeugtVonId String? @db.Uuid
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
audioDatei Datei @relation("DiktatAufnahme", fields: [audioDateiId], references: [id], onDelete: Restrict)
kiVorgang KiVorgang? @relation(fields: [kiVorgangId], references: [id], onDelete: SetNull)
erzeugtVon Person? @relation("DiktatErzeuger", fields: [erzeugtVonId], references: [id], onDelete: SetNull)
@@index([organisationId, stand])
@@index([organisationId, entitaet, entitaetId])
@@map("diktat")
}
/// Was ein Modell auf einer Quittung gelesen hat — als Vorschlag.
///
/// Bewusst eine eigene Tabelle und keine Buchung: die Kasse kommt in Phase 9,
/// und ein Betrag, den eine Maschine aus einem Foto gelesen hat, ist keine
/// Buchung. Er ist ein ausgefülltes Formular, das jemand gegenzeichnet.
///
/// Alle Felder sind optional. Ein Modell, das den Betrag nicht findet, soll
/// nichts erfinden — dann bleibt das Feld leer und ein Mensch trägt es ein.
model Belegentwurf {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
fotoDateiId String @db.Uuid
betrag Decimal? @db.Decimal(12, 2)
waehrung String @default("EUR") @db.VarChar(3)
belegdatum DateTime? @db.Date
haendler String? @db.VarChar(200)
kategorie String? @db.VarChar(80)
/// Steuersatz in Prozent, falls erkannt.
steuersatz Decimal? @db.Decimal(5, 2)
/// Was das Modell geantwortet hat, unverändert. Für den Fall, dass die
/// Auswertung daneben lag und jemand nachsehen will, woran es lag.
rohantwort Json?
stand KiStand @default(ENTWURF)
kiVorgangId String? @db.Uuid
erfasstVonId String? @db.Uuid
bestaetigtVonId String? @db.Uuid
bestaetigtAm DateTime? @db.Timestamptz(3)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
fotoDatei Datei @relation("BelegentwurfFoto", fields: [fotoDateiId], references: [id], onDelete: Restrict)
kiVorgang KiVorgang? @relation(fields: [kiVorgangId], references: [id], onDelete: SetNull)
erfasstVon Person? @relation("BelegentwurfErfasser", fields: [erfasstVonId], references: [id], onDelete: SetNull)
bestaetigtVon Person? @relation("BelegentwurfBestaetiger", fields: [bestaetigtVonId], references: [id], onDelete: SetNull)
@@index([organisationId, stand])
@@index([organisationId, belegdatum])
@@map("belegentwurf")
}