/** * Slide deck generation — DeckForge / PPTAgent / Hermes-inspired pipeline. * * Sources of truth (research): * - DeckForge: intent → outline → expand PER SLIDE → refine → validate IR * https://github.com/Whatsonyourmind/deckforge * - PPTAgent: outline with retrieved document content, then iterative fill * https://github.com/icip-cas/PPTAgent arXiv:2501.03936 * - Hermes PowerPoint skill: domain palettes, no empty slides, QA before ship * https://hermes-agent.nousresearch.com/docs/user-guide/skills/bundled/productivity/productivity-powerpoint * - OpenClaw ppt-html-pipeline: per-page blueprint, no large blank regions * https://github.com/Yuancircle/openclaw-ppt-html-pipeline * * NEVER persists a deck that fails substance validation. */ import { generateObject, generateText } from 'ai' import { z } from 'zod' import { prisma } from '@/lib/prisma' import { buildPresentationHTML } from '@/lib/ai/tools/slides-html-builder' import { assertDeckHasSubstance, countNoteWords, injectEquationSlidesFromFormulas, normalizeSlideDeck, prepareNoteTextForSlides, slideLimitFromWordCount, SLIDE_HARD_CAP, } from '@/lib/ai/services/slide-content-quality' import { extractSourceAssets, type SourceAssets } from '@/lib/ai/services/slide-source-assets' import { intentPromptHints, normalizeSlideIntent, type SlideIntent, } from '@/lib/ai/services/slide-intent' import { getSystemConfig } from '@/lib/config' import { getSlidesProvider } from '@/lib/ai/factory' // ── Types catalog (includes equation — critical for STEM notes) ────────────── const SlideTypeEnum = z.enum([ 'title', 'bullets', 'equation', 'cards', 'comparison', 'timeline', 'stats', 'chart', 'table', 'quote', 'summary', ]) const OutlineSlideSchema = z.object({ position: z.number(), type: SlideTypeEnum, /** Assertive headline, max ~10 words (DeckForge: max 8) */ headline: z.string(), /** 2–5 key points that MUST appear on the slide body */ keyPoints: z.array(z.string()).min(1).max(6), /** For equation slides: latex strings to include */ formulas: z.array(z.string()).optional(), narrativeRole: z.enum(['opening', 'evidence', 'transition', 'conclusion', 'data']).optional(), }) const OutlineSchema = z.object({ title: z.string(), narrativeArc: z.enum(['pyramid', 'scr', 'mece', 'chronological', 'pedagogical']).optional(), slides: z.array(OutlineSlideSchema).min(2).max(SLIDE_HARD_CAP), }) const ExpandedSlideSchema = z.object({ type: SlideTypeEnum, title: z.string(), subtitle: z.string().optional(), items: z.array(z.string()).optional(), equations: z.array(z.object({ latex: z.string(), label: z.string().optional() })).optional(), explanation: z.string().optional(), cards: z.array(z.object({ title: z.string(), description: z.string() })).optional(), left: z .object({ title: z.string(), points: z.array(z.string()), score: z.string().optional() }) .optional(), right: z .object({ title: z.string(), points: z.array(z.string()), score: z.string().optional() }) .optional(), events: z .array(z.object({ date: z.string(), title: z.string(), description: z.string().optional() })) .optional(), stats: z.array(z.object({ value: z.string(), label: z.string() })).optional(), chartType: z.enum(['bar', 'horizontal-bar', 'line', 'donut', 'radar']).optional(), data: z.array(z.object({ label: z.string(), value: z.number() })).optional(), headers: z.array(z.string()).optional(), rows: z.array(z.array(z.string())).optional(), quote: z.string().optional(), author: z.string().optional(), context: z.string().optional(), notes: z.string().optional(), }) export interface GenerateSlideDeckParams { userId: string noteIds?: string[] sourceNotebookId?: string | null theme?: string | null template?: string | null lang?: 'fr' | 'en' contentLanguage?: string | null actionId?: string | null /** Product intent: purpose, audience, slide count */ intent?: SlideIntent | null } export interface GenerateSlideDeckResult { success: boolean canvasId?: string canvasName?: string slideCount?: number error?: string mode?: string } const LANG_NAMES: Record = { fr: 'French', en: 'English', es: 'Spanish', de: 'German', it: 'Italian', pt: 'Portuguese', nl: 'Dutch', pl: 'Polish', ru: 'Russian', zh: 'Chinese', ja: 'Japanese', ko: 'Korean', ar: 'Arabic', fa: 'Persian (Farsi)', hi: 'Hindi', } // ── LLM helpers ────────────────────────────────────────────────────────────── export function extractJsonPayload(text: string): unknown | null { if (!text) return null const raw = text.trim() const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i) const candidate = fenced?.[1]?.trim() || raw const tryParse = (s: string) => { try { return JSON.parse(s) } catch { return null } } let parsed = tryParse(candidate) if (parsed) return parsed const brace = candidate.match(/\{[\s\S]*\}/) if (brace) parsed = tryParse(brace[0]) return parsed } async function llmObject(opts: { model: any schema: z.ZodType system: string prompt: string }): Promise { const { model, schema, system, prompt } = opts try { const { object } = await generateObject({ model, schema, system, prompt }) return object as T } catch (err) { console.warn('[SlideDeck] generateObject failed → text JSON:', (err as Error)?.message) const { text } = await generateText({ model, system, prompt: `${prompt}\n\nOutput ONE JSON object only.`, }) const raw = extractJsonPayload(text) if (!raw) throw new Error(`Non-JSON model output: ${String(text).slice(0, 180)}`) const parsed = schema.safeParse(raw) if (parsed.success) return parsed.data return raw as T } } // ── Notes load ─────────────────────────────────────────────────────────────── async function loadNotes(params: GenerateSlideDeckParams) { if (params.noteIds?.length) { return prisma.note.findMany({ where: { id: { in: params.noteIds }, userId: params.userId, isArchived: false, trashedAt: null, }, select: { id: true, title: true, content: true, createdAt: true, language: true }, }) } if (params.sourceNotebookId) { return prisma.note.findMany({ where: { notebookId: params.sourceNotebookId, userId: params.userId, isArchived: false, trashedAt: null, }, orderBy: { createdAt: 'desc' }, take: 15, select: { id: true, title: true, content: true, createdAt: true, language: true }, }) } return [] } // ── Stage prompts (DeckForge-style) ────────────────────────────────────────── function outlineSystem(lang: 'fr' | 'en', maxSlides: number, assets: SourceAssets): string { const mathRule = assets.hasMath ? lang === 'fr' ? `DOMAINE MATH/STEM DÉTECTÉ: tu DOIS inclure au moins ${Math.min(2, Math.max(1, assets.formulas.length))} slides de type "equation" qui portent les formules extraites. Ne remplace PAS les formules par des bullets vagues.` : `MATH/STEM DOMAIN DETECTED: you MUST include at least ${Math.min(2, Math.max(1, assets.formulas.length))} slides of type "equation" carrying the extracted formulas. Do NOT replace formulas with vague bullets.` : '' if (lang === 'fr') { return `Tu es l'architecte narratif DeckForge/PPTAgent pour Memento. Tu produis UNIQUEMENT un outline JSON (pas le corps final des slides). Règles (DeckForge + think-cell): - Max ${maxSlides} slides - Headline ASSERTIVE, max 10 mots (INTERDIT: "Contexte", "Introduction", "Présentation", "Overview", "Points clés", "Conclusion" seuls) - keyPoints: 2–5 FAITS CONCRETS tirés de la note (chiffres, noms, formules) — jamais de filler - Chaque slide = UNE idée actionnable (titre = insight, pas libellé de section) - Arc pédagogique pour cours: title → définitions/équations → propriétés → exemples → summary - Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | summary - Slide 1 = title, dernière = summary - INTERDIT slides vides, listes de 1 mot, ou répéter le titre de la note ${mathRule} Réponds en JSON OutlineSchema.` } return `You are DeckForge/PPTAgent narrative architect for Memento. Output ONLY outline JSON (not full slide bodies). Rules: - Max ${maxSlides} slides - ASSERTIVE headlines, max 10 words (FORBIDDEN alone: Context, Introduction, Overview, Key points, Conclusion) - keyPoints: 2–5 CONCRETE facts from the note (numbers, names, formulas) — no filler - One actionable idea per slide (title = insight, not section label) - Pedagogical arc for lessons: title → definitions/equations → properties → examples → summary - Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | summary - First=title, last=summary - FORBIDDEN empty slides, one-word lists, or repeating the note title alone ${mathRule} JSON OutlineSchema only.` } function expandOneSystem(lang: 'fr' | 'en'): string { if (lang === 'fr') { return `Tu es le rédacteur de slides DeckForge. Tu EXPANDES UNE slide à la fois. INTERDIT: slide avec seulement un titre. OBLIGATOIRE selon type: - title: title + subtitle (1 phrase) - equation: title + equations[{latex,label?}] (min 1, latex fidèle à la note) + explanation courte - bullets: title + items[3-5] claims ≤18 mots - cards: title + cards[2-4] {title, description} - comparison: title + left/right avec points[2-4] chacun - timeline: title + events[2-5] - stats: title + stats[2-4] SEULEMENT si chiffres réels fournis - chart: title + data[2+] SEULEMENT si chiffres réels - summary: title + items[3-5] actionnables - quote: quote non vide JSON ExpandedSlide uniquement.` } return `You are DeckForge slide writer. Expand ONE slide only. FORBIDDEN: title-only slides. REQUIRED by type: - title: title + subtitle - equation: title + equations[{latex,label?}] min 1 + short explanation - bullets: title + items[3-5] ≤18 words each - cards: title + cards[2-4] - comparison: left/right points[2-4] each - timeline: events[2-5] - stats/chart: only with real numbers provided - summary: items[3-5] - quote: non-empty quote ExpandedSlide JSON only.` } /** Force math slides into outline when server extracted formulas (PPTAgent retrieval). */ function enforceMathOutline( outline: z.infer, assets: SourceAssets, maxSlides: number, ): z.infer { if (!assets.hasMath || assets.formulas.length === 0) return outline const hasEq = outline.slides.some((s) => s.type === 'equation') if (hasEq) { // Ensure equation slides list formulas return { ...outline, slides: outline.slides.map((s, i) => { if (s.type !== 'equation') return s const chunk = assets.formulas.slice(i % assets.formulas.length, (i % assets.formulas.length) + 2) return { ...s, formulas: s.formulas?.length ? s.formulas : chunk.length ? chunk : assets.formulas.slice(0, 2), } }), } } // Inject equation slides after title const title = outline.slides.find((s) => s.type === 'title') || { position: 1, type: 'title' as const, headline: outline.title, keyPoints: assets.keySentences.slice(0, 2), narrativeRole: 'opening' as const, } const rest = outline.slides.filter((s) => s.type !== 'title' && s.type !== 'summary') const summary = outline.slides.find((s) => s.type === 'summary') || { position: 99, type: 'summary' as const, headline: 'Points clés', keyPoints: assets.keySentences.slice(0, 3), narrativeRole: 'conclusion' as const, } const eqSlides: z.infer[] = [] const groups = Math.min(2, Math.ceil(assets.formulas.length / 2)) for (let g = 0; g < groups; g++) { const formulas = assets.formulas.slice(g * 2, g * 2 + 2) eqSlides.push({ position: g + 2, type: 'equation', headline: g === 0 ? 'Équations fondamentales' : 'Formules complémentaires', keyPoints: formulas.map((f) => f.slice(0, 80)), formulas, narrativeRole: 'evidence', }) } let slides = [title, ...eqSlides, ...rest, summary].slice(0, maxSlides) // Ensure last is summary if (slides[slides.length - 1]?.type !== 'summary') { slides = [...slides.slice(0, maxSlides - 1), summary] } return { ...outline, slides: slides.map((s, i) => ({ ...s, position: i + 1 })), } } function fallbackExpandFromOutline( o: z.infer, assets: SourceAssets, ): z.infer { const type = o.type const title = o.headline if (type === 'title') { return { type: 'title', title, subtitle: o.keyPoints[0] || assets.keySentences[0] || '', } } if (type === 'equation') { const forms = o.formulas?.length ? o.formulas : assets.formulas.slice(0, 3) return { type: 'equation', title, equations: (forms.length ? forms : ['f(x) = ?']).map((latex, i) => ({ latex, label: o.keyPoints[i] || `Formule ${i + 1}`, })), explanation: o.keyPoints.join(' · ').slice(0, 200), } } if (type === 'cards') { const pts = o.keyPoints.length >= 2 ? o.keyPoints : assets.keySentences.slice(0, 3) return { type: 'cards', title, cards: pts.slice(0, 4).map((p, i) => ({ title: `Point ${i + 1}`, description: p, })), } } if (type === 'summary') { return { type: 'summary', title, items: (o.keyPoints.length ? o.keyPoints : assets.keySentences).slice(0, 5), } } if (type === 'stats' && assets.numbers.length >= 2) { return { type: 'stats', title, stats: assets.numbers.slice(0, 4).map((n) => ({ value: n.raw, label: n.label || title, })), } } if (type === 'chart' && assets.numbers.length >= 2) { return { type: 'chart', title, chartType: 'bar', data: assets.numbers.slice(0, 6).map((n) => ({ label: n.label.slice(0, 20) || n.raw, value: n.value, })), } } // default bullets — use keyPoints + sentences to fill density const items = [ ...o.keyPoints, ...assets.keySentences.filter((s) => !o.keyPoints.some((k) => s.includes(k.slice(0, 20)))), ].slice(0, 5) while (items.length < 3 && assets.keySentences[items.length]) { items.push(assets.keySentences[items.length]!) } return { type: type === 'quote' ? 'bullets' : type === 'comparison' ? 'bullets' : type === 'timeline' ? 'bullets' : 'bullets', title, items: items.length >= 2 ? items : [title, assets.keySentences[0] || 'Voir la note source'].filter(Boolean), } } // ── Persist ────────────────────────────────────────────────────────────────── async function persist( userId: string, deck: ReturnType, actionId: string | null | undefined, mode: string, ) { const html = buildPresentationHTML({ title: deck.title, theme: deck.theme, slides: deck.slides as any, }) const canvas = await prisma.canvas.create({ data: { name: deck.title || 'Présentation', data: JSON.stringify({ type: 'slides', title: deck.title, html, slideCount: deck.slides.length, theme: deck.theme, mode, spec: { title: deck.title, theme: deck.theme, slides: deck.slides }, }), userId, }, }) if (actionId) { await prisma.agentAction .update({ where: { id: actionId }, data: { status: 'success', result: canvas.id, log: `Slides OK (${mode}): ${deck.slides.length} slides, ${Math.round(html.length / 1024)}KB, substance OK`, }, }) .catch(() => {}) } return canvas } // ── Main ───────────────────────────────────────────────────────────────────── export async function generateSlideDeck(params: GenerateSlideDeckParams): Promise { const lang = params.lang === 'en' ? 'en' : 'fr' const notes = await loadNotes(params) if (!notes.length) { return { success: false, error: lang === 'fr' ? 'Aucune note source.' : 'No source notes.', } } const noteLang = notes[0]?.language const contentLang = params.contentLanguage || (noteLang && LANG_NAMES[noteLang] ? LANG_NAMES[noteLang] : lang === 'fr' ? 'French' : 'English') const combined = notes.map((n) => n.content || '').join('\n\n') const assets = extractSourceAssets(combined) const notesText = notes .map((n) => `### ${n.title || 'Note'}\n${prepareNoteTextForSlides(n.content || '', 10_000)}`) .join('\n\n') const wordCount = notes.reduce((s, n) => s + countNoteWords(n.content || ''), 0) const limit = slideLimitFromWordCount(wordCount) const intent = normalizeSlideIntent(params.intent || { template: params.template || 'auto' }) // User-chosen count overrides word-count heuristic const targetMax = intent.slideCount || limit.max const targetMin = intent.slideCount || limit.min const theme = params.theme && params.theme !== 'auto' ? params.theme : assets.hasMath || intent.purpose === 'course' ? 'clinical-precision' : 'architectural-saas' // Admin-configured slides model (or chat if slides not set). Never rename model IDs. const sysConfig = await getSystemConfig() const model = getSlidesProvider(sysConfig).getModel() const intentHints = intentPromptHints(intent, lang) try { // ── Stage 1: Outline (DeckForge outliner) ── const assetsBlock = [ assets.formulas.length ? `FORMULES EXTRAITES (à placer dans des slides equation):\n${assets.formulas.map((f, i) => `${i + 1}. ${f}`).join('\n')}` : '', assets.numbers.length ? `CHIFFRES:\n${assets.numbers .slice(0, 10) .map((n) => `- ${n.label}: ${n.raw}`) .join('\n')}` : '', assets.keySentences.length ? `PHRASES CLÉS:\n${assets.keySentences .slice(0, 8) .map((s) => `- ${s}`) .join('\n')}` : '', ] .filter(Boolean) .join('\n\n') let outline = await llmObject({ model, schema: OutlineSchema, system: outlineSystem(lang, targetMax, assets), prompt: lang === 'fr' ? `Langue du deck: ${contentLang}.\nNombre de slides cible: ${targetMax} (min ${targetMin}).\nThème: ${theme}.\n${intentHints ? `\n## Intent produit\n${intentHints}\n` : ''}\n## Assets serveur\n${assetsBlock || '(aucun)'}\n\n## Note\n${notesText}\n\nProduis l'outline JSON.` : `Deck language: ${contentLang}.\nTarget slides: ${targetMax} (min ${targetMin}).\nTheme: ${theme}.\n${intentHints ? `\n## Product intent\n${intentHints}\n` : ''}\n## Server assets\n${assetsBlock || '(none)'}\n\n## Note\n${notesText}\n\nProduce outline JSON.`, }) // STEM or course purpose → force equation slides when formulas exist if (assets.hasMath || intent.purpose === 'course') { outline = enforceMathOutline(outline, assets, targetMax) } // ── Stage 2: Expand PER SLIDE (DeckForge SlideWriter loop) ── const expanded: z.infer[] = [] for (const slideOutline of outline.slides) { try { const formulaHint = slideOutline.type === 'equation' ? `\nFORMULES OBLIGATOIRES pour cette slide:\n${(slideOutline.formulas || assets.formulas).slice(0, 4).join('\n')}` : assets.formulas.length && slideOutline.type === 'bullets' ? `\n(Formules dispo si besoin: ${assets.formulas.slice(0, 2).join(' ; ')})` : '' const one = await llmObject({ model, schema: ExpandedSlideSchema, system: expandOneSystem(lang), prompt: lang === 'fr' ? `Langue: ${contentLang}. ${intentHints ? `Intent: ${intentHints}\n` : ''}Slide ${slideOutline.position}/${outline.slides.length} type: ${slideOutline.type} headline: ${slideOutline.headline} keyPoints: ${JSON.stringify(slideOutline.keyPoints)} role: ${slideOutline.narrativeRole || 'evidence'} ${formulaHint} Contexte note (extrait):\n${notesText.slice(0, 6000)} Expand cette slide en JSON ExpandedSlide COMPLET (corps non vide).` : `Language: ${contentLang}. ${intentHints ? `Intent: ${intentHints}\n` : ''}Slide ${slideOutline.position}/${outline.slides.length} type: ${slideOutline.type} headline: ${slideOutline.headline} keyPoints: ${JSON.stringify(slideOutline.keyPoints)} ${formulaHint} Note excerpt:\n${notesText.slice(0, 6000)} Expand into full ExpandedSlide JSON (non-empty body).`, }) // Force type/title from outline if model drifts one.type = slideOutline.type if (!one.title) one.title = slideOutline.headline // Inject formulas if equation empty if (one.type === 'equation' && (!one.equations || one.equations.length === 0)) { const forms = slideOutline.formulas?.length ? slideOutline.formulas : assets.formulas one.equations = forms.slice(0, 4).map((latex, i) => ({ latex, label: slideOutline.keyPoints[i] || `Eq. ${i + 1}`, })) } expanded.push(one) } catch (e) { console.warn('[SlideDeck] expand failed for slide, using deterministic fallback', e) expanded.push(fallbackExpandFromOutline(slideOutline, assets)) } } // ── Stage 3: Normalize + STEM inject + substance gate ── let normalized = normalizeSlideDeck({ title: outline.title, theme, slides: expanded as unknown[], }) // Always inject formulas if missing (deterministic — never ship STEM without equations) if (assets.formulas.length > 0) { normalized = injectEquationSlidesFromFormulas(normalized, assets.formulas, { lang }) } const stemOpts = { requireFormulas: assets.formulas.length > 0 ? assets.formulas : undefined, } let gate = assertDeckHasSubstance(normalized, stemOpts) let mode = 'outline+per-slide-expand' if (!gate.ok) { // Deterministic fill for empty body slides using outline + assets const slides = normalized.slides.map((s, i) => { if (s.type === 'title') return s if (!gate.ok && gate.emptyIndexes.includes(i)) { const o = outline.slides[Math.min(i, outline.slides.length - 1)]! return fallbackExpandFromOutline(o, assets) as unknown as Record } return s }) normalized = normalizeSlideDeck({ title: outline.title, theme, slides, }) if (assets.formulas.length > 0) { normalized = injectEquationSlidesFromFormulas(normalized, assets.formulas, { lang }) } gate = assertDeckHasSubstance(normalized, stemOpts) mode = 'outline+expand+deterministic-fill' } if (!gate.ok) { const msg = lang === 'fr' ? `Refus de livrer un deck incomplet: ${gate.reason}` : `Refusing incomplete deck: ${gate.reason}` if (params.actionId) { await prisma.agentAction .update({ where: { id: params.actionId }, data: { status: 'failure', log: msg } }) .catch(() => {}) } return { success: false, error: msg, mode } } if (!normalized.theme) normalized.theme = theme const canvas = await persist(params.userId, normalized, params.actionId, mode) return { success: true, canvasId: canvas.id, canvasName: canvas.name, slideCount: normalized.slides.length, mode, } } catch (e: any) { console.error('[SlideDeck] FATAL', e) const message = e?.message || String(e) if (params.actionId) { await prisma.agentAction .update({ where: { id: params.actionId }, data: { status: 'failure', log: lang === 'fr' ? `Échec slides: ${message}` : `Slide fail: ${message}`, }, }) .catch(() => {}) } return { success: false, error: message } } }