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,227 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* E2E-Tests für Phase 8 — Wissen, Belege und die KI-Protokolle.
|
||||
*
|
||||
* **Ohne laufendes Sprachmodell.** Das ist Absicht und nicht eine Lücke: der
|
||||
* Testlauf hat keinen Ollama daneben, und genau so sieht es bei einer Wehr
|
||||
* aus, die FLORIAN aufsetzt, bevor sie sich um Modelle kümmert. Geprüft wird
|
||||
* deshalb vor allem, dass die Anwendung ohne Modell **vollständig benutzbar**
|
||||
* bleibt und ehrlich sagt, was fehlt.
|
||||
*
|
||||
* Was ohne Modell laufen muss:
|
||||
*
|
||||
* * Wiki lesen, anlegen, ändern, Fassungen ansehen
|
||||
* * Wortsuche
|
||||
* * Belege fotografieren und von Hand ausfüllen
|
||||
* * beide Protokolle
|
||||
*
|
||||
* Voraussetzung: Stack läuft, Datenbank ist migriert und geseedet.
|
||||
*/
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de";
|
||||
const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
|
||||
|
||||
function eigenerTitel(zweck: string): string {
|
||||
return `E2E-${zweck}-${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
async function anmelden(page: Page): Promise<void> {
|
||||
await page.goto("/anmelden");
|
||||
await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
|
||||
await page.getByLabel("Passwort").fill(ADMIN_PASSWORT);
|
||||
await page.getByRole("button", { name: "Anmelden" }).click();
|
||||
await page.waitForURL("/");
|
||||
}
|
||||
|
||||
test.describe("Wissen", () => {
|
||||
test("zeigt die Seiten aus dem Bestand", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/wiki");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Wissen", level: 1 })).toBeVisible();
|
||||
|
||||
// Aus dem Seed.
|
||||
await expect(page.getByRole("link", { name: "Prüffristen im Überblick" })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("link", { name: "Atemschutz — was vor jedem Einsatz gilt" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* Der wichtigste Test der Datei: ohne Modell muss dastehen, dass keines da
|
||||
* ist — und im selben Atemzug, dass die Wortsuche trotzdem funktioniert.
|
||||
*/
|
||||
test("sagt ohne Sprachmodell, was fehlt und was trotzdem geht", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/wiki");
|
||||
|
||||
await expect(page.getByText("Es ist kein Sprachmodell eingerichtet.")).toBeVisible();
|
||||
await expect(page.getByText(/Wortsuche/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("findet eine Textstelle über die Wortsuche", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/wiki");
|
||||
|
||||
await page.getByLabel("Wortsuche im Wiki").fill("Rückzugsdruck");
|
||||
|
||||
await expect(page.getByText(/Wortsuche: \d+ Stelle/)).toBeVisible();
|
||||
|
||||
// Der Ausschnitt zeigt die Textstelle mitsamt ihrer Umgebung, nicht bloß
|
||||
// den Seitennamen — daran erkennt man beim Suchen, ob es die richtige
|
||||
// Stelle ist, ohne die Seite zu öffnen.
|
||||
await expect(page.getByText(/Der Rückzugsdruck ist der Druck/).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("zeigt eine Seite als formatierten Text", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/wiki/atemschutz-merkblatt");
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Atemschutz — was vor jedem Einsatz gilt", level: 1 }),
|
||||
).toBeVisible();
|
||||
|
||||
// Markdown wird als echte Überschrift dargestellt, nicht als Raute im Text.
|
||||
await expect(page.getByRole("heading", { name: "Rückzugsdruck" })).toBeVisible();
|
||||
await expect(page.getByText(/FwDV 7, Ausgabe 2002/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("stellt eine Markdown-Tabelle als Tabelle dar", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/wiki/schlaeuche-pflegen");
|
||||
|
||||
await expect(page.getByRole("columnheader", { name: "Prüfdruck" })).toBeVisible();
|
||||
await expect(page.getByRole("cell", { name: "C 42 / C 52" })).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* Ohne Modell gibt es keine Einbettungen — die Seite sagt das, statt so zu
|
||||
* tun, als wäre sie durchsuchbar.
|
||||
*/
|
||||
test("weist auf den fehlenden Suchstand hin", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/wiki/alarmierungsordnung");
|
||||
|
||||
await expect(page.getByText(/Suchstand ist älter als der Text/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("legt eine Seite an und zeigt sie sofort", async ({ page }) => {
|
||||
const titel = eigenerTitel("Merkblatt");
|
||||
|
||||
await anmelden(page);
|
||||
await page.goto("/wiki");
|
||||
|
||||
await page.getByRole("button", { name: "Seite anlegen" }).click();
|
||||
await page.getByLabel("Titel").fill(titel);
|
||||
await page.getByLabel("Inhalt").fill("## Erster Abschnitt\n\nHier steht der Text.");
|
||||
await page.getByRole("button", { name: "Anlegen" }).click();
|
||||
|
||||
await expect(page.getByRole("heading", { name: titel, level: 1 })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Erster Abschnitt" })).toBeVisible();
|
||||
});
|
||||
|
||||
/**
|
||||
* Beim Ändern wird die alte Fassung weggeschrieben. Eine Dienstvorschrift,
|
||||
* die sich unbemerkt ändert, ist schlimmer als keine.
|
||||
*/
|
||||
test("hebt die vorherige Fassung auf", async ({ page }) => {
|
||||
const titel = eigenerTitel("Fassungen");
|
||||
|
||||
await anmelden(page);
|
||||
await page.goto("/wiki");
|
||||
|
||||
await page.getByRole("button", { name: "Seite anlegen" }).click();
|
||||
await page.getByLabel("Titel").fill(titel);
|
||||
await page.getByLabel("Inhalt").fill("Der erste Text.");
|
||||
await page.getByRole("button", { name: "Anlegen" }).click();
|
||||
await expect(page.getByRole("heading", { name: titel, level: 1 })).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Bearbeiten" }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByLabel("Inhalt").fill("Der zweite Text.");
|
||||
await dialog.getByLabel("Was wurde geändert? (freiwillig)").fill("Text berichtigt");
|
||||
await dialog.getByRole("button", { name: "Speichern" }).click();
|
||||
|
||||
await expect(page.getByText("Der zweite Text.")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Verlauf" }).click();
|
||||
const verlauf = page.getByRole("dialog");
|
||||
await expect(verlauf.getByText("Text berichtigt")).toBeVisible();
|
||||
|
||||
await verlauf.getByRole("button", { name: /Fassung 1/ }).click();
|
||||
await expect(verlauf.getByText("Der erste Text.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("zeigt beim Bearbeiten, in wie viele Abschnitte zerlegt wird", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/wiki/pruefristen-im-ueberblick");
|
||||
|
||||
await page.getByRole("button", { name: "Bearbeiten" }).click();
|
||||
await expect(page.getByText(/Wird für die Suche in \d+ Abschnitte zerlegt/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("ein Entwurf wird als solcher gekennzeichnet", async ({ page }) => {
|
||||
const titel = eigenerTitel("Entwurf");
|
||||
|
||||
await anmelden(page);
|
||||
await page.goto("/wiki");
|
||||
|
||||
await page.getByRole("button", { name: "Seite anlegen" }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByLabel("Titel").fill(titel);
|
||||
await dialog.getByLabel("Inhalt").fill("Noch nicht fertig.");
|
||||
// Das Kästchen selbst anklicken, nicht seine Beschriftung: auf dem Tablet
|
||||
// trifft ein Klick auf den umbrochenen Text gelegentlich daneben.
|
||||
await dialog.getByRole("checkbox").uncheck();
|
||||
await dialog.getByRole("button", { name: "Anlegen" }).click();
|
||||
|
||||
await expect(page.getByText(/Diese Seite ist ein/)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Belege", () => {
|
||||
test("sagt ohne Vision-Modell, was fehlt und was trotzdem geht", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/belege");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Belege", level: 1 })).toBeVisible();
|
||||
await expect(page.getByText(/kein Modell eingerichtet, das Bilder lesen kann/)).toBeVisible();
|
||||
await expect(page.getByText(/von Hand eingetragen/)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Beleg fotografieren" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Protokolle", () => {
|
||||
test("zeigt das KI-Protokoll mit seinen Kennzahlen", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/ki-protokoll");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "KI-Protokoll", level: 1 })).toBeVisible();
|
||||
await expect(page.getByText("Vorgänge insgesamt")).toBeVisible();
|
||||
await expect(page.getByText("erzeugt, aber nicht entschieden")).toBeVisible();
|
||||
|
||||
// Die Zusicherung, die den Zweck der Liste ausmacht.
|
||||
await expect(page.getByText(/lassen sich nachträglich nicht ändern/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("zeigt das Audit-Log", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/protokoll");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Protokoll", level: 1 })).toBeVisible();
|
||||
await expect(page.getByText(/append-only/)).toBeVisible();
|
||||
|
||||
// Die Anmeldung von eben steht schon drin.
|
||||
await expect(page.getByText("angemeldet").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("filtert das Audit-Log nach Art des Datensatzes", async ({ page }) => {
|
||||
await anmelden(page);
|
||||
await page.goto("/protokoll");
|
||||
|
||||
await page.getByLabel("Nach Entität filtern").fill("Wikiseite");
|
||||
await expect(page.getByText(/Wiki-Seite/).first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user