Spaces:
Sleeping
Sleeping
File size: 5,954 Bytes
4e82797 71343cf 4e82797 71343cf 4e82797 71343cf 4e82797 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 | """
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": {}}
|