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:
Antigravity
2026-07-24 17:51:43 +00:00
parent f385d43d5d
commit 69c99e4f4f
67 changed files with 12005 additions and 33 deletions

View File

@@ -0,0 +1,74 @@
import type { InteractiveDemoV1, ValidationIssue } from '@/lib/interactive-demo'
export type GenerateInteractiveDemoResponse =
| { ok: true; demo: InteractiveDemoV1; attempts: number }
| {
ok: false
error: string
issues?: ValidationIssue[]
quotaExceeded?: boolean
status?: number
}
export async function generateInteractiveDemo(params: {
content: string
selection?: string | null
lang?: string
noteId?: string
}): Promise<GenerateInteractiveDemoResponse> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 170_000)
let res: Response
try {
res = await fetch('/api/ai/interactive-demo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: params.content,
selection: params.selection,
lang: params.lang,
noteId: params.noteId,
}),
signal: controller.signal,
})
} catch (err) {
clearTimeout(timeout)
const aborted = err instanceof Error && err.name === 'AbortError'
return {
ok: false,
error: aborted
? 'Génération trop longue — réessaie (ou raccourcis la note)'
: err instanceof Error
? err.message
: 'Network error',
}
} finally {
clearTimeout(timeout)
}
const data = await res.json().catch(() => ({}))
if (res.status === 402) {
return {
ok: false,
error: data.error || 'Quota exceeded',
quotaExceeded: true,
status: 402,
}
}
if (!res.ok) {
return {
ok: false,
error: data.error || `HTTP ${res.status}`,
issues: data.issues,
status: res.status,
}
}
return {
ok: true,
demo: data.demo,
attempts: data.attempts ?? 1,
}
}

View File

@@ -0,0 +1,291 @@
import { generateText } from 'ai'
import type { AIProvider } from '@/lib/ai/types'
import { cleanAIJsonResponse } from '@/lib/ai/utils/clean-ai-response'
import { extractSourceAssets } from '@/lib/ai/services/slide-source-assets'
import {
INTERACTIVE_DEMO_SCHEMA_VERSION,
PATTERN_IDS,
PANEL_TYPES,
INTENT_IDS,
ANNOTATION_KINDS,
SPEAK_WRITING_RULE,
validateInteractiveDemo,
normalizeInteractiveDemoCandidate,
type InteractiveDemoV1,
type ValidationIssue,
} from '@/lib/interactive-demo'
const MAX_ATTEMPTS = 2
function stripToPlain(html: string): string {
return html
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim()
}
function extractJsonObject(raw: string): unknown | null {
if (!raw) return null
const cleaned = cleanAIJsonResponse(raw)
const tryParse = (s: string): unknown | null => {
try {
return JSON.parse(s)
} catch {
return null
}
}
const stripTrailingCommas = (s: string) => s.replace(/,\s*([}\]])/g, '$1')
let parsed = tryParse(cleaned) ?? tryParse(stripTrailingCommas(cleaned))
if (parsed) return parsed
const fence = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/i)
const candidate = fence?.[1]?.trim() ?? cleaned
parsed = tryParse(candidate) ?? tryParse(stripTrailingCommas(candidate))
if (parsed) return parsed
const start = candidate.indexOf('{')
const end = candidate.lastIndexOf('}')
if (start >= 0 && end > start) {
const slice = candidate.slice(start, end + 1)
parsed = tryParse(slice) ?? tryParse(stripTrailingCommas(slice))
if (parsed) return parsed
}
return null
}
/** STEM cycle example — equations in speak + node labels via $...$ */
const STEM_CYCLE_EXAMPLE = `{
"schemaVersion": 1,
"id": "demo.refrigeration-cycle",
"lang": "fr",
"disclaimer": "Schéma pédagogique — grandeurs illustratives.",
"scene": {
"id": "scene.cycle",
"panels": [{
"id": "panel.cycle",
"type": "svg-scene",
"payload": {
"nodes": [
{ "id": "compressor", "label": "1 · Compresseur\\n$W = h_2 - h_1$", "intent": "compute" },
{ "id": "condenser", "label": "2 · Condenseur\\n$Q_c = h_2 - h_3$", "intent": "output" },
{ "id": "expansion", "label": "3 · Détente\\n$h_3 = h_4$", "intent": "flow" },
{ "id": "evaporator", "label": "4 · Évaporateur\\n$Q_e = h_1 - h_4$", "intent": "cache" }
],
"edges": [
{ "id": "e12", "from": "compressor", "to": "condenser", "style": "solid", "intent": "flow" },
{ "id": "e23", "from": "condenser", "to": "expansion", "style": "solid", "intent": "flow" },
{ "id": "e34", "from": "expansion", "to": "evaporator", "style": "solid", "intent": "flow" },
{ "id": "e41", "from": "evaporator", "to": "compressor", "style": "solid", "intent": "flow" }
]
}
}]
},
"acts": [{
"id": "a1",
"title": "Cycle frigorifique",
"pattern": "flowTrace",
"steps": [
{ "id": "a1.s1", "speak": "Compression : le travail fourni est $W = h_2 - h_1$.", "pattern": "spotlightTour", "pointTo": ["compressor"], "reveal": [{"ids":["compressor"],"scope":"act"}] },
{ "id": "a1.s2", "speak": "Au condenseur, la chaleur rejetée : $Q_c = h_2 - h_3$.", "pattern": "spotlightTour", "pointTo": ["condenser"], "reveal": [{"ids":["condenser","e12"],"scope":"act"}] },
{ "id": "a1.s3", "speak": "Détente isenthalpique : $h_3 = h_4$.", "pattern": "spotlightTour", "pointTo": ["expansion"], "reveal": [{"ids":["expansion","e23"],"scope":"act"}] },
{ "id": "a1.s4", "speak": "Évaporateur : $Q_e = h_1 - h_4$. COP $= Q_e / W$.", "pattern": "overview", "pointTo": ["evaporator"], "reveal": [{"ids":["evaporator","e34","e41"],"scope":"act"}] }
]
}]
}`
function buildSystemPrompt(lang: string, hasMath: boolean): string {
return `You generate Interactive Demo JSON for Memento notes (schemaVersion ${INTERACTIVE_DEMO_SCHEMA_VERSION}).
OUTPUT: a single JSON object only. No markdown fences. No commentary. No <think> blocks.
MISSION: teach the ACTUAL content of the note — concepts, quantities, AND equations.
You are NOT allowed to invent a toy 2-node graph ("A" → "B") that ignores the note.
CONTENT FIDELITY (critical):
- Read EVERY extracted formula. Put the important ones in speak as inline KaTeX: $...$
- Put key equations on the matching node labels too (use \\n then $latex$).
- Use the note's real names (Compresseur, Condenseur, COP, …) — never vague placeholders.
- For a physical CYCLE / loop (frigo, Carnot, Rankine, feedback…): 4+ stage nodes + cycle edges closing the loop.
- For a derivation: nodes = successive expressions / steps, speak cites the formula at each step.
- Min 3 nodes for svg-scene (4+ for cycles). Min 3 steps. Prefer 46 steps.
- NEVER output only 2 boxes with plain words and zero equations when the note has math.
PANEL CHOICE:
- Process / cycle / architecture → svg-scene (rich graph)
- Time series / comparison of measured values → chart
- Matrix / attention / correlation → heatmap-matrix
- Max 2 panels. May combine svg-scene + chart if useful.
SCHEMA:
{
"schemaVersion": 1,
"id": "demo.<slug>",
"lang": "${lang}",
"disclaimer?": string,
"scene": { "id?": string, "panels": [Panel] },
"acts": [ { "id": "a1", "title": string, "pattern?", "steps": [Step] } ]
}
Step: { "id": "a1.s1", "speak": string, "pattern?", "pointTo?", "reveal?":[{"ids":[],"scope":"transient|act|scene"}], "annotate?":[{"kind","targetIds","scope","text?","intent?"}] }
Panel types: ${PANEL_TYPES.join(', ')}
Patterns: ${PATTERN_IDS.join(', ')}
Intents: ${INTENT_IDS.join(', ')}
Annotation kinds: ${ANNOTATION_KINDS.join(', ')}
chartType: line | bar | area
Heatmap cells: r{row}.c{col}; triangular:"lower" when appropriate.
svg-scene: nodes[{id,label?,intent?}], edges[{id,from,to,style?,weight?,intent?}]
chart: series[{id,label?,values:number[],intent?}]
${hasMath ? `STEM MODE ON: formulas were extracted from the note. You MUST:
- Include at least 2 distinct equations from FORMULES_EXTRAITES in speak and/or node labels as $latex$
- Prefer flowTrace / spotlightTour over vague overview-only demos
- Example shape to imitate (adapt to THIS note's real formulas/stages):
${STEM_CYCLE_EXAMPLE}` : `If the note has little math, still build a faithful conceptual diagram (≥3 nodes) from the real vocabulary of the note.`}
RULES:
- ${SPEAK_WRITING_RULE}
- speak may include light markdown + inline $KaTeX$ (required for STEM).
- Never put hex/rgb colors — intents only.
- Never annotate an id that is also in pointTo on the same step.
- Step ids = a1.s1, a1.s2… matching act id.
- All pointTo/reveal/annotate ids must exist in the active scene.
- Wildcard "*" reveal ONLY for heatmap/chart panels — not svg-scene alone.
- Narration language = lang ("${lang}").
- disclaimer when values are pedagogical/illustrative.`
}
function buildUserPrompt(
content: string,
assets: ReturnType<typeof extractSourceAssets>,
opts?: {
issues?: ValidationIssue[]
previousJson?: string
}
): string {
const plain = stripToPlain(content).slice(0, 7000)
let msg = `Create an Interactive Demo that teaches THIS note faithfully (equations included).
EXCERPT_START
${plain}
EXCERPT_END
`
if (assets.formulas.length) {
msg += `\nFORMULES_EXTRAITES (OBLIGATOIRE — réutilise telles quelles en $...$ dans speak et labels):\n`
msg += assets.formulas
.slice(0, 16)
.map((f, i) => `${i + 1}. ${f}`)
.join('\n')
msg += '\n'
}
if (assets.keySentences.length) {
msg += `\nPHRASES_CLES:\n`
msg += assets.keySentences
.slice(0, 8)
.map((s) => `- ${s}`)
.join('\n')
msg += '\n'
}
if (assets.numbers.length) {
msg += `\nDONNEES_NUMERIQUES:\n${JSON.stringify(assets.numbers.slice(0, 8))}\n`
}
msg += `\nCONTRAINTES: ≥3 nœuds svg (cycle → 4+ et boucle fermée). speak avec $équations$ si FORMULES_EXTRAITES non vide. Interdit: schéma jouet à 2 boîtes sans maths.\n`
if (opts?.issues?.length) {
msg += `\nPREVIOUS_JSON_FAILED_VALIDATION. Fix ALL issues and return a corrected FULL JSON.\n`
msg += opts.issues
.slice(0, 12)
.map((i) => `- [${i.code}] ${i.path}: ${i.message}`)
.join('\n')
}
if (opts?.previousJson) {
msg += `\n\nPREVIOUS_JSON_START\n${opts.previousJson.slice(0, 12000)}\nPREVIOUS_JSON_END`
}
return msg
}
export type GenerateInteractiveDemoInput = {
content: string
lang?: string
provider: AIProvider
}
export type GenerateInteractiveDemoResult =
| { ok: true; demo: InteractiveDemoV1; attempts: number }
| { ok: false; issues: ValidationIssue[]; raw?: string; attempts: number }
/**
* LLM → JSON → normalize → validateInteractiveDemo, with repair passes.
* Harvests formulas like slide generation before prompting.
*/
export async function generateInteractiveDemoFromContent(
input: GenerateInteractiveDemoInput
): Promise<GenerateInteractiveDemoResult> {
const lang = input.lang || 'fr'
const assets = extractSourceAssets(input.content)
const system = buildSystemPrompt(lang, assets.hasMath || assets.formulas.length > 0)
const model = input.provider.getModel()
let lastIssues: ValidationIssue[] = []
let lastRaw = ''
let lastNormalizedJson = ''
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const user = buildUserPrompt(input.content, assets, {
issues: attempt > 1 ? lastIssues : undefined,
previousJson: attempt > 1 ? lastNormalizedJson || lastRaw : undefined,
})
const { text: raw } = await generateText({
model,
system,
prompt: user,
temperature: attempt === 1 ? 0.25 : 0.1,
})
lastRaw = raw
const parsed = extractJsonObject(raw)
if (!parsed) {
lastIssues = [
{
code: 'invalid_json',
path: '',
message: 'Model did not return parseable JSON',
},
]
console.warn(
`[interactive-demo] attempt ${attempt}: unparseable JSON`,
raw.slice(0, 400)
)
continue
}
const normalized = normalizeInteractiveDemoCandidate(parsed, lang)
lastNormalizedJson = JSON.stringify(normalized)
const result = validateInteractiveDemo(normalized)
if (result.ok) {
return { ok: true, demo: result.demo, attempts: attempt }
}
lastIssues = result.issues
console.warn(
`[interactive-demo] attempt ${attempt}: validation failed`,
result.issues.slice(0, 8)
)
}
return {
ok: false,
issues: lastIssues,
raw: lastRaw,
attempts: MAX_ATTEMPTS,
}
}

View File

@@ -0,0 +1,211 @@
import type {
PageSection,
PageSpecV1,
PageValidationIssue,
} from '@/lib/interactive-page'
export type PagePlanDemoKind = 'svg-scene' | 'chart' | 'heatmap-matrix' | 'simulation' | 'none'
export type PagePlanSection = {
title: string
goal: string
demoKind: PagePlanDemoKind
demoGoal?: string
}
export type PagePlan = {
heroTitle: string
heroSubtitle?: string
overviewLead: string
overviewCards: {
badge: string
title: string
body: string
intent?: string
}[]
sections: PagePlanSection[]
}
export type GenerateInteractivePageResponse =
| { ok: true; page: PageSpecV1; attempts: number }
| {
ok: false
error: string
reason?: string
issues?: PageValidationIssue[]
quotaExceeded?: boolean
status?: number
}
export type GeneratePlanResponse =
| { ok: true; plan: PagePlan; attempts: number }
| {
ok: false
error: string
reason?: string
quotaExceeded?: boolean
status?: number
}
export type GenerateSectionResponse =
| { ok: true; section: PageSection; attempts: number }
| { ok: false; error: string; status?: number }
async function postJson(
body: Record<string, unknown>,
timeoutMs = 55_000
): Promise<{ res: Response; data: Record<string, never> } | { abortError: true }> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), timeoutMs)
try {
const res = await fetch('/api/ai/interactive-page', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal,
})
const data = await res.json().catch(() => ({}))
return { res, data }
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
return { abortError: true }
}
throw err
} finally {
clearTimeout(timeout)
}
}
/** LLM plan (billed — 20 crédits). */
export async function generateInteractivePagePlan(params: {
content: string
lang?: string
noteId?: string
}): Promise<GeneratePlanResponse> {
let out: Awaited<ReturnType<typeof postJson>>
try {
out = await postJson({ ...params, action: 'plan' })
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : 'Network error',
}
}
if ('abortError' in out) {
return { ok: false, error: 'Génération trop longue — réessaie' }
}
const { res, data } = out as {
res: Response
data: any
}
if (res.status === 402) {
return {
ok: false,
error: data.error || 'Quota exceeded',
quotaExceeded: true,
status: 402,
}
}
if (!res.ok) {
return {
ok: false,
error: data.error || `HTTP ${res.status}`,
reason: data.reason,
status: res.status,
}
}
return { ok: true, plan: data.plan, attempts: data.attempts ?? 1 }
}
/** LLM single section (not billed — page billed at plan time). */
export async function generateInteractivePageSection(params: {
content: string
lang?: string
noteId?: string
pageTitle: string
sectionId: string
section: PagePlanSection
}): Promise<GenerateSectionResponse> {
let out: Awaited<ReturnType<typeof postJson>>
try {
out = await postJson({ ...params, action: 'section' })
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : 'Network error',
}
}
if ('abortError' in out) {
return { ok: false, error: 'timeout' }
}
const { res, data } = out as {
res: Response
data: any
}
if (!res.ok) {
return { ok: false, error: data.error || `HTTP ${res.status}`, status: res.status }
}
return { ok: true, section: data.section, attempts: data.attempts ?? 1 }
}
/** Legacy deterministic full page (fallback, no quota). */
export async function generateInteractivePage(params: {
content: string
lang?: string
noteId?: string
notebookId?: string
}): Promise<GenerateInteractivePageResponse> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 75_000)
let res: Response
try {
res = await fetch('/api/ai/interactive-page', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
signal: controller.signal,
})
} catch (err) {
clearTimeout(timeout)
const aborted = err instanceof Error && err.name === 'AbortError'
return {
ok: false,
error: aborted
? 'Génération trop longue — réessaie'
: err instanceof Error
? err.message
: 'Network error',
}
} finally {
clearTimeout(timeout)
}
const data = await res.json().catch(() => ({}))
if (res.status === 402) {
return {
ok: false,
error: data.error || 'Quota exceeded',
quotaExceeded: true,
status: 402,
}
}
if (!res.ok) {
return {
ok: false,
error: data.error || `HTTP ${res.status}`,
reason: data.reason,
issues: data.issues,
status: res.status,
}
}
return {
ok: true,
page: data.page,
attempts: data.attempts ?? 1,
}
}

View File

