A solo recruiter hiring for one position gets the same flood: 150–300 CVs in a shared inbox, in three formats, with layouts ranging from clean to artistic. Reading each properly takes 3–5 minutes — a full working week for one role, before a single phone screen.

The standard advice is “buy an ATS”. Most small agencies don’t need an ATS; they need the pile sorted. This guide builds exactly that: a Python pipeline that reads CVs (PDF and DOCX), scores them against your job description with Gemini, and produces a ranked shortlist with a reason for every score — under $1 per 200 CVs.

Along with the code comes the part most tutorials skip: candidate data is personal data, and screening affects livelihoods. The privacy and bias rules below aren’t decoration — skip them and you’re one angry candidate from real trouble.

Why scoring against a job description (and not “rating” CVs)

The model never sees other candidates. Each CV is scored independently against the same rubric, 1–5 on criteria you define from the job description. This matters because:

  • Relative ranking invites drift. “Better than the last one?” produces inconsistent, comparison-contaminated scores.
  • An absolute rubric is auditable. When a client asks why someone wasn’t shortlisted, you show criterion scores and stated reasons.
  • You stay the decision-maker. The output is a shortlist with reasons, not a rejection list. Nobody gets rejected by a script.

What you need

ItemCostNotes
Python 3.10+$0Any computer
Gemini API key$0 to startaistudio.google.com → “Get API key”
google-genai, pydantic, pypdf, python-docx$0pip install
The job description$0You write it with the client

Pricing as of October 2026 — verify at ai.google.dev/pricing:

  • Gemini 2.5 Flash (paid tier): $0.30 per 1M input tokens, $2.50 per 1M output tokens
  • Free tier: never for real CVs. Google’s terms allow free-tier content to be used to improve its products. A candidate’s CV is exactly the kind of personal document that must not go through it.

Cost per CV

A two-page CV as a PDF sent directly: roughly 800–1,500 tokens. The rubric and instructions: ~500 tokens. Structured output: ~350 tokens.

  • Input: ~1,800 × $0.30/1M = $0.0005
  • Output: ~350 × $2.50/1M = $0.0009
  • Total: about $0.0015 per CV — 200 CVs ≈ $0.30, a full month of screening for a small agency is a few dollars.

Step 1: The rubric

You write this with the client, from the job description. It’s the whole game — the model only scores what you ask about. Deliberately exclude name, gender, age, photo, school prestige and address; score observable, job-relevant facts:

RUBRIC = """
Role: Retail Supervisor (Jakarta) — 2 positions, entry-level management.

Criteria, each scored 1-5 with one-sentence evidence:
- retail_experience: years of hands-on retail work (any role). 5 = 3+ years.
- supervision: has directly scheduled, trained or led staff. 5 = formally supervised 3+ people.
- ops_tools: used POS / inventory / scheduling software. 5 = names specific systems.
- shift_flexibility: explicitly available for rotating shifts or states no constraint.
- jakarta_based: currently in Greater Jakarta or states willingness to relocate.

Rules:
- Score ONLY what the CV states. Absence of information = 1, never a guess.
- Ignore photo, name, gender, age, marital status, religion, address and school prestige.
  These do not appear in your output at all.
- evidence must quote or closely paraphrase the CV line you relied on.
"""

Two things to notice: “absence = 1, never a guess” stops the model equating a gap with incompetence, and the ignore list is enforced twice — in the rubric and again in the schema below.

Step 2: The script

Save as screen.py. It reads every .pdf and .docx in cv/, sends each as extracted text, and writes a ranked CSV.

import csv, os, sys, time
from pathlib import Path

from google import genai
from google.genai import errors, types
from pydantic import BaseModel
from pypdf import PdfReader

MODEL = os.environ.get("SCREEN_MODEL", "gemini-2.5-flash")
CV_DIR = Path("cv")
OUT = "shortlist.csv"

# --- the ignore list, enforced structurally ------------------------------
class Criterion(BaseModel):
    name: str
    score: int          # 1-5
    evidence: str       # quote or close paraphrase from the CV

