# Bulk ATS Scoring Engine One job description in, many resume PDFs in, a score-sorted leaderboard out. Resumes are extracted with `pypdf`, evaluated concurrently against the job description with the OpenAI Responses API, validated against a strict schema, and returned as a single JSON response. A failure on one resume never fails the batch. [CLAUDE.md](claude.md) is the specification this implements and remains the source of truth for design decisions. ## Setup Requires Python 3.11+. ```bash python -m venv .venv .venv\Scripts\activate # Windows # source .venv/bin/activate # macOS / Linux pip install -e ".[dev]" copy .env.example .env # Windows # cp .env.example .env # macOS / Linux ``` Put an `OPENAI_API_KEY` in `.env`. `.env` is gitignored; never commit it. > On Windows, write `.env` as UTF-8 **without** a BOM. PowerShell 5.1's > `Set-Content -Encoding utf8` adds one, and a BOM becomes part of the first > variable's name — that setting then silently reads as empty. The app parses `.env` > as `utf-8-sig` so it tolerates this, but other tools reading the same file will not. Run the service: ```bash uvicorn app.main:create_app --factory --reload ``` There is deliberately no module-level `app` object. Building one at import time would read settings — and fail on a bad `ANTHROPIC_MODEL` — merely because something imported the module. ## Verify the request shape before trusting it Unit tests use fakes, so they cannot prove the real API accepts the request. One live check does, and it costs a few cents: ```bash python scripts/smoke_structured_output.py ``` It confirms the schema derived from `ATSScore` is accepted, that `output_parsed` comes back valid, and that the second call reports non-zero `cached_tokens`. Run it whenever you change the model or upgrade the SDK. Last verified against `gpt-5.4-mini`: both calls parsed, and call 2 served 2304 of 2649 input tokens from cache. ## API ### `POST /api/v1/score` `multipart/form-data`: | Field | Type | Notes | |---|---|---| | `job_description` | text | Required, non-blank, `MAX_JD_CHARS` ceiling | | `resumes` | file[] | Required, `.pdf` only, `MAX_RESUMES_PER_REQUEST` / `MAX_PDF_SIZE_MB` ceilings | ```bash curl -X POST http://localhost:8000/api/v1/score \ -F "job_description=Backend engineer. Required: Python, FastAPI, Docker." \ -F "resumes=@candidate-a.pdf" \ -F "resumes=@candidate-b.pdf" ``` ```json { "request_id": "2ce31ea9-29b2-4cad-a916-1a18cfc69c20", "total": 2, "succeeded": 1, "failed": 1, "results": [ { "filename": "candidate-a.pdf", "status": "completed", "match_score": 82, "matched_keywords": ["Python", "FastAPI", "Docker"], "missing_keywords": ["AWS", "Kubernetes"], "summary_critique": "Strong Python backend experience, but no cloud or orchestration evidence." }, { "filename": "candidate-b.pdf", "status": "failed", "error_code": "PDF_TEXT_UNAVAILABLE", "error_message": "No usable text could be extracted from the PDF." } ] } ``` Completed results come first, sorted by `match_score` descending. Failures follow, in upload order. Ties keep upload order. ### Status codes | Code | Meaning | |---|---| | 200 | Batch processed — including batches where every candidate failed | | 400 | Malformed multipart request, or blank job description | | 413 | Too many files, or a file over the size limit | | 415 | A file is not a PDF | | 422 | Structurally valid request with an out-of-range field value | | 500 | Unexpected internal error | Error responses are `{"request_id", "error_code", "error_message"}`. Stack traces, provider response bodies, prompts, and document content never appear in them. ### Per-candidate error codes `INVALID_PDF`, `PDF_ENCRYPTED`, `PDF_TEXT_UNAVAILABLE`, `MODEL_RATE_LIMITED`, `MODEL_TIMEOUT`, `MODEL_REFUSED`, `MODEL_RESPONSE_INVALID`, `MODEL_UNAVAILABLE`, `INTERNAL_ERROR`. ## Configuration See [.env.example](.env.example) for the full list. Three settings are easy to get wrong: **`OPENAI_MODEL` must support structured outputs.** Validated at startup as a prefix check over known families — `gpt-5*`, `gpt-4.1*`, `o3*`, `o4*` — rather than an exact list, so a new point release isn't rejected on arrival. Two deliberate exclusions: `gpt-4o` (snapshots before 2024-08-06 lack structured outputs, and aliases hide which you get) and any `-chat-latest` variant (tracks the ChatGPT product surface, no reasoning effort). `gpt-4.1` *is* allowed but is not a reasoning model — the adapter detects that and omits the `reasoning` parameter instead of sending a 400. **`OPENAI_MAX_OUTPUT_TOKENS` covers reasoning tokens and the response together.** A small cap truncates mid-JSON and the candidate fails with `MODEL_RESPONSE_INVALID`. The enforced floor is 2048 and the tested baseline is 4000. **Lower `OPENAI_EFFORT`, not `OPENAI_MAX_OUTPUT_TOKENS`, to cut cost.** The token budget is a truncation guard, not a spend dial; effort is the spend dial. There is no `temperature` / `top_p` setting. Reasoning models reject them; the model is steered by the system prompt and structured outputs instead. ## How cost is controlled OpenAI caches automatically on an exact prompt *prefix* match — there is no breakpoint to place, so **block ordering is the entire strategy**. The instructions and job description are byte-identical across every candidate in a batch and go first; the resume goes second. On the live smoke test this served 87% of input tokens from cache (2304 of 2649) on the second call. Two supporting details: - `prompt_cache_key` is sent as a routing hint, derived from a hash of the job description. It is stable for a whole batch and never per-candidate — a high-cardinality key would defeat the purpose. - A cache entry only becomes readable once the first response exists. If all 50 candidates launched at once, every one would pay full price — so `score_batch` awaits the first candidate alone to prime the prefix, then fans the rest out under the concurrency semaphore. This is why nothing volatile may ever enter the job-description block. A timestamp, request id, or filename there moves the divergence point to the front of the prompt and the whole batch stops hitting the cache. [test_llm.py](tests/unit/test_llm.py) fails if that happens. Caching has a **1024-token minimum**, so short job descriptions will not cache at all. ## Development ```bash ruff check . ruff format --check . mypy app pytest -q ``` No test makes a live API call. Tests inject either `FakeScorer` (replacing the whole adapter) or a fake `responses` resource (to exercise the adapter itself), and an autouse fixture strips `OPENAI_*` from the environment so a real key cannot leak in. Swapping providers is a contained change: the `Scorer` protocol in [app/services/llm.py](app/services/llm.py) is the only seam that touches a vendor SDK. Models, PDF handling, orchestration, routing, and logging are provider-agnostic. ## Data handling Resumes contain personal data. * Uploads are held in memory and parsed from `io.BytesIO`. Nothing is written to disk. * Resume text, job-description text, prompts, and full model responses are never logged. The logger emits only an explicit allowlist of keys, and exceptions are recorded as a type plus `file:line:func` frames — never a formatted message, because provider errors can echo request content. * Nothing is persisted between requests. There is no database and no queue, so retention is bounded by process lifetime. **Adding any storage means writing a retention and deletion policy first.** ## Limitations * **Not a hiring decision.** The score is decision support. The prompt forbids inferring or scoring protected characteristics, and candidates are never compared against each other — each is scored independently against the same job description. * **Scanned and image-only PDFs fail** with `PDF_TEXT_UNAVAILABLE`. There is no OCR. * **Multi-column layouts extract in reading-order-ish, not exact, order.** The system prompt tells the model this is an extraction artifact and not to penalise it. * **No authentication or rate limiting.** Both are required before public deployment.