Sort incoming documents into folders with an LLM
A small worker that watches an inbox folder, classifies each document with an LLM and moves it to the right place. Low-confidence results go to a review folder instead of being guessed.
import json
import os
import shutil
import time
from pathlib import Path
from openai import OpenAI
INBOX = Path(os.environ.get("INBOX_DIR", "./inbox"))
OUTBOX = Path(os.environ.get("OUTBOX_DIR", "./sorted"))
CATEGORIES = ["invoice", "contract", "support", "other"]
SUFFIXES = {".txt", ".md", ".eml"}
MIN_CONFIDENCE = 0.6
client = OpenAI() # reads OPENAI_API_KEY
SYSTEM = (
f"Classify the document into exactly one of: {', '.join(CATEGORIES)}. "
'Reply with JSON: {"category": str, "confidence": number 0-1, "summary": str}.'
)
def classify(text: str) -> dict:
resp = client.chat.completions.create(
model=os.environ.get("OPENAI_MODEL", "gpt-4o-mini"),
response_format={"type": "json_object"},
temperature=0,
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": text[:12_000]}, # bound the cost per file
],
)
data = json.loads(resp.choices[0].message.content or "{}")
try:
confident = float(data.get("confidence", 0)) >= MIN_CONFIDENCE
except (TypeError, ValueError):
confident = False
if data.get("category") not in CATEGORIES or not confident:
data["category"] = "review" # a human decides
return data
def move(path: Path, folder: str) -> None:
dest = OUTBOX / folder
dest.mkdir(parents=True, exist_ok=True)
shutil.move(path, dest / path.name)
def process(path: Path) -> None:
result = classify(path.read_text(encoding="utf-8", errors="replace"))
move(path, result["category"])
with (OUTBOX / "log.jsonl").open("a", encoding="utf-8") as log:
log.write(json.dumps({"file": path.name, **result}) + "\n")
print(f"{path.name} -> {result['category']}")
def main(poll_seconds: int = 10) -> None:
INBOX.mkdir(parents=True, exist_ok=True)
while True:
for path in sorted(INBOX.iterdir()):
# Skip folders, other types and files modified in the last few seconds
if path.suffix not in SUFFIXES or time.time() - path.stat().st_mtime < 5:
continue
try:
process(path)
except Exception as exc:
print(f"{path.name} failed: {exc}")
move(path, "failed")
time.sleep(poll_seconds)
if __name__ == "__main__":
main()Usage
pip install openai
export OPENAI_API_KEY=sk-...
INBOX_DIR=/data/inbox OUTBOX_DIR=/data/sorted python classify_inbox.pyHow it works & gotchas
- 01The 5-second age check avoids picking up files that are still being copied in. For uploads over slow links, write to a temp name and rename when complete.
- 02Anything the model isn't confident about, or answers with an unknown category, lands in
review/. Start with a high threshold and lower it once the log shows it's reliable. - 03
log.jsonlkeeps the model's summary and confidence per file. That's your audit trail and your evaluation set when you change prompts or models. - 04
shutil.moveoverwrites a file with the same name in the destination. Add a timestamp or hash to the name if collisions are possible. - 05PDFs and scans need text extraction first (for example
pypdfor an OCR step) before callingclassify.
Need this wired into a real system?
We build and harden production software end to end.
More code helpers
Forward webhooks to Slack
Turn events from any tool that can send a webhook (monitoring, payments, forms, CI) into readable Slack messages, protected by a shared secret.
Stream LLM responses from a Next.js route handler
Send tokens to the browser as the model produces them instead of making users wait for the full completion. Uses a plain ReadableStream, so it works with any client that can read a fetch body.
Retry LLM calls with exponential backoff and jitter
LLM APIs return 429s and 5xx errors under load. This wrapper retries only the failures worth retrying, honours Retry-After and spreads retries out so many workers don't hit the API in lockstep.