""" 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": {}}