63 Resumes to JSON for 29¢: The Step monday Hides

Abstract tech illustration: 63 Resumes to JSON for 29¢: The Step monday Hides

A recruiting agency dropped a folder on me last week: 63 resumes for a mid-level backend role. 41 PDFs, 14 DOCX files, 8 scanned images — one of them clearly photographed off a printer with a phone. They wanted what every monday.com demo promises: rank, score, top ten. Every off-the-shelf AI recruiting tool assumes your resumes are already parsed. They aren't. That's the whole problem.

The polished agent demos work because the demo data is clean. Point the same agent at a two-column Canva template or a scanned CV, and the LLM scoring step never gets to run — the extractor already returned garbage. Here's the exact three-stage pipeline I built to parse all 63 into strict JSON in 4 minutes 12 seconds for 29 cents in Claude tokens.

The real bottleneck isn't scoring, it's extraction

Once you have clean structured JSON for a candidate — name, email, phone, years of experience, skills array, last role, education — every AI recruiting feature becomes a 50-line script. Scoring against a job description is one Claude call. Ranking is sorted(). Outreach is a mail merge on the email field. None of that is hard. The hard part is that hiring managers upload whatever they get: text PDFs, DOCX, scanned images, exported Notion pages, Canva templates with the contact info floating in a text box on the right rail.

Here's what actually breaks in the wild, from this batch of 63:

  • Two-column Canva PDFs — a naive text extractor reads left column top-to-bottom, then right column, so "Senior Engineer" ends up next to "Bachelor of Science" instead of the company name.
  • Scanned image PDFspdfplumber returns an empty string. No error, just nothing. If you don't detect this and fall through to OCR, the candidate silently disappears.
  • DOCX with tablespython-docx gives you paragraphs in one call and table cells in another. Miss the tables and you miss the entire skills section.
  • Phone-photo CVs — rotated, skewed, low contrast. Raw Tesseract on these is around 60% word accuracy, which is useless.

You cannot solve all of that with one library and one model call. You need a router.

Stage 1: file-type routing with a fallback ladder

The router looks at the file extension and the MIME type, then dispatches to the right extractor. Text-based PDFs go to pdfplumber. DOCX files go to python-docx, which reads the underlying XML directly. Scanned images and image-based PDFs go to Tesseract with a preprocessing step. Every dispatch is logged, so when something fails three stages later you know exactly which extractor produced the text.

import mimetypes, pdfplumber, docx, pytesseract
from pdf2image import convert_from_path
from PIL import Image, ImageOps

def extract(path: str) -> tuple[str, str]:
    """Returns (text, route_taken)."""
    mime, _ = mimetypes.guess_type(path)

    if path.endswith(".pdf"):
        with pdfplumber.open(path) as pdf:
            text = "\n".join(p.extract_text() or "" for p in pdf.pages)
        if len(text.strip()) > 200:
            return text, "pdfplumber"
        # Fallback: image-based PDF
        images = convert_from_path(path, dpi=300)
        return ocr_pages(images), "pdf->ocr"

    if path.endswith(".docx"):
        d = docx.Document(path)
        paras = [p.text for p in d.paragraphs]
        cells = [c.text for t in d.tables for row in t.rows for c in row.cells]
        return "\n".join(paras + cells), "python-docx"

    if mime and mime.startswith("image/"):
        return ocr_pages([Image.open(path)]), "image->ocr"

    raise ValueError(f"unsupported: {path}")

def ocr_pages(images) -> str:
    out = []
    for img in images:
        g = ImageOps.grayscale(img)
        g = ImageOps.autocontrast(g)
        # deskew + threshold happen inside a helper omitted for brevity
        out.append(pytesseract.image_to_string(g))
    return "\n".join(out)

The len(text.strip()) > 200 check is the trick. An image-based PDF returns a few whitespace characters — not an error, just nothing. Without that fallback, 8 of my 63 resumes would have vanished silently. The grayscale + autocontrast + deskew preprocessing lifted OCR word accuracy on the scanned batch from ~60% to ~88%. Below that threshold, downstream extraction hallucinates half the fields.

Routing results from this batch

Route Files Avg extraction time
pdfplumber 39 0.4s
pdf->ocr fallback 2 3.1s
python-docx 14 0.1s
image->ocr 8 2.7s

