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>
This commit is contained in:
561
memento-note/lib/interactive-demo/normalize.ts
Normal file
561
memento-note/lib/interactive-demo/normalize.ts
Normal file
@@ -0,0 +1,561 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
Reference in New Issue
Block a user