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();