Business Apps

Classify Incoming Documents with Python and AI

Sort a shared inbox folder into invoices, contracts, receipts and CVs with a Python script that returns a label, a confidence score and an auditable decision log.

By the end of this guide you will have a script that watches one folder, reads the first page of every PDF that lands in it, decides whether it is an invoice, a contract, a receipt, a CV or something else, moves the file into the matching subfolder, and writes one line to a log for every decision it made. Budget about an hour, and you will spend more of it choosing your labels than writing code.

This is one guide in AI Document Processing with Python, which covers what to do with a folder of PDFs once your business starts receiving more of them than one person can open. Classification is the first job in that chain: once a file knows what it is, the specialist scripts can take over — invoices go to Extract Invoice Data from PDFs with Python and AI, long reports go to Summarize Long PDFs with Python and Chunking.

Prerequisites

You need Python 3.10 or newer inside a virtual environment. If that phrase is new, Create a Python Virtual Environment for AI walks through it. Beyond the tools the main guide already uses, this script needs a token counter so you can measure the snippet you send:

python -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install "openai>=1.40" "pypdf>=5.0" "tiktoken>=0.7" "python-dotenv>=1.0"
pip freeze > requirements.txt

Put your API key in a .env file next to the script:

OPENAI_API_KEY=sk-your-key-here

Then add that file to .gitignore before your first commit, because a key in a public repository gets found and spent by strangers:

echo ".env" >> .gitignore

Finally, create the folders the script will use. inbox is where new files arrive; sorted is where they end up:

mkdir -p inbox sorted

Steps 1 to 4 build a single file, sort_inbox.py, in the order they appear: the label set first, then the text reader, then the model call, then the routing and logging. Each block adds to the ones above it, and where a block ends with if __name__ == "__main__": it replaces the previous one so you can run the file at every stage with python sort_inbox.py.

Step 1 — Write down the label set and give it an escape hatch

A classifier is only as good as its list of choices. Write the list in Python, not in your head, and give each label a one-sentence definition — that sentence goes straight into the prompt, so it is the actual instruction the model follows.

Two rules make the difference between a classifier you can trust and one you cannot. First, labels must not overlap: if "receipt" and "invoice" both mean "a document about money", the model will flip between them and your accuracy will look random. Say what distinguishes them — an invoice asks for payment, a receipt confirms payment already happened. Second, always include an other label. A model asked to choose from four options with no way out will always choose one of the four, even for a parking permit, and it will sound sure about it. The escape hatch converts a silent wrong answer into an obvious one you can act on.

LABELS = {
    "invoice": "a request for payment that is not yet paid; has a due date, an amount owed and a bill-to party",
    "contract": "an agreement between named parties with clauses, obligations or signature blocks",
    "receipt": "proof that a payment already happened; shows a paid total, a card or transaction reference",
    "cv": "a person's career history: name, roles, dates, education, skills",
    "other": "anything that is not clearly one of the labels above, or text too garbled to judge",
}

Keep the set small. Five labels is a comfortable ceiling for a single pass; if you genuinely need fifteen, group them into five families first and run a second, narrower classification inside whichever family the file landed in. Adding a label later costs you one line here and one re-run of the accuracy check in Step 5.

Step 2 — Send the first page only, not the whole document

Feeding a forty-page contract to the model to learn that it is a contract wastes money on every single file, and the cost grows with the length of the document. It is also unnecessary. Documents announce themselves at the top: the letterhead, the title line, the bill-to block and the first paragraph carry nearly all the signal about what kind of document you are holding.

So extract text from the first page or two, cut it to a fixed token budget, and send that. A token is the unit that language models bill in — a chunk of text averaging about three-quarters of a word. Capping the snippet at 800 tokens means a one-page receipt and a two-hundred-page tender document cost you exactly the same to classify.

from pathlib import Path

import tiktoken
from pypdf import PdfReader

ENCODER = tiktoken.encoding_for_model("gpt-4o-mini")


def first_tokens(path: Path, budget: int = 800) -> str:
    """Return the opening ~800 tokens of a PDF's text, or '' if there is none."""
    reader = PdfReader(path)
    pages = reader.pages[:2]                      # page one usually decides it
    text = "\n".join(page.extract_text() or "" for page in pages)
    text = " ".join(text.split())                 # collapse the ragged whitespace
    tokens = ENCODER.encode(text)
    return ENCODER.decode(tokens[:budget])


if __name__ == "__main__":
    sample = next(Path("inbox").glob("*.pdf"))
    snippet = first_tokens(sample)
    print(f"{sample.name}: {len(ENCODER.encode(snippet))} tokens")
    print(snippet[:300])

