CV-Analyser / backend /core /rules_engine.py
Adeen
Prepare for Hugging Face deployment
c7fb8cf
Raw
History Blame Contribute Delete
13.8 kB
"""
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]