Spaces:
Sleeping
Sleeping
| """ | |
| 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"], | |
| } | |
| 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")] | |