122 lines
5.0 KiB
Python
122 lines
5.0 KiB
Python
"""Deterministic job-fit ranking, shared by Find Talent and the CV Bank.
|
|
|
|
This arithmetic started life in talent/plugins.py for LinkedIn profiles. The CV
|
|
Bank needs the same thing for stored resumes, and two copies of a scoring rule
|
|
drift: one gets tuned against live data and the other quietly does not. So the
|
|
implementation lives here and talent/plugins.py re-exports it.
|
|
|
|
What this is NOT: an ATS score. There is no comprehension here, only token
|
|
overlap. It orders a pile of CVs so a recruiter can start at the top; it does
|
|
not judge whether anyone is qualified. The paid OpenAI score does that, and
|
|
only for the handful a human decides to shortlist.
|
|
|
|
Pure module: no FastAPI, no database, no I/O.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
_TOKEN_STOPWORDS = {
|
|
"and", "or", "the", "of", "for", "with", "in", "a", "an", "to",
|
|
# Requirement-prose filler that appears in almost every profile and would
|
|
# inflate every score equally, flattening the ranking.
|
|
"experience", "years", "year", "strong", "including", "ability",
|
|
"knowledge", "skills", "understanding", "familiarity", "proficiency",
|
|
"hands", "must", "have", "plus", "good", "excellent", "etc",
|
|
}
|
|
|
|
# A resume's full text is mostly prose; feeding all of it to the token overlap
|
|
# would match half the dictionary and flatten every score toward the ceiling.
|
|
# Only a lead excerpt is used, which in practice is the summary/skills header.
|
|
RESUME_EXCERPT_CHARS = 1200
|
|
|
|
|
|
def _clean_phrase(text) -> str:
|
|
cleaned = re.sub(r"[^a-z0-9+#]+", " ", str(text or "").lower())
|
|
return " ".join(
|
|
t for t in cleaned.split() if len(t) > 1 and t not in _TOKEN_STOPWORDS
|
|
)
|
|
|
|
|
|
def _match_tokens(*texts) -> set[str]:
|
|
tokens: set[str] = set()
|
|
for text in texts:
|
|
tokens.update(_clean_phrase(text).split())
|
|
return tokens
|
|
|
|
|
|
def rank_profile(job: dict, profile: dict) -> int:
|
|
"""0-100 job-fit rank for sorting, computed when a profile is persisted.
|
|
|
|
Deterministic and free. Title component: a current title CONTAINING every
|
|
job-title token scores 55 — containment, not exact phrase, because job
|
|
titles rarely reappear verbatim ("Generative Engineer" vs the pool's
|
|
"Generative AI Engineer"; seen live: the phrase rule dropped every real
|
|
match to the scattered tier and compressed the whole pool into the 40s).
|
|
The job title as an exact phrase in the headline scores 45; scattered
|
|
token overlap caps at 35 — a keyword-stuffed headline ("AI/ML Engineer |
|
|
Python | FastAPI | ...") must not outrank someone whose title IS the job
|
|
title, which is exactly what token overlap alone did on live data. The
|
|
headline tier stays phrase-only for the same reason: stuffed headlines
|
|
contain every token of every hot title.
|
|
|
|
Skills component (up to 45): GRADED token overlap between the content
|
|
words of the job's requirements + optional skills and the person's
|
|
title/headline/skills/summary. Graded, not per-term all-or-nothing: the
|
|
title facet makes every sourced profile earn the same title points, so
|
|
all differentiation lives here — an all-or-nothing single term put a
|
|
whole live pool on exactly 60.
|
|
"""
|
|
job_title = _clean_phrase(job.get("title"))
|
|
job_title_tokens = set(job_title.split())
|
|
title_text = _clean_phrase(profile.get("current_title"))
|
|
headline_text = _clean_phrase(profile.get("headline"))
|
|
if job_title and job_title_tokens <= set(title_text.split()):
|
|
title_component = 55.0
|
|
elif job_title and job_title in headline_text:
|
|
title_component = 45.0
|
|
else:
|
|
role_tokens = set(title_text.split()) | set(headline_text.split())
|
|
ratio = (
|
|
len(job_title_tokens & role_tokens) / len(job_title_tokens)
|
|
if job_title_tokens
|
|
else 0.0
|
|
)
|
|
title_component = 35 * ratio
|
|
|
|
job_tokens = _match_tokens(
|
|
*(job.get("requirements") or []), *(job.get("optional_skills") or [])
|
|
)
|
|
profile_tokens = _match_tokens(
|
|
profile.get("current_title"),
|
|
profile.get("headline"),
|
|
" ".join(profile.get("skills") or []),
|
|
profile.get("summary"),
|
|
)
|
|
skills_ratio = (
|
|
len(job_tokens & profile_tokens) / len(job_tokens) if job_tokens else 0.0
|
|
)
|
|
|
|
return round(title_component + 45 * skills_ratio)
|
|
|
|
|
|
def bank_row_as_profile(row) -> dict:
|
|
"""Map a manual_upload_candidate bank row onto the profile shape.
|
|
|
|
A resume has no headline, so the employer stands in for one: it is the
|
|
other short, title-adjacent string a person is described by. full_text is
|
|
the summary, truncated — see RESUME_EXCERPT_CHARS.
|
|
"""
|
|
return {
|
|
"current_title": getattr(row, "current_position", "") or "",
|
|
"headline": getattr(row, "current_company", "") or "",
|
|
"skills": list(getattr(row, "skills", None) or []),
|
|
"summary": (getattr(row, "full_text", "") or "")[:RESUME_EXCERPT_CHARS],
|
|
}
|
|
|
|
|
|
def rank_bank_row(job: dict, row) -> int:
|
|
"""Tier-1 rank for one banked CV against one job."""
|
|
return rank_profile(job, bank_row_as_profile(row))
|