File size: 7,067 Bytes
c8f4a46
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
"""
Resume parser: regex-based section detection + structured field extraction.

Returns a ParsedResume dict with keys:
  contact, summary, skills, experience, projects, education, certifications, raw_sections
"""
from __future__ import annotations

import re
from dataclasses import dataclass, field


# ── Section header patterns (order matters β€” more specific first) ──────────
_SECTION_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
    ("certifications", re.compile(r"^certif|^licen|^credential", re.I | re.M)),
    ("projects",       re.compile(r"^project|^portfolio|^open.?source", re.I | re.M)),
    ("education",      re.compile(r"^educ|^academic|^qualif|^degree", re.I | re.M)),
    ("experience",     re.compile(r"^(work\s+)?experience|^employment|^career|^professional\s+background", re.I | re.M)),
    ("skills",         re.compile(r"^(technical\s+)?skills?|^competenc|^technologies|^tech\s+stack|^tools", re.I | re.M)),
    ("summary",        re.compile(r"^summary|^objective|^profile|^about|^overview|^professional\s+summary", re.I | re.M)),
    ("contact",        re.compile(r"^contact|^personal\s+info|^details", re.I | re.M)),
]

# Fallback: lines that look like section headers (ALL CAPS or Title-case short lines)
_HEADER_LINE = re.compile(r"^([A-Z][A-Z\s&/\-]{2,30})$")

# Common action verbs for quality scoring
ACTION_VERBS = {
    "developed", "built", "designed", "implemented", "created", "deployed",
    "optimised", "optimized", "reduced", "increased", "improved", "led",
    "managed", "architected", "engineered", "automated", "integrated",
    "delivered", "launched", "scaled", "migrated", "refactored", "shipped",
    "established", "coordinated", "analysed", "analyzed",
}

# Quantification signals
_QUANT_RE = re.compile(r"\d+\s*(%|x|k|ms|s|mb|gb|tb|users?|requests?|hours?|days?|weeks?|months?|years?)", re.I)


@dataclass
class ParsedResume:
    contact: str = ""
    summary: str = ""
    skills_raw: str = ""
    experience_raw: str = ""
    projects_raw: str = ""
    education_raw: str = ""
    certifications_raw: str = ""
    skills_list: list[str] = field(default_factory=list)
    experience_bullets: list[str] = field(default_factory=list)
    raw_sections: dict[str, str] = field(default_factory=dict)
    full_text: str = ""

    # Quality signals
    action_verb_count: int = 0
    quantified_bullet_count: int = 0

    def to_dict(self) -> dict:
        return {
            "contact": self.contact,
            "summary": self.summary,
            "skills": self.skills_list,
            "experience_raw": self.experience_raw,
            "experience_bullets": self.experience_bullets,
            "projects_raw": self.projects_raw,
            "education_raw": self.education_raw,
            "certifications_raw": self.certifications_raw,
            "raw_sections": self.raw_sections,
            "quality": {
                "action_verb_count": self.action_verb_count,
                "quantified_bullet_count": self.quantified_bullet_count,
            },
        }


def parse_resume(text: str) -> ParsedResume:
    """Parse resume text into structured sections."""
    pr = ParsedResume(full_text=text)

    lines = text.splitlines()
    sections = _split_into_sections(lines)
    pr.raw_sections = sections

    pr.contact        = sections.get("contact", _extract_contact_block(lines))
    pr.summary        = sections.get("summary", "")
    pr.skills_raw     = sections.get("skills", "")
    pr.experience_raw = sections.get("experience", "")
    pr.projects_raw   = sections.get("projects", "")
    pr.education_raw  = sections.get("education", "")
    pr.certifications_raw = sections.get("certifications", "")

    pr.skills_list = _parse_skills(pr.skills_raw)
    pr.experience_bullets = _extract_bullets(pr.experience_raw + "\n" + pr.projects_raw)

    # Quality signals
    all_bullets = "\n".join(pr.experience_bullets)
    pr.action_verb_count = sum(
        1 for b in pr.experience_bullets
        if any(b.strip().lower().startswith(v) for v in ACTION_VERBS)
    )
    pr.quantified_bullet_count = len(_QUANT_RE.findall(all_bullets))

    return pr


# ── Private helpers ────────────────────────────────────────────────────────


def _split_into_sections(lines: list[str]) -> dict[str, str]:
    """
    Walk lines and assign them to labelled buckets based on header detection.
    """
    sections: dict[str, list[str]] = {}
    current: str | None = None

    for raw_line in lines:
        line = raw_line.strip()
        if not line:
            if current:
                sections.setdefault(current, []).append("")
            continue

        label = _detect_section_label(line)
        if label:
            current = label
            sections.setdefault(current, [])
        elif current is not None:
            sections[current].append(line)
        # Lines before any detected section β†’ treat as contact/top block
        else:
            sections.setdefault("contact", []).append(line)

    return {k: "\n".join(v).strip() for k, v in sections.items()}


def _detect_section_label(line: str) -> str | None:
    """Return a canonical section name if this line looks like a section header."""
    clean = line.strip().rstrip(":").strip()
    for name, pat in _SECTION_PATTERNS:
        if pat.match(clean):
            return name
    # Fallback: short ALL-CAPS or Title-Case line with no punctuation
    if _HEADER_LINE.match(clean) and len(clean.split()) <= 4:
        return None  # Don't auto-classify unknown headers
    return None


def _extract_contact_block(lines: list[str]) -> str:
    """Take the first non-empty lines as the contact/header block."""
    out: list[str] = []
    for line in lines[:15]:
        stripped = line.strip()
        if stripped:
            out.append(stripped)
        elif out:
            break
    return "\n".join(out)


def _parse_skills(skills_text: str) -> list[str]:
    """Split skills section into individual skill tokens."""
    if not skills_text:
        return []
    # Split on common delimiters: comma, bullet, pipe, newline, semicolon
    raw = re.split(r"[,|\nβ€’Β·\-–;/]+", skills_text)
    skills: list[str] = []
    for item in raw:
        item = item.strip()
        # Skip very long items (probably a sentence, not a skill)
        if item and len(item) <= 50 and len(item) >= 1:
            skills.append(item)
    return skills


def _extract_bullets(text: str) -> list[str]:
    """Extract bullet-point lines from experience/projects sections."""
    bullets: list[str] = []
    for line in text.splitlines():
        stripped = line.strip()
        # Lines starting with bullet char, dash, or asterisk
        if stripped and (stripped[0] in "-β€’Β·*β–Έβ–ͺ" or re.match(r"^\d+\.", stripped)):
            bullet = re.sub(r"^[-β€’Β·*β–Έβ–ͺ\d\.]+\s*", "", stripped).strip()
            if len(bullet) > 20:
                bullets.append(bullet)
    return bullets