Recipes

Extract structured data

Turning an invoice, a receipt or a scanned form into rows you can insert. The technique is a JSON schema plus a validation step you do not skip.

The extractor#

extract.py
import json
from openai import OpenAI
from pydantic import BaseModel, ValidationError, Field
 
client = OpenAI(base_url="https://ai.upfyn.com/v1", api_key=KEY, max_retries=0)
 
 
class LineItem(BaseModel):
description: str
amount: float
 
 
class Invoice(BaseModel):
invoice_number: str
issued_on: str = Field(description="ISO date, YYYY-MM-DD")
currency: str
total: float
line_items: list[LineItem]
 
 
SCHEMA = {
"type": "object",
"properties": {
"invoice_number": {"type": "string"},
"issued_on": {"type": "string", "description": "ISO date, YYYY-MM-DD"},
"currency": {"type": "string", "enum": ["INR", "USD", "EUR", "GBP"]},
"total": {"type": "number"},
"line_items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"description": {"type": "string"},
"amount": {"type": "number"},
},
"required": ["description", "amount"],
"additionalProperties": False,
},
},
},
"required": ["invoice_number", "issued_on", "currency", "total", "line_items"],
"additionalProperties": False,
}
 
 
def extract_invoice(text: str) -> Invoice:
response = client.chat.completions.create(
model="upfyn-yuva",
messages=[
{"role": "system",
"content": "Extract invoice fields exactly as written. Do not calculate or infer."},
{"role": "user", "content": text},
],
response_format={
"type": "json_schema",
"json_schema": {"name": "invoice", "schema": SCHEMA, "strict": True},
},
max_tokens=2000, # room for a long line-item list
temperature=0, # extraction is not a creative task
)
 
choice = response.choices[0]
if choice.finish_reason == "length":
# Truncated JSON will not parse. This is a token problem, not a schema problem.
raise ValueError("ran out of output tokens — raise max_tokens")
 
return Invoice.model_validate_json(choice.message.content)

Why it is written this way#

Validate after parsing

The schema constrains the model, but nothing guarantees the string you get back is what you expected — a truncated response is still a string. Parsing into a Pydantic model turns a wrong shape into an exception at the boundary instead of a KeyError three functions later.

Temperature zero

Extraction has one right answer. Sampling variety is exactly what you do not want, and it makes the same document produce different rows on different runs.

Check finish_reason first

A parse failure is usually a token failure

If finish_reason is "length", the model ran out of room mid-object and the JSON is genuinely incomplete. The fix is more max_tokens, not a more forgiving parser. Checking this first saves you from debugging the wrong problem.

Enums for anything from a fixed set

"currency" as a free string invites "Rupees", "Rs." and "₹". An enum removes the whole category.

Tell it not to calculate

“Extract exactly as written. Do not calculate or infer.” Without that, a model asked for a total will happily add the line items up and hand you a number the document does not contain — which is worse than a missing field, because it looks right.

Straight from a PDF#

Upload it and reference it — no OCR step of your own.

Extracting from a file
uploaded = client.files.create(file=open("invoice.pdf", "rb"), purpose="assistants")
 
response = client.chat.completions.create(
model="upfyn-yuva",
messages=[{
"role": "user",
"content": [
{"type": "text", "text": "Extract the invoice fields."},
{"type": "file", "file": {"file_id": uploaded.id}},
],
}],
response_format={"type": "json_schema",
"json_schema": {"name": "invoice", "schema": SCHEMA, "strict": True}},
)

Yuva reads PDFs, images and documents. Bala cannot — it is text only, and attaching a file to it returns 400 media_not_supported. See Multimodal input.

Many documents#

Collecting failures rather than raising
def extract_many(documents: list[str]) -> tuple[list[Invoice], list[tuple[int, str]]]:
"""One bad document must not lose the whole batch."""
good, bad = [], []
for i, text in enumerate(documents):
try:
good.append(extract_invoice(text))
except (ValidationError, ValueError, json.JSONDecodeError) as err:
bad.append((i, str(err)))
return good, bad
  • Return the failures with their index so you can re-run only those.
  • Watch your rate limit — a loop over a thousand documents needs throttling.
  • There is no batch API here; concurrency with a semaphore is the way to go faster.