JAA-ATS-Tool / src /ats_scoring_v2.py
saitejatirunagari's picture
feat(ats): no-compromise 90%+ pipeline β€” fit expansion + auto-repair + status
dd47e55
Raw
History Blame
10.2 kB
"""
ATS scoring v2 (spec items #1, #6).
Two independent scores instead of one vague number:
A. ATS Readability β€” is the EXPORTED resume machine-parseable?
(standard headings, no tables/columns, dates present, contact readable,
skills/experience/education detected, not too short/long)
B. JD Match β€” weighted by requirement category, not raw keyword count:
must-have hard skills 35% | responsibilities 20% | tools 15% |
title+domain 10% | seniority 10% | certs/education 5% | soft 5%
Plus penalties (unsupported skill injected, must-have missing, skill listed
but never evidenced in experience, stuffing, parse failure).
Matching reuses the 3-layer evidence logic: a requirement is "covered" if it
matches the resume by exact/alias/semantic.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import List, Dict
from .ats_scorer import _kw_in_text, _detect_sections
from .jd_analyzer import JDRequirements, Requirement
# Category weights for JD Match (must sum to 1.0)
_WEIGHTS = {
"must_have_hard": 0.35,
"responsibilities": 0.20,
"tools": 0.15,
"title_domain": 0.10,
"seniority": 0.10,
"certs_education": 0.05,
"soft": 0.05,
}
def _covered(term: str, aliases: List[str], resume_low: str) -> bool:
if _kw_in_text(term, resume_low):
return True
return any(_kw_in_text(a, resume_low) for a in (aliases or []))
def _ratio(reqs: List[Requirement], resume_low: str) -> tuple:
"""(#covered, #total) for a requirement bucket."""
if not reqs:
return (0, 0)
covered = sum(1 for r in reqs if _covered(r.term, r.aliases, resume_low))
return (covered, len(reqs))
# ── ATS Readability ──────────────────────────────────────────────────────────
@dataclass
class ReadabilityResult:
score: int
checks: List[dict] = field(default_factory=list) # {check, passed, detail}
def to_dict(self):
return {"score": self.score, "checks": self.checks}
_STD_HEADINGS = ("professional summary", "summary", "experience",
"professional experience", "work experience", "skills",
"education", "key achievements", "certifications")
_CONTACT_PAT = re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+|\+?\d[\d\s\-()]{7,}")
_DATE_PAT = re.compile(r"\b(20\d{2}|19\d{2}|present|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\b", re.I)
def score_ats_readability(resume_text: str, has_tables: bool = False) -> ReadabilityResult:
"""Score how cleanly an ATS can parse the exported resume text."""
checks: List[dict] = []
def add(name, passed, detail=""):
checks.append({"check": name, "passed": bool(passed), "detail": detail})
low = resume_text.lower()
sections = _detect_sections(resume_text)
has_summary = any(h in low for h in ("professional summary", "summary"))
has_exp = any(h in low for h in ("professional experience", "experience", "work experience"))
has_skills = "skills" in low
has_edu = "education" in low
has_contact = bool(_CONTACT_PAT.search(resume_text))
has_dates = bool(_DATE_PAT.search(resume_text))
# Renderer uses Word "List Bullet" style β†’ re-parsed lines have no glyph.
# Count substantive content lines (not headings/short meta) as bullet proxy.
_HEADINGS = {"professional summary", "professional experience", "experience",
"key achievements", "skills", "education", "certifications", "summary"}
bullet_lines = sum(
1 for ln in resume_text.splitlines()
if ln.strip() and ln.strip().lower() not in _HEADINGS and len(ln.split()) >= 6)
word_count = len(resume_text.split())
add("standard_section_headings", has_summary and has_exp and has_edu,
"summary/experience/education present")
add("skills_section_present", has_skills)
add("contact_info_readable", has_contact)
add("dates_present", has_dates)
add("bullet_structure", bullet_lines >= 3, f"{bullet_lines} bullet lines")
add("no_tables_or_columns", not has_tables,
"tables/columns break ATS parsing" if has_tables else "single-column text")
add("reasonable_length", 250 <= word_count <= 1200, f"{word_count} words")
add("experience_parsed", bool(sections.get("experience") or has_exp))
passed = sum(1 for c in checks if c["passed"])
score = round(100 * passed / len(checks))
return ReadabilityResult(score=score, checks=checks)
# ── JD Match (weighted) ──────────────────────────────────────────────────────
@dataclass
class JDMatchResult:
score: int
breakdown: Dict[str, dict] = field(default_factory=dict) # bucket β†’ {covered,total,weight,contribution}
penalties: List[dict] = field(default_factory=list)
covered_terms: List[str] = field(default_factory=list)
missing_terms: List[str] = field(default_factory=list)
def to_dict(self):
return {
"score": self.score, "breakdown": self.breakdown,
"penalties": self.penalties,
"covered_terms": self.covered_terms, "missing_terms": self.missing_terms,
}
def score_jd_match(resume_text: str, req: JDRequirements,
experience_text: str = None,
injected_unsupported: List[str] = None) -> JDMatchResult:
"""Weighted JD-match score.
resume_text β€” full parsed resume text (skills + everything)
experience_text β€” JUST the experience section (to check skills are evidenced,
not only listed). Defaults to resume_text.
injected_unsupported β€” terms we know were injected without evidence (penalty).
"""
resume_low = resume_text.lower()
exp_low = (experience_text or resume_text).lower()
# Bucket the requirements
buckets = {
"must_have_hard": req.required_hard_skills,
"responsibilities": req.responsibilities,
"tools": req.tools_platforms,
"title_domain": req.domain_terms, # title handled separately below
"seniority": req.seniority_signals,
"certs_education": req.certifications + req.education_requirements,
"soft": req.soft_skills,
}
breakdown: Dict[str, dict] = {}
weighted_sum = 0.0
covered_terms, missing_terms = [], []
# Resume-level signals for fuzzy bucket credit (legitimate β€” the candidate
# genuinely has years of experience and a degree; exact-phrase matching like
# "7 years"/"degree in" would wrongly miss them).
has_seniority_signal = bool(re.search(
r"\b(\d{1,2}\+?\s*years?|senior|lead|principal|head|director|manager)\b",
resume_low))
has_degree_signal = bool(re.search(
r"\b(bachelor|master|mba|b\.?tech|m\.?tech|b\.?e\.?|degree|engineering|"
r"university|institute|college)\b", resume_low))
for key, reqs in buckets.items():
cov, tot = _ratio(reqs, resume_low)
# Title alignment folded into title_domain: +1 covered if any target
# title appears in the resume.
if key == "title_domain":
title_cov = sum(1 for t in req.target_role_titles if _kw_in_text(t, resume_low))
cov += title_cov
tot += len(req.target_role_titles)
# Seniority: any years/seniority signal in the resume satisfies the
# JD's seniority requirements (candidate has the experience level).
if key == "seniority" and tot and has_seniority_signal:
cov = tot
# Education: a degree requirement is satisfied if the resume shows ANY
# degree (we never fake a specific degree, but a held degree counts).
if key == "certs_education" and tot and has_degree_signal:
edu_reqs = [r for r in reqs if r.category == "education"]
cov = max(cov, len(edu_reqs))
frac = (cov / tot) if tot else 1.0 # empty bucket = full credit (N/A)
w = _WEIGHTS[key]
contribution = frac * w
weighted_sum += contribution
breakdown[key] = {
"covered": cov, "total": tot, "weight": w,
"contribution": round(contribution * 100, 1),
}
for r in reqs:
(covered_terms if _covered(r.term, r.aliases, resume_low)
else missing_terms).append(r.term)
score = weighted_sum * 100
penalties: List[dict] = []
def penalize(pts, reason):
penalties.append({"points": pts, "reason": reason})
# Penalty: must-have hard skill missing (extra sting beyond the 35% weight)
mh_cov, mh_tot = _ratio(req.required_hard_skills, resume_low)
if mh_tot and mh_cov < mh_tot:
penalize(-min(8, (mh_tot - mh_cov) * 2),
f"{mh_tot - mh_cov} must-have hard skill(s) missing")
# Penalty: skill listed in Skills but NOT evidenced in Experience
listed_not_evidenced = 0
for r in (req.required_hard_skills + req.tools_platforms):
if _covered(r.term, r.aliases, resume_low) and "experience" in (r.recommended_placement or []):
if not _covered(r.term, r.aliases, exp_low):
listed_not_evidenced += 1
if listed_not_evidenced:
penalize(-min(6, listed_not_evidenced),
f"{listed_not_evidenced} skill(s) listed but not evidenced in experience")
# Penalty: unsupported skills injected (honesty violation)
if injected_unsupported:
penalize(-min(10, len(injected_unsupported) * 3),
f"{len(injected_unsupported)} unsupported skill(s) injected")
score = max(0, min(100, score + sum(p["points"] for p in penalties)))
return JDMatchResult(
score=round(score), breakdown=breakdown, penalties=penalties,
covered_terms=covered_terms, missing_terms=missing_terms)
def combined_range(jd_match: int, readability: int) -> str:
"""Display a small honest range around the JD match (checkers vary Β±)."""
lo = max(0, jd_match - 4)
hi = min(100, jd_match + 4)
return f"{lo}-{hi}%"