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