feat(credits): solde IA unique (option M), packs Stripe et polish billing

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.
This commit is contained in:
Antigravity
2026-07-16 20:43:18 +00:00
parent 704fed1191
commit 556a0b2f3f
77 changed files with 35745 additions and 10430 deletions

View File

@@ -0,0 +1,692 @@
/**
* 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(),
/** 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(),
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: 25 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: 25 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 }
}
}