/** * Deterministic repairs for LLM-produced Interactive Demo JSON * before Zod/semantic validation (common shape/id mistakes). */ import { ANNOTATION_KINDS, CHART_TYPES, FORBIDDEN_COLOR_RE, INTENT_IDS, PATTERN_IDS, SCOPE_IDS, TRANSITIONS, } from './constants' const INTENT_SET = new Set(INTENT_IDS) const PATTERN_SET = new Set(PATTERN_IDS) const SCOPE_SET = new Set(SCOPE_IDS) const ANN_KIND_SET = new Set(ANNOTATION_KINDS) const CHART_TYPE_SET = new Set(CHART_TYPES) const TRANSITION_SET = new Set(TRANSITIONS) const PANEL_TYPE_ALIASES: Record = { svg: 'svg-scene', svg_scene: 'svg-scene', 'svg-scene': 'svg-scene', graph: 'svg-scene', diagram: 'svg-scene', nodes: 'svg-scene', chart: 'chart', charts: 'chart', line: 'chart', bar: 'chart', area: 'chart', heatmap: 'heatmap-matrix', heatmap_matrix: 'heatmap-matrix', 'heatmap-matrix': 'heatmap-matrix', matrix: 'heatmap-matrix', } function asRecord(v: unknown): Record | null { return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : null } function stripForbiddenColorsDeep(value: unknown): unknown { if (typeof value === 'string') { return FORBIDDEN_COLOR_RE.test(value) ? undefined : value } if (Array.isArray(value)) { return value.map(stripForbiddenColorsDeep).filter((x) => x !== undefined) } if (value && typeof value === 'object') { const out: Record = {} for (const [k, v] of Object.entries(value)) { // Keep human strings even if they mention a color in prose if ( k === 'speak' || k === 'title' || k === 'text' || k === 'disclaimer' || k === 'label' || k === 'rowLabels' || k === 'colLabels' || k === 'lang' ) { out[k] = v continue } const cleaned = stripForbiddenColorsDeep(v) if (cleaned !== undefined) out[k] = cleaned } return out } return value } function normalizeIntent(v: unknown): string | undefined { if (typeof v !== 'string') return undefined if (INTENT_SET.has(v)) return v return 'highlight' } function normalizePattern(v: unknown): string | undefined { if (typeof v !== 'string') return undefined if (PATTERN_SET.has(v)) return v const camel = v.replace(/[-_\s]+(.)/g, (_, c: string) => c.toUpperCase()) if (PATTERN_SET.has(camel)) return camel return 'spotlightTour' } function normalizeScope(v: unknown): string { if (typeof v === 'string' && SCOPE_SET.has(v)) return v return 'act' } function normalizePanel(panel: unknown, index: number): Record | null { const p = asRecord(panel) if (!p) return null const rawType = String(p.type ?? '') const type = PANEL_TYPE_ALIASES[rawType] || PANEL_TYPE_ALIASES[rawType.toLowerCase()] if (!type) return null const id = typeof p.id === 'string' && p.id.trim() ? p.id.trim() : `panel.${index + 1}` let payload = asRecord(p.payload) ?? {} if (type === 'svg-scene') { let nodes = Array.isArray(payload.nodes) ? payload.nodes : [] if (nodes.length === 0 && Array.isArray(p.nodes)) nodes = p.nodes nodes = nodes .map((n, ni) => { const node = asRecord(n) if (!node) return null const nid = typeof node.id === 'string' && node.id.trim() ? node.id.trim() : `n${ni + 1}` return { id: nid, ...(typeof node.label === 'string' ? { label: node.label } : {}), ...(normalizeIntent(node.intent) ? { intent: normalizeIntent(node.intent) } : {}), } }) .filter(Boolean) if (nodes.length === 0) { nodes = [{ id: 'n1', label: 'Concept', intent: 'highlight' }] } const nodeIds = new Set( (nodes as { id: string }[]).map((n) => n.id) ) let edges = Array.isArray(payload.edges) ? payload.edges : [] edges = edges .map((e, ei) => { const edge = asRecord(e) if (!edge) return null const from = String(edge.from ?? '') const to = String(edge.to ?? '') if (!nodeIds.has(from) || !nodeIds.has(to)) return null return { id: typeof edge.id === 'string' && edge.id.trim() ? edge.id.trim() : `e${ei + 1}`, from, to, ...(edge.style === 'dashed' || edge.style === 'solid' ? { style: edge.style } : {}), ...(typeof edge.weight === 'number' ? { weight: edge.weight } : {}), ...(normalizeIntent(edge.intent) ? { intent: normalizeIntent(edge.intent) } : {}), } }) .filter(Boolean) payload = { nodes, ...(edges.length ? { edges } : {}) } } else if (type === 'chart') { let series = Array.isArray(payload.series) ? payload.series : [] series = series .map((s, si) => { const ser = asRecord(s) if (!ser) return null const values = Array.isArray(ser.values) ? ser.values.map((n) => Number(n)).filter((n) => Number.isFinite(n)) : [] if (values.length === 0) return null return { id: typeof ser.id === 'string' && ser.id.trim() ? ser.id.trim() : `s${si + 1}`, ...(typeof ser.label === 'string' ? { label: ser.label } : {}), values, ...(normalizeIntent(ser.intent) ? { intent: normalizeIntent(ser.intent) } : {}), } }) .filter(Boolean) if (series.length === 0) { series = [{ id: 's1', values: [1, 2, 3], intent: 'highlight' }] } const chartType = typeof payload.chartType === 'string' && CHART_TYPE_SET.has(payload.chartType) ? payload.chartType : 'bar' payload = { chartType, series } } else if (type === 'heatmap-matrix') { let rows = Number(payload.rows) let cols = Number(payload.cols) let values = Array.isArray(payload.values) ? payload.values : [] if (!Number.isFinite(rows) || rows < 1) rows = values.length || 3 if (!Number.isFinite(cols) || cols < 1) { cols = Array.isArray(values[0]) ? (values[0] as unknown[]).length : 3 } // Pad / trim to declared shape const matrix: number[][] = [] for (let r = 0; r < rows; r++) { const row = Array.isArray(values[r]) ? (values[r] as unknown[]) : [] const outRow: number[] = [] for (let c = 0; c < cols; c++) { const n = Number(row[c]) outRow.push(Number.isFinite(n) ? n : 0) } matrix.push(outRow) } const triangular = payload.triangular === 'lower' || payload.triangular === 'upper' || payload.triangular === 'none' ? payload.triangular : undefined payload = { rows, cols, values: matrix, ...(triangular ? { triangular } : {}), ...(Array.isArray(payload.rowLabels) ? { rowLabels: payload.rowLabels.map(String).slice(0, rows) } : {}), ...(Array.isArray(payload.colLabels) ? { colLabels: payload.colLabels.map(String).slice(0, cols) } : {}), } } return { id, type, payload } } function normalizeScene(scene: unknown): Record { const s = asRecord(scene) ?? {} const rawPanels = Array.isArray(s.panels) ? s.panels : [] const panels = rawPanels .map((p, i) => normalizePanel(p, i)) .filter(Boolean) .slice(0, 2) as Record[] if (panels.length === 0) { panels.push({ id: 'panel.main', type: 'svg-scene', payload: { nodes: [ { id: 'concept', label: 'Idée', intent: 'highlight' }, { id: 'detail', label: 'Détail', intent: 'compute' }, ], edges: [ { id: 'e1', from: 'concept', to: 'detail', style: 'solid', intent: 'flow', }, ], }, }) } return { ...(typeof s.id === 'string' && s.id.trim() ? { id: s.id.trim() } : {}), panels, } } function normalizeStep( step: unknown, actId: string, stepIndex: number ): Record | null { const st = asRecord(step) if (!st) return null const speak = typeof st.speak === 'string' && st.speak.trim() ? st.speak.trim() : 'Regardons cet élément.' const id = `${actId}.s${stepIndex + 1}` const pointTo = Array.isArray(st.pointTo) ? st.pointTo.map(String).filter(Boolean) : undefined const reveal = Array.isArray(st.reveal) ? st.reveal .map((r) => { const rev = asRecord(r) if (!rev || !Array.isArray(rev.ids) || rev.ids.length === 0) return null return { ids: rev.ids.map(String).filter(Boolean), scope: normalizeScope(rev.scope), } }) .filter(Boolean) : undefined let annotate = Array.isArray(st.annotate) ? st.annotate .map((a) => { const ann = asRecord(a) if (!ann || !Array.isArray(ann.targetIds) || ann.targetIds.length === 0) return null const kind = typeof ann.kind === 'string' && ANN_KIND_SET.has(ann.kind) ? ann.kind : 'callout' return { kind, targetIds: ann.targetIds.map(String).filter(Boolean), scope: normalizeScope(ann.scope), ...(typeof ann.text === 'string' ? { text: ann.text } : {}), ...(normalizeIntent(ann.intent) ? { intent: normalizeIntent(ann.intent) } : {}), } }) .filter(Boolean) : undefined // Drop annotate targets that also appear in pointTo (semantic hard reject) if (annotate && pointTo?.length) { const pt = new Set(pointTo) const filtered = annotate .map((a) => { if (!a) return null const ann = a as { targetIds: string[] kind: string scope: string text?: string intent?: string } const targetIds = ann.targetIds.filter((id) => !pt.has(id)) if (targetIds.length === 0) return null return { ...ann, targetIds } }) .filter(Boolean) as NonNullable<(typeof annotate)[number]>[] annotate = filtered.length ? filtered : undefined } return { id, speak, ...(st.pattern !== undefined ? { pattern: normalizePattern(st.pattern) } : {}), ...(pointTo?.length ? { pointTo } : {}), ...(reveal?.length ? { reveal } : {}), ...(annotate?.length ? { annotate } : {}), } } function collectElementIdsFromScene(scene: Record): { ids: Set wildcardAllowed: boolean firstId?: string } { const ids = new Set() let wildcardAllowed = false const panels = Array.isArray(scene.panels) ? scene.panels : [] for (const panel of panels) { const p = asRecord(panel) if (!p) continue const payload = asRecord(p.payload) ?? {} if (p.type === 'svg-scene') { for (const n of Array.isArray(payload.nodes) ? payload.nodes : []) { const node = asRecord(n) if (node && typeof node.id === 'string') ids.add(node.id) } for (const e of Array.isArray(payload.edges) ? payload.edges : []) { const edge = asRecord(e) if (edge && typeof edge.id === 'string') ids.add(edge.id) } } else if (p.type === 'chart') { wildcardAllowed = true for (const s of Array.isArray(payload.series) ? payload.series : []) { const ser = asRecord(s) if (ser && typeof ser.id === 'string') ids.add(ser.id) } } else if (p.type === 'heatmap-matrix') { wildcardAllowed = true const rows = Number(payload.rows) || 0 const cols = Number(payload.cols) || 0 const triangular = payload.triangular for (let r = 1; r <= rows; r++) { for (let c = 1; c <= cols; c++) { if (triangular === 'lower' && c > r) continue if (triangular === 'upper' && c < r) continue ids.add(`r${r}.c${c}`) } } } } return { ids, wildcardAllowed, firstId: ids.values().next().value } } function filterStepRefs( step: Record, elementIds: Set, wildcardAllowed: boolean ): Record { const filterId = (id: string) => id === '*' ? wildcardAllowed : elementIds.has(id) if (Array.isArray(step.pointTo)) { const pointTo = (step.pointTo as string[]).filter((id) => elementIds.has(id)) if (pointTo.length) step.pointTo = pointTo else delete step.pointTo } if (Array.isArray(step.reveal)) { const reveal = (step.reveal as { ids: string[]; scope: string }[]) .map((r) => ({ ...r, ids: r.ids.filter(filterId), })) .filter((r) => r.ids.length > 0) if (reveal.length) step.reveal = reveal else delete step.reveal } if (Array.isArray(step.annotate)) { const annotate = ( step.annotate as { targetIds: string[]; [k: string]: unknown }[] ) .map((a) => ({ ...a, targetIds: a.targetIds.filter((id) => elementIds.has(id)), })) .filter((a) => a.targetIds.length > 0) if (annotate.length) step.annotate = annotate else delete step.annotate } return step } function normalizeAct( act: unknown, actIndex: number, defaultScene: Record ): Record | null { const a = asRecord(act) if (!a) return null const actId = `a${actIndex + 1}` const title = typeof a.title === 'string' && a.title.trim() ? a.title.trim() : `Acte ${actIndex + 1}` const scene = a.scene ? normalizeScene(a.scene) : undefined const activeScene = scene ?? defaultScene const { ids, wildcardAllowed, firstId } = collectElementIdsFromScene(activeScene) const rawSteps = Array.isArray(a.steps) ? a.steps : [] let steps = rawSteps .map((s, si) => normalizeStep(s, actId, si)) .filter(Boolean) .slice(0, 12) as Record[] steps = steps.map((s) => filterStepRefs(s, ids, wildcardAllowed)) if (steps.length === 0) { steps.push({ id: `${actId}.s1`, speak: 'Voici le point clé.', pattern: 'overview', ...(firstId ? { pointTo: [firstId] } : {}), }) } return { id: actId, title, ...(a.pattern !== undefined ? { pattern: normalizePattern(a.pattern) } : {}), ...(typeof a.transition === 'string' && TRANSITION_SET.has(a.transition) ? { transition: a.transition } : {}), ...(scene ? { scene } : {}), steps, } } /** * Best-effort shape fix so Zod + semantic validate can succeed on near-valid LLM output. */ export function normalizeInteractiveDemoCandidate( input: unknown, lang = 'fr' ): unknown { const root = asRecord(input) if (!root) return input let demo = stripForbiddenColorsDeep(root) as Record demo = asRecord(demo) ?? root const safeLang = typeof lang === 'string' && /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(lang) ? lang : 'fr' demo.schemaVersion = 1 demo.lang = typeof demo.lang === 'string' && /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(demo.lang) ? demo.lang : safeLang if (typeof demo.id !== 'string' || !demo.id.trim()) { demo.id = 'demo.generated' } if (typeof demo.disclaimer !== 'string') { delete demo.disclaimer } const scene = normalizeScene(demo.scene) demo.scene = scene const { firstId } = collectElementIdsFromScene(scene) const rawActs = Array.isArray(demo.acts) ? demo.acts : [] let acts = rawActs .map((a, i) => normalizeAct(a, i, scene)) .filter(Boolean) .slice(0, 8) as Record[] if (acts.length === 0) { acts = [ { id: 'a1', title: 'Introduction', pattern: 'spotlightTour', steps: [ { id: 'a1.s1', speak: 'Voici le point clé.', pattern: 'overview', ...(firstId ? { pointTo: [firstId] } : {}), }, ], }, ] } demo.acts = acts return demo }