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>
562 lines
16 KiB
TypeScript
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
|
|
}
|