HR-ATS-Portal/backend/matching/ranking.py

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))