""" 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}%"