MrugajaJ commited on
Commit
cd3d2c2
·
verified ·
1 Parent(s): e3bd3fb

Upload 11 files

Browse files
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
+ }