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:
co-authored by
Claude Opus 5
parent
12243f177d
commit
f7ccd9a908
@@ -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();
|
||||
Reference in New Issue
Block a user