/** * rankHighlights.ts * Scores and ranks all candidate bullets/achievements against the target role. * Uses the weighted rubric from scoring.ts. */ import type { CandidateProfile, FitAnalysis, HighlightCandidate, TargetRoleProfile, } from '../schema/carouselSchema'; import { DEFAULT_WEIGHTS, buildRationale, rankHighlights, scoreHighlight, } from '../utils/scoring'; // Minimum score threshold — bullets below this are excluded from slides const SCORE_THRESHOLD = 3.5; // Ownership verb patterns (determines whether to soften a claim) const STRONG_OWNERSHIP = /^(led|built|launched|created|founded|architected|designed|drove|owned|pioneered|shipped|established)\b/i; const WEAK_OWNERSHIP = /^(helped|supported|assisted|contributed|participated|worked on|was part of)\b/i; /** * Determine if a bullet's phrasing should be softened. * Returns true if the bullet claims strong ownership but uses weak verb evidence. */ function shouldSoften(bullet: string): boolean { // A bullet is concerning if it uses a strong verb that seems inflated // For now, we flag bullets that use first-person amplifiers without a clear metric const hasStrongVerb = STRONG_OWNERSHIP.test(bullet); const hasWeakVerb = WEAK_OWNERSHIP.test(bullet); const hasMetric = /\d+[%xX$]|\$\d|\d+[KMB]\b/i.test(bullet); // Only soften weak-verb bullets (they may have been paraphrased from stronger originals) // Strong verbs with no metric still pass — we trust the resume return hasWeakVerb && !hasMetric; } /** * Generate a softened version of a bullet with weak ownership language. */ function softenBullet(bullet: string): string { return bullet .replace(/^helped to\s*/i, 'contributed to ') .replace(/^assisted with\s*/i, 'supported ') .replace(/^was part of\s*/i, 'member of team that ') .replace(/^worked on\s*/i, 'contributed to ') .replace(/^participated in\s*/i, 'part of ') .trim(); } /** * Score and rank all bullets from the candidate profile for the target role. * Returns HighlightCandidates sorted descending by totalScore. * Excludes bullets below SCORE_THRESHOLD. */ export function rankCandidateHighlights( candidate: CandidateProfile, role: TargetRoleProfile, _fit: FitAnalysis // reserved for future use (fit context enrichment) ): HighlightCandidate[] { const results: HighlightCandidate[] = []; // Score every bullet from every role for (const resumeRole of candidate.roles) { for (const bullet of resumeRole.bullets) { if (!bullet || bullet.trim().length < 10) continue; // Skip tiny/empty bullets const { scores, totalScore } = scoreHighlight( bullet, role, DEFAULT_WEIGHTS, candidate.roles ); if (totalScore < SCORE_THRESHOLD) continue; // Below threshold — skip const needsSoftening = shouldSoften(bullet); const softenedText = needsSoftening ? softenBullet(bullet) : undefined; // Find the strongest dimension for rationale const topDimension = ( Object.entries(scores) as [keyof typeof scores, number][] ).sort(([, a], [, b]) => b - a)[0][0]; const rationale = buildRationale(scores, totalScore, topDimension); results.push({ text: bullet, source: `${resumeRole.title} at ${resumeRole.employer || 'Unknown'}`, scores, totalScore, rationale, isSoftened: needsSoftening, softenedText, }); } } // Also include project bullets for (const projectBullet of candidate.projects) { if (!projectBullet || projectBullet.trim().length < 10) continue; const { scores, totalScore } = scoreHighlight( projectBullet, role, DEFAULT_WEIGHTS, [] ); if (totalScore < SCORE_THRESHOLD) continue; const topDimension = ( Object.entries(scores) as [keyof typeof scores, number][] ).sort(([, a], [, b]) => b - a)[0][0]; results.push({ text: projectBullet, source: 'Projects', scores, totalScore, rationale: buildRationale(scores, totalScore, topDimension), isSoftened: false, }); } // Sort descending by total score return rankHighlights(results); }