Read any document
Name the document type, then pull its fields.
A shared inbox gets receipts, leases and doctor's notes, and each kind needs different fields. This guide lets Sage2 name the document first, then extract only the fields that kind of document has.
Documents that fit none of your types go to other. Close calls come back as null. Both reach a person instead of the wrong form.
- Choice
- Extract
- Fields read for each type
- receipt: merchant, total, date
- lease: tenant, monthly_rent, term, start_date
- doctors_note: patient, doctor, restriction, restriction_length
- Name the kind of documentChoice
- receiptunder 0.01%
- lease99.99%
- doctors_noteunder 0.01%
- otherunder 0.01%
- Read the fields that kind of document hasExtract
- tenant
- Leo Brandt
- monthly_rent
- €1,250.00
- term
- P3Y (3 years)
- start_date
- 2027-02-01
- Your codeFiled as a lease
The application saves 4 fields: tenant, monthly rent, term, start date.
The type question took 4 ms. Your code then sent the lease field set to Extract: 63 ms.
Get the code
Set SAGE_API_KEY (get a free key), then run it. Python: pip install requests. TypeScript: Node 22+. Save as recipe.mts and run npx tsx recipe.mts.
A null answer is not an error. Sage2 isn't sure, and each step below says what to do with it. Limits, errors and retries: Limits and errors.
import os
import requests
def sage(path, body):
r = requests.post(
"https://sage.levanto.ai" + path,
headers={"Authorization": f"Bearer {os.environ['SAGE_API_KEY']}"},
json=body,
timeout=30,
)
r.raise_for_status()
return r.json()
# From a PDF or a photo? Run OCR first and pass the text.
document = """RESIDENTIAL LEASE
Landlord: Ana Marsh. Tenant: Leo Brandt.
The premises at 8 Quay Lane are let for three (3) years from 1 February 2027.
Monthly rent: 1,250.00 EUR. Deposit: 2,500.00 EUR.
Pets are allowed with written consent."""
pick = sage("/decide", {
"content": document,
"question": {
"id": "document_type",
"kind": "choice",
"instructions": "What kind of document is this?",
"options": [
{
"option": "receipt",
"description": "A proof of purchase from a shop or restaurant",
},
{"option": "lease", "description": "A contract to rent a property"},
{
"option": "doctors_note",
"description": "A note from a doctor about a patient",
},
{"option": "other", "description": "Any other kind of document"},
],
},
})["result"]
BAR = 0.9 # file a document on its own only at this probability or more
kind = pick["chosen"] if (pick["probability"] or 0) >= BAR else None
FIELDS = {
"receipt": {
"merchant": "the shop or restaurant",
"total": {"type": "money"},
"date": {"type": "date"},
},
"lease": {
"tenant": "the person renting",
"monthly_rent": {"type": "money"},
"term": {"type": "duration"},
"start_date": {"type": "date"},
},
"doctors_note": {
"patient": "the person examined",
"doctor": "the doctor",
"restriction": "what the patient should not do",
"restriction_length": {"type": "duration"},
},
}
if kind is None or kind == "other":
# Not one of your types, or Sage isn't sure enough: no field set fits.
print("Send it to a person")
else:
values = sage("/v1/extract", {"content": document, "extract": FIELDS[kind]})["values"]
print(kind, values)
How it works
- OCR
Turn the file into text
Extraction reads text. For a PDF, a scan or a photo, run OCR first and pass the text on. Any OCR tool works, such as Tesseract or your cloud provider's OCR.
To only name the type, Choice can read a photo directly (Images, beta).
Runs with the helper from Get the code.
# From a PDF or a photo? Run OCR first and pass the text. document = """RESIDENTIAL LEASE Landlord: Ana Marsh. Tenant: Leo Brandt. The premises at 8 Quay Lane are let for three (3) years from 1 February 2027. Monthly rent: 1,250.00 EUR. Deposit: 2,500.00 EUR. Pets are allowed with written consent.""" Ask a Choice question with one option per document type, and describe each option in a few words. Sage2 returns its pick in
chosen, with aprobabilityfor every option.otheris the last option. It catches documents that fit none of your types.nullmeans the top two options are too close to call.
Keep the pick only at or above your bar, here 0.9. Below it, the code treats the pick as
null.Runs with the helper from Get the code.
pick = sage("/decide", { "content": document, "question": { "id": "document_type", "kind": "choice", "instructions": "What kind of document is this?", "options": [ { "option": "receipt", "description": "A proof of purchase from a shop or restaurant", }, {"option": "lease", "description": "A contract to rent a property"}, { "option": "doctors_note", "description": "A note from a doctor about a patient", }, {"option": "other", "description": "Any other kind of document"}, ], }, })["result"] BAR = 0.9 # file a document on its own only at this probability or more kind = pick["chosen"] if (pick["probability"] or 0) >= BAR else NoneExample Sage2 output
{ "id": "document_type", "kind": "choice", "result": { "chosen": "lease", "probability": 0.9999, "probabilities": [ { "option": "receipt", "probability": 0 }, { "option": "lease", "probability": 0.9999 }, { "option": "doctors_note", "probability": 0 }, { "option": "other", "probability": 0 } ] }, "meta": { "model": "levanto-sage-2", "latency_ms": 4.1, "question_count": 1, "usage": { "billed_input_tokens": 165, "image_count": 0, "image_tokens": 0, "input_tokens": 165, "output_tokens": 0 } } }Keep one field set per type. The Choice answer picks the set you send to
/v1/extract.otherandnullhave no field set, so a person files those.moneycomes back as{amount, currency},dateas an ISO date anddurationas an ISO 8601 duration. A three-year lease term isP3Y.Runs with the helper from Get the code.
FIELDS = { "receipt": { "merchant": "the shop or restaurant", "total": {"type": "money"}, "date": {"type": "date"}, }, "lease": { "tenant": "the person renting", "monthly_rent": {"type": "money"}, "term": {"type": "duration"}, "start_date": {"type": "date"}, }, "doctors_note": { "patient": "the person examined", "doctor": "the doctor", "restriction": "what the patient should not do", "restriction_length": {"type": "duration"}, }, } if kind is None or kind == "other": # Not one of your types, or Sage isn't sure enough: no field set fits. print("Send it to a person") else: values = sage("/v1/extract", {"content": document, "extract": FIELDS[kind]})["values"] print(kind, values)Example Sage2 output
{ "values": { "tenant": "Leo Brandt", "monthly_rent": { "amount": 1250, "currency": "EUR" }, "term": "P3Y", "start_date": "2027-02-01" }, "results": { "tenant": { "value": "Leo Brandt", "text": "Leo Brandt", "start": 47, "end": 57, "probability": 0.9912, "probabilities": [ { "value": "Leo Brandt", "text": "Leo Brandt", "start": 47, "end": 57, "probability": 0.9912 }, { "value": null, "probability": 0.0088 } ] }, "monthly_rent": { "value": { "amount": 1250, "currency": "EUR" }, "text": "1,250.00 EUR", "start": 151, "end": 163, "probability": 0.9906, "probabilities": [ { "value": { "amount": 1250, "currency": "EUR" }, "text": "1,250.00 EUR", "start": 151, "end": 163, "probability": 0.9906 }, { "value": null, "probability": 0.0094 } ] }, "…": "one entry per field" }, "meta": { "model": "levanto-sage-2", "usage": { "input_tokens": 263, "output_tokens": 432 }, "latency_ms": 62.8 } }
Before you ship
- Add a type in two lines. Add an option to the question and a field set to
FIELDS. There is no training step. - Set your own bar. Test on a sample of your own documents, then file automatically only above the level you trust.
- Use the unknowns. Route
otherandnullto a review queue. Add a type when the same kind of document keeps arriving there. - Fast and small. Each call answers in about 0.1 s. A one-page document reads a few hundred tokens per call.