Projekte
DokumentenverarbeitungGebaut und im Einsatz2026

Rechnungsextraktion, gebaut um gemessen zu werden

Ein Prüfwerkzeug für die Kreditorenbuchhaltung, dessen eigentliches Produkt der Nachweis ist, dass es funktioniert.

PythonFastAPIPostgresSQLAlchemyReactTypeScriptVitepdf.jsDocker
Problem
Ein Modell liest eine Rechnung in der Vorführung richtig, und niemand kann sagen, wie oft es im Betrieb falsch liegt.
Ergebnis
Eine Prüfanwendung, die jedes Dokument gegen seine eigene Arithmetik validiert und danach einreiht, wie verdächtig es aussieht, mit 287 Tests dahinter.
Meine Rolle
Alleiniger Entwickler. Backend, Frontend, Messaufbau und die Zuverlässigkeitsargumentation.
Stand
Gebaut und im Einsatz. Die Entwicklung ist bei acht von zehn Schritten bewusst angehalten.

Das Problem

Modelle ließen die Rechnungsextraktion als gelöst erscheinen. Ein leistungsfähiges Modell liest ein sauberes PDF und gibt die richtige Summe zurück, und eine darauf gebaute Vorführung überzeugt in etwa zehn Minuten. Die Vorführung ist nicht das Problem. Dieselbe Pipeline einer Kreditorenbuchhaltung vorzusetzen schon, denn an diesem Punkt muss jemand eine Frage beantworten, die in der Vorführung nie gestellt wurde: Woher wissen Sie, dass es diesmal richtig war?

Auf diese Frage gibt es in den meisten Extraktionssystemen keine gute Antwort, weil darin nichts gemessen wird. Die Genauigkeit wird aus einem Anbieter-Benchmark auf Dokumenten zitiert, die niemand gesehen hat. Die Konsistenz wird überhaupt nicht zitiert, und die Konsistenz entscheidet darüber, ob eine Pipeline unbeaufsichtigt laufen kann: Einem System, das zu 95 Prozent genau ist und bei jedem Lesen derselben Seite eine andere Antwort gibt, kann man kein Hauptbuch anvertrauen.

Also wurde das hier andersherum gebaut. Der Extraktor ist der gewöhnliche Teil. Die Arbeit steckt im Messinstrument, das sagt, was der Extraktor taugt, und die Anwendung ist das, was dieses Instrument auch für jemanden nutzbar macht, der nicht entwickelt.

Was gebaut wurde

Eine Prüfanwendung auf FastAPI und Postgres hinter einer React-Oberfläche, in einem Docker-Image auf einem Port. Ein Dokument wird hochgeladen, gelesen, gegen seine eigene Arithmetik validiert und in eine Warteschlange eingereiht, die danach sortiert ist, wie verdächtig es aussieht.

Die Maske der prüfenden Person zeigt das Dokument und die extrahierten Werte nebeneinander. pdf.js stellt die Seite dar, die Auswahl eines Feldes rahmt den zugehörigen Wert auf der Seite ein, und mit den Pfeiltasten geht man die Felder durch. Das Verorten geschieht im Browser gegen die Textebene des PDF statt im Backend, was den Server nichts kostet und, nützlicher noch, auch dann weiter funktioniert, wenn ein Modell Werte ganz ohne Koordinaten zurückgibt.

Eine Auswertungsseite hält zwei Fragen auseinander, die die meisten Dashboards vermengen: was in Rechnung gestellt wurde, und wie zuverlässig es gelesen wird. Die erste zeigt eine Zeile je Währung, denn Euro zu Yen zu addieren ergibt eine Zahl, die bedeutungslos ist und maßgeblich aussieht. Die zweite zeigt die Validierungsquote je Lieferant, die schlechteste zuerst, daneben die laufend geführte Fehlersystematik.

Unter beidem sitzt der Messaufbau: ein synthetischer Korpus, bei dem zuerst die Wahrheit festgelegt und dann das Dokument daraus gezeichnet wird, ein Bewerter, der fünf Urteile statt zwei unterscheidet, eine Positionszuordnung, die Zeilen einander zuordnet, bevor sie verglichen werden, und Konsistenzläufe, die dasselbe Dokument wiederholt lesen, um zu sehen, wie weit die Antwort wandert. 287 Tests.

