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) } 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, block: Record ): 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(block) 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 } /** * Deterministic step-by-step block from extracted formulas (math notes). * Replaces the old generic box-diagram fallback — boxes are banned. */ function buildDeterministicSteps( content: string, lang: string, assets: ReturnType ): Record | null { const fr = lang.startsWith('fr') const formulas = assets.formulas.filter((f) => f.length <= 120).slice(0, 6) if (formulas.length < 3) return null const plain = stripToPlain(content) const sentences = [ ...assets.keySentences, ...chunkSentences(plain), ].filter((s, i, a) => a.indexOf(s) === i) return { type: 'steps', title: fr ? 'Dérivation pas à pas' : 'Step-by-step derivation', steps: formulas.map((tex, i) => ({ tex, ...(i === 0 ? { rule: fr ? 'Point de départ' : 'Starting point' } : {}), speak: sentences[i]?.slice(0, 120) || (fr ? `Étape **${i + 1}** de la dérivation.` : `Step **${i + 1}** of the derivation.`), })), caption: fr ? 'Formules extraites de la note, déroulées pas à pas.' : 'Formulas extracted from the note, walked through step by step.', } } /** * Instant reliable page: deterministic skeleton + deterministic steps (math) * or Play/Step demo (other content). No LLM round-trip for the page itself. */ 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 stepsBlock = buildDeterministicSteps(input.content, lang, assets) if (stepsBlock) { pageObj = injectDemo(pageObj, stepsBlock) } 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 } }