@@ -0,0 +1,398 @@
import type { AIProvider } from '@/lib/ai/types'
import { extractSourceAssets } from '@/lib/ai/services/slide-source-assets'
import {
validateInteractiveDemo,
type InteractiveDemoV1,
} from '@/lib/interactive-demo'
import {
validateInteractivePage,
type PageSpecV1,
type PageValidationIssue,
} from '@/lib/interactive-page'
import { normalizeInteractivePageCandidate } from '@/lib/interactive-page/normalize'
function stripToPlain(html: string): string {
return html
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim()
}
function slugId(title: 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}` : 'page.generated'
}
function firstSentence(text: string, max = 100): string {
const s = text.split(/[.!?。]/)[0]?.trim() || text.trim()
return s.slice(0, max) || 'Page interactive'
}
function chunkSentences(text: string): string[] {
return text
.split(/(?<=[.!?。])\s+/)
.map((s) => s.trim())
.filter((s) => s.length > 20)
}
const INTENT_CYCLE = ['compute', 'flow', 'output', 'cache'] as const
/**
* Instant Play/Step demo from note vocabulary — no LLM.
* 34 nodes + spotlight steps; formulas in speak when available.
*/
export function buildDeterministicDemo(
content: string,
lang: string,
assets: ReturnType<typeof extractSourceAssets>
): InteractiveDemoV1 | null {
const fr = lang.startsWith('fr')
const plain = stripToPlain(content)
const sentences = [
...assets.keySentences,
...chunkSentences(plain),
].filter((s, i, a) => a.indexOf(s) === i)
// Prefer short phrase labels from key sentences — never raw formula fragments
const labels: string[] = []
for (const s of sentences.slice(0, 8)) {
const words = s
.replace(/\$[^$]*\$/g, ' ')
.replace(/[\\{}]/g, ' ')
.split(/\s+/)
.filter(Boolean)
.slice(0, 4)
.join(' ')
.trim()
if (words.length >= 6 && words.length <= 40 && !labels.includes(words)) {
labels.push(words)
}
if (labels.length >= 4) break
}
// Clean formulas usable inside $…$ (KaTeX eats spaces → no prose, bounded)
const cleanFormulas = assets.formulas.filter((f) => f.length <= 80)
if (labels.length < 3) {
const fallbacks = fr
? ['Entrée', 'Transformation', 'Résultat', 'Retour']
: ['Input', 'Transform', 'Output', 'Loop']
for (const fb of fallbacks) {
if (labels.length >= 4) break
if (!labels.includes(fb)) labels.push(fb)
}
}
while (labels.length < 3) labels.push(`Étape ${labels.length + 1}`)
const nodeCount = Math.min(4, Math.max(3, labels.length))
const nodes = labels.slice(0, nodeCount).map((label, i) => ({
id: `n${i + 1}`,
label:
cleanFormulas[i] && i < 2
? `${i + 1} · ${label.split('\n')[0]}\n$${cleanFormulas[i]}$`
: `${i + 1} · ${label}`,
intent: INTENT_CYCLE[i % INTENT_CYCLE.length],
}))
const edges = nodes.map((n, i) => {
const next = nodes[(i + 1) % nodes.length]
return {
id: `e${i + 1}`,
from: n.id,
to: next.id,
style: 'solid' as const,
intent: 'flow' as const,
}
})
const steps = nodes.map((n, i) => {
const formula = cleanFormulas[i]
const speakBase =
sentences[i]?.slice(0, 100) ||
(fr ? `Étape **${i + 1}** du mécanisme.` : `Step **${i + 1}** of the mechanism.`)
const speak = formula
? `${speakBase.split('.')[0]}. $${formula}$`
: speakBase
const revealed = nodes.slice(0, i + 1).map((x) => x.id)
if (i > 0) revealed.push(edges[i - 1].id)
const isLast = i === nodes.length - 1
return {
id: `a1.s${i + 1}`,
speak: speak.slice(0, 160),
pattern: isLast ? ('overview' as const) : ('spotlightTour' as const),
pointTo: [n.id],
reveal: [{ ids: isLast ? nodes.map((x) => x.id).concat(edges.map((e) => e.id)) : revealed, scope: 'act' as const }],
}
})
const raw = {
schemaVersion: 1 as const,
id: 'demo.page-auto',
lang,
disclaimer: fr
? 'Schéma pédagogique généré depuis la note — valeurs illustratives.'
: 'Pedagogical diagram from your note — illustrative values.',
scene: {
id: 'scene.main',
panels: [
{
id: 'panel.main',
type: 'svg-scene' as const,
payload: { nodes, edges },
},
],
},
acts: [
{
id: 'a1',
title: fr ? 'Parcours' : 'Walkthrough',
pattern: 'flowTrace' as const,
steps,
},
],
}
const validated = validateInteractiveDemo(raw)
if (!validated.ok) {
console.warn(
'[interactive-page] deterministic demo invalid',
validated.issues.slice(0, 5)
)
return null
}
return validated.demo
}
export function buildPageFromNote(
content: string,
lang: string,
assets: ReturnType<typeof extractSourceAssets>
): Record<string, unknown> {
const plain = stripToPlain(content)
const sentences = [
...assets.keySentences,
...chunkSentences(plain),
].filter((s, i, arr) => arr.indexOf(s) === i)
const title = firstSentence(sentences[0] || plain, 90)
const lead = sentences[0]?.slice(0, 320) || plain.slice(0, 320) || title
const fr = lang.startsWith('fr')
// Displayable formulas only (KaTeX eats spaces — no prose, bounded length)
const displayFormulas = assets.formulas.filter((f) => f.length <= 120)
const formula = displayFormulas[0]
const formula2 = displayFormulas[1]
const cards = [
{
badge: 'PROBLEM',
title: fr ? 'Contexte' : 'Context',
body: (sentences[0] || lead).slice(0, 160),
intent: 'warning' as const,
},
{
badge: 'APPROACH',
title: fr ? 'Approche' : 'Approach',
body: (sentences[1] || sentences[0] || lead).slice(0, 160),
intent: 'flow' as const,
},
{
badge: 'RESULT',
title: formula ? (fr ? 'Relation' : 'Relation') : fr ? 'Idée clé' : 'Key idea',
body: formula ? `$${formula}$` : (sentences[2] || lead).slice(0, 160),
intent: 'output' as const,
},
]
const sections: Record<string, unknown>[] = [
{
id: 's1',
title: fr ? 'Le problème' : 'The problem',
blocks: [
{ type: 'prose', md: sentences[0] || lead },
{
type: 'callout',
kind: 'definition',
title: fr ? 'En bref' : 'In short',
md: (sentences[1] || plain.slice(0, 180) || title).slice(0, 220),
},
],
},
{
id: 's2',
title: fr ? 'Mécanisme' : 'Mechanism',
blocks: [
{ type: 'prose', md: sentences[1] || sentences[0] || lead },
...(formula ? [{ type: 'formula', tex: formula }] : []),
...(formula2
? [
{
type: 'callout',
kind: 'tip',
title: fr ? 'Aussi' : 'Also',
md: `$${formula2}$`,
},
]
: []),
],
},
{
id: 's3',
title: fr ? 'Synthèse' : 'Synthesis',
blocks: [
{
type: 'prose',
md:
sentences[2] ||
(fr
? 'Retenez le mécanisme — la démo interactive en retrace le flux.'
: 'Keep the mechanism — the interactive demo traces the flow.'),
},
{
type: 'stats',
items: [
{
value: String(Math.max(assets.formulas.length, 1)),
label: fr ? 'Formules' : 'Formulas',
},
{
value: String(Math.min(Math.max(sentences.length, 3), 8)),
label: fr ? 'Idées' : 'Ideas',
},
assets.numbers[0]
? {
value: String(assets.numbers[0].value),
label: assets.numbers[0].label || (fr ? 'Donnée' : 'Figure'),
}
: { value: '→', label: fr ? 'Suite' : 'Next' },
],
},
],
},
]
return {
schemaVersion: 1,
id: slugId(title),
lang,
hero: {
kicker: fr ? 'EXPLAINER INTERACTIF' : 'INTERACTIVE EXPLAINER',
title,
subtitle: (sentences[1] || plain).slice(0, 140),
meta: fr ? 'Généré depuis votre note' : 'Generated from your note',
},
overview: { lead, cards },
sections,
}
}
function injectDemo(
page: Record<string, unknown>,
demo: unknown
): Record<string, unknown> {
const sections = Array.isArray(page.sections)
? ([...page.sections] as Record<string, unknown>[])
: []
if (!sections.length) return page
const targetIdx = Math.min(1, sections.length - 1)
const target = { ...sections[targetIdx] }
const blocks = Array.isArray(target.blocks)
? [...(target.blocks as Record<string, unknown>[])]
: []
blocks.push({ type: 'demo', demo, caption: 'Démo interactive' })
target.blocks = blocks
sections[targetIdx] = target
return { ...page, sections }
}
export type GenerateInteractivePageInput = {
content: string
lang?: string
/** Kept for API compatibility — page skeleton no longer depends on LLM. */
provider: AIProvider
}
export type GenerateInteractivePageResult =
| { ok: true; page: PageSpecV1; attempts: number }
| {
ok: false
issues?: PageValidationIssue[]
error?: string
reason?: string
raw?: string
attempts: number
}
/**
* Instant reliable page: deterministic skeleton + deterministic Play/Step demo.
* No LLM round-trip for the page itself (LLM demos were timing out past client abort).
*/
export async function generateInteractivePageFromContent(
input: GenerateInteractivePageInput
): Promise<GenerateInteractivePageResult> {
const lang = input.lang || 'fr'
const assets = extractSourceAssets(input.content)
const plain = stripToPlain(input.content)
if (plain.split(/\s+/).filter(Boolean).length < 30) {
return {
ok: false,
error: 'unsuitable_content',
reason: 'Contenu trop court pour une page interactive',
attempts: 0,
}
}
let pageObj = buildPageFromNote(input.content, lang, assets)
const demo = buildDeterministicDemo(input.content, lang, assets)
if (demo) {
pageObj = injectDemo(pageObj, demo)
}
const normalized = normalizeInteractivePageCandidate(pageObj, lang)
if (!normalized) {
return {
ok: false,
error: 'normalize_failed',
reason: 'Impossible de normaliser la page',
attempts: 1,
}
}
const result = validateInteractivePage(normalized)
if (!result.ok) {
// Last resort: page without demo
const sections = Array.isArray(normalized.sections)
? (normalized.sections as Record<string, unknown>[]).map((sec) => ({
...sec,
blocks: Array.isArray(sec.blocks)
? (sec.blocks as Record<string, unknown>[]).filter(
(b) => b.type !== 'demo'
)
: [],
}))
: []
const stripped = validateInteractivePage({ ...normalized, sections })
if (stripped.ok) {
return { ok: true, page: stripped.page, attempts: 1 }
}
return {
ok: false,
issues: result.issues,
error: 'validation_failed',
reason: result.issues[0]
? `${result.issues[0].path}: ${result.issues[0].message}`
: 'Page invalide',
attempts: 1,
}
}
return { ok: true, page: result.page, attempts: 1 }
}

View File

@@ -0,0 +1,547 @@
/**
* LLM generation for interactive pages (spec §7) — split into short calls:
* 1. generatePagePlan → hero + overview + section list (one fast call)
* 2. generatePageSection → blocks of ONE section, incl. a content-matched demo
*
* Splitting keeps every LLM round-trip well under the client abort (~75 s),
* unlike the original single-shot page generation that timed out (~150 s).
* Validation failures are fed back verbatim (max 2 attempts per call).
*/
import { generateText } from 'ai'
import { z } from 'zod'
import type { AIProvider } from '@/lib/ai/types'
import { cleanAIJsonResponse } from '@/lib/ai/utils/clean-ai-response'
import { extractSourceAssets } from '@/lib/ai/services/slide-source-assets'
import {
INTERACTIVE_DEMO_SCHEMA_VERSION,
PATTERN_IDS,
PANEL_TYPES,
INTENT_IDS,
SPEAK_WRITING_RULE,
} from '@/lib/interactive-demo'
import {
CALLOUT_KINDS,
INTERACTIVE_PAGE_CAPS,
validateInteractivePage,
normalizeInteractivePageCandidate,
type PageSection,
type PageValidationIssue,
} from '@/lib/interactive-page'
import { catalogForPrompt } from '@/lib/simulators'
import thermoFixture from '@/lib/interactive-page/fixtures/thermo-page.json'
const MAX_ATTEMPTS = 2
// ── Shared helpers ───────────────────────────────────────────────────────────
function stripToPlain(html: string): string {
return html
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim()
}
function extractJsonObject(raw: string): unknown | null {
if (!raw) return null
const cleaned = cleanAIJsonResponse(raw)
const tryParse = (s: string): unknown | null => {
try {
return JSON.parse(s)
} catch {
return null
}
}
const stripTrailingCommas = (s: string) => s.replace(/,\s*([}\]])/g, '$1')
let parsed = tryParse(cleaned) ?? tryParse(stripTrailingCommas(cleaned))
if (parsed) return parsed
const fence = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/i)
const candidate = fence?.[1]?.trim() ?? cleaned
parsed = tryParse(candidate) ?? tryParse(stripTrailingCommas(candidate))
if (parsed) return parsed
const start = candidate.indexOf('{')
const end = candidate.lastIndexOf('}')
if (start >= 0 && end > start) {
const slice = candidate.slice(start, end + 1)
parsed = tryParse(slice) ?? tryParse(stripTrailingCommas(slice))
if (parsed) return parsed
}
return null
}
function assetsBlock(assets: ReturnType<typeof extractSourceAssets>): string {
let msg = ''
if (assets.formulas.length) {
msg += `\nFORMULES_EXTRAITES (réutilise telles quelles en $...$ — KaTeX inline):\n`
msg += assets.formulas
.slice(0, 14)
.map((f, i) => `${i + 1}. ${f}`)
.join('\n')
msg += '\n'
}
if (assets.keySentences.length) {
msg += `\nPHRASES_CLES:\n`
msg += assets.keySentences
.slice(0, 8)
.map((s) => `- ${s}`)
.join('\n')
msg += '\n'
}
if (assets.numbers.length) {
msg += `\nDONNEES_NUMERIQUES (source des blocs "stats"/"table" — ne pas inventer d'autres chiffres):\n${JSON.stringify(assets.numbers.slice(0, 10))}\n`
}
return msg
}
// ── Golden demo examples (from the hand-crafted thermo fixture) ─────────────
type FixtureDemoBlock = { type: string; demo: unknown }
function fixtureDemo(panelType: string): string | null {
for (const section of thermoFixture.sections) {
for (const block of section.blocks as FixtureDemoBlock[]) {
if (block.type !== 'demo') continue
const demo = block.demo as {
scene?: { panels?: { type: string }[] }
}
const panels = demo?.scene?.panels ?? []
if (panels.some((p) => p.type === panelType)) {
return JSON.stringify(block.demo)
}
}
}
return null
}
// ── 1. Page plan ─────────────────────────────────────────────────────────────
const DEMO_KINDS = ['svg-scene', 'chart', 'heatmap-matrix', 'simulation', 'none'] as const
export type PagePlanDemoKind = (typeof DEMO_KINDS)[number]
const planSectionSchema = z.object({
title: z.string().min(1),
goal: z.string().min(1),
demoKind: z.enum(DEMO_KINDS),
demoGoal: z.string().optional(),
})
const pagePlanSchema = z.object({
heroTitle: z.string().min(1),
heroSubtitle: z.string().optional(),
overviewLead: z.string().min(1),
overviewCards: z
.array(
z.object({
badge: z.string().min(1),
title: z.string().min(1),
body: z.string().min(1),
intent: z.enum(INTENT_IDS).optional(),
})
)
.min(INTERACTIVE_PAGE_CAPS.minOverviewCards)
.max(INTERACTIVE_PAGE_CAPS.maxOverviewCards),
sections: z.array(planSectionSchema).min(2).max(5),
})
export type PagePlanSection = z.infer<typeof planSectionSchema>
export type PagePlan = z.infer<typeof pagePlanSchema>
export type GeneratePagePlanResult =
| { ok: true; plan: PagePlan; attempts: number }
| { ok: false; error: string; reason?: string; attempts: number }
function buildPlanSystemPrompt(lang: string): string {
return `You plan an interactive pedagogical page (PageSpecV1) for a Memento note, in the style of the Kimi "Attention Residuals" explainer.
You output ONLY the PLAN as one JSON object — sections content is generated later, one call per section.
OUTPUT: a single JSON object only. No markdown fences. No commentary. No <think> blocks.
SCHEMA:
{
"heroTitle": string, // the SUBJECT of the note, never a generic title
"heroSubtitle": string, // one sentence, autoportant
"overviewLead": string, // the central idea in ONE self-contained paragraph (may use $KaTeX$ inline)
"overviewCards": [ { "badge": string, "title": string, "body": string, "intent?": ${JSON.stringify(INTENT_IDS)} } ], // 24 key concepts, small-caps badges (ex. PROBLEM / APPROACH / RESULT)
"sections": [ { "title": string, "goal": string, "demoKind": ${JSON.stringify(DEMO_KINDS)}, "demoGoal?": string } ] // 25
}
COUVERTURE (règle n°1):
- The page covers the CORE of the source: its 25 major ideas, one section each.
- NEVER build the page on the note's simplest example.
- First section = the problem / context; last section = synthesis / results.
- If the content does not benefit from an interactive page, REFUSE: return { "error": "unsuitable_content", "reason": "…" } instead.
MATCHING CONTENU → DÉMO (choose demoKind per section, "none" when no visual helps):
- Catégories × propriétés (comparatif, matrice, échanges) → "heatmap-matrix"
- Loi / relation / courbe / évolution chiffrée → "chart"
- Processus / flux / cycle / architecture → "svg-scene"
- Avant/après, comparaison d'états → "svg-scene" (2 groupes de nœuds) et précise-le dans demoGoal
- Loi chiffrée avec paramètres manipulables (η = 1 Tc/Th, COP, loi physique, modèle) → "simulation" (l'apprenant manipule des curseurs)
- Une démo seulement si elle ENSEIGNE mieux que le texte. Max 3 sections avec démo/simulation.
RULES:
- Language of ALL strings = "${lang}" (the note's language).
- demoGoal: one sentence stating what the demo must show (used by the next LLM call).
- No colors anywhere. Intents only.`
}
export async function generatePagePlan(input: {
content: string
lang?: string
provider: AIProvider
}): Promise<GeneratePagePlanResult> {
const lang = input.lang || 'fr'
const assets = extractSourceAssets(input.content)
const plain = stripToPlain(input.content).slice(0, 6000)
const system = buildPlanSystemPrompt(lang)
const model = input.provider.getModel()
let lastError = 'unknown'
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
let user = `Plan the interactive page for THIS note.
EXCERPT_START
${plain}
EXCERPT_END
${assetsBlock(assets)}`
if (attempt > 1) {
user += `\nPREVIOUS_ANSWER_INVALID: ${lastError}\nReturn a corrected FULL JSON object matching the schema exactly.`
}
const { text: raw } = await generateText({
model,
system,
prompt: user,
temperature: attempt === 1 ? 0.3 : 0.1,
})
const parsed = extractJsonObject(raw)
if (!parsed) {
lastError = 'Model did not return parseable JSON'
console.warn('[interactive-page/plan] unparseable JSON', raw.slice(0, 300))
continue
}
const refusal = parsed as { error?: string; reason?: string }
if (refusal?.error === 'unsuitable_content') {
return {
ok: false,
error: 'unsuitable_content',
reason: refusal.reason || 'Contenu inadapté à une page interactive',
attempts: attempt,
}
}
const result = pagePlanSchema.safeParse(parsed)
if (result.success) {
return { ok: true, plan: result.data, attempts: attempt }
}
lastError = result.error.issues
.slice(0, 6)
.map((i) => `${i.path.join('.')}: ${i.message}`)
.join(' | ')
console.warn('[interactive-page/plan] schema failed', lastError)
}
return { ok: false, error: 'plan_failed', reason: lastError, attempts: MAX_ATTEMPTS }
}
// ── 2. Section generation ────────────────────────────────────────────────────
export type GeneratePageSectionInput = {
content: string
lang?: string
provider: AIProvider
pageTitle: string
sectionId: string
section: PagePlanSection
}
export type GeneratePageSectionResult =
| { ok: true; section: PageSection; attempts: number }
| { ok: false; issues?: PageValidationIssue[]; error?: string; attempts: number }
const SECTION_NARRATION_RULES = `NARRATION des démos (champ "speak"):
- 12 phrases, 25 mots max, une seule idée par étape, gras sur le concept clé, KaTeX inline ($...$) pour les formules.
- Voix off de prof, jamais de description mécanique.
- Jamais annoter ce qu'on pointTo dans la même étape.
- Chaque élément d'une scène est désigné au moins une fois dans l'acte.
- Scènes denses : svg-scene ≥ 5 nœuds ; heatmap avec valeurs réalistes et variées (intensité ∝ valeur, valeurs affichées).
- Étape finale d'acte : reveal ["*"] + pattern overview.
- Valeurs illustratives → "disclaimer" obligatoire.`
function buildSectionSystemPrompt(lang: string, hasMath: boolean): string {
return `You generate ONE section of an interactive pedagogical page (PageSpecV1) for a Memento note, in the style of the Kimi "Attention Residuals" explainer.
OUTPUT: a single JSON object only. No markdown fences. No commentary. No <think> blocks.
SCHEMA:
{
"title": string,
"blocks": [ Block, ... ] // 26 blocks
}
Block types (discriminated by "type"):
- { "type": "prose", "md": string } // light markdown + $KaTeX$ inline
- { "type": "formula", "tex": string, "caption?": string } // KaTeX block for key relations
- { "type": "callout", "kind": ${JSON.stringify(CALLOUT_KINDS)}, "title": string, "md": string }
- { "type": "chart", "payload": { "chartType": "line"|"bar"|"area", "series": [{ "id": string, "label?": string, "values": number[], "intent?": IntentId }] }, "caption?": string }
- { "type": "stats", "items": [{ "value": string, "label": string, "intent?": IntentId }] } // 25, REAL figures from the note only
- { "type": "table", "columns": string[], "rows": string[][], "caption?": string } // every row.length === columns.length
- { "type": "demo", "demo": InteractiveDemoV1, "caption?": string }
- { "type": "sim", "sim": SimRef, "caption?": string } // interactive simulation with sliders
IntentId: ${JSON.stringify(INTENT_IDS)}
SimRef — TWO forms:
(A) CATALOG simulator (PREFERRED when the section matches one): { "simId": "<id from catalog>", "title?": string, "preset?": { "<paramId>": number }, "disclaimer?": string }
→ You ONLY pick the simId and preset values (from the note's real numbers within the allowed ranges). The app runs the simulation.
(B) GENERIC formula simulator: { "simId": "generic-formula", "title": string, "params": [{ "id", "symbol", "label", "min", "max", "step", "defaultValue", "unit?", "intent?" }] (14), "computed": [{ "id", "symbol", "label", "expr", "unit?", "intent?" }] (16), "visual": { "kind": "gauges" } | { "kind": "bars" } | { "kind": "curve", "xParamId", "expr" }, "disclaimer?": string }
→ expr = plain math over param ids: + - * / ^ % parentheses, functions sqrt abs exp ln log round min max, constants pi e. NO other identifiers.
SIMULATOR CATALOG (pick from this, else generic-formula):
${catalogForPrompt(lang)}
InteractiveDemoV1 (schemaVersion ${INTERACTIVE_DEMO_SCHEMA_VERSION}):
{
"schemaVersion": 1, "id": "demo.<slug>", "lang": "${lang}", "disclaimer?": string,
"scene": { "id": string, "panels": [Panel] },
"acts": [ { "id": "a1", "title": string, "pattern?": Pattern, "steps": [Step] } ]
}
Panel types: ${PANEL_TYPES.join(', ')} (max 2 panels; heatmap cells ids: r{row}.c{col}, triangular:"lower" when appropriate)
Patterns: ${PATTERN_IDS.join(', ')}
Step: { "id": "a1.s1", "speak": string, "pattern?", "pointTo?": string[], "reveal?": [{"ids": string[], "scope": "transient|act|scene"}], "annotate?": [...] }
svg-scene: nodes[{id,label?,intent?}] (labels may hold $KaTeX$), edges[{id,from,to,style?,weight?,intent?}]
${SECTION_NARRATION_RULES}
MÉCANIQUE (the validator rejects otherwise):
- Unique semantic ids; NO colors (hex/rgb) anywhere — intents only.
- Blocks per section ≤ ${INTERACTIVE_PAGE_CAPS.maxBlocksPerSection}.
- Every pointTo/reveal/annotate reference must be an id of the active scene.
- "stats"/"table" ONLY with figures actually present in the source.
- All human-facing strings in "${lang}".
RÈGLE D'OR — AUCUN VISUEL DÉCORATIF (checked after generation, violations are rejected):
- Every visual block must TEACH something the text alone cannot. Ask: "what does the learner understand after, that they didn't before?" No answer → no visual block.
- "heatmap-matrix" ONLY when the note contains genuinely matrix-shaped data (table of values, correlations, confusion matrix). Otherwise FORBIDDEN.
- "chart" ONLY with numeric series actually present in the source note. Otherwise FORBIDDEN.
- svg-scene: ≥ 5 nodes, dense, real vocabulary of the note — never 3 generic boxes.
- When in doubt: prose/formula/callout only.
${hasMath ? '- STEM: reuse formulas from FORMULES_EXTRAITES verbatim in formula blocks, prose and demo speak ($...$).' : ''}`
}
function buildSectionUserPrompt(
input: GeneratePageSectionInput,
assets: ReturnType<typeof extractSourceAssets>,
opts?: { issues?: PageValidationIssue[]; previousJson?: string }
): string {
const plain = stripToPlain(input.content).slice(0, 6000)
const { section } = input
let msg = `Generate the section "${section.title}" of the interactive page "${input.pageTitle}".
SECTION_GOAL: ${section.goal}
${
section.demoKind === 'simulation'
? `SIMULATION_REQUIRED: include ONE "sim" block. Prefer a catalog simulator if the section matches one (bind the note's real values into "preset"); otherwise "generic-formula" with the section's key relation.
SIM_GOAL: ${section.demoGoal || section.goal}`
: section.demoKind !== 'none'
? `DEMO_REQUIRED: include ONE "demo" block of kind "${section.demoKind}".
DEMO_GOAL: ${section.demoGoal || section.goal}`
: `NO demo/sim block for this section — rich prose/formula/callout/stats/table only.`
}
EXCERPT_START
${plain}
EXCERPT_END
${assetsBlock(assets)}`
if (section.demoKind === 'heatmap-matrix' || section.demoKind === 'svg-scene') {
const golden = fixtureDemo(section.demoKind)
if (golden) {
msg += `\nEXEMPLE_D_OR (imite sa densité et sa qualité de narration — jamais moins ; adapte au contenu de CETTE note):\n${golden}\n`
}
}
msg += `\nCONTRAINTES: section autoportante, fidèle au contenu réel de la note (jamais un exemple jouet). 26 blocks.`
if (opts?.issues?.length) {
msg += `\n\nPREVIOUS_JSON_FAILED_VALIDATION. Fix ALL issues and return a corrected FULL JSON.\n`
msg += opts.issues
.slice(0, 12)
.map((i) => `- [${i.code}] ${i.path}: ${i.message}`)
.join('\n')
}
if (opts?.previousJson) {
msg += `\n\nPREVIOUS_JSON_START\n${opts.previousJson.slice(0, 10000)}\nPREVIOUS_JSON_END`
}
return msg
}
/** Wrap a section candidate in a minimal page so the shared validator runs (incl. demo delegation). */
function validateSectionCandidate(
candidate: unknown,
sectionId: string,
lang: string
): { ok: true; section: PageSection } | { ok: false; issues: PageValidationIssue[] } {
const wrapped = {
schemaVersion: 1,
id: 'page.section-check',
lang,
hero: { kicker: 'CHECK', title: 'Section check' },
sections: [
typeof candidate === 'object' && candidate !== null
? { id: sectionId, ...(candidate as Record<string, unknown>) }
: candidate,
],
}
const normalized = normalizeInteractivePageCandidate(wrapped, lang)
if (!normalized) {
return {
ok: false,
issues: [
{ code: 'normalize_failed', path: '', message: 'Section not normalizable' },
],
}
}
const result = validateInteractivePage(normalized)
if (!result.ok) return { ok: false, issues: result.issues }
const section = result.page.sections.find((s) => s.id === sectionId)
if (!section) {
return {
ok: false,
issues: [{ code: 'section_missing', path: 'sections', message: 'Section lost in normalization' }],
}
}
return { ok: true, section }
}
/**
* Quality gate (règle d'or): reject decorative visuals AFTER schema validation —
* heatmap/chart whose values don't come from the note, svg-scene too sparse.
* Issues are fed back to the LLM for a repair attempt.
*/
function qualityGateSection(
section: PageSection,
assets: ReturnType<typeof extractSourceAssets>
): PageValidationIssue[] {
const out: PageValidationIssue[] = []
const sourceValues = assets.numbers.map((n) => n.value)
const valueInSource = (v: number) =>
sourceValues.some((sv) => sv !== 0 && Math.abs(sv - v) / Math.max(1, Math.abs(sv)) < 0.06)
for (const [bi, block] of section.blocks.entries()) {
const path = `blocks[${bi}]`
if (block.type === 'chart') {
const values = block.payload.series.flatMap((s) => s.values)
const grounded = values.filter(valueInSource).length
if (values.length > 0 && grounded / values.length < 0.5) {
out.push({
code: 'decorative_data',
path,
message:
'Chart values are not from the note (decorative data). Use real series from the source or remove the chart.',
})
}
}
if (block.type === 'demo') {
for (const panel of block.demo.scene.panels) {
if (panel.type === 'heatmap-matrix') {
const values = panel.payload.values.flat()
const grounded = values.filter(valueInSource).length
if (values.length > 0 && grounded / values.length < 0.5) {
out.push({
code: 'decorative_data',
path,
message:
'Heatmap values are not from the note (decorative data). Only use heatmap-matrix for real matrix-shaped source data.',
})
}
}
if (panel.type === 'svg-scene' && panel.payload.nodes.length < 5) {
out.push({
code: 'scene_too_poor',
path,
message:
'svg-scene has fewer than 5 nodes (too poor pedagogically). Densify: ≥5 nodes with real vocabulary and formulas from the note.',
})
}
}
}
}
return out
}
export async function generatePageSection(
input: GeneratePageSectionInput
): Promise<GeneratePageSectionResult> {
const lang = input.lang || 'fr'
const assets = extractSourceAssets(input.content)
const system = buildSectionSystemPrompt(
lang,
assets.hasMath || assets.formulas.length > 0
)
const model = input.provider.getModel()
let lastIssues: PageValidationIssue[] = []
let lastRaw = ''
let lastNormalizedJson = ''
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const user = buildSectionUserPrompt(input, assets, {
issues: attempt > 1 ? lastIssues : undefined,
previousJson: attempt > 1 ? lastNormalizedJson || lastRaw : undefined,
})
const { text: raw } = await generateText({
model,
system,
prompt: user,
temperature: attempt === 1 ? 0.3 : 0.1,
})
lastRaw = raw
const parsed = extractJsonObject(raw)
if (!parsed) {
lastIssues = [
{
code: 'invalid_json',
path: '',
message: 'Model did not return parseable JSON',
},
]
console.warn(
`[interactive-page/section ${input.sectionId}] attempt ${attempt}: unparseable JSON`,
raw.slice(0, 300)
)
continue
}
lastNormalizedJson = JSON.stringify(parsed)
const result = validateSectionCandidate(parsed, input.sectionId, lang)
if (result.ok) {
const qualityIssues = qualityGateSection(result.section, assets)
if (!qualityIssues.length) {
return { ok: true, section: result.section, attempts: attempt }
}
lastIssues = qualityIssues
console.warn(
`[interactive-page/section ${input.sectionId}] attempt ${attempt}: quality gate`,
qualityIssues.slice(0, 6)
)
continue
}
lastIssues = result.issues
console.warn(
`[interactive-page/section ${input.sectionId}] attempt ${attempt}: validation failed`,
result.issues.slice(0, 8)
)
}
return { ok: false, issues: lastIssues, attempts: MAX_ATTEMPTS }
}

