Upload 11 files
Browse files- src/.gitkeep +1 -0
- src/availability.py +132 -0
- src/hard_filter.py +132 -0
- src/output.py +111 -0
- src/pipeline.py +112 -0
- src/precompute.py +154 -0
- src/raw_score.py +81 -0
- src/run_fast_pipeline.py +156 -0
- src/score_career.py +119 -0
- src/score_embed.py +34 -0
- src/score_skills.py +138 -0
src/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Keep directory
|
src/availability.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import datetime
|
| 2 |
+
from dateutil import parser
|
| 3 |
+
|
| 4 |
+
def compute_multipliers(candidate: dict, jd: dict) -> dict[str, float]:
|
| 5 |
+
"""Computes the availability and location multipliers for a candidate, supporting nested schemas."""
|
| 6 |
+
signals = candidate.get("redrob_signals") or {}
|
| 7 |
+
profile = candidate.get("profile") or {}
|
| 8 |
+
|
| 9 |
+
# --- Availability Multiplier ---
|
| 10 |
+
availability_mult = 1.0
|
| 11 |
+
|
| 12 |
+
# open_to_work_flag
|
| 13 |
+
open_to_work = signals.get("open_to_work_flag")
|
| 14 |
+
if open_to_work is None:
|
| 15 |
+
open_to_work = candidate.get("open_to_work_flag")
|
| 16 |
+
if open_to_work is True:
|
| 17 |
+
availability_mult += 0.10
|
| 18 |
+
|
| 19 |
+
# last_active_date
|
| 20 |
+
last_active_str = signals.get("last_active_date") or candidate.get("last_active_date")
|
| 21 |
+
days_inactive = None
|
| 22 |
+
if last_active_str:
|
| 23 |
+
try:
|
| 24 |
+
last_active = parser.parse(last_active_str)
|
| 25 |
+
if last_active.tzinfo is not None:
|
| 26 |
+
today = datetime.datetime.now(datetime.timezone.utc)
|
| 27 |
+
else:
|
| 28 |
+
today = datetime.datetime.now()
|
| 29 |
+
days_inactive = (today - last_active).days
|
| 30 |
+
except Exception:
|
| 31 |
+
pass
|
| 32 |
+
|
| 33 |
+
if days_inactive is not None:
|
| 34 |
+
if days_inactive <= 14:
|
| 35 |
+
availability_mult += 0.10
|
| 36 |
+
if days_inactive <= 7:
|
| 37 |
+
availability_mult += 0.05 # stacks
|
| 38 |
+
if days_inactive > 90:
|
| 39 |
+
availability_mult -= 0.25
|
| 40 |
+
|
| 41 |
+
# recruiter_response_rate
|
| 42 |
+
response_rate = signals.get("recruiter_response_rate")
|
| 43 |
+
if response_rate is None:
|
| 44 |
+
response_rate = candidate.get("recruiter_response_rate", 0.0)
|
| 45 |
+
if float(response_rate) >= 0.70:
|
| 46 |
+
availability_mult += 0.05
|
| 47 |
+
|
| 48 |
+
# offer_acceptance_rate
|
| 49 |
+
acceptance_rate = signals.get("offer_acceptance_rate")
|
| 50 |
+
if acceptance_rate is None:
|
| 51 |
+
acceptance_rate = candidate.get("offer_acceptance_rate")
|
| 52 |
+
if acceptance_rate is not None and acceptance_rate != -1:
|
| 53 |
+
if float(acceptance_rate) >= 0.80:
|
| 54 |
+
availability_mult += 0.05
|
| 55 |
+
|
| 56 |
+
# avg_response_time_hours
|
| 57 |
+
avg_resp_time = signals.get("avg_response_time_hours")
|
| 58 |
+
if avg_resp_time is None:
|
| 59 |
+
avg_resp_time = candidate.get("avg_response_time_hours")
|
| 60 |
+
if avg_resp_time is not None and float(avg_resp_time) > 72:
|
| 61 |
+
availability_mult -= 0.05
|
| 62 |
+
|
| 63 |
+
# notice_period_days
|
| 64 |
+
notice_period = signals.get("notice_period_days")
|
| 65 |
+
if notice_period is None:
|
| 66 |
+
notice_period = candidate.get("notice_period_days")
|
| 67 |
+
if notice_period is not None and int(notice_period) > 90:
|
| 68 |
+
availability_mult -= 0.10
|
| 69 |
+
|
| 70 |
+
# interview_completion_rate
|
| 71 |
+
completion_rate = signals.get("interview_completion_rate")
|
| 72 |
+
if completion_rate is None:
|
| 73 |
+
completion_rate = candidate.get("interview_completion_rate", 0.0)
|
| 74 |
+
if float(completion_rate) < 0.50:
|
| 75 |
+
availability_mult -= 0.15
|
| 76 |
+
|
| 77 |
+
# expected_salary_range_inr_lpa.min
|
| 78 |
+
salary_range = signals.get("expected_salary_range_inr_lpa") or candidate.get("expected_salary_range_inr_lpa") or {}
|
| 79 |
+
salary_min = 0.0
|
| 80 |
+
if isinstance(salary_range, dict):
|
| 81 |
+
salary_min = salary_range.get("min") or 0.0
|
| 82 |
+
elif isinstance(salary_range, (int, float)):
|
| 83 |
+
salary_min = salary_range
|
| 84 |
+
|
| 85 |
+
budget_max = jd.get("budget_max_inr_lpa") or 40
|
| 86 |
+
if salary_min > budget_max:
|
| 87 |
+
availability_mult -= 0.20
|
| 88 |
+
|
| 89 |
+
# Clamp availability_mult to [0.50, 1.25]
|
| 90 |
+
availability_mult = max(0.50, min(availability_mult, 1.25))
|
| 91 |
+
|
| 92 |
+
# --- Location Multiplier ---
|
| 93 |
+
location_mult = 1.0
|
| 94 |
+
|
| 95 |
+
cand_loc = str(profile.get("location") or candidate.get("location") or "").lower().strip()
|
| 96 |
+
jd_locs = {str(loc).lower().strip() for loc in (jd.get("preferred_locations") or []) if loc}
|
| 97 |
+
|
| 98 |
+
if cand_loc in jd_locs:
|
| 99 |
+
location_mult += 0.05
|
| 100 |
+
else:
|
| 101 |
+
willing_to_relocate = signals.get("willing_to_relocate")
|
| 102 |
+
if willing_to_relocate is None:
|
| 103 |
+
willing_to_relocate = candidate.get("willing_to_relocate", True)
|
| 104 |
+
if willing_to_relocate is False:
|
| 105 |
+
location_mult -= 0.05
|
| 106 |
+
|
| 107 |
+
# Clamp location_mult to [0.70, 1.05]
|
| 108 |
+
location_mult = max(0.70, min(location_mult, 1.05))
|
| 109 |
+
|
| 110 |
+
return {
|
| 111 |
+
"availability_mult": round(availability_mult, 4),
|
| 112 |
+
"location_mult": round(location_mult, 4)
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
def apply_multipliers(scored_results: list[dict], jd: dict) -> list[dict]:
|
| 116 |
+
"""Applies availability and location multipliers to the scored results."""
|
| 117 |
+
updated_results = []
|
| 118 |
+
for res in scored_results:
|
| 119 |
+
mults = compute_multipliers(res["candidate"], jd)
|
| 120 |
+
av_mult = mults["availability_mult"]
|
| 121 |
+
loc_mult = mults["location_mult"]
|
| 122 |
+
|
| 123 |
+
raw_score = res["raw_score"]
|
| 124 |
+
final_score = round(min(raw_score * av_mult * loc_mult, 1.0), 4)
|
| 125 |
+
|
| 126 |
+
updated_res = res.copy()
|
| 127 |
+
updated_res["availability_mult"] = av_mult
|
| 128 |
+
updated_res["location_mult"] = loc_mult
|
| 129 |
+
updated_res["final_score"] = final_score
|
| 130 |
+
updated_results.append(updated_res)
|
| 131 |
+
|
| 132 |
+
return updated_results
|
src/hard_filter.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import datetime
|
| 2 |
+
from dateutil import parser
|
| 3 |
+
from sklearn.metrics.pairwise import cosine_similarity
|
| 4 |
+
|
| 5 |
+
def is_killed(candidate: dict, jd: dict, tfidf) -> tuple[bool, str]:
|
| 6 |
+
"""Applies the 8 hard filtering rules in order, short-circuiting on first match.
|
| 7 |
+
|
| 8 |
+
Supports both nested (actual dataset) and flat (test fixture) candidate structures.
|
| 9 |
+
"""
|
| 10 |
+
signals = candidate.get("redrob_signals") or {}
|
| 11 |
+
profile = candidate.get("profile") or {}
|
| 12 |
+
|
| 13 |
+
# 1. Profile completeness score < 30
|
| 14 |
+
profile_completeness = signals.get("profile_completeness_score") or candidate.get("profile_completeness_score", 0)
|
| 15 |
+
if profile_completeness < 30:
|
| 16 |
+
return True, f"profile_completeness_score {profile_completeness} < 30"
|
| 17 |
+
|
| 18 |
+
# 2. Verified email == False
|
| 19 |
+
verified_email = signals.get("verified_email")
|
| 20 |
+
if verified_email is None:
|
| 21 |
+
verified_email = candidate.get("verified_email", False)
|
| 22 |
+
if verified_email is False:
|
| 23 |
+
return True, "verified_email == False"
|
| 24 |
+
|
| 25 |
+
# 3. Interview completion rate < 0.20
|
| 26 |
+
interview_completion_rate = signals.get("interview_completion_rate")
|
| 27 |
+
if interview_completion_rate is None:
|
| 28 |
+
interview_completion_rate = candidate.get("interview_completion_rate", 0.0)
|
| 29 |
+
if interview_completion_rate < 0.20:
|
| 30 |
+
return True, f"interview_completion_rate {interview_completion_rate} < 0.20"
|
| 31 |
+
|
| 32 |
+
# 4. (today - last_active_date).days > 180
|
| 33 |
+
last_active_date_str = signals.get("last_active_date") or candidate.get("last_active_date")
|
| 34 |
+
if not last_active_date_str:
|
| 35 |
+
return True, "Missing last_active_date"
|
| 36 |
+
try:
|
| 37 |
+
last_active_date = parser.parse(last_active_date_str)
|
| 38 |
+
if last_active_date.tzinfo is not None:
|
| 39 |
+
today = datetime.datetime.now(datetime.timezone.utc)
|
| 40 |
+
else:
|
| 41 |
+
today = datetime.datetime.now()
|
| 42 |
+
days_inactive = (today - last_active_date).days
|
| 43 |
+
if days_inactive > 180:
|
| 44 |
+
return True, f"Inactive for {days_inactive} days (> 180 days)"
|
| 45 |
+
except Exception as e:
|
| 46 |
+
return True, f"Failed to parse last_active_date: {str(e)}"
|
| 47 |
+
|
| 48 |
+
# 5. Open to work flag == False
|
| 49 |
+
open_to_work = signals.get("open_to_work_flag")
|
| 50 |
+
if open_to_work is None:
|
| 51 |
+
open_to_work = candidate.get("open_to_work_flag", False)
|
| 52 |
+
if open_to_work is False:
|
| 53 |
+
return True, "open_to_work_flag == False"
|
| 54 |
+
|
| 55 |
+
# 6. Zero overlap between candidate's industries and jd.target_industries using Python set intersection
|
| 56 |
+
cand_industries = set()
|
| 57 |
+
# Check profile industry
|
| 58 |
+
prof_ind = profile.get("current_industry")
|
| 59 |
+
if prof_ind:
|
| 60 |
+
cand_industries.add(prof_ind.strip().lower())
|
| 61 |
+
# Check career history industries
|
| 62 |
+
history = candidate.get("career_history") or candidate.get("experience") or candidate.get("work_experience") or []
|
| 63 |
+
for job in history:
|
| 64 |
+
if isinstance(job, dict) and job.get("industry"):
|
| 65 |
+
cand_industries.add(job["industry"].strip().lower())
|
| 66 |
+
# Fallback to top-level industries list if present (like in mock fixtures)
|
| 67 |
+
for ind in (candidate.get("industries") or []):
|
| 68 |
+
if ind:
|
| 69 |
+
cand_industries.add(ind.strip().lower())
|
| 70 |
+
|
| 71 |
+
jd_industries = {ind.strip().lower() for ind in (jd.get("target_industries") or []) if ind}
|
| 72 |
+
if not cand_industries.intersection(jd_industries):
|
| 73 |
+
return True, f"Zero industry overlap (Candidate: {cand_industries}, JD: {jd_industries})"
|
| 74 |
+
|
| 75 |
+
# 7. TF-IDF cosine similarity between current_title and jd.title < 0.05
|
| 76 |
+
cand_title = profile.get("current_title") or candidate.get("current_title", "")
|
| 77 |
+
jd_title = jd.get("title", "")
|
| 78 |
+
if not cand_title or not jd_title:
|
| 79 |
+
sim = 0.0
|
| 80 |
+
else:
|
| 81 |
+
try:
|
| 82 |
+
cand_tfidf = tfidf.transform([cand_title])
|
| 83 |
+
jd_tfidf = tfidf.transform([jd_title])
|
| 84 |
+
sim = cosine_similarity(cand_tfidf, jd_tfidf)[0][0]
|
| 85 |
+
except Exception:
|
| 86 |
+
sim = 0.0
|
| 87 |
+
if sim < 0.05:
|
| 88 |
+
return True, f"Title similarity {sim:.4f} < 0.05 (Candidate: '{cand_title}', JD: '{jd_title}')"
|
| 89 |
+
|
| 90 |
+
# 8. Honeypot check: github_activity_score == -1 AND empty skill_assessment_scores AND endorsements_received == 0 AND connection_count < 5
|
| 91 |
+
github_score = signals.get("github_activity_score")
|
| 92 |
+
if github_score is None:
|
| 93 |
+
github_score = candidate.get("github_activity_score", 0)
|
| 94 |
+
|
| 95 |
+
skill_assessments = signals.get("skill_assessment_scores")
|
| 96 |
+
if skill_assessments is None:
|
| 97 |
+
skill_assessments = candidate.get("skill_assessment_scores") or {}
|
| 98 |
+
|
| 99 |
+
endorsements = signals.get("endorsements_received")
|
| 100 |
+
if endorsements is None:
|
| 101 |
+
endorsements = candidate.get("endorsements_received", 0)
|
| 102 |
+
|
| 103 |
+
connections = signals.get("connection_count")
|
| 104 |
+
if connections is None:
|
| 105 |
+
connections = candidate.get("connection_count", 0)
|
| 106 |
+
|
| 107 |
+
if (github_score == -1 and
|
| 108 |
+
not skill_assessments and
|
| 109 |
+
endorsements == 0 and
|
| 110 |
+
connections < 5):
|
| 111 |
+
return True, f"Honeypot detected (GitHub: {github_score}, Assessments: {skill_assessments}, Endorsements: {endorsements}, Connections: {connections})"
|
| 112 |
+
|
| 113 |
+
return False, ""
|
| 114 |
+
|
| 115 |
+
def apply_hard_filter(candidates: list[dict], jd: dict, tfidf) -> tuple[list[dict], list[dict]]:
|
| 116 |
+
"""Applies the hard filter rules to a list of candidates.
|
| 117 |
+
|
| 118 |
+
Returns (survivors_list, killed_list).
|
| 119 |
+
"""
|
| 120 |
+
survivors = []
|
| 121 |
+
killed = []
|
| 122 |
+
for cand in candidates:
|
| 123 |
+
killed_flag, reason = is_killed(cand, jd, tfidf)
|
| 124 |
+
if killed_flag:
|
| 125 |
+
killed.append({
|
| 126 |
+
"candidate_id": cand.get("candidate_id") or cand.get("id"),
|
| 127 |
+
"candidate": cand,
|
| 128 |
+
"reason": reason
|
| 129 |
+
})
|
| 130 |
+
else:
|
| 131 |
+
survivors.append(cand)
|
| 132 |
+
return survivors, killed
|
src/output.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import polars as pl
|
| 2 |
+
|
| 3 |
+
def generate_reasoning(result: dict, jd: dict) -> str:
|
| 4 |
+
"""Generates a structured reasoning string for a candidate's fit, supporting nested schemas."""
|
| 5 |
+
candidate = result["candidate"]
|
| 6 |
+
profile = candidate.get("profile") or {}
|
| 7 |
+
signals = candidate.get("redrob_signals") or {}
|
| 8 |
+
|
| 9 |
+
name = profile.get("anonymized_name") or profile.get("name") or candidate.get("name") or "Unknown"
|
| 10 |
+
yoe = profile.get("years_of_experience") or profile.get("yoe") or candidate.get("years_of_experience") or candidate.get("yoe") or 0
|
| 11 |
+
|
| 12 |
+
# Get current company/title
|
| 13 |
+
history = candidate.get("career_history") or candidate.get("experience") or candidate.get("work_experience") or []
|
| 14 |
+
company = profile.get("current_company") or "Unknown"
|
| 15 |
+
title = profile.get("current_title") or candidate.get("current_title") or ""
|
| 16 |
+
|
| 17 |
+
if history and isinstance(history, list) and isinstance(history[0], dict):
|
| 18 |
+
if not title:
|
| 19 |
+
title = history[0].get("title") or ""
|
| 20 |
+
if company == "Unknown":
|
| 21 |
+
company = history[0].get("company") or history[0].get("company_name") or "Unknown"
|
| 22 |
+
|
| 23 |
+
if not title:
|
| 24 |
+
title = "Candidate"
|
| 25 |
+
|
| 26 |
+
must_have_coverage = result.get("must_have_coverage", 0.0)
|
| 27 |
+
|
| 28 |
+
# Extract top 3 skill names
|
| 29 |
+
cand_skills = candidate.get("skills") or []
|
| 30 |
+
def skill_key(s):
|
| 31 |
+
if isinstance(s, dict):
|
| 32 |
+
return (s.get("endorsements") or 0) + (s.get("duration_months") or 0)
|
| 33 |
+
return 0
|
| 34 |
+
sorted_skills = sorted(cand_skills, key=skill_key, reverse=True)
|
| 35 |
+
skill_names = []
|
| 36 |
+
for s in sorted_skills[:3]:
|
| 37 |
+
if isinstance(s, dict):
|
| 38 |
+
skill_names.append(s.get("name", ""))
|
| 39 |
+
elif isinstance(s, str):
|
| 40 |
+
skill_names.append(s)
|
| 41 |
+
top_skills_str = ", ".join(filter(None, skill_names)) or "None"
|
| 42 |
+
|
| 43 |
+
open_to_work = bool(signals.get("open_to_work_flag") if signals.get("open_to_work_flag") is not None else candidate.get("open_to_work_flag", False))
|
| 44 |
+
notice_period_days = signals.get("notice_period_days") if signals.get("notice_period_days") is not None else candidate.get("notice_period_days", 0)
|
| 45 |
+
github_activity_score = signals.get("github_activity_score") if signals.get("github_activity_score") is not None else candidate.get("github_activity_score", 0)
|
| 46 |
+
|
| 47 |
+
final_score = result["final_score"]
|
| 48 |
+
A = result["A"]
|
| 49 |
+
B = result["B"]
|
| 50 |
+
C = result["C"]
|
| 51 |
+
|
| 52 |
+
return (
|
| 53 |
+
f"{name} | {yoe}y exp | {title} @ {company} | "
|
| 54 |
+
f"Skill match: {must_have_coverage:.0%} must-haves covered | "
|
| 55 |
+
f"Top skills: {top_skills_str} | Open to work: {open_to_work} | "
|
| 56 |
+
f"Notice: {notice_period_days}d | GitHub: {github_activity_score} | "
|
| 57 |
+
f"Score: {final_score:.4f} (A={A:.3f} B={B:.3f} C={C:.3f})"
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
def write_submission(results: list[dict], jd: dict, out_path: str = 'submission.csv') -> pl.DataFrame:
|
| 61 |
+
"""Converts results to a Polars DataFrame, ranks the top 100, and writes them to a CSV."""
|
| 62 |
+
rows = []
|
| 63 |
+
for res in results:
|
| 64 |
+
cand = res["candidate"]
|
| 65 |
+
signals = cand.get("redrob_signals") or {}
|
| 66 |
+
reasoning = generate_reasoning(res, jd)
|
| 67 |
+
|
| 68 |
+
rows.append({
|
| 69 |
+
"candidate_id": res["candidate_id"],
|
| 70 |
+
"final_score": res["final_score"],
|
| 71 |
+
"raw_score": res["raw_score"],
|
| 72 |
+
"availability_mult": res["availability_mult"],
|
| 73 |
+
"location_mult": res["location_mult"],
|
| 74 |
+
"reasoning": reasoning,
|
| 75 |
+
"profile_completeness_score": signals.get("profile_completeness_score") or cand.get("profile_completeness_score", 0),
|
| 76 |
+
"saved_by_recruiters_30d": signals.get("saved_by_recruiters_30d") or cand.get("saved_by_recruiters_30d", 0),
|
| 77 |
+
"component_scores": f"A={res['A']:.3f}, B={res['B']:.3f}, C={res['C']:.3f}"
|
| 78 |
+
})
|
| 79 |
+
|
| 80 |
+
df = pl.DataFrame(rows)
|
| 81 |
+
|
| 82 |
+
# Sort by final_score, then profile_completeness_score, then saved_by_recruiters_30d (all descending)
|
| 83 |
+
df_sorted = df.sort(
|
| 84 |
+
by=["final_score", "profile_completeness_score", "saved_by_recruiters_30d"],
|
| 85 |
+
descending=[True, True, True]
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# Take top 100
|
| 89 |
+
df_top100 = df_sorted.head(100)
|
| 90 |
+
|
| 91 |
+
# Add rank column (1-indexed)
|
| 92 |
+
df_top100 = df_top100.with_columns(
|
| 93 |
+
pl.int_range(1, df_top100.height + 1).alias("rank")
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
# Select and reorder
|
| 97 |
+
df_final = df_top100.select([
|
| 98 |
+
"rank",
|
| 99 |
+
"candidate_id",
|
| 100 |
+
"final_score",
|
| 101 |
+
"raw_score",
|
| 102 |
+
"component_scores",
|
| 103 |
+
"availability_mult",
|
| 104 |
+
"location_mult",
|
| 105 |
+
"reasoning"
|
| 106 |
+
])
|
| 107 |
+
|
| 108 |
+
df_final.write_csv(out_path)
|
| 109 |
+
print(f"Successfully wrote top {df_final.height} candidates to {out_path}")
|
| 110 |
+
|
| 111 |
+
return df_final
|
src/pipeline.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import pickle
|
| 4 |
+
import time
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from src.hard_filter import apply_hard_filter
|
| 8 |
+
from src.raw_score import compute_raw_scores
|
| 9 |
+
from src.availability import apply_multipliers
|
| 10 |
+
from src.output import write_submission
|
| 11 |
+
|
| 12 |
+
def run_pipeline(candidates_path: str, jd_path: str, output_path: str):
|
| 13 |
+
"""Executes the complete candidate vetting, scoring, and ranking pipeline."""
|
| 14 |
+
start_time = time.perf_counter()
|
| 15 |
+
|
| 16 |
+
print("=" * 60)
|
| 17 |
+
print("STARTING VETTLY CANDIDATE SCORING AND FILTERING PIPELINE")
|
| 18 |
+
print("=" * 60)
|
| 19 |
+
|
| 20 |
+
# Resolve paths
|
| 21 |
+
cand_path = Path(candidates_path)
|
| 22 |
+
job_desc_path = Path(jd_path)
|
| 23 |
+
out_path = Path(output_path)
|
| 24 |
+
|
| 25 |
+
# Fallback to check if file exists, if not check common folders
|
| 26 |
+
if not cand_path.exists():
|
| 27 |
+
for alt in [Path("data").joinpath(cand_path.name), Path("[PUB] India_runs_data_and_ai_challenge").joinpath("India_runs_data_and_ai_challenge").joinpath(cand_path.name)]:
|
| 28 |
+
if alt.exists():
|
| 29 |
+
cand_path = alt
|
| 30 |
+
break
|
| 31 |
+
|
| 32 |
+
if not job_desc_path.exists():
|
| 33 |
+
for alt in [Path("data").joinpath(job_desc_path.name), Path("[PUB] India_runs_data_and_ai_challenge").joinpath("India_runs_data_and_ai_challenge").joinpath(job_desc_path.name)]:
|
| 34 |
+
if alt.exists():
|
| 35 |
+
job_desc_path = alt
|
| 36 |
+
break
|
| 37 |
+
|
| 38 |
+
print(f"Candidates Path: {cand_path.resolve()}")
|
| 39 |
+
print(f"Job Description Path: {job_desc_path.resolve()}")
|
| 40 |
+
print(f"Output Path: {out_path.resolve()}")
|
| 41 |
+
|
| 42 |
+
# 1. Load candidates and job description
|
| 43 |
+
if not cand_path.exists():
|
| 44 |
+
raise FileNotFoundError(f"Candidates file not found at: {cand_path}")
|
| 45 |
+
if not job_desc_path.exists():
|
| 46 |
+
raise FileNotFoundError(f"Job description file not found at: {job_desc_path}")
|
| 47 |
+
|
| 48 |
+
print("Loading datasets...")
|
| 49 |
+
if str(cand_path).endswith(".jsonl"):
|
| 50 |
+
with open(cand_path, "r", encoding="utf-8") as f:
|
| 51 |
+
candidates = [json.loads(line) for line in f]
|
| 52 |
+
else:
|
| 53 |
+
with open(cand_path, "r", encoding="utf-8") as f:
|
| 54 |
+
candidates = json.load(f)
|
| 55 |
+
|
| 56 |
+
with open(job_desc_path, "r", encoding="utf-8") as f:
|
| 57 |
+
jd = json.load(f)
|
| 58 |
+
|
| 59 |
+
print(f"Loaded {len(candidates)} candidates.")
|
| 60 |
+
|
| 61 |
+
# 2. Load TF-IDF vectorizer
|
| 62 |
+
# TF-IDF resides in data/precomputed/tfidf.pkl relative to candidates directory
|
| 63 |
+
precomputed_dir = cand_path.parent / "precomputed"
|
| 64 |
+
tfidf_path = precomputed_dir / "tfidf.pkl"
|
| 65 |
+
|
| 66 |
+
if not tfidf_path.exists():
|
| 67 |
+
# Try alternate location
|
| 68 |
+
script_dir = Path(__file__).resolve().parent
|
| 69 |
+
tfidf_path = script_dir.parent / "data" / "precomputed" / "tfidf.pkl"
|
| 70 |
+
|
| 71 |
+
print(f"Loading TF-IDF Vectorizer from: {tfidf_path}")
|
| 72 |
+
with open(tfidf_path, "rb") as f:
|
| 73 |
+
tfidf = pickle.load(f)
|
| 74 |
+
|
| 75 |
+
# 3. Run Hard Filter (Stage 1)
|
| 76 |
+
print("\n--- STAGE 1: Applying Hard Filters ---")
|
| 77 |
+
survivors, killed = apply_hard_filter(candidates, jd, tfidf)
|
| 78 |
+
print(f"Killed: {len(killed)} candidates.")
|
| 79 |
+
print(f"Surviving: {len(survivors)} candidates.")
|
| 80 |
+
|
| 81 |
+
if not survivors:
|
| 82 |
+
print("WARNING: No candidates survived the hard filters! Pipeline exiting early.")
|
| 83 |
+
return
|
| 84 |
+
|
| 85 |
+
# 4. Run Raw Scoring (Stage 2A, 2B, 2C in parallel)
|
| 86 |
+
print("\n--- STAGE 2: Computing Raw Scores ---")
|
| 87 |
+
raw_scored = compute_raw_scores(survivors, jd)
|
| 88 |
+
|
| 89 |
+
# 5. Run Behavioral Multipliers (Stage 3)
|
| 90 |
+
print("\n--- STAGE 3: Applying Behavioral Multipliers ---")
|
| 91 |
+
final_scored = apply_multipliers(raw_scored, jd)
|
| 92 |
+
|
| 93 |
+
# 6. Run Output Generation (Stage 4)
|
| 94 |
+
print("\n--- STAGE 4: Generating Output ---")
|
| 95 |
+
df_final = write_submission(final_scored, jd, str(out_path))
|
| 96 |
+
|
| 97 |
+
elapsed_time = time.perf_counter() - start_time
|
| 98 |
+
print("=" * 60)
|
| 99 |
+
print(f"PIPELINE EXECUTED IN {elapsed_time:.2f} SECONDS")
|
| 100 |
+
print("=" * 60)
|
| 101 |
+
|
| 102 |
+
# Print the top 10 rows from the output DataFrame
|
| 103 |
+
print("\nTOP 10 CANDIDATES:")
|
| 104 |
+
print(df_final.head(10))
|
| 105 |
+
|
| 106 |
+
if __name__ == "__main__":
|
| 107 |
+
# Default paths assuming execution from workspace root
|
| 108 |
+
default_candidates = "data/candidates.json"
|
| 109 |
+
default_jd = "data/job_description.json"
|
| 110 |
+
default_output = "submission.csv"
|
| 111 |
+
|
| 112 |
+
run_pipeline(default_candidates, default_jd, default_output)
|
src/precompute.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import pickle
|
| 4 |
+
import numpy as np
|
| 5 |
+
import faiss
|
| 6 |
+
from sentence_transformers import SentenceTransformer
|
| 7 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 8 |
+
from tqdm import tqdm
|
| 9 |
+
|
| 10 |
+
def build_candidate_text(cand: dict) -> str:
|
| 11 |
+
"""Build a consolidated text string from candidate fields, supporting nested schemas."""
|
| 12 |
+
profile = cand.get("profile") or {}
|
| 13 |
+
|
| 14 |
+
current_title = profile.get("current_title") or cand.get("current_title") or ""
|
| 15 |
+
headline = profile.get("headline") or cand.get("headline") or ""
|
| 16 |
+
|
| 17 |
+
# Skill names
|
| 18 |
+
skills = cand.get("skills") or []
|
| 19 |
+
skill_names = []
|
| 20 |
+
for s in skills:
|
| 21 |
+
if isinstance(s, dict):
|
| 22 |
+
skill_names.append(s.get("name") or "")
|
| 23 |
+
elif isinstance(s, str):
|
| 24 |
+
skill_names.append(s)
|
| 25 |
+
skills_joined = " ".join(filter(None, skill_names))
|
| 26 |
+
|
| 27 |
+
# Career history
|
| 28 |
+
history = cand.get("career_history") or cand.get("experience") or cand.get("work_experience") or []
|
| 29 |
+
history_parts = []
|
| 30 |
+
if isinstance(history, list):
|
| 31 |
+
for job in history:
|
| 32 |
+
if isinstance(job, dict):
|
| 33 |
+
title = job.get("title") or ""
|
| 34 |
+
description = job.get("description") or ""
|
| 35 |
+
title_capped = title[:500]
|
| 36 |
+
description_capped = description[:500]
|
| 37 |
+
if title_capped:
|
| 38 |
+
history_parts.append(title_capped)
|
| 39 |
+
if description_capped:
|
| 40 |
+
history_parts.append(description_capped)
|
| 41 |
+
|
| 42 |
+
history_joined = " ".join(history_parts)
|
| 43 |
+
|
| 44 |
+
# Combine parts
|
| 45 |
+
parts = [current_title, headline, skills_joined, history_joined]
|
| 46 |
+
cleaned_parts = [p.strip() for p in parts if p and p.strip()]
|
| 47 |
+
return " ".join(cleaned_parts)
|
| 48 |
+
|
| 49 |
+
def build_jd_text(jd: dict) -> str:
|
| 50 |
+
"""Build a consolidated text string from job description fields."""
|
| 51 |
+
parts = []
|
| 52 |
+
if jd.get("title"):
|
| 53 |
+
parts.append(jd["title"])
|
| 54 |
+
if jd.get("must_have_skills"):
|
| 55 |
+
parts.append(" ".join(jd["must_have_skills"]))
|
| 56 |
+
if jd.get("nice_to_have_skills"):
|
| 57 |
+
parts.append(" ".join(jd["nice_to_have_skills"]))
|
| 58 |
+
if jd.get("keywords"):
|
| 59 |
+
parts.append(" ".join(jd["keywords"]))
|
| 60 |
+
if jd.get("description"):
|
| 61 |
+
parts.append(jd["description"])
|
| 62 |
+
cleaned_parts = [p.strip() for p in parts if p and p.strip()]
|
| 63 |
+
return " ".join(cleaned_parts)
|
| 64 |
+
|
| 65 |
+
def precompute(candidates_path=None, jd_path=None, precomputed_dir=None):
|
| 66 |
+
# Resolve paths
|
| 67 |
+
script_dir = os.path.dirname(os.path.abspath(__file__))
|
| 68 |
+
project_root = os.path.dirname(script_dir)
|
| 69 |
+
|
| 70 |
+
if not precomputed_dir:
|
| 71 |
+
precomputed_dir = os.path.join(project_root, "data", "precomputed")
|
| 72 |
+
os.makedirs(precomputed_dir, exist_ok=True)
|
| 73 |
+
|
| 74 |
+
if not candidates_path:
|
| 75 |
+
candidates_path = os.path.join(project_root, "data", "candidates.json")
|
| 76 |
+
# Try JSON Lines fallback
|
| 77 |
+
if not os.path.exists(candidates_path) and os.path.exists(candidates_path + "l"):
|
| 78 |
+
candidates_path += "l"
|
| 79 |
+
|
| 80 |
+
if not jd_path:
|
| 81 |
+
jd_path = os.path.join(project_root, "data", "job_description.json")
|
| 82 |
+
|
| 83 |
+
print(f"Loading job description from {jd_path}...")
|
| 84 |
+
with open(jd_path, "r", encoding="utf-8") as f:
|
| 85 |
+
jd_data = json.load(f)
|
| 86 |
+
jd_text = build_jd_text(jd_data)
|
| 87 |
+
|
| 88 |
+
print(f"Loading candidates from {candidates_path}...")
|
| 89 |
+
if not os.path.exists(candidates_path):
|
| 90 |
+
raise FileNotFoundError(f"Candidates file not found at {candidates_path}")
|
| 91 |
+
|
| 92 |
+
if candidates_path.endswith(".jsonl"):
|
| 93 |
+
with open(candidates_path, "r", encoding="utf-8") as f:
|
| 94 |
+
candidates = [json.loads(line) for line in f]
|
| 95 |
+
else:
|
| 96 |
+
with open(candidates_path, "r", encoding="utf-8") as f:
|
| 97 |
+
candidates = json.load(f)
|
| 98 |
+
|
| 99 |
+
print(f"Processing {len(candidates)} candidates...")
|
| 100 |
+
candidate_texts = []
|
| 101 |
+
candidate_ids = []
|
| 102 |
+
for cand in tqdm(candidates, desc="Building candidate texts"):
|
| 103 |
+
cand_id = cand.get("candidate_id") or cand.get("id") or ""
|
| 104 |
+
candidate_ids.append(str(cand_id))
|
| 105 |
+
candidate_texts.append(build_candidate_text(cand))
|
| 106 |
+
|
| 107 |
+
print("Loading SentenceTransformer model 'all-MiniLM-L6-v2'...")
|
| 108 |
+
model = SentenceTransformer("all-MiniLM-L6-v2")
|
| 109 |
+
|
| 110 |
+
print("Embedding job description...")
|
| 111 |
+
jd_vec = model.encode(jd_text, normalize_embeddings=True).astype("float32")
|
| 112 |
+
|
| 113 |
+
print("Embedding candidates...")
|
| 114 |
+
cand_vecs = model.encode(
|
| 115 |
+
candidate_texts,
|
| 116 |
+
batch_size=256,
|
| 117 |
+
show_progress_bar=True,
|
| 118 |
+
normalize_embeddings=True
|
| 119 |
+
).astype("float32")
|
| 120 |
+
|
| 121 |
+
# FAISS index
|
| 122 |
+
print("Building FAISS index...")
|
| 123 |
+
dimension = 384
|
| 124 |
+
index = faiss.IndexFlatIP(dimension)
|
| 125 |
+
index.add(cand_vecs)
|
| 126 |
+
|
| 127 |
+
# TF-IDF Vectorizer
|
| 128 |
+
print("Fitting TF-IDF Vectorizer...")
|
| 129 |
+
tfidf = TfidfVectorizer(max_features=30000, ngram_range=(1, 2))
|
| 130 |
+
tfidf.fit(candidate_texts)
|
| 131 |
+
|
| 132 |
+
# Save outputs
|
| 133 |
+
jd_vec_path = os.path.join(precomputed_dir, "jd_vec.npy")
|
| 134 |
+
cand_vecs_path = os.path.join(precomputed_dir, "cand_vecs.npy")
|
| 135 |
+
cand_ids_path = os.path.join(precomputed_dir, "cand_ids.json")
|
| 136 |
+
faiss_index_path = os.path.join(precomputed_dir, "faiss.index")
|
| 137 |
+
tfidf_pkl_path = os.path.join(precomputed_dir, "tfidf.pkl")
|
| 138 |
+
|
| 139 |
+
print("Saving precomputed outputs...")
|
| 140 |
+
np.save(jd_vec_path, jd_vec)
|
| 141 |
+
np.save(cand_vecs_path, cand_vecs)
|
| 142 |
+
|
| 143 |
+
with open(cand_ids_path, "w", encoding="utf-8") as f:
|
| 144 |
+
json.dump(candidate_ids, f, indent=2)
|
| 145 |
+
|
| 146 |
+
faiss.write_index(index, faiss_index_path)
|
| 147 |
+
|
| 148 |
+
with open(tfidf_pkl_path, "wb") as f:
|
| 149 |
+
pickle.dump(tfidf, f)
|
| 150 |
+
|
| 151 |
+
print("Precomputation completed successfully.")
|
| 152 |
+
|
| 153 |
+
if __name__ == "__main__":
|
| 154 |
+
precompute()
|
src/raw_score.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import pickle
|
| 3 |
+
from concurrent.futures import ProcessPoolExecutor, as_completed
|
| 4 |
+
from tqdm import tqdm
|
| 5 |
+
|
| 6 |
+
from src.score_career import compute_A, compute_keyword_max
|
| 7 |
+
from src.score_skills import compute_B
|
| 8 |
+
from src.score_embed import load_artifacts, get_C_map
|
| 9 |
+
|
| 10 |
+
def candidate_worker(candidate: dict, jd: dict, tfidf, keyword_max: float) -> tuple[dict, dict, dict]:
|
| 11 |
+
"""Module-level worker function to score a single candidate (picklable for ProcessPoolExecutor)."""
|
| 12 |
+
A_res = compute_A(candidate, jd, tfidf, keyword_max)
|
| 13 |
+
B_res = compute_B(candidate, jd)
|
| 14 |
+
return candidate, A_res, B_res
|
| 15 |
+
|
| 16 |
+
def compute_raw_scores(survivors: list[dict], jd: dict) -> list[dict]:
|
| 17 |
+
"""Hub module that coordinates Stage 2A, 2B, and 2C to compute raw scores in parallel."""
|
| 18 |
+
# Resolve paths
|
| 19 |
+
script_dir = os.path.dirname(os.path.abspath(__file__))
|
| 20 |
+
vettly_dir = os.path.dirname(script_dir)
|
| 21 |
+
precomputed_dir = os.path.join(vettly_dir, "data", "precomputed")
|
| 22 |
+
|
| 23 |
+
tfidf_pkl_path = os.path.join(precomputed_dir, "tfidf.pkl")
|
| 24 |
+
|
| 25 |
+
print("Loading TF-IDF vectorizer...")
|
| 26 |
+
with open(tfidf_pkl_path, "rb") as f:
|
| 27 |
+
tfidf = pickle.load(f)
|
| 28 |
+
|
| 29 |
+
print("Loading embedding artifacts...")
|
| 30 |
+
jd_vec, cand_vecs, cand_ids = load_artifacts(precomputed_dir)
|
| 31 |
+
|
| 32 |
+
print("Computing embedding similarities (Stage 2C)...")
|
| 33 |
+
C_map = get_C_map(jd_vec, cand_vecs, cand_ids)
|
| 34 |
+
|
| 35 |
+
print("Computing pool-wide keyword density maximum...")
|
| 36 |
+
keyword_max = compute_keyword_max(survivors, jd, tfidf)
|
| 37 |
+
|
| 38 |
+
results = []
|
| 39 |
+
|
| 40 |
+
# Process in parallel
|
| 41 |
+
print(f"Parallelizing scoring for {len(survivors)} candidates across CPU cores...")
|
| 42 |
+
max_workers = os.cpu_count() or 4
|
| 43 |
+
|
| 44 |
+
with ProcessPoolExecutor(max_workers=max_workers) as executor:
|
| 45 |
+
futures = {
|
| 46 |
+
executor.submit(candidate_worker, cand, jd, tfidf, keyword_max): cand
|
| 47 |
+
for cand in survivors
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
for future in tqdm(as_completed(futures), total=len(futures), desc="Scoring candidates"):
|
| 51 |
+
try:
|
| 52 |
+
candidate, A_res, B_res = future.result()
|
| 53 |
+
cand_id = str(candidate.get("id") or candidate.get("candidate_id") or "")
|
| 54 |
+
|
| 55 |
+
# Get Stage 2C embedding score
|
| 56 |
+
C = C_map.get(cand_id, 0.0)
|
| 57 |
+
|
| 58 |
+
# Assemble raw score: 0.40*A + 0.35*B + 0.25*C
|
| 59 |
+
A = A_res["A"]
|
| 60 |
+
B = B_res["B"]
|
| 61 |
+
raw_score = round(0.40 * A + 0.35 * B + 0.25 * C, 4)
|
| 62 |
+
|
| 63 |
+
results.append({
|
| 64 |
+
"candidate_id": cand_id,
|
| 65 |
+
"candidate": candidate,
|
| 66 |
+
"A": A,
|
| 67 |
+
"title_sim": A_res["title_sim"],
|
| 68 |
+
"industry_match": A_res["industry_match"],
|
| 69 |
+
"prod_keyword_density": A_res["prod_keyword_density"],
|
| 70 |
+
"yoe_score": A_res["yoe_score"],
|
| 71 |
+
"B": B,
|
| 72 |
+
"must_have_coverage": B_res["must_have_coverage"],
|
| 73 |
+
"nice_coverage": B_res["nice_coverage"],
|
| 74 |
+
"cert_bonus": B_res["cert_bonus"],
|
| 75 |
+
"C": C,
|
| 76 |
+
"raw_score": raw_score
|
| 77 |
+
})
|
| 78 |
+
except Exception as e:
|
| 79 |
+
print(f"Error scoring candidate: {e}")
|
| 80 |
+
|
| 81 |
+
return results
|
src/run_fast_pipeline.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import time
|
| 4 |
+
import numpy as np
|
| 5 |
+
import faiss
|
| 6 |
+
import polars as pl
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from sentence_transformers import SentenceTransformer
|
| 9 |
+
from sklearn.feature_extraction.text import TfidfVectorizer
|
| 10 |
+
|
| 11 |
+
from src.hard_filter import apply_hard_filter, is_killed
|
| 12 |
+
from src.score_career import compute_A, compute_keyword_max
|
| 13 |
+
from src.score_skills import compute_B
|
| 14 |
+
from src.score_embed import compute_C_all
|
| 15 |
+
from src.availability import apply_multipliers
|
| 16 |
+
from src.output import write_submission, generate_reasoning
|
| 17 |
+
from src.precompute import build_candidate_text, build_jd_text
|
| 18 |
+
|
| 19 |
+
def run_fast_pipeline():
|
| 20 |
+
start_time = time.perf_counter()
|
| 21 |
+
|
| 22 |
+
print("=" * 60)
|
| 23 |
+
print("RUNNING HIGH-PERFORMANCE PIPELINE ON 100,000 CANDIDATES")
|
| 24 |
+
print("=" * 60)
|
| 25 |
+
|
| 26 |
+
# Paths
|
| 27 |
+
jd_path = Path("data/job_description.json")
|
| 28 |
+
candidates_path = Path("data/candidates.jsonl")
|
| 29 |
+
output_path = Path("submission.csv")
|
| 30 |
+
|
| 31 |
+
if not jd_path.exists():
|
| 32 |
+
raise FileNotFoundError(f"Job description not found at {jd_path}")
|
| 33 |
+
if not candidates_path.exists():
|
| 34 |
+
raise FileNotFoundError(f"Candidates dataset not found at {candidates_path}")
|
| 35 |
+
|
| 36 |
+
# 1. Load Job Description
|
| 37 |
+
print("Loading Job Description...")
|
| 38 |
+
with open(jd_path, "r", encoding="utf-8") as f:
|
| 39 |
+
jd = json.load(f)
|
| 40 |
+
|
| 41 |
+
# ── PASS 1: Stream titles only to fit TF-IDF (low memory) ──
|
| 42 |
+
print("Pass 1: Streaming 100,000 candidate titles for TF-IDF fit...")
|
| 43 |
+
titles = []
|
| 44 |
+
with open(candidates_path, "r", encoding="utf-8") as f:
|
| 45 |
+
for line in f:
|
| 46 |
+
line = line.strip()
|
| 47 |
+
if not line:
|
| 48 |
+
continue
|
| 49 |
+
cand = json.loads(line)
|
| 50 |
+
title = cand.get("profile", {}).get("current_title") or cand.get("current_title") or ""
|
| 51 |
+
titles.append(title)
|
| 52 |
+
|
| 53 |
+
total_count = len(titles)
|
| 54 |
+
print(f" Collected {total_count} titles.")
|
| 55 |
+
|
| 56 |
+
# 2. Fit TF-IDF on all candidate titles for the hard filter
|
| 57 |
+
print("Fitting TF-IDF Vectorizer on all candidate titles...")
|
| 58 |
+
tfidf = TfidfVectorizer(max_features=30000, ngram_range=(1, 2))
|
| 59 |
+
tfidf.fit(titles)
|
| 60 |
+
del titles # free memory immediately
|
| 61 |
+
|
| 62 |
+
# ── PASS 2: Stream again — hard-filter inline and collect survivors ──
|
| 63 |
+
print("\n--- STAGE 1: Applying Hard Filters (streaming pass 2) ---")
|
| 64 |
+
survivors = []
|
| 65 |
+
killed_count = 0
|
| 66 |
+
with open(candidates_path, "r", encoding="utf-8") as f:
|
| 67 |
+
for line in f:
|
| 68 |
+
line = line.strip()
|
| 69 |
+
if not line:
|
| 70 |
+
continue
|
| 71 |
+
cand = json.loads(line)
|
| 72 |
+
killed_flag, reason = is_killed(cand, jd, tfidf)
|
| 73 |
+
if killed_flag:
|
| 74 |
+
killed_count += 1
|
| 75 |
+
else:
|
| 76 |
+
survivors.append(cand)
|
| 77 |
+
|
| 78 |
+
print(f"Killed: {killed_count} candidates.")
|
| 79 |
+
print(f"Surviving: {len(survivors)} candidates.")
|
| 80 |
+
|
| 81 |
+
if not survivors:
|
| 82 |
+
print("No candidates survived. Exiting.")
|
| 83 |
+
return
|
| 84 |
+
|
| 85 |
+
# 4. Generate Embeddings for ONLY the survivors + JD (Massive speedup!)
|
| 86 |
+
print("\n--- STAGE 2: Embedding Survivors & Job Description ---")
|
| 87 |
+
print("Loading SentenceTransformer model 'all-MiniLM-L6-v2'...")
|
| 88 |
+
model = SentenceTransformer("all-MiniLM-L6-v2")
|
| 89 |
+
|
| 90 |
+
print("Embedding Job Description...")
|
| 91 |
+
jd_text = build_jd_text(jd)
|
| 92 |
+
jd_vec = model.encode(jd_text, normalize_embeddings=True).astype("float32")
|
| 93 |
+
|
| 94 |
+
print(f"Embedding {len(survivors)} surviving candidates...")
|
| 95 |
+
survivor_texts = [build_candidate_text(s) for s in survivors]
|
| 96 |
+
cand_vecs = model.encode(survivor_texts, batch_size=256, normalize_embeddings=True).astype("float32")
|
| 97 |
+
|
| 98 |
+
# 5. Compute Stage 2C Embedding Similarity
|
| 99 |
+
print("Computing embedding similarities (Stage 2C)...")
|
| 100 |
+
C_scores = compute_C_all(jd_vec, cand_vecs)
|
| 101 |
+
C_map = {str(s.get("candidate_id") or s.get("id")): float(score) for s, score in zip(survivors, C_scores)}
|
| 102 |
+
|
| 103 |
+
# 6. Fit TF-IDF on survivor texts for career scoring
|
| 104 |
+
print("Fitting TF-IDF Vectorizer on survivor texts...")
|
| 105 |
+
tfidf_surv = TfidfVectorizer(max_features=30000, ngram_range=(1, 2))
|
| 106 |
+
tfidf_surv.fit(survivor_texts)
|
| 107 |
+
|
| 108 |
+
# 7. Compute Stage 2A & 2B scores
|
| 109 |
+
print("Computing career and skill scores...")
|
| 110 |
+
keyword_max = compute_keyword_max(survivors, jd, tfidf_surv)
|
| 111 |
+
|
| 112 |
+
raw_scored = []
|
| 113 |
+
for cand in survivors:
|
| 114 |
+
cand_id = str(cand.get("candidate_id") or cand.get("id"))
|
| 115 |
+
A_res = compute_A(cand, jd, tfidf_surv, keyword_max)
|
| 116 |
+
B_res = compute_B(cand, jd)
|
| 117 |
+
C = C_map.get(cand_id, 0.0)
|
| 118 |
+
|
| 119 |
+
A = A_res["A"]
|
| 120 |
+
B = B_res["B"]
|
| 121 |
+
raw_score = round(0.40 * A + 0.35 * B + 0.25 * C, 4)
|
| 122 |
+
|
| 123 |
+
raw_scored.append({
|
| 124 |
+
"candidate_id": cand_id,
|
| 125 |
+
"candidate": cand,
|
| 126 |
+
"A": A,
|
| 127 |
+
"title_sim": A_res["title_sim"],
|
| 128 |
+
"industry_match": A_res["industry_match"],
|
| 129 |
+
"prod_keyword_density": A_res["prod_keyword_density"],
|
| 130 |
+
"yoe_score": A_res["yoe_score"],
|
| 131 |
+
"B": B,
|
| 132 |
+
"must_have_coverage": B_res["must_have_coverage"],
|
| 133 |
+
"nice_coverage": B_res["nice_coverage"],
|
| 134 |
+
"cert_bonus": B_res["cert_bonus"],
|
| 135 |
+
"C": C,
|
| 136 |
+
"raw_score": raw_score
|
| 137 |
+
})
|
| 138 |
+
|
| 139 |
+
# 8. Apply Behavioral Multipliers (Stage 3)
|
| 140 |
+
print("\n--- STAGE 3: Applying Behavioral Multipliers ---")
|
| 141 |
+
final_scored = apply_multipliers(raw_scored, jd)
|
| 142 |
+
|
| 143 |
+
# 9. Output ranked results (Stage 4)
|
| 144 |
+
print("\n--- STAGE 4: Generating Output ---")
|
| 145 |
+
df_final = write_submission(final_scored, jd, str(output_path))
|
| 146 |
+
|
| 147 |
+
elapsed_time = time.perf_counter() - start_time
|
| 148 |
+
print("=" * 60)
|
| 149 |
+
print(f"PIPELINE EXECUTED IN {elapsed_time:.2f} SECONDS")
|
| 150 |
+
print("=" * 60)
|
| 151 |
+
|
| 152 |
+
print("\nTOP 10 CANDIDATES:")
|
| 153 |
+
print(df_final.head(10))
|
| 154 |
+
|
| 155 |
+
if __name__ == "__main__":
|
| 156 |
+
run_fast_pipeline()
|
src/score_career.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import datetime
|
| 2 |
+
import numpy as np
|
| 3 |
+
from dateutil import parser
|
| 4 |
+
from sklearn.metrics.pairwise import cosine_similarity
|
| 5 |
+
|
| 6 |
+
def compute_job_age(job: dict) -> float:
|
| 7 |
+
"""Calculate the age of a job in years from today."""
|
| 8 |
+
is_current = job.get("is_current")
|
| 9 |
+
end_date_str = job.get("end_date")
|
| 10 |
+
if is_current or not end_date_str or str(end_date_str).lower().strip() in ["present", "current", "none", "null"]:
|
| 11 |
+
return 0.0
|
| 12 |
+
|
| 13 |
+
try:
|
| 14 |
+
end_date = parser.parse(str(end_date_str))
|
| 15 |
+
if end_date.tzinfo is not None:
|
| 16 |
+
today = datetime.datetime.now(datetime.timezone.utc)
|
| 17 |
+
else:
|
| 18 |
+
today = datetime.datetime.now()
|
| 19 |
+
days = (today - end_date).days
|
| 20 |
+
return max(0.0, days / 365.25)
|
| 21 |
+
except Exception:
|
| 22 |
+
return 0.0
|
| 23 |
+
|
| 24 |
+
def compute_raw_keyword_score(candidate: dict, jd: dict) -> float:
|
| 25 |
+
"""Compute the raw, recency-decayed keyword score for a candidate, supporting nested career_history."""
|
| 26 |
+
keywords = jd.get("keywords") or []
|
| 27 |
+
history = candidate.get("career_history") or candidate.get("experience") or candidate.get("work_experience") or []
|
| 28 |
+
if not keywords or not history:
|
| 29 |
+
return 0.0
|
| 30 |
+
|
| 31 |
+
total_score = 0.0
|
| 32 |
+
for job in history:
|
| 33 |
+
if not isinstance(job, dict):
|
| 34 |
+
continue
|
| 35 |
+
job_title = job.get("title") or ""
|
| 36 |
+
job_desc = job.get("description") or ""
|
| 37 |
+
job_text = f"{job_title} {job_desc}".lower()
|
| 38 |
+
|
| 39 |
+
# Count keyword occurrences
|
| 40 |
+
kw_count = sum(job_text.count(str(kw).lower()) for kw in keywords)
|
| 41 |
+
|
| 42 |
+
age = compute_job_age(job)
|
| 43 |
+
decay = np.exp(-0.15 * age)
|
| 44 |
+
total_score += kw_count * decay
|
| 45 |
+
|
| 46 |
+
return float(total_score)
|
| 47 |
+
|
| 48 |
+
def compute_keyword_max(candidates: list[dict], jd: dict, tfidf=None) -> float:
|
| 49 |
+
"""Scans the candidate pool and returns the maximum raw keyword score."""
|
| 50 |
+
max_val = 0.0
|
| 51 |
+
for cand in candidates:
|
| 52 |
+
score = compute_raw_keyword_score(cand, jd)
|
| 53 |
+
if score > max_val:
|
| 54 |
+
max_val = score
|
| 55 |
+
# Return at least 1.0 to avoid division by zero
|
| 56 |
+
return max(max_val, 1.0)
|
| 57 |
+
|
| 58 |
+
def compute_A(candidate: dict, jd: dict, tfidf, keyword_max: float) -> dict:
|
| 59 |
+
"""Computes the career fit score (A) for a candidate, supporting nested and flat schemas."""
|
| 60 |
+
profile = candidate.get("profile") or {}
|
| 61 |
+
|
| 62 |
+
# 1. Title Similarity
|
| 63 |
+
cand_title = profile.get("current_title") or candidate.get("current_title", "")
|
| 64 |
+
jd_title = jd.get("title", "")
|
| 65 |
+
if not cand_title or not jd_title:
|
| 66 |
+
title_sim = 0.0
|
| 67 |
+
else:
|
| 68 |
+
try:
|
| 69 |
+
cand_tfidf = tfidf.transform([cand_title])
|
| 70 |
+
jd_tfidf = tfidf.transform([jd_title])
|
| 71 |
+
title_sim = float(cosine_similarity(cand_tfidf, jd_tfidf)[0][0])
|
| 72 |
+
except Exception:
|
| 73 |
+
title_sim = 0.0
|
| 74 |
+
|
| 75 |
+
# 2. Industry Match (Jaccard similarity)
|
| 76 |
+
cand_industries = set()
|
| 77 |
+
# Check profile industry
|
| 78 |
+
prof_ind = profile.get("current_industry")
|
| 79 |
+
if prof_ind:
|
| 80 |
+
cand_industries.add(prof_ind.strip().lower())
|
| 81 |
+
# Check career history industries
|
| 82 |
+
history = candidate.get("career_history") or candidate.get("experience") or candidate.get("work_experience") or []
|
| 83 |
+
for job in history:
|
| 84 |
+
if isinstance(job, dict) and job.get("industry"):
|
| 85 |
+
cand_industries.add(job["industry"].strip().lower())
|
| 86 |
+
# Fallback to top-level industries list if present
|
| 87 |
+
for ind in (candidate.get("industries") or []):
|
| 88 |
+
if ind:
|
| 89 |
+
cand_industries.add(ind.strip().lower())
|
| 90 |
+
|
| 91 |
+
jd_industries = {ind.strip().lower() for ind in (jd.get("target_industries") or []) if ind}
|
| 92 |
+
if not jd_industries:
|
| 93 |
+
industry_match = 0.0
|
| 94 |
+
else:
|
| 95 |
+
union = cand_industries.union(jd_industries)
|
| 96 |
+
intersection = cand_industries.intersection(jd_industries)
|
| 97 |
+
industry_match = len(intersection) / len(union) if union else 0.0
|
| 98 |
+
|
| 99 |
+
# 3. Keyword Density
|
| 100 |
+
raw_kw = compute_raw_keyword_score(candidate, jd)
|
| 101 |
+
prod_keyword_density = min(raw_kw / keyword_max, 1.0)
|
| 102 |
+
|
| 103 |
+
# 4. YoE Score
|
| 104 |
+
yoe = profile.get("years_of_experience") or profile.get("yoe") or candidate.get("years_of_experience") or candidate.get("yoe") or 0.0
|
| 105 |
+
min_yoe = jd.get("min_yoe") or 5
|
| 106 |
+
if min_yoe <= 0:
|
| 107 |
+
yoe_score = 1.0
|
| 108 |
+
else:
|
| 109 |
+
yoe_score = min(float(yoe) / float(min_yoe), 1.0)
|
| 110 |
+
|
| 111 |
+
A = 0.35 * title_sim + 0.25 * industry_match + 0.25 * prod_keyword_density + 0.15 * yoe_score
|
| 112 |
+
|
| 113 |
+
return {
|
| 114 |
+
"A": round(A, 4),
|
| 115 |
+
"title_sim": round(title_sim, 4),
|
| 116 |
+
"industry_match": round(industry_match, 4),
|
| 117 |
+
"prod_keyword_density": round(prod_keyword_density, 4),
|
| 118 |
+
"yoe_score": round(yoe_score, 4)
|
| 119 |
+
}
|
src/score_embed.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import numpy as np
|
| 4 |
+
|
| 5 |
+
def load_artifacts(precomputed_dir: str) -> tuple[np.ndarray, np.ndarray, list]:
|
| 6 |
+
"""Loads the precomputed artifacts: jd_vec.npy, cand_vecs.npy, and cand_ids.json."""
|
| 7 |
+
jd_vec_path = os.path.join(precomputed_dir, "jd_vec.npy")
|
| 8 |
+
cand_vecs_path = os.path.join(precomputed_dir, "cand_vecs.npy")
|
| 9 |
+
cand_ids_path = os.path.join(precomputed_dir, "cand_ids.json")
|
| 10 |
+
|
| 11 |
+
jd_vec = np.load(jd_vec_path)
|
| 12 |
+
cand_vecs = np.load(cand_vecs_path)
|
| 13 |
+
|
| 14 |
+
with open(cand_ids_path, "r", encoding="utf-8") as f:
|
| 15 |
+
cand_ids = json.load(f)
|
| 16 |
+
|
| 17 |
+
return jd_vec, cand_vecs, cand_ids
|
| 18 |
+
|
| 19 |
+
def compute_C_all(jd_vec: np.ndarray, cand_vecs: np.ndarray) -> np.ndarray:
|
| 20 |
+
"""Computes semantic similarity for all candidates in a single vectorized matrix multiplication."""
|
| 21 |
+
# Ensure 2D shapes for multiplication
|
| 22 |
+
if len(jd_vec.shape) == 1:
|
| 23 |
+
jd_vec = jd_vec.reshape(1, -1)
|
| 24 |
+
if len(cand_vecs.shape) == 1:
|
| 25 |
+
cand_vecs = cand_vecs.reshape(1, -1)
|
| 26 |
+
|
| 27 |
+
scores = (cand_vecs @ jd_vec.T).squeeze()
|
| 28 |
+
scores = np.clip(scores, 0.0, 1.0)
|
| 29 |
+
return np.atleast_1d(scores)
|
| 30 |
+
|
| 31 |
+
def get_C_map(jd_vec: np.ndarray, cand_vecs: np.ndarray, cand_ids: list) -> dict[str, float]:
|
| 32 |
+
"""Computes similarity and returns a dictionary mapping candidate IDs to embedding scores."""
|
| 33 |
+
scores = compute_C_all(jd_vec, cand_vecs)
|
| 34 |
+
return {str(cid): float(score) for cid, score in zip(cand_ids, scores)}
|
src/score_skills.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from rapidfuzz import fuzz
|
| 2 |
+
|
| 3 |
+
def fuzzy_match_skill(cand_skill_name: str, jd_skills: list[str]) -> str | None:
|
| 4 |
+
"""Finds the best matching JD skill for a candidate's skill using a threshold of 85.
|
| 5 |
+
|
| 6 |
+
Returns the JD skill name if matched, else None.
|
| 7 |
+
"""
|
| 8 |
+
if not cand_skill_name or not jd_skills:
|
| 9 |
+
return None
|
| 10 |
+
best_ratio = 0
|
| 11 |
+
best_skill = None
|
| 12 |
+
for jd_s in jd_skills:
|
| 13 |
+
ratio = fuzz.token_sort_ratio(cand_skill_name.lower().strip(), jd_s.lower().strip())
|
| 14 |
+
if ratio > best_ratio:
|
| 15 |
+
best_ratio = ratio
|
| 16 |
+
best_skill = jd_s
|
| 17 |
+
if best_ratio >= 85:
|
| 18 |
+
return best_skill
|
| 19 |
+
return None
|
| 20 |
+
|
| 21 |
+
def skill_trust(skill_obj, assess_scores: dict, jd_skill: str) -> float:
|
| 22 |
+
"""Calculates the trust score for a matched skill.
|
| 23 |
+
|
| 24 |
+
Formula: trust = prof_weight * (0.35 + 0.25 * endorse_w + 0.25 * assess_w + 0.15 * duration_w)
|
| 25 |
+
"""
|
| 26 |
+
if isinstance(skill_obj, str):
|
| 27 |
+
name = skill_obj
|
| 28 |
+
proficiency = "intermediate"
|
| 29 |
+
endorsements = 0
|
| 30 |
+
duration_months = 0
|
| 31 |
+
elif isinstance(skill_obj, dict):
|
| 32 |
+
name = skill_obj.get("name") or ""
|
| 33 |
+
proficiency = skill_obj.get("proficiency") or "intermediate"
|
| 34 |
+
endorsements = skill_obj.get("endorsements") or 0
|
| 35 |
+
duration_months = skill_obj.get("duration_months") or 0
|
| 36 |
+
else:
|
| 37 |
+
return 0.0
|
| 38 |
+
|
| 39 |
+
# 1. Proficiency Weight
|
| 40 |
+
prof = str(proficiency).lower().strip()
|
| 41 |
+
if prof == "beginner":
|
| 42 |
+
prof_weight = 0.40
|
| 43 |
+
elif prof == "intermediate":
|
| 44 |
+
prof_weight = 0.70
|
| 45 |
+
elif prof == "advanced":
|
| 46 |
+
prof_weight = 0.90
|
| 47 |
+
elif prof in ["expert", "master"]:
|
| 48 |
+
prof_weight = 1.00
|
| 49 |
+
else:
|
| 50 |
+
prof_weight = 0.70 # default
|
| 51 |
+
|
| 52 |
+
# 2. Endorsement Weight
|
| 53 |
+
endorse_w = min(float(endorsements) / 20.0, 1.0)
|
| 54 |
+
|
| 55 |
+
# 3. Assessment Weight
|
| 56 |
+
assess_val = 0.0
|
| 57 |
+
if isinstance(assess_scores, dict):
|
| 58 |
+
# Try exact key lookup or fuzzy key lookup in the assessment scores
|
| 59 |
+
# First try exact case-insensitive match on jd_skill or name
|
| 60 |
+
assess_val = assess_scores.get(jd_skill) or assess_scores.get(name)
|
| 61 |
+
if assess_val is None:
|
| 62 |
+
# Try case-insensitive lookup
|
| 63 |
+
lower_scores = {k.lower(): v for k, v in assess_scores.items()}
|
| 64 |
+
assess_val = lower_scores.get(jd_skill.lower()) or lower_scores.get(name.lower()) or 0.0
|
| 65 |
+
assess_w = float(assess_val) / 100.0
|
| 66 |
+
|
| 67 |
+
# 4. Duration Weight
|
| 68 |
+
duration_w = min(float(duration_months) / 24.0, 1.0)
|
| 69 |
+
|
| 70 |
+
trust = prof_weight * (0.35 + 0.25 * endorse_w + 0.25 * assess_w + 0.15 * duration_w)
|
| 71 |
+
return float(trust)
|
| 72 |
+
|
| 73 |
+
def compute_B(candidate: dict, jd: dict) -> dict:
|
| 74 |
+
"""Computes the skill trust score (B) for a candidate, supporting nested and flat structures."""
|
| 75 |
+
must_have_skills = jd.get("must_have_skills") or []
|
| 76 |
+
nice_to_have_skills = jd.get("nice_to_have_skills") or []
|
| 77 |
+
|
| 78 |
+
must_trust = {s: 0.0 for s in must_have_skills}
|
| 79 |
+
nice_trust = {s: 0.0 for s in nice_to_have_skills}
|
| 80 |
+
|
| 81 |
+
cand_skills = candidate.get("skills") or []
|
| 82 |
+
|
| 83 |
+
# Try looking in nested redrob_signals for assessment scores first
|
| 84 |
+
signals = candidate.get("redrob_signals") or {}
|
| 85 |
+
assess_scores = signals.get("skill_assessment_scores")
|
| 86 |
+
if assess_scores is None:
|
| 87 |
+
assess_scores = candidate.get("skill_assessment_scores") or {}
|
| 88 |
+
|
| 89 |
+
# Calculate trust for matching skills
|
| 90 |
+
for s_obj in cand_skills:
|
| 91 |
+
s_name = s_obj if isinstance(s_obj, str) else s_obj.get("name", "")
|
| 92 |
+
if not s_name:
|
| 93 |
+
continue
|
| 94 |
+
|
| 95 |
+
matched_must = fuzzy_match_skill(s_name, must_have_skills)
|
| 96 |
+
if matched_must:
|
| 97 |
+
t = skill_trust(s_obj, assess_scores, matched_must)
|
| 98 |
+
must_trust[matched_must] = max(must_trust[matched_must], t)
|
| 99 |
+
|
| 100 |
+
matched_nice = fuzzy_match_skill(s_name, nice_to_have_skills)
|
| 101 |
+
if matched_nice:
|
| 102 |
+
t = skill_trust(s_obj, assess_scores, matched_nice)
|
| 103 |
+
nice_trust[matched_nice] = max(nice_trust[matched_nice], t)
|
| 104 |
+
|
| 105 |
+
must_cov = sum(must_trust.values()) / len(must_have_skills) if must_have_skills else 0.0
|
| 106 |
+
nice_cov = sum(nice_trust.values()) / len(nice_to_have_skills) if nice_to_have_skills else 0.0
|
| 107 |
+
|
| 108 |
+
# 5. Certification Bonus
|
| 109 |
+
certs = candidate.get("certifications") or candidate.get("certs") or []
|
| 110 |
+
all_jd_skills = must_have_skills + nice_to_have_skills
|
| 111 |
+
cert_matches = 0
|
| 112 |
+
for cert in certs:
|
| 113 |
+
cert_name = cert if isinstance(cert, str) else cert.get("name", "")
|
| 114 |
+
if not cert_name:
|
| 115 |
+
continue
|
| 116 |
+
|
| 117 |
+
# Check if cert fuzzy matches any JD skill or contains it as a substring
|
| 118 |
+
matched = False
|
| 119 |
+
for jd_s in all_jd_skills:
|
| 120 |
+
if fuzz.token_sort_ratio(cert_name.lower().strip(), jd_s.lower().strip()) >= 85:
|
| 121 |
+
matched = True
|
| 122 |
+
break
|
| 123 |
+
if jd_s.lower().strip() in cert_name.lower():
|
| 124 |
+
matched = True
|
| 125 |
+
break
|
| 126 |
+
if matched:
|
| 127 |
+
cert_matches += 1
|
| 128 |
+
|
| 129 |
+
cert_bonus = min(cert_matches * 0.05, 0.15)
|
| 130 |
+
|
| 131 |
+
B = min(0.75 * must_cov + 0.25 * nice_cov + cert_bonus, 1.0)
|
| 132 |
+
|
| 133 |
+
return {
|
| 134 |
+
"B": round(B, 4),
|
| 135 |
+
"must_have_coverage": round(must_cov, 4),
|
| 136 |
+
"nice_coverage": round(nice_cov, 4),
|
| 137 |
+
"cert_bonus": round(cert_bonus, 4)
|
| 138 |
+
}
|