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>
193 lines
6.4 KiB
TypeScript
193 lines
6.4 KiB
TypeScript
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");
|
||
});
|
||
});
|