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