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