work-search / backend /services /scoring_engine.py
Anuragh
fix: correct seniority matrix — overqualified penalty for senior→junior
de9670b
Raw
History Blame Contribute Delete
11.8 kB
"""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