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:
| Call | What is sent | What comes back |
|---|---|---|
| Recognition and review | The source PDF, PNG, or JPEG | Classification plus an independent structured extraction |
| Document Intelligence analyze | The source file | Typed fields with per-field confidence |
| GL suggestion | Normalized fields and line items only, never the file | One 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:
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: strThree 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
InvoiceDataand 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.