anishfyi.com Projects All writings

July 27, 202612 min readOpen Source, Python, Applied AI, terbium

Parse first, escalate only what you must

Most document pipelines throw the whole file at a model and bill you for the easy pages too. On rebuilding tables from word geometry, scoring yourself 0 to 1, and why the confidence score is the most important output.

A vendor sends a 200 page catalogue as a PDF. You need it as rows: SKU, name, materials, image. The modern instinct is to hand the whole file to a vision model and let it read.

It works. It also bills you for all 200 pages, including the 180 that were a clean, machine-readable table you could have parsed for free, and it gives you no way to know which of the 4,000 rows it invented.

That second part is the real problem. The cost is annoying. The uniform confidence is dangerous. A model returns a well-formed row for a page it read perfectly and a well-formed row for a page it guessed at, and nothing in the output distinguishes them.

terbium is built on the opposite premise: solve what you can algorithmically, score how sure you are, and escalate only the parts that are genuinely ambiguous.

A document carries content, not structure

Here is the thing that makes document parsing harder than it looks. A PDF does not contain a table. It contains a set of text runs, each with coordinates.

When you look at a spec sheet, your visual system reconstructs a grid from alignment: these six strings share a left edge, so they are a column; these four share a baseline, so they are a row. That reconstruction is so automatic that it is easy to forget it happened.

A text extractor does not do it. It walks the runs in whatever order they were written and concatenates them. The columns collapse into one line, the grid is gone, and what you get back is a paragraph that happens to contain all the right words in nearly the wrong order.

This is why "just extract the text" fails on exactly the documents you most want to parse. Prose survives flattening. Tables do not. And business documents are almost entirely tables: catalogues, invoices, price lists, schedules, financial grids, size-by-finish matrices.

Rebuilding the grid from geometry

terbium's approach is to keep the coordinates and rebuild the structure the same way your eye does. Every adapter, one per format, normalizes its input into positioned words plus images. From there the reconstruction pass does the work that text extraction throws away:

The detector is content-agnostic on purpose. It works on any column-aligned table. Furniture catalogues are the worked example in the docs because that is the domain that drove the early versions, not because the algorithm knows what a sofa is.

Scoring yourself, honestly

Reconstruction alone is not enough, because reconstruction can be wrong and look fine. So every table gets a confidence between 0 and 1, computed from properties you can actually observe:

Those three are deliberately structural. None of them requires understanding the content, which is what keeps the scorer honest: it cannot convince itself a table is good because the words in it look plausible.

The score is the product's most important output, more than the rows are, because it turns a black box into something you can build a process around:

doc = terbium.parse("pricelist.xlsx", schema="product")
print(doc.stats)
# Stats(total=725, confident=712, ambiguous=13)

Thirteen rows need a human or a model. Seven hundred and twelve do not. You now know the size of your problem before you have spent anything on it. Compare that with a pipeline that returns 725 rows of undifferentiated confidence and leaves you to find the bad ones by having a customer complain.

The escalation ladder

Only after scoring does a model enter the picture, and only for the pages that scored below threshold. The whole loop:

FILE  ->  ADAPT  ->  RECONSTRUCT  ->  SCORE  ->  [ESCALATE]
             |            |             |            |
       pdf/pptx/     columns, rows,  confidence   hard pages only:
       xlsx/csv      matrices from   per record   AI if key, else
                     geometry                      "add a key" message

Escalation routes the individual page to an appropriate model tier rather than reaching for the largest model by reflex. Most ambiguous pages are ambiguous in small ways and a cheap model resolves them. Reserving the expensive tier for pages that need it is the same argument as reserving the model at all.

And if you did not supply a key, terbium does not silently return worse output. It tells you, in plain words, that a key would resolve these specific pages. That distinction matters: a parser that quietly degrades is a parser you cannot trust in a pipeline, because you cannot tell a bad day from a bad file.

The catalogue case

The flagship path is a single call, and it needs no key at all on a clean file:

import terbium

rows = terbium.build_catalog("vendor_catalogue.pdf", images_dir="images/")
terbium.to_catalog_csv(rows, "catalogue.csv")
# rows: {"sku": "RG-1001", "name": "Anatolia Kilim",
#        "materials": "wool", "image": "Anatolia_Kilim.jpeg", "page": 12}

The insight that makes this work is that in a catalogue, the photo is the anchor. Every product has one, it is unambiguous, and the label beneath it is almost always the name. So terbium extracts each image, names it from the text directly below, and mines the surrounding text for the SKU and the materials.

That gets clean catalogues out complete with zero model calls. A visual lookbook that buries the name in a display title and the material in a paragraph of body copy is the hard case, and that is exactly where you pass ai=terbium.AI() so a vision model reads the photo plus the page text and fills the blanks.

Same engine, different documents:

rows = terbium.parse("invoice.pdf")                  # line items, totals, vendor
cv   = terbium.parse("maria_cv.pdf", schema="resume") # sections, experience, skills

And from the shell, because half of this work happens in a terminal at the end of a day:

terbium catalogue.pdf --csv out.csv         # product table + images/, no AI
terbium lookbook.pdf --images out/          # product photos + manifest.csv
terbium invoice.pdf --csv rows.csv --html report.html

Why this is a doctrine, not an optimisation

It would be easy to read all of this as cost engineering. Cheaper is a consequence, but it is not the argument.

The argument is that the intelligence belongs in the logic, not the model call. A deterministic parser has properties a model does not: it is reproducible, so the same file gives the same rows tomorrow. It is debuggable, so a wrong column is a bug with a location rather than a prompt to reword. It is auditable, so you can explain to a vendor why their SKU came out the way it did. And it is honest about failure, because a confidence score is a claim you can check.

A model has exactly one property the parser does not, which is that it can read a page whose structure was never recoverable from geometry in the first place. That is a real and valuable property. It is also a narrow one, and paying for it on every page means paying for it on the 90 percent where it adds nothing.

Put another way: the deterministic path handles the cases you can specify, the model handles the residue, and the confidence score is the interface between them. Remove the score and you no longer have a system, you have two parsers and a coin flip.

What it does not do

terbium reads PDF, PPTX, XLSX and CSV. It does not do OCR on a scanned page that contains no text layer at all, which is a genuinely different problem. It does not understand your business rules: it will faithfully return a SKU that is wrong in your ERP, because it is a parser and not a validator. And on a document with no recoverable structure, it will say so instead of inventing one, which is the behaviour you want and occasionally not the behaviour you were hoping for.

pip install terbium-parse. Python 3.9+, MIT licensed, sponsored by NodeMaven. Source at github.com/anishfyi/terbium, docs at anishfyi.github.io/terbium.

Working on something in this territory? Book thirty minutes.