"""5-dimension job scoring engine — adapted from JustHireMe. Replaces the simple keyword counter with a proper weighted scorer: 1. Stack overlap (40%) — exact + adjacent-skill credit 2. Role alignment (25%) — domain match between resume and job 3. Seniority fit (20%) — level compatibility, hard caps for severe mismatch 4. Job quality (15%) — description richness, company presence Returns a 0-100 integer match_score. """ from __future__ import annotations import re import logging from typing import Dict, List, Optional, Set logger = logging.getLogger(__name__) # ── Tech category taxonomy (adapted from JustHireMe) ───────────────────────── # Maps lowercase skill → category. Skills in the same category are "adjacent". TECH_CATEGORY: Dict[str, str] = { # Languages "python": "lang", "javascript": "lang", "typescript": "lang", "java": "lang", "c++": "lang", "c#": "lang", "go": "lang", "golang": "lang", "rust": "lang", "ruby": "lang", "php": "lang", "swift": "lang", "kotlin": "lang", "scala": "lang", "r": "lang", "matlab": "lang", "dart": "lang", "elixir": "lang", "bash": "lang", "shell": "lang", "powershell": "lang", # Frontend frameworks "react": "frontend", "reactjs": "frontend", "react.js": "frontend", "vue": "frontend", "vue.js": "frontend", "angular": "frontend", "svelte": "frontend", "next.js": "frontend", "nextjs": "frontend", "nuxt": "frontend", "gatsby": "frontend", "jquery": "frontend", # Backend frameworks "node.js": "backend_fw", "nodejs": "backend_fw", "express": "backend_fw", "fastapi": "backend_fw", "django": "backend_fw", "flask": "backend_fw", "spring": "backend_fw", "spring boot": "backend_fw", "rails": "backend_fw", "laravel": "backend_fw", "fastify": "backend_fw", "nestjs": "backend_fw", "gin": "backend_fw", "fiber": "backend_fw", "echo": "backend_fw", # Cloud / infra "aws": "cloud", "azure": "cloud", "gcp": "cloud", "google cloud": "cloud", "digitalocean": "cloud", "heroku": "cloud", "vercel": "cloud", "netlify": "cloud", # DevOps / containers "docker": "devops", "kubernetes": "devops", "k8s": "devops", "terraform": "devops", "ansible": "devops", "helm": "devops", "jenkins": "devops", "github actions": "devops", "circleci": "devops", "gitlab ci": "devops", "argocd": "devops", # Databases (relational) "mysql": "db_rel", "postgresql": "db_rel", "postgres": "db_rel", "sqlite": "db_rel", "mariadb": "db_rel", "sql server": "db_rel", # Databases (NoSQL) "mongodb": "db_nosql", "dynamodb": "db_nosql", "cassandra": "db_nosql", "couchdb": "db_nosql", "firebase": "db_nosql", "firestore": "db_nosql", # Caching / messaging "redis": "cache_msg", "memcached": "cache_msg", "kafka": "cache_msg", "rabbitmq": "cache_msg", "celery": "cache_msg", # ML / AI "tensorflow": "ml", "pytorch": "ml", "keras": "ml", "scikit-learn": "ml", "sklearn": "ml", "pandas": "ml", "numpy": "ml", "huggingface": "ml", "transformers": "ml", "xgboost": "ml", "lightgbm": "ml", # Mobile "react native": "mobile", "flutter": "mobile", "ionic": "mobile", "swiftui": "mobile", "jetpack compose": "mobile", "android": "mobile", # CSS / styling "tailwind": "css", "tailwindcss": "css", "bootstrap": "css", "sass": "css", "scss": "css", "css": "css", # Testing "jest": "testing", "pytest": "testing", "junit": "testing", "selenium": "testing", "cypress": "testing", "playwright": "testing", } # Domain → role keyword signals in job title/description DOMAIN_ROLE_SIGNALS: Dict[str, List[str]] = { "Software / IT": [ "software engineer", "developer", "backend", "frontend", "full stack", "fullstack", "web developer", "platform engineer", "sre", "devops", ], "Data / AI": [ "data scientist", "data engineer", "ml engineer", "machine learning", "ai engineer", "nlp", "data analyst", "analytics engineer", ], "DevOps / Cloud": [ "devops", "sre", "cloud engineer", "platform engineer", "infrastructure", "reliability", "kubernetes", "terraform", ], "Mobile": [ "ios", "android", "mobile developer", "react native", "flutter", ], "Design / UX": [ "designer", "ux", "ui", "product design", "figma", ], "Management": [ "manager", "director", "head of", "vp ", "chief", "lead", ], } # Seniority signals _SENIOR_SIGNALS = ( "senior", "staff", "principal", "lead", "architect", "5+ years", "7+ years", "10+ years", "15+ years", ) _JUNIOR_SIGNALS = ( "junior", "entry level", "entry-level", "new grad", "graduate", "fresher", "intern", "0-2 years", "1-2 years", ) _MID_SIGNALS = ("mid-level", "mid level", "3+ years", "3-5 years", "2-4 years") # Skills to extract from job descriptions _JD_SKILL_PATTERNS = re.compile( r'\b(' + '|'.join(re.escape(s) for s in sorted(TECH_CATEGORY.keys(), key=len, reverse=True)) + r')\b', re.IGNORECASE, ) def _extract_jd_skills(text: str) -> List[str]: """Extract recognized tech skills from a job description.""" if not text: return [] found = [] seen = set() for m in _JD_SKILL_PATTERNS.finditer(text): s = m.group(0).lower() if s not in seen: seen.add(s) found.append(s) return found def _flatten_skills(skills_dict) -> Set[str]: """Flatten candidate skills dict to a set of lowercase skill names.""" if isinstance(skills_dict, dict): flat = set() for v in skills_dict.values(): if isinstance(v, list): flat.update(s.lower() for s in v) return flat if isinstance(skills_dict, list): return set(s.lower() for s in skills_dict) return set() def _stack_overlap(candidate_skills: Set[str], jd_skills: List[str]) -> int: """ Score 0-100 based on how many required skills the candidate covers. Exact match = 1.0 credit, adjacent category match = 0.30 credit. """ if not jd_skills: return 50 # no skill requirements extracted → neutral total_credit = 0.0 for req in jd_skills: req_lower = req.lower() if req_lower in candidate_skills: total_credit += 1.0 else: # Adjacent credit: same tech category, different skill req_cat = TECH_CATEGORY.get(req_lower) if req_cat: for cand in candidate_skills: if TECH_CATEGORY.get(cand) == req_cat: total_credit += 0.30 break coverage = total_credit / len(jd_skills) return min(100, int(coverage * 130)) # 130 factor: 77% coverage → 100 def _role_alignment(job: dict, domain: str) -> int: """Score 0-100: how well the job role matches the resume domain.""" if not domain: return 60 # neutral text = (str(job.get("title") or "") + " " + str(job.get("description") or "")).lower() signals = DOMAIN_ROLE_SIGNALS.get(domain, []) # Direct domain hit matches = sum(1 for s in signals if s in text) if matches >= 3: return 95 if matches == 2: return 80 if matches == 1: return 65 # Heavy penalty: tech resume → clearly non-tech job non_tech_patterns = [ "sales manager", "marketing manager", "account executive", "recruiter", "human resources", "real estate", "insurance agent", "financial advisor", "business development manager", "operations coordinator", ] if domain in ("Software / IT", "Data / AI", "DevOps / Cloud", "Mobile"): if any(p in text for p in non_tech_patterns): return 5 return 45 # no direct signal — possible but uncertain def _seniority_fit(job: dict, resume_text: str) -> int: """ Score 0-100 based on seniority alignment. Applies hard cap: fresher applying to senior-only role → max 25. """ job_text = (str(job.get("title") or "") + " " + str(job.get("description") or "")).lower() resume_lower = (resume_text or "").lower()[:3000] # first 3000 chars job_level = "unknown" if any(s in job_text for s in _SENIOR_SIGNALS): job_level = "senior" elif any(s in job_text for s in _MID_SIGNALS): job_level = "mid" elif any(s in job_text for s in _JUNIOR_SIGNALS): job_level = "junior" # Candidate level from resume text resume_years = 0 m = re.search(r'(\d+)\+?\s*years?\s+(?:of\s+)?experience', resume_lower) if m: resume_years = int(m.group(1)) has_senior_title = any(s in resume_lower for s in ("senior ", "lead ", "principal ", "staff ")) has_junior_title = any(s in resume_lower for s in ("junior ", "entry level", "fresher", "intern")) if has_senior_title or resume_years >= 5: candidate_level = "senior" elif resume_years >= 2: candidate_level = "mid" elif has_junior_title or resume_years <= 1: candidate_level = "junior" else: candidate_level = "unknown" if job_level == "unknown": return 70 # no seniority specified → neutral if candidate_level == "unknown": return 65 # can't assess → slight neutral # Scoring matrix: (job_level, candidate_level) → score matrix = { ("senior", "senior"): 95, ("senior", "mid"): 75, ("senior", "junior"): 20, # hard cap — underqualified ("mid", "senior"): 65, # overqualified — employer likely won't hire ("mid", "mid"): 95, ("mid", "junior"): 50, ("junior", "senior"): 40, # overqualified — employer likely won't hire ("junior", "mid"): 70, ("junior", "junior"): 95, } return matrix.get((job_level, candidate_level), 65) def _job_quality(job: dict) -> int: """Score 0-100 based on listing quality: description richness, company presence.""" score = 50 desc = str(job.get("description") or job.get("snippet") or "") words = len(desc.split()) if words >= 200: score += 30 elif words >= 80: score += 20 elif words >= 30: score += 10 if job.get("company"): score += 10 if job.get("date_posted"): score += 5 if job.get("location"): score += 5 return min(100, score) def score_job( job: dict, candidate_skills, # dict from skill_extractor OR flat list resume_text: str = "", domain: str = "", ) -> int: """ Compute a 0-100 match score using 4 weighted dimensions. Weights: stack_overlap 40% role_alignment 25% seniority_fit 20% job_quality 15% """ jd_text = str(job.get("description") or job.get("snippet") or "") jd_skills = _extract_jd_skills(jd_text + " " + str(job.get("title") or "")) candidate_set = _flatten_skills(candidate_skills) stack = _stack_overlap(candidate_set, jd_skills) role = _role_alignment(job, domain) senior = _seniority_fit(job, resume_text) quality = _job_quality(job) raw = stack * 0.40 + role * 0.25 + senior * 0.20 + quality * 0.15 score = max(1, min(100, int(raw))) logger.debug( f"[scoring] '{job.get('title','')}' → {score} " f"(stack={stack} role={role} senior={senior} quality={quality})" ) return score def rescore_jobs( jobs: List[dict], candidate_skills, resume_text: str = "", domain: str = "", ) -> List[dict]: """Apply score_job to all jobs, replacing match_score. Returns sorted list.""" for job in jobs: job["match_score"] = score_job(job, candidate_skills, resume_text, domain) jobs.sort(key=lambda j: j["match_score"], reverse=True) return jobs