Spaces:
Runtime error
Runtime error
| """ | |
| 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 | |
| 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 |