Files
Momento/memento-note/lib/ai/services/interactive-demo-generate.service.ts
Antigravity 69c99e4f4f 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>
2026-07-24 17:51:43 +00:00

292 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
}
}