View File

@@ -8,6 +8,7 @@ const TEMPLATE_HINTS: Record<PublishTemplateId, string> = {
magazine: `Chapô accrocheur + une citation mise en avant (pullQuote). Style journalistique.`,
brief: `Résumé exécutif dense + 3 à 5 points clés (keyPoints). Ton professionnel actionnable.`,
essay: `Épigraphe inspirante + chapô réfléchi. Ton littéraire mais clair.`,
'interactive-page': `Page immersive avec hero, sections et démos Play/Step (canal dédié — ne pas utiliser ici).`,
}
/* ─── MODE ÉDITORIAL (pas de réécriture) ─────────────────────────────────── */

View File

@@ -56,7 +56,9 @@ export const CREDIT_COSTS: Record<string, number> = {
ai_flashcard: 3,
voice_transcribe: 1,
excalidraw_generate: 4,
slide_generate: 7, // défaut si pas de slideCount (1+6)
slide_generate: 7, // défaut si pas de slideCount (1+N)
interactive_demo: 10, // génération initiale (brainstorm P12)
interactive_page: 20, // page immersive style Kimi
}
export function slideGenerateCreditCost(slideCount?: number | null): number {

View File

@@ -0,0 +1,76 @@
/** Interactive Demo schema v1 — caps & allowlists (brainstorm 2026-07-22). */
export const INTERACTIVE_DEMO_SCHEMA_VERSION = 1 as const
export const INTERACTIVE_DEMO_CAPS = {
maxScenes: 5,
maxActs: 8,
maxStepsPerAct: 12,
maxAnnotationsPerStep: 5,
maxPanelsPerScene: 2,
maxJsonBytes: 64 * 1024,
} as const
export const PATTERN_IDS = [
'progressiveReveal',
'spotlightTour',
'accumulate',
'compareBeforeAfter',
'chartBuild',
'heatmapFill',
'overview',
'flowTrace',
] as const
export const INTENT_IDS = [
'highlight',
'flow',
'cache',
'compute',
'output',
'warning',
] as const
export const SCOPE_IDS = ['transient', 'act', 'scene'] as const
export const ANNOTATION_KINDS = ['circle', 'arrow', 'badge', 'callout'] as const
export const PANEL_TYPES = ['svg-scene', 'chart', 'heatmap-matrix'] as const
export const TRANSITIONS = ['fade', 'cut'] as const
export const CHART_TYPES = ['line', 'bar', 'area'] as const
/**
* Human / locale strings — may contain hex/rgb in prose; changeable by translateDemo.
* Must stay in sync between color-scan skip, translate strip, and docs.
*/
export const HUMAN_STRING_KEYS = [
'lang',
'speak',
'title',
'text',
'disclaimer',
'label',
'rowLabels',
'colLabels',
] as const
export type HumanStringKey = (typeof HUMAN_STRING_KEYS)[number]
const HUMAN_STRING_KEY_SET = new Set<string>(HUMAN_STRING_KEYS)
export function isHumanStringKey(key: string): boolean {
return HUMAN_STRING_KEY_SET.has(key)
}
/** Hex / rgb color literals forbidden in non-human JSON fields (P11). */
export const FORBIDDEN_COLOR_RE =
/#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b|\brgba?\s*\(/i
/**
* Future generation-prompt rule (not a validator cap):
* speak ≈ 12 sentences, ~25 words max — tempo is decided at writing time.
*/
export const SPEAK_WRITING_RULE =
'speak ≈ 12 phrases, ~25 mots max — le tempo de la démo se décide à lécriture, pas au rendu.'

View File

@@ -0,0 +1,469 @@
{
"schemaVersion": 1,
"id": "demo.attnres",
"lang": "fr",
"disclaimer": "Poids d'enseignement — schéma illustratif, pas les mesures de la Figure 8 du papier.",
"scene": {
"id": "scene.problem",
"panels": [
{
"id": "panel.trunk",
"type": "svg-scene",
"payload": {
"nodes": [
{
"id": "residualTrunk",
"label": "Tronc résiduel h",
"intent": "flow"
},
{
"id": "L1",
"label": "L1 · Attention",
"intent": "compute"
},
{
"id": "L2",
"label": "L2 · MLP",
"intent": "cache"
},
{
"id": "L3",
"label": "L3 · Attention",
"intent": "compute"
},
{
"id": "L4",
"label": "L4 · MLP",
"intent": "cache"
},
{
"id": "L5",
"label": "L5 · Attention",
"intent": "compute"
},
{
"id": "L6",
"label": "L6 · MLP",
"intent": "cache"
},
{
"id": "L7",
"label": "L7 · Attention",
"intent": "compute"
},
{
"id": "L8",
"label": "L8 · MLP",
"intent": "cache"
}
],
"edges": [
{
"id": "e.L1.h",
"from": "L1",
"to": "residualTrunk",
"style": "solid",
"weight": 1,
"intent": "flow"
},
{
"id": "e.L2.h",
"from": "L2",
"to": "residualTrunk",
"style": "solid",
"weight": 1,
"intent": "flow"
},
{
"id": "e.L3.h",
"from": "L3",
"to": "residualTrunk",
"style": "solid",
"weight": 1,
"intent": "flow"
},
{
"id": "e.L4.h",
"from": "L4",
"to": "residualTrunk",
"style": "solid",
"weight": 1,
"intent": "flow"
},
{
"id": "e.L5.h",
"from": "L5",
"to": "residualTrunk",
"style": "solid",
"weight": 1,
"intent": "flow"
},
{
"id": "e.L6.h",
"from": "L6",
"to": "residualTrunk",
"style": "solid",
"weight": 1,
"intent": "flow"
},
{
"id": "e.L7.h",
"from": "L7",
"to": "residualTrunk",
"style": "solid",
"weight": 1,
"intent": "flow"
},
{
"id": "e.L8.h",
"from": "L8",
"to": "residualTrunk",
"style": "solid",
"weight": 1,
"intent": "flow"
}
]
}
},
{
"id": "panel.magShare",
"type": "chart",
"payload": {
"chartType": "line",
"series": [
{
"id": "series.magnitude",
"label": "‖h‖",
"values": [
2,
3,
4,
5,
6,
7,
8,
9
],
"intent": "compute"
},
{
"id": "series.embedShare",
"label": "part embedding",
"values": [
0.5,
0.33,
0.25,
0.2,
0.17,
0.14,
0.12,
0.11
],
"intent": "output"
}
]
}
}
]
},
"acts": [
{
"id": "a1",
"title": "Le problème — dilution en profondeur",
"pattern": "accumulate",
"steps": [
{
"id": "a1.s1",
"speak": "Chaque couche entre dans le tronc résiduel avec un **coefficient fixe ×1** — aucune sélection.",
"pattern": "spotlightTour",
"pointTo": [
"residualTrunk",
"L1",
"e.L1.h"
],
"reveal": [
{
"ids": [
"residualTrunk",
"L1",
"e.L1.h"
],
"scope": "act"
}
]
},
{
"id": "a1.s2",
"speak": "La **magnitude** croît avec la profondeur ; la part de l'embedding **dilue** $\\approx 1/(l+1)$.",
"pattern": "chartBuild",
"pointTo": [
"series.magnitude",
"series.embedShare"
],
"reveal": [
{
"ids": [
"series.magnitude"
],
"scope": "act"
},
{
"ids": [
"series.embedShare"
],
"scope": "act"
}
]
}
]
},
{
"id": "a2",
"title": "Full Attention Residuals",
"pattern": "heatmapFill",
"transition": "fade",
"scene": {
"id": "scene.heatmap",
"panels": [
{
"id": "panel.attnMatrix",
"type": "heatmap-matrix",
"payload": {
"rows": 8,
"cols": 8,
"triangular": "lower",
"rowLabels": [
"L1",
"L2",
"L3",
"L4",
"L5",
"L6",
"L7",
"L8"
],
"colLabels": [
"h₁",
"f₁",
"f₂",
"f₃",
"f₄",
"f₅",
"f₆",
"f₇"
],
"values": [
[
1.0,
0,
0,
0,
0,
0,
0,
0
],
[
0.3,
0.7,
0,
0,
0,
0,
0,
0
],
[
0.2,
0.65,
0.15,
0,
0,
0,
0,
0
],
[
0.25,
0.15,
0.45,
0.15,
0,
0,
0,
0
],
[
0.1,
0.1,
0.15,
0.6,
0.05,
0,
0,
0
],
[
0.1,
0.08,
0.12,
0.2,
0.45,
0.05,
0,
0
],
[
0.18,
0.07,
0.1,
0.12,
0.18,
0.3,
0.05,
0
],
[
0.12,
0.06,
0.08,
0.1,
0.14,
0.2,
0.25,
0.05
]
]
}
}
]
},
"steps": [
{
"id": "a2.s1",
"speak": "Matrice **lower-triangular** : chaque ligne = une couche ; chaque ligne **somme à 1**.",
"pattern": "overview"
},
{
"id": "a2.s2",
"speak": "On remplit progressivement : la couche 1 ne voit que **$h_1$**.",
"pattern": "heatmapFill",
"pointTo": [
"r1.c1"
],
"reveal": [
{
"ids": [
"r1.c1"
],
"scope": "act"
}
]
},
{
"id": "a2.s3",
"speak": "**Dominance diagonale** — chaque couche favorise son prédécesseur immédiat.",
"pointTo": [
"r3.c3",
"r4.c4"
],
"reveal": [
{
"ids": [
"r2.c1",
"r2.c2",
"r3.c1",
"r3.c2",
"r3.c3"
],
"scope": "act"
}
],
"annotate": [
{
"kind": "badge",
"targetIds": [
"r2.c2"
],
"scope": "act",
"intent": "highlight"
}
]
},
{
"id": "a2.s4",
"speak": "L'**embedding** garde un poids non trivial en profondeur.",
"annotate": [
{
"kind": "circle",
"targetIds": [
"r8.c1"
],
"scope": "transient",
"intent": "highlight"
},
{
"kind": "badge",
"targetIds": [
"r8.c1"
],
"scope": "act",
"intent": "highlight"
}
]
},
{
"id": "a2.s5",
"speak": "Certaines couches profondes **récupèrent** des sources très antérieures — skip connections apprises.",
"annotate": [
{
"kind": "badge",
"targetIds": [
"r7.c1"
],
"scope": "act",
"intent": "highlight"
},
{
"kind": "callout",
"targetIds": [
"r8.c3"
],
"scope": "transient",
"text": "récupération précoce",
"intent": "warning"
}
]
},
{
"id": "a2.s6",
"speak": "Pre-Attn et Pre-MLP se **spécialisent** différemment (patterns mesurés, ici illustratifs).",
"pattern": "overview",
"annotate": [
{
"kind": "badge",
"targetIds": [
"r5.c4"
],
"scope": "scene",
"intent": "highlight"
}
]
},
{
"id": "a2.s7",
"speak": "Matrice assemblée — triangulaire inférieure ; **chaque ligne somme à 1**.",
"pattern": "overview",
"reveal": [
{
"ids": [
"*"
],
"scope": "act"
}
]
}
]
}
]
}

View File

@@ -0,0 +1,40 @@
export {
INTERACTIVE_DEMO_CAPS,
INTERACTIVE_DEMO_SCHEMA_VERSION,
PATTERN_IDS,
INTENT_IDS,
SCOPE_IDS,
ANNOTATION_KINDS,
PANEL_TYPES,
CHART_TYPES,
HUMAN_STRING_KEYS,
SPEAK_WRITING_RULE,
} from './constants'
export {
SPEAK_WRITING_GUIDE,
intentColor,
dimOpacity,
spotlightColor,
} from './intent-colors'
export { interactiveDemoV1Schema } from './schema'
export {
validateInteractiveDemo,
assertTranslatePreservesStructure,
collectSceneElementIds,
heatmapCellIds,
} from './validate'
export { normalizeInteractiveDemoCandidate } from './normalize'
export {
resolveInteractiveDemo,
resolveActFinalState,
} from './resolve'
export type {
InteractiveDemoV1,
ValidationResult,
ValidationIssue,
DemoAct,
DemoStep,
DemoScene,
Panel,
} from './types'
export type { StepResolvedState, ActResolvedState, ResolvedAnnotation } from './resolve'

View File

@@ -0,0 +1,62 @@
import type { IntentId } from '@/lib/interactive-demo/types'
/**
* Desaturated palette — one set for light, one for dark (P11).
* No free hex in demo JSON; app maps intents here.
*/
export const INTENT_PALETTE_LIGHT: Record<IntentId | 'neutral', string> = {
neutral: '#5c5c5c',
highlight: '#5a7a9a', // prune / slate-blue
flow: '#4a7d68',
cache: '#7a6a8a',
compute: '#8a6b4a',
output: '#4a7a8a',
warning: '#9a6548',
}
export const INTENT_PALETTE_DARK: Record<IntentId | 'neutral', string> = {
neutral: '#a8a8a8',
highlight: '#8aabca',
flow: '#7ab89a',
cache: '#a898b8',
compute: '#c0a080',
output: '#7ab0c0',
warning: '#d09070',
}
/** Light: ~35% readable on white; dark: ~20% as spec. */
export const DIM_OPACITY_LIGHT = 0.35
export const DIM_OPACITY_DARK = 0.2
export function intentColor(
intent: IntentId | null | undefined,
dark: boolean
): string {
const palette = dark ? INTENT_PALETTE_DARK : INTENT_PALETTE_LIGHT
if (!intent) return palette.neutral
return palette[intent] ?? palette.neutral
}
export function spotlightColor(dark: boolean): string {
return intentColor('highlight', dark)
}
export function dimOpacity(dark: boolean): number {
return dark ? DIM_OPACITY_DARK : DIM_OPACITY_LIGHT
}
export const CIRCLED_NUMBERS = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩'] as const
export function badgeGlyph(index: number): string {
if (index >= 1 && index <= CIRCLED_NUMBERS.length) {
return CIRCLED_NUMBERS[index - 1]!
}
return String(index)
}
/** Writing rule for generation prompts (not enforced in renderer). */
export const SPEAK_WRITING_GUIDE = {
maxWordsApprox: 25,
sentences: '12',
note: 'Demo tempo is decided at writing time, not at render.',
} as const

View 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
}

