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#
import jsonfrom openai import OpenAIfrom pydantic import BaseModel, ValidationError, Fieldclient = OpenAI(base_url="https://ai.upfyn.com/v1", api_key=KEY, max_retries=0)class LineItem(BaseModel):description: stramount: floatclass Invoice(BaseModel):invoice_number: strissued_on: str = Field(description="ISO date, YYYY-MM-DD")currency: strtotal: floatline_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 listtemperature=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
Iffinish_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.
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#
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.
