Once you are comfortable · 60 minutes

A classifier that sorts a folder full of text

A tool that reads a folder of text files and tags each one against categories you define — urgent or not, a complaint or a question, which team it belongs to. It writes the results to a CSV you can open in a spreadsheet. The interesting part is not the classifying, it is that running it over ten thousand files costs you an evening of electricity rather than a bill.

60 minutes 5 steps 8 GB RAM Not started yet

  • Python
  • openai
  • CSV
  • Pip text server

Before you start

  • Running in Pip: a text server.
  • 8 GB of memory or more. Less than that and the model you need will not load.
  • Installed already: Python 3.10+.

Worth reading first: Asking a local model well, When it is slow or wrong.

Decide what the categories are, precisely

This step has no code in it and it is the one that decides whether the project works. A model asked to sort things into vague categories will sort them, confidently, and the results will be useless in a way that takes a long time to notice.

Write your categories down with a one-line definition each and at least one borderline case you have already decided. If you cannot decide which category a message belongs to, the model has no chance, and the honest fix is a better category rather than a better prompt.

  • Give every category a short id you will use in code: urgent, question, complaint, spam.
  • Write one sentence saying what belongs in it — not what it is called.
  • Add an escape hatch category. "unclear" is a real answer and a model that cannot give it will guess instead.
  • Write down three examples you already know the answer to. These become your test set.

How you know it worked

A short list you could hand to a new colleague and expect them to sort your test three correctly. If you could not, rewrite the definitions before writing any code.

Ask for an answer a program can read

A summary is for a person, so prose is fine. A classification is for a program, so prose is a problem. You want one of your ids back and nothing else, and the way to get that reliably is to say so twice: once in the instruction, and once by making the reply so constrained that there is nothing else to say.

Notice that the categories are built into the instruction from your own list rather than typed twice. The definitions are the only copy.

classify.py
from openai import OpenAI

client = OpenAI(base_url="http://127.0.0.1:8081/v1", api_key="not-needed")
MODEL = client.models.list().data[0].id

CATEGORIES = {
    "urgent": "needs an answer today; someone is blocked or angry",
    "question": "asking for information, not reporting a problem",
    "complaint": "reporting that something went wrong",
    "spam": "unsolicited marketing or automated noise",
    "unclear": "genuinely does not fit the others, or is too short to tell",
}

MENU = "\n".join(f"{name}: {meaning}" for name, meaning in CATEGORIES.items())

JOB = f"""You sort messages into exactly one category.

The categories are:
{MENU}

Reply with the category name alone. No punctuation, no explanation,
no other words. If you are unsure, reply: unclear"""


def classify(text: str) -> str:
    reply = client.chat.completions.create(
        model=MODEL,
        messages=[
            {"role": "system", "content": JOB},
            {"role": "user", "content": text[:4000]},
        ],
        temperature=0,
        max_tokens=6,  # a category name is one or two tokens; this caps a ramble
    )
    answer = reply.choices[0].message.content.strip().lower()
    # Trust nothing. A model told to reply with one word will occasionally
    # reply with a sentence, and an unknown answer is worse than "unclear".
    return answer if answer in CATEGORIES else "unclear"

How you know it worked

python -c "from classify import classify; print(classify('my order never arrived and nobody has replied'))"

One of your category names — probably complaint or urgent. Run it three times and confirm you get the same answer every time. If you do not, temperature is not actually zero.

Check it against the answers you already know

You wrote down three examples with known answers in step one. Now use them. This is the smallest possible evaluation and it will still catch the two failures that matter: a category the model never picks, and a category it picks for everything.

Run this before you process anything real. A batch of ten thousand files sorted by a broken classifier is ten thousand things you now have to re-sort.

check.py
from classify import classify

# Your own examples, with the answer you already decided.
CASES = [
    ("my order never arrived and nobody has replied", "complaint"),
    ("what are your opening hours on a bank holiday?", "question"),
    ("CONGRATULATIONS you have been selected", "spam"),
]

wrong = 0
for text, expected in CASES:
    got = classify(text)
    mark = "ok " if got == expected else "BAD"
    if got != expected:
        wrong += 1
    print(f"{mark} expected {expected:10} got {got:10} | {text[:50]}")

print(f"\n{len(CASES) - wrong}/{len(CASES)} correct")

How you know it worked

python check.py

Three out of three, ideally. Two out of three is worth investigating before continuing — usually a category definition that overlaps another. One out of three means the categories need rewriting, not the code.

Run it over the folder

Now the part that a cloud API makes expensive and a local model makes free. Walk the folder, classify each file, write a row per file. Two details make this survivable on a real batch: it writes each row as it goes rather than at the end, and it remembers what it has already done.

Both exist for the same reason. A batch of ten thousand takes hours, something will interrupt it, and losing three hours of work to a closed laptop is avoidable.

batch.py
import csv
import sys
from pathlib import Path

from classify import classify


def already_done(csv_path: Path) -> set[str]:
    """Resume rather than restart. Cheap to write, saves an evening once."""
    if not csv_path.exists():
        return set()
    with csv_path.open(encoding="utf-8") as handle:
        return {row["file"] for row in csv.DictReader(handle)}