View File

@@ -0,0 +1,227 @@
import { collectSceneElementIds } from './validate'
import type {
Annotation,
DemoAct,
DemoScene,
DemoStep,
InteractiveDemoV1,
ScopeId,
} from './types'
const SCOPE_RANK: Record<ScopeId, number> = {
transient: 1,
act: 2,
scene: 3,
}
function longestScope(a: ScopeId, b: ScopeId): ScopeId {
return SCOPE_RANK[a] >= SCOPE_RANK[b] ? a : b
}
export type ResolvedAnnotation = Annotation & { badgeIndex?: number }
export type StepResolvedState = {
actId: string
stepId: string
stepIndex: number
speak: string
/** Elements revealed and their effective scope */
revealed: Record<string, ScopeId>
/** Spotlight targets this step (empty ⇒ overview / full brightness) */
spotlight: string[]
/** Active annotations after applying this step (transient of prior steps purged) */
annotations: ResolvedAnnotation[]
overview: boolean
}
export type ActResolvedState = {
actId: string
title: string
steps: StepResolvedState[]
/** State after the last step — static/SSR/print/export default */
final: StepResolvedState
}
function purgeScope(
revealed: Map<string, ScopeId>,
annotations: ResolvedAnnotation[],
scopes: ScopeId[]
): { revealed: Map<string, ScopeId>; annotations: ResolvedAnnotation[] } {
const drop = new Set(scopes)
const nextRevealed = new Map<string, ScopeId>()
for (const [id, scope] of revealed) {
if (!drop.has(scope)) nextRevealed.set(id, scope)
}
const nextAnn = annotations.filter((a) => !drop.has(a.scope))
return { revealed: nextRevealed, annotations: nextAnn }
}
function mergeReveal(
revealed: Map<string, ScopeId>,
id: string,
scope: ScopeId
): void {
const prev = revealed.get(id)
revealed.set(id, prev ? longestScope(prev, scope) : scope)
}
function applyStep(
step: DemoStep,
elementIds: Set<string>,
revealed: Map<string, ScopeId>,
annotations: ResolvedAnnotation[],
badgeCounter: { n: number }
): {
revealed: Map<string, ScopeId>
annotations: ResolvedAnnotation[]
spotlight: string[]
overview: boolean
} {
// Drop previous step's transient
;({ revealed, annotations } = purgeScope(revealed, annotations, ['transient']))
const spotlightSet = new Set<string>()
const revealId = (id: string, scope: ScopeId) => {
if (id === '*') {
for (const eid of elementIds) {
if (!revealed.has(eid)) mergeReveal(revealed, eid, scope)
}
return
}
mergeReveal(revealed, id, scope)
}
for (const rev of step.reveal ?? []) {
for (const id of rev.ids) {
revealId(id, rev.scope)
if (id !== '*') spotlightSet.add(id)
}
}
// Designation ⇒ reveal (pointTo default act)
for (const id of step.pointTo ?? []) {
revealId(id, 'act')
spotlightSet.add(id)
}
for (const ann of step.annotate ?? []) {
for (const id of ann.targetIds) {
revealId(id, ann.scope)
spotlightSet.add(id)
}
const resolved: ResolvedAnnotation = { ...ann }
if (ann.kind === 'badge') {
badgeCounter.n += 1
resolved.badgeIndex = badgeCounter.n
}
annotations.push(resolved)
}
const overview = spotlightSet.size === 0
return {
revealed,
annotations,
spotlight: [...spotlightSet],
overview,
}
}
function resolveAct(
act: DemoAct,
scene: DemoScene,
sceneChanged: boolean,
carried: {
revealed: Map<string, ScopeId>
annotations: ResolvedAnnotation[]
}
): ActResolvedState {
let { revealed, annotations } = carried
if (sceneChanged) {
// New scene: purge everything from old scene (refs would be dead)
revealed = new Map()
annotations = []
} else {
// Soft reset: purge transient + act, keep scene-scoped
;({ revealed, annotations } = purgeScope(revealed, annotations, [
'transient',
'act',
]))
}
const { ids: elementIds } = collectSceneElementIds(scene)
const badgeCounter = { n: 0 }
const steps: StepResolvedState[] = []
for (const [stepIndex, step] of act.steps.entries()) {
const applied = applyStep(
step,
elementIds,
revealed,
annotations,
badgeCounter
)
revealed = applied.revealed
annotations = applied.annotations
const snapshot: StepResolvedState = {
actId: act.id,
stepId: step.id,
stepIndex,
speak: step.speak,
revealed: Object.fromEntries(revealed),
spotlight: applied.spotlight,
annotations: annotations.map((a) => ({ ...a })),
overview: applied.overview,
}
steps.push(snapshot)
}
const last = steps[steps.length - 1]!
return {
actId: act.id,
title: act.title,
steps,
final: last,
}
}
/**
* Pure resolver: auto-reveal, wildcard, longest-scope-wins, spotlight.
* Shared by player / SSR / noscript / print / export — no React.
*/
export function resolveInteractiveDemo(demo: InteractiveDemoV1): {
acts: ActResolvedState[]
} {
let scene = demo.scene
let revealed = new Map<string, ScopeId>()
let annotations: ResolvedAnnotation[] = []
const acts: ActResolvedState[] = []
for (const act of demo.acts) {
const sceneChanged = Boolean(act.scene)
if (act.scene) scene = act.scene
const resolved = resolveAct(act, scene, sceneChanged, {
revealed,
annotations,
})
acts.push(resolved)
// Carry state into next act (may be purged on soft reset / scene change)
const last = resolved.final
revealed = new Map(Object.entries(last.revealed) as [string, ScopeId][])
annotations = last.annotations.map((a) => ({ ...a }))
}
return { acts }
}
/** Convenience: final static state for a given act (P10 default). */
export function resolveActFinalState(
demo: InteractiveDemoV1,
actId: string
): StepResolvedState | undefined {
return resolveInteractiveDemo(demo).acts.find((a) => a.actId === actId)?.final
}

View File

@@ -0,0 +1,136 @@
import { z } from 'zod'
import {
ANNOTATION_KINDS,
CHART_TYPES,
INTENT_IDS,
INTERACTIVE_DEMO_CAPS,
INTERACTIVE_DEMO_SCHEMA_VERSION,
PANEL_TYPES,
PATTERN_IDS,
SCOPE_IDS,
TRANSITIONS,
} from './constants'
const intentSchema = z.enum(INTENT_IDS).optional()
const patternSchema = z.enum(PATTERN_IDS)
const scopeSchema = z.enum(SCOPE_IDS)
const revealSchema = z.object({
ids: z.array(z.string().min(1)).min(1),
scope: scopeSchema,
})
const annotationSchema = z.object({
kind: z.enum(ANNOTATION_KINDS),
targetIds: z.array(z.string().min(1)).min(1),
scope: scopeSchema,
text: z.string().optional(),
intent: intentSchema,
})
const stepSchema = z.object({
id: z.string().min(1),
speak: z.string().min(1),
pattern: patternSchema.optional(),
pointTo: z.array(z.string().min(1)).optional(),
reveal: z.array(revealSchema).optional(),
annotate: z
.array(annotationSchema)
.max(INTERACTIVE_DEMO_CAPS.maxAnnotationsPerStep)
.optional(),
})
const svgNodeSchema = z.object({
id: z.string().min(1),
label: z.string().optional(),
intent: intentSchema,
})
const svgEdgeSchema = z.object({
id: z.string().min(1),
from: z.string().min(1),
to: z.string().min(1),
style: z.enum(['solid', 'dashed']).optional(),
weight: z.number().optional(),
intent: intentSchema,
})
const svgScenePayloadSchema = z.object({
nodes: z.array(svgNodeSchema).min(1),
edges: z.array(svgEdgeSchema).optional(),
})
const chartSeriesSchema = z.object({
id: z.string().min(1),
label: z.string().optional(),
values: z.array(z.number()),
intent: intentSchema,
})
const chartPayloadSchema = z.object({
chartType: z.enum(CHART_TYPES),
series: z.array(chartSeriesSchema).min(1),
})
const heatmapPayloadSchema = z.object({
rows: z.number().int().positive(),
cols: z.number().int().positive(),
values: z.array(z.array(z.number())),
triangular: z.enum(['lower', 'upper', 'none']).optional(),
rowLabels: z.array(z.string()).optional(),
colLabels: z.array(z.string()).optional(),
})
const panelSchema = z.discriminatedUnion('type', [
z.object({
id: z.string().min(1),
type: z.literal('svg-scene'),
payload: svgScenePayloadSchema,
}),
z.object({
id: z.string().min(1),
type: z.literal('chart'),
payload: chartPayloadSchema,
}),
z.object({
id: z.string().min(1),
type: z.literal('heatmap-matrix'),
payload: heatmapPayloadSchema,
}),
])
const sceneSchema = z.object({
id: z.string().min(1).optional(),
panels: z
.array(panelSchema)
.min(1)
.max(INTERACTIVE_DEMO_CAPS.maxPanelsPerScene),
})
const actSchema = z.object({
id: z.string().min(1),
title: z.string().min(1),
scene: sceneSchema.optional(),
transition: z.enum(TRANSITIONS).optional(),
pattern: patternSchema.optional(),
steps: z.array(stepSchema).min(1).max(INTERACTIVE_DEMO_CAPS.maxStepsPerAct),
})
/** Loose BCP-47: primary tag + optional subtags (fr, en, zh-Hans, pt-BR). */
const langSchema = z
.string()
.regex(/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/, 'Invalid BCP-47 language tag')
export const interactiveDemoV1Schema = z.object({
schemaVersion: z.literal(INTERACTIVE_DEMO_SCHEMA_VERSION),
id: z.string().min(1),
lang: langSchema,
disclaimer: z.string().optional(),
scene: sceneSchema,
acts: z.array(actSchema).min(1).max(INTERACTIVE_DEMO_CAPS.maxActs),
})
export type InteractiveDemoV1Parsed = z.infer<typeof interactiveDemoV1Schema>
/** Re-export allowlists for callers that need them at runtime. */
export { PANEL_TYPES, PATTERN_IDS }

View File

