| | """ |
| | Explanation Layer |
| | |
| | Produces human-readable explanations of the scoring results. |
| | v1: LLM-generated explanations with template structure |
| | v2: Will use SHAP/LIME feature importance from ML model |
| | """ |
| |
|
| | import json |
| | from typing import Optional |
| |
|
| | from .feature_extractor import LLMClient, _extract_json |
| | from .prompts.scoring import EXPLANATION_PROMPT |
| |
|
| |
|
| | class Explainer: |
| | """Generates human-readable explanations for probability scores.""" |
| |
|
| | def __init__(self, llm_client: Optional[LLMClient] = None): |
| | self.llm = llm_client or LLMClient() |
| |
|
| | def explain(self, scoring_results: dict, match_analysis: dict) -> dict: |
| | """Generate explanation from scoring results and match data.""" |
| | prompt = EXPLANATION_PROMPT.format( |
| | scoring_results=json.dumps(scoring_results, indent=2), |
| | match_analysis=json.dumps(match_analysis, indent=2), |
| | ) |
| | response = self.llm.complete(prompt, temperature=0.2) |
| | return _extract_json(response) |
| |
|
| | def explain_deterministic(self, calibrated: dict, match_analysis: dict) -> dict: |
| | """ |
| | Rule-based explanation generation. No LLM needed. |
| | Useful for testing and as a structured fallback. |
| | """ |
| | positive = [] |
| | risks = [] |
| | missing = [] |
| |
|
| | |
| | skill_match = match_analysis.get("skill_match_analysis", {}) |
| | coverage = skill_match.get("coverage_ratio", 0) |
| | if coverage >= 0.8: |
| | matched = len(skill_match.get("matched_must_haves", [])) |
| | positive.append(f"Strong skill coverage: {matched} of required skills matched ({coverage:.0%})") |
| | elif coverage >= 0.5: |
| | positive.append(f"Moderate skill coverage at {coverage:.0%} of required skills") |
| |
|
| | for signal in match_analysis.get("positive_signals", []): |
| | positive.append(f"{signal['signal']} ({signal.get('strength', 'moderate')}): {signal.get('evidence', '')}") |
| |
|
| | |
| | for risk in match_analysis.get("risk_flags", []): |
| | risks.append(f"{risk['risk']} [{risk.get('severity', 'medium')}]: {risk.get('evidence', '')}") |
| |
|
| | |
| | for item in match_analysis.get("missing_information", []): |
| | missing.append(item) |
| |
|
| | |
| | overall = calibrated.get("overall_hire_probability", 0) |
| | shortlist = calibrated.get("shortlist_probability", 0) |
| |
|
| | if overall >= 60: |
| | tone = "Strong candidate" |
| | elif overall >= 40: |
| | tone = "Moderate candidate" |
| | elif overall >= 20: |
| | tone = "Below-average fit" |
| | else: |
| | tone = "Poor fit" |
| |
|
| | summary = ( |
| | f"{tone} with {overall}% estimated hire probability. " |
| | f"Shortlist probability is {shortlist}%. " |
| | ) |
| |
|
| | if risks: |
| | summary += f"Key risk: {risks[0].split(':')[0]}. " |
| | if positive: |
| | summary += f"Key strength: {positive[0].split(':')[0]}." |
| |
|
| | |
| | if overall >= 60: |
| | rec = "pass" |
| | elif overall >= 45: |
| | rec = "borderline" |
| | elif overall >= 25: |
| | rec = "no_pass" |
| | else: |
| | rec = "strong_no_pass" |
| |
|
| | if overall >= 75: |
| | rec = "strong_pass" |
| |
|
| | return { |
| | "reasoning_summary": summary, |
| | "positive_signals": positive[:6], |
| | "risk_signals": risks[:6], |
| | "missing_signals": missing[:4], |
| | "recommendation": rec, |
| | } |
| |
|