/** * fitAnalysis.ts * Compares CandidateProfile vs TargetRoleProfile to produce a structured FitAnalysis. * This drives which slides get created and what narrative is chosen. */ import type { AlignmentItem, CandidateProfile, FitAnalysis, PositioningMode, TargetRoleProfile, } from '../schema/carouselSchema'; // --------------------------------------------------------------------------- // Skill matching // --------------------------------------------------------------------------- /** * Score a single candidate skill/tool against a role's required and preferred skills. * Returns 0–10. */ function scoreSkillMatch( candidateSkill: string, requiredSkills: string[], preferredSkills: string[] ): number { const lower = candidateSkill.toLowerCase(); // Exact required match if (requiredSkills.some((s) => s.toLowerCase() === lower)) return 10; // Partial required match (substring) if (requiredSkills.some((s) => lower.includes(s.toLowerCase()) || s.toLowerCase().includes(lower))) return 8; // Exact preferred match if (preferredSkills.some((s) => s.toLowerCase() === lower)) return 6; // Partial preferred if (preferredSkills.some((s) => lower.includes(s.toLowerCase()) || s.toLowerCase().includes(lower))) return 4; return 0; } /** * Find all skills from candidate's tools that match the JD. * Returns them as AlignmentItems with supporting evidence (the bullet that uses the skill). * Each bullet is used as evidence for at most one skill to avoid duplicates on slides. */ function matchSkills( candidate: CandidateProfile, role: TargetRoleProfile ): AlignmentItem[] { const alignments: AlignmentItem[] = []; const usedBullets = new Set(); for (const tool of candidate.tools) { const strength = scoreSkillMatch(tool, role.requiredSkills, role.preferredSkills); if (strength >= 4) { // Find the best unused bullet that mentions this tool as evidence const evidence = candidate.allBullets.find( (b) => !usedBullets.has(b) && b.toLowerCase().includes(tool.toLowerCase()) ) || candidate.allBullets.find((b) => b.toLowerCase().includes(tool.toLowerCase())) || `Tool listed in resume: ${tool}`; // Only mark as used if it's a real bullet (not the fallback string) if (!evidence.startsWith('Tool listed')) usedBullets.add(evidence); alignments.push({ area: tool, evidence, strength }); } } return alignments; } // --------------------------------------------------------------------------- // Bullet-to-JD theme matching // --------------------------------------------------------------------------- /** * Extract the core keyword from a potentially long skill phrase. * e.g. "data visualization tools" → "visualization", "python for data analysis" → "python" */ function extractCoreKeyword(skill: string): string { // Return the first meaningful word (skip articles/prepositions) const stopWords = new Set(['for', 'and', 'or', 'the', 'a', 'an', 'with', 'in', 'of', 'to']); const words = skill.toLowerCase().split(/\s+/); return words.find((w) => w.length > 2 && !stopWords.has(w)) || skill.toLowerCase(); } /** * Check how well a bullet aligns to the JD themes and recruiter priorities. * Uses keyword-level matching rather than phrase-level to avoid missing partial matches. */ function scoreBulletToRole(bullet: string, role: TargetRoleProfile): number { const lower = bullet.toLowerCase(); let score = 0; // Required skill mentions — check both full phrase and core keyword let reqMatches = 0; for (const skill of role.requiredSkills) { const coreKw = extractCoreKeyword(skill); if (lower.includes(skill.toLowerCase()) || lower.includes(coreKw)) { reqMatches++; } } score += Math.min(reqMatches * 2, 6); // JD themes const themeMatches = role.themes.filter((t) => lower.includes(t.toLowerCase())).length; score += Math.min(themeMatches, 2); // Recruiter priorities const priorityMatches = role.recruiterPriorities.filter((p) => lower.includes(p.toLowerCase())).length; score += Math.min(priorityMatches, 2); return Math.min(score, 10); } // --------------------------------------------------------------------------- // Gap detection // --------------------------------------------------------------------------- /** * Identify JD requirements that don't appear in the resume at all. * Uses core keyword matching so "python for data analysis" doesn't show as a gap * when the resume clearly contains "python". */ function detectGaps(candidate: CandidateProfile, role: TargetRoleProfile): string[] { const gaps: string[] = []; const resumeLower = candidate.rawText.toLowerCase(); for (const skill of role.requiredSkills) { const coreKw = extractCoreKeyword(skill); // Gap only if neither the full phrase NOR the core keyword appears in the resume const fullMatch = resumeLower.includes(skill.toLowerCase()); const coreMatch = resumeLower.includes(coreKw); if (!fullMatch && !coreMatch) { // Report the core keyword only (not the full messy phrase) gaps.push(coreKw.length > 2 ? coreKw : skill); } } // Check domain gaps for (const domain of role.domainClues) { if (!candidate.domains.includes(domain) && !resumeLower.includes(domain.toLowerCase())) { gaps.push(`${domain} domain experience`); } } // Check leadership signals for (const signal of role.leadershipSignals.slice(0, 3)) { if (!resumeLower.includes(signal.toLowerCase())) { gaps.push(`leadership: ${signal}`); } } return [...new Set(gaps)].slice(0, 6); // Cap at 6 notable gaps } // --------------------------------------------------------------------------- // Fit story generation // --------------------------------------------------------------------------- /** * Generate a 1–2 sentence best-fit narrative based on alignment data. */ function generateBestFitStory( candidate: CandidateProfile, role: TargetRoleProfile, strongAlignments: AlignmentItem[], overallScore: number ): string { const topAreas = strongAlignments.slice(0, 3).map((a) => a.area); const mostRecentRole = candidate.roles[0]; const candidateTitle = mostRecentRole?.title || 'Experienced professional'; const targetTitle = role.title || 'the target role'; if (overallScore >= 7) { return `Strong fit: ${candidateTitle} with direct experience in ${topAreas.join(', ')} — closely matching ${targetTitle}'s core requirements.`; } else if (overallScore >= 5) { return `Solid adjacent fit: ${candidateTitle} whose ${topAreas.slice(0, 2).join(' and ')} experience translates well to ${targetTitle}, with some transferable gaps to address.`; } else { return `Career transition fit: ${candidateTitle} with ${topAreas[0] || 'transferable skills'} relevant to ${targetTitle} — the narrative should emphasize transferable patterns over direct keyword matches.`; } } // --------------------------------------------------------------------------- // Mode inference // --------------------------------------------------------------------------- /** * Infer the best positioning mode based on role and candidate signals. */ function inferPositioningMode( candidate: CandidateProfile, role: TargetRoleProfile ): PositioningMode { const lowerJD = role.rawText.toLowerCase(); const lowerResume = candidate.rawText.toLowerCase(); // Director/principal/staff → hiring-manager tends to care about technical credibility if (['principal', 'staff', 'director'].includes(role.seniority)) return 'hiring-manager'; // Lots of technical keywords in JD → hiring-manager const techKeywordsInJD = ['architecture', 'distributed', 'systems design', 'ml', 'infrastructure'].filter((k) => lowerJD.includes(k) ).length; if (techKeywordsInJD >= 3) return 'hiring-manager'; // Personal brand signals: consultant, freelance, open source, speaker const brandSignals = ['freelance', 'consultant', 'speaker', 'author', 'open source'].filter((k) => lowerResume.includes(k) ).length; if (brandSignals >= 2) return 'personal-brand'; // Default: recruiter mode return 'recruiter'; } // --------------------------------------------------------------------------- // Main analyzer // --------------------------------------------------------------------------- /** * Produce a FitAnalysis comparing the candidate to the target role. */ export function analyzeFit( candidate: CandidateProfile, role: TargetRoleProfile ): FitAnalysis { // Match skills from tools list const skillAlignments = matchSkills(candidate, role); // Score each bullet against the role const bulletScores = candidate.allBullets.map((bullet) => ({ bullet, score: scoreBulletToRole(bullet, role), })); // Separate alignment strengths const strongAlignments: AlignmentItem[] = skillAlignments.filter((a) => a.strength >= 7); const moderateAlignments: AlignmentItem[] = skillAlignments.filter( (a) => a.strength >= 4 && a.strength < 7 ); // Add bullet-level alignments for areas not covered by skills const coveredAreas = new Set(skillAlignments.map((a) => a.area.toLowerCase())); for (const { bullet, score } of bulletScores) { if (score >= 7) { // Find matching theme const matchedTheme = role.themes.find((t) => bullet.toLowerCase().includes(t.toLowerCase())); if (matchedTheme && !coveredAreas.has(matchedTheme.toLowerCase())) { strongAlignments.push({ area: matchedTheme, evidence: bullet, strength: score }); coveredAreas.add(matchedTheme.toLowerCase()); } } } // Best bullets for this role (top 7 by score) const bestAchievements = bulletScores .sort((a, b) => b.score - a.score) .slice(0, 7) .map((b) => b.bullet); // Detect gaps const notableGaps = detectGaps(candidate, role); // Calculate overall fit score (0–10) const avgSkillScore = skillAlignments.length > 0 ? skillAlignments.reduce((sum, a) => sum + a.strength, 0) / skillAlignments.length : 0; const avgBulletScore = bulletScores.length > 0 ? bulletScores.reduce((sum, b) => sum + b.score, 0) / bulletScores.length : 0; const gapPenalty = Math.min(notableGaps.length * 0.5, 3); const overallFitScore = Math.max( 0, Math.min(10, avgSkillScore * 0.5 + avgBulletScore * 0.5 - gapPenalty) ); const roundedScore = Math.round(overallFitScore * 10) / 10; const inferredMode = inferPositioningMode(candidate, role); const bestFitStory = generateBestFitStory(candidate, role, strongAlignments, roundedScore); return { bestFitStory, strongestAlignments: strongAlignments.slice(0, 5), moderateAlignments: moderateAlignments.slice(0, 5), notableGaps, bestAchievements, overallFitScore: roundedScore, inferredMode, }; }