GuidesExtract data to JSON

Extract data to JSON

Plenty of business data arrives as prose: supplier invoices pasted from email, delivery notes, job applications, meeting notes. This guide turns that text into JSON records your code can store and check. The running example reads supplier invoices for a small restaurant group, Olive & Ash, and produces one record per invoice with the supplier, dates, line items and totals.

You will need: an API key in SYNTIC_API_KEY, Python 3.9 or later, and pip install openai pydantic.

Two ways to get JSON today

ApproachHow it worksStrengths
Prompted JSONThe system message describes the object; the reply text is parsed.Simplest; works with any model; easy to read in logs.
Forced function callA function’s parameters schema describes the object, and tool_choice names that function. The reply’s tool_calls[0].function.arguments is the JSON.The schema steers the shape strongly; no stray prose around the JSON.

Neither approach is a guarantee yet: the server does not check the output against your schema. Your code must validate every record, and the pattern below does exactly that, with one corrective retry.

⚠️
Preview — this feature is being built; the design below is the spec. response_format with json_object and json_schema will make the server guarantee valid JSON matching your schema. Today the field is accepted and ignored. The full design is on JSON output; the validation code in this guide stays useful after it ships, as a cheap second check.

Describe the record

Write the target shape as a Pydantic model. It documents the record, validates the reply, and produces a JSON Schema for the function definition.

from datetime import date
from decimal import Decimal
from typing import Optional
 
from pydantic import BaseModel, Field
 
 
class LineItem(BaseModel):
    description: str
    quantity: Decimal
    unit: Optional[str] = Field(None, description="kg, case, bottle ... or null when not stated")
    unit_price: Decimal
    line_total: Decimal
 
 
class Invoice(BaseModel):
    supplier_name: str
    invoice_number: str
    invoice_date: date
    due_date: Optional[date] = None
    currency: str = Field(description="ISO 4217 code such as EUR or GBP")
    line_items: list[LineItem]
    net_total: Decimal
    tax_total: Decimal
    gross_total: Decimal

Write the instructions

SYSTEM = """
You extract supplier invoices for the accounts team of Olive & Ash, a restaurant group.
 
Rules:
- Copy values exactly as printed. Do not correct supplier names or invoice numbers.
- Dates use YYYY-MM-DD. Invoices from UK and EU suppliers write dates day first.
- Amounts are plain numbers with a dot as decimal separator and no currency symbol.
- If a field is not on the invoice, use null. Never guess a due date or a tax amount.
- Include every line item, including delivery charges and deposits on crates.
""".strip()

The rules target the mistakes extraction models make most often: “fixing” a value that looks like a typo, reading 03/04 as March 4 for a European invoice, and filling a missing field with a plausible number.

Extract with a forced function call

import json
import os
 
from openai import OpenAI
from pydantic import ValidationError
 
client = OpenAI(base_url="https://app.syntic.ai/v1", api_key=os.environ["SYNTIC_API_KEY"])
 
SAVE_INVOICE = {
    "type": "function",
    "function": {
        "name": "save_invoice",
        "description": "Store one extracted supplier invoice.",
        "parameters": Invoice.model_json_schema(),
    },
}
 
 
def extract_invoice(text: str) -> Invoice:
    messages = [
        {"role": "system", "content": SYSTEM},
        {"role": "user", "content": f"<invoice>\n{text}\n</invoice>"},
    ]
    for attempt in range(2):
        reply = client.chat.completions.create(
            model="Amara S1.2",
            stream=False,
            max_tokens=1500,
            messages=messages,
            tools=[SAVE_INVOICE],
            tool_choice={"type": "function", "function": {"name": "save_invoice"}},
        )
        message = reply.choices[0].message
        call = message.tool_calls[0] if message.tool_calls else None
        raw = call.function.arguments if call else (message.content or "")
        try:
            invoice = Invoice.model_validate_json(raw)
        except ValidationError as err:
            if attempt == 1 or call is None:
                raise
            # Show the model exactly what was wrong and let it try once more.
            messages.append(message.model_dump(exclude_none=True))
            messages.append({
                "role": "tool",
                "tool_call_id": call.id,
                "content": "Rejected. Fix these problems and call save_invoice again:\n" + str(err),
            })
            continue
        check_totals(invoice)
        return invoice

