""" 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 = [] # Build positive signals 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', '')}") # Build risk signals for risk in match_analysis.get("risk_flags", []): risks.append(f"{risk['risk']} [{risk.get('severity', 'medium')}]: {risk.get('evidence', '')}") # Missing signals for item in match_analysis.get("missing_information", []): missing.append(item) # Build summary 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]}." # Recommendation 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, }