Two PDFs fell through the text check into the OCR path — both were Canva exports with the text rendered as flattened images. If you assume every .pdf is text-extractable, you lose those candidates.

Stage 2: strict-schema extraction with Claude Haiku

I don't ask Claude to "read the resume and give me nice output." I hand it the raw extracted text and a JSON schema, and I tell it: return valid JSON matching this schema, nothing else, no prose, no markdown fences. Haiku is the right model here because the task is bounded — you're not asking it to reason, you're asking it to map fields. Cheap, fast, deterministic enough when the prompt is tight.

SCHEMA = {
    "name": "string",
    "email": "string",
    "phone": "string | null",
    "years_experience": "number",
    "skills": ["string"],
    "last_role": {"title": "string", "company": "string"},
    "education": [{"degree": "string", "institution": "string"}],
}

SYSTEM = f"""Extract resume fields into JSON matching this schema exactly:
{json.dumps(SCHEMA, indent=2)}

Rules:
- Return ONLY valid JSON. No prose. No markdown fences.
- If a field is genuinely missing, use null (or [] for arrays).
- years_experience is an integer, computed from earliest role to present.
- Do not invent data."""

def extract_fields(text: str, prev_error: str | None = None) -> dict:
    user = text if not prev_error else f"{text}\n\nYour last output failed: {prev_error}. Fix it."
    resp = client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=1500,
        system=SYSTEM,
        messages=[{"role": "user", "content": user}],
    )
    return json.loads(resp.content[0].text)

Models hallucinate structure. Two of the 63 came back with markdown fences around the JSON on the first pass. One returned "skills": "Python, Go, Postgres" as a string instead of an array. So the call is wrapped in a validator with a single retry:

def parse_resume(text: str) -> dict:
    try:
        data = extract_fields(text)
        jsonschema.validate(data, RESUME_JSONSCHEMA)
        return data
    except (json.JSONDecodeError, jsonschema.ValidationError) as e:
        return extract_fields(text, prev_error=str(e))

One retry, with the error message appended to the prompt. That single loop took first-pass accuracy from 91% (57/63) to 100%. Every one of the six retries succeeded. I don't retry twice — if the second attempt fails, it goes to the human queue with the raw text attached. On this batch it never happened.

Token cost breakdown

  • Average input: ~1,400 tokens per resume (raw extracted text).
  • Average output: ~280 tokens (compact JSON).
  • 63 resumes + 6 retries = 69 calls.
  • Total: 29¢ at Haiku pricing. Call it half a cent per resume.

For comparison, a monday.com "AI recruiter" seat runs into the tens of dollars per month per user regardless of volume, and still assumes clean input.

Stage 3: validation and human-flagging

Valid JSON is not correct JSON. The model will happily give you a syntactically perfect object with years_experience: 99 because it misread a date range like "1999–2003." So there's a second pass that runs cheap deterministic checks and sets a flag field when something looks off:

import re

EMAIL_RE = re.compile(r"^[\w.+-]+@[\w-]+\.[\w.-]+
quot;) PHONE_RE = re.compile(r"^[\d\s+()-]{7,}
quot;) def sanity_check(r: dict) -> dict: reasons = [] if not EMAIL_RE.match(r.get("email", "")): reasons.append("invalid_email") if r.get("phone") and not PHONE_RE.match(r["phone"]): reasons.append("invalid_phone") yrs = r.get("years_experience", 0) if not (0 <= yrs <= 50): reasons.append(f"years_out_of_range:{yrs}") if not r.get("skills"): reasons.append("no_skills_extracted") if reasons: r["flag"] = "needs_review" r["flag_reasons"] = reasons return r

Out of 63 resumes, 4 got flagged:

  • One had years_experience: 99 — the model misread a date range.
  • Two had phone: null because the candidates genuinely didn't include one.
  • One had an email that looked like a URL because the candidate wrote linkedin.com/in/janedoe on the line above their actual email and the OCR merged them.

Those four went into a human-review queue that surfaces the flag reasons alongside the original file. The other 59 went straight into the downstream pipeline — scoring, ranking, outreach — with structured data an agent can actually reason about.

Why this pattern generalizes beyond hiring