Wie es aufgebaut ist

Ein Docker-Image, ein Port. FastAPI und Postgres hinter einer mit Vite gebauten React-Oberfläche, wobei der Messaufbau als Bibliothek eingebunden statt nachgebaut wird. Die Abhängigkeit läuft nur in eine Richtung: Der Dienst bindet den Messaufbau ein, nie umgekehrt, und genau das hindert die Anwendung daran, eine zweite Meinung darüber zu entwickeln, wie ein Dokument zu lesen ist.

Die interessante Grenze ist nicht die über HTTP. Es ist das Provider-Protokoll: ein Dokument hinein, eine Rechnung heraus, und alles Modellspezifische liegt dahinter. Das ist es, was das gesamte System offline laufen lässt, ohne Schlüssel und ohne Kosten, denn der Mock erfüllt dasselbe Protokoll, und die Pipeline kann den Unterschied nicht bemerken. Der Messaufbau wird damit über denselben Codepfad beansprucht, der später die bezahlten Aufrufe trägt, und nicht über einen Nachbau.

Der Weg eines Dokuments

  1. 01Ein PDF wird hochgeladen und gespeichert, gehasht, und seine Seitenzahl wird gelesen.
  2. 02Die Aufnahme holt die Textebene. Der Bildpfad existiert und wirft einen Fehler, denn ein Pfad, der stillschweigend Nullen zurückgäbe, wäre von einem Modell, das gescheitert ist, nicht zu unterscheiden.
  3. 03Dem Provider wird eine Anfrage übergeben, und er gibt eine Rechnung zurück, dazu Tokenzahlen und Laufzeit. Welcher Provider sie erzeugt hat, wird auf der Zeile vermerkt.
  4. 04Die Validierung prüft das Dokument gegen seine eigene Arithmetik und gibt Befunde zurück, nie Ausnahmen.
  5. 05Aus den Befunden wird ein Verdachtswert, und dieser Wert entscheidet, ob ein Mensch hinsehen muss.
  6. 06Das Dokument reiht sich an dieser Stelle in die Warteschlange ein. Eine gescheiterte Extraktion wird gespeichert, nicht verworfen, und sortiert sich nach oben.

Was eine Zeile bedeutet

document → extraction → invoice → line_item   (+ finding, on the extraction)

Die Tabelle in der Mitte ist diejenige, die die meisten Systeme nicht haben. Eine Zeile in extractions bedeutet: Dieser Provider hat dieses Dokument in diesem Moment gelesen und dies erhalten. Dasselbe Dokument erneut zu lesen hängt einen Versuch neben den letzten, statt ihn zu überschreiben, sodass Konsistenz zu einer Abfrage über echten Verkehr wird statt zu einer Laborübung. Ein Schema, das nur die jeweils letzte Rechnung je Dokument speichert, hätte die Belege verworfen, bevor jemand auf die Idee kam, danach zu fragen, und könnte keine einzige der Fragen beantworten, die der Zuverlässigkeitsbericht stellt.

Modulübersicht

src/invoice_eval/
Der Messaufbau. Schema, Währungs- und Geldregeln, Validierung, der synthetische Generator, die Extraktionspipeline, der Bewerter, die Positionszuordnung, Konsistenz und die Fehlersystematik.
service/
Die Anwendung. FastAPI-Routen, SQLAlchemy-Modelle, Aufnahme, der Sortierschlüssel für den Verdacht, Auswertungsabfragen und der Seeder.
service/types.py
Spaltentypen, und der entscheidende darunter: Geld wird als NUMERIC(18,4) gespeichert, wo die Datenbank das hergibt, und sonst als Text, wobei eine Fließkommazahl schon beim Binden abgewiesen wird.
service/providers.py
Verbindet die Anwendung mit derselben Provider-Grenze, die auch der Messaufbau nutzt. Der Wechsel auf ein echtes Modell ist eine einzige Umgebungsvariable.
web/src/
React: die Warteschlange, die geteilte Prüfansicht mit pdf.js, die Auswertungsansicht und ihre Diagramme. Zwölf Dateien.
tests/
287 Tests, darunter Mutationsprüfungen, die den Bewerter absichtlich beschädigen, um zu bestätigen, dass die Tests tatsächlich fehlschlagen.

