""" 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