| """ |
| features.py — Feature engineering for candidate scoring. |
| |
| Builds a FeatureVector for each candidate based on: |
| A) Skill Match Score (35% weight) |
| B) Career Fit Score (35% weight) |
| C) Availability Score (20% weight) |
| D) Location Score (10% weight) |
| |
| Every score is normalized to [0.0, 1.0]. |
| |
| Design principle: The JD is very specific about what it wants AND what it |
| doesn't want. We encode both sides — positive signals boost the score, |
| explicit disqualifiers apply hard penalties. |
| """ |
|
|
| from typing import Dict, Any, List, Tuple |
| from datetime import date |
| import math |
|
|
| |
| REFERENCE_DATE = date(2026, 6, 11) |
|
|
| |
|
|
| |
| REQUIRED_SKILLS = { |
| "embeddings", "sentence-transformers", "sentence transformers", |
| "vector search", "faiss", "pinecone", "weaviate", "qdrant", |
| "milvus", "opensearch", "elasticsearch", "hybrid search", |
| "dense retrieval", "retrieval", "python", "ranking", |
| "information retrieval", "semantic search" |
| } |
|
|
| |
| BONUS_SKILLS = { |
| "lora", "qlora", "peft", "fine-tuning", "fine tuning", |
| "learning to rank", "xgboost", "lightgbm", "reranking", |
| "cross-encoder", "bi-encoder", "ndcg", "mrr", "map", |
| "a/b testing", "rag", "retrieval augmented generation", |
| "pytorch", "transformers", "huggingface", "hugging face", |
| "nlp", "natural language processing", "llm" |
| } |
|
|
| |
| DISQUALIFIER_TITLES = { |
| "marketing", "sales manager", "hr manager", "human resources", |
| "accountant", "finance manager", "content writer", "graphic designer", |
| "customer support", "civil engineer", "mechanical engineer", |
| "operations manager", "supply chain" |
| } |
|
|
| |
| CONSULTING_COMPANIES = { |
| "tcs", "tata consultancy services", "infosys", "wipro", |
| "accenture", "cognizant", "capgemini", "hcl technologies", |
| "hcl", "tech mahindra", "hexaware", "mphasis", "ltimindtree", |
| "mindtree", "niit technologies", "syntel", "zensar", |
| "igate", "firstsource", "wns global", "genpact" |
| } |
|
|
| |
| TARGET_CITIES = { |
| "pune", "noida", "hyderabad", "mumbai", "delhi", "bangalore", |
| "bengaluru", "gurugram", "gurgaon", "ncr", "delhi ncr", |
| "new delhi", "greater noida" |
| } |
|
|
| |
| YOE_MIN = 5.0 |
| YOE_IDEAL_LOW = 6.0 |
| YOE_IDEAL_HIGH = 8.0 |
| YOE_MAX = 9.0 |
|
|
| |
| NOTICE_IDEAL_MAX = 30 |
| NOTICE_BUYOUT_MAX = 60 |
| NOTICE_ACCEPTABLE_MAX = 90 |
|
|
| |
| ACTIVE_RECENCY_GOOD_DAYS = 90 |
| ACTIVE_RECENCY_OK_DAYS = 180 |
| ACTIVE_RECENCY_BAD_DAYS = 180 |
|
|
| PROFICIENCY_SCORE = { |
| "expert": 1.0, |
| "advanced": 0.75, |
| "intermediate": 0.5, |
| "beginner": 0.25 |
| } |
|
|
|
|
| class FeatureVector: |
| """Holds all computed features for a single candidate.""" |
|
|
| def __init__(self): |
| |
| self.skill_match_score: float = 0.0 |
| self.career_fit_score: float = 0.0 |
| self.availability_score: float = 0.0 |
| self.location_score: float = 0.0 |
|
|
| |
| self.required_skills_found: List[str] = [] |
| self.bonus_skills_found: List[str] = [] |
| self.years_of_experience: float = 0.0 |
| self.has_product_company: bool = False |
| self.is_consulting_only: bool = False |
| self.has_retrieval_career_work: bool = False |
| self.notice_period_days: int = 0 |
| self.days_since_active: int = 0 |
| self.open_to_work: bool = False |
| self.response_rate: float = 0.0 |
| self.github_score: float = 0.0 |
| self.interview_completion: float = 0.0 |
| self.in_target_location: bool = False |
| self.willing_to_relocate: bool = False |
| self.is_disqualifier_title: bool = False |
| self.honeypot_risk: float = 0.0 |
| self.assessment_score: float = 0.0 |
|
|
| |
| self.final_score: float = 0.0 |
|
|
|
|
| def compute_features(candidate: Dict[str, Any]) -> FeatureVector: |
| """ |
| Compute all features for a candidate. |
| Returns a FeatureVector with all sub-scores populated. |
| """ |
| fv = FeatureVector() |
|
|
| profile = candidate.get("profile", {}) |
| career = candidate.get("career_history", []) |
| skills = candidate.get("skills", []) |
| signals = candidate.get("redrob_signals", {}) |
|
|
| |
| fv.skill_match_score, fv.required_skills_found, fv.bonus_skills_found, fv.assessment_score = ( |
| _compute_skill_score(skills, signals) |
| ) |
|
|
| |
| ( |
| fv.career_fit_score, |
| fv.years_of_experience, |
| fv.has_product_company, |
| fv.is_consulting_only, |
| fv.has_retrieval_career_work, |
| fv.is_disqualifier_title |
| ) = _compute_career_score(profile, career) |
|
|
| |
| ( |
| fv.availability_score, |
| fv.notice_period_days, |
| fv.days_since_active, |
| fv.open_to_work, |
| fv.response_rate, |
| fv.github_score, |
| fv.interview_completion |
| ) = _compute_availability_score(signals) |
|
|
| |
| fv.location_score, fv.in_target_location, fv.willing_to_relocate = ( |
| _compute_location_score(profile, signals) |
| ) |
|
|
| |
| fv.final_score = _combine_scores(fv) |
|
|
| return fv |
|
|
|
|
| def _compute_skill_score( |
| skills: List[Dict], |
| signals: Dict |
| ) -> Tuple[float, List[str], List[str], float]: |
| """ |
| Score skill match against JD requirements. |
| |
| Three components: |
| 1. Required skill coverage (how many must-have skills covered) |
| 2. Skill quality (proficiency + duration) |
| 3. Assessment scores from Redrob platform |
| """ |
| skill_names_lower = {} |
| for s in skills: |
| name = s.get("name", "").lower() |
| skill_names_lower[name] = s |
|
|
| |
| required_found = [] |
| required_score = 0.0 |
|
|
| for req_skill in REQUIRED_SKILLS: |
| |
| matched = None |
| for candidate_skill in skill_names_lower: |
| if req_skill in candidate_skill or candidate_skill in req_skill: |
| matched = candidate_skill |
| break |
|
|
| if matched: |
| skill_data = skill_names_lower[matched] |
| proficiency = skill_data.get("proficiency", "beginner") |
| duration = skill_data.get("duration_months", 0) |
|
|
| |
| prof_score = PROFICIENCY_SCORE.get(proficiency, 0.25) |
|
|
| |
| dur_score = min(1.0, duration / 36) if duration > 0 else 0.3 |
|
|
| skill_score = 0.6 * prof_score + 0.4 * dur_score |
| required_score += skill_score |
| required_found.append(f"{skill_data.get('name', matched)} ({proficiency})") |
|
|
| |
| required_normalized = min(1.0, required_score / (len(REQUIRED_SKILLS) * 0.5)) |
|
|
| |
| bonus_found = [] |
| bonus_score = 0.0 |
|
|
| for bonus_skill in BONUS_SKILLS: |
| for candidate_skill in skill_names_lower: |
| if bonus_skill in candidate_skill or candidate_skill in bonus_skill: |
| skill_data = skill_names_lower[candidate_skill] |
| prof_score = PROFICIENCY_SCORE.get( |
| skill_data.get("proficiency", "beginner"), 0.25 |
| ) |
| bonus_score += prof_score * 0.5 |
| bonus_found.append(skill_data.get("name", candidate_skill)) |
| break |
|
|
| bonus_normalized = min(1.0, bonus_score / (len(BONUS_SKILLS) * 0.3)) |
|
|
| |
| assessment_scores = signals.get("skill_assessment_scores", {}) |
| relevant_assessments = [] |
|
|
| for skill_name, score in assessment_scores.items(): |
| skill_lower = skill_name.lower() |
| is_relevant = any( |
| req in skill_lower or skill_lower in req |
| for req in (REQUIRED_SKILLS | BONUS_SKILLS) |
| ) |
| if is_relevant: |
| relevant_assessments.append(score) |
|
|
| assessment_score = 0.0 |
| if relevant_assessments: |
| assessment_score = sum(relevant_assessments) / len(relevant_assessments) / 100 |
|
|
| |
| |
| final = ( |
| 0.65 * required_normalized |
| + 0.20 * bonus_normalized |
| + 0.15 * assessment_score |
| ) |
|
|
| return final, required_found[:8], bonus_found[:6], assessment_score |
|
|
|
|
| def _compute_career_score( |
| profile: Dict, |
| career: List[Dict] |
| ) -> Tuple[float, float, bool, bool, bool, bool]: |
| """ |
| Score career fit based on: |
| - Years of experience (5-9 ideal range) |
| - Product company vs consulting experience |
| - Evidence of retrieval/search/recommendation work |
| - Current title match |
| - Company size (startup/mid-size preferred) |
| """ |
| yoe = profile.get("years_of_experience", 0) |
| current_title = profile.get("current_title", "").lower() |
|
|
| |
| if YOE_IDEAL_LOW <= yoe <= YOE_IDEAL_HIGH: |
| yoe_score = 1.0 |
| elif YOE_MIN <= yoe < YOE_IDEAL_LOW: |
| yoe_score = 0.7 + 0.3 * (yoe - YOE_MIN) / (YOE_IDEAL_LOW - YOE_MIN) |
| elif YOE_IDEAL_HIGH < yoe <= YOE_MAX: |
| yoe_score = 0.7 + 0.3 * (YOE_MAX - yoe) / (YOE_MAX - YOE_IDEAL_HIGH) |
| elif yoe < YOE_MIN: |
| yoe_score = max(0.1, yoe / YOE_MIN * 0.5) |
| else: |
| yoe_score = max(0.3, 1.0 - (yoe - YOE_MAX) * 0.05) |
|
|
| |
| is_disqualifier = any(t in current_title for t in DISQUALIFIER_TITLES) |
|
|
| |
| positive_title_terms = { |
| "ml engineer", "machine learning", "ai engineer", "data scientist", |
| "nlp engineer", "research engineer", "applied scientist", |
| "software engineer", "senior engineer", "staff engineer", |
| "backend engineer", "platform engineer", "search engineer", |
| "recommendation", "retrieval" |
| } |
| title_score = 0.5 |
| if any(t in current_title for t in positive_title_terms): |
| title_score = 1.0 |
| elif is_disqualifier: |
| title_score = 0.0 |
|
|
| |
| total_months = 0 |
| consulting_months = 0 |
| product_months = 0 |
| has_product_company = False |
| is_consulting_only = False |
|
|
| for job in career: |
| company = job.get("company", "").lower() |
| duration = job.get("duration_months", 0) |
| total_months += duration |
|
|
| is_consulting = any(cf in company for cf in CONSULTING_COMPANIES) |
| if is_consulting: |
| consulting_months += duration |
| else: |
| product_months += duration |
| has_product_company = True |
|
|
| if total_months > 0: |
| consulting_ratio = consulting_months / total_months |
| product_ratio = product_months / total_months |
| |
| is_consulting_only = consulting_ratio > 0.9 and total_months > 24 |
| else: |
| consulting_ratio = 0 |
| product_ratio = 0 |
|
|
| company_type_score = product_ratio if total_months > 0 else 0.5 |
|
|
| |
| career_text = " ".join( |
| job.get("description", "").lower() + " " + job.get("title", "").lower() |
| for job in career |
| ) |
|
|
| retrieval_keywords = { |
| "retrieval", "search", "ranking", "recommendation", "embedding", |
| "vector", "faiss", "similarity", "nlp", "information retrieval", |
| "semantic", "matching", "candidate ranking", "re-rank", "rerank" |
| } |
| retrieval_hits = sum(1 for kw in retrieval_keywords if kw in career_text) |
| has_retrieval_work = retrieval_hits >= 2 |
|
|
| retrieval_score = min(1.0, retrieval_hits / 4) |
|
|
| |
| company_sizes = [] |
| for job in career: |
| if job.get("is_current", False): |
| company_sizes.append(job.get("company_size", "")) |
|
|
| size_score = 0.5 |
| if company_sizes: |
| size = company_sizes[0] |
| size_scores = { |
| "1-10": 1.0, "11-50": 1.0, "51-200": 0.9, "201-500": 0.85, |
| "501-1000": 0.75, "1001-5000": 0.6, "5001-10000": 0.4, "10001+": 0.3 |
| } |
| size_score = size_scores.get(size, 0.5) |
|
|
| |
| if is_consulting_only: |
| |
| career_score = 0.1 |
| elif is_disqualifier: |
| |
| career_score = 0.05 |
| else: |
| career_score = ( |
| 0.25 * yoe_score |
| + 0.30 * title_score |
| + 0.20 * company_type_score |
| + 0.20 * retrieval_score |
| + 0.05 * size_score |
| ) |
|
|
| return ( |
| min(1.0, career_score), |
| yoe, |
| has_product_company, |
| is_consulting_only, |
| has_retrieval_work, |
| is_disqualifier |
| ) |
|
|
|
|
| def _compute_availability_score( |
| signals: Dict |
| ) -> Tuple[float, int, int, bool, float, float, float]: |
| """ |
| Score candidate availability using Redrob behavioral signals. |
| |
| Key insight from JD: |
| "A perfect-on-paper candidate who hasn't logged in for 6 months and has |
| a 5% recruiter response rate is, for hiring purposes, not actually available." |
| """ |
| |
| open_to_work = signals.get("open_to_work_flag", False) |
| last_active_str = signals.get("last_active_date", "") |
| notice_period = signals.get("notice_period_days", 90) |
| response_rate = signals.get("recruiter_response_rate", 0.0) |
| avg_response_hours = signals.get("avg_response_time_hours", 168) |
| github_score = signals.get("github_activity_score", -1) |
| interview_rate = signals.get("interview_completion_rate", 0.5) |
| offer_rate = signals.get("offer_acceptance_rate", -1) |
| completeness = signals.get("profile_completeness_score", 0) |
| applications_30d = signals.get("applications_submitted_30d", 0) |
| saved_30d = signals.get("saved_by_recruiters_30d", 0) |
|
|
| |
| days_since_active = 999 |
| if last_active_str: |
| try: |
| last_active = date.fromisoformat(last_active_str) |
| days_since_active = (REFERENCE_DATE - last_active).days |
| except (ValueError, TypeError): |
| pass |
|
|
| if days_since_active <= ACTIVE_RECENCY_GOOD_DAYS: |
| recency_score = 1.0 |
| elif days_since_active <= ACTIVE_RECENCY_OK_DAYS: |
| recency_score = 0.6 |
| else: |
| |
| recency_score = max(0.0, 0.6 * math.exp( |
| -(days_since_active - ACTIVE_RECENCY_OK_DAYS) / 180 |
| )) |
|
|
| |
| open_score = 1.0 if open_to_work else 0.3 |
| |
| if applications_30d >= 3: |
| open_score = min(1.0, open_score + 0.2) |
|
|
| |
| if notice_period <= NOTICE_IDEAL_MAX: |
| notice_score = 1.0 |
| elif notice_period <= NOTICE_BUYOUT_MAX: |
| notice_score = 0.8 |
| elif notice_period <= NOTICE_ACCEPTABLE_MAX: |
| notice_score = 0.5 |
| else: |
| notice_score = max(0.1, 1.0 - (notice_period - 90) / 180) |
|
|
| |
| if response_rate >= 0.7: |
| response_score = 1.0 |
| elif response_rate >= 0.4: |
| response_score = 0.6 + 0.4 * (response_rate - 0.4) / 0.3 |
| else: |
| |
| response_score = response_rate / 0.4 * 0.6 |
|
|
| |
| if avg_response_hours <= 24: |
| response_time_bonus = 0.2 |
| elif avg_response_hours <= 72: |
| response_time_bonus = 0.1 |
| else: |
| response_time_bonus = 0.0 |
|
|
| response_score = min(1.0, response_score + response_time_bonus) |
|
|
| |
| if github_score == -1: |
| |
| github_norm = 0.4 |
| elif github_score >= 70: |
| github_norm = 1.0 |
| elif github_score >= 40: |
| github_norm = 0.7 |
| elif github_score >= 15: |
| github_norm = 0.5 |
| else: |
| github_norm = 0.2 + github_score / 15 * 0.3 |
|
|
| |
| interview_score = interview_rate if interview_rate >= 0 else 0.5 |
|
|
| |
| completeness_score = completeness / 100 |
|
|
| |
| social_proof = min(1.0, saved_30d / 10) |
|
|
| |
| |
| availability_score = ( |
| 0.25 * recency_score |
| + 0.20 * open_score |
| + 0.20 * notice_score |
| + 0.15 * response_score |
| + 0.10 * github_norm |
| + 0.05 * interview_score |
| + 0.03 * completeness_score |
| + 0.02 * social_proof |
| ) |
|
|
| return ( |
| min(1.0, availability_score), |
| notice_period, |
| days_since_active, |
| open_to_work, |
| response_rate, |
| github_score if github_score >= 0 else 0.0, |
| interview_rate |
| ) |
|
|
|
|
| def _compute_location_score( |
| profile: Dict, |
| signals: Dict |
| ) -> Tuple[float, bool, bool]: |
| """ |
| Score location fit. |
| JD target: Pune, Noida, Hyderabad, Mumbai, Delhi NCR, Bangalore. |
| Open to relocating candidates from Tier-1 Indian cities. |
| Outside India: case-by-case, don't sponsor visas. |
| """ |
| location = profile.get("location", "").lower() |
| country = profile.get("country", "").lower() |
| willing_to_relocate = signals.get("willing_to_relocate", False) |
|
|
| in_target = any(city in location for city in TARGET_CITIES) |
|
|
| if in_target: |
| loc_score = 1.0 |
| elif country in ("india", "in") or "india" in country: |
| |
| if willing_to_relocate: |
| loc_score = 0.8 |
| else: |
| loc_score = 0.4 |
| else: |
| |
| if willing_to_relocate: |
| loc_score = 0.3 |
| else: |
| loc_score = 0.05 |
|
|
| return loc_score, in_target, willing_to_relocate |
|
|
|
|
| def _combine_scores(fv: FeatureVector) -> float: |
| """ |
| Combine all component scores into a final score. |
| |
| Weights derived from JD emphasis: |
| - Skill match: 35% (technical depth is crucial) |
| - Career fit: 35% (product company + retrieval work is crucial) |
| - Availability: 20% (behavioral signals matter a lot) |
| - Location: 10% (preferred but flexible) |
| """ |
| base_score = ( |
| 0.35 * fv.skill_match_score |
| + 0.35 * fv.career_fit_score |
| + 0.20 * fv.availability_score |
| + 0.10 * fv.location_score |
| ) |
|
|
| |
| if fv.is_consulting_only: |
| base_score *= 0.15 |
| elif fv.is_disqualifier_title: |
| base_score *= 0.10 |
|
|
| |
| if fv.days_since_active > 365: |
| base_score *= 0.5 |
|
|
| |
| if fv.honeypot_risk > 0: |
| base_score *= (1.0 - fv.honeypot_risk * 0.9) |
|
|
| return min(1.0, max(0.0, base_score)) |
|
|