/** * writeSlides.ts * Converts SlideOutlines into fully written SlideContent objects. * Applies copy rules: title ≤9 words, subtitle ≤18, body ≤30, bullets ≤3. * All copy must be grounded in candidate/role data — no fabrication. */ import type { CandidateProfile, Education, FitAnalysis, HighlightCandidate, PositioningAngle, Role, SlideContent, SlideOutline, TargetRoleProfile, TimelineEntry, } from '../schema/carouselSchema'; import { compressToSocialCopy, countWords, distillAchievement, distillToTitle, truncate, validateCopyLimits } from '../utils/text'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function clampTitle(text: string): string { return distillToTitle(text, 9); } function clampSubtitle(text: string): string { return truncate(text, 18); } function clampBody(text: string): string { return truncate(text, 30); } /** Extract a hero metric from a bullet string if present. */ function extractMetric(text: string): string | undefined { const match = text.match(/(\$[\d,]+\.?\d*[KMBkm]?|\d+[%]|\d+[xX]|\d+[KMBkm]\b)/); return match ? match[1] : undefined; } /** Pick up to 3 shortest/punchiest bullets from a set. */ function pickTopBullets(bullets: string[], maxCount = 3): string[] { return bullets .map((b) => compressToSocialCopy(b, 15)) .filter((b) => b.length > 5) .slice(0, maxCount); } // --------------------------------------------------------------------------- // Timeline helpers // --------------------------------------------------------------------------- function extractYear(dateStr?: string): string | undefined { if (!dateStr) return undefined; const m = dateStr.match(/\b(19|20)\d{2}\b/); return m ? m[0] : undefined; } function parseDate(dateStr?: string): Date | undefined { if (!dateStr) return undefined; const yearMatch = dateStr.match(/\b(19|20)\d{2}\b/); if (!yearMatch) return undefined; const year = parseInt(yearMatch[0], 10); const monthMap: Record = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11, }; const mMatch = dateStr.match(/\b(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\b/i); const month = mMatch ? (monthMap[mMatch[0].toLowerCase().slice(0, 3)] ?? 0) : 0; return new Date(year, month); } function computeYearsExperience(roles: Role[]): number { const now = new Date(); let totalMonths = 0; for (const role of roles) { const start = parseDate(role.startDate); const end = role.isCurrent ? now : parseDate(role.endDate); if (start && end) { const months = (end.getFullYear() - start.getFullYear()) * 12 + (end.getMonth() - start.getMonth()); totalMonths += Math.max(0, months); } } return totalMonths / 12; } export type ShowEducation = 'auto' | 'always' | 'never'; function shouldIncludeEducation( candidate: CandidateProfile, target: TargetRoleProfile, showEducation: ShowEducation ): boolean { if (showEducation === 'always') return true; if (showEducation === 'never') return false; if (candidate.education.length === 0) return false; // Auto: include if early career (< 6 years experience) const years = computeYearsExperience(candidate.roles); if (years < 6) return true; // Auto: include if degree field is relevant to JD domain or skills for (const edu of candidate.education) { const eduText = [edu.degree, edu.field ?? '', edu.institution].join(' ').toLowerCase(); const relevant = target.domainClues.some((d) => eduText.includes(d.toLowerCase())) || target.requiredSkills.some((s) => { const core = s.split(/\s+/)[0].toLowerCase(); return core.length > 3 && eduText.includes(core); }); if (relevant) return true; } return false; } function buildTimelineEntries( candidate: CandidateProfile, fit: FitAnalysis, target: TargetRoleProfile, includeEducation: boolean ): TimelineEntry[] { const entries: TimelineEntry[] = []; const highlightedBullets = new Set(fit.bestAchievements); for (const role of candidate.roles) { const year = extractYear(role.startDate); if (!year) continue; const isHighlighted = role.bullets.some((b) => highlightedBullets.has(b)); entries.push({ year, label: role.title, sublabel: role.employer || undefined, isHighlighted, isEducation: false, isCurrent: role.isCurrent, }); } if (includeEducation) { for (const edu of candidate.education) { if (!edu.graduationYear) continue; entries.push({ year: edu.graduationYear, label: edu.degree, sublabel: edu.institution || undefined, isHighlighted: false, isEducation: true, isCurrent: false, }); } } // Sort chronologically entries.sort((a, b) => parseInt(a.year, 10) - parseInt(b.year, 10)); return entries; } function writeTimelineSlide( outline: SlideOutline, candidate: CandidateProfile, role: TargetRoleProfile, fit: FitAnalysis, showEducation: ShowEducation ): Omit { const includeEdu = shouldIncludeEducation(candidate, role, showEducation); const timelineEntries = buildTimelineEntries(candidate, fit, role, includeEdu); const years = computeYearsExperience(candidate.roles); const yearsLabel = years >= 1 ? `${Math.round(years)}+ Years` : 'Career'; const domain = role.domainClues[0] ?? role.themes[0] ?? 'Professional'; const title = clampTitle(`${yearsLabel} of ${capitalize(domain)} Growth`); const subtitle = clampSubtitle(`Highlighted roles are most relevant to ${role.title}`); return { title, subtitle, timelineEntries }; } // --------------------------------------------------------------------------- // Slide writers by template type // --------------------------------------------------------------------------- function writeHookSlide( outline: SlideOutline, angle: PositioningAngle, role: TargetRoleProfile ): Omit { const title = clampTitle(angle.headline); const subtitle = clampSubtitle(angle.subheading); const body = clampBody(`Targeting ${role.title}${role.company ? ` at ${role.company}` : ''}.`); return { title, subtitle, body }; } function writeFitSlide( outline: SlideOutline, fit: FitAnalysis, role: TargetRoleProfile ): Omit { const title = clampTitle(`Why I fit the ${role.title} role`); const subtitle = clampSubtitle('3 strengths that align with what you need'); // Use strongest alignments as bullet cards const alignmentBullets = fit.strongestAlignments .slice(0, 3) .map((a) => { const shortEvidence = truncate(a.evidence, 8); return `${formatAreaLabel(a.area)}: ${shortEvidence}`; }); // Fallback to role themes if alignments are sparse const bullets = alignmentBullets.length >= 2 ? alignmentBullets : role.themes.slice(0, 3).map((t) => capitalize(t)); return { title, subtitle, bullets: bullets.slice(0, 3) }; } function writeProofSlide( outline: SlideOutline, highlightText: string, candidate: CandidateProfile, allHighlights: HighlightCandidate[] ): Omit { // Find the HighlightCandidate for this text (if available, use softened version) const hc = allHighlights.find((h) => h.text === highlightText); const displayText = hc?.isSoftened && hc.softenedText ? hc.softenedText : highlightText; const metric = extractMetric(highlightText); // Body: distill to core action + metrics, no raw truncation const body = distillAchievement(displayText, 30); // Title: extract the opening action phrase from the distilled body (before any "—") const titleSource = body.split('—')[0].trim(); const titleWords = titleSource.split(/\s+/).slice(0, 7).join(' '); const title = clampTitle(titleWords); // Subtitle: source context const source = hc?.source || 'Previous role'; const subtitle = clampSubtitle(`${source} — signature achievement`); return { title, subtitle, body, metric }; } function writeWorkStyleSlide( outline: SlideOutline, candidate: CandidateProfile, role: TargetRoleProfile ): Omit { const title = clampTitle('How I work and what that means'); // Build capability pattern bullets from ownership indicators + tools const capabilities: string[] = []; if (candidate.ownershipIndicators.includes('led') || candidate.ownershipIndicators.includes('owned')) { capabilities.push('Own outcomes end-to-end — not just tasks'); } if (candidate.tools.includes('sql') || candidate.tools.includes('python')) { capabilities.push('Data-first: I validate assumptions before shipping'); } if (candidate.ownershipIndicators.includes('launched') || candidate.ownershipIndicators.includes('shipped')) { capabilities.push('Ship fast, iterate in the open'); } if (candidate.collaborationIndicators.length > 2) { capabilities.push('Collaborative by default — stakeholders are partners, not blockers'); } if (candidate.ownershipIndicators.includes('built') || candidate.ownershipIndicators.includes('architected')) { capabilities.push('Build systems that scale, not just solutions that work today'); } // Ensure at least 2 bullets if (capabilities.length < 2) { capabilities.push(`Drive ${role.themes[0] || 'impact'} through structured execution`); capabilities.push('Communicate clearly — in code, docs, and conversations'); } const bullets = capabilities.slice(0, 3); const subtitle = clampSubtitle('My operating philosophy in three lines'); return { title, subtitle, bullets }; } function writeValuePropSlide( outline: SlideOutline, candidate: CandidateProfile, role: TargetRoleProfile, fit: FitAnalysis, angle: PositioningAngle ): Omit { const title = clampTitle(`What I bring to ${role.title}`); // 1-sentence synthesis const topStrengths = fit.strongestAlignments.slice(0, 2).map((a) => a.area).join(' and '); const body = clampBody( `${topStrengths ? `Deep expertise in ${topStrengths}` : 'Strong cross-functional expertise'} — paired with a track record of shipping and scaling. The combination you need for ${role.title}.` ); const subtitle = clampSubtitle(angle.subheading); return { title, subtitle, body }; } function writeCtaSlide( outline: SlideOutline, role: TargetRoleProfile, angle: PositioningAngle ): Omit { const title = clampTitle(`Open to ${role.title} opportunities`); const subtitle = clampSubtitle('Let\'s talk if this resonates'); const cta = `📩 Connect on LinkedIn or DM me. ${role.company ? `Particularly interested in ${role.company}-type companies.` : 'Especially at companies building something meaningful.'}`; const body = clampBody( `If you're hiring a ${role.title} who ${angle.headline.split('—')[1]?.trim() || 'delivers results'}, let's connect.` ); return { title, subtitle, body, cta }; } // --------------------------------------------------------------------------- // Main writer // --------------------------------------------------------------------------- /** * Convert slide outlines into fully written SlideContent. * Validates copy limits and returns validated slides. */ export function writeSlideCopy( outlines: SlideOutline[], candidate: CandidateProfile, role: TargetRoleProfile, fit: FitAnalysis, angle: PositioningAngle, allHighlights: HighlightCandidate[], showEducation: ShowEducation = 'auto' ): SlideContent[] { const slides: SlideContent[] = []; for (const outline of outlines) { let partial: Omit; switch (outline.templateType) { case 'hook': partial = writeHookSlide(outline, angle, role); break; case 'timeline': partial = writeTimelineSlide(outline, candidate, role, fit, showEducation); break; case 'fit': partial = writeFitSlide(outline, fit, role); break; case 'proof': { const highlightText = outline.highlightRef || fit.bestAchievements[0] || ''; partial = writeProofSlide(outline, highlightText, candidate, allHighlights); break; } case 'workstyle': partial = writeWorkStyleSlide(outline, candidate, role); break; case 'valueprop': partial = writeValuePropSlide(outline, candidate, role, fit, angle); break; case 'cta': partial = writeCtaSlide(outline, role, angle); break; default: partial = { title: clampTitle(`Slide ${outline.slideNumber}`), body: outline.intent, }; } const slide: SlideContent = { slideNumber: outline.slideNumber, templateType: outline.templateType, speakerNote: outline.intent, ...partial, }; // Run copy limit validation (log warnings but don't block) const validationResults = validateCopyLimits(slide); for (const result of validationResults) { if (!result.valid) { // Auto-trim if validation fails (belt-and-suspenders) if (result.field === 'title') slide.title = clampTitle(slide.title); if (result.field === 'subtitle' && slide.subtitle) slide.subtitle = clampSubtitle(slide.subtitle); if (result.field === 'body' && slide.body) slide.body = clampBody(slide.body); if (result.field === 'bullets' && slide.bullets) slide.bullets = slide.bullets.slice(0, 3); } } slides.push(slide); } return slides; } // --------------------------------------------------------------------------- // Helper // --------------------------------------------------------------------------- function capitalize(str: string): string { if (!str) return ''; return str.charAt(0).toUpperCase() + str.slice(1); } // Known acronyms that should always be fully uppercased const KNOWN_ACRONYMS = new Set([ 'sql', 'aws', 'gcp', 'api', 'ml', 'ai', 'llm', 'nlp', 'etl', 'bi', 'kpi', 'rag', 'ci', 'cd', 'ab', 'crm', 'erp', 'saas', 'sdk', 'ui', 'ux', 'css', 'html', 'json', 'csv', 'dbt', 'cte', ]); /** * Format a skill/area label for display. * Uppercases known acronyms; title-cases everything else. */ function formatAreaLabel(area: string): string { const lower = area.toLowerCase(); if (KNOWN_ACRONYMS.has(lower)) return area.toUpperCase(); // Handle compound acronyms like "ci/cd", "power bi" const parts = area.split(/\s+/); return parts.map((p) => KNOWN_ACRONYMS.has(p.toLowerCase()) ? p.toUpperCase() : capitalize(p)).join(' '); }