/** * scoring.ts * Scoring utilities for ranking resume highlights against a target role. * Uses the weighted rubric defined in rubric.md. */ import type { HighlightCandidate, HighlightScores, ScoringWeights, TargetRoleProfile, } from '../schema/carouselSchema'; // --------------------------------------------------------------------------- // Default weights from rubric.md // --------------------------------------------------------------------------- export const DEFAULT_WEIGHTS: ScoringWeights = { roleRelevance: 0.25, businessImpact: 0.20, quantitativeStrength: 0.15, technicalDepth: 0.10, ownership: 0.10, scale: 0.05, uniqueness: 0.05, recency: 0.05, recruiterAppeal: 0.05, }; // --------------------------------------------------------------------------- // Keyword banks for heuristic scoring // --------------------------------------------------------------------------- const OWNERSHIP_VERBS_HIGH = /\b(built|launched|founded|architected|led|owned|created|designed|pioneered|established|drove|defined)\b/i; const OWNERSHIP_VERBS_MID = /\b(managed|oversaw|developed|implemented|introduced|restructured|shipped|delivered)\b/i; const OWNERSHIP_VERBS_LOW = /\b(helped|supported|assisted|contributed|participated|collaborated|worked on)\b/i; const METRIC_PATTERN = /(\$[\d,.]+[KMB]?|\d+[%x]|\d+\s*(million|billion|thousand|K|M|B)|[\d,]+\s*(users|customers|engineers|employees|transactions))/i; const LARGE_NUMBER = /(\d{4,}|\$[\d,.]+[MBmb]|millions|billions|enterprise|global)/i; const STRONG_IMPACT_KEYWORDS = /\b(revenue|growth|cost|saving|efficiency|retention|acquisition|churn|conversion|scaling|profit|margin)\b/i; const TECH_KEYWORDS = /\b(architecture|distributed|microservices|kubernetes|ml|ai|llm|pipeline|infrastructure|api|cloud|aws|gcp|azure|terraform|ci\/cd|latency|throughput)\b/i; // --------------------------------------------------------------------------- // Individual dimension scorers // --------------------------------------------------------------------------- // Synonym groups: any term in a group is treated as equivalent for matching const SKILL_SYNONYMS: string[][] = [ ['a/b test', 'a/b experiment', 'ab test', 'split test', 'controlled experiment'], ['experimentation', 'experiment', 'experiments'], ['cuped', 'did', 'difference-in-differences', 'switchback', 'synthetic control'], ['machine learning', 'ml model', 'predictive model', 'statistical model'], ['dashboard', 'visualization', 'reporting', 'report'], ['python', 'pandas', 'numpy', 'scikit'], ['sql', 'query', 'cte', 'window function'], ['llm', 'large language model', 'gpt', 'openai', 'claude', 'rag', 'retrieval'], ['ci/cd', 'mlops', 'mlflow', 'model deployment', 'model training pipeline'], ]; /** * Expand a skill name to all its known synonyms for fuzzy matching. */ function expandToSynonyms(skill: string): string[] { const lower = skill.toLowerCase(); for (const group of SKILL_SYNONYMS) { if (group.some((s) => lower.includes(s) || s.includes(lower))) { return group; } } return [lower]; } /** * Check if a bullet contains a skill (or any of its synonyms). */ function bulletMatchesSkill(bulletLower: string, skill: string): boolean { const synonyms = expandToSynonyms(skill); return synonyms.some((s) => bulletLower.includes(s)); } function scoreRoleRelevance(bullet: string, role: TargetRoleProfile): number { const lowerBullet = bullet.toLowerCase(); let matchCount = 0; let requiredMatchCount = 0; for (const skill of role.requiredSkills) { if (bulletMatchesSkill(lowerBullet, skill)) { requiredMatchCount++; matchCount++; } } for (const skill of role.preferredSkills) { if (bulletMatchesSkill(lowerBullet, skill)) matchCount++; } for (const theme of role.themes) { if (lowerBullet.includes(theme.toLowerCase())) matchCount++; } // Direct required skill match scores very high if (requiredMatchCount >= 2) return 10; if (requiredMatchCount === 1) return 8; if (matchCount >= 3) return 7; if (matchCount === 2) return 6; if (matchCount === 1) return 4; // Check for domain clue overlap const domainMatch = role.domainClues.some((clue) => lowerBullet.includes(clue.toLowerCase()) ); if (domainMatch) return 3; return 1; // No match } function scoreBusinessImpact(bullet: string): number { const lowerBullet = bullet.toLowerCase(); // Check for dollar amounts or revenue/cost language if (/\$[\d,.]+[MBmb]/i.test(bullet)) return 10; if (/\$[\d,.]+[Kk]/i.test(bullet)) return 8; if (STRONG_IMPACT_KEYWORDS.test(bullet)) { // Has impact language but no dollar amount if (METRIC_PATTERN.test(bullet)) return 8; return 6; } // Process/outcome language without dollar impact if (/\b(improved|increased|reduced|accelerated|optimized|streamlined)\b/i.test(bullet)) { return METRIC_PATTERN.test(bullet) ? 7 : 5; } // Product launch or feature delivery if (/\b(launched|shipped|released|deployed|delivered)\b/i.test(bullet)) return 5; // Pure activity return 2; } function scoreQuantitativeStrength(bullet: string): number { const metrics = bullet.match(METRIC_PATTERN) || []; const percentages = bullet.match(/\d+%/g) || []; const dollars = bullet.match(/\$[\d,.]+[KMBkm]?/g) || []; const xMultipliers = bullet.match(/\d+[xX]/g) || []; const metricCount = percentages.length + dollars.length + xMultipliers.length; if (metricCount === 0) { // No numbers — check for qualitative scale words if (/\b(millions|thousands|enterprise|global|company-wide)\b/i.test(bullet)) return 3; return 0; } // Quality of the metric matters more than count if (dollars.length > 0) { // Dollar amount — highest quality metric const dollarStr = dollars[0]!; if (/M|B/i.test(dollarStr)) return 10; if (/K/i.test(dollarStr)) return 8; return 7; } if (percentages.length > 0) { const pct = parseInt(percentages[0]!); if (pct >= 50) return 9; if (pct >= 20) return 8; if (pct >= 10) return 7; return 6; } if (xMultipliers.length > 0) return 8; // "3x faster" — strong if (metrics.length > 0) return 6; // Has some number return 2; } function scoreTechnicalDepth(bullet: string): number { const techMatches = (bullet.match(TECH_KEYWORDS) || []).length; // Named technical systems or architectures if (/\b(designed|architected|built)\b/i.test(bullet) && techMatches >= 2) return 10; if (techMatches >= 3) return 8; if (techMatches >= 2) return 7; if (techMatches === 1) return 5; // General technical language without specific tech stack if (/\b(system|algorithm|framework|service|platform|database|query|model)\b/i.test(bullet)) return 4; return 1; } function scoreOwnership(bullet: string): number { if (OWNERSHIP_VERBS_HIGH.test(bullet)) return 9; if (OWNERSHIP_VERBS_MID.test(bullet)) return 6; if (OWNERSHIP_VERBS_LOW.test(bullet)) return 2; // Passive construction — low ownership signal if (/\bwas\b/i.test(bullet)) return 1; return 4; // Neutral } function scoreScale(bullet: string): number { if (LARGE_NUMBER.test(bullet)) return 9; const userMatch = bullet.match(/(\d+[,.]?\d*)\s*(K|M)?\s*(users|customers|stakeholders)/i); if (userMatch) { const count = parseFloat(userMatch[1].replace(',', '')); const unit = userMatch[2]?.toUpperCase(); const effective = unit === 'M' ? count * 1e6 : unit === 'K' ? count * 1e3 : count; if (effective >= 1e6) return 10; if (effective >= 100000) return 9; if (effective >= 10000) return 7; if (effective >= 1000) return 5; return 3; } const teamMatch = bullet.match(/(\d+)\+?\s*(?:person|people|engineer|team)\s*team/i); if (teamMatch) { const size = parseInt(teamMatch[1]); if (size >= 20) return 8; if (size >= 10) return 6; if (size >= 5) return 4; return 2; } if (/\b(cross-functional|org-wide|company-wide|global)\b/i.test(bullet)) return 7; return 2; } function scoreUniqueness(bullet: string): number { // Open source with stars if (/\d+\s*stars?\b/i.test(bullet)) return 9; // 0-to-1 builds if (/\b(0-to-1|from scratch|ground up|first\s+ever|greenfield|pioneered)\b/i.test(bullet)) return 9; // Patent, publication, conference if (/\b(patent|published|conference|paper|keynote|speaker)\b/i.test(bullet)) return 10; // Exceptional outcomes if (/\b(award|recognition|top\s+\d+%|ranked #\d|highest)\b/i.test(bullet)) return 8; // Framework or system named/adopted broadly if (/\b(framework|platform|system)\b/i.test(bullet) && OWNERSHIP_VERBS_HIGH.test(bullet)) return 7; return 3; // Default for standard bullets } function scoreRecency(bullet: string, roles: Array<{ isCurrent: boolean; bullets: string[] }>): number { // Find which role this bullet belongs to based on content matching const bulletLower = bullet.toLowerCase(); for (let i = 0; i < roles.length; i++) { const role = roles[i]; const isMatch = role.bullets.some( (b) => b.toLowerCase().substring(0, 50) === bulletLower.substring(0, 50) ); if (isMatch) { if (role.isCurrent) return 10; // Estimate recency by position (earlier index = more recent typically) if (i === 0) return 9; if (i === 1) return 7; if (i === 2) return 5; if (i === 3) return 3; return 1; } } return 5; // Unknown recency — default to mid } function scoreRecruiterAppeal(bullet: string): number { // Short and punchy with a clear outcome const wordCount = bullet.split(/\s+/).length; let score = 5; // baseline // Metric presence immediately improves appeal if (METRIC_PATTERN.test(bullet)) score += 2; // Strong verb at start if (/^[A-Z]?[a-z]*(ed|ched|lt|ilt|ped)\b/.test(bullet)) score += 1; // Clear cause-effect structure (action → result) if (/\,?\s*(resulting in|leading to|driving|generating|saving|which)/i.test(bullet)) score += 1; // Penalize very long bullets if (wordCount > 30) score -= 2; if (wordCount > 20) score -= 1; // Penalize filler language if (OWNERSHIP_VERBS_LOW.test(bullet)) score -= 2; return Math.max(0, Math.min(10, score)); } // --------------------------------------------------------------------------- // Public scoring interface // --------------------------------------------------------------------------- export interface HighlightScore { scores: HighlightScores; totalScore: number; } /** * Score a single resume bullet against a target role profile. * Returns dimension scores and weighted total. */ export function scoreHighlight( bullet: string, roleProfile: TargetRoleProfile, weights: ScoringWeights = DEFAULT_WEIGHTS, roles: Array<{ isCurrent: boolean; bullets: string[] }> = [] ): HighlightScore { const scores: HighlightScores = { roleRelevance: scoreRoleRelevance(bullet, roleProfile), businessImpact: scoreBusinessImpact(bullet), quantitativeStrength: scoreQuantitativeStrength(bullet), technicalDepth: scoreTechnicalDepth(bullet), ownership: scoreOwnership(bullet), scale: scoreScale(bullet), uniqueness: scoreUniqueness(bullet), recency: scoreRecency(bullet, roles), recruiterAppeal: scoreRecruiterAppeal(bullet), }; const totalScore = scores.roleRelevance * weights.roleRelevance + scores.businessImpact * weights.businessImpact + scores.quantitativeStrength * weights.quantitativeStrength + scores.technicalDepth * weights.technicalDepth + scores.ownership * weights.ownership + scores.scale * weights.scale + scores.uniqueness * weights.uniqueness + scores.recency * weights.recency + scores.recruiterAppeal * weights.recruiterAppeal; return { scores, totalScore: Math.round(totalScore * 10) / 10 }; } /** * Sort highlight candidates from highest to lowest total score. */ export function rankHighlights( highlights: HighlightCandidate[] ): HighlightCandidate[] { return [...highlights].sort((a, b) => b.totalScore - a.totalScore); } /** * Generate a human-readable rationale string for a scored highlight. */ export function buildRationale( scores: HighlightScores, totalScore: number, topDimension: keyof HighlightScores ): string { const dimLabels: Record = { roleRelevance: 'role relevance', businessImpact: 'business impact', quantitativeStrength: 'quantitative strength', technicalDepth: 'technical depth', ownership: 'ownership signal', scale: 'scale', uniqueness: 'uniqueness', recency: 'recency', recruiterAppeal: 'recruiter appeal', }; // Find top two dimensions const sorted = (Object.entries(scores) as [keyof HighlightScores, number][]) .sort(([, a], [, b]) => b - a); const [first, second] = sorted; const firstLabel = dimLabels[first[0]]; const secondLabel = second ? dimLabels[second[0]] : ''; return `Total score ${totalScore}/10. Strongest signals: ${firstLabel} (${first[1]}/10)${second ? ` and ${secondLabel} (${second[1]}/10)` : ''}.`; }