File size: 3,521 Bytes
b7e194c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
"""
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,
        }