Portfolio Projects ยท Project 5 of 10
Project 5: AI Resume Screener & Job Matcher
A project every recruiter instantly understands. You'll parse resumes, extract skills into structured JSON, match them against job descriptions with explainable scores, and check the system for bias.
What you'll build
- Upload resumes (PDF/DOCX) and a job description.
- Structured extraction of skills, years of experience, education, projects and links (JSON schema).
- A match score combining semantic similarity and must-have skill coverage, with an explanation ("missing: Kubernetes").
- A fairness check: scores don't change when names or genders are swapped.
Architecture
Resume โ text extraction (pdfplumber / python-docx) โ LLM structured extraction (Pydantic schema)
JD โ requirement extraction (must-have, nice-to-have, years)
Match = 0.5 ยท embedding similarity + 0.4 ยท must-have coverage + 0.1 ยท experience fit
โ ranked list + explanation + gaps โ recruiter UI (human decides)
Tech stack
pdfplumber, python-docx, an LLM with JSON schema output, sentence-transformers, pandas, Streamlit, plus a simple bias test harness.
Step by step
- Define the schema: name (stored separately), skills[], total_years, roles[], education[], projects[].
- Extract with an LLM in structured-output mode; validate with Pydantic and retry once on failure.
- Normalise skills with a synonym map ("PyTorch" = "pytorch", "GenAI" โ "Generative AI").
- Parse the JD into must-have and nice-to-have skills and the years required.
- Score: cosine similarity of embeddings (resume summary vs JD), coverage of must-haves, and experience fit.
- Explain: matched skills, missing skills, evidence snippets.
- Bias test: remove names, photos, gender and age signals before scoring; run counterfactual swaps and check the score difference is about 0.
- UI: a ranked table, filters and export to CSV. The recruiter makes the decision.
Starter code
from pydantic import BaseModel
from sentence_transformers import SentenceTransformer, util
class Resume(BaseModel): skills: list[str]; total_years: float; roles: list[str]; summary: str
emb = SentenceTransformer("all-MiniLM-L6-v2")
def score(r: Resume, jd_text: str, must: list[str], years: float):
sim = float(util.cos_sim(emb.encode(r.summary), emb.encode(jd_text)))
have = {s.lower() for s in r.skills}; cov = sum(m.lower() in have for m in must) / max(1, len(must))
exp_fit = min(1.0, r.total_years / max(years, 0.5))
return round(100 * (0.5 * sim + 0.4 * cov + 0.1 * exp_fit)), [m for m in must if m.lower() not in have]
Deploy it
Streamlit on Hugging Face Spaces, using sample, synthetic resumes only. Never upload real people's resumes publicly. State in the README that the tool assists, and never replaces, human judgement.
Put it on your resume
- Built an LLM-powered resume screener with structured extraction (Pydantic), skill normalisation and an explainable match score combining embeddings and must-have coverage.
- Added bias safeguards: PII redaction before scoring and counterfactual name/gender swap tests (score delta < 1 point).
Interview questions you'll get
- How did you get reliable JSON from the LLM? What if a field is missing?
- How do you handle synonyms and skill levels?
- What are the ethical and legal risks of automated screening, and how did you mitigate them?
- How would you evaluate ranking quality against recruiter decisions?
Stretch goals
- Learning-to-rank from recruiter feedback.
- Job-seeker mode: "how to improve my resume for this JD".
- Batch processing with a queue for 1,000+ resumes.
๐ Add this project to my resume๐ค Practise in a mock interview๐ฅ Daily challenge๐ Certificates
Created by Ajeetkumar