Die Anwendung

Die Prüfmaske. Eine dargestellte Rechnung füllt die linke Fensterhälfte; die rechte Hälfte listet die extrahierten Felder, eine Rechenprüfung und einen Befund mit dem Text 'due before issue'.
Die Prüfmaske. Die ganze Aufgabe einer prüfenden Person besteht darin, einen Wert mit der Seite zu vergleichen, von der er stammt, also stehen Dokument und Felder nebeneinander, und nichts schickt sie in einen zweiten Tab. Das Fälligkeitsdatum liegt hier vor dem Rechnungsdatum, was die Arithmetik des Dokuments selbst auffängt, ganz ohne hinterlegte Wahrheit. Die Positionstabelle darunter umfasst 28 Zeilen.
Die Prüfwarteschlange. Eine Tabelle von Rechnungen, sortiert nach einer Verdachtsspalte, die obersten Zeilen mit 'Needs review' und je einem Fehler markiert, die Zeilen darunter mit 'Passed' und Warnungen.
Die Warteschlange ist die Eingangstür und kein Reiter. Die Dokumente sind danach geordnet, wie sehr sie einen Menschen brauchen, und der Wert stammt aus der Arithmetik jedes Dokuments selbst, sodass er keine hinterlegte Wahrheit benötigt und auch bei einer Rechnung funktioniert, die nie jemand beschriftet hat. Alles hier wird schon beim Eintritt als 'synthetic' gekennzeichnet, damit erzeugte Daten nicht versehentlich als echte ausgegeben werden können.
Die Auswertungsseite. Fünf Kennzahlenfelder, eine Tabelle des in Rechnung gestellten Werts mit je einer Zeile für ISK, JPY, EUR und KWD, sowie drei Diagramme, darunter die Validierungsquote je Lieferant mit dem schlechtesten Lieferanten in Rot.
Zwei Fragen, auseinandergehalten: was in Rechnung gestellt wurde, und wie zuverlässig es gelesen wird. Der in Rechnung gestellte Wert steht in je einer Zeile pro Währung und nie in einer Gesamtsumme, denn Euro zu Yen zu addieren ergibt eine Zahl, die nichts bedeutet und dabei genauso maßgeblich aussieht wie eine, die etwas bedeutet. Die Quote je Lieferant beginnt mit der schlechtesten, und Lieferanten mit nur einem Dokument bleiben außen vor, weil ein einzelnes Dokument eine Anekdote ist und keine Quote.

Entscheidungen

Geld berührt nie eine Fließkommazahl, auch nicht in der Speicherung.

In binärer Fließkommadarstellung ist 0,1 plus 0,2 nicht 0,3. Dies ist ein Programm, dessen ganze Aufgabe darin besteht zu prüfen, ob Zahlen aufgehen, also wird eine Fließkommazahl an der Grenze abgewiesen statt stillschweigend umgewandelt, und die Datenbankspalte ist ein Dezimaltyp, der sie schon beim Binden zurückweist. Die naheliegende Abkürzung scheitert hier lautlos und erst Monate später, was die schlechteste verfügbare Kombination ist.

service/types.py
def process_bind_param(self, value: Any, dialect: Any) -> Any:
    if value is None:
        return None
    if isinstance(value, float):
        # Refused rather than converted. A float here means a caller
        # has already lost precision, and quietly accepting it would
        # store the loss and make it permanent.
        raise TypeError(
            "money must not be a float; pass a Decimal or a string of digits"
        )
    value = value if isinstance(value, Decimal) else Decimal(str(value))
    return str(value) if dialect.name == "sqlite" else value

Die Validierung kommentiert. Sie weist nie zurück.

