File size: 9,323 Bytes
2bcd6c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
"""
Evidence matcher (spec items #3 and #7).

Before any JD requirement is injected into the resume, classify it against the
ORIGINAL (base) resume so we never claim a skill the candidate can't back up.

Three-layer matching:
  Layer 1 β€” exact:    the term/phrase appears verbatim (lemma/phrase aware)
  Layer 2 — alias:    a known synonym/variant appears (PostgreSQL→SQL, etc.)
  Layer 3 β€” semantic: related evidence exists (Docker/AWS/CI-CD β†’ Kubernetes),
                      optionally confirmed by the LLM

Evidence status drives the action:
  supported       β†’ add        (place per recommended_placement)
  transferable    β†’ rephrase    (use truthfully in a bullet; don't list raw)
  unsupported     β†’ reject      (DO NOT inject β€” surface as a gap)
  needs_user_input→ ask_user    (plausible but unprovable, e.g. a certification)
"""

from __future__ import annotations

import re
from dataclasses import dataclass, field, asdict
from typing import List, Dict, Optional

from .ats_scorer import _kw_in_text
from .jd_analyzer import Requirement

# Semantic adjacency: requirement term β†’ evidence terms that make it
# TRANSFERABLE (related, truthful to mention) when the exact term is absent.
_RELATED: Dict[str, List[str]] = {
    "kubernetes": ["docker", "aws", "gcp", "ci/cd", "containers", "devops"],
    "postgresql": ["sql", "mysql", "databases", "queries"],
    "mysql": ["sql", "postgresql", "databases"],
    "tableau": ["power bi", "looker", "dashboards", "data visualization", "metabase"],
    "looker": ["tableau", "power bi", "dashboards", "metabase"],
    "ci/cd": ["github actions", "jenkins", "deployment", "gitlab", "devops"],
    "amplitude": ["mixpanel", "ga4", "analytics", "product analytics"],
    "mixpanel": ["amplitude", "ga4", "analytics", "product analytics"],
    "product roadmap": ["roadmap", "roadmapping", "product strategy", "prioritization"],
    "stakeholder management": ["stakeholder", "cross-functional", "stakeholders"],
    "a/b testing": ["experimentation", "experiments", "hypothesis testing"],
    "go-to-market": ["gtm", "launch", "product launch"],
    "user research": ["user interviews", "discovery", "personas", "usability"],
    "sql": ["queries", "databases", "data analysis", "metabase"],
    "gtm": ["go-to-market", "launch"],
}


@dataclass
class EvidenceVerdict:
    keyword: str
    category: str
    evidence_status: str            # supported|transferable|unsupported|needs_user_input
    match_type: str                 # exact|alias|semantic|missing
    confidence: str                 # high|medium|low
    resume_evidence: str = ""       # what in the resume supports it
    action: str = "reject"          # add|rephrase|ask_user|reject
    importance: str = "preferred"
    recommended_placement: List[str] = field(default_factory=list)

    def to_dict(self) -> dict:
        return asdict(self)


# Categories where a missing term is plausibly true but unprovable from a
# resume β†’ ask the user rather than silently rejecting.
_ASK_USER_CATEGORIES = {"certification", "education"}

# PM-UNIVERSAL competencies/methods: any practising product manager legitimately
# does these, so an exact-term miss β†’ TRANSFERABLE (truthful to mention), NOT a
# fake gap. This is deliberately limited to generic PM craft β€” it does NOT cover
# domain knowledge (security/healthcare/lending), specific tools (Kubernetes/
# Tableau), or hard-tech skills, which must be genuinely supported or stay gaps.
_PM_CORE_TRANSFERABLE = {
    "prioritization", "analytics", "data analysis", "requirements",
    "competitive analysis", "market research", "user research", "product vision",
    "product strategy", "product roadmap", "roadmap", "roadmapping",
    "stakeholder management", "customer insights", "user feedback", "feedback",
    "use cases", "user stories", "kpis", "kpi", "metrics", "discovery",
    "go-to-market", "gtm", "a/b testing", "experimentation", "wireframing",
    "backlog", "backlog grooming", "agile", "scrum", "execution", "launch",
    "product launch", "product development", "product management", "vision",
    "strategy", "personas", "user personas", "decision-making",
}


def _alias_hit(aliases: List[str], resume_text: str) -> Optional[str]:
    for a in aliases or []:
        if a and _kw_in_text(a, resume_text):
            return a
    return None


