""" 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")]