/** * 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(/ /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 } function assetsBlock(assets: ReturnType): 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 export type PagePlan = z.infer 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 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)} } ], // 2–4 key concepts, small-caps badges (ex. PROBLEM / APPROACH / RESULT) "sections": [ { "title": string, "goal": string, "demoKind": ${JSON.stringify(DEMO_KINDS)}, "demoGoal?": string } ] // 2–5 } COUVERTURE (règle n°1): - The page covers the CORE of the source: its 2–5 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 { 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"): - 1–2 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 blocks. SCHEMA: { "title": string, "blocks": [ Block, ... ] // 2–6 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 }] } // 2–5, 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": "", "title?": string, "preset?": { "": 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?" }] (1–4), "computed": [{ "id", "symbol", "label", "expr", "unit?", "intent?" }] (1–6), "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.", "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, 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). 2–6 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) } : 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 ): 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 { 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 } }