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