""" Resume parsing service — enhanced extraction. Supports PDF (via PyMuPDF) and DOCX (via python-docx). Extracts: name, email, phone, socials, objective, skills, projects, education, experience, languages, interests. """ import re import io from typing import Optional # ── Section header patterns ─────────────────────────────────── SECTION_PATTERNS = { "objective": r"objective|summary|profile|about me|career goal", "skills": r"skills|technical skills|competencies|technologies", "experience": r"experience|work history|employment|professional experience|internship", "education": r"education|academic|qualification|academics", "projects": r"projects|portfolio|personal projects|academic projects", "languages": r"languages|spoken languages", "interests": r"interests|hobbies|activities|extra.?curricular", "certifications": r"certifications?|achievements?|awards?", } # ── Programming / spoken language keywords ──────────────────── SKILL_KEYWORDS = { "python", "javascript", "typescript", "java", "c++", "c#", "c", "go", "golang", "rust", "swift", "kotlin", "scala", "ruby", "php", "r", "matlab", "bash", "html", "css", "react", "vue", "angular", "next.js", "nuxt", "svelte", "node.js", "express", "fastapi", "django", "flask", "spring", "laravel", "machine learning", "deep learning", "tensorflow", "pytorch", "keras", "scikit-learn", "pandas", "numpy", "matplotlib", "nlp", "computer vision", "aws", "gcp", "azure", "docker", "kubernetes", "terraform", "jenkins", "github actions", "ci/cd", "linux", "mongodb", "postgresql", "mysql", "redis", "elasticsearch", "sqlite", "dynamodb", "cassandra", "neo4j", "git", "graphql", "rest api", "microservices", "kafka", "rabbitmq", "websocket", "grpc", "firebase", "supabase", "prisma", "tailwind", } SPOKEN_LANGUAGE_KEYWORDS = { "english", "hindi", "spanish", "french", "german", "chinese", "mandarin", "japanese", "korean", "arabic", "portuguese", "italian", "russian", "telugu", "tamil", "kannada", "bengali", "marathi", "gujarati", "malayalam", "urdu", "punjabi", } # ── Low-level extractors ─────────────────────────────────────── def extract_email(text: str) -> Optional[str]: m = re.search(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}", text) return m.group(0) if m else None def extract_phone(text: str) -> Optional[str]: m = re.search(r"(\+?[\d\s\-().]{10,15})", text) return m.group(0).strip() if m else None def extract_linkedin(text: str) -> Optional[str]: m = re.search(r"linkedin\.com/in/[\w\-]+", text, re.IGNORECASE) if m: return "https://" + m.group(0) return None def extract_github(text: str) -> Optional[str]: m = re.search(r"github\.com/[\w\-]+", text, re.IGNORECASE) if m: return "https://" + m.group(0) return None def extract_website(text: str) -> Optional[str]: # Match personal URLs that are NOT linkedin/github m = re.search( r"https?://(?!.*(?:linkedin|github))[a-zA-Z0-9.\-/~]+", text, re.IGNORECASE, ) return m.group(0) if m else None def extract_name(text: str) -> Optional[str]: lines = [l.strip() for l in text.split("\n") if l.strip()] name_pattern = re.compile(r"^[A-Z][a-z]+(?:\s[A-Z][a-z]+){1,3}$") for line in lines[:15]: if name_pattern.match(line): return line return None def extract_skills(text: str) -> list[str]: text_lower = text.lower() found = [] for skill in SKILL_KEYWORDS: if re.search(r"\b" + re.escape(skill) + r"\b", text_lower): found.append(skill.title() if len(skill) > 3 else skill.upper()) return sorted(set(found)) def extract_languages(section_text: str, full_text: str) -> list[str]: """Extract spoken/natural languages (not programming languages).""" search_text = (section_text or full_text).lower() found = [] for lang in SPOKEN_LANGUAGE_KEYWORDS: if re.search(r"\b" + lang + r"\b", search_text): found.append(lang.capitalize()) return sorted(set(found)) def extract_interests(section_text: str) -> list[str]: if not section_text: return [] items = re.split(r"[,\n•\-|/]", section_text) interests = [i.strip().strip("•-– ") for i in items if i.strip() and len(i.strip()) > 2] # Deduplicate & cap seen = set() result = [] for item in interests: if item.lower() not in seen and len(item) < 60: seen.add(item.lower()) result.append(item.title()) return result[:15] def extract_projects(section_text: str) -> list[dict]: """Parse projects section into list of {name, description, tech}.""" if not section_text: return [] projects = [] # Split by common project delimiters: blank line, bullet, or ALL-CAPS title line blocks = re.split(r"\n{2,}|(?=\n[•\-★▸])", section_text) for block in blocks: block = block.strip() if not block: continue lines = [l.strip() for l in block.split("\n") if l.strip()] if not lines: continue name = lines[0].lstrip("•-★▸ ").strip() description = " ".join(lines[1:]).strip() if len(lines) > 1 else "" # Extract tech from description tech_found = [] desc_lower = description.lower() for skill in SKILL_KEYWORDS: if re.search(r"\b" + re.escape(skill) + r"\b", desc_lower): tech_found.append(skill.title() if len(skill) > 3 else skill.upper()) if name and len(name) < 120: projects.append({ "name": name, "description": description[:600] if description else "", "tech": sorted(set(tech_found)), }) return projects[:10] def extract_education(section_text: str) -> list[dict]: if not section_text: return [] institutions = [] lines = [l.strip() for l in section_text.split("\n") if l.strip()] i = 0 while i < len(lines): line = lines[i] # Detect degree lines degree_pattern = re.search( r"(b\.?tech|m\.?tech|b\.?e|m\.?e|bsc|msc|b\.?sc|m\.?sc|bachelor|master|phd|doctorate|diploma|high school|hsc|ssc|10th|12th)", line, re.IGNORECASE ) if degree_pattern: entry = { "degree": line, "institution": lines[i + 1] if i + 1 < len(lines) else "", "year": "", } # Look for year for j in range(i, min(i + 4, len(lines))): year_m = re.search(r"\b(19|20)\d{2}\b", lines[j]) if year_m: entry["year"] = year_m.group(0) break institutions.append(entry) i += 2 else: i += 1 return institutions[:6] # ── Section splitter ─────────────────────────────────────────── def split_sections(text: str) -> dict[str, str]: sections: dict[str, list[str]] = {"header": []} current = "header" lines = text.split("\n") for line in lines: stripped = line.strip() matched_section = None for sec_key, pattern in SECTION_PATTERNS.items(): if re.match(rf"^({pattern})s?[\s:]*$", stripped, re.IGNORECASE): matched_section = sec_key break if matched_section: current = matched_section sections.setdefault(current, []) else: sections.setdefault(current, []).append(stripped) return {k: "\n".join(v) for k, v in sections.items()} # ── Main parse function ──────────────────────────────────────── def parse_text(raw_text: str) -> dict: sections = split_sections(raw_text) objective_text = sections.get("objective", "").strip() return { "name": extract_name(raw_text), "email": extract_email(raw_text), "phone": extract_phone(raw_text), "linkedin": extract_linkedin(raw_text), "github": extract_github(raw_text), "website": extract_website(raw_text), "objective": objective_text[:600] if objective_text else None, "skills": extract_skills(raw_text), "languages": extract_languages(sections.get("languages", ""), raw_text), "interests": extract_interests(sections.get("interests", "")), "projects": extract_projects(sections.get("projects", "")), "education": extract_education(sections.get("education", "")), "experience": [], # Structured experience parsing can be added as needed "raw_text": raw_text[:8000], # increased cap for better AI context } def parse_pdf(file_bytes: bytes) -> dict: try: import fitz # PyMuPDF doc = fitz.open(stream=file_bytes, filetype="pdf") text = "\n".join(page.get_text() for page in doc) doc.close() return parse_text(text) except Exception as e: return {"error": str(e), "raw_text": "", "skills": [], "projects": []} def parse_docx(file_bytes: bytes) -> dict: try: from docx import Document doc = Document(io.BytesIO(file_bytes)) text = "\n".join(p.text for p in doc.paragraphs) return parse_text(text) except Exception as e: return {"error": str(e), "raw_text": "", "skills": [], "projects": []}