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
@@ -0,0 +1,242 @@
-- =============================================================================
-- Phase 8 — Lokale KI
--
-- HINWEIS zu den entfernten DropIndex-Blöcken:
-- Prisma erzeugt bei jeder Migration erneut den Vorschlag, die von Hand
-- angelegten GiST- und Trigram-Indizes aus Phase 1 abzuräumen — es kennt sie
-- nicht, weil sie nicht aus dem Schema stammen, sondern aus einer eigenen
-- Migration. Die Blöcke sind deshalb hier entfernt worden, wie in jeder
-- Migration seit Phase 1. Ohne sie wäre die Volltext- und Umkreissuche nach
-- diesem Schritt ein vollständiger Tabellendurchlauf.
--
-- Neu ist die Erweiterung "vector": sie kommt aus dem eigenen Postgres-Image
-- (docker/postgres/Dockerfile). Bei einer bestehenden Installation muss es
-- vorher gebaut werden — `docker compose build postgres`, dann den Dienst neu
-- starten. Ohne die Erweiterung bricht diese Migration mit einer klaren
-- Meldung ab, und das ist besser als eine halb angelegte Tabelle.
-- =============================================================================
-- CreateExtension
CREATE EXTENSION IF NOT EXISTS "vector";
-- CreateEnum
CREATE TYPE "KiArt" AS ENUM ('DIKTAT', 'BERICHTSENTWURF', 'WIKI_ANTWORT', 'BELEGERFASSUNG', 'EINBETTUNG');
-- CreateEnum
CREATE TYPE "KiStand" AS ENUM ('ENTWURF', 'UEBERNOMMEN', 'VERWORFEN', 'FEHLGESCHLAGEN');
-- CreateEnum
CREATE TYPE "WikiKategorie" AS ENUM ('DIENSTVORSCHRIFT', 'AUSBILDUNG', 'GERAETE', 'EINSATZ', 'ORGANISATION', 'SONSTIGES');
-- CreateTable
CREATE TABLE "ki_vorgang" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"art" "KiArt" NOT NULL,
"stand" "KiStand" NOT NULL DEFAULT 'ENTWURF',
"modell" VARCHAR(120) NOT NULL,
"entitaet" VARCHAR(80),
"entitaetId" UUID,
"eingabe" VARCHAR(8000) NOT NULL,
"ausgabe" VARCHAR(40000),
"fehler" VARCHAR(1000),
"dauerMs" INTEGER,
"tokenEingabe" INTEGER,
"tokenAusgabe" INTEGER,
"erzeugtVonId" UUID,
"entschiedenVonId" UUID,
"entschiedenAm" TIMESTAMPTZ(3),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ki_vorgang_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "wikiseite" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"slug" VARCHAR(160) NOT NULL,
"titel" VARCHAR(200) NOT NULL,
"art" "WikiKategorie" NOT NULL DEFAULT 'SONSTIGES',
"inhalt" TEXT NOT NULL,
"quelle" VARCHAR(300),
"schlagworte" TEXT[],
"veroeffentlicht" BOOLEAN NOT NULL DEFAULT true,
"autorId" UUID,
"eingebettetAm" TIMESTAMPTZ(3),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "wikiseite_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "wikiabschnitt" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"seiteId" UUID NOT NULL,
"reihenfolge" INTEGER NOT NULL,
"ueberschrift" VARCHAR(300),
"text" TEXT NOT NULL,
"einbettung" vector(768),
"modell" VARCHAR(120),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "wikiabschnitt_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "wikiversion" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"seiteId" UUID NOT NULL,
"nummer" INTEGER NOT NULL,
"titel" VARCHAR(200) NOT NULL,
"inhalt" TEXT NOT NULL,
"bemerkung" VARCHAR(500),
"autorId" UUID,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "wikiversion_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "diktat" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"audioDateiId" UUID NOT NULL,
"dauerSekunden" INTEGER,
"sprache" VARCHAR(10) NOT NULL DEFAULT 'de',
"transkript" TEXT,
"stand" "KiStand" NOT NULL DEFAULT 'ENTWURF',
"entitaet" VARCHAR(80),
"entitaetId" UUID,
"kiVorgangId" UUID,
"erzeugtVonId" UUID,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "diktat_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "belegentwurf" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"fotoDateiId" UUID NOT NULL,
"betrag" DECIMAL(12,2),
"waehrung" VARCHAR(3) NOT NULL DEFAULT 'EUR',
"belegdatum" DATE,
"haendler" VARCHAR(200),
"kategorie" VARCHAR(80),
"steuersatz" DECIMAL(5,2),
"rohantwort" JSONB,
"stand" "KiStand" NOT NULL DEFAULT 'ENTWURF',
"kiVorgangId" UUID,
"erfasstVonId" UUID,
"bestaetigtVonId" UUID,
"bestaetigtAm" TIMESTAMPTZ(3),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "belegentwurf_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ki_vorgang_organisationId_art_erstelltAm_idx" ON "ki_vorgang"("organisationId", "art", "erstelltAm" DESC);
-- CreateIndex
CREATE INDEX "ki_vorgang_organisationId_entitaet_entitaetId_idx" ON "ki_vorgang"("organisationId", "entitaet", "entitaetId");
-- CreateIndex
CREATE INDEX "ki_vorgang_organisationId_stand_idx" ON "ki_vorgang"("organisationId", "stand");
-- CreateIndex
CREATE INDEX "wikiseite_organisationId_art_idx" ON "wikiseite"("organisationId", "art");
-- CreateIndex
CREATE UNIQUE INDEX "wikiseite_organisationId_slug_key" ON "wikiseite"("organisationId", "slug");
-- CreateIndex
CREATE INDEX "wikiabschnitt_organisationId_idx" ON "wikiabschnitt"("organisationId");
-- CreateIndex
CREATE UNIQUE INDEX "wikiabschnitt_seiteId_reihenfolge_key" ON "wikiabschnitt"("seiteId", "reihenfolge");
-- CreateIndex
CREATE INDEX "wikiversion_organisationId_seiteId_idx" ON "wikiversion"("organisationId", "seiteId");
-- CreateIndex
CREATE UNIQUE INDEX "wikiversion_seiteId_nummer_key" ON "wikiversion"("seiteId", "nummer");
-- CreateIndex
CREATE INDEX "diktat_organisationId_stand_idx" ON "diktat"("organisationId", "stand");
-- CreateIndex
CREATE INDEX "diktat_organisationId_entitaet_entitaetId_idx" ON "diktat"("organisationId", "entitaet", "entitaetId");
-- CreateIndex
CREATE INDEX "belegentwurf_organisationId_stand_idx" ON "belegentwurf"("organisationId", "stand");
-- CreateIndex
CREATE INDEX "belegentwurf_organisationId_belegdatum_idx" ON "belegentwurf"("organisationId", "belegdatum");
-- AddForeignKey
ALTER TABLE "ki_vorgang" ADD CONSTRAINT "ki_vorgang_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ki_vorgang" ADD CONSTRAINT "ki_vorgang_erzeugtVonId_fkey" FOREIGN KEY ("erzeugtVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ki_vorgang" ADD CONSTRAINT "ki_vorgang_entschiedenVonId_fkey" FOREIGN KEY ("entschiedenVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wikiseite" ADD CONSTRAINT "wikiseite_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wikiseite" ADD CONSTRAINT "wikiseite_autorId_fkey" FOREIGN KEY ("autorId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wikiabschnitt" ADD CONSTRAINT "wikiabschnitt_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wikiabschnitt" ADD CONSTRAINT "wikiabschnitt_seiteId_fkey" FOREIGN KEY ("seiteId") REFERENCES "wikiseite"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wikiversion" ADD CONSTRAINT "wikiversion_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wikiversion" ADD CONSTRAINT "wikiversion_seiteId_fkey" FOREIGN KEY ("seiteId") REFERENCES "wikiseite"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "wikiversion" ADD CONSTRAINT "wikiversion_autorId_fkey" FOREIGN KEY ("autorId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "diktat" ADD CONSTRAINT "diktat_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "diktat" ADD CONSTRAINT "diktat_audioDateiId_fkey" FOREIGN KEY ("audioDateiId") REFERENCES "datei"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "diktat" ADD CONSTRAINT "diktat_kiVorgangId_fkey" FOREIGN KEY ("kiVorgangId") REFERENCES "ki_vorgang"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "diktat" ADD CONSTRAINT "diktat_erzeugtVonId_fkey" FOREIGN KEY ("erzeugtVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "belegentwurf" ADD CONSTRAINT "belegentwurf_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "belegentwurf" ADD CONSTRAINT "belegentwurf_fotoDateiId_fkey" FOREIGN KEY ("fotoDateiId") REFERENCES "datei"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "belegentwurf" ADD CONSTRAINT "belegentwurf_kiVorgangId_fkey" FOREIGN KEY ("kiVorgangId") REFERENCES "ki_vorgang"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "belegentwurf" ADD CONSTRAINT "belegentwurf_erfasstVonId_fkey" FOREIGN KEY ("erfasstVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "belegentwurf" ADD CONSTRAINT "belegentwurf_bestaetigtVonId_fkey" FOREIGN KEY ("bestaetigtVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,182 @@
-- =============================================================================
-- Phase 8 — Mandantentrennung, Prüfbedingungen und der Vektorindex
--
-- Drei Dinge, die nicht im Anwendungscode stehen dürfen:
--
-- 1. Row Level Security für die sechs neuen Tabellen. Wie überall: ohne
-- gesetztes app.current_organisation liefert die Anwendungsrolle nichts.
-- 2. Prüfbedingungen für das, was fachlich unmöglich ist — ein negativer
-- Betrag auf einer Quittung, eine Entscheidung ohne Entscheider.
-- 3. Der Vektorindex für die semantische Suche. Ohne ihn wäre jede Frage ans
-- Wiki ein vollständiger Tabellendurchlauf über alle Abschnitte.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- Row Level Security
-- -----------------------------------------------------------------------------
DO $$
DECLARE
tabelle text;
BEGIN
FOREACH tabelle IN ARRAY ARRAY[
'ki_vorgang',
'wikiseite',
'wikiabschnitt',
'wikiversion',
'diktat',
'belegentwurf'
]
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle);
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', tabelle);
EXECUTE format('DROP POLICY IF EXISTS %I ON %I', tabelle || '_mandant', tabelle);
EXECUTE format(
'CREATE POLICY %I ON %I FOR ALL '
'USING ("organisationId" = aktuelle_organisation()) '
'WITH CHECK ("organisationId" = aktuelle_organisation())',
tabelle || '_mandant', tabelle
);
END LOOP;
END $$;
-- -----------------------------------------------------------------------------
-- Prüfbedingungen
-- -----------------------------------------------------------------------------
-- Eine Entscheidung braucht einen Zeitpunkt und umgekehrt. Ein Vorgang, der
-- als übernommen gilt, ohne dass jemand ihn übernommen hat, wäre genau die
-- Lücke, die diese Tabelle schließen soll.
ALTER TABLE "ki_vorgang" ADD CONSTRAINT "ki_vorgang_entscheidung_vollstaendig" CHECK (
("stand" IN ('ENTWURF', 'FEHLGESCHLAGEN') AND "entschiedenAm" IS NULL)
OR ("stand" IN ('UEBERNOMMEN', 'VERWORFEN') AND "entschiedenAm" IS NOT NULL)
);
-- Ein fehlgeschlagener Vorgang hat einen Grund. Sonst steht dort "ging nicht"
-- und niemand weiß, woran es lag.
ALTER TABLE "ki_vorgang" ADD CONSTRAINT "ki_vorgang_fehler_begruendet" CHECK (
"stand" <> 'FEHLGESCHLAGEN' OR "fehler" IS NOT NULL
);
ALTER TABLE "ki_vorgang" ADD CONSTRAINT "ki_vorgang_dauer_plausibel" CHECK (
"dauerMs" IS NULL OR "dauerMs" >= 0
);
-- Eine Quittung über einen negativen Betrag gibt es nicht. Eine Gutschrift ist
-- ein eigener Vorgang und kommt mit der Kasse in Phase 9.
ALTER TABLE "belegentwurf" ADD CONSTRAINT "belegentwurf_betrag_positiv" CHECK (
"betrag" IS NULL OR "betrag" >= 0
);
ALTER TABLE "belegentwurf" ADD CONSTRAINT "belegentwurf_steuersatz_plausibel" CHECK (
"steuersatz" IS NULL OR ("steuersatz" >= 0 AND "steuersatz" <= 100)
);
ALTER TABLE "diktat" ADD CONSTRAINT "diktat_dauer_plausibel" CHECK (
"dauerSekunden" IS NULL OR "dauerSekunden" >= 0
);
ALTER TABLE "wikiversion" ADD CONSTRAINT "wikiversion_nummer_positiv" CHECK ("nummer" > 0);
-- -----------------------------------------------------------------------------
-- Suche
-- -----------------------------------------------------------------------------
-- Der Vektorindex.
--
-- HNSW und nicht IVFFlat: IVFFlat muss auf einem gefüllten Bestand trainiert
-- werden und liefert auf einer frisch angelegten Tabelle schlechte Treffer.
-- HNSW ist ab der ersten Zeile brauchbar — und ein Wiki wächst Seite für
-- Seite, es gibt nie den Moment, an dem man den Index einmal aufbaut.
--
-- Kosinusabstand, weil die Einbettungen normalisiert sind und es dann dasselbe
-- misst wie das Skalarprodukt, aber unabhängig von der Länge des Vektors.
CREATE INDEX IF NOT EXISTS "wikiabschnitt_einbettung_idx"
ON "wikiabschnitt" USING hnsw ("einbettung" vector_cosine_ops);
-- Volltext daneben. Die semantische Suche findet, was gemeint ist; die
-- Wortsuche findet, was dasteht. Wer "FwDV 7" eingibt, will die Seite mit
-- genau dieser Zeichenfolge — kein Modell der Welt ist da besser als LIKE.
CREATE INDEX IF NOT EXISTS "wikiseite_suche_titel"
ON "wikiseite" USING gin ("titel" gin_trgm_ops);
CREATE INDEX IF NOT EXISTS "wikiseite_suche_inhalt"
ON "wikiseite" USING gin ("inhalt" gin_trgm_ops);
-- Nur die veröffentlichten Seiten werden durchsucht und beantwortet.
CREATE INDEX IF NOT EXISTS "wikiseite_veroeffentlicht"
ON "wikiseite" ("organisationId", "art")
WHERE "veroeffentlicht" = true AND "geloeschtAm" IS NULL;
-- Offene Vorschläge — die Liste, die jemand abarbeitet.
CREATE INDEX IF NOT EXISTS "ki_vorgang_offen"
ON "ki_vorgang" ("organisationId", "erstelltAm" DESC)
WHERE "stand" = 'ENTWURF';
-- -----------------------------------------------------------------------------
-- Rechte
-- -----------------------------------------------------------------------------
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'florian_app') THEN
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app';
EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app';
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "kiosk_vorgang" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "druckmessung" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "atemschutz_ereignis" FROM florian_app';
EXECUTE 'REVOKE DELETE ON "benachrichtigung" FROM florian_app';
-- Ein KI-Vorgang lässt sich nicht löschen und nicht umschreiben, was
-- die Sache selbst betrifft. Geändert werden darf nur die
-- Entscheidung — deshalb kein REVOKE UPDATE, sondern der Trigger
-- darunter, der die Ausgabe festhält.
EXECUTE 'REVOKE DELETE ON "ki_vorgang" FROM florian_app';
-- Eine einmal weggeschriebene Fassung einer Wiki-Seite bleibt. Eine
-- Dienstvorschrift, deren Vorgeschichte sich bereinigen lässt, ist
-- als Nachweis wertlos.
EXECUTE 'REVOKE UPDATE, DELETE ON "wikiversion" FROM florian_app';
END IF;
END $$;
-- -----------------------------------------------------------------------------
-- Was ein Modell erzeugt hat, bleibt, wie es war
-- -----------------------------------------------------------------------------
-- Der Trigger schützt genau das, worauf es ankommt: die Ausgabe des Modells
-- und die Angabe, welches Modell sie erzeugt hat. Wer einen Entwurf
-- nachbessern will, tut das im Zieldokument — nicht im Vorgang, der belegen
-- soll, was die Maschine wirklich geliefert hat.
--
-- Die Entscheidung (stand, entschiedenVonId, entschiedenAm) darf sich ändern,
-- aber nur einmal: von ENTWURF weg, nicht wieder zurück.
CREATE OR REPLACE FUNCTION ki_vorgang_unveraenderlich()
RETURNS TRIGGER AS $$
BEGIN
IF NEW."ausgabe" IS DISTINCT FROM OLD."ausgabe"
OR NEW."eingabe" IS DISTINCT FROM OLD."eingabe"
OR NEW."modell" IS DISTINCT FROM OLD."modell"
OR NEW."art" IS DISTINCT FROM OLD."art" THEN
RAISE EXCEPTION
'Ein KI-Vorgang haelt fest, was das Modell geliefert hat. Eingabe, '
'Ausgabe, Modell und Art lassen sich nicht nachtraeglich aendern.';
END IF;
IF OLD."stand" <> 'ENTWURF' AND NEW."stand" <> OLD."stand" THEN
RAISE EXCEPTION
'Ueber diesen Vorschlag wurde bereits entschieden (%). Eine zweite '
'Entscheidung waere nicht nachvollziehbar.', OLD."stand";
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS ki_vorgang_unveraenderlich_trigger ON "ki_vorgang";
CREATE TRIGGER ki_vorgang_unveraenderlich_trigger
BEFORE UPDATE ON "ki_vorgang"
FOR EACH ROW
EXECUTE FUNCTION ki_vorgang_unveraenderlich();
+344 -9
View File
@@ -24,7 +24,7 @@ datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_DATABASE_URL")
extensions = [postgis, pgcrypto, citext, unaccent, pg_trgm(map: "pg_trgm")]
extensions = [postgis, pgcrypto, citext, unaccent, pg_trgm(map: "pg_trgm"), vector]
}
// =============================================================================
@@ -209,14 +209,16 @@ model Organisation {
belastungsuebungen Belastungsuebung[]
// Phase 6 — Kartenmodule
hydranten Hydrant[]
objekte Objekt[]
objektkontakte Objektkontakt[]
objektplaene Objektplan[]
lagekarten Lagekarte[]
lageelemente Lageelement[]
wasserfoerderungen Wasserfoerderung[]
foerderpunkte Foerderpunkt[]
hydranten Hydrant[]
objekte Objekt[]
objektkontakte Objektkontakt[]
objektplaene Objektplan[]
lagekarten Lagekarte[]
lageelemente Lageelement[]
wasserfoerderungen Wasserfoerderung[]
foerderpunkte Foerderpunkt[]
// Phase 7 — Benachrichtigungen und Termine
benachrichtigungsvorlages Benachrichtigungsvorlage[]
zustellkanals Zustellkanal[]
benachrichtigungs Benachrichtigung[]
@@ -224,6 +226,14 @@ model Organisation {
terminanmeldungs Terminanmeldung[]
kalenderzugangs Kalenderzugang[]
// Phase 8 — Lokale KI
kiVorgaenge KiVorgang[]
wikiseiten Wikiseite[]
wikiabschnitte Wikiabschnitt[]
wikiversionen Wikiversion[]
diktate Diktat[]
belegentwuerfe Belegentwurf[]
@@index([aktiv])
@@map("organisation")
}
@@ -337,6 +347,15 @@ model Person {
terminanmeldungen Terminanmeldung[] @relation("TerminanmeldungPerson")
kalenderzugaenge Kalenderzugang[] @relation("KalenderzugangPerson")
// Phase 8 — Lokale KI
angestosseneKiVorgaenge KiVorgang[] @relation("KiVorgangErzeuger")
entschiedeneKiVorgaenge KiVorgang[] @relation("KiVorgangEntscheider")
verfassteWikiseiten Wikiseite[] @relation("WikiseiteAutor")
wikiversionen Wikiversion[] @relation("WikiversionAutor")
diktate Diktat[] @relation("DiktatErzeuger")
erfassteBelege Belegentwurf[] @relation("BelegentwurfErfasser")
bestaetigteBelege Belegentwurf[] @relation("BelegentwurfBestaetiger")
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
/// Person kann in zwei Wehren Mitglied sein.
@@unique([organisationId, email])
@@ -579,6 +598,10 @@ model Datei {
alsHydrantenfoto Hydrant[] @relation("HydrantFoto")
alsFeuerwehrplan Objektplan[] @relation("ObjektplanDatei")
// Phase 8
alsDiktataufnahme Diktat[] @relation("DiktatAufnahme")
alsBelegfoto Belegentwurf[] @relation("BelegentwurfFoto")
@@unique([bucket, objektSchluessel])
@@index([organisationId, entitaet, entitaetId])
@@index([organisationId, kategorie])
@@ -3276,3 +3299,315 @@ model Kalenderzugang {
@@index([organisationId, personId])
@@map("kalenderzugang")
}
// =============================================================================
// Phase 8 — Lokale KI
//
// Der Satz, der über allen Tabellen hier steht und der ihren Aufbau bestimmt:
// **Nichts, was ein Modell erzeugt, gelangt ungeprüft in einen Nachweis.**
//
// Daraus folgt, dass jede KI-Ausgabe zuerst ein eigener Datensatz ist und
// nicht ein Feld im Zieldokument. Ein Einsatzbericht bekommt seinen Text erst,
// wenn ein Mensch „übernehmen" gedrückt hat — bis dahin liegt der Entwurf
// daneben und ist als Entwurf erkennbar. Das ist der Unterschied zwischen
// einer Hilfe und einer Fälschung.
//
// Der zweite Grundsatz: **es läuft alles im Haus.** Ollama und faster-whisper
// als Container daneben, kein Anbieter, kein Schlüssel, keine Zeile Text, die
// das Gerätehaus verlässt. Ohne die Container fehlt die Funktion — sie wird
// nicht durch einen fremden Dienst ersetzt.
// =============================================================================
/// Wofür ein Modell befragt wurde.
enum KiArt {
/// Sprache zu Text (faster-whisper).
DIKTAT
/// Aus Stichpunkten einen Einsatzberichtsentwurf.
BERICHTSENTWURF
/// Frage ans Wiki, beantwortet aus den eigenen Seiten.
WIKI_ANTWORT
/// Quittungsfoto zu Betrag, Datum, Händler.
BELEGERFASSUNG
/// Text für die semantische Suche einbetten.
EINBETTUNG
}
/// Was aus einem Vorschlag geworden ist.
///
/// ENTWURF ist der einzige Zustand, den die Maschine allein erreicht. Alles
/// andere setzt ein Mensch — und genau deshalb steht es hier und nicht als
/// Häkchen irgendwo.
enum KiStand {
ENTWURF
UEBERNOMMEN
VERWORFEN
FEHLGESCHLAGEN
}
/// Womit eine Wiki-Seite zu tun hat.
enum WikiKategorie {
DIENSTVORSCHRIFT
AUSBILDUNG
GERAETE
EINSATZ
ORGANISATION
SONSTIGES
}
/// Jede Befragung eines Modells, mit dem, was dabei herauskam.
///
/// Die Tabelle ist der Nachweis, dass die Regel eingehalten wurde: zu jedem
/// Text, der aus einem Modell stammt, steht hier, wer ihn wann übernommen oder
/// verworfen hat. Ohne sie ließe sich hinterher nicht mehr sagen, welcher Satz
/// in einem Einsatzbericht von einem Menschen stammt.
///
/// Gespeichert wird auch, was nicht geklappt hat. Ein Modell, das dreimal am
/// Tag in eine Zeitüberschreitung läuft, ist eine Information.
model KiVorgang {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
art KiArt
stand KiStand @default(ENTWURF)
/// Welches Modell — bei einem Wechsel soll nachvollziehbar bleiben, womit
/// ein alter Entwurf entstanden ist.
modell String @db.VarChar(120)
/// Worauf sich der Vorgang bezieht: "Einsatz", "Uebung", "Wikiseite".
entitaet String? @db.VarChar(80)
entitaetId String? @db.Uuid
/// Die Eingabe, gekürzt. Nicht das ganze Audio, nicht das ganze Foto —
/// die liegen als Datei in MinIO.
eingabe String @db.VarChar(8000)
ausgabe String? @db.VarChar(40000)
fehler String? @db.VarChar(1000)
dauerMs Int?
tokenEingabe Int?
tokenAusgabe Int?
erzeugtVonId String? @db.Uuid
/// Wer entschieden hat — und wann. Beides leer heißt: noch offen.
entschiedenVonId String? @db.Uuid
entschiedenAm DateTime? @db.Timestamptz(3)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
erzeugtVon Person? @relation("KiVorgangErzeuger", fields: [erzeugtVonId], references: [id], onDelete: SetNull)
entschiedenVon Person? @relation("KiVorgangEntscheider", fields: [entschiedenVonId], references: [id], onDelete: SetNull)
diktate Diktat[]
belegentwuerfe Belegentwurf[]
@@index([organisationId, art, erstelltAm(sort: Desc)])
@@index([organisationId, entitaet, entitaetId])
@@index([organisationId, stand])
@@map("ki_vorgang")
}
/// Eine Seite im Wiki der Wehr.
///
/// Inhalt ist Markdown, weil es sich ohne Werkzeug lesen lässt — auch in
/// zwanzig Jahren, auch ohne FLORIAN. Dienstvorschriften und
/// Ausbildungsunterlagen sollen die Anwendung überleben, die sie verwaltet.
model Wikiseite {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
slug String @db.VarChar(160)
titel String @db.VarChar(200)
art WikiKategorie @default(SONSTIGES)
inhalt String @db.Text
/// Woher es stammt: "FwDV 7, Ausgabe 2002", "Beschluss der Kommandanten".
/// Steht unter jeder Antwort, die aus dieser Seite gebaut wird.
quelle String? @db.VarChar(300)
schlagworte String[]
/// Unveröffentlichte Seiten sind Entwürfe: sie werden nicht durchsucht und
/// nicht zum Beantworten herangezogen.
veroeffentlicht Boolean @default(true)
autorId String? @db.Uuid
/// Wann die Abschnitte zuletzt eingebettet wurden. Liegt es vor
/// `geaendertAm`, ist die Seite seit der letzten Änderung nicht mehr
/// durchsuchbar — und das steht dann auch in der Ansicht.
eingebettetAm DateTime? @db.Timestamptz(3)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
autor Person? @relation("WikiseiteAutor", fields: [autorId], references: [id], onDelete: SetNull)
abschnitte Wikiabschnitt[]
versionen Wikiversion[]
@@unique([organisationId, slug])
@@index([organisationId, art])
@@map("wikiseite")
}
/// Ein Abschnitt einer Wiki-Seite samt seiner Einbettung.
///
/// Zerlegt wird an den Überschriften, nicht nach Zeichenzahl: ein Abschnitt
/// soll für sich verständlich sein, weil er für sich zitiert wird. Wer eine
/// Antwort bekommt, bekommt die Fundstelle dazu — und die ist dann ein
/// Kapitel, kein aus der Mitte gerissener Absatz.
///
/// `einbettung` ist für Prisma ein unbekannter Typ; gelesen und geschrieben
/// wird sie über `$queryRaw`. Das ist der übliche Weg mit pgvector und der
/// Grund, warum es in lib/ki/ eine eigene Datei dafür gibt.
model Wikiabschnitt {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
seiteId String @db.Uuid
reihenfolge Int
ueberschrift String? @db.VarChar(300)
text String @db.Text
/// 768 Dimensionen — das liefert nomic-embed-text, das Standardmodell für
/// Einbettungen unter Ollama. Ein anderes Modell mit anderer Länge braucht
/// eine Migration; das steht im README.
einbettung Unsupported("vector(768)")?
/// Womit eingebettet wurde. Nach einem Modellwechsel sind alte Einbettungen
/// nicht mehr vergleichbar und werden neu erzeugt.
modell String? @db.VarChar(120)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
seite Wikiseite @relation(fields: [seiteId], references: [id], onDelete: Cascade)
@@unique([seiteId, reihenfolge])
@@index([organisationId])
@@map("wikiabschnitt")
}
/// Der Stand einer Seite vor einer Änderung.
///
/// Eine Dienstvorschrift, die sich unbemerkt ändert, ist schlimmer als keine.
/// Deshalb wird bei jeder Änderung der vorherige Text weggeschrieben — schlicht
/// und vollständig, nicht als Differenz.
model Wikiversion {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
seiteId String @db.Uuid
nummer Int
titel String @db.VarChar(200)
inhalt String @db.Text
/// Was der Bearbeiter dazu gesagt hat.
bemerkung String? @db.VarChar(500)
autorId String? @db.Uuid
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
seite Wikiseite @relation(fields: [seiteId], references: [id], onDelete: Cascade)
autor Person? @relation("WikiversionAutor", fields: [autorId], references: [id], onDelete: SetNull)
@@unique([seiteId, nummer])
@@index([organisationId, seiteId])
@@map("wikiversion")
}
/// Eine Aufnahme und was daraus wurde.
///
/// Gedacht für den Weg, den es heute schon gibt: der Einsatzleiter spricht auf
/// der Rückfahrt in sein Telefon, und daraus wird abends ein Bericht. Nur dass
/// jetzt schon ein Text dasteht, den jemand nur noch geradezieht.
///
/// Die Aufnahme bleibt liegen, bis sie jemand entfernt: solange das Transkript
/// noch nicht bestätigt ist, ist sie das einzige Original.
model Diktat {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
/// Die Aufnahme in MinIO. Nie ein Pfad im Dateisystem.
audioDateiId String @db.Uuid
dauerSekunden Int?
sprache String @default("de") @db.VarChar(10)
transkript String? @db.Text
stand KiStand @default(ENTWURF)
/// Wohin der Text soll, wenn er übernommen wird.
entitaet String? @db.VarChar(80)
entitaetId String? @db.Uuid
kiVorgangId String? @db.Uuid
erzeugtVonId String? @db.Uuid
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
audioDatei Datei @relation("DiktatAufnahme", fields: [audioDateiId], references: [id], onDelete: Restrict)
kiVorgang KiVorgang? @relation(fields: [kiVorgangId], references: [id], onDelete: SetNull)
erzeugtVon Person? @relation("DiktatErzeuger", fields: [erzeugtVonId], references: [id], onDelete: SetNull)
@@index([organisationId, stand])
@@index([organisationId, entitaet, entitaetId])
@@map("diktat")
}
/// Was ein Modell auf einer Quittung gelesen hat — als Vorschlag.
///
/// Bewusst eine eigene Tabelle und keine Buchung: die Kasse kommt in Phase 9,
/// und ein Betrag, den eine Maschine aus einem Foto gelesen hat, ist keine
/// Buchung. Er ist ein ausgefülltes Formular, das jemand gegenzeichnet.
///
/// Alle Felder sind optional. Ein Modell, das den Betrag nicht findet, soll
/// nichts erfinden — dann bleibt das Feld leer und ein Mensch trägt es ein.
model Belegentwurf {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
fotoDateiId String @db.Uuid
betrag Decimal? @db.Decimal(12, 2)
waehrung String @default("EUR") @db.VarChar(3)
belegdatum DateTime? @db.Date
haendler String? @db.VarChar(200)
kategorie String? @db.VarChar(80)
/// Steuersatz in Prozent, falls erkannt.
steuersatz Decimal? @db.Decimal(5, 2)
/// Was das Modell geantwortet hat, unverändert. Für den Fall, dass die
/// Auswertung daneben lag und jemand nachsehen will, woran es lag.
rohantwort Json?
stand KiStand @default(ENTWURF)
kiVorgangId String? @db.Uuid
erfasstVonId String? @db.Uuid
bestaetigtVonId String? @db.Uuid
bestaetigtAm DateTime? @db.Timestamptz(3)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
fotoDatei Datei @relation("BelegentwurfFoto", fields: [fotoDateiId], references: [id], onDelete: Restrict)
kiVorgang KiVorgang? @relation(fields: [kiVorgangId], references: [id], onDelete: SetNull)
erfasstVon Person? @relation("BelegentwurfErfasser", fields: [erfasstVonId], references: [id], onDelete: SetNull)
bestaetigtVon Person? @relation("BelegentwurfBestaetiger", fields: [bestaetigtVonId], references: [id], onDelete: SetNull)
@@index([organisationId, stand])
@@index([organisationId, belegdatum])
@@map("belegentwurf")
}
+232
View File
@@ -19,6 +19,8 @@
* Lehrgang mit Warteliste, Arbeitsdienst, Jahreshauptversammlung, ein
* abgesagter Termin und eine abgehakte Anwesenheitsliste. Bewusst OHNE
* Zustellkanäle: niemand wird ungefragt benachrichtigt, auch nicht im Seed.
* Ab Phase 8 dazu: vier Wiki-Seiten mit eigenem Text — Prüffristen,
* Atemschutz-Merkblatt, Schlauchpflege und Alarmierungsordnung.
*
* Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang,
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
@@ -62,6 +64,7 @@ import {
ObjektKategorie,
Serienmuster,
StandortArt,
WikiKategorie,
Teilnahmestand,
TerminArt,
TruppStatus,
@@ -2999,6 +3002,232 @@ async function termineAnlegen(organisationId: string): Promise<void> {
);
}
// -----------------------------------------------------------------------------
// Phase 8 — Wissen
//
// Vier Seiten, wie eine Wehr sie wirklich hätte: eine Zusammenfassung der
// Prüffristen, ein Merkblatt zum Atemschutz, eine Anleitung zur Schlauchpflege
// und die Alarmierungsordnung.
//
// Der Inhalt ist bewusst **eigener Text** und keine abgeschriebene Vorschrift.
// Eine Dienstvorschrift ist urheberrechtlich geschützt; was hier steht, ist
// eine Zusammenfassung mit Fundstellenangabe — genau so, wie eine Wehr ihre
// eigenen Merkblätter schreibt.
//
// Ohne Sprachmodell bleiben die Seiten uneingebettet. Das ist kein Mangel: sie
// sind über die Wortsuche auffindbar, und die Seitenansicht sagt, dass der
// Suchstand fehlt.
// -----------------------------------------------------------------------------
async function wikiAnlegen(organisationId: string): Promise<void> {
const vorhanden = await prisma.wikiseite.count({ where: { organisationId } });
if (vorhanden > 0) {
log("Wiki bereits vorhanden — übersprungen.");
return;
}
const autor = await prisma.person.findFirst({
where: {
organisationId,
rollen: { some: { rolle: { schluessel: RolleSchluessel.KOMMANDANT } } },
},
select: { id: true },
});
const SEITEN: Array<{
slug: string;
titel: string;
art: WikiKategorie;
quelle: string;
schlagworte: string[];
inhalt: string;
}> = [
{
slug: "pruefristen-im-ueberblick",
titel: "Prüffristen im Überblick",
art: WikiKategorie.GERAETE,
quelle: "Zusammenfassung nach DGUV Vorschrift 49 und Herstellerangaben",
schlagworte: ["prüffrist", "dguv", "gerätewart", "wartung"],
inhalt: [
"Diese Seite fasst zusammen, was bei uns in welchem Abstand geprüft wird.",
"Verbindlich sind die Vorschrift und die Herstellerangabe — im Zweifel gilt",
"die kürzere Frist.",
"",
"## Jährlich",
"",
"- Pressluftatmer: Sicht- und Dichtprüfung",
"- Atemschutzmasken: Sichtprüfung, Dichtprüfung",
"- Tragbare Leitern: Sichtprüfung nach DGUV",
"- Rettungsgeräte und Sprungpolster",
"- Handscheinwerfer und Handfunkgeräte: Funktionsprüfung",
"",
"## Alle zwei Jahre",
"",
"- Feuerwehrschläuche: Druckprüfung",
"- Kupplungen und Armaturen: Dichtheit",
"",
"## Alle drei Jahre",
"",
"- Feuerlöscher: Wartung durch einen Sachkundigen",
"",
"## Alle sechs Jahre",
"",
"- Pressluftatmer: Grundüberholung durch die Werkstatt",
"- Atemluftflaschen: wiederkehrende Prüfung nach Druckgeräterichtlinie",
"",
"## Was passiert, wenn eine Frist abläuft",
"",
"Das Gerät wechselt in FLORIAN von selbst auf **Prüfung fällig**. Benutzen",
"darf man es weiter — es ist nicht gesperrt. Gesperrt wird nur von Hand und",
"immer mit Begründung.",
"",
"Wer eine Frist nicht halten kann, meldet das dem Gerätewart. Ein",
"stillschweigend abgelaufener Nachweis ist im Schadensfall das Problem des",
"Kommandanten, nicht des Geräts.",
].join("\n"),
},
{
slug: "atemschutz-merkblatt",
titel: "Atemschutz — was vor jedem Einsatz gilt",
art: WikiKategorie.DIENSTVORSCHRIFT,
quelle: "Zusammenfassung nach FwDV 7, Ausgabe 2002 mit Änderung 2005",
schlagworte: ["atemschutz", "fwdv 7", "überwachung", "rückzugsdruck"],
inhalt: [
"## Wer darf unter Atemschutz",
"",
"Drei Nachweise müssen gleichzeitig gültig sein:",
"",
"1. **G 26.3** — arbeitsmedizinische Vorsorge, in der Regel drei Jahre gültig,",
" ab 50 Jahren jährlich.",
"2. **Belastungsübung** — jährlich, in einer Atemschutzübungsanlage.",
"3. **Einsatz oder Übung unter Atemschutz** — jährlich.",
"",
"Fehlt einer davon, ist der Einsatz unter Atemschutz nicht zulässig. FLORIAN",
"zeigt das an, sperrt aber nicht: die Entscheidung trifft der",
"Atemschutzgerätewart, nicht die Software.",
"",
"## Rückzugsdruck",
"",
"Der Rückzugsdruck ist der Druck, bei dem der Trupp den Rückweg antreten",
"muss. Er wird aus dem **doppelten Anmarschverbrauch** plus einem",
"Sicherheitszuschlag gerechnet:",
"",
"```",
"Rückzugsdruck = Eingangsdruck - 2 x (Eingangsdruck - Druck am Ziel) + Zuschlag",
"```",
"",
"Ist der Anmarsch noch nicht gemessen, gilt die Zwei-Drittel-Regel: bei zwei",
"Dritteln des Eingangsdrucks wird zurückgegangen.",
"",
"## Überwachung",
"",
"Dokumentiert werden: Truppzusammensetzung, Einsatzbeginn, Druckwerte in",
"Abständen von höchstens zehn Minuten, Rückzugsdruck und Ende.",
"",
"> Die analoge Überwachung bleibt führend, solange die Wehr die digitale",
"> nicht ausdrücklich freigegeben hat. Das ist keine Formalie — eine",
"> Software, die im Einsatz ausfällt, darf keine Lücke im Nachweis",
"> hinterlassen.",
].join("\n"),
},
{
slug: "schlaeuche-pflegen",
titel: "Schläuche pflegen und prüfen",
art: WikiKategorie.GERAETE,
quelle: "Merkblatt der Wehr, beschlossen in der Kommandantensitzung",
schlagworte: ["schlauch", "pflege", "druckprüfung", "ablegereife"],
inhalt: [
"## Nach jedem Einsatz",
"",
"1. Schläuche vom Fahrzeug nehmen und durch neue ersetzen — nie einen",
" benutzten Schlauch wieder aufs Fahrzeug legen.",
"2. Grob reinigen, bei Verunreinigung mit Öl oder Chemikalien gesondert",
" behandeln und dem Gerätewart melden.",
"3. Trocknen, bevor sie gerollt werden. Ein feucht gerollter Schlauch",
" schimmelt von innen.",
"",
"## Ablegereife",
"",
"Ein Schlauch wird ausgesondert, wenn:",
"",
"- das Gewebe durchgescheuert oder eingerissen ist,",
"- die Einbindung sich gelöst hat,",
"- er die Druckprüfung nicht besteht,",
"- die Innenbeschichtung sich ablöst.",
"",
"Ein ausgesonderter Schlauch wird gekennzeichnet und **sofort** aus dem",
"Fahrzeug genommen. Ein abgelegter Schlauch, der noch im Regal liegt, kommt",
"irgendwann wieder aufs Fahrzeug.",
"",
"## Druckprüfung",
"",
"Alle zwei Jahre. Prüfdruck nach Herstellerangabe, in der Regel:",
"",
"| Größe | Prüfdruck |",
"|---|---|",
"| C 42 / C 52 | 12 bar |",
"| B 75 | 12 bar |",
"| A 110 | 8 bar |",
"",
"Das Ergebnis wird in FLORIAN am Schlauch eingetragen, nicht auf einem",
"Zettel im Schlauchturm.",
].join("\n"),
},
{
slug: "alarmierungsordnung",
titel: "Alarmierungsordnung",
art: WikiKategorie.ORGANISATION,
quelle: "Beschluss der Kommandantschaft",
schlagworte: ["alarm", "ausrücken", "einsatzleitung"],
inhalt: [
"## Wer rückt aus",
"",
"Alarmiert wird über Meldeempfänger und Sirene. Wer alarmiert wird, kommt",
"zum Gerätehaus — die Entscheidung, welches Fahrzeug besetzt wird, trifft",
"der Einsatzleiter vor Ort.",
"",
"Die Mindestbesetzung ist die **Staffel** (1/5). Kommt sie in fünf Minuten",
"nicht zusammen, wird mit dem nachrückenden Personal aufgefüllt und",
"ausgerückt.",
"",
"## Einsatzleitung",
"",
"Bis zum Eintreffen des Kommandanten führt der ranghöchste Anwesende. Die",
"Übergabe wird ausdrücklich ausgesprochen und in der Lagemeldung vermerkt —",
"ein stillschweigender Wechsel der Einsatzleitung ist im Nachhinein nicht",
"rekonstruierbar.",
"",
"## Was im Bericht stehen muss",
"",
"- Lage beim Eintreffen",
"- Getroffene Maßnahmen, in der Reihenfolge",
"- Übergabe: an wen, wann",
"",
"Nicht in den Bericht gehören Vermutungen über die Brandursache und",
"Bewertungen des Verhaltens Beteiligter. Beides ist Sache der Polizei.",
].join("\n"),
},
];
for (const seite of SEITEN) {
await prisma.wikiseite.create({
data: {
organisationId,
...seite,
veroeffentlicht: true,
autorId: autor?.id ?? null,
// Bewusst kein eingebettetAm: ohne Sprachmodell gibt es keine
// Einbettungen. Die Seitenansicht sagt das, und die Wortsuche
// funktioniert trotzdem.
},
});
}
log(
`Wissen: ${SEITEN.length} Seiten angelegt (noch nicht eingebettet — dafür braucht es ein Sprachmodell)`,
);
}
// -----------------------------------------------------------------------------
// Ablauf
// -----------------------------------------------------------------------------
@@ -3042,6 +3271,9 @@ async function main(): Promise<void> {
// Phase 7 — Benachrichtigungen und Termine
await termineAnlegen(organisation.id);
// Phase 8 — Lokale KI
await wikiAnlegen(organisation.id);
log("Fertig.");
log("");
log("Anmeldung für den Erststart:");