""" rules_engine.py --------------- The heart of the system. Applies deterministic rule-based scoring to rank candidates. SCORING WEIGHTS: 40% Skills Match 20% Experience 15% Education 10% Certifications 10% Projects 5% Bonus (location, language, hobbies) Each component produces a score 0-100, then weighted sum gives the final score. An explanation (selected/rejected, strengths, weaknesses) is generated for every candidate. """ from dataclasses import dataclass, field from typing import List, Dict, Any # --------------------------------------------------------------------------- # Thresholds and constants # --------------------------------------------------------------------------- EDUCATION_SCORE_MAP = { 5: 100, # PhD 4: 90, # Master's 3: 75, # Bachelor's 2: 50, # Associate / Diploma 1: 30, # Certificate 0: 0, # None detected } WEIGHTS = { "skills": 0.40, "experience": 0.20, "education": 0.15, "certs": 0.10, "projects": 0.10, "bonus": 0.05, } # Minimum score to be considered "selected" SELECTION_THRESHOLD = 50.0 # --------------------------------------------------------------------------- # Data class for a scored candidate # --------------------------------------------------------------------------- @dataclass class CandidateResult: rank: int name: str filename: str final_score: float match_percent: float status: str # "Selected" or "Rejected" # Component scores (0-100 each) skills_score: float experience_score: float education_score: float certs_score: float projects_score: float bonus_score: float tfidf_similarity: float # Detail fields matched_skills: List[str] missing_skills: List[str] extra_skills: List[str] experience_years: float education_degree: str education_field: str certifications: List[str] project_count: int languages: List[str] location: str # Human-readable explanation strengths: List[str] weaknesses: List[str] summary: str # --------------------------------------------------------------------------- # Scoring functions # --------------------------------------------------------------------------- def _score_skills(skills_data: dict) -> float: """ Skills score = (matched / required) * 100. Bonus up to +10 for extra skills, penalized if < 50% matched. """ required = skills_data["required_count"] if required == 0: return 100.0 # No requirements → full marks matched = skills_data["match_count"] base_score = (matched / required) * 100 # Penalty: if fewer than 50% matched, apply a steeper deduction if matched / required < 0.5: base_score *= 0.8 # Bonus for extra relevant skills (max +5) extra_bonus = min(len(skills_data.get("extra", [])) * 1.0, 5.0) return min(base_score + extra_bonus, 100.0) def _score_experience(resume_years: float, required_years: float) -> float: """ Experience score: - Perfect match or over-qualified: 100 - Within 1 year: 80 - Within 2 years: 60 - Less than half required: 30 - 0 years: 0 """ if required_years <= 0: return 80.0 # No requirement specified, give benefit of doubt ratio = resume_years / required_years if required_years > 0 else 1.0 if ratio >= 1.0: return 100.0 elif ratio >= 0.8: return 85.0 elif ratio >= 0.6: return 65.0 elif ratio >= 0.4: return 45.0 elif ratio > 0.0: return 25.0 else: return 0.0 def _score_education(edu_data: dict, required_edu: str) -> float: """ Education score based on degree level detected. If required education is specified, it's used to set the target level. """ level = edu_data.get("level", 0) base_score = EDUCATION_SCORE_MAP.get(level, 0) required_lower = required_edu.lower() if required_edu else "" # Determine required level required_level = 0 if any(k in required_lower for k in ["phd", "doctorate"]): required_level = 5 elif any(k in required_lower for k in ["master", "msc", "mba"]): required_level = 4 elif any(k in required_lower for k in ["bachelor", "bsc", "btech", "b.tech"]): required_level = 3 elif "diploma" in required_lower: required_level = 2 if required_level > 0: if level >= required_level: return 100.0 elif level == required_level - 1: return 65.0 else: return 30.0 return float(base_score) def _score_certifications(certs_found: list, required_certs: list) -> float: """ Certification score based on how many required certs are found. Each found cert contributes 25 points (max 100). Bonus for extra certs found. """ if not required_certs: # No requirement — award points if candidate has any return min(len(certs_found) * 15.0, 50.0) required_lower = [c.lower() for c in required_certs] found_lower = [c.lower() for c in certs_found] matched = sum(1 for rc in required_lower if any(rc in fc or fc in rc for fc in found_lower)) base_score = (matched / len(required_lower)) * 100 return min(base_score, 100.0) def _score_projects(project_count: int) -> float: """ Projects score based on count of detected projects: 0 → 0, 1 → 40, 2 → 60, 3 → 80, 4+ → 100 """ if project_count == 0: return 0.0 elif project_count == 1: return 40.0 elif project_count == 2: return 60.0 elif project_count == 3: return 80.0 else: return 100.0 def _score_bonus( location: str, languages: list, preferred_location: str, preferred_languages: list, ) -> float: """ Bonus score for location and language match. Max 100 (which contributes 5% to final). """ score = 0.0 if preferred_location and location != "Not specified": if preferred_location.lower() in location.lower(): score += 60.0 if preferred_languages: lang_lower = [l.lower() for l in languages] for pl in preferred_languages: if pl.lower() in lang_lower: score += 20.0 return min(score, 100.0) # --------------------------------------------------------------------------- # Explanation generator # --------------------------------------------------------------------------- def _generate_explanation(candidate: dict, scores: dict, threshold: float) -> tuple: """ Builds lists of strengths, weaknesses, and a plain-English summary. Returns (strengths: list, weaknesses: list, summary: str). """ strengths = [] weaknesses = [] # Skills matched = candidate["skills"]["matched"] missing = candidate["skills"]["missing"] if matched: strengths.append(f"Matched {len(matched)} required skill(s): {', '.join(matched[:5])}") if missing: weaknesses.append(f"Missing {len(missing)} skill(s): {', '.join(missing[:5])}") # Experience exp_years = candidate["experience_years"] req_years = scores.get("required_years", 0) if exp_years >= req_years and req_years > 0: strengths.append(f"{exp_years:.1f} years of experience meets the requirement ({req_years:.0f}+ years)") elif exp_years < req_years: weaknesses.append(f"Only {exp_years:.1f} year(s) experience; {req_years:.0f}+ required") # Education edu = candidate["education"] if edu["level"] >= 3: strengths.append(f"Holds a {edu['degree']} in {edu['field']}") elif edu["level"] > 0: weaknesses.append(f"Education level ({edu['degree']}) may be below requirement") else: weaknesses.append("No recognized educational qualification detected") # Certifications certs = candidate["certifications"] if certs: strengths.append(f"Has {len(certs)} certification(s): {', '.join(certs[:3])}") else: weaknesses.append("No industry certifications found") # Projects projects = candidate["project_count"] if projects >= 3: strengths.append(f"Strong project portfolio ({projects} projects detected)") elif projects >= 1: strengths.append(f"{projects} project(s) found in resume") else: weaknesses.append("No project experience detected") # TF-IDF Semantic similarity sim = scores.get("tfidf", 0) if sim >= 0.3: strengths.append(f"High semantic relevance to job description ({sim*100:.0f}%)") elif sim < 0.15: weaknesses.append("Resume content has low semantic alignment with job description") final_score = scores.get("final", 0) status = "Selected" if final_score >= threshold else "Rejected" if status == "Selected": summary = ( f"Candidate is SELECTED with a final score of {final_score:.1f}/100. " f"Strong fit based on {len(strengths)} positive factor(s)." ) else: summary = ( f"Candidate is REJECTED (score {final_score:.1f}/100 below threshold {threshold:.0f}). " f"Key gaps: {'; '.join(weaknesses[:2]) if weaknesses else 'overall low relevance'}." ) return strengths, weaknesses, summary # --------------------------------------------------------------------------- # Main engine entry point # --------------------------------------------------------------------------- def score_and_rank( candidates_data: List[Dict[str, Any]], job_requirements: Dict[str, Any], tfidf_scores: List[float], top_n: int = 10, ) -> List[CandidateResult]: """ Score all candidates, sort by final score descending, assign ranks. Parameters ---------- candidates_data : list of dicts from nlp_layer.extract_all() job_requirements : dict with keys: required_skills, experience_years, education, certifications, preferred_location, preferred_languages, top_n tfidf_scores : TF-IDF similarity scores (parallel list to candidates_data) top_n : how many candidates to return Returns ------- List of CandidateResult objects, ranked """ req_skills = job_requirements.get("required_skills", []) req_exp = float(job_requirements.get("experience_years", 0) or 0) req_edu = job_requirements.get("education", "") req_certs = [c.strip() for c in job_requirements.get("certifications", "").split(",") if c.strip()] pref_location = job_requirements.get("preferred_location", "") pref_languages = [l.strip() for l in job_requirements.get("preferred_languages", "").split(",") if l.strip()] threshold = SELECTION_THRESHOLD scored_list = [] for i, candidate in enumerate(candidates_data): tfidf = tfidf_scores[i] if i < len(tfidf_scores) else 0.0 # Compute component scores s_skills = _score_skills(candidate["skills"]) s_exp = _score_experience(candidate["experience_years"], req_exp) s_edu = _score_education(candidate["education"], req_edu) s_certs = _score_certifications(candidate["certifications"], req_certs) s_proj = _score_projects(candidate["project_count"]) s_bonus = _score_bonus( candidate["location"], candidate["languages"], pref_location, pref_languages, ) # Weighted final score (each component is 0–100) final_score = ( s_skills * WEIGHTS["skills"] + s_exp * WEIGHTS["experience"] + s_edu * WEIGHTS["education"] + s_certs * WEIGHTS["certs"] + s_proj * WEIGHTS["projects"] + s_bonus * WEIGHTS["bonus"] ) # Match percent ≈ final score (semantically similar concept for HR) match_percent = round(final_score, 1) scores_context = { "final": final_score, "required_years": req_exp, "tfidf": tfidf, } strengths, weaknesses, summary = _generate_explanation( candidate, scores_context, threshold ) result = CandidateResult( rank=0, # Assigned after sorting name=candidate["name"], filename=candidate.get("filename", "resume.pdf"), final_score=round(final_score, 2), match_percent=match_percent, status="Selected" if final_score >= threshold else "Rejected", skills_score=round(s_skills, 2), experience_score=round(s_exp, 2), education_score=round(s_edu, 2), certs_score=round(s_certs, 2), projects_score=round(s_proj, 2), bonus_score=round(s_bonus, 2), tfidf_similarity=round(tfidf * 100, 2), matched_skills=candidate["skills"]["matched"], missing_skills=candidate["skills"]["missing"], extra_skills=candidate["skills"]["extra"], experience_years=candidate["experience_years"], education_degree=candidate["education"]["degree"], education_field=candidate["education"]["field"], certifications=candidate["certifications"], project_count=candidate["project_count"], languages=candidate["languages"], location=candidate["location"], strengths=strengths, weaknesses=weaknesses, summary=summary, ) scored_list.append(result) # Sort by final score descending scored_list.sort(key=lambda r: r.final_score, reverse=True) # Assign ranks for idx, result in enumerate(scored_list): result.rank = idx + 1 # Return top N return scored_list[:top_n]