""" lib/career_narrative.py — V6 Career Narrative Analyzer Determines if the candidate's career story is coherent. Separate from feature extraction — this is a holistic assessment. Good narrative: Intern -> Engineer -> Senior -> Lead (steady upward progression) Suspicious narrative: Marketing -> AI Engineer -> Architect -> CEO -> Intern (chaotic) Also detects: - Role regression (going from Senior back to Junior) - Domain jumping (completely different fields each role) - Title inflation without evidence (Architect with 1 YOE) - Career gaps (unexplained time between roles) """ from __future__ import annotations from dataclasses import dataclass, field from lib import schema from lib import title_scoring from lib.constants import REFERENCE_DATE @dataclass class CareerNarrative: """Holistic assessment of a candidate's career story.""" coherence: float = 0.5 # 0-1, how coherent is the story trajectory_type: str = "stable" # upward, stable, flat, downward, chaotic regression_count: int = 0 # Number of seniority regressions domain_jump_count: int = 0 # Number of major domain changes title_inflation: bool = False # Title too senior for YOE gap_months: float = 0.0 # Total unexplained career gaps gap_count: int = 0 # Number of career gaps suspicious_patterns: list[str] = field(default_factory=list) def _get_role_domain(role: dict) -> str: """Classify a role into a broad domain.""" title = (role.get("title") or "").lower() desc = (role.get("description") or "").lower() text = f"{title} {desc}" # ML/AI domain ml_kw = ["machine learning", "ml engineer", "ai engineer", "deep learning", "nlp", "data scientist", "applied scientist", "research"] if any(kw in text for kw in ml_kw): return "ml_ai" # Search/Ranking domain search_kw = ["search", "ranking", "retrieval", "recommendation", "information retrieval", "relevance"] if any(kw in text for kw in search_kw): return "search_ranking" # Software Engineering domain swe_kw = ["software engineer", "backend", "frontend", "fullstack", "full stack", "platform engineer", "systems engineer", "devops", "sre", "site reliability"] if any(kw in text for kw in swe_kw): return "software_engineering" # Data Engineering data_kw = ["data engineer", "etl", "data pipeline", "data warehouse", "big data", "data platform", "analytics engineer"] if any(kw in text for kw in data_kw): return "data_engineering" # Product/Management pm_kw = ["product manager", "technical program", "program manager", "project manager", "tech lead", "engineering manager"] if any(kw in text for kw in pm_kw): return "management" # Research research_kw = ["research", "phd", "postdoc", "academic", "scientist", "professor", "fellow"] if any(kw in text for kw in research_kw): return "research" # Non-tech nontech_kw = ["marketing", "sales", "hr", "human resources", "finance", "accounting", "operations", "customer support", "recruiter"] if any(kw in text for kw in nontech_kw): return "non_tech" return "other" def _compute_gaps(ch: list[dict]) -> tuple[float, int]: """Compute total career gaps and count of gaps.""" # Sort by start_date (oldest first) sorted_ch = sorted( [r for r in ch if schema.parse_date(r.get("start_date"))], key=lambda r: schema.parse_date(r.get("start_date")) or REFERENCE_DATE, ) total_gap_months = 0.0 gap_count = 0 for i in range(1, len(sorted_ch)): prev = sorted_ch[i - 1] curr = sorted_ch[i] prev_end = schema.parse_date(prev.get("end_date")) or REFERENCE_DATE curr_start = schema.parse_date(curr.get("start_date")) or REFERENCE_DATE gap_days = (curr_start - prev_end).days if gap_days > 30: # More than 1 month = gap gap_months = gap_days / 30.44 total_gap_months += gap_months gap_count += 1 return total_gap_months, gap_count def analyze(c: dict) -> CareerNarrative: """ Analyze the career narrative coherence of a candidate. """ ch = schema.career_history(c) if not ch: return CareerNarrative(coherence=0.3, trajectory_type="unknown") yoe = schema.years_of_experience(c) narrative = CareerNarrative() # 1. Seniority trajectory (oldest to newest) sorted_ch = sorted( ch, key=lambda r: schema.parse_date(r.get("start_date")) or REFERENCE_DATE, ) seniority_scores = [] for role in sorted_ch: sr, _ = title_scoring.seniority_score(role.get("title", "")) seniority_scores.append(sr) # Count regressions for i in range(1, len(seniority_scores)): if seniority_scores[i] < seniority_scores[i - 1] - 0.10: narrative.regression_count += 1 # Determine trajectory type if len(seniority_scores) >= 2: first_half = seniority_scores[:len(seniority_scores) // 2] second_half = seniority_scores[len(seniority_scores) // 2:] avg_first = sum(first_half) / len(first_half) avg_second = sum(second_half) / len(second_half) if avg_second >= avg_first + 0.10 and narrative.regression_count == 0: narrative.trajectory_type = "upward" elif narrative.regression_count >= 2: narrative.trajectory_type = "chaotic" elif narrative.regression_count == 1: narrative.trajectory_type = "slightly_unstable" elif avg_second < avg_first - 0.10: narrative.trajectory_type = "downward" else: narrative.trajectory_type = "stable" # 2. Domain consistency domains = [_get_role_domain(r) for r in sorted_ch] for i in range(1, len(domains)): if domains[i] != domains[i - 1]: # Non-tech -> tech transitions are fine if domains[i - 1] == "non_tech" and domains[i] in ( "ml_ai", "search_ranking", "software_engineering", "data_engineering" ): continue # tech -> management is fine if domains[i] == "management" and domains[i - 1] in ( "ml_ai", "search_ranking", "software_engineering" ): continue narrative.domain_jump_count += 1 # 3. Title inflation check if yoe < 5: max_sr = max(seniority_scores) if seniority_scores else 0 if max_sr >= 0.90: # Principal/Staff/VP with < 5 YOE narrative.title_inflation = True narrative.suspicious_patterns.append("title_inflation") # 4. Career gaps narrative.gap_months, narrative.gap_count = _compute_gaps(ch) # 5. Suspicious patterns if narrative.regression_count >= 2: narrative.suspicious_patterns.append("multiple_regressions") if narrative.domain_jump_count >= 2: narrative.suspicious_patterns.append("excessive_domain_hopping") if narrative.gap_count >= 2: narrative.suspicious_patterns.append("multiple_career_gaps") if len(ch) > yoe * 0.8 + 1: # More roles than years of experience narrative.suspicious_patterns.append("excessive_job_changes") # Check for very short tenures short_tenures = sum(1 for r in ch if (r.get("duration_months") or 0) < 6) if short_tenures >= 2: narrative.suspicious_patterns.append("multiple_short_tenures") # 6. Compute coherence score coherence = 0.5 # baseline # Trajectory bonus/penalty traj_map = {"upward": 0.20, "stable": 0.10, "slightly_unstable": -0.05, "downward": -0.15, "chaotic": -0.25, "unknown": -0.10} coherence += traj_map.get(narrative.trajectory_type, 0) # Domain consistency bonus coherence -= min(0.15, narrative.domain_jump_count * 0.05) # Gap penalty coherence -= min(0.10, narrative.gap_count * 0.05) # Suspicious pattern penalty coherence -= len(narrative.suspicious_patterns) * 0.05 # Regression penalty coherence -= narrative.regression_count * 0.08 narrative.coherence = max(0.0, min(1.0, coherence)) return narrative