/** * 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', 'image', '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(), /** For image slides: 0-based index into assets.images */ imageIndex: z.number().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(), url: z.string().optional(), caption: 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', } /** Detect content language from text via Unicode script ranges + Latin word frequency. */ function detectContentLanguage(text: string): string { if (!text) return 'English' // Non-Latin scripts (deterministic) if (/[\u0600-\u06FF\uFB50-\uFDFF\uFE70-\uFEFF]/.test(text)) { if (/[\u06CC\u0698\u06AF\u06A9\u067E\u0686]/.test(text)) return 'Persian (Farsi)' return 'Arabic' } if (/[\uAC00-\uD7AF]/.test(text)) return 'Korean' if (/[\u3040-\u309F\u30A0-\u30FF]/.test(text)) return 'Japanese' if (/[\u4E00-\u9FFF]/.test(text)) return 'Chinese' if (/[\u0400-\u04FF]/.test(text)) return 'Russian' if (/[\u0900-\u097F]/.test(text)) return 'Hindi' // Latin script — distinguish via accented chars + function word frequency const lower = text.toLowerCase() const wc = (re: RegExp) => (lower.match(re) || []).length // French: distinctive accents (é è ê à ô etc) + function words const frAccents = (lower.match(/[éèêëàâäïîôöùûüç]/g) || []).length const frWords = wc(/\b(dans|avec|pour|cette|être|avoir|fait|plus|sans|sous|entre|après|très|bien|aussi|même|encore|toujours|jamais|pendant|depuis|chez|leurs|mes|tes|ses|notre|votre|celui|ceux|celle|aucun|autre|chaque)\b/g) const frScore = frAccents + frWords // Spanish const esAccents = (lower.match(/[áíóúñ¿¡]/g) || []).length const esWords = wc(/\b(pero|como|más|para|con|por|una|los|las|del|al|también|puede|antes|después|siempre|nunca|también|aquí|allí|muy|bien|también|nosotros|vosotros|ellos|suyo|nuestro)\b/g) const esScore = esAccents + esWords // German const deUmlauts = (lower.match(/[äöüß]/g) || []).length const deWords = wc(/\b(und|ist|ein|eine|von|mit|für|auf|nicht|auch|sich|bei|zum|zur|den|des|dem|wir|sie|hat|war|wird|sind|kann|muss|noch|schon|immer|wieder)\b/g) const deScore = deUmlauts * 2 + deWords // Pick highest scorer (must beat English baseline) const scores: [string, number][] = [ ['French', frScore], ['Spanish', esScore], ['German', deScore], ] scores.sort((a, b) => b[1] - a[1]) // Require a minimum signal (accents/words) to avoid false positives on short English text if (scores[0][1] >= 6) return scores[0][0] return 'English' } // ── 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.` : '' const imageRule = assets.hasImages ? lang === 'fr' ? `MATÉRIEL VISUEL: ${assets.images.length} image(s) disponible(s). Inclus 1 à ${Math.min(3, assets.images.length)} slide(s) de type "image" pour illustrer le propos. Pour chaque slide image, donne l'index (imageIndex, 0-based) de l'image à utiliser.` : `VISUAL MATERIAL: ${assets.images.length} image(s) available. Include 1 to ${Math.min(3, assets.images.length)} slide(s) of type "image" to illustrate key points. For each image slide, provide imageIndex (0-based) of the image to use.` : '' const chartRule = assets.hasNumbers ? lang === 'fr' ? `DONNÉES NUMÉRIQUES: ${assets.numbers.length} valeurs détectées. Tu DOIS inclure au moins 1 slide de type "chart". chartType: "bar" (comparer des quantités), "line" (tendance temporelle), "donut" (proportions d'un tout).` : `NUMERIC DATA: ${assets.numbers.length} values detected. You MUST include at least 1 slide of type "chart". chartType: "bar" (compare quantities), "line" (time trend), "donut" (proportions of a whole).` : '' 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 | image | summary - ATTENTION: le type "equation" est RÉSERVÉ aux formules mathématiques réelles (avec LaTeX). N'utilise JAMAIS "equation" pour un concept métaphorique (équilibre, rapport de forces, etc.) → utilise "bullets" ou "comparison" à la place. - Slide 1 = title, dernière = summary - INTERDIT slides vides, listes de 1 mot, ou répéter le titre de la note ${mathRule} ${imageRule} ${chartRule} 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 | image | summary - WARNING: type "equation" is RESERVED for real mathematical formulas (with LaTeX). NEVER use "equation" for metaphorical concepts (balance of power, concessions, etc.) → use "bullets" or "comparison" instead. - First=title, last=summary - FORBIDDEN empty slides, one-word lists, or repeating the note title alone ${mathRule} ${imageRule} ${chartRule} 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 + chartType + data[{label, value: NUMBER}] (min 2 entrées) chartType: "bar" (comparer quantités) | "line" (tendance temporelle) | "donut" (proportions) | "horizontal-bar" (libellés longs) | "radar" (comparer dimensions) IMPORTANT: value DOIT être un number (pas string). Utilise les chiffres fournis dans le prompt. - image: title + url (fourni dans le prompt) + caption (1 phrase décrivant ce que l'image illustre) - 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: only with real numbers provided - chart: title + chartType + data[{label, value: NUMBER}] (min 2 entries) chartType: "bar" (compare quantities) | "line" (time trend) | "donut" (proportions) | "horizontal-bar" (long labels) | "radar" (compare dimensions) IMPORTANT: value MUST be a number (not string). Use the numbers provided in the prompt. - image: title + url (provided in prompt) + caption (1 sentence describing what the image illustrates) - 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 })), } } /** Force image slides into outline when server extracted images (like enforceMathOutline for formulas). */ function enforceImageOutline( outline: z.infer, assets: SourceAssets, maxSlides: number, ): z.infer { if (!assets.hasImages || assets.images.length === 0) return outline const hasImg = outline.slides.some((s) => s.type === 'image') if (hasImg) return outline // Inject 1 image slide after title (or after first equation slide) const title = outline.slides.find((s) => s.type === 'title') || outline.slides[0]! const insertAfter = outline.slides.findIndex((s) => s.type === 'title') const insertIdx = insertAfter >= 0 ? insertAfter + 1 : 1 const imgSlide: z.infer = { position: insertIdx + 1, type: 'image', headline: assets.keySentences[0]?.slice(0, 60) || 'Illustration', keyPoints: [assets.images[0]!], imageIndex: 0, narrativeRole: 'evidence', } const before = outline.slides.slice(0, insertIdx) const after = outline.slides.slice(insertIdx, maxSlides - 1) return { ...outline, slides: [...before, imgSlide, ...after].map((s, i) => ({ ...s, position: i + 1 })), } } function pickChartType(count: number): 'bar' | 'horizontal-bar' | 'donut' { if (count <= 4) return 'donut' if (count <= 6) return 'bar' return 'horizontal-bar' } function enforceChartOutline( outline: z.infer, assets: SourceAssets, maxSlides: number, ): z.infer { if (!assets.hasNumbers || assets.numbers.length < 2) return outline const hasChart = outline.slides.some((s) => s.type === 'chart') if (hasChart) return outline const titleIdx = outline.slides.findIndex((s) => s.type === 'title') const insertIdx = titleIdx >= 0 ? titleIdx + 1 : 1 const chartSlide: z.infer = { position: insertIdx + 1, type: 'chart', headline: assets.keySentences[0]?.slice(0, 60) || 'Données clés', keyPoints: assets.numbers.slice(0, 4).map((n) => `${n.label}: ${n.raw}`), narrativeRole: 'data', } const before = outline.slides.slice(0, insertIdx) const after = outline.slides.slice(insertIdx, maxSlides - 1) return { ...outline, slides: [...before, chartSlide, ...after].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) if (!forms.length) { return { type: 'bullets', title, items: (o.keyPoints.length >= 2 ? o.keyPoints : assets.keySentences.slice(0, 3)).slice(0, 5), } } return { type: 'equation', title, equations: forms.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, })), } } if (type === 'image') { const imgIdx = o.imageIndex ?? 0 const url = assets.images[imgIdx] || assets.images[0] || '' return { type: 'image', title, url, caption: o.keyPoints[0] || assets.keySentences[0] || '', } } // 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.find((n) => n.language)?.language const combined = notes.map((n) => n.content || '').join('\n\n') const assets = extractSourceAssets(combined) const contentLang = params.contentLanguage || (noteLang && LANG_NAMES[noteLang] ? LANG_NAMES[noteLang] : detectContentLanguage(combined)) const perNoteLimit = notes.length > 5 ? 1200 : 8000 const notesText = notes .map((n) => `### ${n.title || 'Note'}\n${prepareNoteTextForSlides(n.content || '', perNoteLimit)}`) .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 ? `DONNÉES NUMÉRIQUES (JSON pour slides chart/stats — utilise ces valeurs exactes):\n${JSON.stringify(assets.numbers.slice(0, 10).map((n) => ({ label: n.label.slice(0, 24), value: n.value })))}` : '', assets.keySentences.length ? `PHRASES CLÉS:\n${assets.keySentences .slice(0, 8) .map((s) => `- ${s}`) .join('\n')}` : '', assets.images.length ? `IMAGES DISPONIBLES (utilise imageIndex 0-based pour les slides image):\n${assets.images.map((url, i) => `${i}. ${url}`).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) } // Images → force image slides when images exist but LLM didn't create any if (assets.hasImages) { outline = enforceImageOutline(outline, assets, targetMax) } // Charts → force chart slides when numbers exist but LLM didn't create any if (assets.hasNumbers) { outline = enforceChartOutline(outline, assets, targetMax) } // ── Stage 2: Expand slides ── // Single-pass: outline LLM call + deterministic fill (1 LLM call total) let mode = 'outline+deterministic-fill' const expanded: z.infer[] = outline.slides.map((slideOutline) => { const one = fallbackExpandFromOutline(slideOutline, assets) one.type = slideOutline.type if (!one.title) one.title = slideOutline.headline return one }) // ── Stage 3: Normalize + STEM inject + substance gate ── // Rescue chart slides with missing/partial data before normalization drops them const rescued = expanded.map((slide) => { if (slide.type !== 'chart') return slide const validData = (slide.data || []).filter( (d: any) => d && typeof d.value === 'number' && Number.isFinite(d.value) && String(d.label || '').trim(), ) if (validData.length < 2 && assets.numbers.length >= 2) { return { ...slide, data: assets.numbers.slice(0, 6).map((n) => ({ label: n.label.slice(0, 20), value: n.value })), chartType: slide.chartType || pickChartType(assets.numbers.length), } } return slide }) let normalized = normalizeSlideDeck({ title: outline.title, theme, slides: rescued 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) 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 // GUARANTEE: if numbers were extracted but no chart survived the pipeline, force-inject one if (assets.hasNumbers && assets.numbers.length >= 2) { const hasChart = normalized.slides.some((s) => s.type === 'chart') if (!hasChart) { const chartSlide: Record = { type: 'chart', title: lang === 'fr' ? 'Données clés' : 'Key data', chartType: pickChartType(assets.numbers.length), data: assets.numbers.slice(0, 6).map((n) => ({ label: (n.label.slice(0, 20) || n.raw), value: n.value, })), } const summaryIdx = normalized.slides.findIndex((s) => s.type === 'summary') if (summaryIdx >= 0) { normalized.slides.splice(summaryIdx, 0, chartSlide) } else if (normalized.slides.length < 8) { normalized.slides.push(chartSlide) } else { normalized.slides[normalized.slides.length - 2] = chartSlide } } } 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 } } }