The problem
Models made invoice extraction look solved. A capable model reads a clean PDF and returns the right total, and a demo built on that is convincing in about ten minutes. The demo is not the problem. Putting the same pipeline in front of an accounts payable team is, because at that point somebody has to answer a question the demo never asked: how do you know it was right this time?
That question has no good answer in most extraction systems, because nothing in them is measured. Accuracy is quoted from a vendor benchmark on documents nobody has seen. Consistency is not quoted at all, and consistency is what decides whether a pipeline can run unattended: a system that is 95% accurate and gives a different answer each time it reads the same page cannot be trusted with a ledger.
So this was built the other way round. The extractor is the ordinary part. The instrument that says what the extractor is worth is the work, and the application is what makes that instrument useful to somebody who is not an engineer.
What was built
A review application, running on FastAPI and Postgres behind a React interface, in one Docker image on one port. A document is uploaded, read, validated against its own arithmetic, and placed in a queue ordered by how suspicious it looks.
The reviewer's screen is the document and the extracted values side by side. pdf.js renders the page, selecting a field boxes that value on the page, and arrow keys walk the fields. Locating happens in the browser against the PDF text layer rather than in the backend, which costs the server nothing and, more usefully, means it keeps working for a model that returns values with no coordinates at all.
An analytics page keeps two questions apart that most dashboards merge: what was invoiced, and how reliably it is being read. The first shows one row per currency, because adding euros to yen produces a number that is meaningless and looks authoritative. The second shows validation pass rate by vendor, worst first, beside the live failure taxonomy.
Underneath both sits the measurement harness: a synthetic corpus where ground truth is decided first and the document drawn from it, a scorer that separates five verdicts rather than two, line-item alignment that matches rows before comparing them, and consistency runs that read the same document repeatedly to see how far the answer moves. 287 tests.
How it is put together
One Docker image, one port. FastAPI and Postgres behind a React interface built by Vite, with the measurement harness imported as a library rather than reimplemented. The dependency runs one way only: the service imports the harness, never the reverse, which is what keeps the application from growing a second opinion about how a document should be read.
The interesting boundary is not the HTTP one. It is the provider protocol: one document in, one invoice out, with everything model-specific behind it. That is what lets the whole system run offline with no key and no spend, because the mock satisfies the same protocol and the pipeline cannot tell the difference. The harness is therefore exercised by the same code path that later carries paid calls, not by a lookalike.
The path of one document
- 01A PDF is uploaded and stored, hashed, and its page count read.
- 02Ingestion pulls the text layer. The vision path exists and raises, because a path that quietly returned zeros would be indistinguishable from a model that failed.
- 03The provider is handed one request and returns one invoice, plus token counts and latency. Which provider produced it is recorded on the row.
- 04Validation runs the document against its own arithmetic and returns findings, never exceptions.
- 05The findings become a suspicion score, and the score decides whether a human needs to look.
- 06The document enters the queue at that position. A failed extraction is stored, not dropped, and sorts to the top.
What a row means
document → extraction → invoice → line_item (+ finding, on the extraction)The table in the middle is the one most systems do not have. A row in extractions means this provider read this document at this moment and got this. Reading the same document again appends an attempt beside the last rather than overwriting it, so consistency becomes a query over real traffic instead of a laboratory exercise. A schema storing only the latest invoice per document would have discarded the evidence before anyone thought to ask for it, and would have been unable to answer a single question the reliability report asks.
Module map
- src/invoice_eval/
- The harness. Schema, currency and money rules, validation, the synthetic generator, the extraction pipeline, the scorer, line-item alignment, consistency and the failure taxonomy.
- service/
- The application. FastAPI routes, SQLAlchemy models, ingestion, the suspicion sort key, analytics queries and the seeder.
- service/types.py
- Column types, and the one that matters: money stored as NUMERIC(18,4) where the database has it and as text where it does not, refusing a float at bind time.
- service/providers.py
- Wires the application to the same provider boundary the harness uses. Switching to a real model is one environment variable.
- web/src/
- React: the queue, the split-screen validation view with pdf.js, the analytics view and its charts. Twelve files.
- tests/
- 287 tests, including mutation checks that deliberately break the scorer to confirm the tests actually fail.
The application



