Files
Momento/memento-note/lib/ai/services/slide-deck-generator.service.ts
Antigravity 80ccc1f6de
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m14s
CI / Deploy production (on server) (push) Successful in 1m25s
feat: dashboard Second Brain, essai 7 jours et vérification e-mail
Rendre le dashboard actionnable (inbox, peek, carte mentale), aligner la facturation sur l’essai 7 jours, et bloquer le login e-mail tant que l’adresse n’est pas confirmée.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 07:19:36 +00:00

859 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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(),
/** 25 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<string, string> = {
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<T>(opts: {
model: any
schema: z.ZodType<T>
system: string
prompt: string
}): Promise<T> {
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: 25 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: 25 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<typeof OutlineSchema>,
assets: SourceAssets,
maxSlides: number,
): z.infer<typeof OutlineSchema> {
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<typeof OutlineSlideSchema>[] = []
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<typeof OutlineSchema>,
assets: SourceAssets,
maxSlides: number,
): z.infer<typeof OutlineSchema> {
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<typeof OutlineSlideSchema> = {
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<typeof OutlineSchema>,
assets: SourceAssets,
maxSlides: number,
): z.infer<typeof OutlineSchema> {
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<typeof OutlineSlideSchema> = {
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<typeof OutlineSlideSchema>,
assets: SourceAssets,
): z.infer<typeof ExpandedSlideSchema> {
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<typeof normalizeSlideDeck>,
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<GenerateSlideDeckResult> {
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<typeof ExpandedSlideSchema>[] = 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<string, unknown>
}
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<string, unknown> = {
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 }
}
}