Un pot de crédits global (BASIC 100 / PRO 1 000 / BUSINESS 4 000) remplace les seaux par fonction côté débit. Packs one-shot S/M/L, admin sans seaux legacy, usage multi-features, hints de coût, anti-flash thème et hydratation admin. Inclut aussi le pipeline slides renforcé et la page publique polishée.
693 lines
24 KiB
TypeScript
693 lines
24 KiB
TypeScript
/**
|
||
* 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<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',
|
||
}
|
||
|
||
// ── 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.`
|
||
: ''
|
||
|
||
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 (pas "Contexte", "Introduction")
|
||
- keyPoints: 2–5 faits de la note par slide
|
||
- 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
|
||
${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
|
||
- keyPoints: 2–5 facts from the note per slide
|
||
- 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
|
||
${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<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 })),
|
||
}
|
||
}
|
||
|
||
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)
|
||
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<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[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<typeof ExpandedSlideSchema>[] = []
|
||
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<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
|
||
|
||
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 }
|
||
}
|
||
}
|