# Read any document

Classify a document, then extract the fields that kind of document has. Works on PDFs and photos after OCR.

Category: Read documents. Live demo: https://levanto.ai/examples/document-classification-extraction

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.

## Setup

Base URL: `https://sage.levanto.ai`. Send `Authorization: Bearer $SAGE_API_KEY` on every call. Get a key, free, at https://platform.levanto.ai. Keep the key on your server.

A `null` answer is not an error: Sage2 isn't sure. Each step says what to do with it.

## Step by step

### 1. 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](https://docs.levanto.ai/decision-model/images), beta).

```python
# 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."""
```

```typescript
// From a PDF or a photo? Run OCR first and pass the text.
const 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.`;
```

### 2. Name the document type

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 a `probability` for every option.

- `other` is the last option. It catches documents that fit none of your types.
- `null` means 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`.

```python
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
```

```typescript
const pick = (await 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;
const BAR = 0.9; // file a document on its own only at this probability or more
const kind: string | null = (pick.probability ?? 0) >= BAR ? pick.chosen : null;
```

Response, abridged (a real run):

```json
{
  "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
    }
  }
}
```

### 3. Extract that type's fields

Keep one field set per type. The Choice answer picks the set you send to `/v1/extract`. `other` and `null` have no field set, so a person files those.

`money` comes back as `{amount, currency}`, `date` as an ISO date and `duration` as an ISO 8601 duration. A three-year lease term is `P3Y`.

```python
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)
```

```typescript
const FIELDS: Record<string, object> = {
  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 === null || kind === "other") {
  // Not one of your types, or Sage isn't sure enough: no field set fits.
  console.log("Send it to a person");
} else {
  const values = (await sage("/v1/extract", { content: document, extract: FIELDS[kind] })).values;
  console.log(kind, values);
}
```

Response, abridged (a real run):

```json
{
  "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 `other` and `null` to 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.

## Full recipe (Python, `pip install requests`)

```python
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)
```

## Full recipe (TypeScript, Node 22+, nothing to install: save as `recipe.mts`, run `npx tsx recipe.mts`)

```typescript
// Node 22+. Save as recipe.mts and run npx tsx recipe.mts
async function sage(path: string, body: unknown): Promise<any> {
  const r = await fetch("https://sage.levanto.ai" + path, {
    method: "POST",
    headers: { Authorization: `Bearer ${process.env.SAGE_API_KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(30_000),
  });
  if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
  return r.json();
}

// From a PDF or a photo? Run OCR first and pass the text.
const 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.`;

const pick = (await 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;
const BAR = 0.9; // file a document on its own only at this probability or more
const kind: string | null = (pick.probability ?? 0) >= BAR ? pick.chosen : null;

const FIELDS: Record<string, object> = {
  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 === null || kind === "other") {
  // Not one of your types, or Sage isn't sure enough: no field set fits.
  console.log("Send it to a person");
} else {
  const values = (await sage("/v1/extract", { content: document, extract: FIELDS[kind] })).values;
  console.log(kind, values);
}
```

## Requests

### 1. POST /decide

```bash
curl -s https://sage.levanto.ai/decide \
  -H "Authorization: Bearer $SAGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<'JSON'
{
  "content": "RESIDENTIAL LEASE\nLandlord: Ana Marsh. Tenant: Leo Brandt.\nThe premises at 8 Quay Lane are let for three (3) years from 1 February 2027.\nMonthly rent: 1,250.00 EUR. Deposit: 2,500.00 EUR.\nPets are allowed with written consent.",
  "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"
      }
    ]
  }
}
JSON
```

Response, abridged (a real run):

```json
{
  "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
    }
  }
}
```

### 2. POST /v1/extract

```bash
curl -s https://sage.levanto.ai/v1/extract \
  -H "Authorization: Bearer $SAGE_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<'JSON'
{
  "content": "RESIDENTIAL LEASE\nLandlord: Ana Marsh. Tenant: Leo Brandt.\nThe premises at 8 Quay Lane are let for three (3) years from 1 February 2027.\nMonthly rent: 1,250.00 EUR. Deposit: 2,500.00 EUR.\nPets are allowed with written consent.",
  "extract": {
    "tenant": "the person renting",
    "monthly_rent": {
      "type": "money"
    },
    "term": {
      "type": "duration"
    },
    "start_date": {
      "type": "date"
    }
  }
}
JSON
```

Response, abridged (a real run):

```json
{
  "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
  }
}
```

## Docs

- Choice: https://docs.levanto.ai/decision-model/choice
- Extract: https://docs.levanto.ai/extract
- Limits and errors: https://docs.levanto.ai/decision-model/limits
- OpenAPI: https://sage.levanto.ai/openapi.json
