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