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
+146
View File
@@ -0,0 +1,146 @@
import { describe, expect, it } from "vitest";
import {
BELEG_ANWEISUNG,
BERICHT_ANWEISUNG,
DIKTAT_ANWEISUNG,
WIKI_ANWEISUNG,
einsatzAlsEingabe,
wikiAlsEingabe,
} from "@/lib/ki/anweisungen";
/**
* Die Anweisungen an das Modell.
*
* Sie lassen sich nicht darauf prüfen, ob ein Modell sie befolgt — das hängt
* am Modell. Prüfbar ist, ob die Regeln überhaupt drinstehen, und genau das
* ist der Zweck dieser Tests: sie schlagen an, wenn jemand beim Umformulieren
* die Zeile über das Nichterfinden herausnimmt.
*
* Dazu die Aufbereitung der Eingaben, und die ist echte Logik: was das Modell
* **nicht** bekommt, ist ebenso wichtig wie das, was es bekommt.
*/
describe("Die Anweisungen enthalten ihre Regeln", () => {
it("verbietet dem Berichtsassistenten das Erfinden", () => {
expect(BERICHT_ANWEISUNG).toContain("Erfinde nichts");
expect(BERICHT_ANWEISUNG).toContain("Bewerte nicht");
});
/**
* Die Zeitstempel stehen bereits als Felder im Bericht. Stünden sie noch
* einmal im Fließtext, hätte man sie zweimal — und beim zweiten Mal
* womöglich falsch.
*/
it("hält den Berichtsassistenten von den Zeitstempeln fern", () => {
expect(BERICHT_ANWEISUNG).toContain("Nicht wiederholen");
expect(BERICHT_ANWEISUNG).toContain("Alarmzeit");
});
it("verlangt von der Wiki-Antwort, Nichtwissen zuzugeben", () => {
expect(WIKI_ANWEISUNG).toContain("AUSSCHLIESSLICH");
expect(WIKI_ANWEISUNG).toContain("nicht darin");
expect(WIKI_ANWEISUNG).toContain("Fundstellen");
});
it("erlaubt der Belegerfassung ausdrücklich leere Felder", () => {
expect(BELEG_ANWEISUNG).toContain("null");
expect(BELEG_ANWEISUNG).toContain("Rate nicht");
expect(BELEG_ANWEISUNG).toContain("JJJJ-MM-TT");
});
/**
* Ein Modell, das „Hohlstrahlrohr" für einen Hörfehler hält und zu
* „Hohlstrahler" verbessert, macht aus einem brauchbaren Transkript einen
* falschen Bericht.
*/
it("verbietet dem Diktat das Verbessern von Fachbegriffen", () => {
expect(DIKTAT_ANWEISUNG).toContain("Fachbegriffe");
expect(DIKTAT_ANWEISUNG).toContain("Nicht erlaubt");
});
it("ist durchgängig auf Deutsch verfasst", () => {
for (const anweisung of [
BERICHT_ANWEISUNG,
WIKI_ANWEISUNG,
BELEG_ANWEISUNG,
DIKTAT_ANWEISUNG,
]) {
expect(anweisung.length).toBeGreaterThan(200);
// Keine englischen Anweisungswörter — das Modell soll auf Deutsch denken.
expect(anweisung).not.toMatch(/\bYou are\b/i);
}
});
});
describe("einsatzAlsEingabe", () => {
it("führt die vorhandenen Angaben auf", () => {
const eingabe = einsatzAlsEingabe({
stichwort: "B 3 Wohnhausbrand",
kategorie: "BRAND",
ort: "Schulstraße 4, Musterhausen",
lage: "Verrauchtes Treppenhaus",
stichpunkte: "Angriffstrupp unter PA ins EG",
fahrzeuge: ["Florian Musterhausen 40/1", "Florian Musterhausen 30/1"],
kraefte: 14,
dauerMinuten: 95,
});
expect(eingabe).toContain("B 3 Wohnhausbrand");
expect(eingabe).toContain("Schulstraße 4");
expect(eingabe).toContain("Florian Musterhausen 40/1, Florian Musterhausen 30/1");
expect(eingabe).toContain("14");
expect(eingabe).toContain("95 Minuten");
expect(eingabe).toContain("Angriffstrupp unter PA");
});
/**
* Fehlende Angaben werden weggelassen, nicht als „unbekannt" mitgeschickt.
* Ein Modell, das „Einsatzort: unbekannt" liest, schreibt gern einen Satz
* darüber, dass der Ort unbekannt sei — und das steht dann im Bericht.
*/
it("lässt fehlende Angaben ganz weg", () => {
const eingabe = einsatzAlsEingabe({
stichwort: "H 1 Ölspur",
stichpunkte: "Bindemittel aufgebracht",
});
expect(eingabe).toContain("H 1 Ölspur");
expect(eingabe).not.toContain("Einsatzort");
expect(eingabe).not.toContain("Fahrzeuge");
expect(eingabe).not.toContain("unbekannt");
expect(eingabe).not.toContain("null");
});
it("stellt die Stichpunkte ans Ende", () => {
const eingabe = einsatzAlsEingabe({
stichwort: "Test",
stichpunkte: "Der letzte Absatz",
});
expect(eingabe.trimEnd().endsWith("Der letzte Absatz")).toBe(true);
});
});
describe("wikiAlsEingabe", () => {
it("nummeriert die Auszüge, damit das Modell darauf verweisen kann", () => {
const eingabe = wikiAlsEingabe("Wie oft wird geprüft?", [
{ fundstelle: "Prüffristen › Jährlich", text: "Einmal im Jahr." },
{ fundstelle: "Prüffristen › Alle zwei Jahre", text: "Alle zwei Jahre." },
]);
expect(eingabe).toContain("[1] Prüffristen › Jährlich");
expect(eingabe).toContain("[2] Prüffristen › Alle zwei Jahre");
});
it("stellt die Frage ans Ende", () => {
const eingabe = wikiAlsEingabe("Wie oft?", [{ fundstelle: "A", text: "B" }]);
expect(eingabe.trimEnd().endsWith("Frage: Wie oft?")).toBe(true);
});
it("kommt ohne Auszüge zurecht", () => {
// Kommt in der Anwendung nicht vor — dort wird ohne Auszüge gar nicht
// gefragt —, darf aber nicht abstürzen.
expect(() => wikiAlsEingabe("Frage", [])).not.toThrow();
});
});
+174
View File
@@ -0,0 +1,174 @@
import { describe, expect, it } from "vitest";
import { belegAuswerten, datum, jsonHerausloesen, zahl } from "@/lib/ki/beleg-lesen";
/**
* Das Auswerten einer Modellantwort auf ein Belegfoto.
*
* Der Maßstab ist überall derselbe: **im Zweifel leer.** Ein nicht erkannter
* Betrag ist ein leeres Feld, das jemand ausfüllt. Ein geratener Betrag ist
* eine falsche Buchung, die niemand bemerkt.
*
* Deshalb prüfen die meisten Tests hier nicht, ob etwas erkannt wird, sondern
* ob Unsinn zuverlässig **nicht** erkannt wird.
*/
describe("jsonHerausloesen", () => {
it("nimmt blankes JSON", () => {
expect(jsonHerausloesen('{"betrag": 12.5}')).toBe('{"betrag": 12.5}');
});
it("schält JSON aus einem Codeblock", () => {
const antwort = 'Hier die Angaben:\n```json\n{"betrag": 12.5}\n```\nBitte prüfen.';
expect(jsonHerausloesen(antwort)).toBe('{"betrag": 12.5}');
});
it("schält JSON aus einem Codeblock ohne Sprachangabe", () => {
expect(jsonHerausloesen('```\n{"a": 1}\n```')).toBe('{"a": 1}');
});
it("findet JSON hinter einem Vorspann", () => {
expect(jsonHerausloesen('Gerne! {"betrag": 3}')).toBe('{"betrag": 3}');
});
it("gibt null zurück, wenn nichts da ist", () => {
expect(jsonHerausloesen("Ich kann das Bild nicht lesen.")).toBeNull();
expect(jsonHerausloesen("")).toBeNull();
});
});
describe("zahl", () => {
it("nimmt eine echte Zahl", () => {
expect(zahl(12.5)).toBe(12.5);
expect(zahl(0)).toBe(0);
});
it("liest englische Schreibweise", () => {
expect(zahl("12.50")).toBe(12.5);
});
it("liest deutsche Schreibweise", () => {
expect(zahl("12,50")).toBe(12.5);
});
/**
* Der Fall, der ohne Sorgfalt danebengeht: „1.234,56" ist
* tausendzweihundert und nicht eins Komma zwei. Entschieden wird über das
* letzte Trennzeichen.
*/
it("hält Tausenderpunkt und Dezimalkomma auseinander", () => {
expect(zahl("1.234,56")).toBe(1234.56);
expect(zahl("1,234.56")).toBe(1234.56);
});
it("wirft Währungszeichen weg", () => {
expect(zahl("12,50 €")).toBe(12.5);
expect(zahl("EUR 12.50")).toBe(12.5);
expect(zahl("€12,50")).toBe(12.5);
});
it("gibt bei Unsinn null zurück", () => {
expect(zahl("keine Ahnung")).toBeNull();
expect(zahl(null)).toBeNull();
expect(zahl(undefined)).toBeNull();
expect(zahl({})).toBeNull();
expect(zahl(Number.NaN)).toBeNull();
expect(zahl("")).toBeNull();
});
});
describe("datum", () => {
it("liest ISO", () => {
expect(datum("2026-05-24")).toEqual(new Date(Date.UTC(2026, 4, 24)));
});
it("liest die deutsche Schreibweise", () => {
expect(datum("24.05.2026")).toEqual(new Date(Date.UTC(2026, 4, 24)));
expect(datum("4.5.2026")).toEqual(new Date(Date.UTC(2026, 4, 4)));
});
it("ergänzt ein zweistelliges Jahr", () => {
expect(datum("24.05.26")).toEqual(new Date(Date.UTC(2026, 4, 24)));
});
/** Der 31. Februar wird von Date stillschweigend zum 3. März. */
it("weist einen Tag ab, den es nicht gibt", () => {
expect(datum("2026-02-31")).toBeNull();
expect(datum("31.02.2026")).toBeNull();
});
it("weist ein unmögliches Jahr ab", () => {
// Ein Beleg von 1899 oder aus dem Jahr 2500 ist ein Lesefehler.
expect(datum("1899-01-01")).toBeNull();
expect(datum("2500-01-01")).toBeNull();
});
it("gibt bei Unsinn null zurück", () => {
expect(datum("gestern")).toBeNull();
expect(datum(null)).toBeNull();
expect(datum(42)).toBeNull();
});
});
describe("belegAuswerten", () => {
it("liest eine vollständige Antwort", () => {
const ergebnis = belegAuswerten(
JSON.stringify({
betrag: 45.9,
waehrung: "eur",
belegdatum: "2026-05-24",
haendler: "Tankstelle Musterhausen",
steuersatz: 19,
kategorie: "Kraftstoff",
}),
);
expect(ergebnis.betrag).toBe(45.9);
expect(ergebnis.waehrung).toBe("EUR");
expect(ergebnis.haendler).toBe("Tankstelle Musterhausen");
expect(ergebnis.steuersatz).toBe(19);
expect(ergebnis.fehlend).toEqual([]);
expect(ergebnis.erkannt).toHaveLength(6);
});
it("meldet, was gefehlt hat", () => {
const ergebnis = belegAuswerten(JSON.stringify({ betrag: 12.5, haendler: null }));
expect(ergebnis.betrag).toBe(12.5);
expect(ergebnis.erkannt).toEqual(["betrag"]);
expect(ergebnis.fehlend).toContain("haendler");
expect(ergebnis.fehlend).toContain("belegdatum");
});
it("liefert bei unlesbarer Antwort ein leeres Formular statt eines Fehlers", () => {
const ergebnis = belegAuswerten("Ich kann auf dem Bild nichts erkennen.");
expect(ergebnis.betrag).toBeNull();
expect(ergebnis.erkannt).toEqual([]);
expect(ergebnis.fehlend).toHaveLength(6);
});
it("verträgt kaputtes JSON", () => {
expect(belegAuswerten('{"betrag": ').erkannt).toEqual([]);
expect(belegAuswerten("{}").erkannt).toEqual([]);
expect(belegAuswerten("[1,2,3]").erkannt).toEqual([]);
});
/** Eine Quittung über minus zwölf Euro gibt es nicht — das ist ein Lesefehler. */
it("verwirft einen negativen Betrag", () => {
expect(belegAuswerten(JSON.stringify({ betrag: -12.5 })).betrag).toBeNull();
});
it("verwirft einen unmöglichen Steuersatz", () => {
expect(belegAuswerten(JSON.stringify({ steuersatz: 190 })).steuersatz).toBeNull();
expect(belegAuswerten(JSON.stringify({ steuersatz: -7 })).steuersatz).toBeNull();
});
it('nimmt die Zeichenkette "null" nicht als Text', () => {
expect(belegAuswerten(JSON.stringify({ haendler: "null" })).haendler).toBeNull();
});
it("kürzt einen überlangen Händlernamen", () => {
const ergebnis = belegAuswerten(JSON.stringify({ haendler: "A".repeat(500) }));
expect(ergebnis.haendler).toHaveLength(200);
});
});
+439
View File
@@ -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/);
});
});
+192
View File
@@ -0,0 +1,192 @@
import { describe, expect, it } from "vitest";
import { MAX_ZEICHEN, einbettungstext, fundstelle, zerlegen } from "@/lib/ki/zerlegen";
/**
* Das Zerlegen einer Wiki-Seite.
*
* Es entscheidet darüber, was später als Fundstelle unter einer Antwort steht.
* Ein Abschnitt, der mitten im Satz beginnt, ist als Zitat wertlos — und ein
* Codeblock, der an seinen Rauten zerfällt, macht aus einem Shell-Beispiel
* vier sinnlose Bruchstücke.
*/
describe("zerlegen", () => {
it("trennt an den Überschriften", () => {
const abschnitte = zerlegen(
["# Erstens", "Text A", "", "# Zweitens", "Text B"].join("\n"),
);
expect(abschnitte).toHaveLength(2);
expect(abschnitte[0]!.ueberschrift).toBe("Erstens");
expect(abschnitte[0]!.text).toBe("Text A");
expect(abschnitte[1]!.ueberschrift).toBe("Zweitens");
});
it("führt die Überschriftenkette mit", () => {
const abschnitte = zerlegen(
[
"# FwDV 7",
"Einleitung, lang genug um für sich zu stehen und nicht angehängt zu werden.",
"",
"## Rückzugsdruck",
"Der Rückzugsdruck ist der Druck, bei dem der Trupp den Rückweg antritt.",
].join("\n"),
);
expect(abschnitte[1]!.pfad).toEqual(["FwDV 7", "Rückzugsdruck"]);
});
it("räumt die Kette beim Zurückspringen auf eine höhere Ebene auf", () => {
const abschnitte = zerlegen(
[
"# Eins",
"Ein hinreichend langer Absatz, damit er nicht mit dem nächsten verschmilzt.",
"",
"## Unterpunkt",
"Noch ein hinreichend langer Absatz, damit er als eigener Abschnitt bleibt.",
"",
"# Zwei",
"Und ein dritter Absatz, ebenfalls lang genug für einen eigenen Abschnitt.",
].join("\n"),
);
// Nach dem Sprung zurück auf Ebene 1 darf "Unterpunkt" nicht mehr in der
// Kette stehen — sonst zitiert die Antwort eine Fundstelle, die es nicht
// gibt.
expect(abschnitte.at(-1)!.pfad).toEqual(["Zwei"]);
});
it("lässt Rauten in einem Codeblock in Ruhe", () => {
const abschnitte = zerlegen(
[
"# Anleitung",
"Ein Absatz, der lang genug ist, um für sich zu stehen und nicht zu verschmelzen.",
"",
"```bash",
"# Das ist ein Kommentar, keine Überschrift",
"docker compose up -d",
"```",
].join("\n"),
);
// Genau ein Abschnitt: die Raute im Codeblock hat nichts getrennt.
expect(abschnitte).toHaveLength(1);
expect(abschnitte[0]!.text).toContain("# Das ist ein Kommentar");
});
it("teilt einen zu langen Abschnitt an Absatzgrenzen", () => {
const absatz = "Ein Satz mit ein paar Wörtern darin. ".repeat(20).trim();
const lang = [absatz, "", absatz, "", absatz].join("\n");
const abschnitte = zerlegen(`# Lang\n${lang}`, 500);
expect(abschnitte.length).toBeGreaterThan(1);
for (const abschnitt of abschnitte) {
expect(abschnitt.text.length).toBeLessThanOrEqual(500);
// Kein Stück endet mitten im Wort.
expect(abschnitt.text).not.toMatch(/\w-$/);
}
});
it("gibt geteilten Stücken dieselbe Überschrift", () => {
const lang = "Ein hinreichend langer Satz zum Auffüllen. ".repeat(40);
const abschnitte = zerlegen(`## Prüffristen\n${lang}`, 400);
expect(abschnitte.length).toBeGreaterThan(1);
for (const abschnitt of abschnitte) {
expect(abschnitt.pfad).toEqual(["Prüffristen"]);
}
});
/**
* Der Test, der eine falsche Fundstelle verhindert: ein kurzer Abschnitt
* unter eigener Überschrift wird NICHT an den vorherigen gehängt. Sonst
* stünde unter einer Antwort „Haupt" als Fundstelle, während der zitierte
* Satz aus „Winzig" stammt.
*/
it("hängt einen kurzen Abschnitt nicht unter eine fremde Überschrift", () => {
const abschnitte = zerlegen(
[
"# Haupt",
"Ein hinreichend langer Absatz, der als eigener Abschnitt bestehen bleibt.",
"",
"## Winzig",
"Kurz.",
].join("\n"),
);
expect(abschnitte).toHaveLength(2);
expect(abschnitte[1]!.text).toBe("Kurz.");
expect(abschnitte[1]!.pfad).toEqual(["Haupt", "Winzig"]);
});
it("zieht Reststücke desselben Abschnitts wieder zusammen", () => {
// Ein langer Abschnitt wird geteilt; bleibt am Ende ein kurzes Stück
// übrig, gehört es an das vorherige — es stammt aus demselben Kapitel.
const abschnitte = zerlegen(`## Eins\n${"Satz um Satz zu fuellen. ".repeat(30)}kurz.`, 400);
expect(abschnitte.length).toBeGreaterThan(1);
for (const abschnitt of abschnitte) {
expect(abschnitt.pfad).toEqual(["Eins"]);
expect(abschnitt.text.length).toBeGreaterThan(40);
}
});
it("kommt mit Text vor der ersten Überschrift zurecht", () => {
const abschnitte = zerlegen(
["Ein Vorspann ohne Überschrift, lang genug um zu bestehen.", "", "# Danach", "Inhalt."].join(
"\n",
),
);
expect(abschnitte[0]!.ueberschrift).toBeNull();
expect(abschnitte[0]!.pfad).toEqual([]);
});
it("liefert für leeren Text nichts", () => {
expect(zerlegen("")).toEqual([]);
expect(zerlegen(" \n\n ")).toEqual([]);
});
it("hält die Vorgabelänge ein", () => {
const lang = "Wort ".repeat(2000);
for (const abschnitt of zerlegen(lang)) {
expect(abschnitt.text.length).toBeLessThanOrEqual(MAX_ZEICHEN);
}
});
});
describe("einbettungstext", () => {
/**
* Der wichtigste Test der Datei: ein Abschnitt über den Rückzugsdruck ohne
* das Wort „Atemschutz" darüber findet sich nicht, wenn jemand nach
* Atemschutz fragt.
*/
it("stellt Seitentitel und Überschriftenkette voran", () => {
const text = einbettungstext(
{ ueberschrift: "Rückzugsdruck", pfad: ["Atemschutz", "Rückzugsdruck"], text: "Zwei Drittel." },
"FwDV 7",
);
expect(text).toContain("FwDV 7 › Atemschutz › Rückzugsdruck");
expect(text).toContain("Zwei Drittel.");
});
it("kommt ohne Überschrift aus", () => {
const text = einbettungstext({ ueberschrift: null, pfad: [], text: "Nur Text." }, "Titel");
expect(text).toBe("Titel\n\nNur Text.");
});
});
describe("fundstelle", () => {
it("verkettet Titel und Pfad", () => {
expect(fundstelle("FwDV 7", ["Atemschutz", "Rückzugsdruck"])).toBe(
"FwDV 7 › Atemschutz › Rückzugsdruck",
);
});
it("lässt Leeres weg", () => {
expect(fundstelle("Titel", [])).toBe("Titel");
expect(fundstelle("", ["Kapitel"])).toBe("Kapitel");
});
});