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