Files
FireAdministration/tests/unit/ki-beleg.test.ts
T
Florian WildeandClaude Opus 5 f7ccd9a908 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>
2026-09-04 11:06:03 +02:00

175 lines
5.7 KiB
TypeScript

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);
});
});