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:
@@ -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(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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 4–6 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user