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 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.", "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, 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 { 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, } }