Datalumina

The backend

Typed data models

Define Pydantic models for invoices and receipts and map the raw Document Intelligence dictionary into a typed boundary.

By the end of this stage the raw Azure output has a typed home. Pydantic models describe what an extracted invoice and receipt look like, a mapping layer converts the provider dictionary into those models, and a playground script proves the round trip on real samples.

Dave frames the principle directly: "when I've parsed data, I always want to try and put it into a data model." The raw dictionary from the previous stage never travels further into the app than this boundary.

daveebbelaar/invoice-review0:33:23

Shared field types

Every value Document Intelligence extracts is really a triple of the parsed value, the raw text on the page, and a confidence score. That shape repeats across every field, so it lives once in a common module:

backend/app/schemas/common.py
class ExtractedString(BaseModel):
    value: str | None = None
    content: str | None = None
    confidence: float | None = None


class ExtractedDate(BaseModel):
    value: date | None = None
    content: str | None = None
    confidence: float | None = None


class ExtractedMoney(BaseModel):
    amount: Decimal | None = None
    currency_code: str | None = None
    content: str | None = None
    confidence: float | None = None

Keeping content next to value matters more than it looks. When the parsed date is wrong, the raw string tells you what was actually printed on the document, and the review UI can show both. Confidence rides along on every field because the validation rules will threshold on it later.

The domain models

The invoice model covers the full prebuilt-invoice field surface. In the video Dave explicitly scopes it wide: "I want everything, like as much as possible." A slice of it:

backend/app/schemas/invoice/model.py
class InvoiceExtraction(BaseModel):
    document_type: Literal["invoice"] = "invoice"

    vendor_name: ExtractedString | None = None
    vendor_tax_id: ExtractedString | None = None
    customer_name: ExtractedString | None = None
    customer_tax_id: ExtractedString | None = None

    invoice_id: ExtractedString | None = None
    invoice_date: ExtractedDate | None = None
    due_date: ExtractedDate | None = None
    purchase_order: ExtractedString | None = None

    subtotal: ExtractedMoney | None = None
    total_tax: ExtractedMoney | None = None
    invoice_total: ExtractedMoney | None = None

    items: list[InvoiceLineItem] = []

backend/app/schemas/receipt/model.py does the same for receipts, with merchant and transaction fields instead of vendor and invoice fields. Both models also expose computed average_confidence and minimum_confidence properties, which walk every nested field and aggregate the scores. Those two numbers become the headline quality signal per document.

The mapping layer

Between the raw dictionary and the models sits one function per document type. It knows Azure's naming, the models do not:

backend/app/schemas/invoice/mapping.py
def _map_invoice_items(fields: dict[str, Any]) -> list[InvoiceLineItem]:
    items: list[InvoiceLineItem] = []
    for entry in parse_array_field(fields.get("Items")):
        value_object = parse_object_fields(entry.get("valueObject"))
        items.append(
            InvoiceLineItem(
                description=parse_string_field(value_object.get("Description")),
                quantity=parse_number_field(value_object.get("Quantity")),
                unit_price=parse_currency_field(value_object.get("UnitPrice")),
                amount=parse_currency_field(value_object.get("Amount")),
            )
        )
    return items

The parse_* helpers in common.py each handle one provider quirk. parse_currency_field digs into valueCurrency for the amount and currency code, parse_date_field parses the ISO string in valueDate, and all of them carry the confidence over. Ugly provider knowledge concentrates here, in one file you can throw away if the provider changes.

Prove it on real samples

The playground script runs three documents through analyze-then-map and compares the result against the corpus manifest:

cd playground
uv run --project ../backend --locked --no-sync python map_extraction_samples.py
# --- Manifest comparison: Corpus invoice 01-en-happy-classic.pdf ---
#   vendor_name: mapped='BrightSpark Europe B.V.' expected='BrightSpark Europe B.V.' [match]
#   invoice_total: mapped='847.00' expected='847.00' [match]

samples/manifest.json records the expected value for every core field of every corpus document. Seeing match line up across the happy-path invoice and the fuel receipt is the first evidence that extraction, models, and mapping agree, and that manifest becomes much more important once the full pipeline needs a target to optimize against.

Checkpoint

  • map_extraction_samples.py converts sample analyses into typed models with no validation errors
  • You can explain the difference between value, content, and confidence on a field
  • You know which file to open when Azure renames a field, and it is not a model

On this page