class Screening(BaseModel):
    criteria: list[Criterion]
    total: int          # sum of scores, computed by the model for convenience only
    strengths: str      # max 2 sentences
    concerns: str       # missing info a human should check in a call

def cv_to_text(path: Path) -> str:
    if path.suffix.lower() == ".pdf":
        reader = PdfReader(str(path))
        return "\n".join((p.extract_text() or "") for p in reader.pages)
    if path.suffix.lower() == ".docx":
        import docx
        d = docx.Document(str(path))
        return "\n".join(p.text for p in d.paragraphs)
    raise ValueError(f"unsupported format: {path.suffix}")

def build_prompt(cv_text: str) -> str:
    return f"""You are assisting a recruiter by scoring ONE candidate's CV against a fixed rubric.

{RUBRIC}

Return the JSON. Each criterion score is independent of any other candidate —
you have not seen and will not see other CVs.

CV:
---
{cv_text[:15_000]}
---"""

def screen(prompt):
    for attempt in range(3):
        try:
            resp = client.models.generate_content(
                model=MODEL, contents=prompt,
                config=types.GenerateContentConfig(
                    response_mime_type="application/json",
                    response_schema=Screening,
                    temperature=0,
                    thinking_config=types.ThinkingConfig(thinking_budget=0),
                ),
            )
        except errors.APIError as e:
            print(f"  API error {e.code} (attempt {attempt + 1})", file=sys.stderr)
            time.sleep(5 * (attempt + 1))
            continue
        if resp.parsed:
            return resp.parsed
    return None

def main():
    client = genai.Client()   # reads GEMINI_API_KEY
    files = sorted(p for p in CV_DIR.iterdir() if p.suffix.lower() in {".pdf", ".docx"})
    rows = []
    for path in files:
        try:
            text = cv_to_text(path)
        except Exception as e:
            print(f"ERROR reading {path.name}: {e}", file=sys.stderr)
            continue
        if len(text.strip()) < 80:          # scanned image PDFs extract to ~nothing
            print(f"SKIP {path.name}: no extractable text (scanned image? OCR first)")
            continue
        result = screen(build_prompt(text))
        if result is None:
            print(f"ERROR {path.name}: API failed after retries", file=sys.stderr)
            continue
        total = sum(c.score for c in result.criteria)
        rows.append({
            "file": path.name,
            "total": total,
            **{c.name: c.score for c in result.criteria},
            "strengths": result.strengths,
            "concerns": result.concerns,
            "evidence": " | ".join(f"{c.name}: {c.evidence}" for c in result.criteria),
        })
        print(f"{total:>3}  {path.name}")

    rows.sort(key=lambda r: -r["total"])
    with open(OUT, "w", newline="", encoding="utf-8-sig") as f:
        w = csv.DictWriter(f, fieldnames=rows[0].keys())
        w.writeheader()
        w.writerows(rows)
    print(f"\n{len(rows)} CVs scored -> {OUT} (sorted by total)")
    print("Read 'evidence' before calling anyone. Scores are a reading order, not a verdict.")

if __name__ == "__main__":
    main()
