import type { AIProvider } from '@/lib/ai/types' import { extractSourceAssets } from '@/lib/ai/services/slide-source-assets' import { validateInteractiveDemo, type InteractiveDemoV1, } from '@/lib/interactive-demo' import { validateInteractivePage, type PageSpecV1, type PageValidationIssue, } from '@/lib/interactive-page' import { normalizeInteractivePageCandidate } from '@/lib/interactive-page/normalize' 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 slugId(title: string): string { const s = title .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, '') .slice(0, 40) return s ? `page.${s}` : 'page.generated' } function firstSentence(text: string, max = 100): string { const s = text.split(/[.!?。]/)[0]?.trim() || text.trim() return s.slice(0, max) || 'Page interactive' } function chunkSentences(text: string): string[] { return text .split(/(?<=[.!?。])\s+/) .map((s) => s.trim()) .filter((s) => s.length > 20) } const INTENT_CYCLE = ['compute', 'flow', 'output', 'cache'] as const /** * Instant Play/Step demo from note vocabulary — no LLM. * 3–4 nodes + spotlight steps; formulas in speak when available. */ export function buildDeterministicDemo( content: string, lang: string, assets: ReturnType ): InteractiveDemoV1 | null { const fr = lang.startsWith('fr') const plain = stripToPlain(content) const sentences = [ ...assets.keySentences, ...chunkSentences(plain), ].filter((s, i, a) => a.indexOf(s) === i) // Prefer short phrase labels from key sentences — never raw formula fragments const labels: string[] = [] for (const s of sentences.slice(0, 8)) { const words = s .replace(/\$[^$]*\$/g, ' ') .replace(/[\\{}]/g, ' ') .split(/\s+/) .filter(Boolean) .slice(0, 4) .join(' ') .trim() if (words.length >= 6 && words.length <= 40 && !labels.includes(words)) { labels.push(words) } if (labels.length >= 4) break } // Clean formulas usable inside $…$ (KaTeX eats spaces → no prose, bounded) const cleanFormulas = assets.formulas.filter((f) => f.length <= 80) if (labels.length < 3) { const fallbacks = fr ? ['Entrée', 'Transformation', 'Résultat', 'Retour'] : ['Input', 'Transform', 'Output', 'Loop'] for (const fb of fallbacks) { if (labels.length >= 4) break if (!labels.includes(fb)) labels.push(fb) } } while (labels.length < 3) labels.push(`Étape ${labels.length + 1}`) const nodeCount = Math.min(4, Math.max(3, labels.length)) const nodes = labels.slice(0, nodeCount).map((label, i) => ({ id: `n${i + 1}`, label: cleanFormulas[i] && i < 2 ? `${i + 1} · ${label.split('\n')[0]}\n$${cleanFormulas[i]}$` : `${i + 1} · ${label}`, intent: INTENT_CYCLE[i % INTENT_CYCLE.length], })) const edges = nodes.map((n, i) => { const next = nodes[(i + 1) % nodes.length] return { id: `e${i + 1}`, from: n.id, to: next.id, style: 'solid' as const, intent: 'flow' as const, } }) const steps = nodes.map((n, i) => { const formula = cleanFormulas[i] const speakBase = sentences[i]?.slice(0, 100) || (fr ? `Étape **${i + 1}** du mécanisme.` : `Step **${i + 1}** of the mechanism.`) const speak = formula ? `${speakBase.split('.')[0]}. $${formula}$` : speakBase const revealed = nodes.slice(0, i + 1).map((x) => x.id) if (i > 0) revealed.push(edges[i - 1].id) const isLast = i === nodes.length - 1 return { id: `a1.s${i + 1}`, speak: speak.slice(0, 160), pattern: isLast ? ('overview' as const) : ('spotlightTour' as const), pointTo: [n.id], reveal: [{ ids: isLast ? nodes.map((x) => x.id).concat(edges.map((e) => e.id)) : revealed, scope: 'act' as const }], } }) const raw = { schemaVersion: 1 as const, id: 'demo.page-auto', lang, disclaimer: fr ? 'Schéma pédagogique généré depuis la note — valeurs illustratives.' : 'Pedagogical diagram from your note — illustrative values.', scene: { id: 'scene.main', panels: [ { id: 'panel.main', type: 'svg-scene' as const, payload: { nodes, edges }, }, ], }, acts: [ { id: 'a1', title: fr ? 'Parcours' : 'Walkthrough', pattern: 'flowTrace' as const, steps, }, ], } const validated = validateInteractiveDemo(raw) if (!validated.ok) { console.warn( '[interactive-page] deterministic demo invalid', validated.issues.slice(0, 5) ) return null } return validated.demo } export function buildPageFromNote( content: string, lang: string, assets: ReturnType ): Record { const plain = stripToPlain(content) const sentences = [ ...assets.keySentences, ...chunkSentences(plain), ].filter((s, i, arr) => arr.indexOf(s) === i) const title = firstSentence(sentences[0] || plain, 90) const lead = sentences[0]?.slice(0, 320) || plain.slice(0, 320) || title const fr = lang.startsWith('fr') // Displayable formulas only (KaTeX eats spaces — no prose, bounded length) const displayFormulas = assets.formulas.filter((f) => f.length <= 120) const formula = displayFormulas[0] const formula2 = displayFormulas[1] const cards = [ { badge: 'PROBLEM', title: fr ? 'Contexte' : 'Context', body: (sentences[0] || lead).slice(0, 160), intent: 'warning' as const, }, { badge: 'APPROACH', title: fr ? 'Approche' : 'Approach', body: (sentences[1] || sentences[0] || lead).slice(0, 160), intent: 'flow' as const, }, { badge: 'RESULT', title: formula ? (fr ? 'Relation' : 'Relation') : fr ? 'Idée clé' : 'Key idea', body: formula ? `$${formula}$` : (sentences[2] || lead).slice(0, 160), intent: 'output' as const, }, ] const sections: Record[] = [ { id: 's1', title: fr ? 'Le problème' : 'The problem', blocks: [ { type: 'prose', md: sentences[0] || lead }, { type: 'callout', kind: 'definition', title: fr ? 'En bref' : 'In short', md: (sentences[1] || plain.slice(0, 180) || title).slice(0, 220), }, ], }, { id: 's2', title: fr ? 'Mécanisme' : 'Mechanism', blocks: [ { type: 'prose', md: sentences[1] || sentences[0] || lead }, ...(formula ? [{ type: 'formula', tex: formula }] : []), ...(formula2 ? [ { type: 'callout', kind: 'tip', title: fr ? 'Aussi' : 'Also', md: `$${formula2}$`, }, ] : []), ], }, { id: 's3', title: fr ? 'Synthèse' : 'Synthesis', blocks: [ { type: 'prose', md: sentences[2] || (fr ? 'Retenez le mécanisme — la démo interactive en retrace le flux.' : 'Keep the mechanism — the interactive demo traces the flow.'), }, { type: 'stats', items: [ { value: String(Math.max(assets.formulas.length, 1)), label: fr ? 'Formules' : 'Formulas', }, { value: String(Math.min(Math.max(sentences.length, 3), 8)), label: fr ? 'Idées' : 'Ideas', }, assets.numbers[0] ? { value: String(assets.numbers[0].value), label: assets.numbers[0].label || (fr ? 'Donnée' : 'Figure'), } : { value: '→', label: fr ? 'Suite' : 'Next' }, ], }, ], }, ] return { schemaVersion: 1, id: slugId(title), lang, hero: { kicker: fr ? 'EXPLAINER INTERACTIF' : 'INTERACTIVE EXPLAINER', title, subtitle: (sentences[1] || plain).slice(0, 140), meta: fr ? 'Généré depuis votre note' : 'Generated from your note', }, overview: { lead, cards }, sections, } } function injectDemo( page: Record, demo: unknown ): Record { const sections = Array.isArray(page.sections) ? ([...page.sections] as Record[]) : [] if (!sections.length) return page const targetIdx = Math.min(1, sections.length - 1) const target = { ...sections[targetIdx] } const blocks = Array.isArray(target.blocks) ? [...(target.blocks as Record[])] : [] blocks.push({ type: 'demo', demo, caption: 'Démo interactive' }) target.blocks = blocks sections[targetIdx] = target return { ...page, sections } } export type GenerateInteractivePageInput = { content: string lang?: string /** Kept for API compatibility — page skeleton no longer depends on LLM. */ provider: AIProvider } export type GenerateInteractivePageResult = | { ok: true; page: PageSpecV1; attempts: number } | { ok: false issues?: PageValidationIssue[] error?: string reason?: string raw?: string attempts: number } /** * Instant reliable page: deterministic skeleton + deterministic Play/Step demo. * No LLM round-trip for the page itself (LLM demos were timing out past client abort). */ export async function generateInteractivePageFromContent( input: GenerateInteractivePageInput ): Promise { const lang = input.lang || 'fr' const assets = extractSourceAssets(input.content) const plain = stripToPlain(input.content) if (plain.split(/\s+/).filter(Boolean).length < 30) { return { ok: false, error: 'unsuitable_content', reason: 'Contenu trop court pour une page interactive', attempts: 0, } } let pageObj = buildPageFromNote(input.content, lang, assets) const demo = buildDeterministicDemo(input.content, lang, assets) if (demo) { pageObj = injectDemo(pageObj, demo) } const normalized = normalizeInteractivePageCandidate(pageObj, lang) if (!normalized) { return { ok: false, error: 'normalize_failed', reason: 'Impossible de normaliser la page', attempts: 1, } } const result = validateInteractivePage(normalized) if (!result.ok) { // Last resort: page without demo const sections = Array.isArray(normalized.sections) ? (normalized.sections as Record[]).map((sec) => ({ ...sec, blocks: Array.isArray(sec.blocks) ? (sec.blocks as Record[]).filter( (b) => b.type !== 'demo' ) : [], })) : [] const stripped = validateInteractivePage({ ...normalized, sections }) if (stripped.ok) { return { ok: true, page: stripped.page, attempts: 1 } } return { ok: false, issues: result.issues, error: 'validation_failed', reason: result.issues[0] ? `${result.issues[0].path}: ${result.issues[0].message}` : 'Page invalide', attempts: 1, } } return { ok: true, page: result.page, attempts: 1 } }