SmartHire-AI / src /ranking.py
Vishu2006's picture
Initial commit: SmartHire-AI FastAPI + Streamlit
91e794e
Raw
History Blame Contribute Delete
10.7 kB
"""
ranking.py
----------
Production candidate ranking engine.
Improvements over v1:
- Calibrated similarity scores (no score clustering at 70-80%)
- Weighted skill scoring (critical skills count 3x)
- Confidence intervals per candidate
- Percentile rank across the pool
- AI-generated insight text per candidate
- important_missing field in CandidateResult
- Full structured CandidateResult dataclass
Author: SmartHire AI
"""
import logging
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
import pandas as pd
from src.similarity import (
calibrate_score,
compute_confidence,
compute_percentile_ranks,
get_recommendation,
similarity_to_percentage,
)
from src.skills import full_skill_analysis, get_skill_weight
logger = logging.getLogger(__name__)
# -- CandidateResult Dataclass ----------------------------------------
@dataclass
class CandidateResult:
"""
Full result record for one candidate against one job description.
"""
name : str
resume_text : str
similarity_score : float
calibrated_sim_pct : float
score_pct : float
recommendation : str
recommendation_color : str
matching_skills : List[str] = field(default_factory=list)
missing_skills : List[str] = field(default_factory=list)
critical_missing : List[str] = field(default_factory=list)
important_missing : List[str] = field(default_factory=list)
resume_only_skills : List[str] = field(default_factory=list)
skill_coverage_pct : float = 0.0
weighted_coverage_pct: float = 0.0
skills_by_category : Dict[str, List[str]] = field(default_factory=dict)
confidence : str = "Moderate"
percentile_rank : float = 0.0
rank : int = 0
ai_insight : str = ""
def to_dict(self) -> Dict:
"""Serialize to flat dictionary for DataFrame/CSV export."""
return {
"Candidate" : self.name,
"Rank" : self.rank,
"Match Score (%)" : self.score_pct,
"Semantic Similarity (%)" : self.calibrated_sim_pct,
"Skill Coverage (%)" : self.skill_coverage_pct,
"Weighted Coverage (%)" : self.weighted_coverage_pct,
"Recommendation" : self.recommendation,
"Confidence" : self.confidence,
"Percentile Rank" : self.percentile_rank,
"Matched Skills" : ", ".join(self.matching_skills) or "--",
"Missing Skills" : ", ".join(self.missing_skills) or "--",
"Critical Missing" : ", ".join(self.critical_missing) or "None",
"AI Insight" : self.ai_insight,
}
# -- AI Insight Generator ---------------------------------------------
def generate_insight(result_data: dict, jd_skill_count: int) -> str:
"""Generate a concise human-readable insight. Rule-based, no API needed."""
sim = result_data["calibrated_sim_pct"]
coverage = result_data["skill_coverage_pct"]
critical_miss= result_data["critical_missing"]
important_miss=result_data["important_missing"]
resume_extra = result_data["resume_only"]
parts = []
if sim >= 80:
parts.append(f"Strong contextual alignment with the JD (semantic similarity {sim:.0f}%).")
elif sim >= 60:
parts.append(f"Moderate contextual alignment with the JD (semantic similarity {sim:.0f}%).")
else:
parts.append(f"Limited contextual alignment with the JD (semantic similarity {sim:.0f}%).")
if coverage >= 80:
parts.append(f"Covers {coverage:.0f}% of required skills -- excellent match.")
elif coverage >= 60:
parts.append(f"Covers {coverage:.0f}% of required skills -- solid foundation.")
elif coverage >= 40:
parts.append(f"Covers {coverage:.0f}% of required skills -- some gaps present.")
else:
parts.append(f"Covers only {coverage:.0f}% of required skills -- significant gaps.")
if critical_miss:
top = ", ".join(critical_miss[:3])
parts.append(f"Missing critical skills: {top}{'...' if len(critical_miss) > 3 else ''}.")
elif important_miss:
top = ", ".join(important_miss[:2])
parts.append(f"Gaps in important skills: {top}.")
else:
parts.append("No critical skill gaps detected.")
if len(resume_extra) >= 5:
parts.append(f"Brings {len(resume_extra)} additional skills beyond JD requirements.")
return " ".join(parts)
# -- Core Ranking Function --------------------------------------------
def rank_candidates(
candidates: List[Dict],
jd_text: str,
similarity_weight: float = 0.7,
skill_weight: float = 0.3,
) -> List["CandidateResult"]:
"""
Rank candidates using weighted composite of calibrated semantic similarity
and weighted skill coverage.
Composite = (similarity_weight * calibrated_semantic_pct)
+ (skill_weight * weighted_skill_coverage_pct)
Args:
candidates : List of dicts with 'name', 'text', 'score'.
jd_text : Preprocessed JD text.
similarity_weight : Weight for semantic score (default 0.7).
skill_weight : Weight for skill coverage (default 0.3).
Returns:
List of CandidateResult sorted by composite score (descending).
"""
if not candidates:
raise ValueError("Candidates list is empty.")
if abs(similarity_weight + skill_weight - 1.0) > 0.01:
raise ValueError(f"Weights must sum to 1.0. Got {similarity_weight + skill_weight:.2f}")
logger.info(f"Ranking {len(candidates)} candidates | sim={similarity_weight} skill={skill_weight}")
raw_results = []
for candidate in candidates:
name = candidate["name"]
raw_cosine = candidate["score"]
calibrated_sim = calibrate_score(raw_cosine)
skill_data = full_skill_analysis(candidate["text"], jd_text)
weighted_cov = skill_data["weighted_coverage_pct"]
simple_cov = skill_data["skill_coverage_pct"]
composite = round(min(100.0, max(0.0,
calibrated_sim * similarity_weight + weighted_cov * skill_weight
)), 2)
recommendation, color = get_recommendation(composite)
raw_results.append({
"name" : name,
"resume_text" : candidate["text"],
"similarity_score" : round(raw_cosine, 4),
"calibrated_sim_pct": calibrated_sim,
"score_pct" : composite,
"recommendation" : recommendation,
"color" : color,
"matching" : skill_data["matching"],
"missing" : skill_data["missing"],
"critical_missing" : skill_data["critical_missing"],
"important_missing" : skill_data["important_missing"],
"resume_only" : skill_data["resume_only"],
"skill_coverage_pct" : simple_cov,
"weighted_coverage_pct" : weighted_cov,
"skills_by_category" : skill_data["skills_by_category"],
"jd_skill_count" : len(skill_data["jd_skills"]),
})
raw_results.sort(key=lambda x: x["score_pct"], reverse=True)
scores = [r["score_pct"] for r in raw_results]
percentiles = compute_percentile_ranks(scores)
n = len(raw_results)
results: List[CandidateResult] = []
for rank_idx, (r, pct) in enumerate(zip(raw_results, percentiles), start=1):
confidence = compute_confidence(r["score_pct"], n)
ai_insight = generate_insight(r, r["jd_skill_count"])
result = CandidateResult(
name = r["name"],
resume_text = r["resume_text"],
similarity_score = r["similarity_score"],
calibrated_sim_pct = r["calibrated_sim_pct"],
score_pct = r["score_pct"],
recommendation = r["recommendation"],
recommendation_color = r["color"],
matching_skills = r["matching"],
missing_skills = r["missing"],
critical_missing = r["critical_missing"],
important_missing = r["important_missing"],
resume_only_skills = r["resume_only"],
skill_coverage_pct = r["skill_coverage_pct"],
weighted_coverage_pct = r["weighted_coverage_pct"],
skills_by_category = r["skills_by_category"],
confidence = confidence,
percentile_rank = pct,
rank = rank_idx,
ai_insight = ai_insight,
)
results.append(result)
logger.debug(
f" #{rank_idx} {r['name']}: composite={r['score_pct']:.1f}% "
f"sim={r['calibrated_sim_pct']:.1f}% skill={r['weighted_coverage_pct']:.1f}%"
)
logger.info(f"Top candidate: {results[0].name} ({results[0].score_pct:.1f}%)")
return results
# -- Export Helpers ---------------------------------------------------
def results_to_dataframe(results: List[CandidateResult]) -> pd.DataFrame:
"""Convert ranked results to a Pandas DataFrame indexed by Rank."""
rows = [r.to_dict() for r in results]
df = pd.DataFrame(rows)
df = df.set_index("Rank")
return df
def export_to_csv(results: List[CandidateResult], filepath: str) -> str:
"""Export results to CSV and return filepath."""
df = results_to_dataframe(results)
df.to_csv(filepath)
logger.info(f"Exported to: {filepath}")
return filepath
def summarize_rankings(results: List[CandidateResult]) -> Dict:
"""Compute aggregate summary statistics."""
if not results:
return {}
scores = [r.score_pct for r in results]
return {
"total_candidates" : len(results),
"average_score" : round(sum(scores) / len(scores), 2),
"highest_score" : round(max(scores), 2),
"lowest_score" : round(min(scores), 2),
"score_std" : round(pd.Series(scores).std(), 2) if len(scores) > 1 else 0.0,
"highly_recommended": sum(1 for r in results if r.recommendation == "Highly Recommended"),
"recommended" : sum(1 for r in results if r.recommendation == "Recommended"),
"consider" : sum(1 for r in results if r.recommendation == "Consider"),
"not_recommended" : sum(1 for r in results if r.recommendation == "Not Recommended"),
}