Jede Regel gibt einen Befund zurück, statt einen Fehler zu werfen, denn im Betrieb ist die nützliche Ausgabe kein Bestanden oder Durchgefallen, sondern eine Liste, sortiert danach, wie sehr jedes Dokument einen Menschen braucht. Die Gewichte sind Teil des Codes und werden mit ihm ausgeliefert: Ein unlesbares Dokument sticht alles, ein Fehler sticht mehrere Warnungen, und Warnungen allein halten die Bearbeitung nie an. Weiche Befunde in die Warteschlange zu stellen erzieht Menschen dazu, eine Warteschlange abzuarbeiten, ohne sie zu lesen, und das ist schlimmer, als gar keine zu haben.

service/suspicion.py
#: Nothing was read at all.
UNPARSED = 100

#: A document that contradicts itself. Three errors saturate the scale.
PER_ERROR = 30

#: Plausible but odd. Warnings alone cap below a single error.
PER_WARNING = 8

#: Highest score a parsed document can reach, so an unparsed one is
#: always first in the queue rather than tied with a very bad reading.
PARSED_CEILING = 99


def suspicion_score(findings: list[Finding], *, parsed: bool) -> int:
    """0-100, higher means look at this one first."""
    if not parsed:
        return UNPARSED

    errors = sum(1 for f in findings if f.severity is Severity.ERROR)
    warnings = sum(1 for f in findings if f.severity is Severity.WARNING)
    return min(PARSED_CEILING, errors * PER_ERROR + warnings * PER_WARNING)

Eine Rechnung trägt ihre eigene Prüfsumme, also nutzen Sie sie.

Die Positionen sollten sich zur Zwischensumme addieren, Zwischensumme plus Steuer sollte die Endsumme ergeben, und ein Fälligkeitsdatum sollte nicht vor seinem Rechnungsdatum liegen. Nichts davon braucht eine hinterlegte Wahrheit, und darum geht es: Es liefert ein Qualitätssignal auf unbeschrifteten Produktivdaten, wohin ein Benchmark-Wert nicht reicht. Es ist billig, deterministisch und beim Bauen vollkommen uninteressant, was ungefähr der Grund ist, warum die meisten Projekte es auslassen.

Die Datenbank speichert Extraktionsereignisse, keine Rechnungen.

Eine Zeile bedeutet: Dieser Provider hat dieses Dokument in diesem Moment gelesen und dies erhalten. Erneutes Lesen hängt einen Versuch neben den letzten, statt ihn zu überschreiben, sodass Konsistenz zu einer Abfrage über echten Verkehr wird statt zu einer Laborübung. Ein System, das nur den jeweils letzten Wert je Dokument speichert, hätte die Belege verworfen, bevor jemand auf die Idee kam, danach zu fragen.

Die Anwendung führt keine eigene Extraktion aus.

Alles läuft über dieselbe Provider-Grenze, die auch der Messaufbau nutzt. In dem Moment, in dem die Anwendung ein Dokument anders liest als der Messaufbau, beschreiben die veröffentlichten Zahlen nicht mehr das Produkt. Den Messaufbau und die Anwendung in einer Codebasis zu halten macht das strukturell schwierig, statt bloß davon abzuraten.

Die Ausgabegrammatik des Modells wurde absichtlich entfernt.

Die API kann ein Modell so einschränken, dass sich seine Antwort garantiert parsen lässt. Das war eingerichtet, und es verdeckte die Messung: Ein grammatikalisch eingeschränktes Modell kann keine unparsbare Ausgabe liefern, also hätte die Parse-Quote konstruktionsbedingt 100 Prozent betragen, und ein Teil der Qualitätsbewertung hätte den Decoder der API gemessen statt den Extraktor. Die Form wanderte in den Prompt, eine unparsbare Antwort zählt jetzt als Fehlschlag, und es gibt weder eine Reparaturstufe noch einen erneuten Versuch. Eine niedrige Parse-Quote ist ein Ergebnis.

Ein Wert, der sich nicht verorten lässt, wird als nicht gefunden gemeldet.

Die Prüfansicht zeichnet nie einen ungefähren Rahmen. Ein Rahmen an ungefähr der richtigen Stelle sagt der prüfenden Person, die Zahl sei geprüft worden, und der ganze Zweck dieser Maske ist, dass sie sie prüft.

Was dabei herauskam

56 % auf 70 %

Ein Fehler, gefunden durch einen Blick auf den Bildschirm und nicht durch die Tests.

