Spaces:
Sleeping
Sleeping
| """ | |
| Parse the candidate's original resume PDF into the canonical Resume model. | |
| This is run ONCE at startup (cached to disk) and used as the base for every | |
| LLM tailoring call in Phase 4. | |
| The parser is intentionally forgiving: it joins multi-line bullets, strips | |
| sub-section headers (NIAT Revamp, AI Chatbot, etc.) into flat bullets, | |
| and limits to ~8 bullets per role (keeps the prompt tight). The LLM picks | |
| the best ones per JD. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import re | |
| import json | |
| import pdfplumber | |
| from .resume_model import Resume, Role, Education, Contact | |
| from .resume_customizer import ( | |
| _extract_candidate_name, _normalize_spaced_text, _read_docx_text, | |
| ) | |
| try: | |
| from config import CONTACT_LOCATION | |
| except Exception: # pragma: no cover - config always present in app runtime | |
| CONTACT_LOCATION = "Hyderabad, Telangana, India Β· Open to relocate" | |
| _DATE_PATTERN = re.compile( | |
| r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\w*\s+\d{4}\s*[-ββto]+\s*" | |
| r"(?:(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\w*\s*\n?\s*\d{4}|Present|Current|Now)", | |
| re.IGNORECASE, | |
| ) | |
| _PARTIAL_DATE_RE = re.compile( | |
| r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\w*" | |
| r"(?:\s+\d{2,4})?" | |
| r"(?:\s*[-ββto]+\s*" | |
| r"(?:(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\w*" | |
| r"(?:\s+\d{2,4})?" | |
| r"|\d{2,4}|Present|Current|Now)" | |
| r")?", | |
| re.IGNORECASE, | |
| ) | |
| def parse_resume_pdf(pdf_path: str) -> Resume: | |
| """Read the PDF and return a Resume canonical model.""" | |
| raw_text = "" | |
| with pdfplumber.open(pdf_path) as pdf: | |
| for page in pdf.pages: | |
| t = page.extract_text() | |
| if t: | |
| raw_text += t + "\n" | |
| name = _extract_candidate_name(raw_text) | |
| contact = _extract_contact(raw_text) | |
| summary = _extract_summary(raw_text) | |
| roles = _extract_roles(raw_text) | |
| achievements = _extract_achievements(raw_text) | |
| education = _extract_education_entries(raw_text) | |
| return Resume( | |
| name=name, | |
| contact=contact, | |
| summary=summary, | |
| roles=roles, | |
| achievements=achievements, | |
| education=education, | |
| ) | |
| def parse_resume_pdf_cached(pdf_path: str, cache_path: str = "data/resume/_parsed.json") -> Resume: | |
| """Parse with disk cache keyed by PDF mtime + size.""" | |
| if not os.path.exists(pdf_path): | |
| raise FileNotFoundError(pdf_path) | |
| stat = os.stat(pdf_path) | |
| # cache version bumped to v2 when the contact `location` field was added, so | |
| # pre-existing caches (without an address) are rebuilt. | |
| cache_key = f"v2_{stat.st_mtime_ns}_{stat.st_size}" | |
| if os.path.exists(cache_path): | |
| try: | |
| with open(cache_path, encoding="utf-8") as f: | |
| cached = json.load(f) | |
| if cached.get("_cache_key") == cache_key: | |
| resume = Resume.from_dict(cached["resume"]) | |
| if not resume.contact.location: | |
| resume.contact.location = CONTACT_LOCATION | |
| return resume | |
| except Exception: | |
| pass # Cache invalid, re-parse | |
| resume = parse_resume_pdf(pdf_path) | |
| os.makedirs(os.path.dirname(cache_path), exist_ok=True) | |
| with open(cache_path, "w", encoding="utf-8") as f: | |
| json.dump({"_cache_key": cache_key, "resume": resume.to_dict()}, f, | |
| ensure_ascii=False, indent=2) | |
| return resume | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Section extractors | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _extract_location(text: str) -> str: | |
| """Best-effort: find a 'City, State, Country' line near the top of the resume. | |
| Conservative on purpose β only accepts a short, comma-bearing header line so | |
| we don't mistake a sentence for an address. Returns "" when nothing matches, | |
| and the caller falls back to the configured CONTACT_LOCATION. | |
| """ | |
| for line in text.splitlines()[:10]: | |
| s = line.strip().strip("|β’Β·-").strip() | |
| if not (3 <= len(s) <= 60) or "," not in s: | |
| continue | |
| if re.search(r"@|https?://|linkedin\.com|\d{6,}", s, re.I): | |
| continue # skip email / url / phone lines | |
| # India-based or a generic "City, Region(, Country)" shape | |
| if re.search(r"\bindia\b", s, re.I) or re.match( | |
| r"^[A-Z][a-zA-Z.]+(?:\s[A-Z][a-zA-Z.]+)*,\s*[A-Z][a-zA-Z.]+", s | |
| ): | |
| return s | |
| return "" | |
| def _extract_contact(text: str) -> Contact: | |
| email_m = re.search(r"[\w.+-]+@[\w-]+\.[a-zA-Z]{2,}", text) | |
| phone_m = re.search(r"[\+]?[0-9]{1,4}[\s.-]?[0-9]{4,5}[\s.-]?[0-9]{4,5}", text) | |
| linkedin_m = re.search(r"linkedin\.com/in/[\w-]+", text, re.I) | |
| return Contact( | |
| phone=phone_m.group() if phone_m else "", | |
| email=email_m.group() if email_m else "", | |
| linkedin=("https://" + linkedin_m.group()) if linkedin_m else "", | |
| # Option B: use the resume's own location if present, else the | |
| # configured fallback so the ATS "address" check always passes. | |
| location=_extract_location(text) or CONTACT_LOCATION, | |
| ) | |
| def _extract_summary(text: str) -> str: | |
| """Return the original Professional Summary paragraph (LLM will rewrite it).""" | |
| text_norm = _normalize_spaced_text(text) | |
| m = re.search( | |
| r"PROFESSIONAL\s+SUMMARY\s*\n(.*?)" | |
| r"(?:\n(?:PROFESSIONAL\s+EXPERIENCE|EXPERIENCE|EDUCATION|KEY\s+METRICS|" | |
| r"CORE\s+COMPETENCIES|SKILLS|PROJECTS)|\Z)", | |
| text_norm, re.DOTALL, | |
| ) | |
| if not m: | |
| return "" | |
| body = m.group(1).strip() | |
| # Collapse multi-line summary into one paragraph | |
| return re.sub(r"\s+", " ", body) | |
| def _extract_roles(text: str, max_bullets_per_role: int = 8) -> list[Role]: | |
| """ | |
| Parse the experience section into Role entries with FLAT bullets. | |
| Sub-section headers (NIAT Revamp, AI Chatbot etc.) are NOT preserved β | |
| they're treated as transition markers and dropped. Their bullets are | |
| flattened into the parent role. | |
| Continuation lines from wrapped bullets are joined into the previous | |
| bullet. | |
| Result is capped at `max_bullets_per_role` (default 8) per role β | |
| the LLM will pick the best 5-7 when tailoring. | |
| """ | |
| text_norm = _normalize_spaced_text(text) | |
| exp_match = re.search( | |
| r"(?:PROFESSIONAL\s+|WORK\s+)?EXPERIENCE[S]?\s*\n(.*?)" | |
| r"(?:\n(?:KEY\s+METRICS|KEY\s+ACHIEVEMENTS|CORE\s+COMPETENCIES|" | |
| r"TECHNICAL\s+SKILLS|SKILLS\s*&|SKILLS\s*\n|EDUCATION|" | |
| r"CERTIFICATIONS\s*\n|CERTIFICATIONS\s*&|PROJECTS\s*\n|PROJECTS\s*&|" | |
| r"AWARDS|LANGUAGES|REFERENCES)|\Z)", | |
| text_norm, re.DOTALL, | |
| ) | |
| if not exp_match: | |
| return [] | |
| exp_text = exp_match.group(1).strip() | |
| date_matches = list(_DATE_PATTERN.finditer(exp_text)) | |
| if not date_matches: | |
| return [] | |
| # Slice into blocks: one block per role | |
| block_starts: list[int] = [] | |
| for dm in date_matches: | |
| line_start = exp_text.rfind("\n", 0, dm.start()) + 1 | |
| block_starts.append(line_start) | |
| block_starts.append(len(exp_text)) | |
| roles: list[Role] = [] | |
| for i, dm in enumerate(date_matches): | |
| block = exp_text[block_starts[i]:block_starts[i + 1]] | |
| dates = re.sub(r"\s+", " ", dm.group()).strip() | |
| header_line_end = block.find("\n") | |
| if header_line_end == -1: | |
| header_line, body = block, "" | |
| else: | |
| header_line, body = block[:header_line_end], block[header_line_end + 1:] | |
| # Strip ANY partial date from header | |
| head = _PARTIAL_DATE_RE.sub("", header_line).strip(" |Β·.,") | |
| # Split: "Role Β· Company | Location" | |
| parts = re.split(r"[Β·β’|]", head, maxsplit=1) | |
| title = parts[0].strip() if parts else head | |
| company_loc = parts[1].strip() if len(parts) > 1 else "" | |
| # Further split company_loc into company + location | |
| company, location = _split_company_location(company_loc) | |
| bullets = _flatten_bullets(body, max_bullets_per_role) | |
| roles.append(Role( | |
| title=title, | |
| company=company, | |
| location=location, | |
| dates=dates, | |
| bullets=bullets, | |
| )) | |
| return roles | |
| def _split_company_location(s: str) -> tuple[str, str]: | |
| """ | |
| Try to split "Company | Location" or "Company, Location" or just leave as company. | |
| """ | |
| s = s.strip() | |
| # "Company | Location" pattern | |
| if "|" in s: | |
| parts = [p.strip() for p in s.split("|", 1)] | |
| return parts[0], parts[1] | |
| # "Company, City" pattern | |
| if "," in s: | |
| # Heuristic: split on last comma if right side looks like a location | |
| # (e.g. "Hyderabad, India" β keep together; "Co. Pvt. Ltd., Hyderabad" β split) | |
| idx = s.rfind(",") | |
| right = s[idx + 1:].strip() | |
| # Location-y words on the right side | |
| if any(w in right for w in ["India", "USA", "UK", "Remote", "Bengaluru", | |
| "Bangalore", "Hyderabad", "Mumbai", "Delhi", | |
| "Pune", "Chennai", "Gurgaon", "Gurugram", | |
| "Noida", "Worldwide"]): | |
| return s[:idx].strip(), right | |
| return s, "" | |
| def _flatten_bullets(body: str, max_count: int) -> list[str]: | |
| """ | |
| Walk the body lines and produce a flat list of bullets. | |
| - Drop sub-section headers (lines without bullet char, not Scope:) | |
| - Skip orphan year-only lines (date wrap artifacts) | |
| - Skip Scope: meta lines | |
| - Join continuation lines into the previous bullet | |
| - Cap at max_count (best ones first β the LLM picks) | |
| """ | |
| bullets: list[str] = [] | |
| for raw in body.split("\n"): | |
| line = raw.strip() | |
| if not line: | |
| continue | |
| if re.fullmatch(r"\d{4}", line): | |
| continue # date wrap artifact | |
| low = line.lower() | |
| if low.startswith("scope:"): | |
| continue # meta line β drop entirely in canonical model | |
| if line.startswith(("β’", "-", "β", "β", "*", "βͺ", "β")): | |
| bullets.append(line.lstrip("β’-ββ*βͺβ ").strip()) | |
| else: | |
| # Either a sub-section header OR a continuation of previous bullet. | |
| # Continuation if previous bullet exists AND this line is short | |
| # / starts lowercase / etc. | |
| if bullets and _is_continuation(line): | |
| bullets[-1] = bullets[-1] + " " + line | |
| # else: sub-section header β DROP (canonical model has no sub-sections) | |
| # Light cleanup | |
| cleaned = [] | |
| for b in bullets: | |
| b = re.sub(r"\s+", " ", b).strip() | |
| b = b.rstrip(",;.") | |
| if b and len(b) >= 10: | |
| cleaned.append(b) | |
| return cleaned[:max_count] | |
| def _is_continuation(line: str) -> bool: | |
| """Heuristic: line wraps from the previous bullet, not a new sub-section.""" | |
| if not line: | |
| return False | |
| first = line[0] | |
| if first.islower() or first.isdigit(): | |
| return True | |
| if first in "β+%&([{": | |
| return True | |
| # Multi-Title-Case in first 60 chars β looks like a section header | |
| head_60 = line[:60] | |
| cap_words = re.findall(r"\b[A-Z][a-z]+", head_60) | |
| return len(cap_words) < 2 | |
| def _extract_achievements(text: str) -> list[str]: | |
| """Pull a few KEY METRICS / KEY ACHIEVEMENTS bullets if present.""" | |
| text_norm = _normalize_spaced_text(text) | |
| m = re.search( | |
| r"KEY\s+(?:METRICS|ACHIEVEMENTS)(?:\s*&\s*ACHIEVEMENTS)?\s*\n(.*?)" | |
| r"(?:\n(?:CORE\s+COMPETENCIES|SKILLS|EDUCATION|CERTIFICATIONS|" | |
| r"PROJECTS|LANGUAGES|EXPERIENCE)|\Z)", | |
| text_norm, re.DOTALL, | |
| ) | |
| if not m: | |
| return [] | |
| body = m.group(1) | |
| out: list[str] = [] | |
| for raw in body.split("\n"): | |
| line = raw.strip().lstrip("β’-ββ*βͺβ ").strip() | |
| if not line: | |
| continue | |
| # Drop sub-section headers like "Funnel & Revenue" | |
| if not any(ch in line for ch in [" ", ":"]) and len(line) < 30: | |
| continue | |
| # Title-case headers without quantification are sub-categories | |
| if re.match(r"^[A-Z][A-Za-z\s&]+$", line) and "%" not in line and not re.search(r"\d", line): | |
| continue | |
| if len(line) >= 20: | |
| out.append(line) | |
| return out[:5] | |
| def _extract_education_entries(text: str) -> list[Education]: | |
| """Parse EDUCATION section into structured entries.""" | |
| text_norm = _normalize_spaced_text(text) | |
| m = re.search( | |
| r"EDUCATION(?:\s*&\s*CERTIFICATIONS?)?\s*\n(.*?)" | |
| r"(?:\n(?:CERTIFICATIONS\s*\n|SKILLS\s*\n|EXPERIENCE\s*\n|" | |
| r"PROJECTS\s*\n|REFERENCES|LANGUAGES\s*\n|CORE\s+COMPETENCIES)|\Z)", | |
| text_norm, re.DOTALL, | |
| ) | |
| if not m: | |
| return [] | |
| body = m.group(1).strip() | |
| entries: list[Education] = [] | |
| # Education entries often come as: "Degree" line, "Institution" line, optionally dates | |
| lines = [ln.strip() for ln in body.split("\n") if ln.strip()] | |
| i = 0 | |
| while i < len(lines): | |
| line = lines[i] | |
| # Try to find a date in this line or extract from the line | |
| date_match = _PARTIAL_DATE_RE.search(line) | |
| dates = date_match.group() if date_match else "" | |
| degree_line = _PARTIAL_DATE_RE.sub("", line).strip(" |Β·.,") | |
| # Next line may be the institution (heuristic: short title-case line) | |
| institution = "" | |
| if i + 1 < len(lines): | |
| next_line = lines[i + 1] | |
| next_date = _PARTIAL_DATE_RE.search(next_line) | |
| # If next line is just institution (no date / short) | |
| if not next_date and len(next_line) < 80 and not next_line.startswith(("β’", "-")): | |
| # AND it's not another degree | |
| if not _looks_like_degree(next_line): | |
| institution = next_line | |
| i += 1 | |
| elif next_date and len(next_line) < 100: | |
| # Sometimes institution + dates on same line β parse it | |
| institution = _PARTIAL_DATE_RE.sub("", next_line).strip(" |Β·.,") | |
| if not dates: | |
| dates = next_date.group() | |
| i += 1 | |
| if degree_line: | |
| entries.append(Education( | |
| degree=degree_line, | |
| institution=institution, | |
| dates=dates, | |
| )) | |
| i += 1 | |
| return entries | |
| def _looks_like_degree(line: str) -> bool: | |
| """Heuristic: does this line look like a degree title?""" | |
| keywords = ["diploma", "bachelor", "master", "phd", "doctor", "mba", | |
| "btech", "bsc", "msc", "ba ", "bs ", "ma ", "ms ", "engineering", | |
| "management", "computer science", "certification", "certificate"] | |
| low = line.lower() | |
| return any(k in low for k in keywords) | |