@@ -0,0 +1,116 @@
import type {
ANNOTATION_KINDS,
INTENT_IDS,
PANEL_TYPES,
PATTERN_IDS,
SCOPE_IDS,
TRANSITIONS,
} from './constants'
export type PatternId = (typeof PATTERN_IDS)[number]
export type IntentId = (typeof INTENT_IDS)[number]
export type ScopeId = (typeof SCOPE_IDS)[number]
export type AnnotationKind = (typeof ANNOTATION_KINDS)[number]
export type PanelType = (typeof PANEL_TYPES)[number]
export type TransitionId = (typeof TRANSITIONS)[number]
export type RevealSpec = {
ids: string[]
scope: ScopeId
}
export type Annotation = {
kind: AnnotationKind
targetIds: string[]
scope: ScopeId
text?: string
intent?: IntentId
}
export type DemoStep = {
id: string
speak: string
pattern?: PatternId
pointTo?: string[]
reveal?: RevealSpec[]
annotate?: Annotation[]
}
export type SvgNode = {
id: string
label?: string
intent?: IntentId
}
export type SvgEdge = {
id: string
from: string
to: string
style?: 'solid' | 'dashed'
weight?: number
intent?: IntentId
}
export type SvgScenePayload = {
nodes: SvgNode[]
edges?: SvgEdge[]
}
export type ChartSeries = {
id: string
label?: string
values: number[]
intent?: IntentId
}
export type ChartPayload = {
chartType: 'line' | 'bar' | 'area'
series: ChartSeries[]
}
export type HeatmapPayload = {
rows: number
cols: number
values: number[][]
triangular?: 'lower' | 'upper' | 'none'
rowLabels?: string[]
colLabels?: string[]
}
export type Panel =
| { id: string; type: 'svg-scene'; payload: SvgScenePayload }
| { id: string; type: 'chart'; payload: ChartPayload }
| { id: string; type: 'heatmap-matrix'; payload: HeatmapPayload }
export type DemoScene = {
id?: string
panels: Panel[]
}
export type DemoAct = {
id: string
title: string
scene?: DemoScene
transition?: TransitionId
pattern?: PatternId
steps: DemoStep[]
}
export type InteractiveDemoV1 = {
schemaVersion: 1
id: string
lang: string
disclaimer?: string
scene: DemoScene
acts: DemoAct[]
}
export type ValidationIssue = {
code: string
path: string
message: string
}
export type ValidationResult =
| { ok: true; demo: InteractiveDemoV1 }
| { ok: false; issues: ValidationIssue[] }

View File

@@ -0,0 +1,452 @@
import {
FORBIDDEN_COLOR_RE,
INTERACTIVE_DEMO_CAPS,
isHumanStringKey,
} from './constants'
import { interactiveDemoV1Schema } from './schema'
import type {
DemoAct,
DemoScene,
InteractiveDemoV1,
Panel,
ValidationIssue,
ValidationResult,
} from './types'
function issue(code: string, path: string, message: string): ValidationIssue {
return { code, path, message }
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
/** Heatmap cell ids — respect triangular mode (lower → 36 cells for 8×8). */
export function heatmapCellIds(
rows: number,
cols: number,
triangular?: 'lower' | 'upper' | 'none'
): string[] {
const ids: string[] = []
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.push(`r${r}.c${c}`)
}
}
return ids
}
/** Collect every addressable element id in a scene (nodes, edges, series, heatmap cells). */
export function collectSceneElementIds(scene: DemoScene): {
ids: Set<string>
wildcardAllowed: boolean
} {
const ids = new Set<string>()
let wildcardAllowed = false
for (const panel of scene.panels) {
if (panel.type === 'svg-scene') {
for (const n of panel.payload.nodes) ids.add(n.id)
for (const e of panel.payload.edges ?? []) {
ids.add(e.id)
}
} else if (panel.type === 'chart') {
wildcardAllowed = true
for (const s of panel.payload.series) ids.add(s.id)
} else if (panel.type === 'heatmap-matrix') {
wildcardAllowed = true
const { rows, cols, triangular } = panel.payload
for (const id of heatmapCellIds(rows, cols, triangular)) {
ids.add(id)
}
}
}
return { ids, wildcardAllowed }
}
function countDeclaredScenes(demo: InteractiveDemoV1): number {
let n = 1 // demo.scene
for (const act of demo.acts) {
if (act.scene) n += 1
}
return n
}
/**
* Scan for free color literals — skip human/translatable string fields
* so prose like « couleur #FF0000 » does not hard-reject.
*/
function scanForbiddenColors(
value: unknown,
path: string,
out: ValidationIssue[]
): void {
if (typeof value === 'string') {
if (FORBIDDEN_COLOR_RE.test(value)) {
out.push(
issue(
'forbidden_color',
path,
'Free color literals (hex/rgb) are forbidden — use intent enums'
)
)
}
return
}
if (Array.isArray(value)) {
value.forEach((v, i) => scanForbiddenColors(v, `${path}[${i}]`, out))
return
}
if (value && typeof value === 'object') {
for (const [k, v] of Object.entries(value)) {
if (isHumanStringKey(k)) continue
scanForbiddenColors(v, path ? `${path}.${k}` : k, out)
}
}
}
function validateSvgEdges(panel: Panel, path: string, out: ValidationIssue[]): void {
if (panel.type !== 'svg-scene') return
const nodeIds = new Set(panel.payload.nodes.map((n) => n.id))
for (const [i, edge] of (panel.payload.edges ?? []).entries()) {
if (!nodeIds.has(edge.from)) {
out.push(
issue(
'unknown_edge_endpoint',
`${path}.edges[${i}].from`,
`Edge from "${edge.from}" is not a node id in this panel`
)
)
}
if (!nodeIds.has(edge.to)) {
out.push(
issue(
'unknown_edge_endpoint',
`${path}.edges[${i}].to`,
`Edge to "${edge.to}" is not a node id in this panel`
)
)
}
}
}
function validateHeatmapShape(panel: Panel, path: string, out: ValidationIssue[]): void {
if (panel.type !== 'heatmap-matrix') return
const { rows, cols, values, rowLabels, colLabels } = panel.payload
if (values.length !== rows) {
out.push(
issue(
'heatmap_shape',
`${path}.values`,
`Expected ${rows} rows, got ${values.length}`
)
)
}
for (const [ri, row] of values.entries()) {
if (row.length !== cols) {
out.push(
issue(
'heatmap_shape',
`${path}.values[${ri}]`,
`Expected ${cols} cols, got ${row.length}`
)
)
}
}
if (rowLabels && rowLabels.length !== rows) {
out.push(
issue(
'heatmap_labels',
`${path}.rowLabels`,
`rowLabels length must equal rows (${rows})`
)
)
}
if (colLabels && colLabels.length !== cols) {
out.push(
issue(
'heatmap_labels',
`${path}.colLabels`,
`colLabels length must equal cols (${cols})`
)
)
}
}
function validateScene(
scene: DemoScene,
path: string,
out: ValidationIssue[]
): Set<string> {
const panelIds = new Set<string>()
for (const [pi, panel] of scene.panels.entries()) {
const pPath = `${path}.panels[${pi}]`
if (panelIds.has(panel.id)) {
out.push(issue('duplicate_panel_id', `${pPath}.id`, `Duplicate panel id "${panel.id}"`))
}
panelIds.add(panel.id)
validateSvgEdges(panel, pPath, out)
validateHeatmapShape(panel, pPath, out)
}
const seen = new Set<string>()
const dups = new Set<string>()
for (const panel of scene.panels) {
const local: string[] = []
if (panel.type === 'svg-scene') {
local.push(...panel.payload.nodes.map((n) => n.id))
local.push(...(panel.payload.edges ?? []).map((e) => e.id))
} else if (panel.type === 'chart') {
local.push(...panel.payload.series.map((s) => s.id))
}
for (const id of local) {
if (seen.has(id)) dups.add(id)
seen.add(id)
}
}
for (const id of dups) {
out.push(
issue('duplicate_element_id', path, `Duplicate element id "${id}" in scene`)
)
}
return collectSceneElementIds(scene).ids
}
function validateActRefs(
act: DemoAct,
actIndex: number,
elementIds: Set<string>,
wildcardAllowed: boolean,
out: ValidationIssue[]
): void {
const actPath = `acts[${actIndex}]`
const stepIdRe = new RegExp(`^${escapeRegExp(act.id)}\\.s\\d+$`)
for (const [si, step] of act.steps.entries()) {
const stepPath = `${actPath}.steps[${si}]`
if (!stepIdRe.test(step.id)) {
out.push(
issue(
'step_id_format',
`${stepPath}.id`,
`Step id must match /^${act.id}\\.s\\d+$/ (got "${step.id}")`
)
)
}
for (const id of step.pointTo ?? []) {
if (!elementIds.has(id)) {
out.push(
issue(
'unknown_element_id',
`${stepPath}.pointTo`,
`pointTo references unknown element "${id}" in active scene`
)
)
}
}
for (const [ri, rev] of (step.reveal ?? []).entries()) {
for (const id of rev.ids) {
if (id === '*') {
if (!wildcardAllowed) {
out.push(
issue(
'wildcard_not_allowed',
`${stepPath}.reveal[${ri}]`,
'"*" reveal is only allowed when the active scene has heatmap or chart panels'
)
)
}
continue
}
if (!elementIds.has(id)) {
out.push(
issue(
'unknown_element_id',
`${stepPath}.reveal[${ri}].ids`,
`reveal references unknown element "${id}"`
)
)
}
}
}
for (const [ai, ann] of (step.annotate ?? []).entries()) {
for (const id of ann.targetIds) {
if ((step.pointTo ?? []).includes(id)) {
out.push(
issue(
'annotate_pointto_overlap',
`${stepPath}.annotate[${ai}]`,
`Do not annotate "${id}" in the same step as pointTo — spotlight is enough`
)
)
}
if (!elementIds.has(id)) {
out.push(
issue(
'unknown_element_id',
`${stepPath}.annotate[${ai}].targetIds`,
`annotate references unknown element "${id}"`
)
)
}
}
}
}
}
function semanticValidate(demo: InteractiveDemoV1): ValidationIssue[] {
const out: ValidationIssue[] = []
const jsonBytes = new TextEncoder().encode(JSON.stringify(demo)).length
if (jsonBytes > INTERACTIVE_DEMO_CAPS.maxJsonBytes) {
out.push(
issue(
'json_too_large',
'',
`JSON exceeds ${INTERACTIVE_DEMO_CAPS.maxJsonBytes} bytes (${jsonBytes})`
)
)
}
const sceneCount = countDeclaredScenes(demo)
if (sceneCount > INTERACTIVE_DEMO_CAPS.maxScenes) {
out.push(
issue(
'too_many_scenes',
'scene',
`At most ${INTERACTIVE_DEMO_CAPS.maxScenes} scenes allowed (found ${sceneCount})`
)
)
}
scanForbiddenColors(demo, '', out)
validateScene(demo.scene, 'scene', out)
let activeScene = demo.scene
let { ids: elementIds, wildcardAllowed } = collectSceneElementIds(activeScene)
const actIds = new Set<string>()
for (const [ai, act] of demo.acts.entries()) {
if (actIds.has(act.id)) {
out.push(issue('duplicate_act_id', `acts[${ai}].id`, `Duplicate act id "${act.id}"`))
}
actIds.add(act.id)
if (act.scene) {
validateScene(act.scene, `acts[${ai}].scene`, out)
activeScene = act.scene
;({ ids: elementIds, wildcardAllowed } = collectSceneElementIds(activeScene))
}
const stepIds = new Set<string>()
for (const [si, step] of act.steps.entries()) {
if (stepIds.has(step.id)) {
out.push(
issue(
'duplicate_step_id',
`acts[${ai}].steps[${si}].id`,
`Duplicate step id "${step.id}"`
)
)
}
stepIds.add(step.id)
}
validateActRefs(act, ai, elementIds, wildcardAllowed, out)
}
return out
}
/**
* Validate an Interactive Demo document.
* Structural (Zod allowlist) then semantic (caps, refs, no hex, wildcards).
*/
export function validateInteractiveDemo(input: unknown): ValidationResult {
const parsed = interactiveDemoV1Schema.safeParse(input)
if (!parsed.success) {
const issues: ValidationIssue[] = parsed.error.issues.map((e) => ({
code: e.code,
path: e.path.join('.'),
message: e.message,
}))
return { ok: false, issues }
}
const demo = parsed.data as InteractiveDemoV1
const semantic = semanticValidate(demo)
if (semantic.length > 0) {
return { ok: false, issues: semantic }
}
return { ok: true, demo }
}
/**
* Translate must preserve geometry. Default: strings are structural (kept).
* Only HUMAN_STRING_KEYS are nullified — adding a structural key is protected by default.
*/
export function assertTranslatePreservesStructure(
source: InteractiveDemoV1,
translated: InteractiveDemoV1
): ValidationResult {
const stripHumanStrings = (value: unknown): unknown => {
if (typeof value === 'number' || typeof value === 'boolean' || value === null) {
return value
}
if (typeof value === 'string') {
// Context-free string: structural until proven otherwise.
return value
}
if (Array.isArray(value)) return value.map(stripHumanStrings)
if (value && typeof value === 'object') {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value)) {
if (isHumanStringKey(k)) {
out[k] = null
continue
}
out[k] = stripHumanStrings(v)
}
return out
}
return value
}
const a = JSON.stringify(stripHumanStrings(source))
const b = JSON.stringify(stripHumanStrings(translated))
if (a !== b) {
return {
ok: false,
issues: [
issue(
'translate_structure_drift',
'',
'Translated demo must preserve structure and ids; only human strings may change'
),
],
}
}
if (source.id !== translated.id) {
return {
ok: false,
issues: [
issue(
'translate_id_mismatch',
'id',
'Translated variant must keep the same demo.id'
),
],
}
}
return { ok: true, demo: translated }
}

View File

@@ -0,0 +1,86 @@
/** Interactive Page schema v1 — caps & allowlists (spec Kimi / AttnRes). */
export const INTERACTIVE_PAGE_SCHEMA_VERSION = 1 as const
export const INTERACTIVE_PAGE_CAPS = {
maxSections: 8,
maxBlocksPerSection: 12,
maxDemosPerPage: 5,
maxSimsPerPage: 3,
maxOverviewCards: 4,
minOverviewCards: 2,
maxStatsItems: 5,
minStatsItems: 2,
maxJsonBytes: 128 * 1024,
} as const
export const SIM_CAPS = {
maxParams: 4,
maxComputed: 6,
maxExprChars: 200,
} as const
export const PAGE_BLOCK_TYPES = [
'prose',
'formula',
'callout',
'demo',
'chart',
'stats',
'table',
'image',
'sim',
] as const
export const CALLOUT_KINDS = [
'definition',
'warning',
'tip',
'note',
] as const
/**
* Human / locale strings — may contain hex in prose; skipped by color scan.
* Must stay in sync with validate + translate strip.
*/
export const PAGE_HUMAN_STRING_KEYS = [
// page-level
'lang',
'kicker',
'title',
'subtitle',
'meta',
'lead',
'badge',
'body',
'footer',
// blocks
'md',
'tex',
'caption',
'alt',
'value',
'label',
'columns',
'rows',
// sim blocks
'symbol',
'expr',
'intro',
'xLabel',
'yLabel',
// inherited from demos (speak etc. scanned via demo validator)
'speak',
'text',
'disclaimer',
'rowLabels',
'colLabels',
] as const
export type PageHumanStringKey = (typeof PAGE_HUMAN_STRING_KEYS)[number]
const PAGE_HUMAN_SET = new Set<string>(PAGE_HUMAN_STRING_KEYS)
export function isPageHumanStringKey(key: string): boolean {
return PAGE_HUMAN_SET.has(key)
}

View File

@@ -0,0 +1,354 @@
{
"schemaVersion": 1,
"id": "page.thermo-test",
"lang": "fr",
"hero": {
"kicker": "EXPLAINER INTERACTIF",
"title": "Cycle frigorifique",
"subtitle": "Compression, condensation, détente, évaporation",
"meta": "Note de cours · valeurs illustratives"
},
"overview": {
"lead": "Le cycle frigorifique déplace de la chaleur du froid vers le chaud grâce à un travail $W$.",
"cards": [
{
"badge": "PROBLEM",
"title": "Objectif",
"body": "Extraire $Q_e$ à basse température.",
"intent": "warning"
},
{
"badge": "APPROACH",
"title": "Cycle",
"body": "Quatre organes en boucle fermée.",
"intent": "flow"
},
{
"badge": "RESULT",
"title": "COP",
"body": "$\\mathrm{COP}=Q_e/W$",
"intent": "output"
}
]
},
"sections": [
{
"id": "s1",
"title": "Le problème",
"blocks": [
{
"type": "prose",
"md": "On veut **refroidir** un volume en rejetant la chaleur à lextérieur."
},
{
"type": "callout",
"kind": "definition",
"title": "Travail",
"md": "Le compresseur fournit $W=h_2-h_1$."
}
]
},
{
"id": "s2",
"title": "Échanges",
"blocks": [
{
"type": "formula",
"tex": "\\mathrm{COP}=\\frac{Q_e}{W}",
"caption": "Coefficient de performance"
},
{
"type": "sim",
"sim": {
"simId": "ts-diagram"
},
"caption": "Le même cycle sur le diagramme Ts : les aires sont les chaleurs échangées."
}
]
},
{
"id": "s3",
"title": "Isotherme",
"blocks": [
{
"type": "chart",
"payload": {
"chartType": "line",
"series": [
{
"id": "p",
"label": "P(V)",
"values": [
4,
2.5,
1.8,
1.4,
1.2
],
"intent": "flow"
}
]
},
"caption": "Pression vs volume (illustratif)"
},
{
"type": "demo",
"caption": "Le cycle à compression de vapeur — 4 organes en boucle fermée.",
"demo": {
"schemaVersion": 1,
"id": "demo.vapor-cycle",
"lang": "fr",
"disclaimer": "Schéma pédagogique — valeurs illustratives.",
"scene": {
"id": "scene.cycle",
"panels": [
{
"id": "panel.cycle",
"type": "svg-scene",
"payload": {
"nodes": [
{
"id": "comp",
"label": "1 · Compresseur\n$W = h_2 - h_1$",
"intent": "compute"
},
{
"id": "cond",
"label": "2 · Condenseur\n$Q_c = h_2 - h_3$",
"intent": "output"
},
{
"id": "exp",
"label": "3 · Détente\n$h_3 = h_4$",
"intent": "flow"
},
{
"id": "evap",
"label": "4 · Évaporateur\n$Q_e = h_1 - h_4$",
"intent": "cache"
},
{
"id": "work",
"label": "Travail fourni $W$",
"intent": "highlight"
}
],
"edges": [
{
"id": "e12",
"from": "comp",
"to": "cond",
"style": "solid",
"intent": "flow"
},
{
"id": "e23",
"from": "cond",
"to": "exp",
"style": "solid",
"intent": "flow"
},
{
"id": "e34",
"from": "exp",
"to": "evap",
"style": "solid",
"intent": "flow"
},
{
"id": "e41",
"from": "evap",
"to": "comp",
"style": "solid",
"intent": "flow"
},
{
"id": "ew",
"from": "work",
"to": "comp",
"style": "dashed",
"intent": "highlight"
}
]
}
}
]
},
"acts": [
{
"id": "a1",
"title": "Cycle",
"pattern": "flowTrace",
"steps": [
{
"id": "a1.s1",
"speak": "Le compresseur fournit le travail $W$ au fluide.",
"pattern": "spotlightTour",
"pointTo": [
"comp"
],
"reveal": [
{
"ids": [
"comp",
"work",
"ew"
],
"scope": "act"
}
]
},
{
"id": "a1.s2",
"speak": "Au condenseur, la chaleur $Q_c$ est rejetée vers l'extérieur.",
"pattern": "spotlightTour",
"pointTo": [
"cond"
],
"reveal": [
{
"ids": [
"cond",
"e12"
],
"scope": "act"
}
]
},
{
"id": "a1.s3",
"speak": "La détente est isenthalpique : $h_3 = h_4$.",
"pattern": "spotlightTour",
"pointTo": [
"exp"
],
"reveal": [
{
"ids": [
"exp",
"e23"
],
"scope": "act"
}
]
},
{
"id": "a1.s4",
"speak": "À l'évaporateur, le fluide absorbe $Q_e$ : c'est le froid utile.",
"pattern": "spotlightTour",
"pointTo": [
"evap"
],
"reveal": [
{
"ids": [
"evap",
"e34"
],
"scope": "act"
}
]
},
{
"id": "a1.s5",
"speak": "Le cycle se boucle : le fluide retourne au compresseur.",
"pattern": "overview",
"reveal": [
{
"ids": [
"comp",
"cond",
"exp",
"evap",
"work",
"e12",
"e23",
"e34",
"e41",
"ew"
],
"scope": "act"
}
]
}
]
}
]
}
},
{
"type": "sim",
"sim": {
"simId": "carnot-cycle",
"preset": {
"t_cold": 260,
"t_hot": 300,
"q_cold": 100
},
"disclaimer": "Valeurs illustratives — les COP réels sont inférieurs au COP de Carnot."
},
"caption": "Manipulez les températures des sources : le COP maximal suit le 2ᵉ principe."
},
{
"type": "sim",
"sim": {
"simId": "carnot-cycle-anim"
},
"caption": "Le cycle de Carnot battement par battement — piston et diagramme PV en direct."
},
{
"type": "stats",
"items": [
{
"value": "3.2",
"label": "COP typique",
"intent": "output"
},
{
"value": "1.25×",
"label": "Gain relatif",
"intent": "highlight"
},
{
"value": "<2%",
"label": "Pertes",
"intent": "warning"
}
]
}
]
},
{
"id": "s4",
"title": "Synthèse",
"blocks": [
{
"type": "table",
"columns": [
"Organe",
"Échange"
],
"rows": [
[
"Compresseur",
"$W$"
],
[
"Condenseur",
"$Q_c$"
],
[
"Évaporateur",
"$Q_e$"
]
]
},
{
"type": "prose",
"md": "Le COP mesure lefficacité du cycle."
}
]
}
],
"footer": "Valeurs pédagogiques — pas des mesures expérimentales."
}

