Skip to main content
Lesson 10 of 12 · Structured Output: JSON and Schemas

Advanced Use Cases

Lesson 10 of 12 Updated July 29, 2026 Developer
On this page
  1. Beyond Simple Examples
  2. Extracting an Invoice
  3. Analyzing Reviews Aspect by Aspect
  4. Classifying Text into Multiple Categories
  5. Factorizing the Call
  6. Key Takeaways

Beyond Simple Examples

You have mastered Pydantic, Zod, and client.chat.parse(). The next three cases move beyond simple four-field schemas to scenarios you will encounter in enterprise environments: accounting documents, detailed customer review analysis, and texts that belong to multiple categories simultaneously. Each illustrates a different modeling technique.

Extracting an Invoice

Document processing is the natural territory for nested schemas. An invoice is not a flat object: it contains a header, a footer with totals, and a variable number of line items that have their own structure. Therefore, we model LineItem separately and then reference it within a list. The currency uses an enum, which prevents the model from returning "euros" or "€" depending on how the document is phrased.

from pydantic import BaseModel, Field
from typing import Optional
from enum import Enum

class Currency(str, Enum):
    eur = "EUR"
    usd = "USD"
    gbp = "GBP"

class LineItem(BaseModel):
    description: str = Field(description="Line item description")
    quantity: float = Field(description="Quantity")
    unit_price: float = Field(description="Unit price excluding tax")
    vat_rate: float = Field(description="VAT rate as a percentage (e.g., 20.0)")
    total_ht: float = Field(description="Line total excluding tax")

class InvoiceData(BaseModel):
    invoice_number: str = Field(description="Invoice number")
    date: str = Field(description="Invoice date in YYYY-MM-DD format")
    due_date: Optional[str] = Field(default=None, description="Due date")
    vendor_name: str = Field(description="Vendor name")
    vendor_address: Optional[str] = Field(default=None)
    client_name: str = Field(description="Client name")
    line_items: list[LineItem] = Field(description="Invoice line items")
    subtotal_ht: float = Field(description="Subtotal excluding tax")
    total_vat: float = Field(description="Total VAT amount")
    total_ttc: float = Field(description="Total including tax")
    currency: Currency = Field(description="Currency")
    payment_method: Optional[str] = Field(default=None)

# Extraction
response = client.chat.parse(
    model="mistral-large-latest",
    messages=[
        {
            "role": "system",
            "content": """You are an accounting expert. Extract data from the invoice.
Be precise with amounts. Calculate totals if necessary.
Dates must be in YYYY-MM-DD format."""
        },
        {"role": "user", "content": invoice_text}
    ],
    response_format=InvoiceData,
    temperature=0
)

invoice = response.choices[0].message.parsed
print(f"Invoice {invoice.invoice_number} — {invoice.vendor_name}")
print(f"Total incl. tax: {invoice.total_ttc} {invoice.currency.value}")
for item in invoice.line_items:
    print(f"  - {item.description}: {item.quantity} x {item.unit_price}€ = {item.total_ht}€ excl. tax")

The date format enforced in the system prompt is not a minor detail: without it, a French document might produce 12/03/2026 while an American one yields 03/12/2026, creating two indistinguishable strings for your database. The TypeScript version follows the same architecture, with the format documented via .describe().

import { z } from "zod";

const LineItemSchema = z.object({
  description: z.string(),
  quantity: z.number(),
  unitPrice: z.number().describe("Unit price excluding tax"),
  vatRate: z.number().describe("VAT rate in %"),
  totalHt: z.number(),
});

const InvoiceSchema = z.object({
  invoiceNumber: z.string(),
  date: z.string().describe("Format YYYY-MM-DD"),
  dueDate: z.string().nullable(),
  vendorName: z.string(),
  clientName: z.string(),
  lineItems: z.array(LineItemSchema),
  subtotalHt: z.number(),
  totalVat: z.number(),
  totalTtc: z.number(),
  currency: z.enum(["EUR", "USD", "GBP"]),
});

const response = await client.chat.parse({
  model: "mistral-large-latest",
  messages: [
    { role: "system", content: "Extract invoice data precisely." },
    { role: "user", content: invoiceText },
  ],
  responseFormat: InvoiceSchema,
  temperature: 0,
});

const invoice = response.choices[0].message.parsed!;
console.log(`Invoice ${invoice.invoiceNumber} — Total: ${invoice.totalTtc} ${invoice.currency}`);

Analyzing Reviews Aspect by Aspect

A real customer review is rarely uniformly positive or negative. The hotel review below praises the room and breakfast, condemns the parking and Wi-Fi, and offers a nuanced view of the staff: a single global score would lose all this information. The solution is to model a reusable AspectSentiment and request a list of them. The evidence field is the most interesting part of the schema—by requiring a quote from the text, you force the model to ground its judgment and give yourself the means to verify it.

from pydantic import BaseModel, Field