Run that on a handful of your own files before going further. If a file prints 0 tokens, it is a scan — an image of a page rather than text — and no amount of prompting will fix it; Step 5's troubleshooting section covers what to do with those. The same trimming idea, applied to whole documents rather than first pages, is covered in Split Long Text into Chunks for AI APIs.

How one incoming PDF travels from the inbox folder to a sorted folder or the review queue A data-flow diagram: the script picks up one file from the inbox, extracts the text of page one, trims it to an 800-token snippet, sends it to gpt-4o-mini, and then a confidence gate sends the file either into a labelled folder or into a human review queue. Watch the inbox one new file at a time Read page one pypdf extract_text Ask gpt-4o-mini 800-token snippet Confidence gate label plus a score Move to a folder invoice, CV, receipt Review queue a person decides
Only the trimmed snippet ever reaches the model, so a two-page receipt and a two-hundred-page tender cost the same to classify; everything after the gate is ordinary file handling.

Step 3 — Ask for a label and a confidence in JSON

Ask for prose and you get prose you have to parse. Ask for JSON — a plain data format of keys and values that Python reads with one function — and you get something your script can branch on. The OpenAI SDK has a switch for this: response_format={"type": "json_object"} forces valid JSON out of the model, which removes a whole category of parsing bugs.

Request three fields. The label is the decision. The confidence is a number from 0 to 1 that tells you how much the model wants to be argued with. The reason is a handful of words naming the evidence — "due date and amount owed" — and it is what makes the log readable to a colleague six months later.

Never trust the reply blindly, even in JSON mode. The model can return a label you never defined or a confidence as a string. Validate both, and fall back to other with a confidence of zero rather than letting a surprise value flow into your folder logic.

import json
import os

from dotenv import load_dotenv
from openai import OpenAI

load_dotenv()  # reads .env, which is in .gitignore
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

LABEL_LINES = "\n".join(f"- {name}: {meaning}" for name, meaning in LABELS.items())

SYSTEM_PROMPT = f"""You sort business documents. You will be shown the opening text of one file.
Choose exactly one label from this list:
{LABEL_LINES}

Reply with JSON only, in this shape:
{{"label": "one label from the list", "confidence": 0.0, "reason": "under 8 words"}}

Set confidence to how sure you are, from 0.0 to 1.0.
Use "other" whenever two labels fit equally well, or the text is unreadable.
Never invent a label that is not on the list."""


def classify(snippet: str) -> dict:
    """Return {'label': str, 'confidence': float, 'reason': str} for one snippet."""
    if not snippet.strip():
        return {"label": "other", "confidence": 0.0, "reason": "no extractable text"}

    response = client.chat.completions.create(
        model="gpt-4o-mini",
        temperature=0,
        response_format={"type": "json_object"},
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": snippet},
        ],
    )
    data = json.loads(response.choices[0].message.content)

    label = data.get("label", "other")
    if label not in LABELS:                      # the model invented something
        label = "other"
    try:
        confidence = float(data.get("confidence", 0.0))
    except (TypeError, ValueError):
        confidence = 0.0

    return {
        "label": label,
        "confidence": max(0.0, min(1.0, confidence)),
        "reason": str(data.get("reason", ""))[:80],
    }

temperature=0 asks for the least adventurous answer the model has, which is what you want when the same file should always sort the same way. If you want to know why that single argument matters so much here, What Is Temperature in an LLM API? explains it, and Write System Prompts that Control Output Format covers writing instructions that hold their shape across thousands of calls.

Step 4 — Route on the confidence score, then file and log

Here is where most home-made classifiers go wrong: they act on every answer as if it were equally good. Your script has a number that tells it which answers to doubt, so use it. Pick two thresholds — one above which the file is filed silently, one below which no automatic action is taken at all — and treat the band in between as "filed, but flagged for a spot check".

The other label short-circuits all of that. Whatever confidence the model reports, an other file goes to the review queue, because "I could not tell" is a request for a human, not a filing instruction.

How the confidence score in the JSON reply decides what happens to the file A decision tree: the JSON reply branches into three confidence bands — high, middle and low or unclear — and each band leads to a different action, from silent filing through flagged filing to a human review queue with no automatic action. JSON reply label and confidence Sure enough score 0.85 or above Middle band score 0.60 to 0.84 Unsure or other score below 0.60 File it silently log and move on File and flag weekly spot check Human review no automatic action
Three bands, three different amounts of trust: the script only acts on its own without asking when the score clears the upper threshold and the label is a real one.