View File

@@ -0,0 +1,25 @@
export {
INTERACTIVE_PAGE_CAPS,
INTERACTIVE_PAGE_SCHEMA_VERSION,
PAGE_BLOCK_TYPES,
CALLOUT_KINDS,
PAGE_HUMAN_STRING_KEYS,
isPageHumanStringKey,
} from './constants'
export { pageSpecV1Schema, pageBlockSchema } from './schema'
export { validateInteractivePage } from './validate'
export { normalizeInteractivePageCandidate } from './normalize'
export type {
PageSpecV1,
PageSection,
PageBlock,
PageHero,
PageOverview,
PageValidationIssue,
PageValidationResult,
IntentId,
SimRef,
CatalogSimRef,
GenericFormulaSim,
SimBlock,
} from './types'

View File

@@ -0,0 +1,335 @@
/**
* 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<string>(CALLOUT_KINDS)
const CALLOUT_ALIASES: Record<string, string> = {
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<string, unknown> | null {
return v && typeof v === 'object' && !Array.isArray(v)
? (v as Record<string, unknown>)
: 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, unknown>): 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, unknown>): 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<string, unknown> | 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<string, unknown> = { 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<string, unknown> = { 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<string, unknown>).series)
? (payload as Record<string, unknown>).series
: null
if (!series) return null
const chartType =
asString((payload as Record<string, unknown>).chartType) ||
asString(obj.chartType) ||
'line'
const out: Record<string, unknown> = {
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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> | 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<string, unknown>[]
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<string, unknown> | 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<string, unknown> = {
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<string, unknown> | 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<string, unknown>[]
// Cap demos (speed + reliability): keep first 2
let demoCount = 0
sections = sections.map((sec) => {
const blocks = (sec.blocks as Record<string, unknown>[]).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<string, unknown> = {
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
}

View File

@@ -0,0 +1,210 @@
import { z } from 'zod'
import { INTENT_IDS } from '@/lib/interactive-demo/constants'
import { interactiveDemoV1Schema } from '@/lib/interactive-demo/schema'
import {
CALLOUT_KINDS,
INTERACTIVE_PAGE_CAPS,
INTERACTIVE_PAGE_SCHEMA_VERSION,
PAGE_BLOCK_TYPES,
SIM_CAPS,
} from './constants'
const intentSchema = z.enum(INTENT_IDS).optional()
const langSchema = z
.string()
.regex(/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/, 'Invalid BCP-47 language tag')
const chartPayloadSchema = z.object({
chartType: z.enum(['line', 'bar', 'area']),
series: z
.array(
z.object({
id: z.string().min(1),
label: z.string().optional(),
values: z.array(z.number()),
intent: intentSchema,
})
)
.min(1),
})
const proseBlock = z.object({
type: z.literal('prose'),
md: z.string().min(1),
})
const formulaBlock = z.object({
type: z.literal('formula'),
tex: z.string().min(1),
caption: z.string().optional(),
})
const calloutBlock = z.object({
type: z.literal('callout'),
kind: z.enum(CALLOUT_KINDS),
title: z.string().min(1),
md: z.string().min(1),
})
const demoBlock = z.object({
type: z.literal('demo'),
demo: interactiveDemoV1Schema,
caption: z.string().optional(),
})
const chartBlock = z.object({
type: z.literal('chart'),
payload: chartPayloadSchema,
caption: z.string().optional(),
})
const statsBlock = z.object({
type: z.literal('stats'),
items: z
.array(
z.object({
value: z.string().min(1),
label: z.string().min(1),
intent: intentSchema,
})
)
.min(INTERACTIVE_PAGE_CAPS.minStatsItems)
.max(INTERACTIVE_PAGE_CAPS.maxStatsItems),
})
const tableBlock = z.object({
type: z.literal('table'),
columns: z.array(z.string().min(1)).min(1),
rows: z.array(z.array(z.string())),
caption: z.string().optional(),
})
const imageBlock = z.object({
type: z.literal('image'),
src: z.string().min(1),
alt: z.string().min(1),
caption: z.string().optional(),
})
const simParamIdSchema = z
.string()
.regex(/^[A-Za-z_][A-Za-z0-9_]*$/, 'Invalid sim identifier')
const genericSimParamSchema = z.object({
id: simParamIdSchema,
symbol: z.string().min(1),
label: z.string().min(1),
min: z.number(),
max: z.number(),
step: z.number().positive(),
defaultValue: z.number(),
unit: z.string().optional(),
intent: intentSchema,
})
const genericSimComputedSchema = z.object({
id: simParamIdSchema,
symbol: z.string().min(1),
label: z.string().min(1),
expr: z.string().min(1).max(SIM_CAPS.maxExprChars),
unit: z.string().optional(),
intent: intentSchema,
})
const genericSimSchema = z.object({
simId: z.literal('generic-formula'),
title: z.string().min(1),
intro: z.string().optional(),
params: z.array(genericSimParamSchema).min(1).max(SIM_CAPS.maxParams),
computed: z.array(genericSimComputedSchema).min(1).max(SIM_CAPS.maxComputed),
visual: z.union([
z.object({ kind: z.literal('gauges') }),
z.object({ kind: z.literal('bars') }),
z.object({
kind: z.literal('curve'),
xParamId: simParamIdSchema,
expr: z.string().min(1),
xLabel: z.string().optional(),
yLabel: z.string().optional(),
}),
]),
disclaimer: z.string().optional(),
})
const catalogSimSchema = z.object({
simId: z
.string()
.min(1)
.refine((s) => s !== 'generic-formula', {
message: 'generic-formula must use the full inline schema',
}),
title: z.string().optional(),
preset: z.record(z.string(), z.number()).optional(),
disclaimer: z.string().optional(),
})
const simBlock = z.object({
type: z.literal('sim'),
sim: z.union([genericSimSchema, catalogSimSchema]),
caption: z.string().optional(),
})
export const pageBlockSchema = z.discriminatedUnion('type', [
proseBlock,
formulaBlock,
calloutBlock,
demoBlock,
chartBlock,
statsBlock,
tableBlock,
imageBlock,
simBlock,
])
const sectionSchema = z.object({
id: z.string().min(1),
title: z.string().min(1),
blocks: z
.array(pageBlockSchema)
.min(1)
.max(INTERACTIVE_PAGE_CAPS.maxBlocksPerSection),
})
const overviewSchema = z.object({
lead: z.string().min(1),
cards: z
.array(
z.object({
badge: z.string().min(1),
title: z.string().min(1),
body: z.string().min(1),
intent: intentSchema,
})
)
.min(INTERACTIVE_PAGE_CAPS.minOverviewCards)
.max(INTERACTIVE_PAGE_CAPS.maxOverviewCards),
})
export const pageSpecV1Schema = z.object({
schemaVersion: z.literal(INTERACTIVE_PAGE_SCHEMA_VERSION),
id: z.string().min(1),
lang: langSchema,
hero: z.object({
kicker: z.string().min(1),
title: z.string().min(1),
subtitle: z.string().optional(),
meta: z.string().optional(),
}),
overview: overviewSchema.optional(),
sections: z
.array(sectionSchema)
.min(1)
.max(INTERACTIVE_PAGE_CAPS.maxSections),
footer: z.string().optional(),
})
export type PageSpecV1Parsed = z.infer<typeof pageSpecV1Schema>
/** Re-export for callers. */
export { PAGE_BLOCK_TYPES }

View File

@@ -0,0 +1,355 @@
/**
* Safe math-expression evaluator for the generic-formula simulator.
* Tokenizer + recursive-descent parser — NO eval/Function, no object access,
* no loops. Identifiers resolve only against an explicit environment;
* functions come from a fixed allowlist.
*/
type Token =
| { t: 'num'; v: number }
| { t: 'id'; v: string }
| { t: 'op'; v: string }
| { t: 'lparen' }
| { t: 'rparen' }
| { t: 'comma' }
const CONSTANTS: Record<string, number> = {
pi: Math.PI,
e: Math.E,
}
type Fn = (...args: number[]) => number
const FUNCTIONS: Record<string, { fn: Fn; minArgs: number; maxArgs: number }> = {
sqrt: { fn: Math.sqrt, minArgs: 1, maxArgs: 1 },
abs: { fn: Math.abs, minArgs: 1, maxArgs: 1 },
exp: { fn: Math.exp, minArgs: 1, maxArgs: 1 },
ln: { fn: Math.log, minArgs: 1, maxArgs: 1 },
log: { fn: Math.log10, minArgs: 1, maxArgs: 1 },
round: { fn: Math.round, minArgs: 1, maxArgs: 1 },
floor: { fn: Math.floor, minArgs: 1, maxArgs: 1 },
ceil: { fn: Math.ceil, minArgs: 1, maxArgs: 1 },
min: { fn: Math.min, minArgs: 1, maxArgs: 8 },
max: { fn: Math.max, minArgs: 1, maxArgs: 8 },
}
const ID_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
export type SimExprError = { message: string }
type Ast =
| { k: 'num'; v: number }
| { k: 'id'; v: string }
| { k: 'call'; name: string; args: Ast[] }
| { k: 'un'; op: '-'; a: Ast }
| { k: 'bin'; op: string; a: Ast; b: Ast }
function tokenize(src: string): Token[] | SimExprError {
const tokens: Token[] = []
let i = 0
while (i < src.length) {
const ch = src[i]
if (ch === ' ' || ch === '\t' || ch === '\n') {
i++
continue
}
if (ch >= '0' && ch <= '9') {
let j = i
while (j < src.length && /[0-9.]/.test(src[j])) j++
const raw = src.slice(i, j)
const v = Number(raw)
if (!Number.isFinite(v)) return { message: `invalid number "${raw}"` }
tokens.push({ t: 'num', v })
i = j
continue
}
if (ch === '.' && src[i + 1] >= '0' && src[i + 1] <= '9') {
let j = i + 1
while (j < src.length && /[0-9]/.test(src[j])) j++
tokens.push({ t: 'num', v: Number(src.slice(i, j)) })
i = j
continue
}
if (/[A-Za-z_]/.test(ch)) {
let j = i
while (j < src.length && /[A-Za-z0-9_]/.test(src[j])) j++
tokens.push({ t: 'id', v: src.slice(i, j) })
i = j
continue
}
if ('+-*/^%'.includes(ch)) {
tokens.push({ t: 'op', v: ch })
i++
continue
}
if (ch === '(') {
tokens.push({ t: 'lparen' })
i++
continue
}
if (ch === ')') {
tokens.push({ t: 'rparen' })
i++
continue
}
if (ch === ',') {
tokens.push({ t: 'comma' })
i++
continue
}
return { message: `unexpected character "${ch}"` }
}
return tokens
}
class Parser {
private pos = 0
constructor(private tokens: Token[]) {}
private peek(): Token | undefined {
return this.tokens[this.pos]
}
private next(): Token | undefined {
return this.tokens[this.pos++]
}
private expectOp(): string | null {
const t = this.peek()
return t?.t === 'op' ? t.v : null
}
parseExpr(): Ast | SimExprError {
let left = this.parseTerm()
if ('message' in left) return left
for (;;) {
const op = this.expectOp()
if (op !== '+' && op !== '-') break
this.next()
const right = this.parseTerm()
if ('message' in right) return right
left = { k: 'bin', op, a: left, b: right }
}
return left
}
private parseTerm(): Ast | SimExprError {
let left = this.parseUnary()
if ('message' in left) return left
for (;;) {
const op = this.expectOp()
if (op !== '*' && op !== '/' && op !== '%') break
this.next()
const right = this.parseUnary()
if ('message' in right) return right
left = { k: 'bin', op, a: left, b: right }
}
return left
}
private parseUnary(): Ast | SimExprError {
if (this.expectOp() === '-') {
this.next()
const a = this.parseUnary()
if ('message' in a) return a
return { k: 'un', op: '-', a }
}
if (this.expectOp() === '+') {
this.next()
return this.parseUnary()
}
return this.parsePower()
}
private parsePower(): Ast | SimExprError {
const base = this.parseAtom()
if ('message' in base) return base
if (this.expectOp() === '^') {
this.next()
const exp = this.parseUnary() // right-assoc
if ('message' in exp) return exp
return { k: 'bin', op: '^', a: base, b: exp }
}
return base
}
private parseAtom(): Ast | SimExprError {
const t = this.next()
if (!t) return { message: 'unexpected end of expression' }
if (t.t === 'num') return { k: 'num', v: t.v }
if (t.t === 'lparen') {
const inner = this.parseExpr()
if ('message' in inner) return inner
const close = this.next()
if (close?.t !== 'rparen') return { message: 'missing closing parenthesis' }
return inner
}
if (t.t === 'id') {
if (this.peek()?.t === 'lparen') {
this.next() // consume (
const args: Ast[] = []
if (this.peek()?.t !== 'rparen') {
for (;;) {
const arg = this.parseExpr()
if ('message' in arg) return arg
args.push(arg)
if (this.peek()?.t === 'comma') {
this.next()
continue
}
break
}
}
const close = this.next()
if (close?.t !== 'rparen') return { message: 'missing closing parenthesis' }
return { k: 'call', name: t.v, args }
}
return { k: 'id', v: t.v }
}
return { message: `unexpected token "${'v' in t ? t.v : t.t}"` }
}
parseTop(): Ast | SimExprError {
const ast = this.parseExpr()
if ('message' in ast) return ast
if (this.pos < this.tokens.length) {
return { message: 'trailing tokens after expression' }
}
return ast
}
}
function evalAst(ast: Ast, env: Record<string, number>): number {
switch (ast.k) {
case 'num':
return ast.v
case 'id': {
if (ast.v in CONSTANTS) return CONSTANTS[ast.v]
const v = env[ast.v]
return typeof v === 'number' ? v : NaN
}
case 'un':
return -evalAst(ast.a, env)
case 'bin': {
const a = evalAst(ast.a, env)
const b = evalAst(ast.b, env)
switch (ast.op) {
case '+':
return a + b
case '-':
return a - b
case '*':
return a * b
case '/':
return b === 0 ? NaN : a / b
case '%':
return b === 0 ? NaN : a % b
case '^':
return Math.pow(a, b)
default:
return NaN
}
}
case 'call': {
const def = FUNCTIONS[ast.name]
if (!def) return NaN
const args = ast.args.map((a) => evalAst(a, env))
if (args.some((x) => Number.isNaN(x))) return NaN
return def.fn(...args)
}
}
}
function collectIds(ast: Ast, out: Set<string>): void {
switch (ast.k) {
case 'num':
return
case 'id':
out.add(ast.v)
return
case 'un':
collectIds(ast.a, out)
return
case 'bin':
collectIds(ast.a, out)
collectIds(ast.b, out)
return
case 'call':
ast.args.forEach((a) => collectIds(a, out))
return
}
}
export type ParsedSimExpr = {
/** Evaluate against an environment; NaN when uncomputable. */
evaluate(env: Record<string, number>): number
/** Identifiers used (params/computed refs), excluding constants. */
identifiers: string[]
}
/**
* Parse a safe math expression. Returns error message on syntax problems.
* Unknown function names are rejected at parse time.
*/
export function parseSimExpr(src: string): ParsedSimExpr | SimExprError {
const trimmed = src.trim()
if (!trimmed || trimmed.length > 200) {
return { message: 'expression empty or too long (max 200 chars)' }
}
const tokens = tokenize(trimmed)
if ('message' in tokens) return tokens
const ast = new Parser(tokens).parseTop()
if ('message' in ast) return ast
const ids = new Set<string>()
collectIds(ast, ids)
for (const id of ids) {
if (id in FUNCTIONS) {
return { message: `"${id}" is a function name — call it with (...)` }
}
}
// Validate function calls (unknown names, arity)
const checkCalls = (node: Ast): SimExprError | null => {
if (node.k === 'call') {
const def = FUNCTIONS[node.name]
if (!def) return { message: `unknown function "${node.name}"` }
if (node.args.length < def.minArgs || node.args.length > def.maxArgs) {
return { message: `function "${node.name}" expects ${def.minArgs}${def.maxArgs} args` }
}
for (const a of node.args) {
const err = checkCalls(a)
if (err) return err
}
} else if (node.k === 'un') {
return checkCalls(node.a)
} else if (node.k === 'bin') {
return checkCalls(node.a) ?? checkCalls(node.b)
}
return null
}
const callErr = checkCalls(ast)
if (callErr) return callErr
const identifiers = [...ids].filter((id) => !(id in CONSTANTS))
return {
evaluate: (env) => evalAst(ast, env),
identifiers,
}
}
/**
* Validation helper: parse + require every identifier ∈ allowedIds.
* Returns a list of issue messages (empty = OK).
*/
export function validateSimExprRefs(
src: string,
allowedIds: Set<string>
): string[] {
const parsed = parseSimExpr(src)
if ('message' in parsed) return [parsed.message]
return parsed.identifiers
.filter((id) => !allowedIds.has(id))
.map((id) => `unknown identifier "${id}"`)
}
/** Type guard helper for zod refinements. */
export function isValidSimParamId(id: string): boolean {
return ID_RE.test(id) && !(id in FUNCTIONS) && !(id in CONSTANTS)
}

