ai-talent-finder-backend / app /services /explainability_engine.py
ilyass yani
Deploiement backend dans HF Spaces
9df97a2
Raw
History Blame Contribute Delete
6 kB
"""Explainability engine: Generates human-readable justifications for matches."""
from dataclasses import dataclass
from typing import Callable
import math
@dataclass
class ExplainabilityBreakdown:
"""Human-readable breakdown of a match decision."""
overall_score: float
interpretation: str # "Strong Match" / "Moderate Match" / "Weak Match"
matching_skills: list[str]
missing_skills: list[str]
experience_alignment: str
key_reason: str
recommendations: list[str]
def generate_explanation(
candidate_name: str,
job_title: str,
match_score: dict,
matching_skills: list[str],
missing_skills: list[str],
candidate_years_exp: float = 0.0,
required_years_exp: float = 0.0,
) -> ExplainabilityBreakdown:
"""
Generate human-readable explanation for a CV-job match.
Args:
candidate_name: Candidate full name
job_title: Job title
match_score: Dict with 'overall', 'text_sim', 'skills_match' scores (0-1)
matching_skills: List of skills that matched
missing_skills: List of required skills not found
candidate_years_exp: Candidate's years of experience
required_years_exp: Required years for job
Returns:
ExplainabilityBreakdown with justification details
"""
overall = match_score.get("match_score", 0.0)
text_sim = match_score.get("text_similarity", 0.0)
skill_match = match_score.get("skills_match", 0.0)
# Interpretation
if overall >= 0.80:
interpretation = "🟒 Strong Match"
elif overall >= 0.60:
interpretation = "🟑 Moderate Match"
else:
interpretation = "πŸ”΄ Weak Match"
# Experience alignment
exp_gap = candidate_years_exp - required_years_exp
if exp_gap >= 2:
experience_alignment = f"βœ… Highly experienced ({candidate_years_exp:.1f} years > {required_years_exp:.1f} required)"
elif exp_gap >= 0:
experience_alignment = f"βœ… Meets experience requirement ({candidate_years_exp:.1f} years β‰ˆ {required_years_exp:.1f} required)"
elif exp_gap > -2:
experience_alignment = f"⚠️ Slightly under-experienced ({candidate_years_exp:.1f} years < {required_years_exp:.1f} required)"
else:
experience_alignment = f"❌ Under-experienced ({candidate_years_exp:.1f} years < {required_years_exp:.1f} required)"
# Key reason
if len(matching_skills) >= 5 and skill_match > 0.85:
key_reason = f"Strong technical skills alignment: {', '.join(matching_skills[:3])} + {len(matching_skills)-3} more"
elif len(matching_skills) > 0:
key_reason = f"Good skills match: {', '.join(matching_skills[:2])}"
elif text_sim > 0.75:
key_reason = "Strong text/profile similarity with job description"
else:
key_reason = "Limited overlap between background and job requirements"
# Recommendations
recommendations = []
if len(missing_skills) > 0:
recommendations.append(f"Consider candidates with: {', '.join(missing_skills[:2])}")
if exp_gap < -1:
recommendations.append("Candidate needs more industry experience")
if overall > 0.60:
recommendations.append("Recommended for interview or technical assessment")
if overall < 0.50:
recommendations.append("Better candidates may exist; consider re-posting")
if not recommendations:
recommendations.append("Strong candidate β€” proceed with hiring process")
return ExplainabilityBreakdown(
overall_score=round(overall, 2),
interpretation=interpretation,
matching_skills=matching_skills[:5], # Top 5
missing_skills=missing_skills[:3], # Top 3
experience_alignment=experience_alignment,
key_reason=key_reason,
recommendations=recommendations[:2], # Top 2 recommendations
)
def generate_shortlist_summary(
matches: list[dict],
job_title: str,
top_n: int = 5,
) -> dict:
"""
Generate summary for a shortlist of candidates.
Args:
matches: List of match results (each with candidate, score, skills data)
job_title: Job title being matched
top_n: Number of top candidates to feature
Returns:
Summary dict with insights and recommendations
"""
if not matches:
return {
"total_candidates_screened": 0,
"strong_matches": 0,
"recommendations": ["No candidates found. Consider widening search criteria."],
"top_skills_in_shortlist": [],
}
sorted_matches = sorted(matches, key=lambda x: x.get("score", 0), reverse=True)
top_matches = sorted_matches[:top_n]
strong = sum(1 for m in matches if m.get("score", 0) >= 0.80)
moderate = sum(1 for m in matches if 0.60 <= m.get("score", 0) < 0.80)
# Extract top skills from candidates
all_skills = []
for match in top_matches:
if "matching_skills" in match:
all_skills.extend(match["matching_skills"])
from collections import Counter
skill_counts = Counter(all_skills)
top_skills = [skill for skill, count in skill_counts.most_common(5)]
recommendations = []
if strong >= 3:
recommendations.append(f"βœ… Excellent pool: {strong} strong matches. Recommend interviews for all.")
elif strong >= 1:
recommendations.append(f"πŸ‘ Good: {strong} strong match(es) available. Start with top candidates.")
else:
recommendations.append(f"⚠️ Limited strong matches ({strong}). Consider adjusting requirements or widening search.")
if moderate > 0:
recommendations.append(f"Consider {moderate} moderate matches for technical screening.")
return {
"total_candidates_screened": len(matches),
"strong_matches": strong,
"moderate_matches": moderate,
"top_skills_in_pool": top_skills,
"recommendations": recommendations,
}