Bei einer Rechnung ohne Steuerzeile ist die einzige Umsatzsteuerangabe auf der Seite die eigene Identifikationsnummer des Lieferanten im Briefkopf. Die Heuristik akzeptierte jeden Kandidaten, der eine Ziffer enthielt, und las so eine Kennnummer als Steuerbetrag von 148 Millionen Dinar. Zu verlangen, dass der Wert hinter einer Geldbezeichnung wie eine Zahl aussieht, hob die exakt extrahierten Dokumente von 56 auf 70 Prozent und die Validierungsquote von 80 auf 98 Prozent.

251

Bestandene Tests, die weder diesen Fehler noch den daneben ans Licht brachten.

Beide wurden gefunden, indem die laufende Anwendung geöffnet und der Bildschirm gelesen wurde. Drei weitere Layoutfehler kamen auf demselben Weg zum Vorschein, darunter ein fixierter Tabellenkopf, der die erste Zeile der Warteschlange verdeckte, sodass Klicks auf diese Zeile den Kopf trafen. Dieses Projekt prüft durch Messung, und Messung erfasst kein Layout.

2 von 2

Die Modelle des geplanten Vergleichs waren sich über ihre eigenen Parameter uneinig.

Das eine wies eine Aufwandseinstellung rundheraus mit einem 400 zurück. Das andere schaltete erweitertes Nachdenken ein, sofern man es nicht ausdrücklich untersagte, was Reasoning-Tokens zu Ausgabepreisen abgerechnet hätte, für eine Aufgabe, die Lesen ist und nicht Nachdenken. Eine fest verdrahtete Tabelle dazu veraltet mit der nächsten Modellveröffentlichung, also fragt der Provider die API, was das Zielmodell akzeptiert, und konfiguriert sich einmal je Lauf selbst.

Was es nicht leistet

  • Keine Authentifizierung und kein Benutzermodell. Es bindet an localhost, und alles über eine Maschine hinaus verlangt, dass zuerst eine Authentifizierung gebaut wird.
  • Die Extraktion läuft synchron. Das ist für ein Dokument nach dem anderen richtig und für Tausende falsch; eine Warteschlange im Hintergrund gibt es nicht.
  • Noch keine Korrekturschleife. Der beabsichtigte Ertrag ist, dass ein von einer prüfenden Person bestätigter Wert zur hinterlegten Wahrheit wird, sodass die Anwendung genau die beschrifteten Daten erzeugt, gegen die der Messaufbau misst. Das Datenmodell ist dafür vorhanden, die Funktion ist es nicht.
  • Gescannte Dokumente werden nicht unterstützt. Der Korpus erzeugt saubere digitale PDFs, und der Bildpfad wirft einen Fehler, statt Nullen zurückzugeben, denn ein nicht umgesetzter Pfad, der stillschweigend 0 Prozent meldete, wäre von einem gescheiterten Modell nicht zu unterscheiden.
  • Der maßgebliche Modellvergleich ist festgelegt und kalkuliert, aber nicht durchgeführt. Einzelne Dokumentenproben haben bestätigt, dass der bezahlte Pfad durchgängig funktioniert; der vollständige Durchlauf über zwei Modellstufen hat nicht stattgefunden.
  • Es ist ein funktionierendes Werkzeug für einen einzelnen Mandanten, kein Produkt. Es war nie irgendwo anders im Einsatz als auf einem Laptop.

Wo es steht

Gebaut und im Einsatz. Die Entwicklung ist angehalten und nicht abgeschlossen: Acht von zehn geplanten Schritten sind fertig, die Anwendung funktioniert, und der Vergleich der Modellstufen, der die maßgebliche Zahl liefert, ist festgelegt, mit etwa dreißig Cent kalkuliert und nicht durchgeführt.

Der Quellcode ist nicht öffentlich. Er sollte immer als frisches Repository veröffentlicht werden, sobald sich die Zahlen mit einem einzigen Befehl reproduzieren lassen, und diese Schwelle ist nicht erreicht, weshalb hier bewusst kein Link darauf steht. Etwas Unfertiges hinter einer Fallstudie zu veröffentlichen ist die Art, wie ein Portfolio Glaubwürdigkeit ausgibt statt sie aufzubauen.