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.
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:
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 = NoneKeeping 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:
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:
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 itemsThe 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.pyconverts sample analyses into typed models with no validation errors- You can explain the difference between
value,content, andconfidenceon a field - You know which file to open when Azure renames a field, and it is not a model