Hire-ai/backend/app/services/resume_parser.py
2026-10-08 02:50:35 +05:30

267 lines
9.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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": []}