Analyze 10,000 documents overnight.
Async batch routing at ~50% the realtime cost. Structured JSON extraction. SQLite-backed progress + failure recovery. ~120 lines of Python.
Architecture
Two scripts + SQLite
- enqueue.py — iterates a directory of docs, fires one batch request per doc, records the job_id in SQLite. Idempotent: re-running skips already-queued docs.
- poll_results.py — background worker that polls all
queuedjobs every 30 s, persists results + errors. Stops when no queued rows remain. - SQLite — single-file state. No Redis, no Postgres needed for this scale.
Why batch: at 10,000 documents averaging 5,000 tokens each, realtime costs roughly twice as much as batch. The SLA is 15-30 minutes per doc, but we don’t care — we’re running overnight.
1. Define the output
JSON schema for the model
We use response_format: json_object + an inline schema in the system prompt. For stricter validation, switch to response_format: json_schema with the same shape — supported on most newer models.
# schemas.py — what we want from every document.
SCHEMA = {
"type": "object",
"properties": {
"title": {"type": "string"},
"doc_type": {"type": "string", "enum": ["invoice", "contract", "receipt", "report", "other"]},
"date": {"type": "string", "description": "ISO YYYY-MM-DD if visible, else null"},
"amounts": {
"type": "array",
"items": {
"type": "object",
"properties": {
"label": {"type": "string"},
"value": {"type": "number"},
"currency": {"type": "string"},
},
"required": ["value", "currency"],
},
},
"parties": {"type": "array", "items": {"type": "string"}},
"key_clauses": {"type": "array", "items": {"type": "string"}},
"language": {"type": "string"},
},
"required": ["title", "doc_type", "language"],
}2. Enqueue
Fire batch requests
# enqueue.py — fire one batch request per document.
import os
import json
import sqlite3
import httpx
from pathlib import Path
from schemas import SCHEMA
API = "https://api.anoman.io"
KEY = os.environ["ANOMAN_API_KEY"]
# Local SQLite for progress + idempotency. Reuse this script — already-
# queued docs are skipped on re-run.
db = sqlite3.connect("batch_run.db")
db.execute("""
CREATE TABLE IF NOT EXISTS jobs (
doc_path TEXT PRIMARY KEY,
job_id TEXT,
status TEXT DEFAULT 'queued',
result TEXT,
error TEXT,
enqueued_at REAL DEFAULT (strftime('%s', 'now'))
)
""")
db.commit()
SYSTEM = """Extract structured metadata from the document. Return JSON
matching the provided schema. If a field is not present in the doc,
omit it. Don't hallucinate values."""
def enqueue_doc(doc_path: Path):
# Skip if already queued
cur = db.execute("SELECT job_id FROM jobs WHERE doc_path = ?", (str(doc_path),))
if row := cur.fetchone():
return row[0]
content = doc_path.read_text() # or PDF→text extraction
response = httpx.post(
f"{API}/v1/chat/completions",
headers={
"Authorization": f"Bearer {KEY}",
"x-anoman-prefer-batch": "true",
},
json={
"model": "deepseek-v3", # Long context, batch-friendly, cheap
"messages": [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": f"Schema:\n{json.dumps(SCHEMA)}\n\nDoc:\n{content[:50_000]}"},
],
"response_format": {"type": "json_object"},
"metadata": {"doc_path": str(doc_path)},
},
)
response.raise_for_status()
job = response.json()
db.execute(
"INSERT INTO jobs (doc_path, job_id) VALUES (?, ?)",
(str(doc_path), job["id"]),
)
db.commit()
return job["id"]
# Walk a directory of docs
DOCS = Path("./docs_to_analyze")
for doc in DOCS.glob("*.txt"): # or *.pdf
enqueue_doc(doc)
print(f"queued {doc.name}")Run this once. It walks the directory, queues every doc, exits. SQLite remembers what’s queued — safe to re-run.
3. Poll for completions
Background poller
Polls every queued job every 30 s. Records results to SQLite. Records errors to SQLite. Exits when no more queued rows.
# poll_results.py — run in a separate process. Polls every queued
# job; persists results + errors. Idempotent — re-running picks up
# where it left off.
import json, time, sqlite3, httpx
API, KEY = "https://api.anoman.io", os.environ["ANOMAN_API_KEY"]
db = sqlite3.connect("batch_run.db")
def poll_one(job_id: str) -> tuple[str, dict | None, str | None]:
"""Returns (status, result, error_message). status ∈ {queued, complete, failed}."""
r = httpx.get(
f"{API}/anoman/v1/batch/{job_id}",
headers={"Authorization": f"Bearer {KEY}"},
timeout=30,
)
if r.status_code == 200:
body = r.json()
try:
payload = json.loads(body["choices"][0]["message"]["content"])
return "complete", payload, None
except (KeyError, json.JSONDecodeError) as e:
return "failed", None, f"bad_payload: {e}"
if r.status_code == 202:
return "queued", None, None
return "failed", None, f"http_{r.status_code}: {r.text[:200]}"
while True:
rows = db.execute(
"SELECT doc_path, job_id FROM jobs WHERE status = 'queued'"
).fetchall()
if not rows:
print("all jobs complete or failed — exit")
break
for doc_path, job_id in rows:
status, result, err = poll_one(job_id)
if status == "queued":
continue
if status == "complete":
db.execute(
"UPDATE jobs SET status = ?, result = ? WHERE doc_path = ?",
("complete", json.dumps(result), doc_path),
)
print(f"✓ {doc_path}")
else:
db.execute(
"UPDATE jobs SET status = ?, error = ? WHERE doc_path = ?",
("failed", err, doc_path),
)
print(f"✗ {doc_path}: {err}")
db.commit()
time.sleep(30)4. Progress report
See where you are
Run anytime to check % done + cumulative batch savings.
# progress.py — run anytime to see how far along the batch is.
import sqlite3
db = sqlite3.connect("batch_run.db")
total, queued, complete, failed = db.execute("""
SELECT
COUNT(*),
SUM(CASE WHEN status = 'queued' THEN 1 ELSE 0 END),
SUM(CASE WHEN status = 'complete' THEN 1 ELSE 0 END),
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END)
FROM jobs
""").fetchone()
print(f"Total: {total}")
print(f"Queued: {queued} ({100*queued/total:.1f}%)")
print(f"Complete: {complete} ({100*complete/total:.1f}%)")
print(f"Failed: {failed} ({100*failed/total:.1f}%)")
# Total savings from batch routing — _anoman.savings_usd on each result
import json
total_saved = 0
for (result_json,) in db.execute("SELECT result FROM jobs WHERE status = 'complete'"):
body = json.loads(result_json) if result_json else {}
saved = body.get("_anoman", {}).get("savings_usd", "0")
total_saved += float(saved)
print(f"Total saved vs realtime: ${total_saved:.2f}")Production tips
- Webhook over polling — for >1,000 docs, configure the
batch.completedwebhook (see /docs/webhooks) and skip the poller. Anoman pushes results when ready. - Per-doc retries — if a doc fails with
guardrail_error(PII redaction wiped too much content), retry with thex-anoman-policy-groupheader pointing at a more permissive policy group. - Long docs — for >50K-token documents, chunk and run multiple batch jobs per doc, then merge results. Cheaper than realtime-with-long-context.
- Cost cap — set a per-key monthly budget. Runaway pipelines hit 402 instead of draining you.
- Auto-promotion to realtime — Anoman auto-promotes batch jobs at 80% SLA. You won’t miss the deadline, but you stop saving on those specific jobs. Surfaces in the trace.