def _semantic_hit(term: str, resume_text: str) -> List[str]:
    related = _RELATED.get(term.lower().strip(), [])
    return [r for r in related if _kw_in_text(r, resume_text)]


def classify_requirement(req: Requirement, resume_text: str) -> EvidenceVerdict:
    """Deterministic 3-layer classification of one requirement vs the resume."""
    rl = resume_text.lower()
    term = req.term

    # Layer 1 β€” exact
    if _kw_in_text(term, rl):
        return EvidenceVerdict(
            keyword=term, category=req.category, evidence_status="supported",
            match_type="exact", confidence="high", resume_evidence=term,
            action="add", importance=req.importance,
            recommended_placement=req.recommended_placement)

    # Layer 2 β€” alias
    alias = _alias_hit(req.aliases, rl)
    if alias:
        return EvidenceVerdict(
            keyword=term, category=req.category, evidence_status="supported",
            match_type="alias", confidence="medium", resume_evidence=alias,
            action="add", importance=req.importance,
            recommended_placement=req.recommended_placement)

    # Layer 3 β€” semantic (related evidence)
    related_hits = _semantic_hit(term, rl)
    if related_hits:
        return EvidenceVerdict(
            keyword=term, category=req.category, evidence_status="transferable",
            match_type="semantic", confidence="medium",
            resume_evidence=", ".join(related_hits[:4]),
            action="rephrase", importance=req.importance,
            recommended_placement=req.recommended_placement)

    # Nothing found
    if req.category in _ASK_USER_CATEGORIES:
        return EvidenceVerdict(
            keyword=term, category=req.category, evidence_status="needs_user_input",
            match_type="missing", confidence="low", resume_evidence="",
            action="ask_user", importance=req.importance,
            recommended_placement=req.recommended_placement)

    # Soft skills + PM-universal craft are broadly transferable for any PM β€”
    # truthful to mention in bullets even if the exact term isn't verbatim.
    if req.category == "soft_skill" or term.lower().strip() in _PM_CORE_TRANSFERABLE:
        return EvidenceVerdict(
            keyword=term, category=req.category, evidence_status="transferable",
            match_type="semantic", confidence="low", resume_evidence="general PM experience",
            action="rephrase", importance=req.importance,
            recommended_placement=req.recommended_placement)

    return EvidenceVerdict(
        keyword=term, category=req.category, evidence_status="unsupported",
        match_type="missing", confidence="low", resume_evidence="",
        action="reject", importance=req.importance,
        recommended_placement=req.recommended_placement)


def classify_all(requirements: List[Requirement], resume_text: str,
                 llm=None, cfg: dict = None) -> List[EvidenceVerdict]:
    """Classify every requirement. Deterministic first; if an LLM is provided,
    it may UPGRADE an 'unsupported' verdict to 'transferable' when it finds
    genuine adjacent evidence (never downgrades, never invents 'supported')."""
    verdicts = [classify_requirement(r, resume_text) for r in requirements]

    if llm is not None and hasattr(llm, "judge_evidence"):
        unresolved = [v.keyword for v in verdicts if v.evidence_status == "unsupported"]
        if unresolved:
            try:
                judged = llm.judge_evidence(cfg or {}, resume_text, unresolved)
                # judged: {keyword: {"status": "...", "evidence": "..."}}
                jmap = {k.lower(): val for k, val in (judged or {}).items()}
                for v in verdicts:
                    j = jmap.get(v.keyword.lower())
                    if not j or v.evidence_status != "unsupported":
                        continue
                    status = (j.get("status") or "").lower()
                    if status == "transferable":
                        v.evidence_status = "transferable"
                        v.match_type = "semantic"
                        v.confidence = "low"
                        v.resume_evidence = (j.get("evidence") or "")[:120]
                        v.action = "rephrase"
            except Exception as e:
                print(f"[evidence] LLM judge skipped: {e}")

    return verdicts


# ── Convenience splits for the tailoring engine ──────────────────────────────

def addable_terms(verdicts: List[EvidenceVerdict]) -> List[EvidenceVerdict]:
    """Supported (add) + transferable (rephrase) β€” safe to put in the resume."""
    return [v for v in verdicts if v.action in ("add", "rephrase")]


def gap_terms(verdicts: List[EvidenceVerdict]) -> List[EvidenceVerdict]:
    """Unsupported / needs_user_input β€” NEVER injected; surfaced as gaps."""
    return [v for v in verdicts if v.action in ("reject", "ask_user")]