def main(folder: str, out: str = "results.csv") -> None:
    source, csv_path = Path(folder), Path(out)
    files = sorted(p for p in source.rglob("*.txt") if p.is_file())
    done = already_done(csv_path)
    todo = [p for p in files if str(p) not in done]

    print(f"{len(files)} files, {len(done)} already done, {len(todo)} to go")

    new_file = not csv_path.exists()
    with csv_path.open("a", newline="", encoding="utf-8") as handle:
        writer = csv.writer(handle)
        if new_file:
            writer.writerow(["file", "category"])
        for index, path in enumerate(todo, start=1):
            try:
                text = path.read_text(encoding="utf-8", errors="replace")
            except OSError as err:
                print(f"  skipped {path}: {err}", file=sys.stderr)
                continue
            writer.writerow([str(path), classify(text)])
            handle.flush()  # a row on disk is a row you do not repeat
            if index % 25 == 0:
                print(f"  {index}/{len(todo)}")


if __name__ == "__main__":
    if len(sys.argv) < 2:
        sys.exit("usage: python batch.py <folder> [out.csv]")
    main(*sys.argv[1:3])

How you know it worked

python batch.py ./samples

A results.csv with one row per file. Stop it halfway with Ctrl-C and run it again — it should report the ones already done and only process the rest.

Look at what it actually did

The last step, and the one most likely to be skipped. Count the categories. A distribution that is 90% one category or that never once used a category you defined is telling you something is wrong, and it is much easier to see in a count than in ten thousand rows.

tally.py
import csv
from collections import Counter
import sys

path = sys.argv[1] if len(sys.argv) > 1 else "results.csv"
with open(path, encoding="utf-8") as handle:
    counts = Counter(row["category"] for row in csv.DictReader(handle))

total = sum(counts.values())
for name, count in counts.most_common():
    print(f"{name:12} {count:6}  {count / total:6.1%}")
print(f"{'total':12} {total:6}")

How you know it worked

python tally.py results.csv

A distribution you can defend. High "unclear" is honest and fixable — it usually means one category needs a clearer definition. A category with zero rows means the model never chose it, which is a prompt problem, not a data problem.

Building it with an agent

I want to classify a folder of text files into categories I define, write the results to CSV, and be able to stop and resume the run. It has to use a local model, and it has to be reliable enough that I trust the output without reading every row.

With Claude Code

Claude Code is good at this one because it can run the thing it just wrote. Give it the categories first:

  I want a Python tool that classifies text files into these
  categories: urgent, question, complaint, spam, unclear.
  [paste your one-line definitions]

  It must use a local OpenAI-compatible server at
  http://127.0.0.1:8081/v1 via the openai package — no cloud.
  Use temperature 0 and cap max_tokens, and never return a
  category that is not on the list.

  Build classify.py first with a handful of test cases I can
  run, and let me check the answers before you write the batch
  part.

Getting it to build the test cases before the batch loop is the whole trick. It is the difference between finding a broken classifier in thirty seconds and finding it after ten thousand files.

With Codex

Give Codex the whole shape, including the resume behaviour, which it will not add unless asked:

  Create a Python project: classify.py, batch.py, tally.py,
  check.py, requirements.txt.

  classify.py holds a CATEGORIES dict and a classify(text)
  function using the openai package against
  base_url="http://127.0.0.1:8081/v1", api_key="not-needed",
  temperature=0, max_tokens=6. Any reply not in CATEGORIES
  becomes "unclear".

  batch.py walks a folder of .txt files, writes file,category
  rows to a CSV, flushes after every row, and on restart skips
  files already present in the CSV.

  tally.py prints the category distribution with percentages.
  check.py runs classify against three known examples.

The flush-after-every-row and skip-on-restart lines are the two an agent leaves out by default, and they are the two you notice missing three hours into a run.

Read what it gave you

Generated code is a draft. These are the things that go wrong on this particular project, in roughly the order they are worth checking.

  • Does it validate the model's answer against the category list? An agent will often trust the reply and write whatever came back into your CSV, including a full sentence.
  • Is temperature actually 0? Anything higher means the same file classified twice gives different answers, and your results stop being reproducible.
  • Is max_tokens capped? Without it one confused input can generate until it hits the context limit and stall the batch.
  • Does it flush or commit each row? Buffered writes lose everything since the last flush when the run is interrupted.
  • Does resume compare the right thing? Matching on filename is right; matching on array index silently corrupts the results when files are added or removed between runs.
  • Does it handle a file it cannot decode? One file with odd bytes should not end the run.
  • Ask it to explain why it chose the chunk or truncation limit it used. If the answer is vague, the limit was a guess, and guesses about context windows are where local-model projects break.

Where to take it next

  • Add a second pass that re-runs only the "unclear" rows against a larger model, so the cheap model does the bulk and the expensive one handles the hard cases.
  • Ask for a confidence score alongside the category and sort your review queue by it. You will find that reviewing the least confident 5% catches most of the errors.
  • Swap the CSV for a small SQLite database once you are past a few thousand rows.
  • Run the same folder through two different models and compare where they disagree. Disagreement between models is a good proxy for the cases a human should look at.

Where this comes from

  1. openai-python: the official client library

Stuck on a step, or found something here that does not work? Email info@redroostertec.com and a person will answer.