HireGrid.io / backend /core /rules_engine.py
Aayan-Laptop
1
dee5594
Raw
History Blame Contribute Delete
14.7 kB
EDUCATION_LEVELS = {
"Unknown": 0, "High School": 1, "Bachelor": 2, "Master": 3, "PhD": 4
}
# ──────────────────────────────────────────────
# Individual component scoring helpers
# ──────────────────────────────────────────────
def calculate_experience_score(required_exp: int, candidate_exp: float) -> float:
if required_exp <= 0:
return 100.0
if candidate_exp >= required_exp:
return 100.0
return round((candidate_exp / required_exp) * 100.0, 2)
def calculate_location_score(preferred_location: str, candidate_location: str) -> float:
if not preferred_location:
return 100.0
if not candidate_location:
return 50.0
pref_clean = preferred_location.lower().strip()
cand_clean = candidate_location.lower().strip()
if pref_clean in cand_clean or cand_clean in pref_clean:
return 100.0
if "remote" in pref_clean or "remote" in cand_clean:
return 80.0
return 0.0
def calculate_education_score(required_education: str, candidate_education: str) -> float:
if not required_education or required_education == "Any":
return 100.0
req_level = EDUCATION_LEVELS.get(required_education, 0)
cand_level = EDUCATION_LEVELS.get(candidate_education, 0)
if cand_level >= req_level:
return 100.0
if req_level == 0:
return 100.0
return round((cand_level / req_level) * 100.0, 2)
def calculate_certification_score(required_certs: list, candidate_certs: list) -> float:
if not required_certs:
return 100.0
if not candidate_certs:
return 0.0
req_lower = [c.lower() for c in required_certs]
cand_lower = [c.lower() for c in candidate_certs]
matched = sum(1 for c in req_lower if any(c in cc or cc in c for cc in cand_lower))
return round((matched / len(required_certs)) * 100.0, 2)
def calculate_language_score(preferred_languages: list, candidate_languages: list) -> float:
if not preferred_languages:
return 100.0
if not candidate_languages:
return 50.0
pref_lower = [l.lower() for l in preferred_languages]
cand_lower = [l.lower() for l in candidate_languages]
matched = sum(1 for l in pref_lower if l in cand_lower)
return round((matched / len(preferred_languages)) * 100.0, 2)
def calculate_skills_score(required_skills: list, candidate_skills: list) -> float:
"""Exact-match skills score."""
if not required_skills:
return 100.0
if not candidate_skills:
return 0.0
req_lower = set(s.lower() for s in required_skills)
cand_lower = set(s.lower() for s in candidate_skills)
matched = len(req_lower & cand_lower)
return round((matched / len(req_lower)) * 100.0, 2)
def calculate_density_weighted_skills_score(required_skills: list, skills_density: dict) -> float:
"""
Density-aware skills score. Penalises single-mention keyword stuffing.
count == 1 β†’ 0.4 (casual / stuffed mention)
count == 2 β†’ 0.7 (moderate use)
count >= 3 β†’ 1.0 (core expertise)
"""
if not required_skills:
return 100.0
if not skills_density:
return 0.0
density_lower = {k.lower(): v for k, v in skills_density.items()}
total_weight = 0.0
for skill in required_skills:
count = density_lower.get(skill.lower(), 0)
if count == 0:
multiplier = 0.0
elif count == 1:
multiplier = 0.4
elif count == 2:
multiplier = 0.7
else:
multiplier = 1.0
total_weight += multiplier
return round((total_weight / len(required_skills)) * 100.0, 2)
def calculate_projects_score(candidate_projects: list) -> float:
if not candidate_projects:
return 0.0
count = len(candidate_projects)
if count >= 3:
return 100.0
return round((count / 3) * 100.0, 2)
# ──────────────────────────────────────────────
# Utility helpers
# ──────────────────────────────────────────────
def get_matched_missing_skills(required_skills: list, candidate_skills: list):
req_lower = {s.lower(): s for s in required_skills}
cand_lower = set(s.lower() for s in candidate_skills)
matched = [req_lower[r] for r in req_lower if r in cand_lower]
missing = [req_lower[r] for r in req_lower if r not in cand_lower]
return matched, missing
def check_seniority_deficit(job_title: str, past_titles: list) -> bool:
"""
Checks if a candidate is applying for a senior/lead job while their history
consists entirely of junior-level titles and contains no mid-level or senior titles.
"""
if not job_title or not past_titles:
return False
senior_keywords = {"senior", "lead", "principal", "manager", "architect", "director", "head", "vp", "specialist"}
junior_modifiers = {"junior", "associate", "intern", "graduate", "trainee", "entry", "entry-level"}
job_lower = job_title.lower()
is_senior_job = any(k in job_lower for k in senior_keywords)
if not is_senior_job:
return False
# Check if they have ANY senior history
has_any_senior_history = any(any(k in t.lower() for k in senior_keywords) for t in past_titles)
if has_any_senior_history:
return False
# Check if they have ANY mid-level history (a title that has neither junior nor senior keywords)
has_any_mid_level = False
for title in past_titles:
t_low = title.lower()
is_junior = any(k in t_low for k in junior_modifiers)
is_senior = any(k in t_low for k in senior_keywords)
if not is_junior and not is_senior:
has_any_mid_level = True
break
# Seniority deficit gate triggers ONLY if all extracted titles are junior/intern roles
all_roles_are_junior = all(any(k in t.lower() for k in junior_modifiers) for t in past_titles)
if all_roles_are_junior and not has_any_senior_history and not has_any_mid_level:
return True
return False
def generate_summary(candidate_id: str, matched_skills: list, missing_skills: list,
exp_score: float, semantic_score: float, final_score: float) -> str:
strength = "strong" if final_score >= 80 else "moderate" if final_score >= 60 else "limited"
skill_note = f"Matches {len(matched_skills)} required skill(s)." if matched_skills else "No direct skill matches found."
gap_note = f"Gaps: {', '.join(missing_skills[:3])}." if missing_skills else "No critical skill gaps detected."
exp_note = "Experience fully meets requirements." if exp_score >= 100 else f"Experience score: {exp_score:.0f}%."
sem_note = f"Semantic alignment with job description: {semantic_score:.0f}%."
return f"{candidate_id} shows {strength} overall fit. {skill_note} {gap_note} {exp_note} {sem_note}"
# ──────────────────────────────────────────────
# Final composite scorer β€” v3 "Soft Veto" with Non-Linear Calibrations
# ──────────────────────────────────────────────
def compute_final_score(extracted_data: dict, job_reqs: dict, semantic_score: float,
skill_similarity_score: float, semantic_model=None) -> dict:
"""
v3 Scoring with upgraded Non-Linear experience deficit penalty and audit logs.
"""
weights = {
"semantic": 0.40,
"skills": 0.20,
"experience": 0.15,
"education": 0.10,
"certifications": 0.05,
"location": 0.05,
"language": 0.05,
}
req_skills = job_reqs.get("required_skills", [])
cand_skills = extracted_data.get("skills", [])
skills_density = extracted_data.get("skills_density", {})
# ── 1. Skill sub-scores ──────────────────────
exact_score = calculate_skills_score(req_skills, cand_skills)
density_score = calculate_density_weighted_skills_score(req_skills, skills_density)
sem_skill_score = skill_similarity_score
# Blend: 40% exact | 30% density | 30% semantic-skills
blended_skill_score = (exact_score * 0.40) + (density_score * 0.30) + (sem_skill_score * 0.30)
# ── 2. Core tech score (out of 60 possible points) ──
tech_score = (blended_skill_score * weights["skills"]) + (semantic_score * weights["semantic"])
# ── 3. Soft Veto ─────────────────────────────
if tech_score >= 30.0:
tech_ratio = 1.0
else:
tech_ratio = max(0.4, tech_score / 30.0)
# ── 4. Secondary scores ──────────────────────
req_exp_years = job_reqs.get("required_experience_years", 0)
cand_exp_years = extracted_data.get("experience", 0)
exp_score = calculate_experience_score(req_exp_years, cand_exp_years)
required_education = job_reqs.get("required_education", "Any")
candidate_education = extracted_data.get("education", "Unknown")
edu_score = calculate_education_score(required_education, candidate_education)
required_certifications = job_reqs.get("required_certifications", [])
candidate_certifications = extracted_data.get("certifications", [])
cert_score = calculate_certification_score(required_certifications, candidate_certifications)
preferred_location = job_reqs.get("preferred_location", "")
candidate_location = extracted_data.get("location", "")
loc_score = calculate_location_score(preferred_location, candidate_location)
preferred_languages = job_reqs.get("preferred_languages", [])
candidate_languages = extracted_data.get("languages", [])
lang_score = calculate_language_score(preferred_languages, candidate_languages)
adjusted_exp_score = exp_score * tech_ratio
adjusted_edu_score = edu_score * tech_ratio
adjusted_cert_score = cert_score
adjusted_loc_score = loc_score
adjusted_lang_score = lang_score
# ── 5. Final composite ────────────────────────
final_score = (
tech_score +
(adjusted_exp_score * weights["experience"]) +
(adjusted_edu_score * weights["education"]) +
(adjusted_cert_score * weights["certifications"]) +
(adjusted_loc_score * weights["location"]) +
(adjusted_lang_score * weights["language"])
)
# ── 6. Deficit Score Penalties & Gate ──────────
# Seniority Deficit Gate matching
job_title_input = job_reqs.get("job_title", "")
past_titles = extracted_data.get("past_titles", [])
seniority_deficit = check_seniority_deficit(job_title_input, past_titles)
seniority_explanation = "Seniority history is aligned with requirements."
if seniority_deficit:
final_score = final_score * 0.75
seniority_explanation = "Applied 25% reduction: Candidate is applying for a senior/lead role but their history only contains junior/intern titles."
# Experience Deficit sliding piecewise penalty
exp_penalty = 0.0
exp_explanation = "Experience fully meets or exceeds requirements."
if cand_exp_years < req_exp_years:
exp_deficit = req_exp_years - cand_exp_years
# Piecewise curve
if exp_deficit <= 2.0:
exp_penalty = exp_deficit * 3.0
elif exp_deficit <= 5.0:
exp_penalty = 6.0 + (exp_deficit - 2.0) * 4.0
else:
exp_penalty = 18.0 + (exp_deficit - 5.0) * 1.5
# Cap the maximum experience penalty
exp_penalty = min(25.0, exp_penalty)
final_score = final_score - exp_penalty
exp_explanation = f"Deducted {exp_penalty:.1f} points: Candidate has {cand_exp_years} years, job requires {req_exp_years} years (Applied non-linear gap scaling)."
final_score = max(0.0, min(100.0, final_score))
# Compile a highly detailed analytical audit log
matched_skills, missing_skills = get_matched_missing_skills(req_skills, cand_skills)
edu_explanation = f"Education level meets requirements (Candidate: {candidate_education}, Required: {required_education})."
if edu_score < 100.0:
edu_explanation = f"Education deficit: Candidate has {candidate_education}, but role requires {required_education}. Applied soft-veto scaling."
skills_explanation = f"Blended skills match: {blended_skill_score:.1f}% (Composed of 40% keyword intersection, 30% frequency density, and 30% dense-context semantic skill similarity)."
semantic_explanation = f"Semantic alignment with JD: {semantic_score:.1f}%. Represents overall conceptual match."
loc_explanation = "Location check passed."
if loc_score == 80.0:
loc_explanation = "Location matched via hybrid/remote settings."
elif loc_score == 0.0 and preferred_location:
loc_explanation = f"Location mismatch: Role preferred {preferred_location}, candidate resides in {candidate_location or 'Unknown'}."
cert_explanation = "Certification requirements met."
if required_certifications and cert_score < 100.0:
cert_explanation = f"Missing certifications: Candidate matched {len(candidate_certifications)} of {len(required_certifications)} required certs."
audit_log = {
"experience": exp_explanation,
"seniority": seniority_explanation,
"education": edu_explanation,
"skills": skills_explanation,
"semantic_similarity": semantic_explanation,
"location": loc_explanation,
"certifications": cert_explanation
}
return {
"final_score": round(final_score, 2),
"breakdown": {
"skills": round(blended_skill_score, 2),
"semantic_similarity": round(semantic_score, 2),
"experience": round(exp_score, 2),
"education": round(edu_score, 2),
"certifications": round(cert_score, 2),
"location": round(loc_score, 2),
"language": round(lang_score, 2),
},
"audit_log": audit_log
}