Datalumina

Models and data

Explore the outputs

Inspect what each provider returns in isolation, understand the inputs and outputs, and design the normalized data model everything else depends on.

By the end of this stage you understand the inputs and outputs of both Azure services well enough to design the application's own data model. This is deliberately a playground stage. No pipeline, no persistence, just documents in and structured responses out, until the shapes stop surprising you.

Provider response shapes, confidence values, long-running operations, strict schemas, and failure modes are much easier to understand without application orchestration around them. Most tutorials skip this and pay for it later.

Two models, two different jobs

Run the corpus documents through each provider separately and study what comes back.

Document Intelligence returns purpose-built fields. For an invoice you get supplier and customer identity, invoice ID, dates, totals, currency, and line items, each with a confidence value. For the fuel receipt, the receipt model returns merchant, transaction date, and totals instead. What it misses, it leaves empty; it does not guess.

Azure OpenAI with strict structured output is a different instrument. Given the same source file it can do three things the prebuilt models cannot:

  • Classify the document, invoice, receipt, or unsupported, which decides the rest of the flow
  • Independently extract the same fields, sometimes recovering a purchase order or amount due the prebuilt model missed
  • Generate text and suggestions, a GL account proposal, a correction email draft

Try the Dutch invoice 02-nl-happy-compact.pdf against both and compare field by field. Then try the degraded receipt 13-nl-fuel-receipt.png. The differences you find are not noise; they are the design input for the merge logic you will build in the pipeline stage.

What gets sent where

Part of understanding inputs and outputs is knowing exactly what leaves your machine. Three different payloads go to Azure in this project:

CallWhat is sentWhat comes back
Recognition and reviewThe source PDF, PNG, or JPEGClassification plus an independent structured extraction
Document Intelligence analyzeThe source fileTyped fields with per-field confidence
GL suggestionNormalized fields and line items only, never the fileOne schema-valid account with rationale and confidence

Financial documents are business-sensitive, so this table is worth keeping honest for any client project. The correction email drafter, later, also receives normalized fields only.

Design the normalized model

Now the payoff. Two providers with two vocabularies need to become one application model, or every later layer would be written twice.

This is where you define the project's central types, before any pipeline exists. InvoiceData is the shape every later layer builds on, and it is worth reading slowly:

backend/app/invoices/schemas.py (the shape, trimmed)
DocumentType = Literal["invoice", "receipt"]
FieldSource = Literal["document_intelligence", "llm_fallback", "human"]

class InvoiceData(BaseModel):
    document_type: DocumentType = "invoice"
    vendor_name: str | None = None
    vendor_vat_id: str | None = None
    customer_name: str | None = None
    customer_vat_id: str | None = None
    invoice_number: str | None = None
    purchase_order: str | None = None
    invoice_date: date | None = None
    due_date: date | None = None
    currency: str | None = None
    subtotal: Decimal | None = None
    total_tax: Decimal | None = None
    invoice_total: Decimal | None = None
    line_items: list[InvoiceLineItem] = []
    field_confidence: dict[str, float] = {}
    field_sources: dict[str, FieldSource] = {}

class ValidationIssue(BaseModel):
    code: str
    field: str | None
    severity: Literal["error", "warning"]
    message: str

Three decisions in this shape do a lot of work. Every field is optional, because real extraction is partial and the rules decide what missing means. Amounts are Decimal and dates are date, so string parsing happens once at the boundary. And field_sources records provenance per field, document_intelligence, llm_fallback, or human.

The provenance field looks like a small detail and turns out to be the heart of the app. It is what lets Maya see that a purchase order came from the LLM rather than the primary extractor, and it is what makes the merge auditable instead of magical.

Azure SDK types stop at the provider adapters. Nothing outside backend/app/providers/ ever sees an Azure response object, only InvoiceData. If Microsoft renames a field or you swap providers, one adapter changes and the rules, database, API, and frontend do not.

Model output is evidence, not approval

One more observation before building. Both providers return confident-looking structured data, and both are sometimes wrong or incomplete. Confidence values, missing fields, and disagreements between the two extractions must stay visible to deterministic code and to the reviewer. Nothing in this stage decides anything. That framing, models produce evidence, is what the next stage turns into actual policy.

Checkpoint

  • You have run at least one invoice and the fuel receipt through both providers and compared the outputs
  • You can list what each of the three Azure calls sends and returns
  • You can sketch InvoiceData and explain why provenance is recorded per field

Next: Business rules

Turn the Northstar rulebook into pure Python that decides ready or needs review, offline and reproducibly.

On this page