Upload src/explainer.py with huggingface_hub
Browse files- src/explainer.py +102 -0
src/explainer.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Explanation Layer
|
| 3 |
+
|
| 4 |
+
Produces human-readable explanations of the scoring results.
|
| 5 |
+
v1: LLM-generated explanations with template structure
|
| 6 |
+
v2: Will use SHAP/LIME feature importance from ML model
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
from typing import Optional
|
| 11 |
+
|
| 12 |
+
from .feature_extractor import LLMClient, _extract_json
|
| 13 |
+
from .prompts.scoring import EXPLANATION_PROMPT
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class Explainer:
|
| 17 |
+
"""Generates human-readable explanations for probability scores."""
|
| 18 |
+
|
| 19 |
+
def __init__(self, llm_client: Optional[LLMClient] = None):
|
| 20 |
+
self.llm = llm_client or LLMClient()
|
| 21 |
+
|
| 22 |
+
def explain(self, scoring_results: dict, match_analysis: dict) -> dict:
|
| 23 |
+
"""Generate explanation from scoring results and match data."""
|
| 24 |
+
prompt = EXPLANATION_PROMPT.format(
|
| 25 |
+
scoring_results=json.dumps(scoring_results, indent=2),
|
| 26 |
+
match_analysis=json.dumps(match_analysis, indent=2),
|
| 27 |
+
)
|
| 28 |
+
response = self.llm.complete(prompt, temperature=0.2)
|
| 29 |
+
return _extract_json(response)
|
| 30 |
+
|
| 31 |
+
def explain_deterministic(self, calibrated: dict, match_analysis: dict) -> dict:
|
| 32 |
+
"""
|
| 33 |
+
Rule-based explanation generation. No LLM needed.
|
| 34 |
+
Useful for testing and as a structured fallback.
|
| 35 |
+
"""
|
| 36 |
+
positive = []
|
| 37 |
+
risks = []
|
| 38 |
+
missing = []
|
| 39 |
+
|
| 40 |
+
# Build positive signals
|
| 41 |
+
skill_match = match_analysis.get("skill_match_analysis", {})
|
| 42 |
+
coverage = skill_match.get("coverage_ratio", 0)
|
| 43 |
+
if coverage >= 0.8:
|
| 44 |
+
matched = len(skill_match.get("matched_must_haves", []))
|
| 45 |
+
positive.append(f"Strong skill coverage: {matched} of required skills matched ({coverage:.0%})")
|
| 46 |
+
elif coverage >= 0.5:
|
| 47 |
+
positive.append(f"Moderate skill coverage at {coverage:.0%} of required skills")
|
| 48 |
+
|
| 49 |
+
for signal in match_analysis.get("positive_signals", []):
|
| 50 |
+
positive.append(f"{signal['signal']} ({signal.get('strength', 'moderate')}): {signal.get('evidence', '')}")
|
| 51 |
+
|
| 52 |
+
# Build risk signals
|
| 53 |
+
for risk in match_analysis.get("risk_flags", []):
|
| 54 |
+
risks.append(f"{risk['risk']} [{risk.get('severity', 'medium')}]: {risk.get('evidence', '')}")
|
| 55 |
+
|
| 56 |
+
# Missing signals
|
| 57 |
+
for item in match_analysis.get("missing_information", []):
|
| 58 |
+
missing.append(item)
|
| 59 |
+
|
| 60 |
+
# Build summary
|
| 61 |
+
overall = calibrated.get("overall_hire_probability", 0)
|
| 62 |
+
shortlist = calibrated.get("shortlist_probability", 0)
|
| 63 |
+
|
| 64 |
+
if overall >= 60:
|
| 65 |
+
tone = "Strong candidate"
|
| 66 |
+
elif overall >= 40:
|
| 67 |
+
tone = "Moderate candidate"
|
| 68 |
+
elif overall >= 20:
|
| 69 |
+
tone = "Below-average fit"
|
| 70 |
+
else:
|
| 71 |
+
tone = "Poor fit"
|
| 72 |
+
|
| 73 |
+
summary = (
|
| 74 |
+
f"{tone} with {overall}% estimated hire probability. "
|
| 75 |
+
f"Shortlist probability is {shortlist}%. "
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
if risks:
|
| 79 |
+
summary += f"Key risk: {risks[0].split(':')[0]}. "
|
| 80 |
+
if positive:
|
| 81 |
+
summary += f"Key strength: {positive[0].split(':')[0]}."
|
| 82 |
+
|
| 83 |
+
# Recommendation
|
| 84 |
+
if overall >= 60:
|
| 85 |
+
rec = "pass"
|
| 86 |
+
elif overall >= 45:
|
| 87 |
+
rec = "borderline"
|
| 88 |
+
elif overall >= 25:
|
| 89 |
+
rec = "no_pass"
|
| 90 |
+
else:
|
| 91 |
+
rec = "strong_no_pass"
|
| 92 |
+
|
| 93 |
+
if overall >= 75:
|
| 94 |
+
rec = "strong_pass"
|
| 95 |
+
|
| 96 |
+
return {
|
| 97 |
+
"reasoning_summary": summary,
|
| 98 |
+
"positive_signals": positive[:6],
|
| 99 |
+
"risk_signals": risks[:6],
|
| 100 |
+
"missing_signals": missing[:4],
|
| 101 |
+
"recommendation": rec,
|
| 102 |
+
}
|