Go to file
ahmed.mujtaba 38249cc6f2 job_matching assignment by recruiter added 2026-08-11 19:01:24 +05:00
.cursor/rules login confirmation and forget password done 2026-08-04 20:40:42 +05:00
app complete ATS with tetsting 2026-08-10 20:54:14 +05:00
backend job_matching assignment by recruiter added 2026-08-11 19:01:24 +05:00
docs LINKEDIN POST CONFIG DONE AND TESTED 2026-08-05 20:09:34 +05:00
frontend job_matching assignment by recruiter added 2026-08-11 19:01:24 +05:00
scripts complete ATS with tetsting 2026-08-10 20:54:14 +05:00
tests complete ATS with tetsting 2026-08-10 20:54:14 +05:00
tools REACTJS STACK WITH RBAC TESTED 2026-08-05 17:41:46 +05:00
.env.example Add bulk ATS scoring engine 2026-08-06 14:37:21 +05:00
.gitignore LINKEDIN POST CONFIG DONE AND TESTED 2026-08-05 20:09:34 +05:00
.gitignore.local Add bulk ATS scoring engine 2026-08-06 14:37:21 +05:00
README.md complete ATS with tetsting 2026-08-10 20:54:14 +05:00
Sync_read.md Background saving terminated 2026-08-07 18:26:37 +05:00
Sync_write_request.md Background saving terminated 2026-08-07 18:26:37 +05:00
claude.md complete ATS with tetsting 2026-08-10 20:54:14 +05:00
docker-compose.yml talet pool api done 2026-08-10 21:01:51 +05:00
pyproject.toml Add bulk ATS scoring engine 2026-08-06 14:37:21 +05:00

README.md

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 is the specification this implements and remains the source of truth for design decisions.

Setup

Requires Python 3.11+.

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:

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:

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
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"
{
  "request_id": "2ce31ea9-29b2-4cad-a916-1a18cfc69c20",
  "total": 2,
  "succeeded": 1,
  "failed": 1,
  "results": [
    {
      "filename": "candidate-a.pdf",
      "status": "completed",
      "candidate_name": "Ada Lovelace",
      "job_title": "Backend Engineer",
      "current_company": "Acme",
      "years_experience": 6,
      "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.

The profile fields (candidate_name, job_title, current_company, years_experience) are extracted from the resume by the model and are null whenever the resume does not state them. years_experience uses the total stated in the resume when there is one, otherwise it is computed from explicitly stated dates — never guessed. matched_keywords are verified server-side against the resume text; a keyword the resume never mentions is dropped rather than shown as evidence.

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 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 fails if that happens.

Caching has a 1024-token minimum, so short job descriptions will not cache at all.

Development

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 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.