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,439 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Was die Datenbank bei den KI-Daten durchsetzt.
|
||||
*
|
||||
* Die Regel dieser Phase — nichts Maschinelles gelangt ungeprüft in einen
|
||||
* Nachweis — steht nicht nur im Anwendungscode. Sie steht in der Datenbank,
|
||||
* und das ist der Unterschied zwischen einer Absicht und einer Zusicherung:
|
||||
*
|
||||
* 1. Eingabe, Ausgabe, Modell und Art eines KI-Vorgangs sind nach dem
|
||||
* Anlegen unveränderlich.
|
||||
* 2. Über einen Vorschlag wird genau einmal entschieden.
|
||||
* 3. Ein Vorgang lässt sich über die Anwendungsrolle nicht löschen.
|
||||
* 4. Eine Entscheidung ohne Zeitpunkt gibt es nicht.
|
||||
* 5. Frühere Fassungen einer Wiki-Seite bleiben.
|
||||
* 6. Der Vektorindex findet den ähnlichsten Abschnitt.
|
||||
*
|
||||
* Ohne erreichbare Datenbank überspringen sich die Tests selbst.
|
||||
*/
|
||||
|
||||
const APP_URL = process.env.DATABASE_URL ?? "";
|
||||
const SYSTEM_URL = process.env.DIRECT_DATABASE_URL ?? APP_URL;
|
||||
|
||||
const app = new PrismaClient({ datasources: { db: { url: APP_URL } } });
|
||||
const system = new PrismaClient({ datasources: { db: { url: SYSTEM_URL } } });
|
||||
|
||||
const SLUG = "test-ki-wehr";
|
||||
|
||||
let erreichbar = false;
|
||||
let organisationId = "";
|
||||
let personId = "";
|
||||
let seiteId = "";
|
||||
|
||||
/** Ein Vektor mit 768 Stellen, dessen erste Werte gesetzt sind. */
|
||||
function vektor(...werte: number[]): string {
|
||||
const alle = Array.from({ length: 768 }, (_, stelle) => werte[stelle] ?? 0);
|
||||
return `[${alle.join(",")}]`;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
try {
|
||||
await system.$queryRaw`SELECT 1`;
|
||||
erreichbar = true;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const organisation = await system.organisation.upsert({
|
||||
where: { slug: SLUG },
|
||||
update: {},
|
||||
create: { slug: SLUG, name: "KI-Testwehr", kurzname: "KTW" },
|
||||
});
|
||||
organisationId = organisation.id;
|
||||
|
||||
const person = await system.person.upsert({
|
||||
where: { organisationId_mitgliedsnummer: { organisationId, mitgliedsnummer: "K001" } },
|
||||
update: {},
|
||||
create: { organisationId, mitgliedsnummer: "K001", vorname: "Kai", nachname: "Testwart" },
|
||||
});
|
||||
personId = person.id;
|
||||
|
||||
const seite = await system.wikiseite.create({
|
||||
data: {
|
||||
organisationId,
|
||||
slug: "testseite",
|
||||
titel: "Testseite",
|
||||
inhalt: "# Test\n\nInhalt.",
|
||||
},
|
||||
});
|
||||
seiteId = seite.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (erreichbar) {
|
||||
await system.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
|
||||
// Der Unveränderlichkeits-Trigger sitzt auf ki_vorgang. Für das
|
||||
// Aufräumen der Testdaten wird er kurz abgeschaltet; im Betrieb
|
||||
// passiert das nie.
|
||||
await tx.$executeRawUnsafe('ALTER TABLE "ki_vorgang" DISABLE TRIGGER USER');
|
||||
await tx.organisation.deleteMany({ where: { slug: SLUG } });
|
||||
await tx.$executeRawUnsafe('ALTER TABLE "ki_vorgang" ENABLE TRIGGER USER');
|
||||
});
|
||||
}
|
||||
await Promise.allSettled([app.$disconnect(), system.$disconnect()]);
|
||||
});
|
||||
|
||||
async function vorgangAnlegen(felder: Record<string, unknown> = {}) {
|
||||
return system.kiVorgang.create({
|
||||
data: {
|
||||
organisationId,
|
||||
art: "BERICHTSENTWURF",
|
||||
modell: "qwen2.5:14b",
|
||||
eingabe: "Stichpunkte",
|
||||
ausgabe: "Ein Entwurf.",
|
||||
erzeugtVonId: personId,
|
||||
...felder,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe.runIf(process.env.DATABASE_URL)("Ein KI-Vorgang hält fest, was war", () => {
|
||||
it("nimmt einen Vorgang an", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const vorgang = await vorgangAnlegen();
|
||||
expect(vorgang.stand).toBe("ENTWURF");
|
||||
expect(vorgang.entschiedenAm).toBeNull();
|
||||
});
|
||||
|
||||
it("weist eine Änderung der Ausgabe ab", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const vorgang = await vorgangAnlegen();
|
||||
|
||||
await expect(
|
||||
system.kiVorgang.update({
|
||||
where: { id: vorgang.id },
|
||||
data: { ausgabe: "Etwas ganz anderes." },
|
||||
}),
|
||||
).rejects.toThrow(/nicht nachtraeglich aendern/);
|
||||
});
|
||||
|
||||
it("weist eine Änderung des Modells ab", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const vorgang = await vorgangAnlegen();
|
||||
|
||||
await expect(
|
||||
system.kiVorgang.update({ where: { id: vorgang.id }, data: { modell: "anderes" } }),
|
||||
).rejects.toThrow(/nicht nachtraeglich aendern/);
|
||||
});
|
||||
|
||||
it("lässt die Entscheidung zu", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const vorgang = await vorgangAnlegen();
|
||||
|
||||
const nachher = await system.kiVorgang.update({
|
||||
where: { id: vorgang.id },
|
||||
data: { stand: "UEBERNOMMEN", entschiedenVonId: personId, entschiedenAm: new Date() },
|
||||
});
|
||||
|
||||
expect(nachher.stand).toBe("UEBERNOMMEN");
|
||||
});
|
||||
|
||||
/**
|
||||
* Eine zweite Entscheidung wäre nicht nachvollziehbar: hinterher stünde
|
||||
* „verworfen" an einem Text, der längst im Bericht steht.
|
||||
*/
|
||||
it("lässt über einen Vorschlag nur einmal entscheiden", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const vorgang = await vorgangAnlegen();
|
||||
|
||||
await system.kiVorgang.update({
|
||||
where: { id: vorgang.id },
|
||||
data: { stand: "UEBERNOMMEN", entschiedenVonId: personId, entschiedenAm: new Date() },
|
||||
});
|
||||
|
||||
await expect(
|
||||
system.kiVorgang.update({ where: { id: vorgang.id }, data: { stand: "VERWORFEN" } }),
|
||||
).rejects.toThrow(/bereits entschieden/);
|
||||
});
|
||||
|
||||
it("weist eine Entscheidung ohne Zeitpunkt ab", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const vorgang = await vorgangAnlegen();
|
||||
|
||||
await expect(
|
||||
system.kiVorgang.update({ where: { id: vorgang.id }, data: { stand: "UEBERNOMMEN" } }),
|
||||
).rejects.toThrow(/entscheidung_vollstaendig/);
|
||||
});
|
||||
|
||||
it("verlangt zu einem Fehlschlag einen Grund", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
await expect(
|
||||
vorgangAnlegen({ stand: "FEHLGESCHLAGEN", ausgabe: null, fehler: null }),
|
||||
).rejects.toThrow(/fehler_begruendet/);
|
||||
});
|
||||
|
||||
it("nimmt einen Fehlschlag mit Grund an", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const vorgang = await vorgangAnlegen({
|
||||
stand: "FEHLGESCHLAGEN",
|
||||
ausgabe: null,
|
||||
fehler: "Das Modell hat nach 120 Sekunden nicht geantwortet.",
|
||||
});
|
||||
|
||||
expect(vorgang.fehler).toContain("120 Sekunden");
|
||||
});
|
||||
|
||||
it("lässt sich über die Anwendungsrolle nicht löschen", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const vorgang = await vorgangAnlegen();
|
||||
|
||||
await expect(
|
||||
app.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
|
||||
await tx.kiVorgang.delete({ where: { id: vorgang.id } });
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(process.env.DATABASE_URL)("Wiki-Fassungen bleiben", () => {
|
||||
it("nimmt eine Fassung an", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const fassung = await system.wikiversion.create({
|
||||
data: { organisationId, seiteId, nummer: 1, titel: "Alt", inhalt: "Alter Text." },
|
||||
});
|
||||
|
||||
expect(fassung.nummer).toBe(1);
|
||||
});
|
||||
|
||||
it("weist eine zweite Fassung mit derselben Nummer ab", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
await system.wikiversion.create({
|
||||
data: { organisationId, seiteId, nummer: 7, titel: "A", inhalt: "A" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
system.wikiversion.create({
|
||||
data: { organisationId, seiteId, nummer: 7, titel: "B", inhalt: "B" },
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("weist die Nummer null ab", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
await expect(
|
||||
system.wikiversion.create({
|
||||
data: { organisationId, seiteId, nummer: 0, titel: "A", inhalt: "A" },
|
||||
}),
|
||||
).rejects.toThrow(/nummer_positiv/);
|
||||
});
|
||||
|
||||
it("lässt sich über die Anwendungsrolle weder ändern noch löschen", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const fassung = await system.wikiversion.create({
|
||||
data: { organisationId, seiteId, nummer: 99, titel: "Fest", inhalt: "Fest." },
|
||||
});
|
||||
|
||||
await expect(
|
||||
app.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
|
||||
await tx.wikiversion.update({ where: { id: fassung.id }, data: { inhalt: "geändert" } });
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(process.env.DATABASE_URL)("Die semantische Suche", () => {
|
||||
it("legt Einbettungen an und findet den ähnlichsten Abschnitt", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
// Drei Abschnitte mit klar verschiedenen Vektoren. Der erste zeigt in
|
||||
// dieselbe Richtung wie die spätere Suchanfrage.
|
||||
await system.$executeRawUnsafe(
|
||||
`INSERT INTO "wikiabschnitt" ("id","organisationId","seiteId","reihenfolge","text","einbettung","erstelltAm")
|
||||
VALUES (gen_random_uuid(), $1::uuid, $2::uuid, 0, 'Nah dran', $3::vector, now()),
|
||||
(gen_random_uuid(), $1::uuid, $2::uuid, 1, 'Weit weg', $4::vector, now()),
|
||||
(gen_random_uuid(), $1::uuid, $2::uuid, 2, 'Dazwischen', $5::vector, now())`,
|
||||
organisationId,
|
||||
seiteId,
|
||||
vektor(1, 0, 0),
|
||||
vektor(0, 0, 1),
|
||||
vektor(0.7, 0.7, 0),
|
||||
);
|
||||
|
||||
const treffer = await system.$queryRawUnsafe<Array<{ text: string; abstand: number }>>(
|
||||
`SELECT "text", ("einbettung" <=> $1::vector) AS "abstand"
|
||||
FROM "wikiabschnitt"
|
||||
WHERE "organisationId" = $2::uuid AND "einbettung" IS NOT NULL
|
||||
ORDER BY "einbettung" <=> $1::vector
|
||||
LIMIT 3`,
|
||||
vektor(1, 0, 0),
|
||||
organisationId,
|
||||
);
|
||||
|
||||
expect(treffer[0]!.text).toBe("Nah dran");
|
||||
expect(Number(treffer[0]!.abstand)).toBeCloseTo(0, 5);
|
||||
expect(treffer.at(-1)!.text).toBe("Weit weg");
|
||||
});
|
||||
|
||||
it("weist einen Vektor mit falscher Länge ab", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
// Die Spalte ist auf 768 festgelegt. Ein Modellwechsel mit anderer Länge
|
||||
// soll hier auffallen und nicht als stiller Datenmüll enden.
|
||||
await expect(
|
||||
system.$executeRawUnsafe(
|
||||
`INSERT INTO "wikiabschnitt" ("id","organisationId","seiteId","reihenfolge","text","einbettung","erstelltAm")
|
||||
VALUES (gen_random_uuid(), $1::uuid, $2::uuid, 90, 'Falsch', '[1,2,3]'::vector, now())`,
|
||||
organisationId,
|
||||
seiteId,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("nimmt einen Abschnitt auch ohne Einbettung an", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
// Ohne Sprachmodell entstehen keine Vektoren — die Seite muss trotzdem
|
||||
// gespeichert und über die Wortsuche auffindbar sein.
|
||||
const abschnitt = await system.wikiabschnitt.create({
|
||||
data: { organisationId, seiteId, reihenfolge: 50, text: "Ohne Vektor" },
|
||||
});
|
||||
|
||||
expect(abschnitt.id).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(process.env.DATABASE_URL)("Mandantentrennung der neuen Tabellen", () => {
|
||||
it("liefert ohne gesetzten Mandanten nichts", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const [vorgaenge, seiten, abschnitte] = await Promise.all([
|
||||
app.kiVorgang.findMany({ where: { organisationId } }),
|
||||
app.wikiseite.findMany({ where: { organisationId } }),
|
||||
app.wikiabschnitt.findMany({ where: { organisationId } }),
|
||||
]);
|
||||
|
||||
expect(vorgaenge).toHaveLength(0);
|
||||
expect(seiten).toHaveLength(0);
|
||||
expect(abschnitte).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("liefert mit gesetztem Mandanten die eigenen Daten", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const seiten = await app.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
|
||||
return tx.wikiseite.findMany({ where: { organisationId } });
|
||||
});
|
||||
|
||||
expect(seiten.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("verbirgt die Abschnitte einer fremden Wehr auch bei der Vektorsuche", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const fremde = await system.organisation.upsert({
|
||||
where: { slug: "test-ki-fremde" },
|
||||
update: {},
|
||||
create: { slug: "test-ki-fremde", name: "Fremde Wehr", kurzname: "FW" },
|
||||
});
|
||||
|
||||
const fremdeSeite = await system.wikiseite.create({
|
||||
data: {
|
||||
organisationId: fremde.id,
|
||||
slug: "fremd",
|
||||
titel: "Fremd",
|
||||
inhalt: "Geheim.",
|
||||
},
|
||||
});
|
||||
|
||||
await system.$executeRawUnsafe(
|
||||
`INSERT INTO "wikiabschnitt" ("id","organisationId","seiteId","reihenfolge","text","einbettung","erstelltAm")
|
||||
VALUES (gen_random_uuid(), $1::uuid, $2::uuid, 0, 'Fremder Abschnitt', $3::vector, now())`,
|
||||
fremde.id,
|
||||
fremdeSeite.id,
|
||||
vektor(1, 0, 0),
|
||||
);
|
||||
|
||||
// Dieselbe Suche wie oben, aber im Kontext der eigenen Wehr: der fremde
|
||||
// Abschnitt darf nicht auftauchen, obwohl sein Vektor perfekt passt.
|
||||
const treffer = await app.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.current_organisation', ${organisationId}, true)`;
|
||||
return tx.$queryRawUnsafe<Array<{ text: string }>>(
|
||||
`SELECT "text" FROM "wikiabschnitt"
|
||||
WHERE "einbettung" IS NOT NULL
|
||||
ORDER BY "einbettung" <=> $1::vector
|
||||
LIMIT 10`,
|
||||
vektor(1, 0, 0),
|
||||
);
|
||||
});
|
||||
|
||||
expect(treffer.map((zeile) => zeile.text)).not.toContain("Fremder Abschnitt");
|
||||
|
||||
await system.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT set_config('app.audit_bereinigung', 'an', true)`;
|
||||
await tx.organisation.deleteMany({ where: { slug: "test-ki-fremde" } });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(process.env.DATABASE_URL)("Belegentwürfe", () => {
|
||||
it("weist einen negativen Betrag ab", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const datei = await system.datei.create({
|
||||
data: {
|
||||
organisationId,
|
||||
bucket: "florian",
|
||||
objektSchluessel: `test/${Date.now()}-beleg.jpg`,
|
||||
dateiname: "beleg.jpg",
|
||||
mimeTyp: "image/jpeg",
|
||||
groesseBytes: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
system.belegentwurf.create({
|
||||
data: { organisationId, fotoDateiId: datei.id, betrag: -5 },
|
||||
}),
|
||||
).rejects.toThrow(/betrag_positiv/);
|
||||
});
|
||||
|
||||
it("weist einen unmöglichen Steuersatz ab", async ({ skip }) => {
|
||||
if (!erreichbar) return skip();
|
||||
|
||||
const datei = await system.datei.create({
|
||||
data: {
|
||||
organisationId,
|
||||
bucket: "florian",
|
||||
objektSchluessel: `test/${Date.now()}-beleg2.jpg`,
|
||||
dateiname: "beleg2.jpg",
|
||||
mimeTyp: "image/jpeg",
|
||||
groesseBytes: 1000,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
system.belegentwurf.create({
|
||||
data: { organisationId, fotoDateiId: datei.id, steuersatz: 150 },
|
||||
}),
|
||||
).rejects.toThrow(/steuersatz_plausibel/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user