Files
Antigravity 69c99e4f4f feat: page interactive, démos Play/Step et simulateur Carnot
Ajoute le pipeline PageSpec (validation, rendu, publication /p/{slug}),
les démos TipTap /demo, et le simulateur Carnot (modes frigo/PAC/moteur,
énergie kJ vs puissance W, unités K/°C/°F) avec correctifs d’équations KaTeX.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 17:51:43 +00:00

562 lines
16 KiB
TypeScript

/**
* 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<string>(INTENT_IDS)
const PATTERN_SET = new Set<string>(PATTERN_IDS)
const SCOPE_SET = new Set<string>(SCOPE_IDS)
const ANN_KIND_SET = new Set<string>(ANNOTATION_KINDS)
const CHART_TYPE_SET = new Set<string>(CHART_TYPES)
const TRANSITION_SET = new Set<string>(TRANSITIONS)
const PANEL_TYPE_ALIASES: Record<string, string> = {
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<string, unknown> | null {
return v && typeof v === 'object' && !Array.isArray(v)
? (v as Record<string, unknown>)
: 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<string, unknown> = {}
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<string, unknown> | 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<string, unknown> {
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<string, unknown>[]
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<string, unknown> | 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<string, unknown>): {
ids: Set<string>
wildcardAllowed: boolean
firstId?: string
} {
const ids = new Set<string>()
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<string, unknown>,
elementIds: Set<string>,
wildcardAllowed: boolean
): Record<string, unknown> {
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<string, unknown>
): Record<string, unknown> | 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<string, unknown>[]
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<string, unknown>
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<string, unknown>[]
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
}