Spaces:
Sleeping
Sleeping
Download engine/extractor.py from ResumeSelectors/resume-analyzer: direct link, hf CLI and curl.
- Browser
- Download file 112 kB
-
https://huggingface.co/spaces/ResumeSelectors/resume-analyzer/resolve/main/engine/extractor.py
- Command line
-
hf download hf://spaces/ResumeSelectors/resume-analyzer/engine/extractor.py
-
curl -L -o extractor.py https://huggingface.co/spaces/ResumeSelectors/resume-analyzer/resolve/main/engine/extractor.py
112 kB
| # ============================================================================= | |
| # engine/extractor.py — Structured Field Extraction from Resume Text | |
| # ============================================================================= | |
| # PURPOSE: | |
| # Takes raw resume text (output of parser.py) and extracts structured fields. | |
| # This is the most important file in the engine — everything downstream | |
| # (scoring, explainability) depends on what this file extracts. | |
| # | |
| # WHAT IT EXTRACTS: | |
| # - Candidate name, email, phone | |
| # - Technical skills (matched against taxonomy) | |
| # - Soft skills | |
| # - Work experience (years, roles, companies, tenure, stability) | |
| # - Education (degree, field, institution) | |
| # - Projects, certifications, hobbies | |
| # - Recency flag (how recently key skills were used) | |
| # | |
| # MAIN FUNCTION (only one you call from outside): | |
| # build_resume_record(file_name, raw_text) → dict | |
| # This calls all other functions and returns one complete DB-ready dict. | |
| # | |
| # USAGE: | |
| # from engine.extractor import build_resume_record | |
| # record = build_resume_record("resume_1.pdf", raw_text) | |
| # ============================================================================= | |
| import os | |
| import re | |
| import sys | |
| import json | |
| import time | |
| from datetime import datetime | |
| from dateutil import parser as dateparser | |
| from dateutil.relativedelta import relativedelta | |
| # Add project root to path | |
| sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) | |
| import config | |
| # ML skill model — trained on job postings dataset | |
| # Falls back silently if model hasn't been trained yet | |
| try: | |
| from train_skill_model import predict_skills_for_text as _ml_predict | |
| _ML_MODEL_OK = True | |
| except ImportError: | |
| _ML_MODEL_OK = False | |
| def _ml_predict(*args, **kwargs): return [] | |
| # spaCy — load once at module level so it isn't reloaded for every resume | |
| try: | |
| import spacy | |
| _NLP = spacy.load("en_core_web_sm") | |
| _SPACY_OK = True | |
| except (ImportError, OSError): | |
| _NLP = None | |
| _SPACY_OK = False | |
| print("[Extractor] WARNING: spaCy model not found. " | |
| "Run: python -m spacy download en_core_web_sm") | |
| # ============================================================================= | |
| # ESCO + JobBERT-v2 SKILL INDEX | |
| # ============================================================================= | |
| # Built by: python build_esco_index.py | |
| # Three artefacts in models/: | |
| # esco_embeddings.npy — (N, dim) L2-normalised float32 | |
| # esco_skills.json — {labels, altLabels, skillType, model, …} | |
| # esco_index_report.json | |
| # | |
| # If any artefact is missing, the extractor silently falls back to the legacy | |
| # taxonomy match + TF-IDF model — so the pipeline never crashes mid-deployment. | |
| _ESCO_OK = False | |
| _ESCO_MODEL = None | |
| _ESCO_EMBEDS = None # numpy (N, dim) | |
| _ESCO_META = None # parsed esco_skills.json | |
| _ESCO_SOFT_IDX = set() # int indices of transversal/soft ESCO skills | |
| # Optional binary "is this label soft?" classifier head trained on top of | |
| # JobBERT-v2. Loaded lazily; if absent, runtime falls back to rule-based | |
| # routing (denylist + ESCO transversal lookup). | |
| _SOFTNESS_CLF = None | |
| _SOFTNESS_THRESHOLD = 0.7 # conservative — only fire on confident soft hits | |
| # LLM-classified overrides for every skill seen in resume_data.csv. Produced | |
| # by llm_classify_skills.py. Authoritative when present — overrides denylist | |
| # and ESCO transversal lookup. Maps lowercase label → {"category", "...}. | |
| _SKILL_OVERRIDES: dict[str, dict] = {} | |
| def _load_esco_index() -> None: | |
| """Best-effort load of the ESCO embedding cache. Idempotent.""" | |
| global _ESCO_OK, _ESCO_MODEL, _ESCO_EMBEDS, _ESCO_META, _ESCO_SOFT_IDX | |
| import numpy as _np | |
| _model_dir = os.path.join(config.BASE_DIR, "models") | |
| _embed_path = os.path.join(_model_dir, "esco_embeddings.npy") | |
| _labels_path = os.path.join(_model_dir, "esco_skills.json") | |
| if not (os.path.exists(_embed_path) and os.path.exists(_labels_path)): | |
| return | |
| try: | |
| from sentence_transformers import SentenceTransformer | |
| _ESCO_EMBEDS = _np.load(_embed_path) | |
| with open(_labels_path, encoding="utf-8") as f: | |
| _ESCO_META = json.load(f) | |
| model_name = _ESCO_META.get("model", "TechWolf/JobBERT-v2") | |
| _ESCO_MODEL = SentenceTransformer(model_name) | |
| _ESCO_OK = True | |
| print(f"[Extractor] ESCO index loaded — " | |
| f"{_ESCO_EMBEDS.shape[0]:,} skills via {model_name}") | |
| # Map transversal-skill conceptUris to indices into _ESCO_EMBEDS so we | |
| # can route soft hits at query time without rebuilding the index. | |
| _transversal_path = os.path.join(config.BASE_DIR, "data", | |
| "esco_transversal_uris.txt") | |
| if os.path.exists(_transversal_path): | |
| soft_uris = set() | |
| with open(_transversal_path, encoding="utf-8") as f: | |
| for line in f: | |
| line = line.strip() | |
| if not line or line.startswith("#"): | |
| continue | |
| soft_uris.add(line.split("\t", 1)[0]) | |
| uris = _ESCO_META.get("conceptUri", []) | |
| _ESCO_SOFT_IDX = {i for i, u in enumerate(uris) if u in soft_uris} | |
| print(f"[Extractor] Transversal index loaded — " | |
| f"{len(_ESCO_SOFT_IDX):,} soft-skill URIs") | |
| except Exception as e: | |
| print(f"[Extractor] ESCO index load failed: {type(e).__name__}: {e}") | |
| _ESCO_OK = False | |
| _load_esco_index() | |
| def _load_softness_classifier() -> None: | |
| """Lazy-load the optional JobBERT softness classifier. Idempotent.""" | |
| global _SOFTNESS_CLF | |
| if _SOFTNESS_CLF is not None: | |
| return | |
| import pickle | |
| _path = os.path.join(config.BASE_DIR, "models", "softness_classifier.pkl") | |
| if not os.path.exists(_path): | |
| return | |
| try: | |
| with open(_path, "rb") as f: | |
| _SOFTNESS_CLF = pickle.load(f) | |
| m = _SOFTNESS_CLF.get("metrics", {}) | |
| print(f"[Extractor] Softness classifier loaded — " | |
| f"F1(soft)={m.get('f1_soft', 0):.3f}, " | |
| f"threshold={_SOFTNESS_THRESHOLD}") | |
| except Exception as e: | |
| print(f"[Extractor] Softness classifier load failed: " | |
| f"{type(e).__name__}: {e}") | |
| _load_softness_classifier() | |
| def _load_skill_overrides() -> None: | |
| """ | |
| Load data/skill_softness_overrides.csv if present. This is the LLM- | |
| classified routing table for every skill seen in resume_data.csv — | |
| authoritative when present, overrides denylist + ESCO transversal lookup. | |
| """ | |
| import csv as _csv | |
| global _SKILL_OVERRIDES | |
| path = os.path.join(config.BASE_DIR, "data", "skill_softness_overrides.csv") | |
| if not os.path.exists(path): | |
| return | |
| try: | |
| with open(path, encoding="utf-8") as f: | |
| for row in _csv.DictReader(f): | |
| lbl = (row.get("label") or "").strip() | |
| cat = (row.get("category") or "").strip().lower() | |
| if lbl and cat in {"soft", "hard", "language", "other"}: | |
| _SKILL_OVERRIDES[lbl.lower()] = { | |
| "category": cat, | |
| "confidence": (row.get("confidence") or "").strip().lower(), | |
| "human_reviewed": (row.get("human_reviewed") or "").strip(), | |
| } | |
| from collections import Counter as _Counter | |
| cats = _Counter(v["category"] for v in _SKILL_OVERRIDES.values()) | |
| print(f"[Extractor] Skill overrides loaded — " | |
| f"{len(_SKILL_OVERRIDES):,} labels ({dict(cats)})") | |
| except Exception as e: | |
| print(f"[Extractor] Skill overrides load failed: " | |
| f"{type(e).__name__}: {e}") | |
| _load_skill_overrides() | |
| def _classify_softness(labels: list[str]) -> dict[str, float]: | |
| """ | |
| Return {label: P(soft)} for each input label. Uses JobBERT-v2 encoder | |
| that's already loaded for ESCO. If the classifier isn't available, | |
| returns an empty dict (caller falls back to rule-based routing). | |
| """ | |
| if _SOFTNESS_CLF is None or _ESCO_MODEL is None or not labels: | |
| return {} | |
| import numpy as _np | |
| enc = _ESCO_MODEL.encode( | |
| labels, | |
| normalize_embeddings=True, | |
| show_progress_bar=False, | |
| convert_to_numpy=True, | |
| ).astype(_np.float32, copy=False) | |
| proba = _SOFTNESS_CLF["model"].predict_proba(enc)[:, 1] | |
| return {labels[i]: float(proba[i]) for i in range(len(labels))} | |
| def _normalise_skill_label(label: str) -> str: | |
| """ | |
| ESCO often disambiguates skills with parenthetical context, e.g. | |
| 'Python (programming language)'. Strip these so the resulting label | |
| keyword-matches whatever a JD's required_skills list contains. | |
| """ | |
| out = re.sub(r"\s*\([^)]+\)\s*", " ", label).strip() | |
| return re.sub(r"\s+", " ", out) | |
| def _chunk_resume_text(text: str, max_chunks: int = 40) -> list: | |
| """ | |
| Split a resume into short, embedding-friendly chunks. | |
| We DON'T pull in nltk just for this: a simple regex split on | |
| sentence-end punctuation + line breaks works fine for resume text, | |
| which is mostly bullets and short sentences. | |
| """ | |
| parts = re.split(r"(?<=[\.!?])\s+|\n+|•|·|■|►|▪|◦", text or "") | |
| chunks = [] | |
| for p in parts: | |
| s = p.strip() | |
| if 12 <= len(s) <= 280: | |
| chunks.append(s) | |
| if len(chunks) >= max_chunks: | |
| break | |
| return chunks | |
| def _esco_extract_skills(text: str, | |
| threshold: float = 0.55, | |
| top_k: int = 35) -> tuple[list, list]: | |
| """ | |
| Return (hard_skills, soft_skills) — ESCO labels semantically present in | |
| `text`, split by transversal-skill classification. | |
| Algorithm: | |
| 1. Split resume into ≤40 short chunks. | |
| 2. Encode chunks with JobBERT-v2 → (C, dim) L2-normalised matrix. | |
| 3. Dot-product with cached ESCO matrix → (C, N) cosine similarities. | |
| 4. For each skill, take the **max** similarity across chunks. | |
| 5. Keep top-k whose max-sim ≥ threshold; route by transversal flag. | |
| """ | |
| if not _ESCO_OK or not text: | |
| return [], [] | |
| chunks = _chunk_resume_text(text) | |
| if not chunks: | |
| return [], [] | |
| import numpy as _np | |
| q = _ESCO_MODEL.encode( | |
| chunks, | |
| normalize_embeddings=True, | |
| show_progress_bar=False, | |
| convert_to_numpy=True, | |
| ).astype(_np.float32, copy=False) | |
| sims = q @ _ESCO_EMBEDS.T # (chunks, N) | |
| per_skl = sims.max(axis=0) # best chunk-match per skill | |
| keep = _np.argsort(per_skl)[::-1][: top_k * 3] # over-pull, filter | |
| labels = _ESCO_META["labels"] | |
| hard: list[str] = [] | |
| soft: list[str] = [] | |
| seen: set[str] = set() | |
| for idx in keep: | |
| score = float(per_skl[idx]) | |
| if score < threshold: | |
| break # sorted desc → rest are below threshold | |
| label = _normalise_skill_label(labels[idx]) | |
| key = label.lower() | |
| if key in seen: | |
| continue | |
| seen.add(key) | |
| if int(idx) in _ESCO_SOFT_IDX: | |
| soft.append(label) | |
| else: | |
| hard.append(label) | |
| if len(hard) + len(soft) >= top_k: | |
| break | |
| return hard, soft | |
| # ============================================================================= | |
| # LOAD SKILLS TAXONOMY | |
| # ============================================================================= | |
| def _load_taxonomy() -> list: | |
| """ | |
| Load master skills list from data/skills_taxonomy.txt. | |
| Returns lowercase list. Falls back to a built-in starter list if | |
| the file doesn't exist yet. | |
| """ | |
| if os.path.exists(config.SKILLS_TAXONOMY_PATH): | |
| with open(config.SKILLS_TAXONOMY_PATH, "r", encoding="utf-8") as f: | |
| skills = [line.strip().lower() for line in f if line.strip()] | |
| return skills | |
| # Built-in starter taxonomy — covers most IT roles | |
| # Add more to data/skills_taxonomy.txt to extend this | |
| return [ | |
| # Languages | |
| "python", "java", "javascript", "typescript", "c++", "c#", "golang", | |
| "ruby", "php", "swift", "kotlin", "scala", "r", "matlab", "perl", | |
| # Web | |
| "react", "angular", "vue", "node.js", "django", "flask", "fastapi", | |
| "spring boot", "html", "css", "rest api", "graphql", | |
| # Data & ML | |
| "sql", "mysql", "postgresql", "mongodb", "redis", "elasticsearch", | |
| "pandas", "numpy", "scikit-learn", "tensorflow", "pytorch", "keras", | |
| "machine learning", "deep learning", "nlp", "computer vision", | |
| "data analysis", "data science", "statistics", "tableau", "power bi", | |
| "excel", "spark", "hadoop", "hive", | |
| # Cloud & DevOps | |
| "aws", "azure", "gcp", "docker", "kubernetes", "jenkins", "git", | |
| "github", "gitlab", "ci/cd", "terraform", "ansible", "linux", | |
| # Other Tech | |
| "agile", "scrum", "jira", "confluence", "microservices", "kafka", | |
| "airflow", "dbt", "snowflake", "databricks", | |
| # HR / Business (for non-tech JDs) | |
| "sap", "workday", "salesforce", "crm", "erp", "hris", | |
| ] | |
| _SKILLS_TAXONOMY = _load_taxonomy() | |
| # Soft skills vocabulary for pattern matching | |
| _SOFT_SKILLS = [ | |
| "communication", "leadership", "teamwork", "collaboration", "problem solving", | |
| "critical thinking", "time management", "adaptability", "creativity", | |
| "stakeholder management", "presentation", "negotiation", "mentoring", | |
| "conflict resolution", "decision making", "project management", | |
| "analytical", "detail oriented", "self motivated", "multitasking", | |
| ] | |
| # Degree keywords for education extraction | |
| _DEGREE_KEYWORDS = [ | |
| "b.tech", "b.e.", "b.sc", "bsc", "btech", "bachelor", "b.a", "ba", | |
| "m.tech", "m.e.", "m.sc", "msc", "mtech", "master", "m.a", "ma", "mba", | |
| "ph.d", "phd", "doctorate", "diploma", "pgdm", "pgpm", | |
| ] | |
| # ============================================================================= | |
| # 1. CONTACT INFORMATION | |
| # ============================================================================= | |
| # ── Indian city / state database for location fallback ─────────────────────── | |
| # Used when the LLM returns an empty location field. | |
| # Source: India Census 2011 + IT-hub common names. | |
| _INDIAN_CITIES = [ | |
| # Tier-1 metros | |
| "Mumbai", "Delhi", "Bangalore", "Bengaluru", "Hyderabad", "Chennai", | |
| "Kolkata", "Pune", "Ahmedabad", "Surat", | |
| # Tier-2 | |
| "Jaipur", "Lucknow", "Kanpur", "Nagpur", "Indore", "Thane", "Bhopal", | |
| "Visakhapatnam", "Vizag", "Patna", "Vadodara", "Ludhiana", "Agra", | |
| "Nashik", "Faridabad", "Meerut", "Rajkot", "Varanasi", "Prayagraj", | |
| "Allahabad", "Guwahati", "Dehradun", "Jammu", "Aurangabad", | |
| # NCR / satellite cities | |
| "Noida", "Gurgaon", "Gurugram", "Ghaziabad", "Greater Noida", "Navi Mumbai", | |
| # IT hubs & regional capitals | |
| "Chandigarh", "Coimbatore", "Kochi", "Cochin", "Trivandrum", | |
| "Thiruvananthapuram", "Mysore", "Mysuru", "Mangalore", "Mangaluru", | |
| "Hubballi", "Hubli", "Vijayawada", "Madurai", "Bhubaneswar", "Cuttack", | |
| "Ranchi", "Raipur", "Jabalpur", "Gwalior", "Jodhpur", "Udaipur", "Kota", | |
| "Amritsar", "Jalandhar", "Dhanbad", "Srinagar", | |
| ] | |
| _INDIAN_STATES = [ | |
| "Maharashtra", "Karnataka", "Tamil Nadu", "Andhra Pradesh", "Telangana", | |
| "Gujarat", "Rajasthan", "Uttar Pradesh", "West Bengal", "Madhya Pradesh", | |
| "Kerala", "Punjab", "Haryana", "Bihar", "Odisha", "Jharkhand", "Assam", | |
| "Delhi", "NCR", "NCT", | |
| ] | |
| def _extract_location_from_text(text: str) -> str: | |
| """ | |
| Best-effort location extraction from raw resume text. | |
| Strategy: | |
| 1. 'City, State' / 'City - State' pattern with known Indian states. | |
| 2. Known city name in the first ~350 chars (header zone). | |
| 3. Full-text scan up to 600 chars. | |
| Returns a city/location string, or "" if nothing found. | |
| """ | |
| # Pattern 1 — "City, State" with a recognised Indian state | |
| state_pat = '|'.join(re.escape(s) for s in _INDIAN_STATES) | |
| m = re.search( | |
| rf'([A-Z][a-zA-Z\s]{{2,25}})[,\s\-]+(?:{state_pat})\b', | |
| text | |
| ) | |
| if m: | |
| # Return the full match (e.g. "Noida, UP" or "Mumbai, Maharashtra") | |
| return m.group(0).strip().rstrip(',').strip() | |
| # Pattern 2 — header zone (first 350 chars) | |
| header = text[:350] | |
| for city in _INDIAN_CITIES: | |
| if re.search(r'\b' + re.escape(city) + r'\b', header, re.IGNORECASE): | |
| # Try to capture a trailing state/region on the same line | |
| city_m = re.search( | |
| r'\b' + re.escape(city) + r'\b[,\s]*([^\n]{0,30})', | |
| header, re.IGNORECASE | |
| ) | |
| if city_m: | |
| extra = city_m.group(1).strip().rstrip(',').strip() | |
| extra_first_line = extra.split('\n')[0].strip() | |
| if any(s.lower() in extra_first_line.lower() for s in _INDIAN_STATES): | |
| return f"{city.title()}, {extra_first_line}" | |
| return city.title() | |
| # Pattern 3 — full text scan (slower, last resort) | |
| for city in _INDIAN_CITIES: | |
| if re.search(r'\b' + re.escape(city) + r'\b', text[:600], re.IGNORECASE): | |
| return city.title() | |
| return "" | |
| # ── Post-processing scrubs for LLM hallucinations ──────────────────────────── | |
| # Built once at module load — cheap lower-case lookups. | |
| _LOCATION_TOKENS = ( | |
| {c.lower() for c in _INDIAN_CITIES} | |
| | {s.lower() for s in _INDIAN_STATES} | |
| | {"ncr", "india", "region", "area", "north", "south", "east", "west"} | |
| ) | |
| # Pure business-function / department / section-header tokens. | |
| # A "company" composed ONLY of these is a section header, not a real employer. | |
| _FUNCTION_TOKENS = { | |
| # Departments / functions | |
| "operations", "management", "marketing", "sales", "finance", "accounting", | |
| "administration", "administrative", "hr", "recruitment", "talent", | |
| "business", "analysis", "analytics", "strategy", "development", "planning", | |
| "credit", "risk", "compliance", "audit", "treasury", "regulatory", | |
| "support", "services", "service", "customer", "client", "relationship", | |
| "insurance", "banking", "investment", "wealth", "private", "retail", | |
| "corporate", "commercial", "consumer", "product", "project", "program", | |
| "delivery", "engineering", "production", "manufacturing", "supply", "chain", | |
| "logistics", "procurement", "quality", "assurance", "control", "controls", | |
| "technology", "information", "data", "research", | |
| "transformation", "digital", "general", "cash", "automation", "platform", | |
| "process", "processes", "digitization", "fraud", "financial", | |
| "leadership", "leadership", "revenue", "pricing", "approval", "approvals", | |
| "review", "reviewed", "approval", "growth", "performance", | |
| # Industries / domains often mistaken for companies | |
| "pension", "life", "health", "asset", "lending", "loans", "mortgage", | |
| "payments", "fintech", "ecommerce", "e-commerce", | |
| # Acronyms & metrics frequently mis-extracted as company names | |
| "kpi", "kpis", "api", "apis", "roi", "sla", "kra", "kras", | |
| "bpo", "kpo", "p&l", "pnl", "ai", "ml", "saas", | |
| # UI/UX/IT abbreviations sometimes returned as "company" | |
| "ui", "ux", "it", | |
| # Country / region tokens (frequently leaked from section headers like "UK Life") | |
| "uk", "us", "usa", "uae", "europe", "asia", "apac", "emea", "global", | |
| # Degree leaks | |
| "b.tech", "btech", "m.tech", "mtech", "b.sc", "bsc", "m.sc", "msc", | |
| "mba", "ba", "ma", "phd", "pgdm", | |
| # Resume section-header words (LLM sometimes returns these as "companies") | |
| "profile", "summary", "core", "competencies", "competency", | |
| "professional", "work", "history", "objective", "overview", | |
| "highlights", "expertise", "achievements", "awards", | |
| "education", "documentation", "contact", "address", "id", "email", | |
| # Product / industry descriptor words (not a company name on their own) | |
| "personal", "durables", "consumer", "sme", "msme", | |
| # Finance / compliance acronyms often leaked from job descriptions | |
| "kyc", "aml", "emi", "npa", "nro", "nre", "upi", "pos", | |
| "nbfc", "mfi", "rbi", "irda", "amfi", "sebi", "lap", "tprm", | |
| # Banking / NBFC function words commonly leaked as fake company names | |
| "account", "accounts", "key", "vendor", "payment", "payments", | |
| "escrow", "nodal", "loan", "loans", "detection", "collection", | |
| "collections", "people", "stakeholder", "stakeholders", | |
| "limit", "loading", "borrower", "disbursement", "reconciliation", | |
| "portfolio", "bucket", "delinquency", "finops", "fin-ops", | |
| "housing", "home", "projects", "sourcing", "monitoring", | |
| "team", "building", "performance", "legal", "linkedin", | |
| "inr", "usd", "pre", "paid", "instrument", "instruments", | |
| # Job-title seniority/level words — appear in role prefixes, not company names | |
| "head", "vice", "vp", "avp", "svp", "evp", | |
| } | |
| # Job-title prefixes that the LLM sometimes pastes in front of a company name | |
| # e.g. "Country Head – League of Extraordinary" → starts with "Country Head" | |
| # Any company-string that starts with one of these prefixes is a role label, not a company. | |
| _JOB_TITLE_PREFIX_RE = re.compile( | |
| r'^(?:' | |
| r'country\s+head|head\s+of|vp\s+of|vice\s+president(?:\s+of)?|' | |
| r'avp\s+(?:of\s+)?|svp\s+(?:of\s+)?|evp\s+(?:of\s+)?|' | |
| r'chief\s+(?:executive|operating|financial|technology|marketing|people)|' | |
| r'managing\s+director|general\s+manager|deputy\s+(?:general\s+)?manager|' | |
| r'senior\s+vice\s+president|assistant\s+vice\s+president|' | |
| r'regional\s+(?:head|manager|director)|national\s+(?:head|manager|director)|' | |
| r'zonal\s+(?:head|manager|director)|business\s+head|' | |
| r'president\s+(?:and|&)\s+|founder\s+(?:and|&)\s+|' | |
| r'cluster\s+head|branch\s+head|area\s+(?:head|manager)' | |
| r')\b', | |
| re.IGNORECASE, | |
| ) | |
| # Corporate suffixes — any of these in the string strongly suggests a real company | |
| _CORPORATE_SUFFIXES = { | |
| "ltd", "ltd.", "limited", "pvt", "pvt.", "private", | |
| "inc", "inc.", "incorporated", "corp", "corp.", "corporation", | |
| "llc", "llp", "plc", "gmbh", "ag", "sa", "co", "co.", | |
| "company", "bank", "industries", "technologies", "solutions", | |
| "systems", "services", "consulting", "consultants", "enterprises", | |
| "group", "holdings", "international", | |
| } | |
| def _looks_like_location(text: str) -> bool: | |
| """ | |
| True iff `text` is essentially a place name (no real company-name signal). | |
| Used to reject hallucinated 'last_company' values like 'Delhi-NCR'. | |
| """ | |
| if not text: | |
| return False | |
| t = text.lower().strip(' .,-–—()') | |
| if not t: | |
| return False | |
| tokens = [w for w in re.split(r"[\s,\-–—/&]+", t) if w] | |
| if not tokens: | |
| return False | |
| connectors = {"and", "the", "of", "in", "at"} | |
| return all((tok in _LOCATION_TOKENS or tok in connectors) for tok in tokens) | |
| def _looks_like_function(text: str) -> bool: | |
| """ | |
| True iff `text` is purely a job/department/function name with no company- | |
| specific proper-noun tokens. E.g. 'Insurance Operations', 'Credit Management', | |
| 'Bank', 'FCA & PRA Compliance'. | |
| Decision rule: a real company must have at least one PROPER NOUN — i.e. a | |
| token that is NOT in any of (function, location, regulator, connector, | |
| corporate-suffix) sets. Otherwise it's just a description. | |
| """ | |
| if not text: | |
| return False | |
| t = re.sub(r"\s+", " ", text.lower()).strip() | |
| if not t: | |
| return False | |
| tokens = [w.strip(".,") for w in re.split(r"[\s/&\-–—,]+", t) if w] | |
| tokens = [tok for tok in tokens if tok] | |
| if not tokens: | |
| return False | |
| connectors = {"and", "the", "of", "in", "at", "for"} | |
| # Regulator / non-company acronyms (UK Financial Conduct Authority, etc.) | |
| regulators = { | |
| "fca", "pra", "sec", "rbi", "icai", "fda", "hipaa", "sox", | |
| "gdpr", "pci", "irda", "sebi", "nse", "bse", | |
| } | |
| # A proper-noun token = not in any of the "common / generic" pools | |
| proper_noun_tokens = [ | |
| tok for tok in tokens | |
| if tok not in _FUNCTION_TOKENS | |
| and tok not in _LOCATION_TOKENS | |
| and tok not in _CORPORATE_SUFFIXES | |
| and tok not in connectors | |
| and tok not in regulators | |
| ] | |
| # No proper noun → it's just function/location/suffix words → reject | |
| if not proper_noun_tokens: | |
| return True | |
| return False | |
| def _looks_like_garbage(text: str) -> bool: | |
| """ | |
| True iff `text` is obviously not a company name — bullet text, all | |
| punctuation, too short, oversized fragments, or PDF unicode artifacts. | |
| """ | |
| if not text: | |
| return True | |
| t = text.strip() | |
| # Contains a newline → multi-line section fragment, not a single company | |
| if '\n' in t or '\r' in t: | |
| return True | |
| # Absurdly long → entire section header / bullet cluster | |
| if len(t) > 80: | |
| return True | |
| # Starts with a bullet/symbol or unicode dash → bullet text fragment | |
| # Includes ASCII hyphen, em-dash (—), en-dash (–), minus (−) | |
| if re.match(r'^[•●○◦►▪▫◆◇■□★☆\-\*\+§¶—–−]', t): | |
| return True | |
| # PDF unicode private-use area artifacts (Wingdings glyphs etc.) | |
| # These appear as , , … when PDF bullets are extracted | |
| if re.match(r'^[-]', t): | |
| return True | |
| # Anywhere in the first 3 chars | |
| if any(0xf000 <= ord(ch) <= 0xf0ff for ch in t[:3]): | |
| return True | |
| # Has no alphabetic content at all | |
| if not re.search(r'[A-Za-z]', t): | |
| return True | |
| # Strip non-letter chars; if ≤ 2 letters remain → garbage | |
| # (catches "UI", "P&L", "HR", "IT" etc.) | |
| letters_only = re.sub(r'[^A-Za-z]', '', t) | |
| if len(letters_only) <= 2: | |
| return True | |
| # Contains internal bullet markers → multi-bullet fragment | |
| if re.search(r'[•●◦►▪▫◆◇■□]', t): | |
| return True | |
| # Unbalanced parentheses → truncated/corrupted string | |
| # e.g. "Anti, Money Laundering (AML" or "FIU IND (CIMS & DAKSH" | |
| if t.count('(') != t.count(')'): | |
| return True | |
| # Contains '@' → email address leaked as company name | |
| if '@' in t: | |
| return True | |
| # Starts with a job-title prefix → role label, not a company name | |
| # e.g. "Country Head League of Extraordinary", "Head of Insurance" | |
| if _JOB_TITLE_PREFIX_RE.match(t): | |
| return True | |
| # Suspicious starts — verb or structural phrase that identifies a non-company string | |
| suspicious_starts = ( | |
| 'executed ', 'facilitated ', 'currently', 'working with ', 'handled ', 'guided ', 'collaborated ', | |
| 'monitored ', 'led ', 'managed ', 'developed ', 'created ', | |
| 'designed ', 'built ', 'implemented ', 'delivered ', 'achieved ', | |
| 'worked ', 'responsible ', 'role ', 'duty ', | |
| 'reviewed ', 'approved ', 'drove ', 'utilize ', 'utilized ', | |
| 'utilise ', 'utilised ', 'directed ', 'oversaw ', 'spearheaded ', | |
| 'ensured ', 'supported ', 'assisted ', 'coordinated ', | |
| 'analyzed ', 'analysed ', | |
| ) | |
| if t.lower().startswith(suspicious_starts): | |
| return True | |
| return False | |
| def _clean_company_name(co: str) -> str: | |
| """ | |
| Best-effort cleanup of an LLM-emitted company name. | |
| Rejects: | |
| - Bullet text fragments ("• Executed daily reviews") | |
| - Pure locations ("Delhi NCR") | |
| - Pure job functions ("Insurance Operations", "Credit Management") | |
| - Punctuation-only or too-short strings | |
| Keeps: | |
| - 'Infosys' → 'Infosys' | |
| - 'HDFC Bank, Mumbai' → 'HDFC Bank' | |
| - 'Groz Engineering Tools Pvt. Ltd.' → 'Groz Engineering Tools Pvt. Ltd.' | |
| - 'Yes Bank Ltd' → 'Yes Bank Ltd' | |
| """ | |
| if not co: | |
| return "" | |
| raw = co.strip() | |
| if _looks_like_garbage(raw): return "" | |
| if _looks_like_location(raw): return "" | |
| if _looks_like_function(raw): return "" | |
| # Strip trailing location markers via segment split | |
| parts = re.split(r"\s*[,\-–—]\s*", raw) | |
| kept = [] | |
| for p in parts: | |
| p = p.strip() | |
| if not p: | |
| continue | |
| if _looks_like_location(p): | |
| continue | |
| if _looks_like_function(p): | |
| continue | |
| kept.append(p) | |
| cleaned = ", ".join(kept) if kept else "" | |
| # Final sanity check after splitting | |
| if not cleaned or _looks_like_garbage(cleaned): | |
| return "" | |
| return cleaned.strip() | |
| def _filter_project_entries(projects: list, companies: list) -> list: | |
| """ | |
| Remove entries that look like company names or pure work-history bullets | |
| from the projects list. The LLM sometimes leaks employer names here. | |
| """ | |
| if not projects: | |
| return [] | |
| co_keys = {c.lower().strip() for c in companies if c} | |
| cleaned = [] | |
| for p in projects: | |
| if not p or not p.strip(): | |
| continue | |
| ps = p.strip() | |
| # Drop direct company-name matches | |
| if ps.lower() in co_keys: | |
| continue | |
| # Drop bare location strings | |
| if _looks_like_location(ps): | |
| continue | |
| # Drop entries whose leading token is a known company name. | |
| # Splits on | — – - , ( so "HDFC Bank, Mumbai (2018-2022)" → "HDFC Bank" | |
| first_part = re.split(r'[|—–\-,(]', ps, 1)[0].strip() | |
| if first_part and first_part.lower() in co_keys: | |
| continue | |
| # Drop entries whose first two words match a company prefix | |
| # e.g. "HDFC Bank quarterly review" when company is "HDFC Bank" | |
| first_two = " ".join(first_part.split()[:2]).lower() | |
| if first_two and first_two in co_keys: | |
| continue | |
| cleaned.append(ps) | |
| return cleaned | |
| def _years_from_tenures(tenures: list) -> float: | |
| """ | |
| Sum non-overlapping calendar time across a list of company_tenures | |
| entries. "Present"/"Current"/"Now" are anchored to today's date — NOT | |
| the date the CV was written. Returns total years (rounded to 1 dp), or | |
| 0 if no parseable date ranges were found. | |
| Accepts both formats: | |
| - "Company | Role | Start – End" (new format) | |
| - "Company – City (Start – End)" (legacy format) | |
| """ | |
| if not tenures: | |
| return 0.0 | |
| _month = ( | |
| r'(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|' | |
| r'Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|' | |
| r'Nov(?:ember)?|Dec(?:ember)?)' | |
| ) | |
| range_re = re.compile( | |
| rf'({_month}\s*\'?\d{{2,4}}|20\d{{2}})\s*[-–—]\s*' | |
| rf'({_month}\s*\'?\d{{2,4}}|20\d{{2}}|[Pp]resent|[Cc]urrent|[Nn]ow|[Oo]ngoing)', | |
| re.IGNORECASE, | |
| ) | |
| now = datetime.now() | |
| pairs: list = [] | |
| for t in tenures: | |
| m = range_re.search(str(t)) | |
| if not m: | |
| continue | |
| start_str, end_str = m.group(1).strip(), m.group(2).strip().lower() | |
| start = _parse_date_safe(start_str) | |
| end = now if end_str in ('present', 'current', 'now', 'ongoing') \ | |
| else _parse_date_safe(end_str) | |
| if start and end and end >= start: | |
| pairs.append((start, end)) | |
| if not pairs: | |
| return 0.0 | |
| merged_months = 0 | |
| for s, e in _merge_date_ranges(pairs): | |
| d = relativedelta(e, s) | |
| merged_months += d.years * 12 + d.months | |
| return round(merged_months / 12, 1) | |
| def _build_tenures_from_text(text: str, companies: list) -> list: | |
| """ | |
| Regex fallback: build "Company – City (Month Year – Month Year)" entries | |
| by scanning the raw resume text for date ranges near each known company name. | |
| Called only when the LLM returns an empty company_tenures list. | |
| Strategy per company: | |
| 1. Find the company name's first occurrence in the text. | |
| 2. In the next ~400 chars search for a month-year date range. | |
| 3. Optionally capture a known Indian city in the same window. | |
| 4. Emit the formatted tenure string. | |
| """ | |
| if not companies: | |
| return [] | |
| # Pattern A — "Month Year – Month Year" (most common in structured CVs) | |
| _month = ( | |
| r'(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|' | |
| r'Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|' | |
| r'Nov(?:ember)?|Dec(?:ember)?)' | |
| ) | |
| _date_range_re = re.compile( | |
| rf'({_month}\s*\'?\d{{2,4}})\s*[-–—]\s*' | |
| rf'({_month}\s*\'?\d{{2,4}}|[Pp]resent|[Cc]urrent)', | |
| re.IGNORECASE, | |
| ) | |
| # Pattern B — "Year – Year" or "Year – Present" (year-only ranges) | |
| _year_range_re = re.compile( | |
| r'(20\d{2})\s*[-–—]\s*(20\d{2}|[Pp]resent|[Cc]urrent)', | |
| re.IGNORECASE, | |
| ) | |
| tenures: list = [] | |
| seen: set = set() | |
| for company in companies: | |
| # Find position of this company in the full text (case-insensitive) | |
| try: | |
| idx = text.lower().index(company.lower()) | |
| except ValueError: | |
| continue | |
| # 400-char window starting at the company mention | |
| window = text[idx: idx + 400] | |
| m_month = _date_range_re.search(window) | |
| m_year = _year_range_re.search(window) | |
| if m_month and m_year: | |
| # Take whichever date range appears first (closest to the company) | |
| if m_month.start() <= m_year.start(): | |
| start_str = m_month.group(1).strip() | |
| end_raw = m_month.group(2).strip() | |
| else: | |
| start_str = m_year.group(1).strip() | |
| end_raw = m_year.group(2).strip() | |
| elif m_month: | |
| start_str = m_month.group(1).strip() | |
| end_raw = m_month.group(2).strip() | |
| elif m_year: | |
| start_str = m_year.group(1).strip() | |
| end_raw = m_year.group(2).strip() | |
| else: | |
| continue | |
| # Normalise "present"/"current" → "Present" | |
| end_str = "Present" if end_raw.lower() in ("present", "current") else end_raw | |
| # Look for a known Indian city in the first 200 chars of the window | |
| city = "" | |
| for cname in _INDIAN_CITIES: | |
| if re.search(r'\b' + re.escape(cname) + r'\b', window[:200], re.IGNORECASE): | |
| city = cname.title() | |
| break | |
| dates = f"{start_str} – {end_str}" | |
| key = (company.lower(), dates.lower()) | |
| if key in seen: | |
| continue | |
| seen.add(key) | |
| if city: | |
| tenures.append(f"{company} – {city} ({dates})") | |
| else: | |
| tenures.append(f"{company} ({dates})") | |
| return tenures | |
| _NAME_SKIP_WORDS = { | |
| # Document type / section labels that bubble up into the resume header | |
| 'resume', 'cv', 'curriculum', 'vitae', 'profile', 'objective', | |
| 'summary', 'contact', 'information', 'details', 'education', | |
| 'experience', 'skills', 'certifications', 'projects', 'hobbies', | |
| 'interests', 'achievements', 'career', 'personal', 'professional', | |
| # Job-title nouns — common false positives when scanning header lines | |
| 'senior', 'junior', 'lead', 'manager', 'analyst', 'engineer', | |
| 'developer', 'consultant', 'specialist', 'executive', 'director', | |
| 'intern', 'trainee', 'associate', 'accountant', 'designer', | |
| 'copywriter', 'writer', 'marketer', 'page', 'of', | |
| # Address / contact noise | |
| 'mobile', 'phone', 'email', 'address', 'linkedin', 'github', | |
| 'india', 'delhi', 'mumbai', 'bangalore', 'pune', 'hyderabad', | |
| } | |
| # Explicit labels that prefix a name on the resume header | |
| _NAME_LABEL_PATTERNS = [ | |
| # "Name: John Doe" / "Candidate Name - John Doe" | |
| re.compile( | |
| r'^\s*(?:name|candidate\s*name|full\s*name|applicant\s*name)\s*[:\-–]\s*' | |
| r'([A-Za-z][A-Za-z\.\-\']+(?:\s+[A-Za-z][A-Za-z\.\-\']+){1,3})\s*$', | |
| re.IGNORECASE, | |
| ), | |
| # "I am John Doe" / "My name is John Doe" | |
| re.compile( | |
| r'^\s*(?:i\s+am|my\s+name\s+is|this\s+is)\s+' | |
| r'([A-Za-z][A-Za-z\.\-\']+(?:\s+[A-Za-z][A-Za-z\.\-\']+){1,3})\b', | |
| re.IGNORECASE, | |
| ), | |
| ] | |
| # 2–4 ALL-CAPS tokens, optionally separated by " - " — very common header style | |
| _NAME_ALLCAPS_PATTERN = re.compile( | |
| r'^\s*([A-Z][A-Z\.\-\']{1,}(?:\s+[A-Z][A-Z\.\-\']{1,}){1,3})\s*$' | |
| ) | |
| # Title-cased "First Last [Middle]" pattern, allowing single-letter middles | |
| _NAME_TITLECASE_PATTERN = re.compile( | |
| r'^\s*([A-Z][a-z]+(?:\s+[A-Z]\.?)?\s+[A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)\s*$' | |
| ) | |
| def _looks_like_name(text: str) -> bool: | |
| """Lightweight gate used by every regex/NER branch.""" | |
| words = text.strip().split() | |
| if not (2 <= len(words) <= 4): | |
| return False | |
| if re.search(r'[0-9@|/\\:_]', text): | |
| return False | |
| if any(w.lower() in _NAME_SKIP_WORDS for w in words): | |
| return False | |
| if len(text.strip()) < 4: | |
| return False | |
| return True | |
| def _header_lines(text: str, n: int = 10) -> list: | |
| """ | |
| Return the first `n` non-empty lines of the resume. Name extraction is | |
| restricted to this slice because resume names live in the header. | |
| """ | |
| out = [] | |
| for ln in text.split('\n'): | |
| s = ln.strip() | |
| if not s: | |
| continue | |
| out.append(s) | |
| if len(out) >= n: | |
| break | |
| return out | |
| def extract_candidate_name(text: str, file_name: str = "") -> str: | |
| """ | |
| Extract candidate name from a resume. | |
| Strategy (in order of reliability): | |
| 1. spaCy PERSON entity — but only on the **first 10 non-empty lines**, | |
| not the full document. Stops mid-resume "John, my mentor" hits. | |
| 2. Regex fallback — explicit "Name: John Doe" labels. | |
| 3. Regex fallback — ALL-CAPS header (e.g. "GAURAV JOSHI"). | |
| 4. Regex fallback — Title-case "First Last" pattern. | |
| 5. Loose line scan inside the header zone. | |
| 6. Filename derivation as a last resort. | |
| Args: | |
| text : Raw resume text | |
| file_name : Original filename — used as last-resort fallback | |
| Returns: | |
| Candidate name string, or "Unknown" if nothing found. | |
| """ | |
| if not text: | |
| return _name_from_filename(file_name) | |
| header = _header_lines(text, n=10) | |
| header_text = "\n".join(header) | |
| # ── Strategy 1: spaCy NER on header zone only ──────────────────────────── | |
| if _SPACY_OK and _NLP and header_text: | |
| try: | |
| doc = _NLP(header_text) | |
| for ent in doc.ents: | |
| if ent.label_ == "PERSON" and _looks_like_name(ent.text): | |
| cleaned = re.sub(r"[^a-zA-Z\s\.\-']", '', ent.text).strip() | |
| if cleaned and len(cleaned) >= 4: | |
| return cleaned.title() | |
| except Exception: | |
| pass # spaCy occasionally chokes on garbled OCR text | |
| # ── Strategy 2: Regex — explicit "Name:" labels in the header ──────────── | |
| for ln in header: | |
| for pat in _NAME_LABEL_PATTERNS: | |
| m = pat.search(ln) | |
| if m and _looks_like_name(m.group(1)): | |
| return m.group(1).strip().title() | |
| # ── Strategy 3: Regex — ALL-CAPS header line (top 5 lines only) ────────── | |
| for ln in header[:5]: | |
| m = _NAME_ALLCAPS_PATTERN.match(ln) | |
| if m and _looks_like_name(m.group(1)): | |
| return m.group(1).strip().title() | |
| # ── Strategy 4: Regex — Title-cased "First Last" ───────────────────────── | |
| for ln in header[:6]: | |
| m = _NAME_TITLECASE_PATTERN.match(ln) | |
| if m and _looks_like_name(m.group(1)): | |
| return m.group(1).strip().title() | |
| # ── Strategy 5: Loose pattern scan over the full header ────────────────── | |
| for ln in header: | |
| words = ln.split() | |
| if (2 <= len(words) <= 4 and | |
| not re.search(r'[0-9@|/\\:_\.]', ln) and | |
| all(w[0].isupper() for w in words if w) and | |
| not any(w.lower() in _NAME_SKIP_WORDS for w in words) and | |
| len(ln) >= 5): | |
| return ln.title() | |
| # ── Strategy 6: Derive from filename ───────────────────────────────────── | |
| return _name_from_filename(file_name) | |
| def _name_from_filename(file_name: str) -> str: | |
| """ | |
| Derive a display name from a resume filename. | |
| Examples: | |
| "priya_sharma.pdf" → "Priya Sharma" | |
| "Gaurav_Joshi_Resume pdf.pdf" → "Gaurav Joshi" | |
| "Naukri_AayushiShukla[6y_0m].pdf" → "Aayushishukla" | |
| "kartik CV.pdf" → "Kartik" | |
| "resume_1.pdf" → "Unknown" | |
| """ | |
| if not file_name: | |
| return "Unknown" | |
| stem = os.path.splitext(os.path.basename(file_name))[0] | |
| # Replace underscores, hyphens, dots with spaces | |
| stem = re.sub(r'[_\-\.]+', ' ', stem).strip() | |
| # Strip brackets and their contents e.g. [6y_0m] | |
| stem = re.sub(r'\[.*?\]|\(.*?\)', ' ', stem).strip() | |
| # Remove any remaining non-alphabetic characters from each token | |
| words = [re.sub(r'[^a-zA-Z]', '', w) for w in stem.split()] | |
| words = [w for w in words if w] # drop empty tokens after stripping | |
| # Skip generic filename tokens (including file-extension leftovers) | |
| generic = { | |
| 'resume', 'cv', 'curriculum', 'vitae', 'application', | |
| 'candidate', 'updated', 'final', 'new', 'f', 'v', 'v2', 'v3', | |
| 'pdf', 'docx', 'doc', 'txt', | |
| 'naukri', 'linkedin', 'profile', 'my', 'latest', 'copy', | |
| '1', '2', '3', '2024', '2025', '2026', | |
| } | |
| filtered = [w for w in words | |
| if w.lower() not in generic and len(w) >= 2 and not w.isdigit()] | |
| if len(filtered) >= 2: | |
| return ' '.join(w.title() for w in filtered[:3]) | |
| # Accept single meaningful word as a first-name-only fallback | |
| if len(filtered) == 1 and len(filtered[0]) >= 3: | |
| return filtered[0].title() | |
| return "Unknown" | |
| def extract_contact(text: str) -> dict: | |
| """ | |
| Extract email address and phone number from resume text. | |
| Phone extraction — multi-strategy, Indian-mobile-first: | |
| 1. +91 prefix → capture the 10 digits that follow (+91 removed as prefix) | |
| 2. 91 without + → same | |
| 3. Leading 0 → STD-style (0 removed as prefix) | |
| 4. Bare 10-digit mobile starting with 6-9 | |
| 5. Generic international fallback | |
| The old ``lstrip('+91')`` was a character-set strip (stripped any of | |
| '+','9','1'), which turned '+91 9953668257' → '53668257'. | |
| This version uses regex prefix removal so every digit is preserved. | |
| Returns: | |
| {"email": "x@y.com", "phone": "9876543210"} | |
| Values are empty string if not found. | |
| """ | |
| # Email — standard pattern | |
| email_match = re.search( | |
| r'[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}', text | |
| ) | |
| email = email_match.group(0) if email_match else "" | |
| phone = "" | |
| # Strategy 1: +91 [sep?] 10-digit-mobile (with optional mid-separators) | |
| m = re.search(r'\+91[\s\-\.]?([6-9]\d{2})[\s\-\.]?(\d{3})[\s\-\.]?(\d{4})', text) | |
| if m: | |
| phone = m.group(1) + m.group(2) + m.group(3) | |
| # Strategy 2: 91 (no +) then 10-digit mobile — e.g. "919876543210" | |
| if not phone: | |
| m = re.search(r'(?<!\d)91([6-9]\d{2})[\s\-\.]?(\d{3})[\s\-\.]?(\d{4})(?!\d)', text) | |
| if m: | |
| phone = m.group(1) + m.group(2) + m.group(3) | |
| # Strategy 3: Leading-0 STD format — e.g. "09876543210" | |
| if not phone: | |
| m = re.search(r'(?<!\d)0([6-9]\d{2})[\s\-\.]?(\d{3})[\s\-\.]?(\d{4})(?!\d)', text) | |
| if m: | |
| phone = m.group(1) + m.group(2) + m.group(3) | |
| # Strategy 4: Bare 10-digit Indian mobile (most common in Indian CVs) | |
| if not phone: | |
| m = re.search(r'(?<!\d)([6-9]\d{2})[\s\-\.]?(\d{3})[\s\-\.]?(\d{4})(?!\d)', text) | |
| if m: | |
| phone = m.group(1) + m.group(2) + m.group(3) | |
| # Strategy 5: Generic international number ≥ 10 digits | |
| if not phone: | |
| m = re.search(r'\+\d[\d\s\-\.]{9,14}\d', text) | |
| if m: | |
| digits = re.sub(r'\D', '', m.group(0)) | |
| # Remove +91 country code if present (regex prefix, not char-set) | |
| digits = re.sub(r'^91', '', digits) if len(digits) > 10 else digits | |
| phone = digits[-10:] if len(digits) >= 10 else digits | |
| return {"email": email, "phone": phone} | |
| def extract_urls(text: str) -> dict: | |
| """ | |
| Pull every clickable URL out of the resume and bucket them by type: | |
| - linkedin_url : LinkedIn profile (linkedin.com/in/…) | |
| - github_url : GitHub profile or repo (github.com/…) | |
| - portfolio_url : First non-LinkedIn / non-GitHub personal site | |
| (heuristic: looks like a portfolio domain or | |
| contains personal keywords) | |
| - other_urls : Every remaining URL — kept as a JSON list so the UI | |
| can render them as extra link chips. | |
| The patterns are loose on protocol — we accept "https://", "http://" or | |
| bare "www.linkedin.com/in/foo" because Indian CVs frequently strip the | |
| protocol when exporting from Word to PDF. | |
| Returns dict with all four keys; values are "" or [] when not found. | |
| """ | |
| # Canonical URL pattern — accepts protocol-less domains too. | |
| url_re = re.compile( | |
| r'(?:https?://|www\.)[A-Za-z0-9._~:/?#@!$&\'()*+,;=\-%]+', | |
| re.IGNORECASE, | |
| ) | |
| candidates = [] | |
| for m in url_re.finditer(text or ""): | |
| u = m.group(0).strip().rstrip('.,;:)]}') | |
| if u.lower().startswith("www."): | |
| u = "https://" + u | |
| if u not in candidates: | |
| candidates.append(u) | |
| # Also pick up bare LinkedIn slugs even when the user wrote just | |
| # "linkedin.com/in/foo" without www/https — common in scraped PDFs. | |
| for m in re.finditer( | |
| r'(?:^|\s)((?:linkedin\.com|github\.com)/[A-Za-z0-9._~:/?#@!$&\'()*+,;=\-%]+)', | |
| text or "", re.IGNORECASE, | |
| ): | |
| u = "https://" + m.group(1).strip().rstrip('.,;:)]}') | |
| if u not in candidates: | |
| candidates.append(u) | |
| linkedin = "" | |
| github = "" | |
| portfolio = "" | |
| others = [] | |
| PORTFOLIO_HINT_RE = re.compile( | |
| r'(portfolio|behance|dribbble|medium|substack|notion\.site|' | |
| r'github\.io|wordpress|wixsite|webflow|carrd|vercel\.app|' | |
| r'netlify\.app|pages\.dev)', | |
| re.IGNORECASE, | |
| ) | |
| SKIP_RE = re.compile( | |
| r'(google\.com/maps|wa\.me|tel:|mailto:|youtu\.be|youtube\.com/watch)', | |
| re.IGNORECASE, | |
| ) | |
| for u in candidates: | |
| lo = u.lower() | |
| if SKIP_RE.search(lo): | |
| continue | |
| if not linkedin and "linkedin.com" in lo: | |
| linkedin = u | |
| continue | |
| if not github and "github.com" in lo: | |
| github = u | |
| continue | |
| if not portfolio and PORTFOLIO_HINT_RE.search(lo): | |
| portfolio = u | |
| continue | |
| others.append(u) | |
| return { | |
| "linkedin_url" : linkedin, | |
| "github_url" : github, | |
| "portfolio_url" : portfolio, | |
| "other_urls" : others[:5], # cap at 5 to keep CSV row sane | |
| } | |
| # ============================================================================= | |
| # 2. SKILLS EXTRACTION | |
| # ============================================================================= | |
| def extract_skills(text: str, | |
| taxonomy: list = None, | |
| category: str = "", | |
| job_title: str = "", | |
| esco_threshold: float = 0.55, | |
| esco_top_k: int = 35) -> list: | |
| """ | |
| Extract technical skills from resume / JD text. | |
| Layered strategy (each layer adds to a single deduplicated set): | |
| 1. ESCO + JobBERT-v2 embedding lookup (Layer 1, recall-oriented). | |
| Built via build_esco_index.py. Catches semantic mentions like | |
| "built ETL pipelines" → Apache Airflow / Data Engineering even | |
| when the literal skill name never appears. | |
| 2. Taxonomy keyword match (precision-oriented). | |
| Anchors high-confidence exact matches against | |
| data/skills_taxonomy.txt — guarantees obvious skills like | |
| "Python" or "SQL" never get missed even if ESCO is unavailable. | |
| 3. Legacy TF-IDF classifier (train_skill_model.py). | |
| Only used when the ESCO index has NOT been built yet, so older | |
| deployments keep working until they run build_esco_index.py. | |
| Args: | |
| text : Raw resume or JD text | |
| taxonomy : Override skills list (defaults to loaded taxonomy) | |
| category : Job category hint for the legacy ML model | |
| job_title : Job title hint for the legacy ML model | |
| esco_threshold: Min cosine similarity to keep an ESCO label (0–1) | |
| esco_top_k : Max ESCO labels per call | |
| Returns: | |
| Sorted deduplicated list of skill strings. | |
| """ | |
| if not text: | |
| return [] | |
| # Soft-skill block set. Any label whose lowercase form is in here will be | |
| # rejected by _add() regardless of which layer produced it — this is what | |
| # prevents "Leadership"/"Communication" from leaking out of the taxonomy | |
| # match and into technical_skills. | |
| _soft_blocklist = {s.lower() for s in _SOFT_SKILLS} | |
| if _ESCO_OK and _ESCO_META: | |
| _labels = _ESCO_META.get("labels", []) | |
| for _idx in _ESCO_SOFT_IDX: | |
| if 0 <= _idx < len(_labels): | |
| _soft_blocklist.add(_normalise_skill_label(_labels[_idx]).lower()) | |
| found = {} # lower-case key → display label (preserves first form seen) | |
| def _add(label: str) -> None: | |
| if not label: | |
| return | |
| key = label.lower() | |
| if key in _soft_blocklist: | |
| return | |
| if key not in found: | |
| found[key] = label | |
| # ── Layer 1: ESCO semantic lookup (hard skills only — soft hits are | |
| # routed by transversal flag and consumed by extract_soft_skills) ───────── | |
| if _ESCO_OK: | |
| hard_hits, _soft_hits = _esco_extract_skills(text, | |
| threshold=esco_threshold, | |
| top_k=esco_top_k) | |
| for s in hard_hits: | |
| _add(s) | |
| # ── Layer 2: Taxonomy keyword match (precision floor) ──────────────────── | |
| tax = taxonomy or _SKILLS_TAXONOMY | |
| text_lower = text.lower() | |
| for skill in tax: | |
| pattern = r'\b' + re.escape(skill) + r'\b' | |
| if re.search(pattern, text_lower): | |
| display = skill.upper() if len(skill) <= 3 else skill.title() | |
| _add(display) | |
| # ── Layer 3: Legacy TF-IDF predictions (only if ESCO not yet built) ────── | |
| if not _ESCO_OK and _ML_MODEL_OK: | |
| for s in _ml_predict(text, category=category, job_title=job_title): | |
| _add(s) | |
| return sorted(found.values()) | |
| def extract_soft_skills(text: str) -> list: | |
| """ | |
| Extract soft skills from resume text. | |
| Sources (deduplicated, case-insensitive): | |
| 1. ESCO transversal-skill hits via JobBERT-v2 (data/esco_transversal_uris.txt) | |
| 2. The legacy 20-word _SOFT_SKILLS regex denylist | |
| """ | |
| if not text: | |
| return [] | |
| found: dict[str, str] = {} # lower-case key → display label | |
| def _add(label: str) -> None: | |
| if not label: | |
| return | |
| k = label.lower() | |
| if k not in found: | |
| found[k] = label | |
| # Layer 1: ESCO transversal hits (JobBERT semantic match) | |
| if _ESCO_OK: | |
| _hard, soft_hits = _esco_extract_skills(text) | |
| for s in soft_hits: | |
| _add(s) | |
| # Layer 2: regex denylist (catches obvious surface forms ESCO may miss) | |
| text_lower = text.lower() | |
| for skill in _SOFT_SKILLS: | |
| if re.search(r'\b' + re.escape(skill) + r'\b', text_lower): | |
| _add(skill.title()) | |
| return sorted(found.values()) | |
| # ============================================================================= | |
| # 3. WORK EXPERIENCE | |
| # ============================================================================= | |
| def _parse_date_safe(date_str: str): | |
| """ | |
| Safely parse a date string. Returns datetime or None. | |
| """ | |
| try: | |
| return dateparser.parse(date_str, default=datetime(2000, 1, 1)) | |
| except Exception: | |
| return None | |
| def _merge_date_ranges(ranges: list) -> list: | |
| """ | |
| Given a list of (start_datetime, end_datetime) pairs, sort them by start | |
| date and merge any overlapping or adjacent intervals. | |
| Example: | |
| [(Jan-2020, Jun-2021), (Mar-2021, Dec-2022)] | |
| → [(Jan-2020, Dec-2022)] ← correctly avoids double-counting overlap | |
| Returns the merged list sorted by start date. | |
| """ | |
| if not ranges: | |
| return [] | |
| sorted_r = sorted(ranges, key=lambda x: x[0]) | |
| merged = [list(sorted_r[0])] # work with mutable lists | |
| for start, end in sorted_r[1:]: | |
| prev_start, prev_end = merged[-1] | |
| if start <= prev_end: # overlapping or touching | |
| merged[-1][1] = max(prev_end, end) | |
| else: | |
| merged.append([start, end]) | |
| return [(s, e) for s, e in merged] | |
| # Section-header keyword maps. Used by _build_section_map to label every | |
| # character offset in the resume with the section it belongs to, so date | |
| # ranges can be classified by SECTION (not by ±N-char window, which bleeds | |
| # across section boundaries in dense CVs). | |
| _SECTION_HEADERS = { | |
| "work": [ | |
| "professional experience", "work experience", "employment history", | |
| "career history", "experience", "work history", "professional summary", | |
| "professional history", "employment", "career", | |
| ], | |
| "internship": [ | |
| "internship", "internships", "internship experience", | |
| "summer internship", "summer training", "industrial training", | |
| "training", "apprenticeship", "trainings & internships", | |
| ], | |
| "education": [ | |
| "education", "academic", "qualifications", "academics", | |
| "educational background", "academic background", "academic qualification", | |
| ], | |
| "projects": [ | |
| "projects", "project", "key projects", "academic projects", | |
| "personal projects", "selected projects", | |
| ], | |
| "certifications": [ | |
| "certifications", "certificates", "courses", "credentials", | |
| ], | |
| "achievements": [ | |
| "achievements", "awards", "honors", "honours", "recognitions", | |
| "publications", "patents", | |
| ], | |
| } | |
| # Header line pattern — short, mostly-uppercase or title-case, optionally | |
| # followed by colon or dash. Real-world CV headers are ≤40 chars. | |
| _HEADER_LINE_RE = re.compile( | |
| r'^\s*([A-Z][A-Za-z &/]{2,40})\s*[:\-–—]?\s*$' | |
| ) | |
| def _build_section_map(text: str) -> list: | |
| """ | |
| Walk the resume line-by-line and emit a list of | |
| (char_offset, section_kind) | |
| tuples marking where each section starts. `section_kind` is one of | |
| "work" / "internship" / "education" / "projects" / "certifications" | |
| / "achievements" / "unknown". The list is sorted by offset. | |
| To classify a date range, binary-search this list for the last entry | |
| whose offset is ≤ the range's start — that entry's kind is the section | |
| the range belongs to. | |
| Falls back to "unknown" for content before any header. | |
| """ | |
| sections: list = [(0, "unknown")] | |
| offset = 0 | |
| for raw_line in text.splitlines(keepends=True): | |
| stripped = raw_line.strip() | |
| line_len = len(raw_line) | |
| m = _HEADER_LINE_RE.match(stripped) if stripped else None | |
| if m: | |
| label = m.group(1).strip().lower() | |
| kind = "unknown" | |
| for sect_kind, keywords in _SECTION_HEADERS.items(): | |
| # Match if the header IS one of the keywords (allow short | |
| # extras like "PROFESSIONAL EXPERIENCE & PROJECTS") | |
| if any(label == k or label.startswith(k + " ") | |
| or k in label.split() | |
| for k in keywords): | |
| kind = sect_kind | |
| break | |
| if kind != "unknown": | |
| sections.append((offset, kind)) | |
| offset += line_len | |
| return sections | |
| def _section_at(sections: list, char_offset: int) -> str: | |
| """Return the section kind covering this offset (binary-search-friendly).""" | |
| last = "unknown" | |
| for off, kind in sections: | |
| if off <= char_offset: | |
| last = kind | |
| else: | |
| break | |
| return last | |
| # Conservative fallback regexes — used ONLY when no section header was found | |
| # anywhere in the CV (rare; mostly bullet-only PDFs). They look in a small | |
| # ±80-char window so we don't pull "Trainee" / "Project" from a neighbouring | |
| # section. | |
| _INTERNSHIP_INLINE_RE = re.compile( | |
| r'\b(intern(?:ship)?s?|summer\s+training|industrial\s+training|' | |
| r'apprentice|articled\s+assistant|practicum|co[-\s]?op)\b', | |
| re.IGNORECASE, | |
| ) | |
| _NON_WORK_INLINE_RE = re.compile( | |
| r'\b(bachelor|master|b\.?\s?tech|m\.?\s?tech|b\.?\s?e\.?|m\.?\s?e\.?|' | |
| r'b\.?\s?sc|m\.?\s?sc|mba|pgdm|ph\.?d|diploma|class\s+(?:x|xii|10|12)|' | |
| r'high\s+school|cbse|icse|university|college|institute|school|' | |
| r'certification|certificate|coursera|udemy|nptel|edx|hackerrank|' | |
| r'final[-\s]?year\s+project|capstone|thesis|publication|patent|' | |
| r'hackathon|olympiad)\b', | |
| re.IGNORECASE, | |
| ) | |
| def _classify_date_range(text: str, span_start: int, span_end: int, | |
| sections: list) -> str: | |
| """ | |
| Decide whether a date range belongs to a full-time role, an internship, | |
| or neither (education / project / award). | |
| Strategy: | |
| 1. Look up which SECTION the range falls into using the pre-computed | |
| section map. Sections are derived from real header lines, so this | |
| is dramatically more accurate than a sliding window. | |
| 2. If the section is "unknown" (no header found, or content before | |
| the first header), fall back to a tight inline keyword window. | |
| Returns one of: "work" | "internship" | "non_work" | |
| """ | |
| sect = _section_at(sections, span_start) | |
| if sect == "internship": | |
| return "internship" | |
| if sect in ("education", "projects", "certifications", "achievements"): | |
| return "non_work" | |
| if sect == "work": | |
| return "work" | |
| # Unknown section — use a tight inline window | |
| lo = max(0, span_start - 80) | |
| hi = min(len(text), span_end + 80) | |
| window = text[lo:hi] | |
| if _NON_WORK_INLINE_RE.search(window): | |
| return "non_work" | |
| if _INTERNSHIP_INLINE_RE.search(window): | |
| return "internship" | |
| return "work" | |
| # Master date-range regex — captures ALL of: | |
| # Jan 2020 – Mar 2023 / January 2020 to March 2023 | |
| # Jan'20 – Mar'23 / 01/2020 – 03/2023 | |
| # 01-2020 – 03-2023 / 2020 – 2023 | |
| # May 2021 – Present / Jun 2022 – Current / Aug 2024 – Ongoing | |
| _MONTH_TOKEN = ( | |
| r'(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|' | |
| r'Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|' | |
| r'Nov(?:ember)?|Dec(?:ember)?)' | |
| ) | |
| _DATE_START_TOKEN = ( | |
| rf'(?:{_MONTH_TOKEN}[\s,\.-]*\'?\d{{2,4}}' # Jan 2020 / Jan'20 | |
| rf'|(?:0?[1-9]|1[0-2])[\s/.\-](?:19|20)\d{{2}}' # 01/2020 | |
| rf'|(?:19|20)\d{{2}})' # 2020 | |
| ) | |
| _DATE_END_TOKEN = ( | |
| rf'(?:{_MONTH_TOKEN}[\s,\.-]*\'?\d{{2,4}}' | |
| rf'|(?:0?[1-9]|1[0-2])[\s/.\-](?:19|20)\d{{2}}' | |
| rf'|(?:19|20)\d{{2}}' | |
| rf'|[Pp]resent|[Cc]urrent|[Nn]ow|[Tt]ill\s+[Dd]ate|[Oo]ngoing)' | |
| ) | |
| _DATE_RANGE_RE = re.compile( | |
| rf'({_DATE_START_TOKEN})\s*(?:[-–—]|to|until|till)\s*({_DATE_END_TOKEN})', | |
| re.IGNORECASE, | |
| ) | |
| def _scan_date_ranges(text: str) -> list: | |
| """ | |
| Scan the ENTIRE resume text and return a list of dicts: | |
| {start: datetime, end: datetime, kind: "work"|"internship"|"non_work", span: (lo, hi)} | |
| Each date range is classified by the SECTION header it falls under | |
| (Experience, Internship, Education, Projects, Certifications, …) — not | |
| by a sliding text window — so section boundaries are respected even | |
| when adjacent sections are physically close on the page. | |
| Date ranges shorter than 1 month or longer than 50 years are discarded | |
| as parser noise. Duplicates (same month/year start AND end) are kept | |
| only once so the same line being matched twice doesn't double-count. | |
| """ | |
| now = datetime.now() | |
| sections = _build_section_map(text) | |
| out = [] | |
| seen = set() | |
| for m in _DATE_RANGE_RE.finditer(text): | |
| start_str, end_str = m.group(1).strip(), m.group(2).strip() | |
| start = _parse_date_safe(start_str) | |
| if not start: | |
| continue | |
| if end_str.lower() in ('present', 'current', 'now', 'till date', | |
| 'ongoing'): | |
| end = now | |
| else: | |
| end = _parse_date_safe(end_str) | |
| if not end or end < start: | |
| continue | |
| delta = relativedelta(end, start) | |
| months = delta.years * 12 + delta.months | |
| if not (0 < months < 600): | |
| continue | |
| key = (start.year, start.month, end.year, end.month) | |
| if key in seen: | |
| continue | |
| seen.add(key) | |
| kind = _classify_date_range(text, m.start(), m.end(), sections) | |
| out.append({"start": start, "end": end, "kind": kind, | |
| "span": (m.start(), m.end())}) | |
| return out | |
| def _sum_months(pairs: list) -> int: | |
| """Sum non-overlapping calendar months across (start, end) pairs.""" | |
| merged = _merge_date_ranges(pairs) | |
| total = 0 | |
| for s, e in merged: | |
| d = relativedelta(e, s) | |
| total += d.years * 12 + d.months | |
| return total | |
| def extract_experience(text: str) -> dict: | |
| """ | |
| Extract work-experience details from FULL resume text. | |
| Strategy (no length cap — scans every character of the input): | |
| 1. spaCy ORG entities for company names (kept short-context for speed). | |
| 2. Regex sweep for ALL date ranges in the text, including those buried | |
| several pages deep. | |
| 3. Each date range is classified by surrounding context as | |
| "work" / "internship" / "non_work" (education, project, award, etc.) | |
| using ±180-char keyword windows. This is what lets us separate the | |
| "full-time experience" total from "internship experience" cleanly. | |
| 4. Total work experience = SUM of non-overlapping merged work ranges | |
| (so two overlapping roles don't double-count), with internship and | |
| non-work ranges excluded entirely. | |
| Returns: | |
| { | |
| "total_years" : 4.5, | |
| "internship_years" : 0.5, | |
| "job_titles" : [...], | |
| "companies" : [...], | |
| "avg_tenure_months" : 22.0, | |
| "last_company" : "TCS", | |
| "stability_flag" : False, | |
| } | |
| """ | |
| result = { | |
| "total_years" : 0.0, | |
| "internship_years" : 0.0, | |
| "job_titles" : [], | |
| "companies" : [], | |
| "avg_tenure_months" : 0.0, | |
| "last_company" : "", | |
| "stability_flag" : False, | |
| } | |
| # --- Extract company names via spaCy ORG entities --- | |
| # Companies often appear in the first 1-2 pages, but we scan the first | |
| # 6000 chars (≈2-3 pages) to balance speed and completeness. The LLM | |
| # extractor sees the full text and remains the primary source. | |
| companies = [] | |
| if _SPACY_OK and _NLP: | |
| doc = _NLP(text[:6000]) | |
| for ent in doc.ents: | |
| if ent.label_ == "ORG": | |
| name = ent.text.strip() | |
| if (len(name) >= 2 and | |
| not name.isdigit() and | |
| name not in companies): | |
| companies.append(name) | |
| result["companies"] = companies[:10] | |
| result["last_company"] = companies[0] if companies else "" | |
| # --- Extract job titles via common patterns --- | |
| title_patterns = [ | |
| r'(?i)(senior|junior|lead|principal|associate|staff)?\s*' | |
| r'(software|data|ml|ai|backend|frontend|full.?stack|devops|cloud|' | |
| r'product|project|hr|talent|recruitment|business|marketing|sales|' | |
| r'finance|operations|' | |
| r'analyst|engineer|developer|architect|manager|consultant|' | |
| r'scientist|specialist|coordinator|executive|intern|trainee)', | |
| ] | |
| titles = [] | |
| for pattern in title_patterns: | |
| matches = re.findall(pattern, text) | |
| for match in matches: | |
| title = ' '.join(m for m in match if m).strip().title() | |
| if title and title not in titles and len(title) > 4: | |
| titles.append(title) | |
| result["job_titles"] = titles[:8] | |
| # --- Scan ALL date ranges from the full text and classify each one --- | |
| all_ranges = _scan_date_ranges(text) | |
| work_pairs = [(r["start"], r["end"]) for r in all_ranges | |
| if r["kind"] == "work"] | |
| intern_pairs = [(r["start"], r["end"]) for r in all_ranges | |
| if r["kind"] == "internship"] | |
| if work_pairs: | |
| work_months = _sum_months(work_pairs) | |
| individual_months = [] | |
| for s, e in work_pairs: | |
| d = relativedelta(e, s) | |
| m = d.years * 12 + d.months | |
| if 0 < m < 600: | |
| individual_months.append(m) | |
| avg_months = (sum(individual_months) / len(individual_months) | |
| if individual_months else 0) | |
| result["total_years"] = round(work_months / 12, 1) | |
| result["avg_tenure_months"] = round(avg_months, 1) | |
| result["stability_flag"] = avg_months < config.MIN_TENURE_MONTHS | |
| elif not all_ranges: | |
| # No date ranges anywhere — last-ditch "X years experience" hint | |
| yr_match = re.search( | |
| r'(\d+(?:\.\d+)?)\s*\+?\s*years?\s*(?:of\s*)?(?:experience|exp)', | |
| text, re.IGNORECASE, | |
| ) | |
| if yr_match: | |
| result["total_years"] = float(yr_match.group(1)) | |
| if intern_pairs: | |
| result["internship_years"] = round(_sum_months(intern_pairs) / 12, 1) | |
| return result | |
| # ============================================================================= | |
| # 4. EDUCATION | |
| # ============================================================================= | |
| def extract_education(text: str) -> dict: | |
| """ | |
| Extract highest education qualification from resume text. | |
| Returns: | |
| {"degree": "B.Tech", "field": "Computer Science", "institution": "VIT"} | |
| """ | |
| result = {"degree": "", "field": "", "institution": ""} | |
| text_lower = text.lower() | |
| # Find degree | |
| for deg in sorted(_DEGREE_KEYWORDS, key=len, reverse=True): | |
| if re.search(r'\b' + re.escape(deg) + r'\b', text_lower): | |
| result["degree"] = deg.upper() if len(deg) <= 5 else deg.title() | |
| break | |
| # Find field of study — common patterns after degree keyword | |
| field_match = re.search( | |
| r'(?:b\.?tech|m\.?tech|b\.?sc|m\.?sc|bachelor|master|mba|pgdm)' | |
| r'[,\s]+(?:in\s+)?([a-zA-Z\s&/]{3,40}?)(?:\n|,|\.|from|at|–|-)', | |
| text, re.IGNORECASE | |
| ) | |
| if field_match: | |
| result["field"] = field_match.group(1).strip().title() | |
| # Find institution via spaCy ORG or keyword pattern | |
| inst_match = re.search( | |
| r'\b(?:university|college|institute|iit\s?\w*|nit\s?\w*|bits|iim|vit|' | |
| r'symbiosis|amity|manipal|lpu)\b[a-zA-Z\s,\.]{0,40}', | |
| text, re.IGNORECASE | |
| ) | |
| if inst_match: | |
| result["institution"] = inst_match.group(0).strip().title() | |
| elif _SPACY_OK and _NLP: | |
| # Find ORG entities near degree keywords | |
| edu_section = _find_section(text, ["education", "academic", "qualification"]) | |
| if edu_section: | |
| doc = _NLP(edu_section[:500]) | |
| for ent in doc.ents: | |
| if ent.label_ == "ORG" and len(ent.text) > 3: | |
| result["institution"] = ent.text.strip().title() | |
| break | |
| return result | |
| # ============================================================================= | |
| # 5. PROJECTS, CERTIFICATIONS, HOBBIES | |
| # ============================================================================= | |
| def extract_projects(text: str) -> list: | |
| """ | |
| Extract project names/descriptions from the Projects section. | |
| Returns: | |
| List of project name strings (up to 8). | |
| """ | |
| section = _find_section(text, ["projects", "project work", "key projects"]) | |
| if not section: | |
| return [] | |
| # Each project usually starts with a title on its own line or after a bullet | |
| projects = [] | |
| lines = section.split('\n') | |
| for line in lines: | |
| line = line.strip().lstrip('•–-*►▪').strip() | |
| # Project line: reasonably short, has uppercase, not just a date | |
| if (5 < len(line) < 100 and | |
| not re.match(r'^\d{4}', line) and | |
| any(c.isupper() for c in line)): | |
| projects.append(line) | |
| return projects[:8] | |
| def extract_certifications(text: str) -> list: | |
| """ | |
| Extract certifications from resume text. | |
| Returns: | |
| List of certification name strings (up to 10). | |
| """ | |
| section = _find_section(text, | |
| ["certifications", "certificates", "courses", | |
| "training", "achievements"]) | |
| source = section if section else text | |
| # Common certification keywords | |
| cert_patterns = [ | |
| r'(?:AWS|Azure|GCP|Google|Microsoft|Oracle|Cisco|PMI|PMP|' | |
| r'Scrum|SAFe|ITIL|CompTIA|Coursera|Udemy|edX|NPTEL|' | |
| r'HackerRank|HackerEarth|Certified|Certification)' | |
| r'[a-zA-Z\s\-:®™]{0,60}', | |
| ] | |
| certs = [] | |
| for pattern in cert_patterns: | |
| matches = re.findall(pattern, source) | |
| for m in matches: | |
| m = m.strip().rstrip(':-') | |
| if len(m) > 5 and m not in certs: | |
| certs.append(m) | |
| return certs[:10] | |
| def extract_hobbies(text: str) -> list: | |
| """ | |
| Extract hobbies and interests from resume text. | |
| Returns: | |
| List of hobby strings. | |
| """ | |
| section = _find_section(text, ["hobbies", "interests", "activities", | |
| "extra-curricular", "extracurricular"]) | |
| if not section: | |
| return [] | |
| hobbies = [] | |
| for line in section.split('\n'): | |
| line = line.strip().lstrip('•–-*').strip() | |
| if 2 < len(line) < 60: | |
| # Split by commas too (e.g. "Reading, Music, Cricket") | |
| parts = [p.strip() for p in line.split(',')] | |
| for p in parts: | |
| if p and p not in hobbies: | |
| hobbies.append(p.title()) | |
| return hobbies[:10] | |
| # ============================================================================= | |
| # 6. RECENCY FLAG | |
| # ============================================================================= | |
| def extract_recency(text: str, skills: list) -> str: | |
| """ | |
| Determine how recently the candidate used their key skills. | |
| Logic: | |
| - Find the most recent employment date in the resume | |
| - If last job ended within RECENCY_STALE_YEARS → "Recent" | |
| - If last job ended more than RECENCY_STALE_YEARS ago → "Stale" | |
| - If no dates found → "Unknown" | |
| Args: | |
| text: Raw resume text | |
| skills: Extracted skills list (unused for now, reserved for future) | |
| Returns: | |
| "Recent" | "Stale" | "Unknown" | |
| """ | |
| now = datetime.now() | |
| most_recent = None | |
| # Look for "Present" / "Current" — candidate is currently employed | |
| if re.search(r'\b(?:present|current|now)\b', text, re.IGNORECASE): | |
| return "Recent" | |
| # Find all 4-digit years mentioned in the resume | |
| years = re.findall(r'\b(20\d{2})\b', text) | |
| if years: | |
| latest_year = max(int(y) for y in years) | |
| years_ago = now.year - latest_year | |
| if years_ago <= config.RECENCY_STALE_YEARS: | |
| return "Recent" | |
| else: | |
| return "Stale" | |
| return "Unknown" | |
| # ============================================================================= | |
| # INTERNAL HELPER — Section Finder | |
| # ============================================================================= | |
| def _find_section(text: str, keywords: list) -> str: | |
| """ | |
| Find a named section in a resume and return its content. | |
| Looks for lines that match section header keywords, then returns | |
| everything until the next section header (all-caps or title-case line). | |
| Args: | |
| text: Full resume text | |
| keywords: List of keywords to identify the section | |
| Returns: | |
| Section content string, or empty string if not found. | |
| """ | |
| lines = text.split('\n') | |
| section_lines = [] | |
| in_section = False | |
| # Section header pattern — all caps or title case, short line | |
| header_pattern = re.compile(r'^[A-Z][A-Z\s&/]{2,30}$') | |
| for i, line in enumerate(lines): | |
| stripped = line.strip() | |
| # Detect section start | |
| if any(kw.lower() in stripped.lower() for kw in keywords): | |
| in_section = True | |
| continue | |
| if in_section: | |
| # Stop at next section header | |
| if (header_pattern.match(stripped) and | |
| len(stripped) > 3 and | |
| stripped not in ['AND', 'OR', 'THE']): | |
| break | |
| section_lines.append(line) | |
| return '\n'.join(section_lines).strip() | |
| # ============================================================================= | |
| # LLM EXTRACTION — Primary method for field labelling | |
| # ============================================================================= | |
| # Uses Groq (free) or OpenAI to intelligently label every field from raw text. | |
| # Falls back to regex-based extraction when no API key is available. | |
| # LLM extraction is significantly more reliable because it understands context — | |
| # it can tell "Python" in "fluent in Python and Java" is a skill, and | |
| # "Python" in "born in Python, Mississippi" is not. | |
| _LLM_EXTRACTOR_OK = False | |
| _llm_extractor_client = None | |
| _llm_extractor_model = "" | |
| def user_config_dir() -> str: | |
| """ | |
| OS-standard per-user config directory for keys. | |
| Windows: %LOCALAPPDATA%\\ResumeAnalyzer | |
| macOS : ~/Library/Application Support/ResumeAnalyzer | |
| Linux : $XDG_CONFIG_HOME/resume_analyzer (or ~/.config/resume_analyzer) | |
| """ | |
| if sys.platform == "win32": | |
| base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~") | |
| return os.path.join(base, "ResumeAnalyzer") | |
| if sys.platform == "darwin": | |
| return os.path.expanduser("~/Library/Application Support/ResumeAnalyzer") | |
| base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config") | |
| return os.path.join(base, "resume_analyzer") | |
| def user_env_path() -> str: | |
| return os.path.join(user_config_dir(), ".env") | |
| def _load_user_api_keys() -> None: | |
| """ | |
| Read API keys from the per-user .env file (if it exists) and inject them | |
| into os.environ — but ONLY for variables not already set. Environment | |
| variables always win, so devs running locally keep using their shell env. | |
| """ | |
| path = user_env_path() | |
| if not os.path.exists(path): | |
| return | |
| try: | |
| with open(path, encoding="utf-8") as f: | |
| for line in f: | |
| line = line.strip() | |
| if not line or line.startswith("#") or "=" not in line: | |
| continue | |
| key, _, val = line.partition("=") | |
| key = key.strip() | |
| val = val.strip().strip('"').strip("'") | |
| if key and val and key not in os.environ: | |
| os.environ[key] = val | |
| except Exception as exc: | |
| print(f"[Extractor] couldn't read {path}: {type(exc).__name__}: {exc}") | |
| def _init_llm_extractor(): | |
| """ | |
| Initialise LLM client for extraction. Priority order: | |
| 1. Anthropic Claude (best quality — install: pip install anthropic) | |
| 2. Groq via OpenAI-compatible API (openai package already installed) | |
| 3. OpenAI directly | |
| """ | |
| global _LLM_EXTRACTOR_OK, _llm_extractor_client, _llm_extractor_model | |
| # User-config fallback: when the app is installed via the Windows installer | |
| # there's no project-level .env. The Settings UI writes per-user keys to | |
| # the OS's standard user-config directory; honour those if the environment | |
| # doesn't already define them. | |
| _load_user_api_keys() | |
| # ── Option 1: Anthropic Claude (best at understanding context) ──────────── | |
| anthropic_key = os.environ.get("ANTHROPIC_API_KEY", "") | |
| if anthropic_key: | |
| try: | |
| import anthropic as _anthropic | |
| _llm_extractor_client = _anthropic.Anthropic(api_key=anthropic_key) | |
| _llm_extractor_model = "claude-haiku-4-5-20251001" | |
| _LLM_EXTRACTOR_OK = True | |
| print("[Extractor] LLM: Anthropic Claude (claude-haiku-4-5)") | |
| return | |
| except ImportError: | |
| print("[Extractor] anthropic package not installed — pip install anthropic") | |
| # ── Option 2: Groq via OpenAI-compatible API (no groq package needed) ──── | |
| groq_key = os.environ.get("GROQ_API_KEY", "") | |
| if groq_key: | |
| try: | |
| from openai import OpenAI | |
| _llm_extractor_client = OpenAI( | |
| api_key = groq_key, | |
| base_url = "https://api.groq.com/openai/v1", | |
| ) | |
| _llm_extractor_model = "llama-3.3-70b-versatile" | |
| _LLM_EXTRACTOR_OK = True | |
| print("[Extractor] LLM: Groq llama-3.3-70b-versatile (OpenAI-compat)") | |
| return | |
| except ImportError: | |
| print("[Extractor] openai package not installed") | |
| # ── Option 3: OpenAI directly ───────────────────────────────────────────── | |
| openai_key = os.environ.get("OPENAI_API_KEY", "") | |
| if openai_key: | |
| try: | |
| from openai import OpenAI | |
| _llm_extractor_client = OpenAI(api_key=openai_key) | |
| _llm_extractor_model = "gpt-4o-mini" | |
| _LLM_EXTRACTOR_OK = True | |
| print("[Extractor] LLM: OpenAI gpt-4o-mini") | |
| return | |
| except ImportError: | |
| pass | |
| print("[Extractor] WARNING: No LLM API key found — falling back to regex extraction only.") | |
| print(" Set GROQ_API_KEY in .env for free LLM-powered extraction.") | |
| _init_llm_extractor() | |
| _LLM_EXTRACTION_PROMPT = """Extract structured information from this resume text. | |
| Return ONLY valid JSON. No explanation, no markdown fences. | |
| Extract these fields: | |
| - candidate_name: The person's full name ONLY (given name + surname). Must be a real person's name found in the first 3 lines. NOT a job title (e.g. NOT "Financial Controller.txt"), NOT a location (e.g. NOT "Delhi NCR"), NOT a company name. (string) | |
| - email: Email address (string or "") | |
| - phone: Phone number digits only (string or "") | |
| - age: Age in years if mentioned, else 0 (number) | |
| - location: City and state/country if mentioned (string or "") | |
| - linkedin_url: LinkedIn profile URL if present (string or "") | |
| - github_url: GitHub profile URL if present, e.g. github.com/username (string or "") | |
| - portfolio_url: Personal portfolio / personal website URL if present (must NOT be linkedin.com or github.com — those go in their own fields) (string or "") | |
| - resume_summary: Write a 2-3 sentence professional summary of this candidate based on their overall profile. Include their years of experience, domain, strongest skills, and any standout achievement. (string) | |
| - technical_skills: List of technical/hard skills only (list of strings) | |
| - soft_skills: List of interpersonal/behavioural skills (list of strings) | |
| - languages: List of spoken/written languages the candidate knows (list of strings) | |
| - total_experience_years: Total years of full-time work experience, NOT counting internships (number) | |
| - job_titles: All full-time job titles held, most recent first (list of strings) | |
| - companies: List of REAL employer organisation names ONLY — i.e. the legally-registered entity the candidate was paid by. Each entry MUST look like a company brand. | |
| ✓ GOOD: "Infosys", "HDFC Bank", "Groz Engineering Tools Pvt. Ltd.", "Tata Consultancy Services", "Indian Armour Systems Pvt. Ltd." | |
| ✗ BAD — NEVER include any of these as a "company": | |
| - Cities/regions: "Delhi-NCR", "Mumbai", "Bangalore", "Gurgaon" | |
| - Job functions/departments: "Operations", "Insurance Operations", "Credit Management", "Business Analysis", "Operations Management", "Treasury & Cash Management" | |
| - Section headers from the resume: "EXPERIENCE", "PROFESSIONAL EXPERIENCE", "Work History" | |
| - Bullet-point fragments: "• Executed daily reviews", "Handled client queries", "Led a team of 5" | |
| - Acronyms-only: "KPI", "API", "PROGRAM &" | |
| (list of strings) | |
| - last_company: The MOST RECENT employer's organisation name ONLY. MUST be a real registered company (e.g. "HDFC Bank", "Infosys", "Groz Engineering Tools Pvt. Ltd."). MUST NOT be a city, department, job function, or section header. If you cannot identify a clear company name, return "". (string or "") | |
| - company_tenures: For every full-time role (NOT internships, NOT projects), one entry formatted EXACTLY as: "Company Name | Role Title | Start – End". Use full month names + 4-digit years when the resume gives them (e.g. "May 2021 – March 2023"); use year-only when months are not stated (e.g. "2018 – 2021"). Use the literal word "Present" for current/ongoing roles — do NOT compute or substitute the current year, the application will handle that. Example outputs: "Groz Engineering Tools Pvt. Ltd. | Production Engineer | Oct 2014 – Feb 2017", "HDFC Bank | Branch Manager | 2018 – 2021", "TCS | Senior Data Analyst | Jun 2022 – Present". Most recent role first. Company name MUST NOT include the city. (list of strings) | |
| - avg_tenure_months: Average months per full-time role (number or 0) | |
| - internships: List of internship descriptions — each as "Company | Role | Duration" (list of strings) | |
| - internship_count: Number of internships (number) | |
| - education_degree: Highest degree e.g. B.Tech, MBA, M.Tech (string or "") | |
| - education_field: Field of study e.g. Computer Science (string or "") | |
| - education_institution: University or college name (string or "") | |
| - graduation_year: Year of most recent graduation (number or 0) | |
| - certifications: List of certification names (list of strings) | |
| - projects: TECHNICAL PROJECTS the candidate personally built/designed/researched. A project is a tangible deliverable like an app, model, dashboard, system, or research study. STRICT RULES: (a) NEVER list employers/companies here — those belong in `companies`. (b) NEVER list job descriptions or work-history bullets here. (c) Each entry must look like a project title with an optional description, e.g. "Sales Revenue Dashboard — Automated monthly reporting using Python" or "Customer Churn Prediction Model — Reduced churn by 12%". If the resume has no clearly-labelled Projects section, return an empty list []. (list of strings) | |
| - achievements: List of awards, recognitions, hackathon wins, publications (list of strings) | |
| - hobbies: List of hobbies and interests (list of strings) | |
| - recency_flag: "Recent" if currently employed or worked within 2 years, "Stale" if last job was more than 2 years ago, "Unknown" if unclear | |
| - pros: Top 3 strengths of this candidate as a recruiter would see them (list of 3 strings) | |
| - cons: Top 3 weaknesses or gaps in this resume (list of 3 strings) | |
| Resume text: | |
| {resume_text} | |
| Return ONLY the JSON object. Every field must be present — use "" or [] or 0 for missing values.""" | |
| def _extract_via_llm(raw_text: str) -> dict: | |
| """ | |
| Use LLM to extract all structured fields from resume text. | |
| Returns a flat dict with all fields, or empty dict on failure. | |
| The caller merges this with regex-based fallbacks for any missing fields. | |
| """ | |
| if not _LLM_EXTRACTOR_OK or not _llm_extractor_client: | |
| return {} | |
| # Send the FULL resume text — every modern LLM we use (Claude haiku-4.5 | |
| # ≥200k, Groq llama-3.3-70b ≥128k, gpt-4o-mini ≥128k) has plenty of headroom | |
| # for even a 10-page CV. The previous 6 000-char truncation was the main | |
| # reason late-page employers (page 3+) were silently dropped from | |
| # company_tenures, which broke total-experience accuracy. | |
| # Cap at 60 000 chars (~15 k tokens) as a safety belt for pathological CVs. | |
| text_snippet = raw_text[:60000].strip() | |
| # Retry transient errors (rate limits, brief network blips). For | |
| # per-day token-cap rate limits, falling back to a smaller Groq model | |
| # (8B instant) keeps the pipeline producing usable extractions instead | |
| # of silently giving up on the LLM entirely. | |
| last_exc = None | |
| raw = "" | |
| fallback_models = ( | |
| [_llm_extractor_model, "llama-3.1-8b-instant"] | |
| if "llama-3.3-70b" in _llm_extractor_model | |
| else [_llm_extractor_model] | |
| ) | |
| for model_name in fallback_models: | |
| # The 8B fallback has only ~8k token context — truncate the resume | |
| # text much harder when we're using it, otherwise we get 413s. | |
| if "8b" in model_name.lower(): | |
| snippet_for_model = text_snippet[:12000] | |
| else: | |
| snippet_for_model = text_snippet | |
| prompt = _LLM_EXTRACTION_PROMPT.format(resume_text=snippet_for_model) | |
| for attempt in range(3): | |
| try: | |
| # Anthropic client has a different API surface | |
| if hasattr(_llm_extractor_client, "messages"): | |
| response = _llm_extractor_client.messages.create( | |
| model = model_name, | |
| max_tokens = 3000, | |
| messages = [{"role": "user", "content": prompt}], | |
| ) | |
| raw = response.content[0].text.strip() | |
| else: | |
| response = _llm_extractor_client.chat.completions.create( | |
| model = model_name, | |
| messages = [{"role": "user", "content": prompt}], | |
| max_tokens = 3000, | |
| temperature = 0.1, | |
| ) | |
| raw = response.choices[0].message.content.strip() | |
| break # success | |
| except Exception as exc: | |
| msg = str(exc) | |
| last_exc = exc | |
| # On daily-quota rate-limits, don't waste retries on the same | |
| # model — break out to the fallback model immediately. | |
| if "tokens per day" in msg or "TPD" in msg: | |
| break | |
| # Per-minute rate-limit: short backoff and retry | |
| wait = 2 ** attempt | |
| m = re.search(r"try again in\s+([\d.]+)s", msg, re.I) | |
| if m: | |
| wait = min(30, float(m.group(1)) + 1.0) | |
| time.sleep(wait) | |
| if raw: | |
| break # found a working model | |
| if not raw: | |
| raise (last_exc or RuntimeError("LLM call failed")) | |
| try: | |
| # Strip accidental markdown fences | |
| raw = re.sub(r"^```(?:json)?|```$", "", raw, flags=re.MULTILINE).strip() | |
| parsed = json.loads(raw) | |
| if not isinstance(parsed, dict): | |
| return {} | |
| # Normalise list fields | |
| list_fields = [ | |
| "technical_skills", "soft_skills", "languages", | |
| "job_titles", "companies", "certifications", | |
| "projects", "hobbies", "pros", "cons", | |
| "internships", "achievements", "company_tenures", | |
| ] | |
| for field in list_fields: | |
| val = parsed.get(field, []) | |
| if not isinstance(val, list): | |
| parsed[field] = [str(val)] if val else [] | |
| return parsed | |
| except json.JSONDecodeError as e: | |
| print(f"[Extractor] LLM returned invalid JSON: {e}") | |
| return {} | |
| except Exception as e: | |
| print(f"[Extractor] LLM extraction failed: {type(e).__name__}: {e}") | |
| return {} | |
| # ============================================================================= | |
| # MASTER FUNCTION — Call This From main.py | |
| # ============================================================================= | |
| def build_resume_record(file_name: str, raw_text: str) -> dict: | |
| """ | |
| Build a complete, DB-ready resume record from raw text. | |
| EXTRACTION STRATEGY — two-pass, best-of-both: | |
| Pass 1 — LLM extraction (Groq or OpenAI): | |
| Sends raw text to an LLM with a structured prompt. | |
| Returns a labelled JSON dict for all fields. | |
| Reliable because LLMs understand context — they can distinguish | |
| "Python (skill)" from "Python (place name)" and correctly identify | |
| which lines are education vs experience vs projects. | |
| Pass 2 — Regex/rule-based fallback: | |
| Runs for any field that LLM left empty or returned invalid data for. | |
| Uses the existing regex extractors as safety net. | |
| Always runs for: skills taxonomy matching (expands LLM skill list), | |
| contact info (email/phone regex is very reliable). | |
| The final record merges both passes — LLM fields take priority, | |
| regex fills gaps. | |
| Args: | |
| file_name : Original filename e.g. "priya_sharma.pdf" | |
| raw_text : Cleaned text from parser.extract_text() | |
| Returns: | |
| Dict matching resumes table schema, ready for DB insert. | |
| All list fields are JSON strings. | |
| """ | |
| # ── Pass 1: LLM extraction ──────────────────────────────────────────────── | |
| llm = _extract_via_llm(raw_text) | |
| # ── Pass 2: Regex fallbacks ─────────────────────────────────────────────── | |
| name_regex = extract_candidate_name(raw_text, file_name) | |
| contact = extract_contact(raw_text) | |
| url_bundle = extract_urls(raw_text) | |
| skills_regex = extract_skills(raw_text) # taxonomy-matched skills | |
| soft_regex = extract_soft_skills(raw_text) | |
| exp_regex = extract_experience(raw_text) | |
| edu_regex = extract_education(raw_text) | |
| projects_regex = extract_projects(raw_text) | |
| certs_regex = extract_certifications(raw_text) | |
| hobbies_regex = extract_hobbies(raw_text) | |
| recency_regex = extract_recency(raw_text, skills_regex) | |
| # ── Merge: LLM takes priority, regex fills gaps ─────────────────────────── | |
| # Identity — name needs careful handling: | |
| # If the filename gives a clean ≥2-word name (e.g. "Gaurav Joshi" from | |
| # "Gaurav_Joshi_Resume pdf.pdf"), that is more reliable than an LLM | |
| # extraction that may read garbled OCR text (e.g. "AURAV OSHI"). | |
| # Rule: prefer filename when it gives ≥2 words AND LLM gives ≤ same words. | |
| # Only let LLM override when it gives strictly MORE words (i.e. a more complete name). | |
| _llm_name = (llm.get("candidate_name") or "").strip() | |
| _filename_name = _name_from_filename(file_name) | |
| _llm_words = len(_llm_name.split()) if _llm_name != "" else 0 | |
| _file_words = len(_filename_name.split()) if _filename_name != "Unknown" else 0 | |
| if _file_words >= 2 and _llm_words <= _file_words: | |
| # Filename gives a solid 2+ word name and LLM gives same or fewer — | |
| # trust the filename (immune to OCR errors in PDF text) | |
| name = _filename_name | |
| elif _llm_name: | |
| # LLM gives more words (more complete) or filename only has 1 word | |
| name = _llm_name | |
| else: | |
| name = name_regex # spaCy / line-scan fallback | |
| age = int(llm.get("age") or 0) | |
| location = (llm.get("location") or "").strip() | |
| # If LLM didn't find a location, fall back to Indian-city database match | |
| if not location: | |
| location = _extract_location_from_text(raw_text) | |
| # URLs — LLM gives only linkedin_url; the regex bundle adds github, | |
| # portfolio and any other clickable URLs found anywhere in the CV. | |
| linkedin = (llm.get("linkedin_url") or "").strip() or url_bundle["linkedin_url"] | |
| github_url = (llm.get("github_url") or "").strip() or url_bundle["github_url"] | |
| portfolio_url = (llm.get("portfolio_url")or "").strip() or url_bundle["portfolio_url"] | |
| other_urls = url_bundle["other_urls"] | |
| # Summary — LLM only (needs full context understanding) | |
| resume_summary = (llm.get("resume_summary") or "").strip() | |
| # Skills — three-layer routing: | |
| # 1. LLM overrides (data/skill_softness_overrides.csv) are authoritative | |
| # for any label they cover. These are recruiter-context labels | |
| # assigned by llama-3.3-70b and (optionally) human-reviewed. | |
| # 2. Rule-based fallback (regex denylist + ESCO transversal lookup) for | |
| # labels not in the overrides. | |
| # 3. Subtractive merge below guarantees no skill appears in both lists. | |
| # The legacy JobBERT softness classifier is loaded but only consulted | |
| # when config.USE_SOFTNESS_CLASSIFIER = True (off by default; the LLM | |
| # overrides are usually better). | |
| llm_skills = [s.strip() for s in llm.get("technical_skills", []) if s.strip()] | |
| llm_soft = [s.strip() for s in llm.get("soft_skills", []) if s.strip()] | |
| rule_soft = list({s.lower(): s for s in soft_regex + llm_soft}.values()) | |
| rule_hard = list({s.lower(): s for s in skills_regex + llm_skills}.values()) | |
| # Apply LLM overrides: pull anything marked soft out of rule_hard, push | |
| # anything marked hard out of rule_soft. "language" and "other" labels | |
| # drop out of the skill lists entirely (they belong in `languages` / | |
| # nowhere). | |
| soft_final: list[str] = [] | |
| hard_final: list[str] = [] | |
| for s in rule_hard: | |
| ov = _SKILL_OVERRIDES.get(s.lower()) | |
| if ov: | |
| if ov["category"] == "soft": soft_final.append(s) | |
| elif ov["category"] == "hard": hard_final.append(s) | |
| # "language" / "other" → discard | |
| else: | |
| hard_final.append(s) | |
| for s in rule_soft: | |
| ov = _SKILL_OVERRIDES.get(s.lower()) | |
| if ov: | |
| if ov["category"] == "soft": soft_final.append(s) | |
| elif ov["category"] == "hard": hard_final.append(s) | |
| else: | |
| soft_final.append(s) | |
| # Optional in-flight classifier promotion for OOV labels. | |
| soft_keys = {s.lower() for s in soft_final} | |
| if _SOFTNESS_CLF is not None and getattr(config, "USE_SOFTNESS_CLASSIFIER", False): | |
| uncovered = [s for s in hard_final | |
| if s.lower() not in _SKILL_OVERRIDES | |
| and s.lower() not in soft_keys] | |
| if uncovered: | |
| for label, p in _classify_softness(uncovered).items(): | |
| if p >= _SOFTNESS_THRESHOLD: | |
| soft_final.append(label) | |
| soft_keys.add(label.lower()) | |
| merged_soft = list({s.lower(): s for s in soft_final}.values()) | |
| merged_skills = list({s.lower(): s for s in hard_final | |
| if s.lower() not in soft_keys}.values()) | |
| # Languages | |
| languages = [s.strip() for s in llm.get("languages", []) if s.strip()] | |
| # Full-time experience. | |
| # The regex scanner runs across the FULL resume text and now classifies | |
| # every date range as "work" / "internship" / "non_work" (education, | |
| # project, award …) using the surrounding context. That gives us a clean | |
| # split between full-time experience and internship time, computed | |
| # against datetime.now() (pipeline-run date) rather than the stale | |
| # "X years experience" string the candidate typed at CV-write time. | |
| regex_total_years = float(exp_regex["total_years"] or 0) | |
| regex_intern_yrs = float(exp_regex["internship_years"] or 0) | |
| total_years = (regex_total_years if regex_total_years > 0 | |
| else float(llm.get("total_experience_years") or 0)) | |
| internship_years = regex_intern_yrs # always trust regex split; LLM has no | |
| # comparable computed field | |
| job_titles = llm.get("job_titles", []) or exp_regex["job_titles"] | |
| companies = llm.get("companies", []) or exp_regex["companies"] | |
| # Scrub LLM hallucinations: remove location-only entries and trailing cities | |
| companies = [c for c in (_clean_company_name(c) for c in companies) if c] | |
| # last_company: try LLM first, then spaCy regex, then first cleaned company | |
| _lc_raw = (llm.get("last_company") or "").strip() | |
| if not _lc_raw: | |
| _lc_raw = exp_regex.get("last_company", "") | |
| last_company = _clean_company_name(_lc_raw) | |
| # If cleaning rejected it (location/function/garbage), fall back to the | |
| # first valid entry in the cleaned companies list. | |
| if not last_company: | |
| # Walk the companies list in order (most-recent-first from LLM) and | |
| # use the first one that is different from the rejected last_company | |
| for co in companies: | |
| if co and co != _lc_raw: | |
| last_company = co | |
| break | |
| avg_tenure = float(llm.get("avg_tenure_months") or | |
| exp_regex["avg_tenure_months"] or 0) | |
| # Internships (separate from full-time experience) | |
| internships = [s.strip() for s in llm.get("internships", []) if s.strip()] | |
| internship_count = int(llm.get("internship_count") or len(internships)) | |
| # Per-company tenure list — "Company | Role | Start – End" | |
| company_tenures = [s.strip() for s in llm.get("company_tenures", []) if s.strip()] | |
| # Regex fallback: LLM often skips this field (token budget / truncation); | |
| # build tenure entries from the raw text when the LLM returns nothing. | |
| if not company_tenures and companies: | |
| company_tenures = _build_tenures_from_text(raw_text, companies) | |
| # Anchor experience years to the pipeline-run date (NOT the CV write date). | |
| # We have two independent sources of truth: | |
| # (a) regex_total_years — full-CV date-range scan with internship-context | |
| # exclusion (set above). | |
| # (b) tenure_years — derived from the LLM's structured | |
| # company_tenures list, which can pick up roles the regex missed | |
| # because the start/end dates were on a different line from the | |
| # company name. | |
| # Take the MAX of the two so we never under-count when one source has a | |
| # blind spot. The exception is when regex strictly excludes a range as | |
| # internship — in that case we still want to trust the regex split. | |
| tenure_years = _years_from_tenures(company_tenures) | |
| if tenure_years > total_years: | |
| total_years = tenure_years | |
| # Education | |
| edu_degree = (llm.get("education_degree") or "").strip() or edu_regex.get("degree", "") | |
| edu_field = (llm.get("education_field") or "").strip() or edu_regex.get("field", "") | |
| edu_inst = (llm.get("education_institution") or "").strip() or edu_regex.get("institution", "") | |
| graduation_year = int(llm.get("graduation_year") or 0) | |
| education = { | |
| "degree" : edu_degree, | |
| "field" : edu_field, | |
| "institution" : edu_inst, | |
| "graduation_year": graduation_year, | |
| } | |
| # Projects, certifications, achievements, hobbies. | |
| # Trust the LLM's empty list when it ran — the prompt is explicit that an | |
| # absent "Projects" section in the CV must return []. Falling back to the | |
| # heuristic regex would invent project entries from random work bullets, | |
| # which is exactly what we want to avoid. | |
| if _LLM_EXTRACTOR_OK and "projects" in llm: | |
| projects = llm.get("projects") or [] | |
| else: | |
| projects = projects_regex | |
| # Scrub: drop any "project" that's actually a company-name/work-history leak | |
| projects = _filter_project_entries(projects, companies) | |
| certs = llm.get("certifications",[]) or certs_regex | |
| achievements = [s.strip() for s in llm.get("achievements", []) if s.strip()] | |
| hobbies = llm.get("hobbies", []) or hobbies_regex | |
| # Recency | |
| recency = (llm.get("recency_flag") or "").strip().title() | |
| if recency not in ("Recent", "Stale", "Unknown"): | |
| recency = recency_regex | |
| pros = [s.strip() for s in llm.get("pros", []) if s.strip()] | |
| cons = [s.strip() for s in llm.get("cons", []) if s.strip()] | |
| # Stability flag | |
| stability_flag = (avg_tenure < config.MIN_TENURE_MONTHS | |
| if avg_tenure > 0 else False) | |
| # ─── Rule-based fallback when LLM is unavailable (rate-limit / error) ────── | |
| # Each list is handled independently so that a strong candidate (no cons) | |
| # and a weak candidate (no pros) are both covered correctly. | |
| skill_count = len(merged_skills) | |
| if not pros: | |
| _pros = [] | |
| # Experience depth | |
| if total_years >= 5: | |
| _pros.append(f"{total_years:.0f}+ years of professional experience") | |
| elif total_years >= 2: | |
| _pros.append(f"{total_years:.0f} years of relevant experience") | |
| elif total_years >= 1: | |
| _pros.append(f"{total_years:.0f} year of work experience") | |
| # Skills breadth | |
| if skill_count >= 10: | |
| _pros.append(f"Strong technical breadth — {skill_count} skills detected") | |
| elif skill_count >= 5: | |
| _pros.append(f"{skill_count} technical skills identified") | |
| elif skill_count >= 1: | |
| _pros.append(f"{skill_count} technical skill(s) listed") | |
| # Recency | |
| if recency == "Recent": | |
| _pros.append("Currently employed / recently active") | |
| # Stable career | |
| if avg_tenure >= 18: | |
| _pros.append(f"Stable job history (avg {avg_tenure:.0f} months per role)") | |
| # Certifications | |
| if len(certs) >= 2: | |
| _pros.append(f"{len(certs)} certifications on record") | |
| elif len(certs) == 1: | |
| _pros.append("Holds a professional certification") | |
| # Education | |
| if edu_degree: | |
| _pros.append("Holds a " + edu_degree + (f" in {edu_field}" if edu_field else "")) | |
| # Catch-all — always ensure at least one entry | |
| if not _pros: | |
| _pros.append("Resume received — limited extractable data; manual review recommended") | |
| pros = _pros | |
| if not cons: | |
| _cons = [] | |
| # Experience gap | |
| if total_years == 0: | |
| _cons.append("No quantifiable work experience detected in resume") | |
| elif total_years < 2: | |
| _cons.append(f"Limited work experience ({total_years:.1f} yrs) — under typical threshold") | |
| # Skills gap | |
| if skill_count < 3: | |
| _cons.append("Very few technical skills detected — resume may need more detail") | |
| # Job hopping | |
| if stability_flag: | |
| _cons.append(f"Frequent job changes detected (avg {avg_tenure:.0f} months per role)") | |
| # Stale / inactive | |
| if recency == "Stale": | |
| _cons.append("Employment gap or outdated recent role detected") | |
| # No certifications for junior profiles | |
| if not certs and total_years < 3: | |
| _cons.append("No professional certifications listed") | |
| # Catch-all — strong candidates genuinely have no concerns | |
| if not _cons: | |
| _cons.append("No significant concerns identified from extracted resume data") | |
| cons = _cons | |
| # ─── Rule-based summary fallback ───────────────────────────────────────── | |
| if not resume_summary: | |
| _parts = [] | |
| if name and name != "Unknown": | |
| _parts.append(f"{name} is a") | |
| else: | |
| _parts.append("Candidate is a") | |
| if total_years >= 1: | |
| _parts.append(f"{total_years:.0f}-year professional") | |
| else: | |
| _parts.append("professional") | |
| if edu_degree: | |
| _parts.append(f"holding a {edu_degree}" + (f" in {edu_field}" if edu_field else "")) | |
| if merged_skills: | |
| top_skills = merged_skills[:4] | |
| _parts.append(f"with skills in {', '.join(top_skills)}") | |
| if last_company: | |
| _parts.append(f"(most recently at {last_company})") | |
| resume_summary = " ".join(_parts).strip().rstrip(",") + "." | |
| # Capitalise first letter | |
| if resume_summary: | |
| resume_summary = resume_summary[0].upper() + resume_summary[1:] | |
| return { | |
| # Identity | |
| "file_name" : file_name, | |
| "candidate_name" : name, | |
| "email" : contact["email"], | |
| "phone" : contact["phone"], | |
| "age" : age, | |
| "location" : location, | |
| "linkedin_url" : linkedin, | |
| "github_url" : github_url, | |
| "portfolio_url" : portfolio_url, | |
| "other_urls" : json.dumps(other_urls), | |
| # Summary | |
| "resume_summary" : resume_summary, | |
| # Raw text (kept for semantic matching) | |
| "resume_text" : raw_text, | |
| # Skills | |
| "skills" : json.dumps(merged_skills), | |
| "soft_skills" : json.dumps(merged_soft), | |
| "languages" : json.dumps(languages), | |
| # Full-time experience | |
| "experience_years" : total_years, | |
| "last_company" : last_company, | |
| "job_titles" : json.dumps(job_titles), | |
| "companies" : json.dumps(companies), | |
| "avg_tenure_months" : avg_tenure, | |
| # Internships — kept separately so they never inflate the work-exp total | |
| "internships" : json.dumps(internships), | |
| "internship_count" : internship_count, | |
| "internship_years" : internship_years, | |
| # Per-company tenure: ["Company | Role | 18 months", …] | |
| "company_tenures" : json.dumps(company_tenures), | |
| # Education | |
| "education" : json.dumps(education), | |
| "graduation_year" : graduation_year, | |
| # Other | |
| "certifications" : json.dumps(certs), | |
| "projects" : json.dumps(projects), | |
| "achievements" : json.dumps(achievements), | |
| "hobbies" : json.dumps(hobbies), | |
| # Scoring inputs (used by matcher.py) | |
| "recency_flag" : recency, | |
| "stability_flag" : stability_flag, | |
| # Batch | |
| "upload_batch" : datetime.now().strftime("%Y%m%d_%H%M%S"), | |
| "technical_skills": ", ".join(merged_skills), # was "skills" (JSON) | |
| "total_experience_years": total_years, # was "experience_years" | |
| "education_degree": edu_degree, | |
| "education_field": edu_field, | |
| "education_institution": edu_inst, | |
| "pros" : json.dumps(pros), | |
| "cons" : json.dumps(cons), | |
| } | |
| # ============================================================================= | |
| # SELF-TEST | |
| # Run directly: python engine/extractor.py | |
| # ============================================================================= | |
| if __name__ == "__main__": | |
| GREEN = "\033[92m" | |
| RED = "\033[91m" | |
| YELLOW = "\033[93m" | |
| BOLD = "\033[1m" | |
| RESET = "\033[0m" | |
| print(f"\n{BOLD}{'='*60}{RESET}") | |
| print(f"{BOLD} EXTRACTOR SELF-TEST{RESET}") | |
| print(f"{BOLD}{'='*60}{RESET}\n") | |
| # Check spaCy | |
| if _SPACY_OK: | |
| print(f" {GREEN}✓{RESET} spaCy loaded: en_core_web_sm") | |
| else: | |
| print(f" {YELLOW}⚠{RESET} spaCy not available — " | |
| f"run: python -m spacy download en_core_web_sm") | |
| print(f" {GREEN}✓{RESET} Skills taxonomy: {len(_SKILLS_TAXONOMY)} skills loaded\n") | |
| # Sample resume text | |
| SAMPLE = """ | |
| Priya Sharma | |
| priya.sharma@email.com | +91 9876543210 | |
| Mumbai, India | |
| EXPERIENCE | |
| Senior Data Analyst — Infosys, Mumbai | |
| Jan 2022 – Present | |
| - Built sales dashboards using Python, SQL, and Tableau | |
| - Automated reporting pipeline with Pandas and NumPy | |
| Data Analyst — Wipro, Pune | |
| June 2020 – Dec 2021 | |
| - Developed churn prediction model using scikit-learn | |
| - Created Power BI dashboards for leadership team | |
| EDUCATION | |
| B.Tech in Computer Science — VIT University, 2020 | |
| SKILLS | |
| Python, SQL, Tableau, Power BI, Machine Learning, Pandas, NumPy, | |
| scikit-learn, Git, Agile | |
| PROJECTS | |
| Sales Revenue Dashboard — Automated monthly reporting using Python | |
| Customer Churn Model — Reduced churn by 12% using ML classification | |
| CERTIFICATIONS | |
| AWS Cloud Practitioner, Google Data Analytics Certificate | |
| HOBBIES | |
| Reading, Hackathons, Cricket | |
| """ | |
| record = build_resume_record("priya_sharma.pdf", SAMPLE) | |
| passed = 0 | |
| failed = 0 | |
| def check(condition, label, got=""): | |
| global passed, failed | |
| if condition: | |
| print(f" {GREEN}✓{RESET} {label}" | |
| + (f" → {got}" if got else "")) | |
| passed += 1 | |
| else: | |
| print(f" {RED}✗{RESET} {label}" | |
| + (f" → got: {got}" if got else "")) | |
| failed += 1 | |
| check(record["candidate_name"] != "Unknown", | |
| "Name extracted", | |
| record["candidate_name"]) | |
| check("sharma" in record["email"].lower() or "@" in record["email"], | |
| "Email extracted", | |
| record["email"]) | |
| check(len(record["phone"]) >= 8, | |
| "Phone extracted", | |
| record["phone"]) | |
| skills = json.loads(record["skills"]) | |
| check(len(skills) >= 3, | |
| f"Skills extracted ({len(skills)} found)", | |
| ", ".join(skills[:5])) | |
| soft = json.loads(record["soft_skills"]) | |
| check(isinstance(soft, list), | |
| f"Soft skills extracted ({len(soft)} found)", | |
| ", ".join(soft[:3]) if soft else "none") | |
| check(record["experience_years"] > 0, | |
| f"Experience years", | |
| f"{record['experience_years']} yrs") | |
| check(record["avg_tenure_months"] > 0, | |
| f"Avg tenure", | |
| f"{record['avg_tenure_months']} months") | |
| check(record["recency_flag"] in ("Recent", "Stale", "Unknown"), | |
| f"Recency flag", | |
| record["recency_flag"]) | |
| edu = json.loads(record["education"]) | |
| check(edu.get("degree", "") != "", | |
| "Education extracted", | |
| f"{edu.get('degree')} in {edu.get('field')}") | |
| projects = json.loads(record["projects"]) | |
| check(len(projects) >= 1, | |
| f"Projects extracted ({len(projects)} found)", | |
| projects[0] if projects else "none") | |
| certs = json.loads(record["certifications"]) | |
| check(len(certs) >= 1, | |
| f"Certifications extracted ({len(certs)} found)", | |
| certs[0] if certs else "none") | |
| # All required keys present | |
| required_keys = [ | |
| "file_name", "candidate_name", "email", "phone", "resume_text", | |
| "skills", "soft_skills", "experience_years", "last_company", | |
| "job_titles", "education", "certifications", "projects", "hobbies", | |
| "avg_tenure_months", "recency_flag", "upload_batch" | |
| ] | |
| missing_keys = [k for k in required_keys if k not in record] | |
| check(len(missing_keys) == 0, | |
| "All DB schema keys present", | |
| f"Missing: {missing_keys}" if missing_keys else "") | |
| print(f"\n Passed: {GREEN}{passed}{RESET} | " | |
| f"Failed: {RED}{failed}{RESET}") | |
| if failed == 0: | |
| print(f"\n {GREEN}{BOLD}✓ All tests passed — extractor.py is ready.{RESET}") | |
| print(f"\n Next step: tell Claude 'ready for Step 5 (jd_parser)'\n") | |
| else: | |
| print(f"\n {YELLOW}Some tests failed — check output above.{RESET}") | |
| print(f" Partial failures are okay if spaCy isn't installed yet.\n") | |
| print("="*60 + "\n") |