Decisions
Money never touches a float, including in storage.
In binary floating point, 0.1 plus 0.2 is not 0.3. This is a program whose entire job is checking whether numbers add up, so a float is refused at the boundary rather than quietly converted, and the database column is a decimal type that rejects one at bind time. The obvious shortcut here fails silently and months later, which is the worst combination available.
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 valueValidation annotates. It never rejects.
Every rule returns a finding instead of raising, because in production the useful output is not pass or fail, it is a list sorted by how much each document needs a human. The weights are part of the code and ship with it: an unreadable document outranks everything, one error outranks several warnings, and warnings alone never stop the line. Queueing soft findings trains people to clear a queue without reading it, which is worse than not having a queue.
#: 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)An invoice carries its own checksum, so use it.
Line items should sum to the subtotal, subtotal plus tax should equal the total, and a due date should not precede its issue date. None of that needs ground truth, which is the point: it gives a quality signal on unlabelled production data, where a benchmark score cannot reach. It is cheap, deterministic and completely uninteresting to build, which is roughly why most projects skip it.
The database stores extraction events, not invoices.
A row means this provider read this document at this moment and got this. Reading again adds an attempt beside the last rather than overwriting it, so consistency becomes a query over real traffic instead of a laboratory exercise. A system storing only the latest value per document would have discarded the evidence before anyone thought to ask for it.
The application implements no extraction of its own.
Everything goes through the same provider boundary the measurement harness uses. The moment the application reads a document differently from the harness, the published numbers stop describing the product. Keeping the harness and the application in one codebase makes that structurally difficult rather than merely discouraged.
The model's output grammar was removed on purpose.
The API can constrain a model so its response is guaranteed to parse. That was in place, and it was hiding the measurement: a grammar-constrained model cannot return unparseable output, so the parse rate would have read 100% by construction and part of the quality score would have been measuring the API's decoder rather than the extractor. The shape moved into the prompt, an unparseable response now counts as a failure, and there is no repair step and no retry. A low parse rate is a result.
A value that cannot be located is reported as not found.
The review pane never draws an approximate box. A box in roughly the right place tells a reviewer the number was checked, and the entire purpose of the screen is that the reviewer checks it.
What it found
56% to 70%
One bug, found by looking at the screen rather than by running the tests.
On an invoice with no tax row, the only VAT string on the page is the vendor's own registration number in the letterhead. The heuristic accepted any candidate containing a digit, and so read an identifier as a tax charge of 148 million dinar. Requiring a money label's value to look like a number moved exactly extracted documents from 56% to 70%, and validation pass rate from 80% to 98%.
251
Passing tests that did not surface that bug, or the one beside it.
Both were found by opening the running application and reading the screen. Three more layout defects surfaced the same way, including a sticky table header that covered the first row of the queue so clicks on that row hit the header instead. This project verifies by measurement, and measurement does not cover layout.
2 of 2
Models in the intended comparison disagreed about their own parameters.
One rejected an effort setting outright with a 400. The other enabled extended thinking unless explicitly told not to, which would have billed reasoning tokens at output rates for a task that is reading, not reasoning. A hardcoded table of that goes stale at the next model release, so the provider asks the API what the target model accepts and configures itself once per run.
What it does not do
- No authentication and no user model. It binds to localhost and anything beyond one machine needs auth built first.
- Extraction is synchronous. That is right for one document at a time and wrong for thousands; there is no background queue.
- No correction loop yet. The intended payoff is that a reviewer's confirmed value becomes ground truth, so the application generates the labelled data the harness measures against. The data model is in place for it and the feature is not built.
- Scanned documents are not supported. The corpus renders clean digital PDFs, and the vision path raises rather than returning zeros, because an unimplemented path that quietly reported 0% would be indistinguishable from a model that failed.
- The headline model comparison has been specified and costed but not run. Single document probes confirmed the paid path works end to end; the full sweep across two tiers has not happened.
- It is a single tenant working tool, not a product. It has never been deployed anywhere but a laptop.
Where it stands
Built and running. Development is parked rather than finished: eight of ten planned steps are complete, the application works, and the model tier comparison that produces the headline number is specified, costed at roughly thirty cents, and unrun.
The source is not public. It was always intended to be published as a fresh repository once the numbers reproduce in one command, and that gate has not been reached, so there is deliberately no link to it here. Publishing something unfinished behind a case study is how a portfolio spends credibility instead of building it.