/** * Deterministic repairs for LLM PageSpecV1 before Zod validation. * Mirrors interactive-demo/normalize — fix common shape/field aliases. */ import { normalizeInteractiveDemoCandidate } from '@/lib/interactive-demo/normalize' import { CALLOUT_KINDS, INTERACTIVE_PAGE_CAPS } from './constants' const CALLOUT_SET = new Set(CALLOUT_KINDS) const CALLOUT_ALIASES: Record = { definition: 'definition', def: 'definition', warning: 'warning', warn: 'warning', danger: 'warning', alert: 'warning', tip: 'tip', hint: 'tip', advice: 'tip', note: 'note', info: 'note', remark: 'note', } function asRecord(v: unknown): Record | null { return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : null } function asString(v: unknown): string | undefined { if (typeof v === 'string' && v.trim()) return v.trim() if (typeof v === 'number' && Number.isFinite(v)) return String(v) return undefined } function slugId(title: string, fallback: string): string { const s = title .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '') .slice(0, 40) return s ? `page.${s}` : fallback } function pickMd(obj: Record): string | undefined { return ( asString(obj.md) || asString(obj.content) || asString(obj.text) || asString(obj.body) || asString(obj.html) || asString(obj.markdown) ) } function pickTex(obj: Record): string | undefined { return ( asString(obj.tex) || asString(obj.latex) || asString(obj.formula) || asString(obj.math) || asString(obj.equation) ) } function normalizeCalloutKind(v: unknown): string { if (typeof v !== 'string') return 'note' const key = v.trim().toLowerCase() const mapped = CALLOUT_ALIASES[key] || key return CALLOUT_SET.has(mapped) ? mapped : 'note' } function normalizeBlock( raw: unknown, index: number ): Record | null { const obj = asRecord(raw) if (!obj) return null const type = asString(obj.type)?.toLowerCase() if (!type) return null if (type === 'prose' || type === 'text' || type === 'markdown' || type === 'paragraph') { const md = pickMd(obj) if (!md) return null return { type: 'prose', md } } if (type === 'formula' || type === 'math' || type === 'equation' || type === 'katex') { const tex = pickTex(obj) if (!tex) return null const out: Record = { type: 'formula', tex } const caption = asString(obj.caption) if (caption) out.caption = caption return out } if (type === 'callout' || type === 'aside' || type === 'box') { const md = pickMd(obj) || '—' const title = asString(obj.title) || asString(obj.heading) || 'Note' return { type: 'callout', kind: normalizeCalloutKind(obj.kind || obj.variant || obj.style), title, md, } } if (type === 'demo' || type === 'interactive' || type === 'animation') { const nested = obj.demo ?? obj.spec ?? obj.interactiveDemo if (!nested) return null const normalized = normalizeInteractiveDemoCandidate(nested) const out: Record = { type: 'demo', demo: normalized } const caption = asString(obj.caption) if (caption) out.caption = caption return out } if (type === 'chart' || type === 'graph') { const payload = asRecord(obj.payload) || asRecord(obj.chart) || obj const series = Array.isArray((payload as Record).series) ? (payload as Record).series : null if (!series) return null const chartType = asString((payload as Record).chartType) || asString(obj.chartType) || 'line' const out: Record = { type: 'chart', payload: { chartType: ['line', 'bar', 'area'].includes(chartType) ? chartType : 'line', series, }, } const caption = asString(obj.caption) if (caption) out.caption = caption return out } if (type === 'stats' || type === 'metrics' || type === 'kpis') { const items = Array.isArray(obj.items) ? obj.items : Array.isArray(obj.stats) ? obj.stats : null if (!items || items.length < 2) return null return { type: 'stats', items: items.slice(0, INTERACTIVE_PAGE_CAPS.maxStatsItems).map((it) => { const r = asRecord(it) || {} return { value: asString(r.value) || asString(r.v) || '—', label: asString(r.label) || asString(r.name) || '—', ...(asString(r.intent) ? { intent: asString(r.intent) } : {}), } }), } } if (type === 'table') { const columns = Array.isArray(obj.columns) ? obj.columns.map((c) => asString(c) || '—') : [] const rows = Array.isArray(obj.rows) ? obj.rows : [] if (!columns.length) return null const out: Record = { type: 'table', columns, rows } const caption = asString(obj.caption) if (caption) out.caption = caption return out } if (type === 'image' || type === 'img' || type === 'figure') { const src = asString(obj.src) || asString(obj.url) const alt = asString(obj.alt) || asString(obj.title) || 'Image' if (!src) return null const out: Record = { type: 'image', src, alt } const caption = asString(obj.caption) if (caption) out.caption = caption return out } if (type === 'sim' || type === 'simulation' || type === 'slider' || type === 'interactive-sim') { const sim = asRecord(obj.sim) || asRecord(obj.simulator) || obj const simId = asString(sim.simId) || asString(sim.simulator) || asString(sim.id) if (!simId) return null const out: Record = { type: 'sim', sim: { ...sim, simId } } const caption = asString(obj.caption) if (caption) out.caption = caption return out } return null } function normalizeSection( raw: unknown, index: number ): Record | null { const obj = asRecord(raw) if (!obj) return null const title = asString(obj.title) || asString(obj.heading) || `Section ${index + 1}` const id = asString(obj.id) || `s${index + 1}` const blocksRaw = Array.isArray(obj.blocks) ? obj.blocks : Array.isArray(obj.content) ? obj.content : [] const blocks = blocksRaw .map((b, i) => normalizeBlock(b, i)) .filter(Boolean) .slice(0, INTERACTIVE_PAGE_CAPS.maxBlocksPerSection) as Record[] if (!blocks.length) { blocks.push({ type: 'prose', md: asString(obj.summary) || asString(obj.lead) || title, }) } return { id, title, blocks } } /** * Best-effort normalize of an LLM page candidate. * Returns a plain object ready for validateInteractivePage. */ export function normalizeInteractivePageCandidate( input: unknown, lang = 'fr' ): Record | null { const root = asRecord(input) if (!root) return null const heroIn = asRecord(root.hero) || {} const title = asString(heroIn.title) || asString(root.title) || 'Page interactive' const kicker = asString(heroIn.kicker) || asString(heroIn.eyebrow) || asString(heroIn.label) || (lang.startsWith('fr') ? 'EXPLAINER INTERACTIF' : 'INTERACTIVE EXPLAINER') const hero: Record = { kicker, title, } const subtitle = asString(heroIn.subtitle) || asString(root.subtitle) const meta = asString(heroIn.meta) || asString(root.meta) if (subtitle) hero.subtitle = subtitle if (meta) hero.meta = meta let overview: Record | undefined const overviewIn = asRecord(root.overview) if (overviewIn) { const lead = asString(overviewIn.lead) || asString(overviewIn.summary) || asString(overviewIn.text) const cardsRaw = Array.isArray(overviewIn.cards) ? overviewIn.cards : [] const cards = cardsRaw .map((c) => { const r = asRecord(c) if (!r) return null const badge = asString(r.badge) || asString(r.label) || 'IDEA' const cTitle = asString(r.title) || badge const body = asString(r.body) || asString(r.text) || asString(r.md) || cTitle return { badge, title: cTitle, body, ...(asString(r.intent) ? { intent: asString(r.intent) } : {}), } }) .filter(Boolean) if (lead && cards.length >= INTERACTIVE_PAGE_CAPS.minOverviewCards) { overview = { lead, cards: cards.slice(0, INTERACTIVE_PAGE_CAPS.maxOverviewCards), } } else if (lead) { // Pad to min cards so validation can pass const padded = [...cards] while (padded.length < INTERACTIVE_PAGE_CAPS.minOverviewCards) { padded.push({ badge: `C${padded.length + 1}`, title: title, body: lead.slice(0, 120), }) } overview = { lead, cards: padded.slice(0, INTERACTIVE_PAGE_CAPS.maxOverviewCards), } } } const sectionsRaw = Array.isArray(root.sections) ? root.sections : [] let sections = sectionsRaw .map((s, i) => normalizeSection(s, i)) .filter(Boolean) as Record[] // Cap demos (speed + reliability): keep first 2 let demoCount = 0 sections = sections.map((sec) => { const blocks = (sec.blocks as Record[]).filter((b) => { if (b.type !== 'demo') return true demoCount += 1 return demoCount <= 2 }) return { ...sec, blocks } }) sections = sections.slice(0, 5) if (!sections.length) { sections = [ { id: 's1', title: title, blocks: [{ type: 'prose', md: asString(root.summary) || title }], }, ] } const out: Record = { schemaVersion: 1, id: asString(root.id) || slugId(title, 'page.generated'), lang: asString(root.lang) || lang, hero, sections, } if (overview) out.overview = overview const footer = asString(root.footer) if (footer) out.footer = footer return out }