View File

@@ -0,0 +1,153 @@
import type { ChartPayload, IntentId, InteractiveDemoV1 } from '@/lib/interactive-demo/types'
import type { CALLOUT_KINDS, PAGE_BLOCK_TYPES } from './constants'
export type { IntentId }
export type PageBlockType = (typeof PAGE_BLOCK_TYPES)[number]
export type CalloutKind = (typeof CALLOUT_KINDS)[number]
export type PageHero = {
kicker: string
title: string
subtitle?: string
meta?: string
}
export type OverviewCard = {
badge: string
title: string
body: string
intent?: IntentId
}
export type PageOverview = {
lead: string
cards: OverviewCard[]
}
export type ProseBlock = { type: 'prose'; md: string }
export type FormulaBlock = { type: 'formula'; tex: string; caption?: string }
export type CalloutBlock = {
type: 'callout'
kind: CalloutKind
title: string
md: string
}
export type DemoBlock = {
type: 'demo'
demo: InteractiveDemoV1
caption?: string
}
export type ChartBlock = {
type: 'chart'
payload: ChartPayload
caption?: string
}
export type StatsBlock = {
type: 'stats'
items: { value: string; label: string; intent?: IntentId }[]
}
export type TableBlock = {
type: 'table'
columns: string[]
rows: string[][]
caption?: string
}
export type ImageBlock = {
type: 'image'
src: string
alt: string
caption?: string
}
// ── Simulator block (plugin catalog + generic formula) ──────────────────────
/** Curated simulator from the plugin registry — AI only picks + presets. */
export type CatalogSimRef = {
simId: string
title?: string
preset?: Record<string, number>
disclaimer?: string
}
export type GenericSimParam = {
id: string
symbol: string
label: string
min: number
max: number
step: number
defaultValue: number
unit?: string
intent?: IntentId
}
export type GenericSimComputed = {
id: string
symbol: string
label: string
/** Safe math expression (see sim-eval) over params + previous computed. */
expr: string
unit?: string
intent?: IntentId
}
export type GenericSimVisual =
| { kind: 'gauges' }
| { kind: 'bars' }
| { kind: 'curve'; xParamId: string; expr: string; xLabel?: string; yLabel?: string }
/** Inline custom simulation — expressions validated by sim-eval (no eval). */
export type GenericFormulaSim = {
simId: 'generic-formula'
title: string
intro?: string
params: GenericSimParam[]
computed: GenericSimComputed[]
visual: GenericSimVisual
disclaimer?: string
}
export type SimRef = CatalogSimRef | GenericFormulaSim
export type SimBlock = {
type: 'sim'
sim: SimRef
caption?: string
}
export type PageBlock =
| ProseBlock
| FormulaBlock
| CalloutBlock
| DemoBlock
| ChartBlock
| StatsBlock
| TableBlock
| ImageBlock
| SimBlock
export type PageSection = {
id: string
title: string
blocks: PageBlock[]
}
export type PageSpecV1 = {
schemaVersion: 1
id: string
lang: string
hero: PageHero
overview?: PageOverview
sections: PageSection[]
footer?: string
}
export type PageValidationIssue = {
code: string
path: string
message: string
}
export type PageValidationResult =
| { ok: true; page: PageSpecV1 }
| { ok: false; issues: PageValidationIssue[] }

View File

@@ -0,0 +1,303 @@
import { FORBIDDEN_COLOR_RE } from '@/lib/interactive-demo/constants'
import { validateInteractiveDemo } from '@/lib/interactive-demo/validate'
import { getPlugin } from '@/lib/simulators'
import {
INTERACTIVE_PAGE_CAPS,
isPageHumanStringKey,
} from './constants'
import { pageSpecV1Schema } from './schema'
import { validateSimExprRefs } from './sim-eval'
import type {
PageBlock,
PageSpecV1,
PageValidationIssue,
PageValidationResult,
} from './types'
function issue(code: string, path: string, message: string): PageValidationIssue {
return { code, path, message }
}
function scanForbiddenColors(
value: unknown,
path: string,
out: PageValidationIssue[]
): void {
if (typeof value === 'string') {
if (FORBIDDEN_COLOR_RE.test(value)) {
out.push(
issue(
'forbidden_color',
path,
'Free color literals (hex/rgb) are forbidden — use intent enums'
)
)
}
return
}
if (Array.isArray(value)) {
value.forEach((v, i) => scanForbiddenColors(v, `${path}[${i}]`, out))
return
}
if (value && typeof value === 'object') {
for (const [k, v] of Object.entries(value)) {
if (isPageHumanStringKey(k)) continue
// Nested demos are validated separately (incl. their own color scan)
if (k === 'demo') continue
scanForbiddenColors(v, path ? `${path}.${k}` : k, out)
}
}
}
function countDemos(page: PageSpecV1): number {
let n = 0
for (const s of page.sections) {
for (const b of s.blocks) {
if (b.type === 'demo') n += 1
}
}
return n
}
function countSims(page: PageSpecV1): number {
let n = 0
for (const s of page.sections) {
for (const b of s.blocks) {
if (b.type === 'sim') n += 1
}
}
return n
}
/** Simulator block: catalog ref integrity / generic exprs safety. */
function validateSim(
block: Extract<PageBlock, { type: 'sim' }>,
path: string,
out: PageValidationIssue[]
): void {
const sim = block.sim
if (sim.simId === 'generic-formula') {
const generic = sim as Extract<typeof sim, { simId: 'generic-formula' }>
const paramIds = new Set(generic.params.map((p) => p.id))
for (const p of generic.params) {
if (p.min >= p.max) {
out.push(issue('sim_param_range', `${path}.params`, `Param "${p.id}": min >= max`))
}
if (p.defaultValue < p.min || p.defaultValue > p.max) {
out.push(
issue('sim_param_default', `${path}.params`, `Param "${p.id}": default outside [min, max]`)
)
}
}
const allowed = new Set<string>(paramIds)
for (const c of generic.computed) {
const errs = validateSimExprRefs(c.expr, allowed)
for (const e of errs) {
out.push(issue('sim_expr', `${path}.computed.${c.id}`, e))
}
if (errs.length === 0) allowed.add(c.id) // computed may chain
}
if (generic.visual.kind === 'curve') {
if (!paramIds.has(generic.visual.xParamId)) {
out.push(
issue('sim_curve_param', `${path}.visual.xParamId`, `Unknown param "${generic.visual.xParamId}"`)
)
}
const errs = validateSimExprRefs(generic.visual.expr, allowed)
for (const e of errs) {
out.push(issue('sim_expr', `${path}.visual.expr`, e))
}
}
return
}
// Catalog plugin: must exist. Sims: preset within bounds + compute finite.
const catalog = sim as Extract<typeof sim, { simId: string }> & {
preset?: Record<string, number>
}
const plugin = getPlugin(catalog.simId)
if (!plugin) {
out.push(issue('unknown_simulator', `${path}.simId`, `Unknown simulator "${catalog.simId}"`))
return
}
if (plugin.family === 'anim') {
if (!plugin.beats.length) {
out.push(issue('sim_anim_empty', `${path}`, 'Animation plugin has no beats'))
}
return
}
const env: Record<string, number> = {}
for (const p of plugin.params) env[p.id] = p.defaultValue
if (catalog.preset) {
for (const [k, v] of Object.entries(catalog.preset)) {
const def = plugin.params.find((p) => p.id === k)
if (!def) {
out.push(issue('sim_preset_key', `${path}.preset.${k}`, 'Not a parameter of this simulator'))
continue
}
if (v < def.min || v > def.max) {
out.push(
issue('sim_preset_range', `${path}.preset.${k}`, `Value ${v} outside [${def.min}, ${def.max}]`)
)
continue
}
env[k] = v
}
}
try {
const result = plugin.compute(env)
for (const o of plugin.outputs) {
if (!Number.isFinite(result[o.id])) {
out.push(
issue('sim_compute', `${path}`, `Output "${o.id}" not finite at preset values`)
)
}
}
} catch {
out.push(issue('sim_compute', `${path}`, 'Simulator compute() threw at preset values'))
}
}
function validateTable(
block: Extract<PageBlock, { type: 'table' }>,
path: string,
out: PageValidationIssue[]
): void {
const cols = block.columns.length
for (const [ri, row] of block.rows.entries()) {
if (row.length !== cols) {
out.push(
issue(
'table_shape',
`${path}.rows[${ri}]`,
`Expected ${cols} cells, got ${row.length}`
)
)
}
}
}
function validateImageSrc(
block: Extract<PageBlock, { type: 'image' }>,
path: string,
out: PageValidationIssue[]
): void {
const src = block.src.trim()
// Allow relative /uploads, https, and data:image — reject javascript: etc.
if (/^\s*javascript:/i.test(src) || /^\s*data:text\/html/i.test(src)) {
out.push(
issue('unsafe_image_src', `${path}.src`, 'Unsafe image src rejected')
)
}
}
function semanticValidate(page: PageSpecV1): PageValidationIssue[] {
const out: PageValidationIssue[] = []
const jsonBytes = new TextEncoder().encode(JSON.stringify(page)).length
if (jsonBytes > INTERACTIVE_PAGE_CAPS.maxJsonBytes) {
out.push(
issue(
'json_too_large',
'',
`JSON exceeds ${INTERACTIVE_PAGE_CAPS.maxJsonBytes} bytes (${jsonBytes})`
)
)
}
const demoCount = countDemos(page)
if (demoCount > INTERACTIVE_PAGE_CAPS.maxDemosPerPage) {
out.push(
issue(
'too_many_demos',
'sections',
`At most ${INTERACTIVE_PAGE_CAPS.maxDemosPerPage} demos per page (found ${demoCount})`
)
)
}
const simCount = countSims(page)
if (simCount > INTERACTIVE_PAGE_CAPS.maxSimsPerPage) {
out.push(
issue(
'too_many_sims',
'sections',
`At most ${INTERACTIVE_PAGE_CAPS.maxSimsPerPage} sims per page (found ${simCount})`
)
)
}
scanForbiddenColors(page, '', out)
const sectionIds = new Set<string>()
for (const [si, section] of page.sections.entries()) {
const sPath = `sections[${si}]`
if (sectionIds.has(section.id)) {
out.push(
issue(
'duplicate_section_id',
`${sPath}.id`,
`Duplicate section id "${section.id}"`
)
)
}
sectionIds.add(section.id)
if (!/^s\d+$/.test(section.id)) {
out.push(
issue(
'section_id_format',
`${sPath}.id`,
`Section id should match /^s\\d+$/ (got "${section.id}")`
)
)
}
for (const [bi, block] of section.blocks.entries()) {
const bPath = `${sPath}.blocks[${bi}]`
// Unknown types already hard-rejected by Zod discriminatedUnion.
if (block.type === 'table') validateTable(block, bPath, out)
if (block.type === 'image') validateImageSrc(block, bPath, out)
if (block.type === 'sim') validateSim(block, bPath, out)
if (block.type === 'demo') {
const demoResult = validateInteractiveDemo(block.demo)
if (!demoResult.ok) {
for (const iss of demoResult.issues) {
out.push({
code: `demo_${iss.code}`,
path: `${bPath}.demo${iss.path ? '.' + iss.path : ''}`,
message: iss.message,
})
}
}
}
}
}
return out
}
/**
* Validate a PageSpecV1 document.
* Structural (Zod allowlist) then semantic (caps, colors, demos, tables).
*/
export function validateInteractivePage(input: unknown): PageValidationResult {
const parsed = pageSpecV1Schema.safeParse(input)
if (!parsed.success) {
const issues: PageValidationIssue[] = parsed.error.issues.map((e) => ({
code: e.code,
path: e.path.join('.'),
message: e.message,
}))
return { ok: false, issues }
}
const page = parsed.data as PageSpecV1
const semantic = semanticValidate(page)
if (semantic.length > 0) {
return { ok: false, issues: semantic }
}
return { ok: true, page }
}

View File

@@ -25,6 +25,8 @@ export const FALLBACK_TIER_LIMITS: Record<
ai_flashcard: 5,
voice_transcribe: 20,
publish_enhance: 2,
interactive_demo: 5,
interactive_page: 2,
},
PRO: {
semantic_search: 200,
@@ -42,6 +44,8 @@ export const FALLBACK_TIER_LIMITS: Record<
ai_flashcard: 100,
voice_transcribe: 500,
publish_enhance: 15,
interactive_demo: 40,
interactive_page: 20,
},
BUSINESS: {
semantic_search: 1000,
@@ -59,6 +63,8 @@ export const FALLBACK_TIER_LIMITS: Record<
ai_flashcard: 'unlimited',
voice_transcribe: 'unlimited',
publish_enhance: 100,
interactive_demo: 200,
interactive_page: 80,
},
ENTERPRISE: {
semantic_search: 'unlimited',
@@ -75,6 +81,8 @@ export const FALLBACK_TIER_LIMITS: Record<
ai_flashcard: 'unlimited',
voice_transcribe: 'unlimited',
publish_enhance: 'unlimited',
interactive_demo: 'unlimited',
interactive_page: 'unlimited',
},
};

View File

