File size: 5,537 Bytes
6d3a6a8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
INDEPENDENT validation scorer (anti-circular).

The generation pipeline uses candidate_fit ("plausible" expansion) to decide what
to include, and ats_scoring_v2 to score. If the same "plausible" logic both
inserts a term AND credits it, the 90%+ is self-confirming. This module scores
the FINAL EXPORTED, RE-PARSED resume with a deliberately DIFFERENT, stricter
ruleset that knows nothing about candidate_fit:

  - exact / alias presence ONLY in the parsed text — never "plausible" credit
  - EVIDENCE-WEIGHTED: a required skill that appears only in the Skills list but
    is NOT evidenced in an Experience bullet earns partial credit (0.5)
  - NO seniority fuzzing: if the JD asks for N years, the resume must actually
    show >= N years, else the seniority bucket fails
  - NO education fuzzing beyond a real degree token being present
  - title/domain require the actual term in the parsed text

It answers one question: "If a stranger's ATS read ONLY this exported file, would
it still score 90%+?" Used to label WEAK_90_INTERNAL_ONLY and to gate downloads.
"""

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 analyze_jd, JDRequirements, Requirement
from .ats_scoring_v2 import score_ats_readability, _WEIGHTS


@dataclass
class ValidationResult:
    independent_jd_match: int
    ats_readability: int
    breakdown: Dict[str, dict] = field(default_factory=dict)
    evidenced_terms: List[str] = field(default_factory=list)
    skills_only_terms: List[str] = field(default_factory=list)
    missing_terms: List[str] = field(default_factory=list)
    seniority_ok: bool = True
    notes: List[str] = field(default_factory=list)

    def to_dict(self):
        return self.__dict__


def _max_years(text: str) -> int:
    yrs = [int(m) for m in re.findall(r"(\d{1,2})\+?\s*years?", text.lower())]
    return max(yrs) if yrs else 0


def _coverage(reqs: List[Requirement], full_low: str, exp_low: str) -> tuple:
    """Evidence-weighted coverage. Returns (credit, total, evidenced, skills_only,
    missing). A term in experience = 1.0; only elsewhere (skills) = 0.5; absent = 0."""
    if not reqs:
        return (0.0, 0, [], [], [])
    credit = 0.0
    evidenced, skills_only, missing = [], [], []
    for r in reqs:
        terms = [r.term] + list(r.aliases or [])
        in_exp = any(_kw_in_text(t, exp_low) for t in terms)
        in_full = any(_kw_in_text(t, full_low) for t in terms)
        if in_exp:
            credit += 1.0
            evidenced.append(r.term)
        elif in_full:
            credit += 0.5
            skills_only.append(r.term)
        else:
            missing.append(r.term)
    return (credit, len(reqs), evidenced, skills_only, missing)


def validate_resume(jd_text: str, parsed_text: str,
                    experience_text: str = None,
                    base_resume_text: str = None) -> ValidationResult:
    req = analyze_jd(jd_text)               # structure only; deterministic
    full_low = (parsed_text or "").lower()
    exp_low = (experience_text or parsed_text or "").lower()

    buckets = {
        "must_have_hard": req.required_hard_skills,
        "responsibilities": req.responsibilities,
        "tools": req.tools_platforms,
        "title_domain": req.domain_terms,
        "seniority": req.seniority_signals,
        "certs_education": req.certifications + req.education_requirements,
        "soft": req.soft_skills,
    }

    breakdown: Dict[str, dict] = {}
    weighted = 0.0
    all_evidenced, all_skills_only, all_missing = [], [], []
    notes: List[str] = []

    # Strict seniority: JD's required years vs the candidate's REAL years. We
    # measure from the BASE resume when provided — never the generated text,
    # which could have a JD year-phrase ("12+ years") injected into it (that
    # would let the system game its own seniority check).
    jd_years = _max_years(jd_text)
    resume_years = _max_years(base_resume_text if base_resume_text else parsed_text)
    seniority_ok = (jd_years == 0) or (resume_years >= jd_years)
    if not seniority_ok:
        notes.append(f"JD wants {jd_years}y; candidate shows {resume_years}y")

    for key, reqs in buckets.items():
        credit, tot, ev, so, miss = _coverage(reqs, full_low, exp_low)
        all_evidenced += ev; all_skills_only += so; all_missing += miss
        if key == "title_domain":
            tcov = sum(1 for t in req.target_role_titles if _kw_in_text(t, full_low))
            credit += tcov
            tot += len(req.target_role_titles)
        if key == "seniority":
            # STRICT: no fuzzy credit. Full only if the actual year bar is met.
            frac = 1.0 if (tot == 0 or seniority_ok) else 0.0
        else:
            frac = (credit / tot) if tot else 1.0
        w = _WEIGHTS[key]
        contrib = frac * w
        weighted += contrib
        breakdown[key] = {"credit": round(credit, 1), "total": tot,
                          "weight": w, "contribution": round(contrib * 100, 1)}

    score = round(max(0, min(100, weighted * 100)))
    readability = score_ats_readability(parsed_text).score

    return ValidationResult(
        independent_jd_match=score,
        ats_readability=readability,
        breakdown=breakdown,
        evidenced_terms=all_evidenced,
        skills_only_terms=all_skills_only,
        missing_terms=all_missing,
        seniority_ok=seniority_ok,
        notes=notes,
    )