Redrob-hackathon / lib /career_narrative.py
Mohit0708's picture
Initial commit
7b833a7
Raw
History Blame Contribute Delete
8.33 kB
"""
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