Redrob-hackathon / lib /candidate_profile.py
Mohit0708's picture
Initial commit
7b833a7
Raw
History Blame Contribute Delete
17.8 kB
"""
lib/candidate_profile.py — V6 Canonical Candidate Profile
Intermediate representation between raw JSON and features.
Every downstream module reads this object instead of raw candidate dict.
This makes the system:
- Cleaner: one place to compute derived signals
- Reusable: same profile works for any JD
- Testable: profile creation is isolated from feature extraction
- Consistent: no duplicate computation across features
Structure:
Experience: years, domain depth, pre-LLM experience
Leadership: seniority trajectory, management signals
Ownership: level, best verb, per-role ownership
Production: readiness, categories, deployment evidence
Scale: user scale, QPS, data scale
Impact: quantified metrics, best strength
Domain: skill map, expertise level, primary domain
Career: coherence, trajectory, company tier trend
Behaviour: availability, responsiveness, recency, trust
Safety: honeypot, disqualifiers, penalties
Education: tier, relevance
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from lib import schema, title_scoring, company_tier, honeypot
from lib.career_narrative import analyze as analyze_narrative
from lib.evidence import extract_all_evidence, get_evidence_summary, Evidence
@dataclass
class ImpactMetric:
"""A single quantified impact metric."""
metric_type: str # ndcg, latency, recall, users, qps, etc.
value: str # "15%", "200ms to 45ms", "50M"
strength: float # 0-1
company: str = ""
year_range: str = ""
@dataclass
class RoleSnapshot:
"""Summary of a single role."""
company: str
title: str
seniority: float # 0-1 from title_scoring
company_tier: float # 0-1 from company_tier
ownership: float # 0-1 from evidence
has_production: bool
has_quantified: bool
duration_months: float
domain: str # broad domain classification
start_year: int = 0
end_year: int = 0
@dataclass
class CanonicalProfile:
"""Universal Canonical Candidate Profile.
This is the SINGLE intermediate representation that all features
and scoring read from. No module should access raw JSON directly
after this point (except for evidence extraction which needs
raw text).
"""
# === Identity ===
candidate_id: str = ""
current_title: str = ""
current_company: str = ""
headline: str = ""
# === Experience ===
yoe: float = 0.0
yoe_in_domain: float = 0.0 # months in JD-relevant domain
yoe_domain_ratio: float = 0.0 # fraction of career in domain
pre_llm_months: float = 0.0 # pre-2022 IR experience (months)
num_roles: int = 0
role_snapshots: list[RoleSnapshot] = field(default_factory=list)
# === Leadership ===
max_seniority: float = 0.0
has_managed: bool = False
management_signals: list[str] = field(default_factory=list)
# === Ownership ===
ownership_level: float = 0.0 # 0-1
best_ownership_verb: str = ""
ownership_by_role: list[tuple[str, float]] = field(default_factory=list)
# === Production ===
production_readiness: float = 0.0 # 0-1
has_production_evidence: bool = False
production_categories: list[str] = field(default_factory=list)
# === Scale ===
max_user_scale: float = 0.0
max_qps: float = 0.0
data_scale: float = 0.0
# === Impact ===
has_quantified_impact: bool = False
impact_metrics: list[ImpactMetric] = field(default_factory=list)
best_impact_strength: float = 0.0
impact_count: int = 0
# === Domain ===
domain_skills: dict[str, float] = field(default_factory=dict)
domain_expertise: float = 0.0 # 0-1
primary_domain: str = "unknown"
# === Career ===
career_coherence: float = 0.5
career_trajectory: float = 0.5
company_tier_avg: float = 0.5
company_tier_trajectory: list[float] = field(default_factory=list)
avg_tenure_months: float = 0.0
narrative_type: str = "stable"
narrative_suspicious: list[str] = field(default_factory=list)
# === Behaviour ===
availability: float = 0.5
responsiveness: float = 0.5
recency: float = 0.5
platform_trust: float = 0.5
market_demand: float = 0.0
github_activity: float = 0.0
interview_completion: float = 0.0
# === Resume Quality ===
quantified_outcomes: float = 0.0
truthiness: float = 0.5
keyword_stuffing_risk: float = 0.0
profile_completeness: float = 0.0
# === Safety ===
is_honeypot: bool = False
honeypot_reasons: list[str] = field(default_factory=list)
disqualifier_penalty: float = 1.0
disqualifier_reasons: list[str] = field(default_factory=list)
# === Education ===
education_tier: str = "unknown" # tier_1, tier_2, unknown
has_relevant_degree: bool = False
education_field: str = ""
# === Evidence (top-N for reasoning) ===
top_evidence: list[Evidence] = field(default_factory=list)
evidence_summary: dict = field(default_factory=dict)
# === Location ===
location: str = ""
country: str = ""
willing_to_relocate: bool = False
preferred_work_mode: str = ""
# === Raw refs (for reasoning, not for scoring) ===
_raw: dict = field(default_factory=dict)
def build(c: dict) -> CanonicalProfile:
"""
Build a Canonical Candidate Profile from raw candidate JSON.
This is the SINGLE place where raw JSON is accessed.
All downstream modules read the CanonicalProfile.
"""
from lib.jd_parser import get_jd
from lib.career_narrative import analyze as analyze_narrative
jd = get_jd()
p = schema.profile(c)
ch = schema.career_history(c)
sigs = schema.signals(c)
edu = schema.education(c)
text = schema.unified_text_blob(c)
profile = CanonicalProfile()
# === Identity ===
profile.candidate_id = c.get("candidate_id", "")
profile.current_title = schema.current_title(c)
profile.current_company = schema.current_company(c)
profile.headline = p.get("headline", "")
# === Experience ===
profile.yoe = schema.years_of_experience(c)
profile.num_roles = len(ch)
# Role snapshots
relevant_kw = set()
for skills in jd.required_skills.values():
relevant_kw.update(skills)
for skills in jd.preferred_skills.values():
relevant_kw.update(skills)
relevant_kw.update(jd.pre_llm_keywords)
total_months = 0
relevant_months = 0
pre_llm_mos = 0.0
for role in ch:
sd = schema.parse_date(role.get("start_date"))
ed = schema.parse_date(role.get("end_date")) or __import__("lib.constants", fromlist=["REFERENCE_DATE"]).REFERENCE_DATE
dur = role.get("duration_months") or 0
total_months += dur
sr, _ = title_scoring.seniority_score(role.get("title", ""))
ct = company_tier.company_quality_score(role.get("company", ""))
role_text = f"{role.get('title','')} {role.get('description','')}".lower()
is_relevant = any(kw in role_text for kw in relevant_kw)
if is_relevant:
relevant_months += dur
# Pre-LLM
if sd and sd.year < jd.pre_llm_cutoff_year:
if not any(m in role_text for m in jd.post_llm_markers):
hits = [kw for kw in jd.pre_llm_keywords if kw in role_text]
if hits:
pre_llm_mos += dur
# Ownership for this role
own_level = 0.0
from lib.evidence import _OWNERSHIP_RE, _OWNERSHIP_TIERS
m = _OWNERSHIP_RE.search(role_text)
if m:
own_level = _OWNERSHIP_TIERS.get(m.group(1).lower(), 0.15)
# Production evidence for this role
prod_evidence = [p for p in (role.get("description") or "").lower().split()
if p in ["production", "deployed", "shipped", "launched", "live"]]
# Quantified impact for this role
import re
has_quant = bool(re.search(
r'(\d+(?:\.\d+)?)\s*%|(\d+(?:\.\d+)?)\s*million|p99|ndcg|qps|rps',
role_text, re.IGNORECASE
))
from lib.career_narrative import _get_role_domain
snap = RoleSnapshot(
company=role.get("company", ""),
title=role.get("title", ""),
seniority=sr,
company_tier=ct,
ownership=own_level,
has_production=bool(prod_evidence),
has_quantified=has_quant,
duration_months=dur,
domain=_get_role_domain(role),
start_year=sd.year if sd else 0,
end_year=ed.year if ed else 0,
)
profile.role_snapshots.append(snap)
profile.ownership_by_role.append((role.get("company", ""), own_level))
profile.yoe_in_domain = relevant_months
profile.yoe_domain_ratio = relevant_months / total_months if total_months > 0 else 0
profile.pre_llm_months = pre_llm_mos
# === Leadership ===
profile.max_seniority = max(
(s.seniority for s in profile.role_snapshots), default=0
)
profile.has_managed = bool(
re.search(r"manag|lead\s+a\s+team|team\s+of|mentor|supervis",
text, re.IGNORECASE)
)
profile.management_signals = [
kw for kw in ["managed", "led a team", "mentored", "supervised"]
if kw in text
]
# === Ownership ===
if profile.ownership_by_role:
profile.ownership_level = max(v for _, v in profile.ownership_by_role)
best_role = max(profile.ownership_by_role, key=lambda x: x[1])
profile.best_ownership_verb = best_role[0]
# === Production ===
prod_keywords = ["production", "deployed", "shipped", "launched",
"live traffic", "at scale", "real users", "on-call"]
prod_hits = [kw for kw in prod_keywords if kw in text]
profile.production_categories = list(set(prod_hits))
profile.has_production_evidence = len(prod_hits) >= 2
profile.production_readiness = min(1.0, len(prod_hits) / 5.0)
# === Scale ===
m_users = re.search(r'(\d+(?:\.\d+)?)\s*million\s+(?:daily\s+)?(?:active\s+)?users', text, re.IGNORECASE)
if m_users:
profile.max_user_scale = float(m_users.group(1))
m_qps = re.search(r'(\d+(?:\.\d+)?)\s*k?\s*(?:qps|rps|requests?\s*per\s*sec)', text, re.IGNORECASE)
if m_qps:
profile.max_qps = float(m_qps.group(1))
m_data = re.search(r'(\d+)\s*(?:tb|gb|pb)\s+of\s+data', text, re.IGNORECASE)
if m_data:
profile.data_scale = float(m_data.group(1))
# === Impact ===
all_evidence = extract_all_evidence(c)
impact_ev = [e for e in all_evidence if e.type == "impact"]
profile.impact_count = len(impact_ev)
profile.has_quantified_impact = len(impact_ev) > 0
profile.best_impact_strength = impact_ev[0].score / 20.0 if impact_ev else 0
for ev in impact_ev:
profile.impact_metrics.append(ImpactMetric(
metric_type=ev.metric, value=ev.context,
strength=ev.score / 20.0, company=ev.company,
year_range=ev.year_range,
))
# === Domain ===
from lib.domain import get_taxonomy
tax = get_taxonomy()
for skill, tier in tax.skill_tier.items():
weight = {1: 1.0, 2: 0.5, 3: 0.1}.get(tier, 0.05)
if skill in text:
profile.domain_skills[skill] = weight
profile.domain_expertise = min(1.0, len(profile.domain_skills) / 5.0) if profile.domain_skills else 0
# Primary domain from most recent role
if profile.role_snapshots:
profile.primary_domain = profile.role_snapshots[0].domain
# === Career ===
narrative = analyze_narrative(c)
profile.career_coherence = narrative.coherence
profile.narrative_type = narrative.trajectory_type
profile.narrative_suspicious = narrative.suspicious_patterns
profile.avg_tenure_months = total_months / len(ch) if ch else 0
# Company tier trajectory
profile.company_tier_trajectory = [s.company_tier for s in profile.role_snapshots]
if profile.company_tier_trajectory:
profile.company_tier_avg = sum(profile.company_tier_trajectory) / len(profile.company_tier_trajectory)
# Career trajectory (reuse from features logic)
if len(profile.role_snapshots) >= 2:
seniority_by_role = [s.seniority for s in reversed(profile.role_snapshots)]
upward = sum(1 for i in range(1, len(seniority_by_role))
if seniority_by_role[i] > seniority_by_role[i-1] + 0.05)
profile.career_trajectory = min(1.0, 0.3 + upward * 0.25)
# === Behaviour ===
from lib.constants import REFERENCE_DATE
# Availability
notice = float(sigs.get("notice_period_days", 45) or 45)
otw = bool(sigs.get("open_to_work_flag", False))
profile.availability = 0.0
if otw:
profile.availability += 0.40
if notice <= 30:
profile.availability += 0.30
elif notice <= 60:
profile.availability += 0.15
else:
profile.availability += 0.05
profile.availability = min(1.0, profile.availability + 0.10)
# Responsiveness
rr = float(sigs.get("recruiter_response_rate", 0.3) or 0.3)
avg_resp = float(sigs.get("avg_response_time_hours", 48) or 48)
profile.responsiveness = min(1.0, 0.4 * rr + 0.3 * max(0, 1 - avg_resp / 72) + 0.10)
# Recency
last_active = schema.parse_date(sigs.get("last_active_date"))
if last_active:
days = (REFERENCE_DATE - last_active).days
if days <= 30:
profile.recency = 1.0
elif days <= 60:
profile.recency = 0.75
elif days <= 90:
profile.recency = 0.50
elif days <= 180:
profile.recency = 0.25
else:
profile.recency = 0.10
# Platform trust
verified = sigs.get("verified_email", False) and sigs.get("verified_phone", False)
linkedin = sigs.get("linkedin_connected", False)
profile.platform_trust = 0.0
if verified:
profile.platform_trust += 0.30
if linkedin:
profile.platform_trust += 0.20
profile.platform_trust += 0.10 # baseline
# Market demand
saves = min(float(sigs.get("saved_by_recruiters_30d") or 0), 20) / 20
appearances = min(float(sigs.get("search_appearance_30d") or 0), 200) / 200
profile.market_demand = 0.5 * saves + 0.3 * appearances + 0.2 * min(1.0, float(sigs.get("profile_views_received_30d", 0) or 0) / 50)
# GitHub
gh = float(sigs.get("github_activity_score", -1) or -1)
profile.github_activity = max(0.0, min(1.0, gh / 100)) if gh >= 0 else 0.0
# Interview
ic = float(sigs.get("interview_completion_rate", 0) or 0)
profile.interview_completion = min(1.0, ic)
# === Resume Quality ===
# Quantified outcomes
quant_patterns = [r'\d+\s*%', r'\d+(?:\.\d+)?\s*million', r'p99', r'ndcg', r'\d+k?\s*qps']
quant_count = sum(1 for pat in quant_patterns if re.search(pat, text, re.IGNORECASE))
profile.quantified_outcomes = min(1.0, quant_count / 3.0)
# Truthiness (simplified)
listed = schema.listed_skill_names(c)
context_matches = sum(1 for s in listed if s in text)
profile.truthiness = min(1.0, context_matches / max(len(listed), 1))
# Keyword stuffing risk
listed_jd_skills = sum(1 for s in listed
if any(jd_sk in s for d in [jd.required_skills, jd.preferred_skills]
for jd_sk in d.values() for jd_sk in d))
listed_non_jd = len(listed) - listed_jd_skills
if listed_non_jd > 5 and profile.truthiness < 0.3:
profile.keyword_stuffing_risk = min(1.0, listed_non_jd / 15.0)
# Profile completeness
profile.profile_completeness = (float(sigs.get("profile_completeness_score", 0) or 0) / 100.0)
# === Safety ===
profile.is_honeypot, profile.honeypot_reasons = honeypot.is_honeypot(c)
# Disqualifier (simplified - full logic in features.py)
from lib.jd_requirements import BAD_TITLE_PATTERNS, CONSULTING_FIRMS, CONSULTING_INDUSTRIES
profile.disqualifier_reasons = []
title_lower = profile.current_title.lower()
if any(bp in title_lower for bp in BAD_TITLE_PATTERNS):
profile.disqualifier_reasons.append("non_engineering_title")
all_consulting = all(
any(cf in (r.get("company") or "").lower() for cf in CONSULTING_FIRMS)
or (r.get("industry") or "").lower() in CONSULTING_INDUSTRIES
for r in ch
)
if all_consulting and ch:
profile.disqualifier_reasons.append("consulting_only_career")
profile.disqualifier_penalty = 0.50
if profile.disqualifier_reasons:
profile.disqualifier_penalty = min(profile.disqualifier_penalty, 0.65)
# === Education ===
if edu:
best_edu = max(edu, key=lambda e: e.get("end_year", 0) or 0)
profile.education_field = (best_edu.get("field_of_study") or "").lower()
profile.education_tier = (best_edu.get("tier") or "unknown").lower()
profile.has_relevant_degree = any(
kw in profile.education_field
for kw in ["computer science", "cs", "artificial intelligence",
"machine learning", "data science", "information technology",
"mathematics", "statistics", "e&e", "electronics"]
)
# === Location ===
profile.location = p.get("location", "")
profile.country = p.get("country", "")
profile.willing_to_relocate = bool(sigs.get("willing_to_relocate", False))
profile.preferred_work_mode = (sigs.get("preferred_work_mode") or "").lower()
# === Evidence (top 5 for reasoning) ===
profile.top_evidence = all_evidence[:5]
profile.evidence_summary = get_evidence_summary(c)
# === Raw ref (for reasoning only) ===
profile._raw = c
return profile