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