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