talentry-ai / src /talentry /features /skill_match.py
williyam's picture
deploy: sync from talentry-ai @ f4ea7a8
c008e60
Raw
History Blame Contribute Delete
6.52 kB
"""Skill evidence scoring - the anti-keyword-stuffer core of the ranker.
Why is this a separate module?
The JD is *explicit* about the trap built into the dataset:
A candidate who has all the AI keywords listed as skills but whose title is
'Marketing Manager' is not a fit, no matter how perfect their skill list
looks.
If we score skills as a flat keyword match (skill name in JD ⇒ +1) we will
rank stuffers in our top-10 and disqualify ourselves at Stage 3 via the
honeypot filter. So instead we score *evidence* per claimed skill:
evidence = f(endorsements, duration_months, proficiency, assessment_score)
…then aggregate per skill-cluster. A "LangChain" claim with zero endorsements,
3 months of usage and no assessment is worth essentially nothing.
Returns are bounded in [0, 1] per cluster so the downstream linear combination
remains interpretable.
"""
from __future__ import annotations
from dataclasses import dataclass
from rapidfuzz import fuzz
from talentry.core.models import Candidate, Skill
from talentry.nlp.lexicons import (
CV_ONLY_SKILLS,
SKILL_CLUSTERS,
SPEECH_ONLY_SKILLS,
)
from talentry.nlp.tokenize import normalise
# Proficiency → base trust multiplier.
_PROFICIENCY_WEIGHT: dict[str, float] = {
"beginner": 0.25,
"intermediate": 0.55,
"advanced": 0.85,
"expert": 1.0,
}
# Skill-name fuzzy threshold for lexical matching ("postgresql" ↔ "postgres").
_FUZZY_THRESHOLD = 88
@dataclass(slots=True)
class SkillEvidence:
"""Aggregated, evidence-weighted skill scoring for one candidate."""
cluster_scores: dict[str, float]
must_have_hits: list[str]
must_have_misses: list[str]
cv_or_speech_dominance: float # 0..1, share of *evidence* attributable to CV/speech only
keyword_stuff_ratio: float # 0..1, share of cluster hits that are pure-keyword (no evidence)
@property
def overall(self) -> float:
"""Mean of cluster scores - a fast scalar summary."""
if not self.cluster_scores:
return 0.0
return sum(self.cluster_scores.values()) / len(self.cluster_scores)
def _skill_trust(skill: Skill) -> float:
"""Return a [0,1] trust score for a single claimed skill.
A keyword-stuffer typically posts `expert` claims with 0-3 endorsements
and 0-6 months of usage. Real practitioners have *long* duration and
accumulate endorsements. We make those facts numeric.
"""
prof = _PROFICIENCY_WEIGHT.get(skill.proficiency, 0.4)
# Endorsements saturate around 50 (per the dataset's natural max ≈ 60).
endorse = min(skill.endorsements, 50) / 50.0
# Duration saturates around 36 months.
dur = min(skill.duration_months, 36) / 36.0
# Assessment score (if present) is the strongest signal.
if skill.assessment_score is not None:
assess = skill.assessment_score / 100.0
return min(1.0, 0.40 * prof + 0.20 * endorse + 0.10 * dur + 0.30 * assess)
return min(1.0, 0.55 * prof + 0.25 * endorse + 0.20 * dur)
def _matches_cluster(skill_name: str, cluster: list[str]) -> str | None:
"""Return the cluster member matched (or None) using fuzzy comparison."""
n = normalise(skill_name)
if not n:
return None
for member in cluster:
m = normalise(member)
if m in n or n in m:
return member
if fuzz.ratio(n, m) >= _FUZZY_THRESHOLD:
return member
return None
def score_skill_evidence(c: Candidate, must_have: list[str]) -> SkillEvidence:
"""Compute :class:`SkillEvidence` for one candidate.
`must_have` is the JD's "things you absolutely need" list.
"""
cluster_total: dict[str, float] = dict.fromkeys(SKILL_CLUSTERS.keys(), 0.0)
cluster_count: dict[str, int] = dict.fromkeys(SKILL_CLUSTERS.keys(), 0)
cv_trust = 0.0
speech_trust = 0.0
total_trust = 1e-9
stuff_hits = 0
stuff_total = 0
for s in c.skills:
t = _skill_trust(s)
total_trust += t
sn = normalise(s.name)
if sn in CV_ONLY_SKILLS:
cv_trust += t
if sn in SPEECH_ONLY_SKILLS:
speech_trust += t
# Stuffer probe runs independently of cluster assignment: any AI
# keyword surface claim contributes to the stuff ratio if the
# candidate posted it with high proficiency but trivial evidence.
# "Trivial evidence" = essentially zero endorsements AND short usage,
# regardless of the proficiency label they self-assigned.
if _matches_cluster(s.name, SKILL_CLUSTERS["ai_keyword_surface"]):
stuff_total += 1
looks_padded = (
s.proficiency in {"advanced", "expert"}
and s.endorsements <= 2
and s.duration_months <= 6
and s.assessment_score is None
)
if looks_padded or t < 0.40:
stuff_hits += 1
# For cluster contribution we still pick the most specific (first)
# cluster so we don't double-count one skill.
for cluster_name, members in SKILL_CLUSTERS.items():
if cluster_name == "ai_keyword_surface":
continue
if _matches_cluster(s.name, members):
cluster_total[cluster_name] += t
cluster_count[cluster_name] += 1
break
# Normalise each cluster to [0,1]. We divide by the smaller of
# (cluster_size, 4) so a candidate doesn't need to list 12 retrieval skills
# to score a perfect 1.0 on that cluster.
cluster_scores: dict[str, float] = {}
for cluster_name, total in cluster_total.items():
target = min(len(SKILL_CLUSTERS[cluster_name]), 4)
cluster_scores[cluster_name] = min(1.0, total / target) if target else 0.0
# Must-have hit / miss diagnostics (free strings - used by the reasoning
# composer).
must_hits: list[str] = []
must_misses: list[str] = []
for need in must_have:
if any(_matches_cluster(s.name, [need]) for s in c.skills):
must_hits.append(need)
else:
must_misses.append(need)
cv_or_speech_dominance = (cv_trust + speech_trust) / total_trust
keyword_stuff_ratio = (stuff_hits / stuff_total) if stuff_total else 0.0
return SkillEvidence(
cluster_scores=cluster_scores,
must_have_hits=must_hits,
must_have_misses=must_misses,
cv_or_speech_dominance=min(1.0, cv_or_speech_dominance),
keyword_stuff_ratio=keyword_stuff_ratio,
)