Spaces:
Sleeping
Sleeping
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
|