Spaces:
Sleeping
Sleeping
| """ | |
| 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 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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) ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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}%" | |