/** * validation.ts * Structural validation for carousel documents, slide ordering, and content integrity. */ import type { CarouselDocument, SlideContent, ValidationResult } from '../schema/carouselSchema'; import { validateCopyLimits } from './text'; // --------------------------------------------------------------------------- // Slide-level validators // --------------------------------------------------------------------------- /** * Check that no two slides share suspiciously identical content. * Compares titles and the first 60 chars of body copy. */ export function checkNoDuplicateContent(slides: SlideContent[]): ValidationResult { const seenTitles = new Set(); const seenBodyFragments = new Set(); for (const slide of slides) { const titleKey = slide.title.toLowerCase().trim(); if (seenTitles.has(titleKey)) { return { valid: false, field: `slide_${slide.slideNumber}.title`, message: `Duplicate title found across slides: "${slide.title}"`, severity: 'error', }; } seenTitles.add(titleKey); if (slide.body) { const bodyFragment = slide.body.toLowerCase().trim().substring(0, 60); if (seenBodyFragments.has(bodyFragment)) { return { valid: false, field: `slide_${slide.slideNumber}.body`, message: `Near-duplicate body content detected on slide ${slide.slideNumber}`, severity: 'warning', }; } seenBodyFragments.add(bodyFragment); } } return { valid: true, message: 'No duplicate content detected', severity: 'info' }; } /** * Check that at least one slide has a CTA (call-to-action) field set. * Required for the carousel to be actionable for the audience. */ export function checkCTAPresent(slides: SlideContent[]): ValidationResult { const hasCta = slides.some( (s) => s.templateType === 'cta' && s.cta && s.cta.trim().length > 0 ); if (!hasCta) { return { valid: false, field: 'slides', message: 'No CTA slide found. The carousel must have a call-to-action on the final slide.', severity: 'error', }; } return { valid: true, message: 'CTA slide present', severity: 'info' }; } /** * Check that slides are in a coherent order: * - Slide 1 must be 'hook' * - Last slide must be 'cta' * - 'proof' slides should appear in the middle * - Slide numbers must be sequential */ export function checkSlideOrdering(slides: SlideContent[]): ValidationResult { if (slides.length === 0) { return { valid: false, field: 'slides', message: 'No slides found in document', severity: 'error', }; } // Check sequential numbering for (let i = 0; i < slides.length; i++) { if (slides[i].slideNumber !== i + 1) { return { valid: false, field: `slide_${i + 1}.slideNumber`, message: `Slide numbering is not sequential. Expected ${i + 1}, got ${slides[i].slideNumber}`, severity: 'error', }; } } // Check first slide is hook if (slides[0].templateType !== 'hook') { return { valid: false, field: 'slide_1.templateType', message: `First slide should be type 'hook', got '${slides[0].templateType}'`, severity: 'warning', }; } // Check last slide is cta const lastSlide = slides[slides.length - 1]; if (lastSlide.templateType !== 'cta') { return { valid: false, field: `slide_${lastSlide.slideNumber}.templateType`, message: `Last slide should be type 'cta', got '${lastSlide.templateType}'`, severity: 'warning', }; } return { valid: true, message: 'Slide ordering is coherent', severity: 'info' }; } // --------------------------------------------------------------------------- // Document-level validator // --------------------------------------------------------------------------- /** * Run all validation checks on a complete CarouselDocument. * Returns array of ValidationResults — errors and warnings combined. */ export function validateCarouselDocument(doc: CarouselDocument): ValidationResult[] { const results: ValidationResult[] = []; // Structural checks results.push(checkNoDuplicateContent(doc.slides)); results.push(checkCTAPresent(doc.slides)); results.push(checkSlideOrdering(doc.slides)); // Copy limit checks for each slide for (const slide of doc.slides) { const copyResults = validateCopyLimits(slide); results.push(...copyResults); } // Positioning angle present if (!doc.positioningAngle.headline || doc.positioningAngle.headline.trim().length === 0) { results.push({ valid: false, field: 'positioningAngle.headline', message: 'Positioning angle headline is empty', severity: 'error', }); } // At least 3 highlights ranked if (doc.highlights.length < 3) { results.push({ valid: false, field: 'highlights', message: `Only ${doc.highlights.length} highlights ranked. Carousel needs at least 3 for proof slides.`, severity: 'warning', }); } // Check for highlights that scored below threshold const weakHighlights = doc.highlights.filter((h) => h.totalScore < 3.5); if (weakHighlights.length > 0) { results.push({ valid: false, field: 'highlights', message: `${weakHighlights.length} highlight(s) scored below 3.5 and should be excluded from slides`, severity: 'warning', }); } return results; } /** * Format validation results for console display. */ export function formatValidationReport(results: ValidationResult[]): string { const errors = results.filter((r) => !r.valid && r.severity === 'error'); const warnings = results.filter((r) => !r.valid && r.severity === 'warning'); const passed = results.filter((r) => r.valid); const lines: string[] = [ `\nValidation Report: ${passed.length} passed, ${warnings.length} warnings, ${errors.length} errors`, ]; if (errors.length > 0) { lines.push('\nErrors:'); errors.forEach((e) => lines.push(` [ERROR] ${e.field ? `${e.field}: ` : ''}${e.message}`)); } if (warnings.length > 0) { lines.push('\nWarnings:'); warnings.forEach((w) => lines.push(` [WARN] ${w.field ? `${w.field}: ` : ''}${w.message}`)); } return lines.join('\n'); }