JAA-ATS-Tool / src /ats_safe.py
saitejatirunagari's picture
feat: deterministic LLM-free ATS pipeline (extraction, rewrite, scoring)
87087f8
Raw
History Blame
24.5 kB
"""Safe, evidence-gated résumé/ATS-alignment orchestrator.
This is the ONE entry point every résumé-generation path must use. It replaces
the old "extract run-grams → inject into every bullet" pipeline that fabricated
experience and leaked scraped page noise.
Guarantees (all enforced by construction, all covered by tests):
1. Every JD is preprocessed server-side (contamination stripped) before use.
2. Extraction output is schema-validated and JD-traceable; hallucinated or
prompt-injected phrases are dropped deterministically.
3. No keyword is ever inserted without résumé evidence — the résumé is
PRESERVED verbatim; gaps are disclosed, never filled.
4. When the JD can't be isolated or extraction is unusable, the pipeline
returns a typed `manual_review_required` status and preserves the résumé —
it NEVER falls back to the unvalidated run-gram extractor to modify output.
5. The generated PDF is re-parsed and validated before success is claimed.
The value delivered is an honest, evidence-backed alignment report — not keyword
stuffing. Any "alignment estimate" is explicitly an internal estimate, never a
Greenhouse score.
"""
from __future__ import annotations
import os
import re
import tempfile
from typing import Dict, List, Optional
from .jd_preprocess import preprocess_jd
from .keyword_schema import validate_and_repair, calibrate
from .evidence_gate import map_evidence
from .resume_rewrite import plan_and_apply_rewrites
from .ats_score import score_alignment, max_supported_score, compute_coverage
from .keyword_schema import _norm as _knorm
STATUS_OK = "ATS_ALIGNMENT_REPORT_READY"
STATUS_MANUAL = "manual_review_required"
def _deterministic_report_only_items(clean_jd: str) -> List[dict]:
"""Fallback extractor for REPORTING ONLY (no LLM available).
Uses the curated taxonomy extractor on the CLEANED JD to produce structured
items. These are used solely for evidence mapping / gap reporting — they are
NEVER injected (the evidence gate + this orchestrator never insert anything).
This is deliberately NOT the old run-gram extractor.
"""
try:
from .external_ats import extract_jd_keywords as _tax_fn
except Exception:
return []
items = []
for term in (_tax_fn(clean_jd) or []):
t = (term or "").strip()
if len(t) < 2:
continue
items.append({
"exact_phrase": t,
"normalized_concept": t.lower(),
"category": "hard_skill",
"requirement_type": "preferred",
"importance": "medium",
"source_text": t,
"semantic_variants": [],
"confidence": 0.5,
"requires_resume_evidence": True,
})
return items
def _resume_section_order(latex_src: str, resume_text: str) -> List[str]:
"""Actual section order as it appears in the résumé SOURCE (so PDF-order
validation compares against the résumé's own order, not a fixed assumption)."""
candidates = ["summary", "experience", "projects", "education", "skills",
"certifications"]
found = []
for c in candidates:
m = re.search(r"\\section\*?\{[^}]*" + re.escape(c) + r"[^}]*\}", latex_src, re.I)
pos = m.start() if m else (latex_src.lower().find(c.upper().lower())
if c.upper() in latex_src else -1)
if pos >= 0:
found.append((pos, c))
ordered = [c for _, c in sorted(found)]
return ordered or ["experience", "education", "skills"]
def generate_alignment_safe(
latex_src: str,
jd_text: str,
*,
company: str = "",
job_title: str = "",
llm_client=None,
rewrite_fn=None,
summary_fn=None,
criteria: Optional[list] = None,
selected_model: Optional[str] = None,
model_health: Optional[list] = None,
run_audit: bool = False,
out_dir: Optional[str] = None,
compile_pdf: bool = True,
progress_callback=None,
) -> Dict:
"""Evidence-gated alignment + evidence-backed rewriting. Never fabricates.
`rewrite_fn(original, target_phrase, concept, category)->str` overrides the
rewriter (tests/demo). In production it defaults to llm_client.rewrite_bullet.
Returns a report dict with: status, jd_diagnostics, extraction, evidence,
pdf_validation, internal_alignment_estimate, tex, pdf_path.
"""
from .latex_resume import latex_to_text, compile_latex_to_pdf, _safe_jobname
def _prog(stage, pct):
if progress_callback:
try:
progress_callback(stage, pct)
except Exception:
pass
latex_src = latex_src or ""
resume_text = latex_to_text(latex_src)
report: Dict = {
"source": "ats_safe",
"status": STATUS_MANUAL,
"resume_preserved": True,
"tex": latex_src,
"pdf_path": None,
"engine": None,
"compiled": False,
"jd_diagnostics": {},
"extraction": {"valid": [], "rejected_count": 0, "used_fallback": False},
"evidence": {},
"pdf_validation": {},
"internal_alignment_estimate": None,
"live_model": {"selected_model": selected_model, "health": model_health or []},
"reason": "",
}
# All models failed health-check → live optimization unavailable (honest).
if model_health is not None and selected_model is None and llm_client is None:
report["live_model"]["status"] = "live_model_unavailable"
# 1. MANDATORY preprocessing — treat all input as untrusted.
_prog("Cleaning job description…", 10)
pre = preprocess_jd(jd_text, company=company)
report["jd_diagnostics"] = {
"ok": pre.ok, "confidence": pre.confidence,
"reason": pre.reason, **pre.diagnostics,
"dropped_samples": pre.dropped_samples[:8],
}
if not pre.ok:
report["reason"] = f"jd_isolation_failed:{pre.reason}"
_compile_preserved(report, latex_src, out_dir, job_title, compile_pdf,
compile_latex_to_pdf, _safe_jobname, _prog)
return report
clean_jd = pre.clean_text
# 2. DETERMINISTIC source-grounded extraction (NO LLM). This is the primary
# path — extraction/ranking/evidence/scoring never depend on an external
# model. The LLM (if any) is optional wording polish only, applied later.
_prog("Extracting hiring criteria…", 30)
if criteria is not None: # test/eval injection (bypass extraction)
raw_items = list(criteria)
report["extraction"]["mode"] = "injected"
else:
from .deterministic_extract import extract_criteria
raw_items = extract_criteria(clean_jd, pre.sections)
report["extraction"]["mode"] = "deterministic"
report["extraction"]["used_fallback"] = False
# 3. Validate + traceability gate (defense-in-depth; deterministic items are
# already traceable, but this normalizes/guards uniformly).
_prog("Validating extraction…", 40)
valid, rejected = validate_and_repair(raw_items, clean_jd)
report["extraction"]["rejected_count"] = len(rejected)
report["extraction"]["rejected_samples"] = [
{"exact_phrase": r.get("exact_phrase", ""),
"reason": r.get("_reject_reason", "")}
for r in rejected[:10]
]
if not valid:
report["reason"] = "no_valid_criteria_extracted"
_compile_preserved(report, latex_src, out_dir, job_title, compile_pdf,
compile_latex_to_pdf, _safe_jobname, _prog)
return report
# 3.5. Calibrate — pick the 4-6 match-critical criteria (weights total 100).
valid = calibrate(valid)
report["extraction"]["valid"] = valid
report["calibration"] = [
{"exact_phrase": c["exact_phrase"], "concept": c["normalized_concept"],
"requirement_type": c["requirement_type"], "importance": c["importance"],
"calibration_weight": c.get("calibration_weight", 0)}
for c in valid if (c.get("calibration_weight") or 0) > 0
]
# 4. Evidence gate (BEFORE) — classify covered / partial / gap.
_prog("Mapping résumé evidence…", 55)
ev_before = map_evidence(valid, resume_text)
# Score BEFORE on the ORIGINAL résumé rendered through the SAME PDF pipeline as
# the final, so before/after is a fair apples-to-apples (PDF-parsed) comparison.
before_text = resume_text
try:
from .latex_resume import render_text_to_pdf
from .pdf_validate import _extract_pdf_text
_bpdf = os.path.join(out_dir or tempfile.mkdtemp(prefix="ats_before_"),
"_before.pdf")
if render_text_to_pdf(resume_text, _bpdf) and os.path.exists(_bpdf):
_bt = _extract_pdf_text(_bpdf)
if _bt and len(_bt) > 200:
before_text = _bt
except Exception:
pass
score_before = score_alignment(valid, ev_before.to_dict(), before_text,
stuffing_penalty=_detect_stuffing(before_text, valid))
# 5. Evidence-backed rewriting (pass 1) — align supported criteria to the JD's
# exact wording. DEFAULT is DETERMINISTIC (no LLM); an LLM, if supplied,
# only refines wording and its output is re-verified. Every rewrite passes
# the deterministic verifier; rejects fall back to the deterministic result.
from .resume_rewrite import make_deterministic_rewrite_fn, make_deterministic_summary_fn
candidates = ev_before.rewrite_candidates()
if rewrite_fn is not None:
effective_rewrite_fn = rewrite_fn # test/demo override
report["rewrite_mode"] = "override"
elif llm_client is not None and hasattr(llm_client, "rewrite_bullet"):
effective_rewrite_fn = llm_client.rewrite_bullet # optional LLM polish
report["rewrite_mode"] = "llm_enhanced"
else:
effective_rewrite_fn = make_deterministic_rewrite_fn(candidates)
report["rewrite_mode"] = "deterministic"
final_latex = latex_src
rewrite_records = []
_prog("Optimizing résumé (evidence-backed)…", 66)
final_latex, recs = plan_and_apply_rewrites(latex_src, candidates, effective_rewrite_fn)
# If an LLM run produced zero applied rewrites (unreliable model), fall back to
# the deterministic rewriter so the résumé still improves (deterministic_rewrite_mode).
if report["rewrite_mode"] == "llm_enhanced" and not any(r.applied for r in recs):
det = make_deterministic_rewrite_fn(candidates)
final_latex, recs = plan_and_apply_rewrites(latex_src, candidates, det)
report["rewrite_mode"] = "deterministic_rewrite_mode"
rewrite_records = [r.to_dict() for r in recs]
# 5.5. Headline/summary optimization — DETERMINISTIC by default (swap supported
# variants for JD exact phrases within the existing summary; corpus-verified).
if summary_fn is not None:
eff_summary_fn = summary_fn
elif (report["rewrite_mode"] == "llm_enhanced"
and llm_client is not None and hasattr(llm_client, "rewrite_summary")):
eff_summary_fn = llm_client.rewrite_summary
else:
eff_summary_fn = make_deterministic_summary_fn(candidates)
report["summary_rewrite"] = None
if eff_summary_fn is not None:
from .resume_rewrite import optimize_summary
top_phrases = [c["exact_phrase"] for c in valid
if (c.get("calibration_weight") or 0) > 0
and any(cc.keyword == c["normalized_concept"]
for cc in ev_before.covered)][:6]
new_latex, srec = optimize_summary(
final_latex, job_title or "", top_phrases, resume_text, eff_summary_fn)
final_latex = new_latex
report["summary_rewrite"] = srec
# 6. Compile + PDF-parse (so scoring can use PARSED text, not just LaTeX).
_compile_preserved(report, final_latex, out_dir, job_title, compile_pdf,
compile_latex_to_pdf, _safe_jobname, _prog,
resume_text=latex_to_text(final_latex))
score_text = _scoring_text(report, final_latex, latex_to_text)
# 6.5. INDEPENDENT evaluation from the parsed text (not the rewrite flags):
# which supported-critical criteria are actually missing from the résumé?
ev_after = map_evidence(valid, latex_to_text(final_latex))
cov = compute_coverage(valid, ev_after.to_dict(), score_text)
missing_critical = cov.get("missing_supported_critical", [])
# 6.6. ONE controlled correction pass for supported-critical terms still absent.
correction_applied = False
if missing_critical and effective_rewrite_fn is not None:
miss = {_knorm(m) for m in missing_critical}
done = {_knorm(r.get("exact_jd_phrase", "")) for r in rewrite_records
if r.get("applied")}
retry = [c for c in ev_before.rewrite_candidates()
if _knorm(c.exact_phrase) in miss and _knorm(c.exact_phrase) not in done]
if retry:
_prog("Correction pass…", 80)
corrected, recs2 = plan_and_apply_rewrites(final_latex, retry,
effective_rewrite_fn)
if corrected != final_latex:
final_latex = corrected
rewrite_records += [r.to_dict() for r in recs2]
_compile_preserved(report, final_latex, out_dir, job_title,
compile_pdf, compile_latex_to_pdf, _safe_jobname,
_prog, resume_text=latex_to_text(final_latex))
score_text = _scoring_text(report, final_latex, latex_to_text)
ev_after = map_evidence(valid, latex_to_text(final_latex))
correction_applied = True
report["rewrites"] = rewrite_records
applied = [r for r in rewrite_records if r.get("applied")]
report["tex"] = final_latex
report["resume_preserved"] = (final_latex == latex_src)
report["correction_pass_applied"] = correction_applied
report["evidence"] = ev_after.to_dict()
# 7. FINAL score — computed from the parsed résumé text, with stuffing penalty
# and the 90% gate.
stuffing = _detect_stuffing(score_text, valid)
final_score = score_alignment(valid, ev_after.to_dict(), score_text,
pdf_validation=report.get("pdf_validation") or None,
applied_rewrites=len(applied),
stuffing_penalty=stuffing)
ceiling = max_supported_score(valid, ev_after.to_dict(), score_text)
report["status"] = STATUS_OK
report["reason"] = "ok"
report["internal_alignment_estimate"] = {
"label": final_score["label"],
"before": score_before["score"],
"after": final_score["score"],
"max_evidence_supported": ceiling,
"gate_90_passed": final_score["gate_90_passed"],
"components": final_score["components"],
"coverage": final_score["coverage"],
"penalties": final_score["penalties"],
"supported_integrations": len(applied),
"unsupported_insertions": 0,
"coverage_rate": ev_after.metrics().get("coverage_rate"),
"mandatory_recall": ev_after.metrics().get("mandatory_recall"),
"scored_from": "parsed_pdf" if report.get("_pdf_text_used") else "latex_text",
}
# 8. INDEPENDENT audit + two-parser PDF verification (acceptance gate). The
# audit re-extracts from the JD independently and scores from the PARSED
# PDF; the reported acceptance score cannot exceed what the audit confirms.
if run_audit and llm_client is not None and report.get("_pdf_text_used"):
try:
from .ats_evaluate import independent_audit, acceptance_verdict
from .pdf_validate import _extract_pdf_text, verify_keywords_two_parsers
pdf_text = _extract_pdf_text(report["pdf_path"]) or score_text
audit = independent_audit(clean_jd, resume_text, pdf_text, llm_client)
accepted_kw = [r.get("exact_jd_phrase") for r in rewrite_records
if r.get("applied")]
two_parser = verify_keywords_two_parsers(report["pdf_path"], accepted_kw) \
if accepted_kw else {}
verdict = acceptance_verdict(audit, 0, stuffing, two_parser)
report["independent_audit"] = audit
report["pdf_two_parser"] = two_parser
report["acceptance"] = verdict
except Exception as e:
report["audit_error"] = str(e)[:160]
return report
def _scoring_text(report: Dict, final_latex: str, latex_to_text) -> str:
"""Prefer PDF-extracted text for scoring (that is what an ATS reads); fall back
to LaTeX-derived text when no engine compiled a PDF."""
report["_pdf_text_used"] = False
path = report.get("pdf_path")
if path:
try:
from .pdf_validate import _extract_pdf_text
txt = _extract_pdf_text(path)
if txt and len(txt) > 200:
report["_pdf_text_used"] = True
return txt
except Exception:
pass
return latex_to_text(final_latex)
def _detect_stuffing(text: str, criteria: List[dict]) -> float:
"""Deterministic keyword-stuffing penalty: (a) a criterion phrase repeated
unnaturally often (>3x) or (b) a dense comma-dump line of bare keywords. Note:
this penalty is applied to BOTH the before and after résumé, so a résumé's own
Skills section never creates an unfair before/after delta — only a résumé that
is MORE stuffed than another scores lower (see test_keyword_stuffed…)."""
low = (text or "").lower()
penalty = 0.0
for c in criteria:
p = (c.get("exact_phrase") or "").lower().strip()
if len(p) >= 5 and low.count(p) > 3:
penalty += 5
for line in low.splitlines():
if line.count(",") >= 6 and len(line.split()) < line.count(",") * 4:
penalty += 5
return min(penalty, 25.0)
def to_legacy_report(safe: Dict) -> Dict:
"""Map a safe-orchestrator report onto the legacy payload keys the API/endpoints
and the extension popup already consume.
`injected` now lists the truthful, evidence-backed integrations that were
applied (exact phrases aligned into existing bullets). `unsupported_insertions`
stays 0 — gaps are disclosed, never inserted.
"""
ev = safe.get("evidence") or {}
covered = ev.get("covered", [])
gaps = ev.get("gaps", [])
partial = ev.get("partial", [])
metrics = ev.get("metrics", {}) or {}
total = (metrics.get("total_criteria")
or (len(covered) + len(partial) + len(gaps)) or 0)
found = metrics.get("covered", len(covered))
est = safe.get("internal_alignment_estimate") or {}
# Prefer the AFTER alignment score for the headline pct; fall back to coverage.
pct = int(round(est.get("after")
if est.get("after") is not None
else (metrics.get("coverage_rate") or 0) * 100))
applied = [r for r in (safe.get("rewrites") or []) if r.get("applied")]
injected = [r.get("exact_jd_phrase") or r.get("normalized_concept")
for r in applied]
keywords = []
for c in covered:
applied_here = any(
(r.get("normalized_concept") == c.get("keyword")) for r in applied)
keywords.append({
"keyword": c.get("exact_phrase") or c.get("keyword"),
"found_in_export": True,
"section": ("Integrated (evidence-backed rewrite)" if applied_here
else "Résumé (evidence-backed)"),
"reason": "",
"evidence": c.get("resume_evidence", ""),
"status": c.get("status", ""),
"requirement_type": c.get("requirement_type", ""),
})
for p in partial:
keywords.append({
"keyword": p.get("exact_phrase") or p.get("keyword"),
"found_in_export": False,
"section": "(partially supported — not inserted)",
"reason": "concept present but not as a clear capability; not inserted",
"requirement_type": p.get("requirement_type", ""),
})
for g in gaps:
keywords.append({
"keyword": g.get("exact_phrase") or g.get("keyword"),
"found_in_export": False,
"section": "(gap — not in résumé)",
"reason": "no résumé evidence — not inserted (honest gap, no fabrication)",
"requirement_type": g.get("requirement_type", ""),
})
manual = safe.get("status") == STATUS_MANUAL
return {
"source": "latex",
"status": safe.get("status"),
"manual_review_required": manual,
"pct": pct,
"expected": total,
"found": found,
"missing": [g.get("exact_phrase") or g.get("keyword") for g in gaps],
"keywords": keywords,
"coverage_count": f"{found}/{total}",
"injected": injected, # truthful evidence-backed integrations
"rewrites": safe.get("rewrites", []),
"calibration": safe.get("calibration", []),
"gated": {},
"tex": safe.get("tex"),
"engine": safe.get("engine"),
"compiled": safe.get("compiled"),
"pdf_path": safe.get("pdf_path"),
"compile_log": safe.get("compile_log", ""),
# New, honest fields (extension may ignore or surface these):
"evidence": ev,
"jd_diagnostics": safe.get("jd_diagnostics", {}),
"extraction_diagnostics": {
"valid_count": len(safe.get("extraction", {}).get("valid", [])),
"rejected_count": safe.get("extraction", {}).get("rejected_count", 0),
"used_fallback": safe.get("extraction", {}).get("used_fallback", False),
"rejected_samples": safe.get("extraction", {}).get("rejected_samples", []),
},
"pdf_validation": safe.get("pdf_validation", {}),
"internal_alignment_estimate": safe.get("internal_alignment_estimate"),
"reason": safe.get("reason", ""),
}
def _compile_preserved(report, latex_src, out_dir, job_title, compile_pdf,
compile_latex_to_pdf, _safe_jobname, _prog,
resume_text: str = "") -> None:
"""Compile the unmodified résumé and validate the resulting PDF."""
if not compile_pdf:
return
_prog("Compiling PDF…", 75)
out_dir = out_dir or tempfile.mkdtemp(prefix="ats_safe_")
slug = _safe_jobname(job_title)
jobname = f"Saiteja_Tirunagari_{slug}_Resume" if slug else "Saiteja_Tirunagari_Resume"
try:
comp = compile_latex_to_pdf(latex_src, out_dir, jobname=jobname, timeout=420)
report["engine"] = comp.get("engine")
report["compiled"] = comp.get("compiled")
report["pdf_path"] = comp.get("pdf_path")
report["compile_log"] = comp.get("log", "")
except Exception as e:
report["compile_log"] = f"compile_error: {e}"
# ATS-safe fallback: when no LaTeX engine compiled a PDF, render the résumé as
# a single-column plain-text PDF (reportlab). This is what an ATS reads anyway,
# and it guarantees a real, parseable PDF on any host (incl. no-tectonic).
if not report.get("pdf_path"):
try:
from .latex_resume import latex_to_text, render_text_to_pdf
fb = os.path.join(out_dir, f"{jobname}.pdf")
if render_text_to_pdf(latex_to_text(latex_src), fb) and os.path.exists(fb):
report["pdf_path"] = fb
report["engine"] = report.get("engine") or "reportlab-atsafe"
report["compiled"] = True
report["pdf_fallback"] = True
except Exception as e:
report["compile_log"] = (report.get("compile_log", "") + f" | fallback: {e}")
if report.get("pdf_path"):
from .pdf_validate import validate_pdf
_prog("Validating PDF…", 90)
try:
from .ats_safe import _resume_section_order
sections = (_resume_section_order(latex_src, resume_text)
if resume_text else None)
report["pdf_validation"] = validate_pdf(
report["pdf_path"], expected_sections=sections)
except Exception as e:
report["pdf_validation"] = {"ok": False, "warnings": [f"validate_error:{e}"]}