Back to Blog
Build in Public

Day 4 of Document Extraction Workbench: Cleanup is code, not a sentence in the prompt

Day 4 added the cleanup rules. A total of 125 becomes 125.00. February 30, a negative amount, and a number with three decimal places are rejected. A missing currency stays null.

Document Extraction Workbench: an invoice becomes a structured record with a source page
By Brian Shimkus 5 min read

The short version

Day 3 got the words off the file. Day 4 decides which of those words are allowed to count as a date, a currency, or a total.

I still did not call a model. The rules are a Python function named normalize. Five tests passed.

What the function accepts

normalize lives in work/extract.py. None stays None. A blank string, including one that is only spaces, is an error. Vendor and invoice number are returned unchanged, aside from stripping extra space.

def normalize(name, value):
    if value is None:
        return None
    value = value.strip()
    if not value:
        raise ValueError("empty value")
    if name == "date":
        return date.fromisoformat(value).isoformat()
    if name == "currency":
        if value.upper() not in {"USD", "EUR", "GBP"}:
            raise ValueError("unsupported or ambiguous currency")
        return value.upper()
    if name == "total":
        try:
            number = Decimal(value)
        except InvalidOperation as error:
            raise ValueError("invalid decimal") from error
        if not number.is_finite() or number < 0:
            raise ValueError("invalid invoice amount")
        if number != number.quantize(Decimal("0.01")):
            raise ValueError("too many decimal places")
        return format(number, ".2f")
    return value
  • A date has to be a real day written YYYY-MM-DD.
  • usd becomes USD. The only codes this project accepts are USD, EUR, and GBP. $ is rejected, because a symbol is not a currency.
  • Money uses Decimal, not a binary fraction. 125 becomes 125.00. More than two digits after the decimal point is rejected.

What the tests refuse

5 passed, 3 deselected

The five passing cases are the ones that should fail the rules:

  • 2026-02-30 is not a calendar day.
  • NaN is not a real amount.
  • -1.00 is negative. This project covers invoices, not credit notes.
  • 12.345 has too many decimal places.
  • A currency of None stays None. The missing currency on invoice 2 is still not guessed.

Then I checked a value the tests do not cover:

normalize("total", "125")
125.00

Why these rules are code

A prompt can say "use ISO dates and two decimal places." The model can still return February 30 or 12.345, and the draft would look fine until someone tried to pay it. normalize raises ValueError before that value is saved as a finished answer.

These exact rules would be wrong for another customer. A credit note needs negative amounts. Yen does not use two decimal places. The supported set is three currencies, and adding another one means revisiting the money rules, not only the prompt.

What I set up on Day 4

Day 4 was two commits.

  • feat: normalize dates, currency, and totals. normalize now checks dates, currency codes, and amounts.
  • docs: move the journal to the repo root. NOTES.md moved out of work/ so the lesson notes sit next to the README.

Frequently asked questions

What does normalize("total", "125") return?

125.00. The raw value can leave off the cents. The cleaned value always has two decimal places.

Why is February 30 rejected?

date.fromisoformat only accepts a real calendar day. 2026-02-30 raises ValueError.

Why does a missing currency stay null?

None returns immediately, before the currency code is checked. An empty fact stays empty. $ still does not become USD.

Did a model apply these rules?

No. The rules are Python. A later lesson can still ask a model to extract the fields. These checks run after that answer comes back.

Next up

The reading and the cleanup rules exist, and a model has not touched an invoice yet. I will write about what I learn as I go, including the mistakes.

Related: Day 3 of Document Extraction Workbench: A page I can read can still have no text