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