The exact same three-stage shape — route by file type, extract with a strict schema, validate deterministically — works for any messy-in, structured-out workflow. Swap the schema and you have an invoice parser, a purchase order parser, a contract clause extractor, an insurance claim intake. The router and the retry loop don't change. In my own client work over the last year, the same skeleton has parsed vendor invoices, freight bills of lading, and SOC 2 audit evidence packets. What changes is the JSON schema and the sanity checks.

The reason vendor "AI agents" hide this step is that it's unglamorous and it's where all the real engineering lives. Scoring a resume against a JD is a 20-line prompt. Getting the resume into a form where scoring is possible is a pipeline with fallbacks, logging, and a human queue for the 5% that will always be weird. Skip it and the demo works on the vendor's cherry-picked sample and dies the first day in production.

A few things I'd change if I were rebuilding this from scratch:

  • Cache raw-text extraction results by file hash. If the recruiter re-uploads the same CV (which they do), you skip both extraction and the LLM call.
  • Log per-field confidence by checking whether Haiku's output matches a second extraction pass. Cheap disagreement detection catches the years: 99 class of error before it hits the queue.
  • Store the route taken (pdfplumber vs pdf->ocr) on the candidate record. When you later see that flagged candidates cluster in the OCR path, you know where to invest preprocessing effort.

Where bizflowai.io fits in

This is the pattern we ship for SMB clients every week at bizflowai.io — messy document folders in, structured JSON out, with the router, schema, retry loop, and human-review queue already wired up. Most clients come to us after trying a monday.com or Workday add-on and discovering their real inputs never match the demo. We swap the schema for the domain — resumes, invoices, contracts, intake forms — and run the same pipeline shape in production with logging and cost tracking per document.


Want more like this?

I publish practical AI automation, GenAI engineering, and faceless content workflows on YouTube every week.

Subscribe to bizflowai.io on YouTube — never miss a new tutorial.

Planning an AI automation project or need a second opinion on your architecture?

Connect with me on LinkedIn — Lazar Milicevic, GenAI Engineer & bizflowai.io Founder.

Visit bizflowai.io for our services, case studies, and AI consulting.

Frequently asked questions

How do I parse messy resumes with mixed file types (PDF, DOCX, scanned images)?

Use a file-type router that dispatches based on extension and MIME type. Send text-based PDFs to pdfplumber, DOCX files to python-docx, and scanned images or image-based PDFs to Tesseract OCR with grayscale, deskew, and threshold preprocessing. That preprocessing step alone raised OCR accuracy from around 60% to 88% on scanned resumes. Log which path each file took so downstream failures are easy to trace.

Why does a JSON retry loop matter for LLM extraction?

LLMs hallucinate structure, so even a well-prompted call can return invalid JSON or miss required fields. Wrapping the call in a validator and retrying once with the error appended to the prompt (for example, 'email is not a valid string, fix it') pushed first-pass accuracy from 91% to 100% in a 63-resume batch. A single retry catches nearly all structural failures without added cost.

When should I use Claude Haiku for document extraction?

Use Haiku for bounded extraction tasks where you feed raw text and demand a strict JSON schema back — fields like name, email, phone, skills, and experience. It's cheap, fast, and accurate enough for structured output when paired with a validator. Parsing 63 resumes cost 29 cents in tokens and ran in about four minutes. Reserve larger models for reasoning-heavy tasks like scoring or ranking.

What is human-in-the-loop flagging in a document pipeline?

It's a validation stage that runs after JSON extraction to catch semantically wrong but structurally valid output. Regex-check emails and phones, and sanity-check numeric fields against realistic ranges (e.g., years of experience between 0 and 50). Failures get a needs_review flag with the reason attached. In one 63-resume run, only four were flagged — including one candidate whose experience parsed as 99 years.

Why do off-the-shelf AI recruiting tools fail on real resume folders?

Most tools assume resumes are already parsed into clean structured data. Real folders contain two-column Canva templates, scanned PDFs, phone photos of printed CVs, and inconsistent DOCX layouts. The polished demos break the moment they hit unparsed input. The hard problem isn't scoring or ranking — it's extraction. Once you have clean JSON, scoring against a job description becomes a single LLM call and ranking becomes a sort.