@@ -1,4 +1,4 @@
export const PUBLISH_TEMPLATES = ['magazine', 'brief', 'essay'] as const
export const PUBLISH_TEMPLATES = ['magazine', 'brief', 'essay', 'interactive-page'] as const
export type PublishTemplateId = (typeof PUBLISH_TEMPLATES)[number]
/** Métadonnées éditoriales IA — corps = HTML source original. */
@@ -19,3 +19,9 @@ export interface PublishRewriteSpec {
export function isPublishTemplateId(value: string): value is PublishTemplateId {
return (PUBLISH_TEMPLATES as readonly string[]).includes(value)
}
export function isInteractivePageTemplate(
value: string | null | undefined
): boolean {
return value === 'interactive-page'
}

View File

@@ -13,6 +13,8 @@ export const VALID_FEATURES = [
'ai_flashcard',
'voice_transcribe',
'publish_enhance',
'interactive_demo',
'interactive_page',
] as const;
export type FeatureName = (typeof VALID_FEATURES)[number];

View File

@@ -0,0 +1,59 @@
# Simulateurs interactifs (plugins)
Bibliothèque de simulateurs pédagogiques pour les **pages interactives** (`/p/{slug}`).
L'IA ne code jamais une simulation : elle **choisit** un simulateur de cette liste et le
**configure** (`preset` = valeurs de la note), ou utilise `generic-formula` si aucun ne
correspond au sujet.
## Liste des simulateurs disponibles
| `simId` | Famille | Sujet | Interaction |
|---|---|---|---|
| `carnot-cycle` | `sim` | Machine frigorifique / PAC / moteur de Carnot (2ᵉ principe) | Curseurs T_c, T_h, Q_c → COP, η, W min, Q_h en direct |
| `carnot-cycle-anim` | `anim` | Cycle de Carnot animé (piston + diagramme PV live, 5 étapes) | Play / Pause / Step / Reset / vitesse + narration |
| `generic-formula` | `sim` | (générique) toute relation chiffrée y = f(paramètres) | Curseurs définis par l'IA, expressions sûres (aucun `eval`) |
Deux familles de plugins : `sim` (manipulation de paramètres, calcul en direct) et
`anim` (scène animée codée en dur, pilotée par le player Play/Step — narration par
battements). Les deux sont choisis par l'IA via le même bloc `{ "type": "sim" }`.
## Comment l'IA les utilise
1. Le prompt de génération de section injecte `catalogForPrompt(lang)` (registry.ts) —
id + résumé + mots-clés + bornes de chaque simulateur.
2. Si le contenu de la note correspond (`keywords`), l'IA émet
`{ "type": "sim", "sim": { "simId": "carnot-cycle", "preset": { "t_hot": 300 } } }`.
3. `validateInteractivePage` vérifie : `simId` connu, preset dans les bornes,
`compute(preset)` fini — sinon rejet (renvoyé au LLM ou fallback).
## Ajouter un plugin (5 étapes)
Pour un **`sim`** (curseurs) :
1. **`lib/simulators/<id>.ts`** — implémenter `SimulatorPlugin` (`family: 'sim'`) :
`id`, `title`/`summary` fr+en (le summary sert au matching IA), `keywords`,
`params` (curseurs : bornes, pas, défaut, unité, intent), `outputs` (symbole KaTeX,
unité), et `compute(env)` **pur et déterministe**.
2. **`lib/simulators/index.ts`** — ajouter au `REGISTRY`.
3. **`components/simulators/<id>-view.tsx`** — composant sur mesure
(props : `preset`, `title`, `disclaimer`, `lang`). Réutiliser
`SimSlider` / `SimOutputCard` / `SimHeading` / `SimKaTeX` de `sim-controls.tsx`.
4. **`components/simulators/index.ts`** — enregistrer dans `SIMULATOR_VIEWS`.
5. Vérifier : `compute` fini aux valeurs par défaut, `npx tsc --noEmit`, tester sur
`/dev/interactive-page`.
Pour une **`anim`** (scène animée Play/Step) :
1. **`lib/simulators/<id>.ts`** — implémenter `AnimPlugin` (`family: 'anim'`) :
`id`, `title`/`summary` fr+en, `keywords`, `disclaimer?`, `beats` (narration
fr+en par étape — KaTeX inline `$…$` dans `speak`).
2. **`lib/simulators/index.ts`** — ajouter au `REGISTRY`.
3. **`components/simulators/<id>-anim-view.tsx`** — scène SVG/React pilotée par
`step` (props : `step`, `lang`). Transitions CSS sur `transform`/`opacity`
uniquement. Le chrome (Play/Pause/Step/Reset/vitesse + panneau de narration +
clavier Espace/←/→/R) est fourni par `AnimPlayerShell` — ne pas le réécrire.
4. **`components/simulators/index.ts`** — enregistrer dans `ANIM_VIEWS`.
5. Vérifier : chaque `step` rend un état cohérent (y compris état final sans JS),
`npx tsc --noEmit`, tester sur `/dev/interactive-page`.
Règles : pas de couleurs en dur (intents / tokens `--pp-*`), labels fr+en dans le
plugin, tout calcul côté `compute` ou scène codée (jamais de logique LLM), SSR =
état lisible sans JS.

View File

@@ -0,0 +1,76 @@
import type { AnimPlugin } from './types'
/**
* Carnot cycle — animated piston + live P-V diagram, 5 beats.
* Scene rendered by components/simulators/carnot-cycle-anim-view.tsx.
*/
export const carnotCycleAnim: AnimPlugin = {
family: 'anim',
id: 'carnot-cycle-anim',
title: {
fr: 'Le cycle de Carnot en mouvement',
en: 'The Carnot cycle in motion',
},
summary: {
fr: 'Animation du cycle de Carnot : piston, détentes/compressions isothermes et adiabatiques, diagramme P-V tracé en direct, échanges Q et W. 2ᵉ principe, machine thermique, réfrigérateur.',
en: 'Animated Carnot cycle: piston, isothermal and adiabatic expansion/compression, live P-V diagram, Q and W exchanges. Second law, heat engine, refrigerator.',
},
keywords: [
'carnot',
'thermodynamique',
'thermodynamics',
'cycle',
'piston',
'isotherme',
'adiabatique',
'isothermal',
'adiabatic',
'entropie',
'entropy',
'machine thermique',
'heat engine',
'deuxième principe',
'second law',
],
disclaimer: {
fr: 'Schéma pédagogique — grandeurs illustratives, gaz parfait.',
en: 'Teaching schematic — illustrative values, ideal gas.',
},
beats: [
{
id: 'b1',
speak: {
fr: '**Détente isotherme** à $T_h$ : le gaz pousse le piston, la chaleur $Q_h$ entre depuis la source chaude.',
en: '**Isothermal expansion** at $T_h$: the gas pushes the piston, heat $Q_h$ flows in from the hot reservoir.',
},
},
{
id: 'b2',
speak: {
fr: '**Détente adiabatique** : isolé, le gaz continue de se détendre et se refroidit de $T_h$ à $T_c$.',
en: '**Adiabatic expansion**: insulated, the gas keeps expanding and cools from $T_h$ down to $T_c$.',
},
},
{
id: 'b3',
speak: {
fr: '**Compression isotherme** à $T_c$ : on travaille sur le gaz, la chaleur $Q_c$ sort vers la source froide.',
en: '**Isothermal compression** at $T_c$: work is done on the gas, heat $Q_c$ flows out to the cold reservoir.',
},
},
{
id: 'b4',
speak: {
fr: '**Compression adiabatique** : le gaz remonte de $T_c$ à $T_h$ — le cycle se referme.',
en: '**Adiabatic compression**: the gas warms back from $T_c$ to $T_h$ — the cycle closes.',
},
},
{
id: 'b5',
speak: {
fr: "Le **travail net** $W = Q_h - Q_c$ est l'aire du cycle sur le diagramme $P$$V$. Le rendement $\eta = 1 - T_c/T_h$ est le maximum permis par le 2ᵉ principe.",
en: '**Net work** $W = Q_h - Q_c$ is the cycle area on the $P$$V$ diagram. Efficiency $\eta = 1 - T_c/T_h$ is the second-law maximum.',
},
},
],
}

View File

@@ -0,0 +1,254 @@
import type { SimulatorPlugin } from './types'
/**
* Ideal Carnot machine between two reservoirs (2nd law).
*
* Physics (absolute temperatures only):
* - Refrigerator: COP_R = Tc/(ThTc), W_min = Qc/COP_R, Qh = Qc+W
* - Heat pump: COP_HP = Th/(ThTc) = COP_R+1, W_min = Qh/COP_HP
* - Engine: η = 1Tc/Th, W_out = η·Qh, Qc = QhW
*
* `q_cold` is the primary load magnitude (kJ or W — same number; unit is UI-only).
* For fridge it is Qc; the view remaps for PAC / engine. Temperatures always Kelvin.
*
* Refs: COP_R Carnot = Tc/(ThTc); 1st law Qh=Qc+W; energy↔power interchangeable
* if all rates use the same time basis (see standard thermo textbooks / Carnot fridge calculators).
*/
export const carnotCycleSimulator: SimulatorPlugin = {
family: 'sim',
id: 'carnot-cycle',
title: {
fr: 'Machine de Carnot (frigo / PAC / moteur)',
en: 'Carnot machine (fridge / heat pump / engine)',
},
summary: {
fr: 'Limites de Carnot entre deux sources : COP frigo Tc/(ThTc), COP pompe à chaleur Th/(ThTc), rendement moteur η=1Tc/Th, travail ou puissance minimal(e). 1er et 2e principes.',
en: 'Carnot limits between two reservoirs: fridge COP Tc/(ThTc), heat-pump COP Th/(ThTc), engine η=1Tc/Th, minimum work or power. 1st and 2nd laws.',
},
keywords: [
'carnot',
'thermodynamique',
'thermodynamics',
'cop',
'réfrigérateur',
'frigo',
'refrigerator',
'pompe à chaleur',
'heat pump',
'moteur',
'engine',
'rendement',
'efficiency',
'watt',
'puissance',
'power',
'deuxième principe',
'second law',
],
params: [
{
id: 't_cold',
symbol: 'T_c',
label: { fr: 'Source froide', en: 'Cold reservoir' },
min: 200,
max: 320,
step: 1,
defaultValue: 260,
unit: 'K',
intent: 'cache',
},
{
id: 't_hot',
symbol: 'T_h',
label: { fr: 'Source chaude', en: 'Hot reservoir' },
min: 273,
max: 400,
step: 1,
defaultValue: 300,
unit: 'K',
intent: 'warning',
},
{
id: 'q_cold',
symbol: 'Q_c',
label: { fr: 'Charge (Qc frigo)', en: 'Load (fridge Qc)' },
min: 10,
max: 500,
step: 5,
defaultValue: 100,
unit: 'kJ',
intent: 'flow',
},
],
outputs: [
{
id: 'cop_fridge',
symbol: '\\mathrm{COP}_{R}',
label: { fr: 'COP réfrigérateur', en: 'Fridge COP' },
intent: 'output',
digits: 2,
},
{
id: 'cop_hp',
symbol: '\\mathrm{COP}_{HP}',
label: { fr: 'COP pompe à chaleur', en: 'Heat-pump COP' },
intent: 'output',
digits: 2,
},
{
id: 'eta',
symbol: '\\eta',
label: { fr: 'Rendement moteur', en: 'Engine efficiency' },
unit: '%',
intent: 'highlight',
digits: 1,
},
{
id: 'w_min',
symbol: 'W',
label: { fr: 'Travail (énergie)', en: 'Work (energy)' },
unit: 'kJ',
intent: 'compute',
digits: 1,
},
{
id: 'q_hot',
symbol: 'Q_h',
label: { fr: 'Chaleur côté chaud', en: 'Hot-side heat' },
unit: 'kJ',
intent: 'flow',
digits: 1,
},
],
compute(env) {
const tc = env.t_cold
const th = env.t_hot
const qc = env.q_cold
if (!(th > tc) || !(qc > 0)) {
return {
cop_fridge: NaN,
cop_hp: NaN,
eta: NaN,
w_min: NaN,
q_hot: NaN,
}
}
const copFridge = tc / (th - tc)
const copHp = th / (th - tc)
const eta = 1 - tc / th
const wMin = qc / copFridge
return {
cop_fridge: copFridge,
cop_hp: copHp,
eta: eta * 100,
w_min: wMin,
q_hot: qc + wMin,
}
},
}
/** Operating mode for the interactive view (UI-only; physics shared). */
export type CarnotMode = 'fridge' | 'heat_pump' | 'engine'
/** Energy (kJ) vs power (W) — same ratios; only unit labels change. */
export type CarnotQuantity = 'energy' | 'power'
export type CarnotPhysics = {
ok: boolean
tc: number
th: number
copR: number
copHP: number
eta: number
/** Heat exchanged with cold reservoir (magnitude > 0). */
qc: number
/** Heat exchanged with hot reservoir (magnitude > 0). */
qh: number
/** Work magnitude > 0 (input for fridge/PAC, output for engine). */
w: number
/** Reversible check: Qc/Tc ≈ Qh/Th */
entropyOk: boolean
}
/**
* Resolve magnitudes for the selected mode.
* `load` is the primary useful quantity:
* - fridge: Qc extracted from cold
* - heat_pump: Qh delivered to hot
* - engine: Qh absorbed from hot
*/
export function resolveCarnotPhysics(
tc: number,
th: number,
load: number,
mode: CarnotMode
): CarnotPhysics {
if (!(th > tc) || !(load > 0) || !Number.isFinite(tc) || !Number.isFinite(th)) {
return {
ok: false,
tc,
th,
copR: NaN,
copHP: NaN,
eta: NaN,
qc: NaN,
qh: NaN,
w: NaN,
entropyOk: false,
}
}
const copR = tc / (th - tc)
const copHP = th / (th - tc)
const eta = 1 - tc / th
let qc: number
let qh: number
let w: number
if (mode === 'fridge') {
qc = load
w = qc / copR
qh = qc + w
} else if (mode === 'heat_pump') {
qh = load
w = qh / copHP
qc = qh - w
} else {
qh = load
w = eta * qh
qc = qh - w
}
const ratioC = qc / tc
const ratioH = qh / th
const entropyOk =
Number.isFinite(ratioC) &&
Number.isFinite(ratioH) &&
Math.abs(ratioC - ratioH) / Math.max(ratioC, ratioH, 1e-9) < 1e-6
return { ok: true, tc, th, copR, copHP, eta, qc, qh, w, entropyOk }
}
/** Convert fridge-stored Qc load ↔ display load for other modes (fixture-compatible). */
export function fridgeLoadFromModeLoad(
tc: number,
th: number,
modeLoad: number,
mode: CarnotMode
): number {
if (!(th > tc) || !(modeLoad > 0)) return modeLoad
if (mode === 'fridge') return modeLoad
if (mode === 'heat_pump') return modeLoad * (tc / th) // Qc = Qh · Tc/Th
return modeLoad * (tc / th) // engine: Qc = Qh · (1η) = Qh · Tc/Th
}
export function modeLoadFromFridgeLoad(
tc: number,
th: number,
fridgeQc: number,
mode: CarnotMode
): number {
if (!(th > tc) || !(fridgeQc > 0)) return fridgeQc
if (mode === 'fridge') return fridgeQc
if (mode === 'heat_pump') return fridgeQc * (th / tc) // Qh = Qc · Th/Tc
return fridgeQc * (th / tc) // engine Qh = Qc / (1η) = Qc · Th/Tc
}

View File

@@ -0,0 +1,65 @@
import type { IntentId } from '@/lib/interactive-demo/types'
import { carnotCycleSimulator } from './carnot-cycle'
import { carnotCycleAnim } from './carnot-cycle-anim'
import { tsDiagramAnim } from './ts-diagram'
import type { AnimPlugin, AnyPlugin, SimulatorPlugin } from './types'
export const GENERIC_SIM_ID = 'generic-formula' as const
const REGISTRY: Record<string, AnyPlugin> = {
[carnotCycleSimulator.id]: carnotCycleSimulator,
[carnotCycleAnim.id]: carnotCycleAnim,
[tsDiagramAnim.id]: tsDiagramAnim,
}
export function getPlugin(id: string): AnyPlugin | null {
return REGISTRY[id] ?? null
}
export function getSimulator(id: string): SimulatorPlugin | null {
const p = REGISTRY[id]
return p?.family === 'sim' ? p : null
}
export function getAnimPlugin(id: string): AnimPlugin | null {
const p = REGISTRY[id]
return p?.family === 'anim' ? p : null
}
export function isCatalogSimId(id: string): boolean {
return id !== GENERIC_SIM_ID && id in REGISTRY
}
export function listSimulators(): AnyPlugin[] {
return Object.values(REGISTRY)
}
/**
* Compact catalog injected into the section-generation prompt so the LLM
* can pick a curated plugin (sim or anim) and bind note values into a preset.
*/
export function catalogForPrompt(lang: string): string {
const fr = lang.startsWith('fr')
const catalog = listSimulators().map((plugin) => ({
simId: plugin.id,
family: plugin.family,
summary: fr ? plugin.summary.fr : plugin.summary.en,
keywords: plugin.keywords.slice(0, 10),
...(plugin.family === 'sim'
? {
params: plugin.params.map((p) => ({
id: p.id,
symbol: p.symbol,
range: [p.min, p.max],
default: p.defaultValue,
unit: p.unit,
})),
outputs: plugin.outputs.map((o) => o.symbol),
}
: {}),
}))
return JSON.stringify(catalog, null, 1)
}
export type { SimulatorPlugin, AnimPlugin, AnyPlugin, AnimBeat, SimI18n, SimParamDef, SimOutputDef } from './types'
export type { IntentId }

View File

@@ -0,0 +1,74 @@
import type { AnimPlugin } from './types'
/**
* Ts diagram of the Carnot cycle — the canonical 2nd-law diagram:
* isotherms are horizontal, adiabatics vertical, heat = area.
* Same 5 phases as carnot-cycle-anim (piston) so both tell one story.
*/
export const tsDiagramAnim: AnimPlugin = {
family: 'anim',
id: 'ts-diagram',
title: {
fr: 'Le cycle de Carnot sur le diagramme Ts',
en: 'The Carnot cycle on the Ts diagram',
},
summary: {
fr: 'Diagramme températureentropie (Ts) du cycle de Carnot : isothermes horizontales, adiabatiques verticales, aires = chaleurs Q_h et Q_c, aire du cycle = travail W. 2ᵉ principe, entropie, rendement.',
en: 'Temperatureentropy (Ts) diagram of the Carnot cycle: horizontal isotherms, vertical adiabatics, areas = heats Q_h and Q_c, cycle area = work W. Second law, entropy, efficiency.',
},
keywords: [
'carnot',
'entropie',
'entropy',
'diagramme t-s',
't-s diagram',
'thermodynamique',
'thermodynamics',
'deuxième principe',
'second law',
'rendement',
'efficiency',
'cycle',
],
disclaimer: {
fr: 'Diagramme pédagogique — grandeurs illustratives.',
en: 'Teaching diagram — illustrative values.',
},
beats: [
{
id: 'b1',
speak: {
fr: '**Détente isotherme** à $T_h$ : lentropie croît, la chaleur $Q_h = T_h \\Delta s$ est laire sous lisotherme.',
en: '**Isothermal expansion** at $T_h$: entropy grows, heat $Q_h = T_h \\Delta s$ is the area under the isotherm.',
},
},
{
id: 'b2',
speak: {
fr: '**Détente adiabatique** : verticale — lentropie est constante, la température chute de $T_h$ à $T_c$.',
en: '**Adiabatic expansion**: vertical line — entropy is constant, temperature drops from $T_h$ to $T_c$.',
},
},
{
id: 'b3',
speak: {
fr: '**Compression isotherme** à $T_c$ : lentropie décroît, la chaleur $Q_c = T_c \\Delta s$ est rejetée — laire bleue.',
en: '**Isothermal compression** at $T_c$: entropy decreases, heat $Q_c = T_c \\Delta s$ is rejected — the blue area.',
},
},
{
id: 'b4',
speak: {
fr: '**Compression adiabatique** : remontée verticale de $T_c$ à $T_h$ — le rectangle se referme.',
en: '**Adiabatic compression**: vertical climb from $T_c$ back to $T_h$ — the rectangle closes.',
},
},
{
id: 'b5',
speak: {
fr: 'Le **travail net** $W = (T_h - T_c)\\Delta s$ est laire du rectangle. Rapport des aires = rendement $\\eta = 1 - T_c/T_h$ — le maximum du 2ᵉ principe.',
en: '**Net work** $W = (T_h - T_c)\\Delta s$ is the rectangle area. Area ratio = efficiency $\\eta = 1 - T_c/T_h$ — the second-law maximum.',
},
},
],
}

View File

@@ -0,0 +1,72 @@
import type { IntentId } from '@/lib/interactive-demo/types'
/** Bilingual label — page content follows the note's language, not the UI locale. */
export type SimI18n = { fr: string; en: string }
export type SimParamDef = {
id: string
/** KaTeX symbol (without $…$), e.g. "T_c" */
symbol: string
label: SimI18n
min: number
max: number
step: number
defaultValue: number
unit?: string
intent?: IntentId
}
export type SimOutputDef = {
id: string
/** KaTeX symbol (without $…$), e.g. "\\mathrm{COP}" */
symbol: string
label: SimI18n
unit?: string
intent?: IntentId
/** Fraction digits for display (default 2). */
digits?: number
}
/**
* A curated interactive simulator plugin (catalog).
* The AI never writes simulation code — it picks a plugin and a preset.
* `compute` must be pure and deterministic; it runs client-side on every
* slider move and once server-side at validation time.
*/
export type SimulatorPlugin = {
family: 'sim'
id: string
title: SimI18n
/** Shown to the LLM for content matching. */
summary: SimI18n
keywords: string[]
params: SimParamDef[]
outputs: SimOutputDef[]
compute(env: Record<string, number>): Record<string, number>
}
// ── Animated pedagogical scenes (Play/Step narration over a coded scene) ────
export type AnimBeat = {
id: string
speak: SimI18n
}
/**
* A curated ANIMATION plugin: a hand-coded parametric scene (like the demos
* on distill.pub / the Kimi AttnRes page) driven beat-by-beat by the shared
* player chrome. The view component receives `stepIndex` and renders the
* scene state — transitions are CSS (transform/opacity) only.
*/
export type AnimPlugin = {
family: 'anim'
id: string
title: SimI18n
summary: SimI18n
keywords: string[]
disclaimer?: SimI18n
/** Narration beats; step N of the player = beat N. */
beats: AnimBeat[]
}
export type AnyPlugin = SimulatorPlugin | AnimPlugin