class AspectSentiment(BaseModel):
    aspect: str = Field(description="The aspect analyzed (quality, price, service, delivery, etc.)")
    sentiment: str = Field(description="positive, negative, or neutral")
    score: float = Field(description="Score between -1.0 (very negative) and 1.0 (very positive)")
    evidence: str = Field(description="Quote or excerpt from the text justifying the sentiment")

class ReviewAnalysis(BaseModel):
    overall_sentiment: str = Field(description="Overall sentiment: positive, negative, mixed, or neutral")
    overall_score: float = Field(description="Overall score between -1.0 and 1.0")
    aspects: list[AspectSentiment] = Field(description="Aspect-by-aspect analysis")
    recommendation: bool = Field(description="Does the author recommend the product/service?")
    key_strengths: list[str] = Field(description="Identified strengths")
    key_weaknesses: list[str] = Field(description="Identified weaknesses")

review_text = """
The hotel is magnificent; the room was spacious and clean. The breakfast is excellent
with a wide selection. However, the parking is outrageously expensive (€35/night) and the Wi-Fi was
unstable throughout the stay. The reception staff was very welcoming,
but room service took over an hour to deliver our order.
"""

response = client.chat.parse(
    model="mistral-large-latest",
    messages=[
        {
            "role": "system",
            "content": "Analyze this customer review by detailing each mentioned aspect."
        },
        {"role": "user", "content": review_text}
    ],
    response_format=ReviewAnalysis,
    temperature=0
)

review = response.choices[0].message.parsed
print(f"Overall sentiment: {review.overall_sentiment} ({review.overall_score:+.1f})")
for aspect in review.aspects:
    print(f"  [{aspect.score:+.1f}] {aspect.aspect}: {aspect.sentiment}")

Classifying Text into Multiple Categories

Unlike simple classification, where a single label is assigned, multi-label classification recognizes that a text can belong to several categories. The incident described below touches on security, finance, fraud, and data protection simultaneously: forcing a single label would impoverish the routing logic.

Two elements of the schema deserve attention. reasoning requires a justification for each label, which improves score quality. requires_human_review turns model uncertainty into an operational signal: instead of discovering after the fact that a classification was dubious, you automatically escalate it to a human when confidence drops below 0.7.

from pydantic import BaseModel, Field

class LabelScore(BaseModel):
    label: str = Field(description="Category name")
    confidence: float = Field(description="Confidence score between 0.0 and 1.0")
    reasoning: str = Field(description="One-sentence justification")

class MultiLabelClassification(BaseModel):
    primary_label: str = Field(description="Primary category")
    all_labels: list[LabelScore] = Field(
        description="All applicable categories with their confidence scores"
    )
    requires_human_review: bool = Field(
        description="True if classification is uncertain (confidence < 0.7)"
    )

text = """
Our mobile payment application has a security vulnerability allowing
access to banking data. Several customers have reported unauthorized
transactions. The technical team is currently investigating.
"""

response = client.chat.parse(
    model="mistral-large-latest",
    messages=[
        {
            "role": "system",
            "content": """Classify this text among the following categories (multiple possible):
security, finance, technical, legal, customer_support, compliance, fraud, data_privacy.
Assign a confidence score to each applicable category."""
        },
        {"role": "user", "content": text}
    ],
    response_format=MultiLabelClassification,
    temperature=0
)

result = response.choices[0].message.parsed
print(f"Primary category: {result.primary_label}")
print(f"Human review required: {'Yes' if result.requires_human_review else 'No'}")
for label in result.all_labels:
    print(f"  [{label.confidence:.0%}] {label.label} — {label.reasoning}")

Factorizing the Call

These three examples share the same mechanics: a system prompt, a text, a schema, and temperature=0. Instead of copying the call for every new case, extract it into a generic function. The TypeVar bound to BaseModel preserves typing: the returned object has the type of the schema passed as an argument, and your IDE knows it.

from typing import TypeVar, Type
from pydantic import BaseModel

T = TypeVar("T", bound=BaseModel)

def extract(client, text: str, schema: Type[T], system_prompt: str = "") -> T:
    """Reusable structured extraction."""
    messages = []
    if system_prompt:
        messages.append({"role": "system", "content": system_prompt})
    messages.append({"role": "user", "content": text})

    response = client.chat.parse(
        model="mistral-large-latest",
        messages=messages,
        response_format=schema,
        temperature=0
    )
    return response.choices[0].message.parsed

# Usage
invoice = extract(client, invoice_text, InvoiceData, "Extract invoice data.")
review = extract(client, customer_review, ReviewAnalysis, "Analyze this customer review.")
classification = extract(client, ticket, MultiLabelClassification, "Classify this text.")

Three very different use cases, each requiring only a single line of code to call: this is the concrete benefit of good schema modeling.

Key Takeaways

  • Nested schemas (list[LineItem], list[AspectSentiment]) enable complex analyses
  • Enums (Currency, categories) constrain possible values
  • The evidence / reasoning field forces the model to justify its choices
  • A generic wrapper reduces code duplication
  • temperature=0 is mandatory for these production use cases