talentry-ai / src /talentry /features /builder.py
williyam's picture
deploy: sync from talentry-ai @ f4ea7a8
c008e60
Raw
History Blame Contribute Delete
3.89 kB
"""Feature builders: per-candidate text blobs and role signals.
These are the *only* per-candidate features used for hot-path scoring. We keep
them tiny and pure so they're trivially testable and cheap at 100K scale.
"""
from __future__ import annotations
from talentry.core.models import Candidate
from talentry.nlp.lexicons import (
CONSULTING_FIRMS,
PRODUCT_COMPANY_HINTS,
ROLE_KEYWORD_INDEX,
)
from talentry.nlp.tokenize import normalise
def build_text_blob(c: Candidate) -> str:
"""Compose the searchable text used by BM25 / TF-IDF.
We deliberately *upweight* the parts of the profile that the JD says are
most signal-bearing - career-history descriptions and the summary - by
repeating them. BM25 saturates so the duplication just nudges term
frequency in the right direction without breaking IDF.
"""
parts: list[str] = [
c.headline,
c.summary, c.summary, # 2x - JD: read between the lines of the summary
c.current_title, c.current_title,
c.current_industry,
]
for entry in c.career:
parts.append(entry.title)
parts.append(entry.description)
parts.append(entry.description) # 2x - career description = where IR/RAG hides
parts.append(entry.industry)
for s in c.skills:
parts.append(s.name)
for e in c.education:
parts.append(f"{e.degree} {e.field_of_study} {e.institution}")
blob = " ".join(p for p in parts if p)
c.text_blob = blob
return blob
def _match_role(title: str) -> tuple[str, float]:
"""Map a free-text title to (family, family_score). Longest-match wins."""
if not title:
return ("unknown", 0.0)
t = normalise(title)
best: tuple[str, float, int] = ("unknown", 0.0, 0) # (family, score, kw_len)
for kw, (fam, score) in ROLE_KEYWORD_INDEX.items():
if kw in t and len(kw) > best[2]:
best = (fam, score, len(kw))
return best[0], best[1]
def build_role_signals(c: Candidate) -> dict[str, float | str | bool]:
"""Derive role-trajectory features used by the title/career alignment scorer.
Returns a flat dict so callers don't pay attribute-access overhead in the
hot loop.
"""
current_family, current_score = _match_role(c.current_title)
# Look at the last (up to) 3 roles for trajectory analysis.
recent = c.career[:3] if c.career else []
recent_families: list[str] = []
recent_scores: list[float] = []
for r in recent:
fam, sc = _match_role(r.title)
recent_families.append(fam)
recent_scores.append(sc)
avg_recent_score = sum(recent_scores) / len(recent_scores) if recent_scores else current_score
# Tenure-only-at-consulting-firms check.
companies = [normalise(r.company) for r in c.career] + [normalise(c.current_company)]
companies = [x for x in companies if x]
only_consulting = bool(companies) and all(
any(firm in co for firm in CONSULTING_FIRMS) for co in companies
)
has_product_company = any(
any(p in co for p in PRODUCT_COMPANY_HINTS) for co in companies
)
# JD: penalise people who haven't shipped code in last 18 months
# (moved into pure "tech lead" / "architecture"). We approximate that by
# current_title being a pure-management title.
mgmt_titles = {"manager", "director", "head", "vp", "vice president", "chief"}
cur_t = normalise(c.current_title)
pure_mgmt = any(m in cur_t for m in mgmt_titles) and not any(
eng in cur_t for eng in ("engineer", "developer", "scientist", "architect")
)
return {
"current_family": current_family,
"current_family_score": current_score,
"avg_recent_family_score": avg_recent_score,
"only_consulting": only_consulting,
"has_product_company": has_product_company,
"pure_management_track": pure_mgmt,
}