mkdir cv && cp /path/to/cvs/*.pdf cv/   # and .docx files
python screen.py

shortlist.csv comes out sorted by total, every score backed by quoted evidence. You read the top 15–20, pick the phone-screen list yourself; concerns tells you what to verify in the call.

Why the checks matter as much as the scores

  • Scanned CVs. Candidates for operational roles often submit photographed CVs with no text layer. The length check catches them for OCR instead of silently dropping them — which would systematically exclude exactly the candidates least likely to have polished PDFs.
  • Evidence strings. Every 5 must point at a CV line. Vague evidence (“has experience”) means an unreliable score — re-read that CV yourself.
  • Temperature 0. The same CV should score the same twice. If it doesn’t, the rubric is ambiguous — fix the rubric, not the seed.

The privacy rules (this is the part that keeps you in business)

CVs are personal data. In Indonesia UU No. 27/2022 (PDP Law) applies; screening for overseas clients often drags GDPR-style duties along. The practical rules:

  1. Paid tier only. Free-tier content can be used to improve Google’s products and may be reviewed by humans. Candidate CVs never go through it. Billing costs cents.
  2. Process in an appropriate region. Set the API processing region for your clients’ data; note it in the client agreement.
  3. Don’t retain what you don’t need. The shortlist survives; CVs of non-shortlisted candidates get deleted per the agreed window (commonly 30–90 days).
  4. Tell candidates. The intake confirmation states that automated tools assist initial screening and a human makes all decisions. Several jurisdictions require this; it’s good practice everywhere.
  5. A human decides. Every invitation and rejection is issued by you, not the script — an ethical line and, increasingly (NYC Local Law 144, the EU AI Act’s hiring provisions), a legal one.
  6. No client data on free web tools. “Just paste the CV into a chatbot” is the shortcut that ends relationships and can end agencies.

Bias: what the rubric can and can’t fix

A rubric that ignores name, age, gender and school reduces some biases — the model literally isn’t asked about them. It can’t remove bias already in your criteria: “3+ years retail experience” quietly excludes career-changers; “names specific systems” favours candidates from chains that could afford that software. Review each criterion with the client before the first batch: does this predict the job, or just mirror the last hire?

Then audit the output. If one demographic cluster pools at the bottom in a way the evidence strings don’t explain, the rubric is doing something you didn’t intend. The evidence column exists to make that audit possible.

Honest cost breakdown

One role, 200 CVs:

ItemCost
Gemini 2.5 Flash, 200 × ~$0.0015~$0.30
Re-scores after rubric revision~$0.30
Your time: rubric with client, review of top 202–3 h
Totalunder $1 + your time

Compare: a week of CV reading, or an ATS subscription plus the same reading. The tool doesn’t replace your judgment; it replaces the re-typing and re-reading that made you ration your judgment.

How to Sell This as a Service

Small recruiting shops, HR freelancers serving several SMEs, and directly-hiring SMEs with no HR department feel this pain with every open role.

The offer:

  1. Per-role screening: client sends the JD and the CV pile; you deliver a ranked shortlist (top 10–20) with evidence-based scores and a “what to verify in the call” column within 48 hours. Fastest sell — they pay for a shortlist, not software.
  2. Setup + monthly: for agencies with steady intake, install the pipeline on their machine with their key, tune rubrics per client, handle each new batch. Recurring work, recurring fee.

Pricing. Anchor on hours saved and placement value, not the $0.30 of API. A test zone: Rp 1,500,000–4,000,000 per role (a few hundred USD internationally) for up to 200 CVs; retainers for agencies doing 3–5 roles/month land higher. Benchmark against their analyst hours. No established market rate exists — the first client sets the real one.

What to promise: “A ranked shortlist with a written reason for every score, in 48 hours, on your rubric.” Never “the best candidates found by AI” — you can’t guarantee quality, and screening is a human-decision domain legally and ethically. Saying so builds more trust than hype.

Reality check: what does NOT work

  • Auto-rejecting below a threshold. Scores are a reading order, not a verdict. Low scores get skimmed by a human, never auto-declined.
  • Free tier for CVs. Never — candidate data plus Google’s free-tier terms is the exact combination you must not create.
  • Scoring what isn’t there. “Absence = 1” is load-bearing; remove it and the model flatters incomplete CVs.
  • Trusting scores without evidence. Vague evidence is a guess in a nice font. Read the evidence column first.
  • Keeping CVs forever. Retention is a liability, not an asset. Agree a window, delete on schedule.
  • Hiding the automation. Discovery later loses you client and candidate alike. Up-front disclosure reads as professionalism.

Final checklist

  • Rubric reviewed with the client, ignore-list confirmed
  • Paid-tier API key, region set, budget alert on
  • Scanned CVs OCR’d and included, not silently skipped
  • Every shortlisted score has concrete evidence
  • Candidate disclosure line in your intake emails
  • Retention window agreed, deletion scheduled
  • A human made every invite/decline decision

Try it on your last closed role — you already know who you hired. If the shortlist would have surfaced that person in the top ten, you have a faster process and a demo worth showing the next agency.