Spaces:
Sleeping
Sleeping
| """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 | |