The corrective retry is cheap and effective. The model sees its own output followed by the validator’s error list (for example “invoice_date: input is not a valid date”), and the second attempt almost always fixes it. Cap it at one retry: a record that fails twice should go to a person.

Check the numbers yourself

Schema validation proves the shape, not the arithmetic. Add business checks that a person would do:

class InvoiceMismatch(Exception):
    pass
 
 
def check_totals(inv: Invoice) -> None:
    lines = sum(item.line_total for item in inv.line_items)
    if abs(lines - inv.net_total) > Decimal("0.02"):
        raise InvoiceMismatch(f"line items add up to {lines}, invoice says net {inv.net_total}")
    if abs(inv.net_total + inv.tax_total - inv.gross_total) > Decimal("0.02"):
        raise InvoiceMismatch("net + tax does not equal gross")
    for item in inv.line_items:
        if abs(item.quantity * item.unit_price - item.line_total) > Decimal("0.02"):
            raise InvoiceMismatch(f"line '{item.description}' does not multiply out")

A mismatch does not always mean the extraction is wrong; suppliers make mistakes too. Either way, the invoice needs a human look, so send it to a review queue with the reason attached.

The same thing with prompted JSON

If you prefer plain text, describe the shape in the system message and parse the reply. This is also the way to go when your schema is too large or nested for a function definition you want to maintain.

SHAPE = json.dumps(Invoice.model_json_schema(), indent=1)
 
reply = client.chat.completions.create(
    model="Amara S1.2",
    stream=False,
    max_tokens=1500,
    messages=[
        {"role": "system", "content": SYSTEM + "\n\nReply with one JSON object matching this schema, and nothing else:\n" + SHAPE},
        {"role": "user", "content": f"<invoice>\n{text}\n</invoice>"},
    ],
)
content = reply.choices[0].message.content or ""
invoice = Invoice.model_validate_json(content[content.index("{"): content.rindex("}") + 1])

Slicing from the first { to the last } protects against a stray sentence such as “Here is the invoice:” before the JSON.

Many records in one document

A statement or a spreadsheet export can hold dozens of records. Two options:

  • One request, an array: wrap the schema in {"invoices": [Invoice, ...]}. Good up to a few dozen records; check finish_reason, because length means the reply was cut off and the last record is incomplete. Raise max_tokens or split the input.
  • Split first, then extract: cut the document at obvious boundaries (page breaks, “Invoice No.”), then extract each piece in its own request. Requests can run in parallel, and one bad record does not spoil the rest.

Documents that are not text

The chat endpoint takes text today. Convert PDFs with a text-extraction library (for example pdfplumber or pypdf) before sending; keep the page layout with layout=True where the library supports it, because columns carry meaning on invoices. Scanned images need OCR first. Direct PDF and image input is specified on PDF and documents and Vision and is in preview.

Measure quality field by field

Hand-check 50 to 100 real invoices and compare field by field rather than record by record; a record with one wrong digit is still mostly useful, but you need to know which fields fail.

FIELDS = ["supplier_name", "invoice_number", "invoice_date", "due_date", "currency", "net_total", "tax_total", "gross_total"]
right = {f: 0 for f in FIELDS}
for sample in samples:                       # [{"text": ..., "expected": {...}}]
    got = extract_invoice(sample["text"]).model_dump(mode="json")
    for f in FIELDS:
        right[f] += str(got[f]) == str(sample["expected"][f])
for f in FIELDS:
    print(f"{f:<16} {right[f] / len(samples):.0%}")
Was this page helpful?