The log is not optional paperwork. It is the only way to answer "why is this contract in the receipts folder?" without re-running anything, and it is the raw material for tuning your thresholds later. Write one CSV row per file, with the timestamp, the original name, the label, the score, the model's reason and where the file went.

import csv
import shutil
from datetime import datetime, timezone
from pathlib import Path

INBOX = Path("inbox")
SORTED_ROOT = Path("sorted")
LOG_FILE = Path("classification_log.csv")

AUTO_FILE = 0.85       # at or above this, no human ever sees it
NEEDS_REVIEW = 0.60    # below this, no automatic filing at all

FIELDS = ["ts", "source", "label", "confidence", "reason", "action", "destination"]


def route(label: str, confidence: float) -> tuple[str, Path]:
    """Decide what happens to a file, given the model's answer."""
    if label == "other" or confidence < NEEDS_REVIEW:
        return "review", SORTED_ROOT / "_review"
    if confidence < AUTO_FILE:
        return "flagged", SORTED_ROOT / label
    return "auto", SORTED_ROOT / label


def log_decision(row: dict) -> None:
    is_new = not LOG_FILE.exists()
    with LOG_FILE.open("a", newline="", encoding="utf-8") as handle:
        writer = csv.DictWriter(handle, fieldnames=FIELDS)
        if is_new:
            writer.writeheader()
        writer.writerow(row)


def process(path: Path) -> dict:
    result = classify(first_tokens(path))
    action, folder = route(result["label"], result["confidence"])
    folder.mkdir(parents=True, exist_ok=True)

    target = folder / path.name
    if target.exists():                                   # two files, same name
        stamp = datetime.now(timezone.utc).strftime("%H%M%S")
        target = folder / f"{stamp}-{path.name}"
    shutil.move(str(path), str(target))

    row = {
        "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
        "source": path.name,
        "label": result["label"],
        "confidence": round(result["confidence"], 2),
        "reason": result["reason"],
        "action": action,
        "destination": str(target),
    }
    log_decision(row)
    return row


if __name__ == "__main__":
    for pdf in sorted(INBOX.glob("*.pdf")):
        row = process(pdf)
        print(f"{row['source'][:34]:<36} {row['label']:<9} {row['confidence']:.2f}  {row['action']}")

Drop a dozen files into inbox, run it, and you have sorted folders plus classification_log.csv. Notice that nothing is ever deleted and nothing is overwritten — a same-named file gets a time prefix. When you later run this on a schedule, Schedule Python AI Jobs with GitHub Actions shows how, and Log and Monitor AI API Calls in Production covers keeping the log useful once it has thousands of rows.

Step 5 — Measure accuracy on twenty documents you already know

You cannot tune a classifier you have not measured, and "it looked right when I tried it" is not a measurement. Twenty files is enough to expose the problems that matter — a whole label that never fires, a pair of types the model keeps swapping — without turning into a research project.

Gather twenty real documents, roughly four per label, and write the answers down yourself in a two-column CSV called labels.csv:

file,expected
samples/acme-march.pdf,invoice
samples/nda-2026.pdf,contract
samples/taxi-0412.pdf,receipt
samples/j-okafor-cv.pdf,cv
samples/parking-permit.pdf,other

Then run every file through the same classify function your live script uses. Save this second script as check_accuracy.py next to sort_inbox.py, so it imports the exact prompt and label set you are running in production:

import csv
from collections import Counter
from pathlib import Path

from sort_inbox import classify, first_tokens


def accuracy_check(csv_path: str = "labels.csv") -> None:
    with open(csv_path, newline="", encoding="utf-8") as handle:
        rows = list(csv.DictReader(handle))

    correct, mistakes = 0, Counter()
    wrong_scores = []

    for row in rows:
        result = classify(first_tokens(Path(row["file"])))
        got, expected = result["label"], row["expected"]
        if got == expected:
            correct += 1
        else:
            mistakes[f"{expected} -> {got}"] += 1
            wrong_scores.append(result["confidence"])
            print(f"MISS {row['file']}: wanted {expected}, got {got} "
                  f"({result['confidence']:.2f}) — {result['reason']}")

    print(f"\n{correct}/{len(rows)} correct")
    for pair, count in mistakes.most_common():
        print(f"  confused {pair}: {count}x")
    if wrong_scores:
        print(f"  highest confidence on a wrong answer: {max(wrong_scores):.2f}")


if __name__ == "__main__":
    accuracy_check()

That last line is the most valuable number the check produces. It tells you where to put AUTO_FILE: set the threshold above the highest score the model gave a wrong answer, and those mistakes stop being filed silently. If the misses all fall on one pair — receipts read as invoices, say — do not raise the threshold, sharpen that pair of definitions in LABELS and run the twenty files again. Re-run this check whenever you add a label, change the prompt or switch models; it takes a minute and it is the only honest comparison between gpt-4o-mini and gpt-4o for your own documents.

