File size: 7,026 Bytes
2bcd6c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dd47e55
 
2bcd6c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dd47e55
 
 
 
 
2bcd6c2
 
dd47e55
 
 
 
 
 
2bcd6c2
 
 
 
 
 
 
 
 
 
 
 
dd47e55
2bcd6c2
dd47e55
2bcd6c2
 
 
dd47e55
 
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
"""
ATS report orchestrator (spec items #10, #11, plus the missing-keyword loop #8
and calibration modes #9).

Ties the pieces together:
  analyze_jd  β†’  classify_evidence  β†’  score (readability + weighted JD match)
  β†’  human-readable explanation.

`build_ats_report` is the single entry point the pipeline/UI calls after a
resume is rendered and re-parsed. It never injects anything β€” it only scores
and explains. The tailoring engine consumes `addable_terms`/`gap_terms` from the
evidence matcher to decide what to place where.
"""

from __future__ import annotations

from typing import List, Dict, Optional

from .jd_analyzer import analyze_jd, JDRequirements
from .candidate_fit import (
    classify_all_fit, includable, review_terms, ask_user_terms, blocked_terms,
)
from .ats_scoring_v2 import (
    score_ats_readability, score_jd_match, combined_range,
)

# Calibration modes (spec #9). Currently only adjusts how the range is shown and
# which buckets are emphasised in recommendations. Jobalytics-style = exact
# keyword coverage focus.
MODES = ("general", "jobalytics", "jobscan", "resume_worded", "simplify")


def build_ats_report(
    base_resume_text: str,
    final_resume_text: str,
    jd_text: str,
    experience_text: str = None,
    llm=None,
    cfg: dict = None,
    has_tables: bool = False,
    mode: str = "jobalytics",
) -> dict:
    """Produce the full explanation report (spec #10).

    base_resume_text  β€” ORIGINAL resume (for evidence classification)
    final_resume_text β€” RENDERED + re-parsed resume (what we actually score)
    """
    req = analyze_jd(jd_text, llm=llm, cfg=cfg)
    # Candidate Fit Expansion (aggressive_plausible_match): the resume is a base
    # profile, not the full truth. explicit/plausible/adjacent are legitimate to
    # include; only BLOCKED terms (regulated creds, deep-tech specialty, seniority
    # jump) count as a wrongful injection.
    verdicts = classify_all_fit(req, base_resume_text)

    final_low = final_resume_text.lower()
    blocked = blocked_terms(verdicts)
    review = review_terms(verdicts)
    ask = ask_user_terms(verdicts)

    # Penalty only for genuinely BLOCKED terms that leaked into the final resume.
    injected_unsupported = [v.keyword for v in blocked if v.keyword.lower() in final_low]

    readability = score_ats_readability(final_resume_text, has_tables=has_tables)
    jd_match = score_jd_match(
        final_resume_text, req,
        experience_text=experience_text,
        injected_unsupported=injected_unsupported,
    )

    # Strong vs weak matches (against the FINAL resume)
    strong, weak = [], []
    for v in verdicts:
        present = v.keyword.lower() in final_low
        if present and v.fit_status in ("explicit", "plausible", "adjacent"):
            strong.append(v.keyword)
        elif v.importance == "must_have" and not present and v.action != "block":
            weak.append(v.keyword)

    unsupported_missing = [
        {"keyword": v.keyword, "category": v.category, "reason": v.reason}
        for v in blocked
    ]
    ask_user = [v.keyword for v in ask]

    recommendations = _recommendations(req, jd_match, readability, weak, ask_user, mode)

    return {
        "mode": mode,
        "estimated_scores": {
            "ats_readability": readability.score,
            "jd_match": jd_match.score,
            "combined_range": combined_range(jd_match.score, readability.score),
        },
        "jd_match_breakdown": jd_match.breakdown,
        "penalties": jd_match.penalties,
        "strong_matches": strong[:30],
        "weak_matches": weak[:20],
        "unsupported_missing_keywords": unsupported_missing[:20],
        "needs_user_input": ask_user[:10],
        "covered_terms": jd_match.covered_terms,
        "missing_terms": jd_match.missing_terms,
        "formatting_checks": readability.checks,
        "recommendations": recommendations,
        "requirements": req.to_dict(),
        "evidence": [v.to_dict() for v in verdicts],
    }


def _recommendations(req, jd_match, readability, weak, ask_user, mode) -> List[str]:
    recs: List[str] = []
    for c in readability.checks:
        if not c["passed"]:
            recs.append(f"Fix ATS readability: {c['check'].replace('_', ' ')}.")
    if weak:
        recs.append(
            "Add genuine evidence for must-have skills if you have it: "
            + ", ".join(weak[:6]) + ".")
    if ask_user:
        recs.append(
            "Confirm whether you hold these (we won't fake them): "
            + ", ".join(ask_user[:6]) + ".")
    for p in jd_match.penalties:
        recs.append(f"Penalty: {p['reason']}.")
    if jd_match.score >= 88:
        recs.append("Strong JD match β€” verify on the target checker.")
    elif not recs:
        recs.append("Coverage is reasonable; add quantified evidence for remaining JD skills.")
    return recs[:10]


# ── Missing-keyword feedback loop (spec #8) ──────────────────────────────────

def reconcile_missing_keywords(
    pasted_keywords: List[str],
    jd_text: str,
    base_resume_text: str,
    llm=None,
    cfg: dict = None,
) -> List[dict]:
    """User pastes the 'missing keywords' a real checker (Jobalytics) reported.
    For each: is it actually in the JD? does the resume support it? decide.

    Returns rows: {keyword, in_jd, evidence, decision, placement}.
    Decision rules (spec #8):
      in JD + supported     β†’ add
      in JD + transferable  β†’ rephrase
      in JD + unsupported   β†’ gap (do not fake)
      not in JD             β†’ ignore (unless user insists)
    """
    jd_low = (jd_text or "").lower()
    req = analyze_jd(jd_text, llm=llm, cfg=cfg)
    # Map known requirement terms for category/placement lookup
    req_by_term = {r.term.lower(): r for r in req.all_requirements()}

    rows: List[dict] = []
    for kw in pasted_keywords:
        kw = (kw or "").strip()
        if not kw:
            continue
        kl = kw.lower()
        in_jd = kl in jd_low or kl in req_by_term
        if not in_jd:
            rows.append({"keyword": kw, "in_jd": False, "evidence": "",
                         "decision": "ignore", "placement": []})
            continue
        # Classify with Candidate Fit Expansion (aggressive_plausible_match)
        from .jd_analyzer import Requirement, _categorize
        from .candidate_fit import classify_fit, infer_seniority
        r = req_by_term.get(kl) or Requirement(term=kw, category=_categorize(kl))
        v = classify_fit(r, base_resume_text, seniority=infer_seniority(base_resume_text))
        decision = {
            "include": "add", "include_carefully": "rephrase (review)",
            "ask_user": "ask user", "block": "gap (do not fake)",
        }[v.action]
        rows.append({
            "keyword": kw, "in_jd": True, "fit_status": v.fit_status,
            "evidence": v.reason, "decision": decision,
            "placement": v.recommended_placement,
        })
    return rows