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