JAA-ATS-Tool / src /ats_report.py
saitejatirunagari's picture
feat(ats): no-compromise 90%+ pipeline β€” fit expansion + auto-repair + status
dd47e55
Raw
History Blame
7.03 kB
"""
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