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