JAA-ATS-Tool / src /parseability.py
saitejatirunagari's picture
feat(phase-15): LLM-curated scoring denominator (generalizes) + weave-to-90 + fixes (R34)
71343cf
Raw
History Blame
5.95 kB
"""
src/parseability.py — ATS parseability verifier for generated résumés.
Simulates what an ATS parser does to a résumé and reports structural issues that
cause rejection BEFORE keyword scoring begins (the higher-signal round-one lever
from the ATS research). Advisory/non-blocking: any exception returns pass=True so
generation is never blocked by a verifier bug.
Input modes:
- LaTeX source string (default — V2 has the .tex before compiling)
- PDF file path string (is_pdf_path=True for the compiled artifact)
Returns: {"pass": bool, "issues": [str], "checks": {...}}.
Blocking checks: text_extractable, section_headers_present, contact_info_present.
Non-blocking (warning only): dates_present, name_present, single_column_likely.
"""
from __future__ import annotations
import re
import logging
from typing import Any
log = logging.getLogger(__name__)
_SECTION_HEADERS: dict = {
"experience": re.compile(r"\bexperience\b", re.I),
"education": re.compile(r"\beducation\b", re.I),
"skills": re.compile(r"\b(skills|competencies)\b", re.I),
"summary": re.compile(r"\b(summary|profile|objective)\b", re.I),
}
_EMAIL_RE = re.compile(r"[\w.+\-]+@[\w\-]+\.[a-zA-Z]{2,}")
_PHONE_RE = re.compile(r"\+?[0-9][\d\s\-().]{8,}")
_YEAR_RE = re.compile(r"\b(19|20)\d{2}\b")
_NAME_RE = re.compile(r"^[ \t]*([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)", re.MULTILINE)
def _latex_to_plain(tex_src: str) -> str:
try:
from src.latex_resume import latex_to_text
return latex_to_text(tex_src)
except Exception:
pass
try:
text = re.sub(r"\\[a-zA-Z]+\*?\{([^}]*)\}", r"\1", tex_src)
text = re.sub(r"\\[a-zA-Z]+\*?", " ", text)
return re.sub(r"[{}]", " ", text)
except Exception:
return ""
def _check_text_extractable(text: str) -> tuple:
ok = len(text.strip()) >= 200
return ok, None if ok else "text_not_extractable: <200 chars extracted"
def _check_contact_info(text: str) -> tuple:
has_email = bool(_EMAIL_RE.search(text))
has_phone = bool(_PHONE_RE.search(text))
if has_email and has_phone:
return True, None
missing = ([] if has_email else ["email"]) + ([] if has_phone else ["phone"])
return False, f"contact_info_missing:{'+'.join(missing)}"
def _check_section_headers(text: str) -> tuple:
found = [k for k, pat in _SECTION_HEADERS.items() if pat.search(text)]
if len(found) >= 3:
return True, None
missing = [k for k in _SECTION_HEADERS if k not in found]
return False, f"missing_section_headers:{','.join(missing)}"
def _check_dates(text: str) -> tuple:
ok = bool(_YEAR_RE.search(text))
return ok, None if ok else "no_year_dates_found"
def _check_name(text: str) -> tuple:
ok = bool(_NAME_RE.search(text))
return ok, None if ok else "name_not_detected_at_top"
def _check_single_column_pdf(pdf_path: str) -> tuple:
"""Detect a genuine TWO-COLUMN layout. A single-column résumé with right-aligned
dates and a full-width header has a wide x-spread but FEW words in the right half;
a true two-column layout has a substantial cluster of line-START words in the right
half. We flag only the latter (so right-aligned dates don't false-positive)."""
try:
import pdfplumber
with pdfplumber.open(pdf_path) as pdf:
if not pdf.pages:
return None, None
page = pdf.pages[0]
words = page.extract_words()
if not words or not page.width:
return None, None
mid = page.width / 2.0
right_starts = sum(1 for w in words if w["x0"] > mid)
frac = right_starts / len(words)
# >35% of words STARTING in the right half ⇒ a real second column.
ok = frac <= 0.35
return ok, None if ok else f"possible_multi_column_layout:{frac:.0%}_words_right"
except Exception as exc:
log.debug("pdfplumber column check failed (non-fatal): %s", exc)
return None, None
def parseability_report(source: str, is_pdf_path: bool = False) -> dict:
"""Simulate ATS parsing on a résumé. Returns {pass, issues, checks}.
Never raises — any exception returns {pass: True, issues: [], checks: {}}."""
try:
checks: dict = {
"text_extractable": False, "contact_info_present": False,
"section_headers_present": False, "dates_present": False,
"single_column_likely": None, "name_present": False,
}
issues: list = []
if is_pdf_path:
try:
import pdfplumber
with pdfplumber.open(source) as pdf:
text = "\n".join(p.extract_text() or "" for p in pdf.pages)
except Exception as exc:
log.debug("pdfplumber extraction failed: %s", exc)
text = ""
else:
text = _latex_to_plain(source)
for key, fn in (("text_extractable", _check_text_extractable),
("contact_info_present", _check_contact_info),
("section_headers_present", _check_section_headers),
("dates_present", _check_dates),
("name_present", _check_name)):
ok, iss = fn(text)
checks[key] = ok
if iss:
issues.append(iss)
if is_pdf_path:
ok_col, iss_col = _check_single_column_pdf(source)
checks["single_column_likely"] = ok_col
if iss_col:
issues.append(iss_col)
passed = bool(checks["text_extractable"] and checks["section_headers_present"]
and checks["contact_info_present"])
return {"pass": passed, "issues": issues, "checks": checks}
except Exception as exc:
log.warning("parseability_report failed (non-fatal, pass=True): %s", exc)
return {"pass": True, "issues": [], "checks": {}}