Spaces:
Sleeping
Sleeping
File size: 10,162 Bytes
2bcd6c2 dd47e55 2bcd6c2 dd47e55 2bcd6c2 dd47e55 2bcd6c2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 | """
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}%"
|