JAA-ATS-Tool / src /evidence_matcher.py
saitejatirunagari's picture
feat(ats): v2 pipeline β€” structured JD, evidence matching, weighted 2-part score
2bcd6c2
Raw
History Blame
9.32 kB
"""
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")]