/** * text.ts * Text processing utilities for slide copy generation and validation. * All functions are pure and deterministic. */ import type { SlideContent, ValidationResult } from '../schema/carouselSchema'; // --------------------------------------------------------------------------- // Basic text utilities // --------------------------------------------------------------------------- /** * Count the number of words in a string. */ export function countWords(text: string): number { return text.trim().split(/\s+/).filter(Boolean).length; } /** * Truncate text to a maximum word count, appending ellipsis if truncated. */ export function truncate(text: string, maxWords: number): string { const words = text.trim().split(/\s+/).filter(Boolean); if (words.length <= maxWords) return text; return words.slice(0, maxWords).join(' ') + '…'; } // --------------------------------------------------------------------------- // Resume filler patterns to strip when compressing to social copy // --------------------------------------------------------------------------- const FILLER_PATTERNS: RegExp[] = [ /\b(responsible for|worked on|helped to|assisted with|tasked with)\b/gi, /\b(various|numerous|multiple|a number of)\b/gi, /\b(effectively|successfully|proactively|synergistically)\b/gi, /\b(results-oriented|self-starter|team player|detail-oriented|passionate about)\b/gi, /\b(in order to|so as to|with the goal of)\b/gi, /\b(leveraged|utilized|facilitated|spearheaded)\b/gi, // often filler when overused ]; const OWNERSHIP_VERBS: Record = { 'was responsible for': 'owned', 'worked on': 'built', 'helped to': 'contributed to', 'assisted with': 'supported', 'tasked with': '', }; /** * Compress a resume bullet into punchy social copy. * Removes filler phrases, normalizes ownership language, enforces word limit. */ export function compressToSocialCopy(bullet: string, maxWords: number): string { let text = bullet.trim(); // Replace common filler ownership phrases first for (const [pattern, replacement] of Object.entries(OWNERSHIP_VERBS)) { text = text.replace(new RegExp(pattern, 'gi'), replacement); } // Strip other filler patterns for (const pattern of FILLER_PATTERNS) { text = text.replace(pattern, ''); } // Collapse extra whitespace left by removals text = text.replace(/\s{2,}/g, ' ').trim(); // Capitalize first letter if (text.length > 0) { text = text.charAt(0).toUpperCase() + text.slice(1); } // Remove trailing punctuation that looks odd after compression text = text.replace(/[,;]\s*$/, '').trim(); return truncate(text, maxWords); } // --------------------------------------------------------------------------- // Copy limit validation // --------------------------------------------------------------------------- interface CopyLimits { titleMaxWords: number; subtitleMaxWords: number; bodyMaxWords: number; bulletsMax: number; } const DEFAULT_LIMITS: CopyLimits = { titleMaxWords: 9, subtitleMaxWords: 18, bodyMaxWords: 30, bulletsMax: 3, }; /** * Validate that a slide's copy stays within enforced limits. * Returns an array of ValidationResult — one per field that fails. */ export function validateCopyLimits( slide: SlideContent, limits: CopyLimits = DEFAULT_LIMITS ): ValidationResult[] { const results: ValidationResult[] = []; const titleWords = countWords(slide.title); if (titleWords > limits.titleMaxWords) { results.push({ valid: false, field: 'title', message: `Slide ${slide.slideNumber} title is ${titleWords} words (max ${limits.titleMaxWords}): "${slide.title}"`, severity: 'warning', }); } if (slide.subtitle) { const subtitleWords = countWords(slide.subtitle); if (subtitleWords > limits.subtitleMaxWords) { results.push({ valid: false, field: 'subtitle', message: `Slide ${slide.slideNumber} subtitle is ${subtitleWords} words (max ${limits.subtitleMaxWords})`, severity: 'warning', }); } } if (slide.body) { const bodyWords = countWords(slide.body); if (bodyWords > limits.bodyMaxWords) { results.push({ valid: false, field: 'body', message: `Slide ${slide.slideNumber} body is ${bodyWords} words (max ${limits.bodyMaxWords})`, severity: 'warning', }); } } if (slide.bullets && slide.bullets.length > limits.bulletsMax) { results.push({ valid: false, field: 'bullets', message: `Slide ${slide.slideNumber} has ${slide.bullets.length} bullets (max ${limits.bulletsMax})`, severity: 'error', }); } return results; } // --------------------------------------------------------------------------- // Smart distillation // --------------------------------------------------------------------------- /** * Distill a long headline into a tight title (≤maxWords words). * Strategy: * 1. If there's a colon, prefer the part after the colon (the descriptor). * 2. If there's an em-dash, prefer the part before it. * 3. Strip role-level suffixes like "-ready", "opportunities", "role". * 4. Fall back to compressToSocialCopy if still over limit. */ export function distillToTitle(text: string, maxWords: number): string { let candidate = text.trim(); // Prefer content after a colon — it's usually the punchy descriptor const colonIdx = candidate.indexOf(':'); if (colonIdx !== -1) { const afterColon = candidate.slice(colonIdx + 1).trim(); if (countWords(afterColon) <= maxWords && afterColon.length > 4) { candidate = afterColon; } } // Prefer content before an em-dash — usually the role/identity hook const dashIdx = candidate.indexOf('—'); if (dashIdx !== -1 && countWords(candidate) > maxWords) { const beforeDash = candidate.slice(0, dashIdx).trim(); if (countWords(beforeDash) <= maxWords && beforeDash.length > 4) { candidate = beforeDash; } } // Strip common verbose suffixes candidate = candidate .replace(/\s*-ready\b/gi, '') .replace(/\s+opportunities\b/gi, '') .replace(/\s+role\b/gi, '') .trim(); // If still over limit, compress then truncate if (countWords(candidate) > maxWords) { candidate = compressToSocialCopy(candidate, maxWords); } // Capitalize first letter if (candidate.length > 0) { candidate = candidate.charAt(0).toUpperCase() + candidate.slice(1); } return candidate; } /** * Distill a resume achievement bullet into tight proof-slide body copy. * Strategy: extract opening action phrase + any metrics, combine as "{action} — {metrics}". * Avoids raw truncation with "…". */ export function distillAchievement(text: string, maxWords: number): string { let cleaned = text.trim(); // Replace ownership filler for (const [pattern, replacement] of Object.entries({ 'was responsible for': 'owned', 'worked on': 'built', 'helped to': 'contributed to', 'assisted with': 'supported', 'tasked with': '', })) { cleaned = cleaned.replace(new RegExp(pattern, 'gi'), replacement); } cleaned = cleaned.replace(/\s{2,}/g, ' ').trim(); // Extract all metrics from the full text const metricMatches = cleaned.match(/\$?[\d,]+\.?\d*[KMBkm%+★x×]?(?:\+)?(?:\s*(?:users|conversations|ratings?|stars?|downloads?|clients?|customers?|people|months?|weeks?))?/gi) || []; const metrics = metricMatches .map((m) => m.trim()) .filter((m) => /\d/.test(m) && m.length > 1) .slice(0, 3) .join(', '); // Extract the opening action phrase: up to first "for", "to", "using", "with", "via", comma, or semicolon const actionMatch = cleaned.match(/^(.+?)(?:\s+(?:for|to|using|via|in order)\s|\s*[,;])/i); const actionPhrase = actionMatch ? actionMatch[1].trim() : cleaned.split(/\s+/).slice(0, 8).join(' '); // Combine action + metrics if we have both let result = metrics ? `${actionPhrase} — ${metrics}` : actionPhrase; // Capitalize if (result.length > 0) { result = result.charAt(0).toUpperCase() + result.slice(1); } // Final safety: if still over limit, compress if (countWords(result) > maxWords) { result = compressToSocialCopy(result, maxWords); } return result; } // --------------------------------------------------------------------------- // Anti-fabrication detection // --------------------------------------------------------------------------- /** * Check whether a claim in slide copy can be grounded in the source bullets. * * Strategy: * 1. Extract significant tokens (numbers, proper nouns, key verbs) from the claim. * 2. Check if those tokens appear in at least one source bullet. * 3. If none of the significant tokens match, flag as fabrication risk. * * Returns true if fabrication risk is detected (claim cannot be grounded). */ export function detectFabricationRisk( claim: string, sourceBullets: string[] ): boolean { if (!claim || sourceBullets.length === 0) return true; const combinedSource = sourceBullets.join(' ').toLowerCase(); // Extract tokens that carry factual content const significantTokens = extractSignificantTokens(claim); if (significantTokens.length === 0) return false; // No checkable claims // A claim is grounded if ANY significant token appears in source // (We use OR logic because paraphrasing legitimately changes phrasing) const isGrounded = significantTokens.some((token) => combinedSource.includes(token.toLowerCase()) ); return !isGrounded; } /** * Extract tokens that carry factual weight: numbers, percentages, dollar amounts, * proper nouns (capitalized words not at sentence start), and strong action verbs. */ function extractSignificantTokens(text: string): string[] { const tokens: string[] = []; // Numbers with units: "40%", "$2M", "150K", "3 months" const numberMatches = text.match(/\$?[\d,]+\.?\d*[KMBkm%]?/g) || []; tokens.push(...numberMatches); // Capitalized multi-word phrases that look like proper nouns const properNounMatches = text.match(/\b[A-Z][a-z]+(?:\s[A-Z][a-z]+)+/g) || []; tokens.push(...properNounMatches); // Single capitalized words in mid-sentence (likely tool/company names) const words = text.split(/\s+/); for (let i = 1; i < words.length; i++) { const word = words[i]; if (/^[A-Z][a-zA-Z]+$/.test(word) && word.length > 2) { tokens.push(word); } } // Strong ownership verbs that imply specific claims const ownershipVerbMatches = text.match( /\b(launched|built|led|founded|architected|designed|migrated|reduced|increased|saved|generated|managed|shipped|created)\b/gi ) || []; tokens.push(...ownershipVerbMatches); return [...new Set(tokens)]; // deduplicate }