Files
scan-receipts/tests/e2e/extraction_quality.test.ts

496 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Extraktions-Qualität: Regressionen aus dem Wechsel auf openai/gpt-5.6-luna.
*
* Deckt drei Befunde ab, die live an den Demo-Belegen reproduziert wurden:
* 1. Platzhalter-Händlernamen ("Nicht lesbar") kamen mit hoher Confidence durch
* und landeten ungeprüft als Händlername in der Excel.
* 2. Trinkgeld auf Bewirtungsbelegen darf nicht gegen den Bruttobetrag gerechnet
* werden — sonst meldet Check 5 bei jedem Beleg mit Tip eine Abweichung.
* 3. Das Modell-Schema darf `validation` nicht enthalten (wird lokal berechnet)
* und muss für OpenAI-strict jedes Feld in `required` führen.
*/
import { describe, test, expect } from "./runner";
import {
validateReceiptMath,
isPlaceholderMerchantName,
isTipLineItem,
CONFIDENCE_THRESHOLDS,
} from "../../src/lib/ai/mathValidator";
import ExcelJS from "exceljs";
import {
ProcessedReceipt,
ReceiptData,
ReceiptExtractionModelSchema,
LineItemViewBatchModelSchema,
LineItemVerificationModelSchema,
PENDING_VALIDATION,
grossWithTip,
} from "../../src/lib/schema/receipt";
import { generateDualSheetExcel } from "../../src/lib/export/excelGenerator";
import { generateAccountingCsv } from "../../src/lib/export/csvGenerator";
import {
chooseBetterExtraction,
extractionQualityScore,
mergeLineItemViews,
} from "../../src/lib/ai/extractor";
/** Extraktionsergebnis zu einem gespeicherten Beleg aufwerten. */
function stored(data: ReceiptData): ProcessedReceipt {
return {
...data,
id: "rcpt_test",
imageHash: "hash_test",
originalFileName: "test.jpg",
fileSizeBytes: 1024,
createdAt: "2026-08-12T10:00:00.000Z",
updatedAt: "2026-08-12T10:00:00.000Z",
status: "ready",
};
}
function receipt(overrides: Partial<ReceiptData> = {}): ReceiptData {
return {
merchant: { name: "REWE", address: null, taxId: null, confidence: 0.97 },
date: { isoDate: "2026-08-12", time: null, confidence: 0.95 },
documentType: "KASSENBON",
receiptNumber: null,
currency: "EUR",
totalAmount: { value: 11.9, confidence: 0.98 },
netAmount: 10.0,
taxBreakdown: [{ ratePercent: 19, taxAmount: 1.9, netAmount: 10.0 }],
lineItems: [
{ description: "Testartikel", quantity: 1, price: 11.9, unitPrice: null, taxRate: 19 },
],
suggestedCategory: "Sonstiges",
validation: { ...PENDING_VALIDATION },
...overrides,
} as ReceiptData;
}
describe("Extraktion: Platzhalter-Händlernamen", () => {
const placeholders = [
"Nicht lesbar",
"nicht lesbar",
"Taxiunternehmen (Name unleserlich)",
"Unbekannter Händler",
"Unknown",
"n/a",
"N/A",
"—",
"-",
" ",
"",
];
for (const name of placeholders) {
test(`EQ-1: "${name || "(leer)"}" gilt als Platzhalter`, () => {
expect(isPlaceholderMerchantName(name)).toBe(true);
});
}
const realNames = [
"REWE",
"Aral Tankstelle Station",
"BISTRO AM MARKT",
"Apotheke am Stadtpark",
"Trattoria Bella Vista",
"MediaMarkt",
"Deutsche Bahn AG",
];
for (const name of realNames) {
test(`EQ-2: "${name}" gilt NICHT als Platzhalter`, () => {
expect(isPlaceholderMerchantName(name)).toBe(false);
});
}
test("EQ-3: Platzhalter wird trotz hoher Confidence zur Prüfung markiert", () => {
const r = validateReceiptMath(
receipt({
merchant: { name: "Nicht lesbar", address: null, taxId: null, confidence: 0.99 },
})
);
expect(r.needsUserReview).toBe(true);
expect(r.reviewField).toBe("merchant");
});
test("EQ-4: Echter Händlername mit hoher Confidence bleibt ungeflaggt", () => {
const r = validateReceiptMath(receipt());
expect(r.needsUserReview).toBe(false);
});
test("EQ-5: Niedrige Confidence flaggt weiterhin unabhängig vom Namen", () => {
const r = validateReceiptMath(
receipt({
merchant: {
name: "REWE",
address: null,
taxId: null,
confidence: CONFIDENCE_THRESHOLDS.merchant - 0.01,
},
})
);
expect(r.needsUserReview).toBe(true);
expect(r.reviewField).toBe("merchant");
});
});
describe("Extraktion: Trinkgeld auf Bewirtungsbelegen", () => {
test("EQ-6: Trinkgeld-Positionen werden erkannt", () => {
expect(isTipLineItem("Trinkgeld")).toBe(true);
expect(isTipLineItem("trinkgeld")).toBe(true);
expect(isTipLineItem("Tip")).toBe(true);
expect(isTipLineItem("Gratuity")).toBe(true);
});
test("EQ-7: Normale Positionen sind kein Trinkgeld", () => {
expect(isTipLineItem("Pizza Margherita")).toBe(false);
expect(isTipLineItem("San Pellegrino 0.75l")).toBe(false);
expect(isTipLineItem("Tiramisu")).toBe(false);
expect(isTipLineItem(null)).toBe(false);
});
test("EQ-8: Trattoria-Fall — Tip zählt nicht gegen den Bruttobetrag", () => {
// Realer Beleg: Total 31,80 (Netto 26,72 + 19% 5,08), handschriftlich
// Trinkgeld 5,00 und Gesamtbetrag 36,80.
const r = validateReceiptMath(
receipt({
merchant: { name: "Trattoria Bella Vista", address: null, taxId: null, confidence: 0.98 },
documentType: "BEWIRTUNGSBELEG",
totalAmount: { value: 31.8, confidence: 0.98 },
netAmount: 26.72,
taxBreakdown: [{ ratePercent: 19, taxAmount: 5.08, netAmount: 26.72 }],
lineItems: [
{ description: "Pizza Margherita", quantity: 2, price: 25.0, unitPrice: 12.5, taxRate: 19 },
{ description: "San Pellegrino 0.75l", quantity: 1, price: 6.8, unitPrice: null, taxRate: 19 },
{ description: "Trinkgeld", quantity: 1, price: 5.0, unitPrice: null, taxRate: 0 },
],
})
);
expect(r.isMathValid).toBe(true);
expect(r.needsUserReview).toBe(false);
expect(r.issues.some((i) => i.field === "lineItems")).toBe(false);
});
test("EQ-9: Ohne Tip-Ausnahme bliebe eine echte Artikel-Abweichung erkennbar", () => {
const r = validateReceiptMath(
receipt({
totalAmount: { value: 31.8, confidence: 0.98 },
netAmount: 26.72,
taxBreakdown: [{ ratePercent: 19, taxAmount: 5.08, netAmount: 26.72 }],
lineItems: [
{ description: "Pizza Margherita", quantity: 2, price: 25.0, unitPrice: 12.5, taxRate: 19 },
{ description: "Dessert", quantity: 1, price: 11.8, unitPrice: null, taxRate: 19 },
],
})
);
expect(r.issues.some((i) => i.field === "lineItems")).toBe(true);
});
});
describe("Trinkgeld: tipAmount-Feld", () => {
const trattoria = () =>
receipt({
merchant: { name: "Trattoria Bella Vista", address: null, taxId: null, confidence: 0.98 },
documentType: "BEWIRTUNGSBELEG",
totalAmount: { value: 31.8, confidence: 0.98 },
netAmount: 26.72,
tipAmount: 5.0,
taxBreakdown: [{ ratePercent: 19, taxAmount: 5.08, netAmount: 26.72 }],
lineItems: [
{ description: "Pizza Margherita", quantity: 2, price: 25.0, unitPrice: 12.5, taxRate: 19 },
{ description: "San Pellegrino 0.75l", quantity: 1, price: 6.8, unitPrice: null, taxRate: 19 },
],
});
test("EQ-15: grossWithTip addiert das Trinkgeld auf den Rechnungsbetrag", () => {
expect(grossWithTip(trattoria())).toBe(36.8);
});
test("EQ-16: ohne Trinkgeld bleibt grossWithTip der Bruttobetrag", () => {
expect(grossWithTip(receipt())).toBe(11.9);
expect(grossWithTip(receipt({ tipAmount: null }))).toBe(11.9);
});
test("EQ-17: Trinkgeld verfälscht die Netto/MwSt-Gegenprobe nicht", () => {
const r = validateReceiptMath(trattoria());
expect(r.isMathValid).toBe(true);
expect(r.needsUserReview).toBe(false);
});
test("EQ-18: negatives Trinkgeld ist ein Fehler", () => {
const r = validateReceiptMath(receipt({ tipAmount: -2 }));
expect(r.isMathValid).toBe(false);
expect(r.needsUserReview).toBe(true);
});
test("EQ-19: Trinkgeld über dem Rechnungsbetrag wird zur Prüfung markiert", () => {
// Typischer Lesefehler: handschriftlicher Gesamtbetrag als Tip erfasst.
const r = validateReceiptMath(
receipt({ totalAmount: { value: 31.8, confidence: 0.98 }, netAmount: 26.72, tipAmount: 36.8, taxBreakdown: [{ ratePercent: 19, taxAmount: 5.08, netAmount: 26.72 }] })
);
expect(r.needsUserReview).toBe(true);
});
test("EQ-20: Excel führt Trinkgeld- und Gesamt-gezahlt-Spalte nur bei Bedarf", async () => {
const withTip = await generateDualSheetExcel([stored(trattoria())]);
const withoutTip = await generateDualSheetExcel([stored(receipt())]);
const headerOf = async (buf: Buffer) => {
const wb = new ExcelJS.Workbook();
await wb.xlsx.load(buf as unknown as ArrayBuffer);
const row = wb.worksheets[0].getRow(1);
const out: string[] = [];
row.eachCell((c) => out.push(String(c.value ?? "")));
return out;
};
const h1 = await headerOf(withTip);
expect(h1.some((h) => h.startsWith("Trinkgeld"))).toBe(true);
expect(h1.some((h) => h.startsWith("Gesamt gezahlt"))).toBe(true);
const h2 = await headerOf(withoutTip);
expect(h2.some((h) => h.startsWith("Trinkgeld"))).toBe(false);
});
test("EQ-21: CSV enthält Trinkgeld und Gesamt gezahlt", () => {
const csv = generateAccountingCsv([stored(trattoria())]);
const [header, row] = csv.replace(/^/, "").split("\r\n");
expect(header.includes("Trinkgeld")).toBe(true);
expect(header.includes("Gesamt gezahlt")).toBe(true);
// Rechnungsbetrag bleibt 31,80, gezahlt wurden 36,80.
expect(row.includes('"31,80"')).toBe(true);
expect(row.includes('"5,00"')).toBe(true);
expect(row.includes('"36,80"')).toBe(true);
});
test("EQ-22: CSV ohne Trinkgeld führt die Spalten nicht", () => {
const csv = generateAccountingCsv([stored(receipt())]);
expect(csv.split("\r\n")[0].includes("Trinkgeld")).toBe(false);
});
});
describe("Extraktion: Modell-Schema für OpenAI strict mode", () => {
test("EQ-10: validation ist nicht Teil des Modell-Schemas", () => {
const keys = Object.keys(ReceiptExtractionModelSchema.shape);
expect(keys.includes("validation")).toBe(false);
expect(keys.includes("merchant")).toBe(true);
expect(keys.includes("totalAmount")).toBe(true);
expect(keys.includes("taxBreakdown")).toBe(true);
});
test("EQ-11: kein Feld ist optional (strict verlangt required für jeden Key)", () => {
// .optional() erzeugt eine Lücke in `required` -> HTTP 400 invalid_json_schema.
const optional = Object.entries(ReceiptExtractionModelSchema.shape)
.filter(([, v]) => (v as { isOptional?: () => boolean }).isOptional?.())
.map(([k]) => k);
expect(optional.length).toBe(0);
});
test("EQ-12: Modell-Ausgabe ohne validation ist gültig", () => {
const sample = {
merchant: { name: "REWE", address: null, taxId: null, confidence: 0.97 },
date: { isoDate: "2026-08-12", time: "14:32", confidence: 0.95 },
documentType: "KASSENBON",
receiptNumber: "2026-004871",
currency: "EUR",
totalAmount: { value: 9.06, confidence: 0.98 },
netAmount: 8.18,
taxBreakdown: [
{ ratePercent: 7, taxAmount: 0.4, netAmount: 5.67 },
{ ratePercent: 19, taxAmount: 0.48, netAmount: 2.51 },
],
lineItems: [
{
description: "Vollmilch",
quantity: 2,
price: 2.58,
unitPrice: 1.29,
taxRate: 7,
confidence: 0.96,
sourceView: null,
rowOrder: null,
},
],
suggestedCategory: "Verpflegungsmehraufwand",
hospitality: null,
tipAmount: null,
paymentMethod: null,
};
expect(ReceiptExtractionModelSchema.safeParse(sample).success).toBe(true);
});
test("EQ-14: tipAmount ist gegenüber dem Modell Pflicht (nullable, nicht optional)", () => {
// Fehlt das Feld, wäre es nicht in `required` -> HTTP 400 im strict mode.
const shape = ReceiptExtractionModelSchema.shape as Record<string, { isOptional?: () => boolean }>;
expect("tipAmount" in shape).toBe(true);
expect(shape.tipAmount.isOptional?.() ?? false).toBe(false);
});
test("EQ-13: PENDING_VALIDATION ist neutral (flaggt nichts vor)", () => {
expect(PENDING_VALIDATION.needsUserReview).toBe(false);
expect(PENDING_VALIDATION.isMathValid).toBe(true);
expect(PENDING_VALIDATION.reviewField).toBe("none");
});
test("EQ-25: fokussierter OCR-Retry hat ein kleines strict-kompatibles Schema", () => {
const parsed = LineItemVerificationModelSchema.safeParse({
lineItems: [
{
description: "Bio Gurken",
quantity: 1,
price: 1.29,
unitPrice: null,
taxRate: 7,
confidence: 0.94,
sourceView: null,
rowOrder: null,
},
],
});
expect(parsed.success).toBe(true);
expect(Object.keys(LineItemVerificationModelSchema.shape)).toEqual(["lineItems"]);
});
test("EQ-26: Kachel-Batch verlangt Ausschnitt, Reihenfolge und Confidence", () => {
const parsed = LineItemViewBatchModelSchema.safeParse({
views: [
{
sourceView: 2,
lineItems: [
{
description: "Bio Gurken",
quantity: 1,
price: 1.29,
unitPrice: null,
taxRate: 7,
confidence: 0.93,
sourceView: 2,
rowOrder: 1,
},
],
},
],
});
expect(parsed.success).toBe(true);
});
});
describe("Extraktion: Qualitätsauswahl beim Reasoning-Retry", () => {
test("EQ-23: rechnerisch vollständige Positionen gewinnen gegen eine Warnung", () => {
const incomplete = receipt({
totalAmount: { value: 3.72, confidence: 0.99 },
lineItems: [
{ description: "Artikel A", quantity: 1, price: 1.99, unitPrice: null, taxRate: 7 },
{ description: "Artikel B", quantity: 1, price: 1.13, unitPrice: null, taxRate: 7 },
],
validation: {
...PENDING_VALIDATION,
needsUserReview: true,
issues: [{ field: "lineItems", severity: "warning", message: "Sum mismatch" }],
},
});
const complete = receipt({
totalAmount: { value: 3.72, confidence: 0.99 },
lineItems: [
{ description: "Artikel A", quantity: 1, price: 1.99, unitPrice: null, taxRate: 7 },
{ description: "Artikel B", quantity: 1, price: 1.73, unitPrice: null, taxRate: 7 },
],
validation: { ...PENDING_VALIDATION },
});
expect(extractionQualityScore(complete)).toBeLessThan(extractionQualityScore(incomplete));
expect(chooseBetterExtraction(incomplete, complete)).toBe(complete);
});
test("EQ-24: ein schlechterer Retry überschreibt die Erstextraktion nicht", () => {
const first = receipt({
totalAmount: { value: 1.99, confidence: 0.99 },
lineItems: [
{ description: "Bio Artikel", quantity: 1, price: 1.99, unitPrice: null, taxRate: 7 },
],
validation: { ...PENDING_VALIDATION },
});
const worse = receipt({
totalAmount: { value: 1.99, confidence: 0.99 },
lineItems: [],
validation: {
...PENDING_VALIDATION,
needsUserReview: true,
issues: [{ field: "lineItems", severity: "warning", message: "Missing" }],
},
});
expect(chooseBetterExtraction(first, worse)).toBe(first);
});
});
describe("Extraktion: deterministische Kachel-Zusammenführung", () => {
const item = (
description: string,
price: number,
sourceView: number,
rowOrder: number,
confidence: number = 0.9
) => ({
description,
quantity: 1,
price,
unitPrice: null,
taxRate: 7,
confidence,
sourceView,
rowOrder,
});
test("EQ-27: überlappende Zeilen erscheinen nur einmal", () => {
const merged = mergeLineItemViews([
{
sourceView: 1,
lineItems: [item("Artikel A", 1.29, 1, 1), item("Bio Heidelbeeren", 1.99, 1, 2)],
},
{
sourceView: 2,
lineItems: [
item("Bio Heidelbeer.", 1.99, 2, 1, 0.96),
item("Bio Gurken", 1.29, 2, 2),
],
},
]);
expect(merged.map((line) => line.price)).toEqual([1.29, 1.99, 1.29]);
expect(merged[1].description).toBe("Bio Heidelbeer.");
});
test("EQ-28: identische Produkte auf getrennten Zeilen bleiben erhalten", () => {
const merged = mergeLineItemViews([
{
sourceView: 1,
lineItems: [item("Wasser", 0.99, 1, 1), item("Wasser", 0.99, 1, 2)],
},
{
sourceView: 2,
lineItems: [item("Wasser", 0.99, 2, 1), item("Brot", 2.49, 2, 2)],
},
]);
expect(merged.map((line) => line.description)).toEqual(["Wasser", "Wasser", "Brot"]);
});
test("EQ-29: sehr lange Artikellisten erhalten keine wachsende Euro-Toleranz", () => {
const value = receipt({
totalAmount: { value: 100, confidence: 0.99 },
lineItems: Array.from({ length: 100 }, (_, index) => ({
description: `Artikel ${index + 1}`,
quantity: 1,
price: index === 99 ? 0.9 : 1,
unitPrice: null,
taxRate: 7,
})),
});
const validation = validateReceiptMath(value);
expect(validation.issues.some((issue) => issue.field === "lineItems")).toBe(true);
});
test("EQ-30: Gesamtbetrag ohne erkannte Artikel wird markiert", () => {
const value = receipt({ totalAmount: { value: 12.34, confidence: 0.99 }, lineItems: [] });
const validation = validateReceiptMath(value);
expect(validation.issues.some((issue) => issue.field === "lineItems")).toBe(true);
});
});