Confidence bands and routing

Confidence from the modelLabelWhat the script does
0.85 and abovea real labelMove into sorted/<label>/, log as auto
0.60 to 0.84a real labelMove into sorted/<label>/, log as flagged
Below 0.60anyMove into sorted/_review/, log as review
Any scoreotherMove into sorted/_review/, log as review

Troubleshooting

json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0) — the model answered with prose, or with JSON wrapped in a code fence. The cause is almost always a missing response_format={"type": "json_object"} on the call. Add it, and keep the word "JSON" in your system prompt, which the API requires when that mode is on. Fix JSONDecodeError with AI API Responses in Python covers the stubborn cases.

pypdf.errors.PdfReadError: EOF marker not found — the PDF is truncated or was still being copied when your script picked it up. Wrap the first_tokens call in a try block, log the failure, and move the file to sorted/_review/ rather than letting one bad file stop the whole run.

Every file comes back as other with a low score — check what first_tokens actually returned. Scanned documents are images of pages, so extract_text() returns an empty string and the model correctly says it cannot tell. Those files need optical character recognition before classification; route them to the review folder and handle them as a separate batch.

openai.RateLimitError: Error code: 429 — you are sending files faster than your account allows. Add a short time.sleep between calls or retry with a growing delay; Fix the 429 Rate-Limit Error in Python has a ready-made backoff loop you can paste in.

When to use this vs. alternatives

  • Filename or keyword rules beat AI when your documents come from a handful of known senders with stable templates — matching INV- in a filename is free, instant and perfectly accurate for those. They fall apart the moment a supplier redesigns a template or a new sender appears, which is exactly the situation this script is built for.
  • Embeddings with a nearest-neighbour lookup suit a large, fixed set of categories where you have many labelled examples already; the per-file cost is lower than a chat call. You lose the plain-language reason, though, and you cannot add a category by typing one sentence.
  • A dedicated document-understanding service is worth the integration work when you need page-level bounding boxes, handwriting or table structure. For "which of five buckets does this belong in", it is more setup than the job requires.
Filename rules compared with an AI classifier on four practical criteria A comparison matrix with four rows — new file layouts, setup effort, cost per file and audit trail — showing how hand-written filename rules and an AI classifier behave differently on each of them. Filename rules AI classifier New layout a redesigned form breaks quietly the pattern misses usually holds it reads the words Setup effort before the first run one rule per sender and format one prompt plus a label list Cost per file once it is running nothing at all plain Python one small call about 800 tokens Audit trail why this label the rule that hit no reasoning label and score and a short reason
Rules win on cost and stay perfectly predictable; the AI classifier wins when the incoming documents keep changing shape and you need a readable reason for each decision.

In practice the two combine well. Run your cheap filename rules first, and only call the model for the files no rule matched — you keep the low cost on the predictable majority and the flexibility on the awkward remainder. Once files are sorted, the rest of this series takes over: send the invoices onward for field extraction, and point Build a Document Question-Answering Tool at the contracts folder so people can ask questions of what you have filed. Back to AI Document Processing with Python.

Frequently asked questions

How much of a document does the AI need to read to classify it?

Far less than you think. The first page of a document nearly always announces what it is: an invoice shows a bill-to block, a contract opens with parties and recitals, a CV starts with a name and a job history. Sending roughly 800 tokens of page one is enough, and it keeps the cost per file flat no matter how long the file is.

What should the classifier do when a document fits no label?

Give it an explicit "other" label and tell the model to use it whenever two labels fit equally well or the text is unreadable. Without that escape hatch the model must pick one of your real labels, so it guesses, and a confident wrong guess is far more expensive to clean up than an honest shrug.

Can I trust the confidence score a language model returns?

Treat it as a rough sorting signal, not a probability. It is useful because low scores really do land on the same documents a human would find ambiguous. Calibrate it yourself: run twenty documents you already know, look at the scores on the wrong answers, and set your review threshold just above them.

Which model should I use for document classification?

Start with gpt-4o-mini. Picking one label out of five from a page of text is an easy task, and the small model handles it while keeping the per-file cost low. Move to gpt-4o only if your accuracy check shows a specific document type it keeps confusing, and compare the two on the same twenty files.

Do I need a training dataset to classify documents this way?

No. A traditional classifier needs hundreds of labelled examples per category before it works. A language model already understands what an invoice is, so you describe each label in one sentence and it works on the first run. You only need about twenty labelled files, and those are for measuring accuracy rather than training.