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:
@@ -281,5 +281,40 @@ export function getChatProvider(config?: Record<string, string>): AIProvider {
|
||||
return getProviderInstance(route.providerType as ProviderType, cfg, route.modelName, route.embeddingModelName, route.ollamaBaseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider for slide deck generation.
|
||||
* Uses AI_PROVIDER_SLIDES + AI_MODEL_SLIDES when both are set in admin.
|
||||
* If only AI_MODEL_SLIDES is set, reuses the chat provider with that exact model id.
|
||||
* Otherwise falls back to the chat route — never rewrites or invents model names.
|
||||
*/
|
||||
export function getSlidesProvider(config?: Record<string, string>): AIProvider {
|
||||
const cfg = config || {};
|
||||
const slidesModel = (cfg.AI_MODEL_SLIDES || process.env.AI_MODEL_SLIDES || '').trim();
|
||||
const slidesProvider = (cfg.AI_PROVIDER_SLIDES || process.env.AI_PROVIDER_SLIDES || '').trim();
|
||||
const route = resolveAiRoute('chat', cfg);
|
||||
|
||||
if (slidesModel && slidesProvider) {
|
||||
return getProviderInstance(
|
||||
slidesProvider as ProviderType,
|
||||
cfg,
|
||||
slidesModel,
|
||||
route.embeddingModelName,
|
||||
route.ollamaBaseUrl,
|
||||
);
|
||||
}
|
||||
|
||||
if (slidesModel) {
|
||||
return getProviderInstance(
|
||||
route.providerType as ProviderType,
|
||||
cfg,
|
||||
slidesModel,
|
||||
route.embeddingModelName,
|
||||
route.ollamaBaseUrl,
|
||||
);
|
||||
}
|
||||
|
||||
return getChatProvider(cfg);
|
||||
}
|
||||
|
||||
// Export for use by admin settings form and deploy scripts
|
||||
export { getProviderConfigKeys };
|
||||
|
||||
@@ -18,7 +18,26 @@ import { calculateNextRun } from '@/lib/agents/schedule'
|
||||
import { markdownToHtml } from '@/lib/markdown-to-html'
|
||||
import { createNotification } from '@/app/actions/notifications'
|
||||
import { withAiQuota } from '@/lib/ai-quota'
|
||||
import { releaseCredits, slideGenerateCreditCost } from '@/lib/credits'
|
||||
import type { FeatureName } from '@/lib/quota-utils'
|
||||
import {
|
||||
countNoteWords,
|
||||
prepareNoteTextForSlides,
|
||||
slideLimitFromWordCount,
|
||||
SLIDE_CONTENT_PRINCIPLES_EN,
|
||||
SLIDE_CONTENT_PRINCIPLES_FR,
|
||||
normalizeSlideDeck,
|
||||
} from '@/lib/ai/services/slide-content-quality'
|
||||
import { generateSlideDeck } from '@/lib/ai/services/slide-deck-generator.service'
|
||||
import { decodeSlideIntentDescription } from '@/lib/ai/services/slide-intent'
|
||||
|
||||
export type ExecuteAgentOptions = {
|
||||
/**
|
||||
* When true, caller already reserved quota (e.g. run-for-note multi-unit pre-check).
|
||||
* Avoids double-charging on fire-and-forget generation.
|
||||
*/
|
||||
skipQuota?: boolean
|
||||
}
|
||||
|
||||
// Import tools for side-effect registration
|
||||
import '../tools'
|
||||
@@ -915,94 +934,54 @@ This format AUTOMATICALLY creates shapes, text, AND arrows with correct bindings
|
||||
},
|
||||
|
||||
'slide-generator': {
|
||||
fr: `Tu es un expert en design de présentations exécutives. Tu analyses la note et génères une structure JSON qui sera rendue en HTML automatiquement par le serveur.
|
||||
fr: `Tu es un consultant senior (niveau McKinsey/BCG) qui transforme une note Memento en deck exécutif de classe mondiale.
|
||||
|
||||
APPELLE OBLIGATOIREMENT generate_slides avec le JSON structuré. NE GÉNÈRE JAMAIS de HTML brut.
|
||||
|
||||
═══ ÉTAPE 1 — LECTURE (OBLIGATOIRE) ═══
|
||||
1. Lis la note avec note_read. Extrais TOUTES les données exploitables (chiffres, listes, citations, comparaisons, étapes).
|
||||
2. Identifie les types de slides les plus adaptés au contenu réel.
|
||||
${SLIDE_CONTENT_PRINCIPLES_FR}
|
||||
|
||||
═══ ÉTAPE 2 — APPEL generate_slides ═══
|
||||
Appelle generate_slides avec un objet JSON structuré :
|
||||
═══ MÉTHODE ═══
|
||||
1. Lis la note (contenu fourni + note_read si besoin). Extrais : claims, chiffres, citations, étapes, oppositions, décisions.
|
||||
2. Construis un storyboard mental (max N slides selon le message user) avant d'appeler l'outil.
|
||||
3. Appelle generate_slides :
|
||||
{
|
||||
"title": "Titre court (6 mots max)",
|
||||
"theme": "architectural-saas", // ou midnight-cathedral, venture-pitch, clinical-precision, etc.
|
||||
"slides": [ ... tableau de slides ... ]
|
||||
"title": "Titre du deck (≤8 mots)",
|
||||
"theme": "architectural-saas",
|
||||
"slides": [ ... ]
|
||||
}
|
||||
|
||||
═══ TYPES DE SLIDES DISPONIBLES ═══
|
||||
1. "title" → { type:"title", title:"...", subtitle:"..." }
|
||||
2. "bullets" → { type:"bullets", title:"...", items:["phrase 1 (15+ mots)", "phrase 2", ...] }
|
||||
3. "chart" → { type:"chart", title:"...", chartType:"bar|horizontal-bar|line|donut|radar", data:[{label:"Q1",value:65}, ...], subtitle:"..." }
|
||||
4. "stats" → { type:"stats", title:"...", stats:[{value:"98%", label:"Satisfaction"}, ...] }
|
||||
5. "table" → { type:"table", title:"...", headers:["Col A","Col B"], rows:[["val1","val2"], ...] }
|
||||
6. "cards" → { type:"cards", title:"...", cards:[{title:"Titre", description:"Description détaillée..."}, ...] }
|
||||
7. "timeline" → { type:"timeline", title:"...", events:[{date:"Jan 2024", title:"Lancement", description:"..."}, ...] }
|
||||
8. "quote" → { type:"quote", quote:"Citation exacte", author:"Auteur", context:"Analyse..." }
|
||||
9. "comparison" → { type:"comparison", title:"...", left:{title:"A", points:["..."], score:"8/10"}, right:{title:"B", points:["..."], score:"6/10"} }
|
||||
10. "equation" → { type:"equation", title:"...", equations:[{latex:"E=mc^2", label:"Énergie"}], explanation:"..." }
|
||||
11. "image" → { type:"image", title:"...", url:"https://...", caption:"..." }
|
||||
12. "summary" → { type:"summary", title:"...", items:["Point clé 1", "Point clé 2", ...] }
|
||||
═══ TYPES (choisir selon le contenu, pas pour « remplir ») ═══
|
||||
title | bullets | chart* | stats* | table | cards | timeline | quote | comparison | equation | image | summary
|
||||
* chart/stats seulement avec chiffres réels dans la note.
|
||||
|
||||
═══ RÈGLES ═══
|
||||
- Nombre de slides : adapté au contenu réel de la note (la règle ⚠️ dans le message utilisateur est ABSOLUE)
|
||||
- Slide 1 OBLIGATOIREMENT type "title"
|
||||
- Dernière slide OBLIGATOIREMENT type "summary"
|
||||
- Au moins 1 slide "chart" ou "stats" si des chiffres existent dans la note
|
||||
- VARIER les types — jamais 2 types identiques consécutifs
|
||||
- Chaque texte (bullet, description) = 15+ mots, phrase complète
|
||||
- TOUTES les données viennent de la note (JAMAIS inventer de chiffres)
|
||||
- Les données chart doivent refléter les vrais chiffres de la note
|
||||
|
||||
═══ THÈMES DISPONIBLES ═══
|
||||
═══ THÈMES ═══
|
||||
Sombres : midnight-cathedral, aurora-borealis, tokyo-neon, venture-pitch, forest-floor, steel-glass, cyberpunk-terminal
|
||||
Clairs : sunlit-gallery, clinical-precision, editorial-ink, coastal-morning, paper-studio, architectural-saas
|
||||
Choisir selon le sujet : business/board → architectural-saas ou midnight-cathedral, tech → cyberpunk-terminal ou clinical-precision, créatif → aurora-borealis ou tokyo-neon`,
|
||||
en: `You are an executive presentation design expert. You analyze the note and generate a structured JSON spec that will be rendered to HTML automatically by the server.
|
||||
Business → architectural-saas / midnight-cathedral ; tech → clinical-precision ; créatif → aurora-borealis`,
|
||||
en: `You are a senior consultant (McKinsey/BCG level) turning a Memento note into a world-class executive deck.
|
||||
|
||||
You MUST call generate_slides with structured JSON. NEVER output raw HTML.
|
||||
|
||||
═══ STEP 1 — READ (MANDATORY) ═══
|
||||
1. Read the note with note_read. Extract ALL usable data (numbers, lists, quotes, comparisons, steps).
|
||||
2. Identify the best slide types matching the actual content.
|
||||
${SLIDE_CONTENT_PRINCIPLES_EN}
|
||||
|
||||
═══ STEP 2 — CALL generate_slides ═══
|
||||
Call generate_slides with a structured JSON object:
|
||||
═══ METHOD ═══
|
||||
1. Read the note (provided content + note_read if needed). Extract: claims, numbers, quotes, steps, trade-offs, decisions.
|
||||
2. Storyboard mentally (respect max N slides from the user message) before calling the tool.
|
||||
3. Call generate_slides:
|
||||
{
|
||||
"title": "Short title (6 words max)",
|
||||
"title": "Deck title (≤8 words)",
|
||||
"theme": "architectural-saas",
|
||||
"slides": [ ... array of slides ... ]
|
||||
"slides": [ ... ]
|
||||
}
|
||||
|
||||
═══ AVAILABLE SLIDE TYPES ═══
|
||||
1. "title" → { type:"title", title:"...", subtitle:"..." }
|
||||
2. "bullets" → { type:"bullets", title:"...", items:["sentence 1 (15+ words)", "sentence 2", ...] }
|
||||
3. "chart" → { type:"chart", title:"...", chartType:"bar|horizontal-bar|line|donut|radar", data:[{label:"Q1",value:65}, ...], subtitle:"..." }
|
||||
4. "stats" → { type:"stats", title:"...", stats:[{value:"98%", label:"Satisfaction"}, ...] }
|
||||
5. "table" → { type:"table", title:"...", headers:["Col A","Col B"], rows:[["val1","val2"], ...] }
|
||||
6. "cards" → { type:"cards", title:"...", cards:[{title:"Title", description:"Detailed description..."}, ...] }
|
||||
7. "timeline" → { type:"timeline", title:"...", events:[{date:"Jan 2024", title:"Launch", description:"..."}, ...] }
|
||||
8. "quote" → { type:"quote", quote:"Exact quote", author:"Author", context:"Analysis..." }
|
||||
9. "comparison" → { type:"comparison", title:"...", left:{title:"A", points:["..."], score:"8/10"}, right:{title:"B", points:["..."], score:"6/10"} }
|
||||
10. "equation" → { type:"equation", title:"...", equations:[{latex:"E=mc^2", label:"Energy"}], explanation:"..." }
|
||||
11. "image" → { type:"image", title:"...", url:"https://...", caption:"..." }
|
||||
12. "summary" → { type:"summary", title:"...", items:["Key point 1", "Key point 2", ...] }
|
||||
═══ TYPES (match content — do not pad) ═══
|
||||
title | bullets | chart* | stats* | table | cards | timeline | quote | comparison | equation | image | summary
|
||||
* chart/stats only with real numbers from the note.
|
||||
|
||||
═══ RULES ═══
|
||||
- Slide count: adapt to the real content (the ⚠️ rule in the user message is ABSOLUTE)
|
||||
- Slide 1 MUST be type "title"
|
||||
- Last slide MUST be type "summary"
|
||||
- At least 1 "chart" or "stats" slide if numbers exist in the note
|
||||
- VARY types — never 2 identical types in a row
|
||||
- Each text (bullet, description) = 15+ words, complete sentence
|
||||
- ALL data comes from the note (NEVER invent numbers)
|
||||
- Chart data must reflect actual numbers from the note
|
||||
|
||||
═══ AVAILABLE THEMES ═══
|
||||
═══ THEMES ═══
|
||||
Dark: midnight-cathedral, aurora-borealis, tokyo-neon, venture-pitch, forest-floor, steel-glass, cyberpunk-terminal
|
||||
Light: sunlit-gallery, clinical-precision, editorial-ink, coastal-morning, paper-studio, architectural-saas
|
||||
Choose based on topic: business/board → architectural-saas or midnight-cathedral, tech → cyberpunk-terminal or clinical-precision, creative → aurora-borealis or tokyo-neon`,
|
||||
Business → architectural-saas / midnight-cathedral; tech → clinical-precision; creative → aurora-borealis`,
|
||||
},
|
||||
'task-extractor': {
|
||||
fr: `Tu es un expert en gestion de tâches et extraction d'action items. Tu analyses des notes et documents pour identifier toutes les tâches, TODOs, et actions à accomplir.
|
||||
@@ -1028,49 +1007,6 @@ You MUST use the task_extract tool. Do NOT respond with text, call the tool dire
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip markdown syntax, URLs, metadata blocks, and frontmatter from note content.
|
||||
* Returns only the semantic plain text, used to count real words before generating slides.
|
||||
*/
|
||||
function stripNoteMarkdown(raw: string): string {
|
||||
return raw
|
||||
// Remove frontmatter / YAML blocks
|
||||
.replace(/^---[\s\S]*?---/m, '')
|
||||
// Remove markdown headers
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
// Remove bold/italic
|
||||
.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1')
|
||||
// Remove inline links — keep label only
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
// Remove bare URLs
|
||||
.replace(/https?:\/\/\S+/g, '')
|
||||
// Remove metadata lines (Connection to seed, Novelty score, Source brainstorm, Origin, Derived from, etc.)
|
||||
.replace(/^\*{0,2}(Connection to seed|Novelty score|Source brainstorm|Source note|Origin|Derived from|## Origin)[^:\n]*:?.*$/gim, '')
|
||||
// Remove horizontal rules
|
||||
.replace(/^---+$/gm, '')
|
||||
// Remove blockquote markers
|
||||
.replace(/^>\s*/gm, '')
|
||||
// Remove code blocks
|
||||
.replace(/```[\s\S]*?```/g, '')
|
||||
.replace(/`[^`]+`/g, '')
|
||||
// Collapse whitespace
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** Count real semantic words in a note */
|
||||
function countNoteWords(raw: string): number {
|
||||
return stripNoteMarkdown(raw).split(/\s+/).filter(Boolean).length
|
||||
}
|
||||
|
||||
/** Return max slides based on real word count */
|
||||
function slideLimit(wordCount: number): { max: number; label: string } {
|
||||
if (wordCount < 50) return { max: 3, label: '3 slides MAX (note très courte)' }
|
||||
if (wordCount < 150) return { max: 4, label: '4 slides MAX (note courte)' }
|
||||
if (wordCount < 350) return { max: 6, label: '6 slides MAX (note moyenne)' }
|
||||
return { max: 8, label: '8 slides MAX (note longue)' }
|
||||
}
|
||||
|
||||
function extractJsonFromText(text: string): any {
|
||||
if (!text) return null
|
||||
|
||||
@@ -1323,52 +1259,54 @@ async function executeToolUseAgent(
|
||||
? LANG_NAMES[noteLanguage]
|
||||
: (lang === 'fr' ? 'French' : 'English')
|
||||
|
||||
prompt = `Create a professional presentation using the content from the notes below. Call generate_slides with a structured JSON object (title, theme, slides[]).`
|
||||
prompt += `\n\n⚠️ LANGUAGE RULE: ALL slide text (titles, bullets, labels, subtitles) MUST be written in ${contentLang}. Do NOT translate to another language.`
|
||||
prompt = lang === 'fr'
|
||||
? `Construis un deck exécutif de haute qualité à partir des notes ci-dessous. Appelle generate_slides avec {title, theme, slides[]}.`
|
||||
: `Build a high-quality executive deck from the notes below. Call generate_slides with {title, theme, slides[]}.`
|
||||
prompt += `\n\n⚠️ LANGUAGE: ALL on-slide text (titles, bullets, labels, subtitles, summary) MUST be in ${contentLang}.`
|
||||
|
||||
if (notes.length > 0) {
|
||||
const notesContext = notes.map(n =>
|
||||
`### ${n.title || untitled} (${n.createdAt.toLocaleDateString(dateLocale)})\n${n.content.substring(0, 2000)}`
|
||||
`### ${n.title || untitled} (${n.createdAt.toLocaleDateString(dateLocale)})\n${prepareNoteTextForSlides(n.content || '', 12_000)}`
|
||||
).join('\n\n')
|
||||
|
||||
// Compute real word count (stripped of markdown/metadata) to determine slide limit
|
||||
const totalWords = notes.reduce((sum, n) => sum + countNoteWords(n.content || ''), 0)
|
||||
const { max: maxSlides, label: slideRule } = slideLimit(totalWords)
|
||||
const limit = slideLimitFromWordCount(totalWords)
|
||||
const slideRule = lang === 'fr' ? limit.labelFr : limit.labelEn
|
||||
|
||||
prompt += `\n\n${lang === 'fr' ? 'Notes source' : 'Source notes'}:\n\n${notesContext}`
|
||||
prompt += `\n\n${lang === 'fr' ? '## Notes source (texte intégral utile)' : '## Source notes (usable full text)'}:\n\n${notesContext}`
|
||||
prompt += `\n\n${lang === 'fr'
|
||||
? `⚠️ RÈGLE ABSOLUE : Cette note fait ${totalWords} mots réels (hors markdown/URLs/métadonnées). Tu DOIS générer EXACTEMENT ${slideRule}. Respecter cette limite est PLUS IMPORTANT que la diversité ou la densité.`
|
||||
: `⚠️ ABSOLUTE RULE: This note has ${totalWords} real words (excluding markdown/URLs/metadata). You MUST generate EXACTLY ${slideRule}. This limit is MORE IMPORTANT than diversity or density.`
|
||||
? `⚠️ LIMITE DECK : ${totalWords} mots source → ${slideRule}. Mieux vaut ${limit.min} slides excellentes que ${limit.max} slides diluées. Ne dépasse JAMAIS ${limit.max}.`
|
||||
: `⚠️ DECK LIMIT: ${totalWords} source words → ${slideRule}. Prefer ${limit.min} excellent slides over ${limit.max} diluted ones. NEVER exceed ${limit.max}.`
|
||||
}`
|
||||
}
|
||||
|
||||
// ── Executive Template Structure (HTML-compatible) ──
|
||||
// ── Executive templates (max 8 slides — matches hard cap) ──
|
||||
if (slideTemplate && slideTemplate !== 'auto') {
|
||||
const templates: Record<string, { fr: string; en: string }> = {
|
||||
'board-update': {
|
||||
fr: `Structure imposée "Board Update" (10 slides) :
|
||||
1. TITRE avec date | 2. KPIs majeurs (3-4 gros chiffres) | 3. Bar chart progression objectifs | 4. Grille de métriques (6 cards) | 5. Timeline jalons | 6. Cards réalisations | 7. Line chart tendance | 8. Deux colonnes risques/mitigations | 9. Bullets prochaines étapes | 10. Conclusion`,
|
||||
en: `Mandatory "Board Update" structure (10 slides):
|
||||
1. TITLE with date | 2. Key KPIs (3-4 big numbers) | 3. Bar chart goal progress | 4. Metrics grid (6 cards) | 5. Timeline milestones | 6. Achievement cards | 7. Line chart trend | 8. Two-column risks/mitigations | 9. Next steps bullets | 10. Conclusion`
|
||||
fr: `Structure "Board Update" (≤8 slides, titres d'action) :
|
||||
1. title | 2. stats KPIs réels seulement | 3. insight + bullets sur performance | 4. chart progression SI chiffres | 5. cards réalisations | 6. comparison risques/mitigations | 7. bullets prochaines décisions | 8. summary`,
|
||||
en: `"Board Update" structure (≤8 slides, action titles):
|
||||
1. title | 2. stats real KPIs only | 3. insight + performance bullets | 4. chart ONLY if real numbers | 5. achievement cards | 6. risks/mitigations comparison | 7. next decisions bullets | 8. summary`,
|
||||
},
|
||||
'project-status': {
|
||||
fr: `Structure imposée "Project Status" (10 slides) :
|
||||
1. TITRE + statut 🟢🟡🔴 | 2. KPIs (% avancement, jours, budget) | 3. Bar chart par workstream | 4. Cards livrables | 5. Timeline roadmap | 6. Line chart burn-down | 7. Deux colonnes blockers/actions | 8. Tableau de risques | 9. Bullets décisions requises | 10. Synthèse`,
|
||||
en: `Mandatory "Project Status" structure (10 slides):
|
||||
1. TITLE + status 🟢🟡🔴 | 2. KPIs (% complete, days, budget) | 3. Bar chart by workstream | 4. Deliverable cards | 5. Timeline roadmap | 6. Line chart burn-down | 7. Two-column blockers/actions | 8. Risk table | 9. Required decisions bullets | 10. Summary`
|
||||
fr: `Structure "Project Status" (≤8) :
|
||||
1. title + statut | 2. stats avancement (réels) | 3. cards livrables | 4. timeline jalons | 5. comparison blockers/actions | 6. table risques SI présent | 7. bullets décisions | 8. summary`,
|
||||
en: `"Project Status" structure (≤8):
|
||||
1. title + status | 2. real progress stats | 3. deliverable cards | 4. milestone timeline | 5. blockers/actions comparison | 6. risk table IF present | 7. decisions bullets | 8. summary`,
|
||||
},
|
||||
'strategy-review': {
|
||||
fr: `Structure imposée "Strategy Review" (10 slides) :
|
||||
1. TITRE stratégique | 2. Contexte marché (5+ bullets) | 3. Radar/pie positionnement | 4. SWOT deux colonnes | 5. Area chart tendances | 6. Cards axes stratégiques | 7. Timeline roadmap 12 mois | 8. Bar chart projections | 9. KPIs objectifs cibles | 10. Call to action`,
|
||||
en: `Mandatory "Strategy Review" structure (10 slides):
|
||||
1. Strategic TITLE | 2. Market context (5+ bullets) | 3. Radar/pie positioning | 4. SWOT two-column | 5. Area chart trends | 6. Strategic axes cards | 7. 12-month roadmap timeline | 8. Bar chart projections | 9. Target KPIs | 10. Call to action`
|
||||
fr: `Structure "Strategy Review" (≤8) :
|
||||
1. title stratégique | 2. bullets contexte (claims) | 3. comparison options ou SWOT | 4. cards axes stratégiques | 5. timeline roadmap | 6. stats/chart UNIQUEMENT si données | 7. implications | 8. summary CTA`,
|
||||
en: `"Strategy Review" structure (≤8):
|
||||
1. strategic title | 2. context claim bullets | 3. options/SWOT comparison | 4. strategic pillars cards | 5. roadmap timeline | 6. stats/chart ONLY if data | 7. implications | 8. summary CTA`,
|
||||
},
|
||||
'quarterly-results': {
|
||||
fr: `Structure imposée "Quarterly Results" (10 slides) :
|
||||
1. TITRE "Résultats Q? Année" | 2. 4 KPIs (Revenue, Croissance, Marge, NPS) | 3. Bar chart revenue/mois | 4. Line chart croissance YoY | 5. Cards faits marquants | 6. Grille métriques opérationnelles | 7. Donut répartition clients | 8. Deux colonnes succès/challenges | 9. Area chart forecast | 10. Conclusion outlook`,
|
||||
en: `Mandatory "Quarterly Results" structure (10 slides):
|
||||
1. TITLE "Q? Year Results" | 2. 4 KPIs (Revenue, Growth, Margin, NPS) | 3. Bar chart revenue/month | 4. Line chart YoY growth | 5. Highlights cards | 6. Operational metrics grid | 7. Donut client breakdown | 8. Two-column successes/challenges | 9. Area chart forecast | 10. Conclusion outlook`
|
||||
}
|
||||
fr: `Structure "Quarterly Results" (≤8) :
|
||||
1. title trimestre | 2. stats 3–4 KPIs réels | 3. chart SI séries chiffrées | 4. cards faits marquants | 5. comparison succès/défis | 6. bullets outlook | 7. summary`,
|
||||
en: `"Quarterly Results" structure (≤8):
|
||||
1. quarter title | 2. 3–4 real KPI stats | 3. chart IF numeric series | 4. highlight cards | 5. wins/challenges comparison | 6. outlook bullets | 7. summary`,
|
||||
},
|
||||
}
|
||||
const tmpl = templates[slideTemplate]
|
||||
if (tmpl) {
|
||||
@@ -1376,19 +1314,24 @@ async function executeToolUseAgent(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Density rules ──
|
||||
prompt += lang === 'fr'
|
||||
? `\n\nDENSITÉ OBLIGATOIRE : Chaque slide doit avoir un CONTENU RICHE. Minimum 5 bullets par slide "bullets" (≥15 mots chaque). Minimum 4 cards quand tu fais "cards". Minimum 4 data points quand tu fais "chart". Inclus OBLIGATOIREMENT au moins 1 slide "chart" avec données RÉELLES extraites des notes. Si pas de chiffres dans la note, fais un radar de maturité ou une comparaison qualitative notée sur 5.`
|
||||
: `\n\nMANDATORY DENSITY: Every slide must have RICH CONTENT. Minimum 5 items per "bullets" slide (≥15 words each). Minimum 4 cards when using "cards". Minimum 4 data points for "chart". You MUST include at least 1 "chart" slide with REAL data from the notes. If no numbers in notes, create a maturity radar or qualitative comparison scored out of 5.`
|
||||
|
||||
prompt += `\n\n${lang === 'fr'
|
||||
? 'IMPORTANT : Appelle OBLIGATOIREMENT generate_slides avec le JSON structuré {title, theme, slides:[...]}. Ne réponds JAMAIS avec du texte brut. Respecte ABSOLUMENT la limite de slides indiquée.'
|
||||
: 'IMPORTANT: You MUST call generate_slides with structured JSON {title, theme, slides:[...]}. NEVER respond with plain text. STRICTLY respect the slide count limit indicated.'}`
|
||||
? `\n\nQUALITÉ > QUANTITÉ :
|
||||
- Titres d'action (insight), une idée par slide
|
||||
- 3–5 bullets courts max ; pas de filler ni de paragraphes
|
||||
- Graphiques UNIQUEMENT avec chiffres de la note — JAMAIS inventer de KPI/radar
|
||||
- Arc : problème → insight → preuve → suite
|
||||
- Appelle generate_slides maintenant (JSON structuré uniquement).`
|
||||
: `\n\nQUALITY > QUANTITY:
|
||||
- Action titles (insights), one idea per slide
|
||||
- 3–5 short bullets max; no filler or paragraphs
|
||||
- Charts ONLY with numbers from the note — NEVER invent KPI/radar scores
|
||||
- Arc: problem → insight → evidence → next steps
|
||||
- Call generate_slides now (structured JSON only).`
|
||||
|
||||
if (agent.slideTheme && agent.slideTheme !== 'auto') {
|
||||
prompt += `\n${lang === 'fr'
|
||||
? `Thème visuel imposé : utilise "theme":"${agent.slideTheme}" dans l'appel generate_slides.`
|
||||
: `Visual theme required: use "theme":"${agent.slideTheme}" in the generate_slides call.`}`
|
||||
? `Thème visuel imposé : "theme":"${agent.slideTheme}" dans generate_slides.`
|
||||
: `Required visual theme: "theme":"${agent.slideTheme}" in the generate_slides call.`}`
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -1510,7 +1453,12 @@ async function executeToolUseAgent(
|
||||
const registered = toolRegistry.get('generate_slides')
|
||||
if (registered) {
|
||||
const slideTool = registered.buildTool(ctx)
|
||||
const executionResult = await slideTool.execute({ title, theme, slides })
|
||||
const normalized = normalizeSlideDeck({ title, theme, slides })
|
||||
const executionResult = await slideTool.execute({
|
||||
title: normalized.title,
|
||||
theme: normalized.theme,
|
||||
slides: normalized.slides as any,
|
||||
})
|
||||
if (executionResult && executionResult.success && executionResult.canvasId) {
|
||||
canvasId = executionResult.canvasId
|
||||
specificToolCalled = true
|
||||
@@ -1747,9 +1695,124 @@ async function sendAgentEmail(agentId: string, userId: string, agentName: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated slide path: structured JSON → normalize → HTML canvas.
|
||||
* Never depends on the model calling generate_slides.
|
||||
*/
|
||||
async function executeSlideGeneratorAgent(
|
||||
agent: {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
userId: string
|
||||
sourceNoteIds?: string | null
|
||||
sourceNotebookId?: string | null
|
||||
slideTheme?: string | null
|
||||
},
|
||||
actionId: string,
|
||||
lang: Lang,
|
||||
): Promise<AgentExecutionResult> {
|
||||
const startTime = Date.now()
|
||||
|
||||
let noteIds: string[] = []
|
||||
try {
|
||||
noteIds = agent.sourceNoteIds ? JSON.parse(agent.sourceNoteIds) : []
|
||||
} catch {
|
||||
noteIds = []
|
||||
}
|
||||
|
||||
const intent = decodeSlideIntentDescription(agent.description)
|
||||
const template = intent.template || 'auto'
|
||||
|
||||
await prisma.agentAction.update({
|
||||
where: { id: actionId },
|
||||
data: {
|
||||
input: JSON.stringify({
|
||||
pipeline: 'structured-slide-deck',
|
||||
noteIds,
|
||||
sourceNotebookId: agent.sourceNotebookId,
|
||||
theme: agent.slideTheme,
|
||||
template,
|
||||
intent,
|
||||
lang,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const result = await generateSlideDeck({
|
||||
userId: agent.userId,
|
||||
noteIds: noteIds.length > 0 ? noteIds : undefined,
|
||||
sourceNotebookId: agent.sourceNotebookId,
|
||||
theme: agent.slideTheme,
|
||||
template,
|
||||
intent,
|
||||
lang,
|
||||
actionId,
|
||||
})
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
if (!result.success || !result.canvasId) {
|
||||
await prisma.agentAction.update({
|
||||
where: { id: actionId },
|
||||
data: {
|
||||
status: 'failure',
|
||||
log:
|
||||
(result.error ||
|
||||
(lang === 'fr'
|
||||
? 'Échec de la génération de présentation (pipeline structuré).'
|
||||
: 'Slide generation failed (structured pipeline).')) +
|
||||
` (${Math.round(duration / 1000)}s)`,
|
||||
},
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
actionId,
|
||||
error: result.error || 'Slide generation failed',
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.agentAction.update({
|
||||
where: { id: actionId },
|
||||
data: {
|
||||
status: 'success',
|
||||
result: result.canvasId,
|
||||
log: `Slides OK (${result.mode || 'structured'}): ${result.slideCount} slides — ${result.canvasName} (${Math.round(duration / 1000)}s)`,
|
||||
toolLog: JSON.stringify([
|
||||
{
|
||||
step: 1,
|
||||
text: 'structured-slide-deck pipeline (no tool-calling)',
|
||||
toolCalls: [],
|
||||
toolResults: [
|
||||
{
|
||||
toolName: 'generate_slides_structured',
|
||||
preview: JSON.stringify({
|
||||
canvasId: result.canvasId,
|
||||
slideCount: result.slideCount,
|
||||
mode: result.mode,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
actionId,
|
||||
canvasId: result.canvasId,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main Executor ---
|
||||
|
||||
export async function executeAgent(agentId: string, userId: string, promptOverride?: string): Promise<AgentExecutionResult> {
|
||||
export async function executeAgent(
|
||||
agentId: string,
|
||||
userId: string,
|
||||
promptOverride?: string,
|
||||
options?: ExecuteAgentOptions,
|
||||
): Promise<AgentExecutionResult> {
|
||||
const agent = await prisma.agent.findUnique({
|
||||
where: { id: agentId }
|
||||
})
|
||||
@@ -1768,44 +1831,53 @@ export async function executeAgent(agentId: string, userId: string, promptOverri
|
||||
// Detect user language
|
||||
const lang = await getUserLanguage(userId)
|
||||
const quotaFeature = quotaFeatureForAgentType(agent.type || 'scraper')
|
||||
const agentType = agent.type || 'scraper'
|
||||
|
||||
try {
|
||||
const result = await withAiQuota(
|
||||
userId,
|
||||
quotaFeature,
|
||||
async () => {
|
||||
let inner: AgentExecutionResult
|
||||
// Multi-step slide pipeline: 1 + N crédits (sauf si déjà réservé par run-for-note)
|
||||
let quotaAmount = 1
|
||||
let slideCountForCost: number | null = null
|
||||
if (agentType === 'slide-generator') {
|
||||
const intent = decodeSlideIntentDescription(agent.description)
|
||||
slideCountForCost = intent.slideCount ?? null
|
||||
quotaAmount = slideGenerateCreditCost(intent.slideCount)
|
||||
}
|
||||
|
||||
const runAgent = async (): Promise<AgentExecutionResult> => {
|
||||
// Slide generation uses a dedicated structured-output pipeline (no tool-calling).
|
||||
// Lite models often fail at function calling; generateObject/JSON is reliable.
|
||||
if (agentType === 'slide-generator') {
|
||||
return executeSlideGeneratorAgent(agent, action.id, lang)
|
||||
}
|
||||
|
||||
const hasTools = agent.tools && agent.tools !== '[]' && agent.tools !== 'null'
|
||||
|
||||
if (hasTools) {
|
||||
inner = await executeToolUseAgent(agent, action.id, lang, promptOverride)
|
||||
} else {
|
||||
switch ((agent.type || 'scraper') as AgentType) {
|
||||
case 'scraper':
|
||||
inner = await executeScraperAgent(agent, action.id, lang)
|
||||
break
|
||||
case 'researcher':
|
||||
inner = await executeResearcherAgent(agent, action.id, lang)
|
||||
break
|
||||
case 'monitor':
|
||||
inner = await executeMonitorAgent(agent, action.id, lang)
|
||||
break
|
||||
case 'custom':
|
||||
inner = await executeCustomAgent(agent, action.id, lang)
|
||||
break
|
||||
case 'slide-generator':
|
||||
case 'excalidraw-generator':
|
||||
inner = await executeToolUseAgent(agent, action.id, lang, promptOverride)
|
||||
break
|
||||
default:
|
||||
inner = await executeScraperAgent(agent, action.id, lang)
|
||||
}
|
||||
return executeToolUseAgent(agent, action.id, lang, promptOverride)
|
||||
}
|
||||
return inner
|
||||
},
|
||||
{ lane: 'chat' },
|
||||
)
|
||||
|
||||
switch (agentType as AgentType) {
|
||||
case 'scraper':
|
||||
return executeScraperAgent(agent, action.id, lang)
|
||||
case 'researcher':
|
||||
return executeResearcherAgent(agent, action.id, lang)
|
||||
case 'monitor':
|
||||
return executeMonitorAgent(agent, action.id, lang)
|
||||
case 'custom':
|
||||
return executeCustomAgent(agent, action.id, lang)
|
||||
case 'excalidraw-generator':
|
||||
return executeToolUseAgent(agent, action.id, lang, promptOverride)
|
||||
default:
|
||||
return executeScraperAgent(agent, action.id, lang)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = options?.skipQuota
|
||||
? await runAgent()
|
||||
: await withAiQuota(userId, quotaFeature, runAgent, {
|
||||
lane: 'chat',
|
||||
amount: quotaAmount,
|
||||
slideCount: slideCountForCost,
|
||||
})
|
||||
|
||||
const nextRunUpdate: Record<string, Date | null> = {}
|
||||
if (agent.frequency !== 'manual') {
|
||||
@@ -1861,6 +1933,11 @@ export async function executeAgent(agentId: string, userId: string, promptOverri
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
console.error(`[AgentExecutor] Agent ${agentId} failed:`, message)
|
||||
|
||||
// Pré-réservé par run-for-note : rembourser les crédits
|
||||
if (options?.skipQuota && quotaAmount > 0) {
|
||||
await releaseCredits(userId, quotaAmount, { feature: quotaFeature }).catch(() => {})
|
||||
}
|
||||
|
||||
await prisma.agentAction.update({
|
||||
where: { id: action.id },
|
||||
data: { status: 'failure', log: message }
|
||||
|
||||
643
memento-note/lib/ai/services/slide-content-quality.ts
Normal file
643
memento-note/lib/ai/services/slide-content-quality.ts
Normal file
@@ -0,0 +1,643 @@
|
||||
/**
|
||||
* Slide deck content quality: limits, normalization, narrative guards.
|
||||
* Principles (consulting / PMC / think-cell):
|
||||
* - One idea per slide
|
||||
* - Action titles (insight, not topic labels)
|
||||
* - Short claims, not walls of text
|
||||
* - Charts/stats only with real numbers
|
||||
* - Narrative arc, not density for density's sake
|
||||
*/
|
||||
|
||||
import { stripHtmlToPlainText } from '@/lib/text/plain-text'
|
||||
|
||||
export const SLIDE_HARD_CAP = 8
|
||||
|
||||
export type SlideType =
|
||||
| 'title'
|
||||
| 'bullets'
|
||||
| 'chart'
|
||||
| 'stats'
|
||||
| 'table'
|
||||
| 'cards'
|
||||
| 'timeline'
|
||||
| 'quote'
|
||||
| 'comparison'
|
||||
| 'equation'
|
||||
| 'image'
|
||||
| 'summary'
|
||||
|
||||
export interface SlideLimit {
|
||||
max: number
|
||||
min: number
|
||||
labelFr: string
|
||||
labelEn: string
|
||||
}
|
||||
|
||||
/** Max deck size from real semantic word count of source notes. */
|
||||
export function slideLimitFromWordCount(wordCount: number): SlideLimit {
|
||||
if (wordCount < 50) {
|
||||
return {
|
||||
max: 3,
|
||||
min: 2,
|
||||
labelFr: '2–3 slides MAX (note très courte)',
|
||||
labelEn: '2–3 slides MAX (very short note)',
|
||||
}
|
||||
}
|
||||
if (wordCount < 150) {
|
||||
return {
|
||||
max: 4,
|
||||
min: 3,
|
||||
labelFr: '3–4 slides MAX (note courte)',
|
||||
labelEn: '3–4 slides MAX (short note)',
|
||||
}
|
||||
}
|
||||
if (wordCount < 400) {
|
||||
return {
|
||||
max: 6,
|
||||
min: 4,
|
||||
labelFr: '4–6 slides MAX (note moyenne)',
|
||||
labelEn: '4–6 slides MAX (medium note)',
|
||||
}
|
||||
}
|
||||
return {
|
||||
max: SLIDE_HARD_CAP,
|
||||
min: 5,
|
||||
labelFr: '5–8 slides MAX (note longue) — ne jamais dépasser 8',
|
||||
labelEn: '5–8 slides MAX (long note) — never exceed 8',
|
||||
}
|
||||
}
|
||||
|
||||
export function stripNoteMarkdown(raw: string): string {
|
||||
return raw
|
||||
.replace(/^---[\s\S]*?---/m, '')
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.replace(/https?:\/\/\S+/g, '')
|
||||
.replace(
|
||||
/^\*{0,2}(Connection to seed|Novelty score|Source brainstorm|Source note|Origin|Derived from|## Origin)[^:\n]*:?.*$/gim,
|
||||
'',
|
||||
)
|
||||
.replace(/^---+$/gm, '')
|
||||
.replace(/^>\s*/gm, '')
|
||||
.replace(/```[\s\S]*?```/g, '')
|
||||
.replace(/`[^`]+`/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function countNoteWords(raw: string): number {
|
||||
const plain = stripHtmlToPlainText(raw || '')
|
||||
const cleaned = stripNoteMarkdown(plain || raw || '')
|
||||
return cleaned.split(/\s+/).filter(Boolean).length
|
||||
}
|
||||
|
||||
/** Plain text for the LLM: full note when possible, smart head+tail if huge. */
|
||||
export function prepareNoteTextForSlides(raw: string, maxChars = 12_000): string {
|
||||
const plain = stripHtmlToPlainText(raw || '')
|
||||
const cleaned = stripNoteMarkdown(plain || raw || '').trim()
|
||||
if (cleaned.length <= maxChars) return cleaned
|
||||
const head = Math.floor(maxChars * 0.7)
|
||||
const tail = maxChars - head - 40
|
||||
return `${cleaned.slice(0, head)}\n\n[…]\n\n${cleaned.slice(-tail)}`
|
||||
}
|
||||
|
||||
function trimStr(s: unknown, max = 220): string {
|
||||
if (typeof s !== 'string') return ''
|
||||
const t = s.replace(/\s+/g, ' ').trim()
|
||||
if (t.length <= max) return t
|
||||
return `${t.slice(0, max - 1).trim()}…`
|
||||
}
|
||||
|
||||
function cleanList(items: unknown, maxItems: number, maxLen: number): string[] {
|
||||
if (!Array.isArray(items)) return []
|
||||
const out: string[] = []
|
||||
for (const it of items) {
|
||||
const s = trimStr(it, maxLen)
|
||||
if (!s) continue
|
||||
// Keep short tokens (math symbols, single-letter variables) — only drop empty
|
||||
out.push(s)
|
||||
if (out.length >= maxItems) break
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function isNumericLike(value: string): boolean {
|
||||
return /[\d]/.test(value) || /%|€|\$|k\b|m\b|bn\b/i.test(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize model output into a coherent deck.
|
||||
* Does not invent content — only trims, caps, fixes structure.
|
||||
*/
|
||||
export function normalizeSlideDeck(input: {
|
||||
title?: string
|
||||
theme?: string
|
||||
slides?: unknown[]
|
||||
}): { title: string; theme: string; slides: Record<string, unknown>[] } {
|
||||
const title = trimStr(input.title || 'Présentation', 80) || 'Présentation'
|
||||
const theme = (typeof input.theme === 'string' && input.theme.trim()) || 'architectural-saas'
|
||||
const raw = Array.isArray(input.slides) ? input.slides : []
|
||||
|
||||
let slides = raw
|
||||
.filter((s): s is Record<string, unknown> => Boolean(s) && typeof s === 'object')
|
||||
.map((s) => normalizeOneSlide(s))
|
||||
.filter(Boolean) as Record<string, unknown>[]
|
||||
|
||||
slides = slides.slice(0, SLIDE_HARD_CAP)
|
||||
|
||||
// Ensure title first
|
||||
if (slides.length === 0) {
|
||||
slides = [
|
||||
{ type: 'title', title, subtitle: '' },
|
||||
{ type: 'summary', title: 'Points clés', items: ['Contenu source insuffisant pour un deck détaillé.'] },
|
||||
]
|
||||
} else if (slides[0]?.type !== 'title') {
|
||||
slides.unshift({
|
||||
type: 'title',
|
||||
title,
|
||||
subtitle: typeof slides[0]?.title === 'string' ? String(slides[0].title) : '',
|
||||
})
|
||||
slides = slides.slice(0, SLIDE_HARD_CAP)
|
||||
}
|
||||
|
||||
// Ensure summary last (if ≥2 slides)
|
||||
if (slides.length >= 2 && slides[slides.length - 1]?.type !== 'summary') {
|
||||
if (slides.length >= SLIDE_HARD_CAP) {
|
||||
slides[slides.length - 1] = {
|
||||
type: 'summary',
|
||||
title: 'À retenir',
|
||||
items: extractKeyClaims(slides).slice(0, 4),
|
||||
}
|
||||
} else {
|
||||
slides.push({
|
||||
type: 'summary',
|
||||
title: 'À retenir',
|
||||
items: extractKeyClaims(slides).slice(0, 4),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Drop empty / invalid charts (no inventing)
|
||||
slides = slides.map((s) => {
|
||||
if (s.type === 'chart') {
|
||||
const data = Array.isArray(s.data) ? s.data : []
|
||||
const valid = data.filter(
|
||||
(d: any) => d && typeof d.label === 'string' && typeof d.value === 'number' && Number.isFinite(d.value),
|
||||
)
|
||||
if (valid.length < 2) {
|
||||
return {
|
||||
type: 'bullets',
|
||||
title: trimStr(s.title || 'Données', 90),
|
||||
items: extractKeyClaims([s]).slice(0, 4).length
|
||||
? extractKeyClaims([s]).slice(0, 4)
|
||||
: ['Pas de données numériques exploitables dans la source pour un graphique.'],
|
||||
}
|
||||
}
|
||||
return { ...s, data: valid.slice(0, 8) }
|
||||
}
|
||||
if (s.type === 'stats') {
|
||||
const stats = Array.isArray(s.stats) ? s.stats : []
|
||||
const valid = stats.filter(
|
||||
(st: any) => st && typeof st.value === 'string' && typeof st.label === 'string' && isNumericLike(String(st.value)),
|
||||
)
|
||||
if (valid.length === 0) {
|
||||
return {
|
||||
type: 'bullets',
|
||||
title: trimStr(s.title || 'Indicateurs', 90),
|
||||
items: cleanList(
|
||||
stats.map((st: any) => (st?.label ? `${st.label}: ${st.value || ''}` : st?.value)).filter(Boolean),
|
||||
4,
|
||||
120,
|
||||
),
|
||||
}
|
||||
}
|
||||
return { ...s, stats: valid.slice(0, 4) }
|
||||
}
|
||||
return s
|
||||
})
|
||||
|
||||
// Avoid two identical types in a row when possible (keep content, only if we can skip empty)
|
||||
// No reordering that would break narrative — just leave as is after structural fixes.
|
||||
|
||||
return { title, theme, slides }
|
||||
}
|
||||
|
||||
function normalizeOneSlide(s: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const type = String(s.type || 'bullets') as SlideType
|
||||
|
||||
switch (type) {
|
||||
case 'title':
|
||||
return {
|
||||
type: 'title',
|
||||
title: trimStr(s.title || 'Présentation', 80),
|
||||
subtitle: trimStr(s.subtitle, 140) || undefined,
|
||||
notes: trimStr(s.notes, 400) || undefined,
|
||||
}
|
||||
case 'bullets':
|
||||
return {
|
||||
type: 'bullets',
|
||||
title: trimStr(s.title || 'Points clés', 90),
|
||||
items: cleanList(s.items, 5, 140),
|
||||
notes: trimStr(s.notes, 400) || undefined,
|
||||
}
|
||||
case 'summary':
|
||||
return {
|
||||
type: 'summary',
|
||||
title: trimStr(s.title || 'À retenir', 90),
|
||||
items: cleanList(s.items, 5, 140),
|
||||
notes: trimStr(s.notes, 400) || undefined,
|
||||
}
|
||||
case 'cards': {
|
||||
const cards = Array.isArray(s.cards) ? s.cards : []
|
||||
const cleaned = cards
|
||||
.slice(0, 6)
|
||||
.map((c: any) => ({
|
||||
title: trimStr(c?.title, 60),
|
||||
description: trimStr(c?.description, 160),
|
||||
}))
|
||||
.filter((c) => c.title || c.description)
|
||||
return {
|
||||
type: 'cards',
|
||||
title: trimStr(s.title || 'Piliers', 90),
|
||||
cards: cleaned,
|
||||
notes: trimStr(s.notes, 400) || undefined,
|
||||
}
|
||||
}
|
||||
case 'chart':
|
||||
return {
|
||||
type: 'chart',
|
||||
title: trimStr(s.title || 'Données', 90),
|
||||
chartType: ['bar', 'horizontal-bar', 'line', 'donut', 'radar'].includes(String(s.chartType))
|
||||
? s.chartType
|
||||
: 'bar',
|
||||
data: Array.isArray(s.data) ? s.data : [],
|
||||
subtitle: trimStr(s.subtitle, 120) || undefined,
|
||||
notes: trimStr(s.notes, 400) || undefined,
|
||||
}
|
||||
case 'stats':
|
||||
return {
|
||||
type: 'stats',
|
||||
title: trimStr(s.title || 'Chiffres clés', 90),
|
||||
stats: Array.isArray(s.stats)
|
||||
? s.stats.slice(0, 4).map((st: any) => ({
|
||||
value: trimStr(st?.value, 24),
|
||||
label: trimStr(st?.label, 48),
|
||||
}))
|
||||
: [],
|
||||
notes: trimStr(s.notes, 400) || undefined,
|
||||
}
|
||||
case 'table':
|
||||
return {
|
||||
type: 'table',
|
||||
title: trimStr(s.title || 'Tableau', 90),
|
||||
headers: cleanList(s.headers, 6, 40),
|
||||
rows: Array.isArray(s.rows)
|
||||
? s.rows.slice(0, 8).map((r: unknown) => cleanList(r, 6, 60))
|
||||
: [],
|
||||
notes: trimStr(s.notes, 400) || undefined,
|
||||
}
|
||||
case 'timeline': {
|
||||
const events = Array.isArray(s.events) ? s.events : []
|
||||
return {
|
||||
type: 'timeline',
|
||||
title: trimStr(s.title || 'Chronologie', 90),
|
||||
events: events.slice(0, 6).map((e: any) => ({
|
||||
date: trimStr(e?.date, 40),
|
||||
title: trimStr(e?.title, 80),
|
||||
description: trimStr(e?.description, 140) || undefined,
|
||||
})),
|
||||
notes: trimStr(s.notes, 400) || undefined,
|
||||
}
|
||||
}
|
||||
case 'quote':
|
||||
return {
|
||||
type: 'quote',
|
||||
quote: trimStr(s.quote, 280),
|
||||
author: trimStr(s.author, 60) || undefined,
|
||||
context: trimStr(s.context, 160) || undefined,
|
||||
notes: trimStr(s.notes, 400) || undefined,
|
||||
}
|
||||
case 'comparison': {
|
||||
const left = (s.left && typeof s.left === 'object' ? s.left : {}) as any
|
||||
const right = (s.right && typeof s.right === 'object' ? s.right : {}) as any
|
||||
return {
|
||||
type: 'comparison',
|
||||
title: trimStr(s.title || 'Comparaison', 90),
|
||||
left: {
|
||||
title: trimStr(left.title || 'A', 40),
|
||||
points: cleanList(left.points, 4, 100),
|
||||
score: trimStr(left.score, 16) || undefined,
|
||||
},
|
||||
right: {
|
||||
title: trimStr(right.title || 'B', 40),
|
||||
points: cleanList(right.points, 4, 100),
|
||||
score: trimStr(right.score, 16) || undefined,
|
||||
},
|
||||
notes: trimStr(s.notes, 400) || undefined,
|
||||
}
|
||||
}
|
||||
case 'equation':
|
||||
return {
|
||||
type: 'equation',
|
||||
title: trimStr(s.title || 'Formules', 90),
|
||||
equations: Array.isArray(s.equations)
|
||||
? s.equations.slice(0, 4).map((eq: any) => ({
|
||||
latex: trimStr(eq?.latex, 120),
|
||||
label: trimStr(eq?.label, 60) || undefined,
|
||||
}))
|
||||
: [],
|
||||
explanation: trimStr(s.explanation, 200) || undefined,
|
||||
notes: trimStr(s.notes, 400) || undefined,
|
||||
}
|
||||
case 'image':
|
||||
return {
|
||||
type: 'image',
|
||||
title: trimStr(s.title || 'Illustration', 90),
|
||||
url: typeof s.url === 'string' ? s.url.trim() : undefined,
|
||||
caption: trimStr(s.caption, 140) || undefined,
|
||||
notes: trimStr(s.notes, 400) || undefined,
|
||||
}
|
||||
default:
|
||||
return {
|
||||
type: 'bullets',
|
||||
title: trimStr(s.title || 'Points', 90),
|
||||
items: cleanList(s.items || s.content, 5, 140),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A body slide is empty if it has a title but no real payload
|
||||
* (the bug that shipped title-only decks).
|
||||
*/
|
||||
export function isSlideBodyEmpty(slide: Record<string, unknown>): boolean {
|
||||
const type = String(slide.type || '')
|
||||
if (type === 'title') {
|
||||
// Title slides need a non-empty title (and ideally a subtitle, not required)
|
||||
return !String(slide.title || '').trim()
|
||||
}
|
||||
switch (type) {
|
||||
case 'bullets':
|
||||
case 'summary':
|
||||
return !Array.isArray(slide.items) || slide.items.filter((x) => String(x || '').trim()).length < 2
|
||||
case 'cards':
|
||||
return (
|
||||
!Array.isArray(slide.cards) ||
|
||||
slide.cards.filter((c: any) => String(c?.title || c?.description || '').trim()).length < 2
|
||||
)
|
||||
case 'stats':
|
||||
return (
|
||||
!Array.isArray(slide.stats) ||
|
||||
slide.stats.filter((s: any) => String(s?.value || '').trim() && String(s?.label || '').trim()).length < 2
|
||||
)
|
||||
case 'chart':
|
||||
return (
|
||||
!Array.isArray(slide.data) ||
|
||||
slide.data.filter((d: any) => d && typeof d.value === 'number' && String(d.label || '').trim()).length < 2
|
||||
)
|
||||
case 'table':
|
||||
return !Array.isArray(slide.rows) || slide.rows.length < 1
|
||||
case 'timeline':
|
||||
return (
|
||||
!Array.isArray(slide.events) ||
|
||||
slide.events.filter((e: any) => String(e?.title || '').trim()).length < 2
|
||||
)
|
||||
case 'quote':
|
||||
return !String(slide.quote || '').trim()
|
||||
case 'comparison': {
|
||||
const left = (slide.left || {}) as any
|
||||
const right = (slide.right || {}) as any
|
||||
const lp = Array.isArray(left.points) ? left.points.filter(Boolean).length : 0
|
||||
const rp = Array.isArray(right.points) ? right.points.filter(Boolean).length : 0
|
||||
return lp < 1 || rp < 1
|
||||
}
|
||||
case 'equation':
|
||||
return !Array.isArray(slide.equations) || slide.equations.length < 1
|
||||
case 'image':
|
||||
// image without URL is weak but caption-only is still something; require title at least
|
||||
return !String(slide.title || '').trim()
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function listEmptyBodySlides(slides: Record<string, unknown>[]): number[] {
|
||||
const empty: number[] = []
|
||||
slides.forEach((s, i) => {
|
||||
if (s.type === 'title') return
|
||||
if (isSlideBodyEmpty(s)) empty.push(i)
|
||||
})
|
||||
return empty
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard gate: refuse to ship a deck that is mostly titles.
|
||||
* Content slides (non-title) must have real bodies.
|
||||
* STEM: if formulas were extracted from the note, deck MUST include equation slides with latex.
|
||||
*/
|
||||
export function assertDeckHasSubstance(
|
||||
deck: {
|
||||
title?: string
|
||||
slides: Record<string, unknown>[]
|
||||
},
|
||||
opts?: { requireFormulas?: string[] },
|
||||
): { ok: true } | { ok: false; reason: string; emptyIndexes: number[] } {
|
||||
const slides = deck.slides || []
|
||||
if (slides.length < 2) {
|
||||
return { ok: false, reason: 'Deck has fewer than 2 slides', emptyIndexes: [] }
|
||||
}
|
||||
const bodySlides = slides.filter((s) => s.type !== 'title')
|
||||
if (bodySlides.length === 0) {
|
||||
return { ok: false, reason: 'Deck has no body slides', emptyIndexes: [] }
|
||||
}
|
||||
const emptyIndexes = listEmptyBodySlides(slides)
|
||||
if (emptyIndexes.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `${emptyIndexes.length} body slide(s) have titles only (no bullets/cards/stats/…)`,
|
||||
emptyIndexes,
|
||||
}
|
||||
}
|
||||
// At least 2 content claims across the deck
|
||||
const claims = extractKeyClaims(slides)
|
||||
if (claims.length < 2) {
|
||||
return { ok: false, reason: 'Deck has insufficient claims/body text', emptyIndexes: [] }
|
||||
}
|
||||
|
||||
// STEM gate: note had formulas → at least one equation slide with non-empty latex
|
||||
const required = opts?.requireFormulas?.filter((f) => f && f.trim()) || []
|
||||
if (required.length > 0) {
|
||||
const eqSlides = slides.filter((s) => s.type === 'equation')
|
||||
const latexCount = eqSlides.reduce((n, s) => {
|
||||
const eqs = Array.isArray(s.equations) ? s.equations : []
|
||||
return (
|
||||
n +
|
||||
eqs.filter((e: any) => typeof e?.latex === 'string' && e.latex.trim().length >= 2).length
|
||||
)
|
||||
}, 0)
|
||||
if (latexCount < 1) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `STEM gate: note has ${required.length} formula(s) but deck has no equation slides with LaTeX`,
|
||||
emptyIndexes: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
/** Inject equation slides from extracted formulas (deterministic STEM fix). */
|
||||
export function injectEquationSlidesFromFormulas(
|
||||
deck: { title: string; theme: string; slides: Record<string, unknown>[] },
|
||||
formulas: string[],
|
||||
opts?: { lang?: 'fr' | 'en' },
|
||||
): { title: string; theme: string; slides: Record<string, unknown>[] } {
|
||||
const forms = formulas.map((f) => f.trim()).filter((f) => f.length >= 2).slice(0, 8)
|
||||
if (forms.length === 0) return deck
|
||||
|
||||
const hasEq = deck.slides.some((s) => {
|
||||
if (s.type !== 'equation') return false
|
||||
const eqs = Array.isArray(s.equations) ? s.equations : []
|
||||
return eqs.some((e: any) => String(e?.latex || '').trim().length >= 2)
|
||||
})
|
||||
if (hasEq) return deck
|
||||
|
||||
const lang = opts?.lang || 'fr'
|
||||
const titleSlide = deck.slides.find((s) => s.type === 'title')
|
||||
const summarySlide = deck.slides.find((s) => s.type === 'summary')
|
||||
const middle = deck.slides.filter((s) => s.type !== 'title' && s.type !== 'summary')
|
||||
|
||||
const groups: string[][] = []
|
||||
for (let i = 0; i < forms.length; i += 2) {
|
||||
groups.push(forms.slice(i, i + 2))
|
||||
}
|
||||
const eqSlides = groups.slice(0, 2).map((group, gi) => ({
|
||||
type: 'equation',
|
||||
title:
|
||||
gi === 0
|
||||
? lang === 'fr'
|
||||
? 'Équations fondamentales'
|
||||
: 'Core equations'
|
||||
: lang === 'fr'
|
||||
? 'Formules complémentaires'
|
||||
: 'Additional formulas',
|
||||
equations: group.map((latex, i) => ({
|
||||
latex,
|
||||
label: lang === 'fr' ? `Formule ${gi * 2 + i + 1}` : `Formula ${gi * 2 + i + 1}`,
|
||||
})),
|
||||
explanation:
|
||||
lang === 'fr'
|
||||
? 'Formules extraites de la note source'
|
||||
: 'Formulas extracted from the source note',
|
||||
}))
|
||||
|
||||
const slides = [
|
||||
titleSlide || { type: 'title', title: deck.title },
|
||||
...eqSlides,
|
||||
...middle,
|
||||
summarySlide || {
|
||||
type: 'summary',
|
||||
title: lang === 'fr' ? 'À retenir' : 'Key takeaways',
|
||||
items:
|
||||
lang === 'fr'
|
||||
? ['Revoir les formules', 'Appliquer sur un exemple', 'Vérifier les conditions']
|
||||
: ['Review formulas', 'Apply on an example', 'Check conditions'],
|
||||
},
|
||||
].slice(0, SLIDE_HARD_CAP)
|
||||
|
||||
return normalizeSlideDeck({ title: deck.title, theme: deck.theme, slides })
|
||||
}
|
||||
|
||||
function extractKeyClaims(slides: Record<string, unknown>[]): string[] {
|
||||
const claims: string[] = []
|
||||
for (const s of slides) {
|
||||
if (s.type === 'title' || s.type === 'summary') continue
|
||||
if (Array.isArray(s.items)) {
|
||||
for (const it of s.items) {
|
||||
if (typeof it === 'string' && it.trim()) claims.push(trimStr(it, 120))
|
||||
}
|
||||
}
|
||||
if (Array.isArray(s.cards)) {
|
||||
for (const c of s.cards as any[]) {
|
||||
const line = [c?.title, c?.description].filter(Boolean).join(' — ')
|
||||
if (line) claims.push(trimStr(line, 120))
|
||||
}
|
||||
}
|
||||
if (typeof s.title === 'string' && s.type !== 'bullets') {
|
||||
claims.push(trimStr(s.title, 100))
|
||||
}
|
||||
}
|
||||
return [...new Set(claims)].filter(Boolean)
|
||||
}
|
||||
|
||||
/** System-prompt quality rules (shared FR/EN cores built in agent-executor). */
|
||||
export const SLIDE_CONTENT_PRINCIPLES_FR = `
|
||||
═══ QUALITÉ DU CONTENU (OBLIGATOIRE) ═══
|
||||
Tu es un consultant McKinsey/BCG qui structure un deck exécutif. Le public lit les titres d'abord.
|
||||
|
||||
1. UNE IDÉE PAR SLIDE
|
||||
- Le "title" de chaque slide est un TITRE D'ACTION : une phrase d'insight, pas un libellé de sujet.
|
||||
- Mauvais : "Contexte" / "Analyse" / "Résultats"
|
||||
- Bon : "Le délai de livraison a doublé en 6 mois" / "Trois leviers expliquent 80 % du retard"
|
||||
|
||||
2. ARC NARRATIF (dans l'ordre)
|
||||
title → contexte/problème → insight principal → preuves (chiffres ou exemples) → implications/options → summary (next steps)
|
||||
Ne pas coller une liste de sujets sans fil conducteur.
|
||||
|
||||
3. TEXTE COURT ET UTILE
|
||||
- Bullets : 3 à 5 MAX, 1 claim distinct par ligne (8–18 mots idéalement).
|
||||
- INTERDIT : paragraphes, répétition du titre, filler ("Il est important de noter…", "En conclusion on peut dire…").
|
||||
- Chaque bullet doit apporter une information nouvelle tirée de la note.
|
||||
|
||||
4. DONNÉES HONNÊTES
|
||||
- "chart" / "stats" UNIQUEMENT si des chiffres REELS figurent dans la note.
|
||||
- INTERDIT d'inventer des KPI, scores radar, pourcentages ou tendances.
|
||||
- Sans chiffres : cards, comparison, timeline, quote, bullets — jamais de faux graphiques.
|
||||
|
||||
5. VARIÉTÉ UTILE
|
||||
- Varier les types selon le contenu (pas 4× bullets d'affilée si d'autres formats collent mieux).
|
||||
- Premier slide = "title", dernier = "summary" avec 3–5 takeaways actionnables.
|
||||
|
||||
6. FIDÉLITÉ À LA SOURCE
|
||||
- Reformuler pour la clarté, ne pas inventer de faits, noms, dates ou conclusions absents de la note.
|
||||
- Si la note est courte : peu de slides, contenu dense et fidèle — pas de padding.
|
||||
|
||||
7. NOTES ORATEUR (optionnel)
|
||||
- Champ "notes" : 1–2 phrases pour le présentateur (non affiché sur la slide).
|
||||
`
|
||||
|
||||
export const SLIDE_CONTENT_PRINCIPLES_EN = `
|
||||
═══ CONTENT QUALITY (MANDATORY) ═══
|
||||
You are a McKinsey/BCG consultant building an executive deck. Audiences read titles first.
|
||||
|
||||
1. ONE IDEA PER SLIDE
|
||||
- Each slide "title" is an ACTION TITLE: an insight sentence, not a topic label.
|
||||
- Bad: "Context" / "Analysis" / "Results"
|
||||
- Good: "Delivery time doubled in 6 months" / "Three levers explain 80% of the delay"
|
||||
|
||||
2. NARRATIVE ARC (in order)
|
||||
title → context/problem → key insight → evidence (numbers or examples) → implications/options → summary (next steps)
|
||||
Do not dump a topic list without a storyline.
|
||||
|
||||
3. SHORT, USEFUL TEXT
|
||||
- Bullets: 3–5 MAX, one distinct claim per line (ideally 8–18 words).
|
||||
- FORBIDDEN: paragraphs, restating the title, filler ("It is important to note…").
|
||||
- Each bullet must add new information from the note.
|
||||
|
||||
4. HONEST DATA
|
||||
- "chart" / "stats" ONLY if REAL numbers appear in the note.
|
||||
- NEVER invent KPIs, radar scores, percentages, or trends.
|
||||
- No numbers: use cards, comparison, timeline, quote, bullets — never fake charts.
|
||||
|
||||
5. USEFUL VARIETY
|
||||
- Vary types based on content (not 4× bullets if another type fits better).
|
||||
- First slide = "title", last = "summary" with 3–5 actionable takeaways.
|
||||
|
||||
6. FIDELITY TO SOURCE
|
||||
- Rephrase for clarity; do not invent facts, names, dates, or conclusions absent from the note.
|
||||
- Short notes → few slides, faithful content — no padding.
|
||||
|
||||
7. SPEAKER NOTES (optional)
|
||||
- "notes" field: 1–2 sentences for the presenter (not shown on the slide).
|
||||
`
|
||||
692
memento-note/lib/ai/services/slide-deck-generator.service.ts
Normal file
692
memento-note/lib/ai/services/slide-deck-generator.service.ts
Normal 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(),
|
||||
/** 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 }
|
||||
}
|
||||
}
|
||||
151
memento-note/lib/ai/services/slide-intent.ts
Normal file
151
memento-note/lib/ai/services/slide-intent.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Slide generation intent (Sprint A product layer).
|
||||
* Passed from UI → API → agent.description → generateSlideDeck.
|
||||
*/
|
||||
|
||||
export type SlidePurpose =
|
||||
| 'auto'
|
||||
| 'course'
|
||||
| 'board'
|
||||
| 'project'
|
||||
| 'strategy'
|
||||
| 'pitch'
|
||||
| 'summary'
|
||||
|
||||
export type SlideAudience = 'auto' | 'student' | 'board' | 'team' | 'client' | 'general'
|
||||
|
||||
export interface SlideIntent {
|
||||
purpose?: SlidePurpose
|
||||
audience?: SlideAudience
|
||||
/** Target body slides including title+summary, clamped 3–8 */
|
||||
slideCount?: number
|
||||
/** Existing executive template id or auto */
|
||||
template?: string
|
||||
}
|
||||
|
||||
const PURPOSE_SET = new Set<string>([
|
||||
'auto',
|
||||
'course',
|
||||
'board',
|
||||
'project',
|
||||
'strategy',
|
||||
'pitch',
|
||||
'summary',
|
||||
])
|
||||
|
||||
const AUDIENCE_SET = new Set<string>([
|
||||
'auto',
|
||||
'student',
|
||||
'board',
|
||||
'team',
|
||||
'client',
|
||||
'general',
|
||||
])
|
||||
|
||||
export function clampSlideCount(n: unknown): number | undefined {
|
||||
const v = typeof n === 'number' ? n : typeof n === 'string' ? parseInt(n, 10) : NaN
|
||||
if (!Number.isFinite(v)) return undefined
|
||||
return Math.min(8, Math.max(3, Math.round(v)))
|
||||
}
|
||||
|
||||
export function normalizeSlideIntent(raw: Partial<SlideIntent> | null | undefined): SlideIntent {
|
||||
if (!raw || typeof raw !== 'object') return {}
|
||||
const purpose = raw.purpose && PURPOSE_SET.has(raw.purpose) ? raw.purpose : 'auto'
|
||||
const audience = raw.audience && AUDIENCE_SET.has(raw.audience) ? raw.audience : 'auto'
|
||||
const slideCount = clampSlideCount(raw.slideCount)
|
||||
const template =
|
||||
typeof raw.template === 'string' && raw.template.trim() ? raw.template.trim() : 'auto'
|
||||
return { purpose, audience, slideCount, template }
|
||||
}
|
||||
|
||||
/** Encode intent into agent.description (backward compatible with template:xxx). */
|
||||
export function encodeSlideIntentDescription(intent: SlideIntent): string {
|
||||
const n = normalizeSlideIntent(intent)
|
||||
// Prefer structured JSON for the structured pipeline
|
||||
return `slide-intent:${JSON.stringify(n)}`
|
||||
}
|
||||
|
||||
/** Decode agent.description → intent */
|
||||
export function decodeSlideIntentDescription(description?: string | null): SlideIntent {
|
||||
if (!description) return {}
|
||||
if (description.startsWith('slide-intent:')) {
|
||||
try {
|
||||
return normalizeSlideIntent(JSON.parse(description.slice('slide-intent:'.length)))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
if (description.startsWith('template:')) {
|
||||
const template = description.replace('template:', '').trim() || 'auto'
|
||||
// Map legacy templates to purpose
|
||||
const purposeMap: Record<string, SlidePurpose> = {
|
||||
'board-update': 'board',
|
||||
'project-status': 'project',
|
||||
'strategy-review': 'strategy',
|
||||
'quarterly-results': 'board',
|
||||
}
|
||||
return normalizeSlideIntent({
|
||||
template,
|
||||
purpose: purposeMap[template] || 'auto',
|
||||
})
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
/** Human guidance injected into outline/expand prompts */
|
||||
export function intentPromptHints(intent: SlideIntent, lang: 'fr' | 'en'): string {
|
||||
const n = normalizeSlideIntent(intent)
|
||||
const lines: string[] = []
|
||||
|
||||
if (n.purpose && n.purpose !== 'auto') {
|
||||
const mapFr: Record<SlidePurpose, string> = {
|
||||
auto: '',
|
||||
course: 'Type: COURS / leçon — arc pédagogique (définitions → formules → exemples → résumé). Priorité aux slides equation si maths.',
|
||||
board: 'Type: COMITÉ / board — KPIs, décisions, risques, next steps.',
|
||||
project: 'Type: SUIVI PROJET — avancement, blockers, livrables, roadmap.',
|
||||
strategy: 'Type: STRATÉGIE — options, trade-offs, recommandations.',
|
||||
pitch: 'Type: PITCH — problème, solution, preuve, call to action.',
|
||||
summary: 'Type: SYNTHÈSE — peu de slides, dense, takeaways actionnables.',
|
||||
}
|
||||
const mapEn: Record<SlidePurpose, string> = {
|
||||
auto: '',
|
||||
course: 'Type: LESSON — pedagogical arc (defs → formulas → examples → summary). Prefer equation slides for math.',
|
||||
board: 'Type: BOARD — KPIs, decisions, risks, next steps.',
|
||||
project: 'Type: PROJECT STATUS — progress, blockers, deliverables, roadmap.',
|
||||
strategy: 'Type: STRATEGY — options, trade-offs, recommendations.',
|
||||
pitch: 'Type: PITCH — problem, solution, proof, CTA.',
|
||||
summary: 'Type: SUMMARY — few dense slides, actionable takeaways.',
|
||||
}
|
||||
lines.push(lang === 'fr' ? mapFr[n.purpose] : mapEn[n.purpose])
|
||||
}
|
||||
|
||||
if (n.audience && n.audience !== 'auto') {
|
||||
const mapFr: Record<SlideAudience, string> = {
|
||||
auto: '',
|
||||
student: 'Audience: étudiants — clarté, définitions, exemples, formules explicites.',
|
||||
board: 'Audience: comité de direction — concis, chiffres, décisions.',
|
||||
team: 'Audience: équipe — opérationnel, actions, responsabilités.',
|
||||
client: 'Audience: client — bénéfices, preuves, sans jargon interne.',
|
||||
general: 'Audience: grand public — langage simple.',
|
||||
}
|
||||
const mapEn: Record<SlideAudience, string> = {
|
||||
auto: '',
|
||||
student: 'Audience: students — clarity, definitions, examples, explicit formulas.',
|
||||
board: 'Audience: board — concise, numbers, decisions.',
|
||||
team: 'Audience: team — operational, actions, ownership.',
|
||||
client: 'Audience: client — benefits, proof, no internal jargon.',
|
||||
general: 'Audience: general — plain language.',
|
||||
}
|
||||
lines.push(lang === 'fr' ? mapFr[n.audience] : mapEn[n.audience])
|
||||
}
|
||||
|
||||
if (n.slideCount) {
|
||||
lines.push(
|
||||
lang === 'fr'
|
||||
? `Nombre de slides imposé: EXACTEMENT ${n.slideCount} (title + corps + summary inclus).`
|
||||
: `Slide count required: EXACTLY ${n.slideCount} (including title + body + summary).`,
|
||||
)
|
||||
}
|
||||
|
||||
return lines.filter(Boolean).join('\n')
|
||||
}
|
||||
103
memento-note/lib/ai/services/slide-source-assets.ts
Normal file
103
memento-note/lib/ai/services/slide-source-assets.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Server-side extraction of usable assets from a note BEFORE any slide LLM call.
|
||||
* Inspired by PPTAgent (retrieve relevant content per slide) and DeckForge (data_needs).
|
||||
* Critical for STEM notes: formulas must be harvested, not hoped for from the model.
|
||||
*/
|
||||
|
||||
export interface SourceAssets {
|
||||
formulas: string[]
|
||||
numbers: Array<{ label: string; value: number; raw: string }>
|
||||
keySentences: string[]
|
||||
hasMath: boolean
|
||||
hasNumbers: boolean
|
||||
wordCount: number
|
||||
}
|
||||
|
||||
/** Extract LaTeX / equation-like fragments from note plain text or HTML. */
|
||||
export function extractFormulas(raw: string): string[] {
|
||||
if (!raw) return []
|
||||
const found: string[] = []
|
||||
const push = (s: string) => {
|
||||
const t = s.replace(/\s+/g, ' ').trim()
|
||||
if (t.length >= 2 && t.length <= 280 && !found.includes(t)) found.push(t)
|
||||
}
|
||||
|
||||
// $$ ... $$
|
||||
for (const m of raw.matchAll(/\$\$([\s\S]+?)\$\$/g)) push(m[1] || '')
|
||||
// \[ ... \]
|
||||
for (const m of raw.matchAll(/\\\[([\s\S]+?)\\\]/g)) push(m[1] || '')
|
||||
// \( ... \)
|
||||
for (const m of raw.matchAll(/\\\(([\s\S]+?)\\\)/g)) push(m[1] || '')
|
||||
// $ ... $ (single line)
|
||||
for (const m of raw.matchAll(/(?<![\\$])\$([^$\n]{2,120})\$(?!\$)/g)) push(m[1] || '')
|
||||
// Common latex commands in free text
|
||||
for (const m of raw.matchAll(
|
||||
/(?:^|[\s(])((?:\\frac\{[^}]+\}\{[^}]+\}|\\partial|\\nabla|\\sum|\\int|\\frac|\\cdot|\\times|\\left|\\right|y'|y''|d[xy]\/d[xy]|∂)[^\n]{0,100})/g,
|
||||
)) {
|
||||
push(m[1] || '')
|
||||
}
|
||||
// Equation-like: f(x) = ..., dy/dx = ...
|
||||
for (const m of raw.matchAll(
|
||||
/(?:^|\n)\s*([A-Za-zΑ-ω∂∇][A-Za-z0-9_'"′]*\s*(?:\([^)]*\))?\s*[=≈≡]\s*[^\n]{3,100})/g,
|
||||
)) {
|
||||
const line = (m[1] || '').trim()
|
||||
if (/[=≈]/.test(line) && /[a-zA-Z0-9]/.test(line)) push(line)
|
||||
}
|
||||
|
||||
return found.slice(0, 24)
|
||||
}
|
||||
|
||||
export function extractNumbers(raw: string): Array<{ label: string; value: number; raw: string }> {
|
||||
if (!raw) return []
|
||||
const out: Array<{ label: string; value: number; raw: string }> = []
|
||||
const re =
|
||||
/(?:^|[^\d])((?:≈|~)?\s*-?\d+(?:[.,]\d+)?)\s*(%|€|\$|k|m|bn|ms|s|°C)?\b/gi
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(raw)) !== null && out.length < 20) {
|
||||
const numStr = (m[1] || '').replace(/[≈~\s]/g, '').replace(',', '.')
|
||||
const value = parseFloat(numStr)
|
||||
if (!Number.isFinite(value)) continue
|
||||
const unit = m[2] || ''
|
||||
// crude label: 40 chars before
|
||||
const start = Math.max(0, m.index - 40)
|
||||
const ctx = raw.slice(start, m.index).replace(/\s+/g, ' ').trim()
|
||||
const label = ctx.split(/[.;:!?\n]/).pop()?.trim().slice(-32) || `n${out.length + 1}`
|
||||
out.push({ label, value: unit === '%' ? value : value, raw: `${numStr}${unit}` })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function extractKeySentences(raw: string, max = 16): string[] {
|
||||
if (!raw) return []
|
||||
const plain = raw
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\$\$[\s\S]+?\$\$/g, ' ')
|
||||
.replace(/\\\[[\s\S]+?\\\]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
const parts = plain
|
||||
.split(/(?<=[.!?])\s+/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length >= 40 && s.length <= 220)
|
||||
const out: string[] = []
|
||||
for (const p of parts) {
|
||||
if (out.length >= max) break
|
||||
if (!out.some((x) => x.slice(0, 40) === p.slice(0, 40))) out.push(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function extractSourceAssets(raw: string): SourceAssets {
|
||||
const formulas = extractFormulas(raw)
|
||||
const numbers = extractNumbers(raw)
|
||||
const keySentences = extractKeySentences(raw)
|
||||
const wordCount = raw.replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length
|
||||
return {
|
||||
formulas,
|
||||
numbers,
|
||||
keySentences,
|
||||
hasMath: formulas.length > 0 || /équat|equat|différen|differen|dériv|deriv|intégr|integr|EDO|ODE|PDE|latex/i.test(raw),
|
||||
hasNumbers: numbers.length >= 2,
|
||||
wordCount,
|
||||
}
|
||||
}
|
||||
@@ -72,17 +72,19 @@ function renderTitle(slide: SlideTitle, r: Recipe, idx: number): string {
|
||||
}
|
||||
|
||||
function renderBullets(slide: SlideBullets, r: Recipe, idx: number): string {
|
||||
const items = slide.items.map((item, i) => `
|
||||
<div class="reveal" style="display:flex;align-items:flex-start;gap:16px;padding:8px 0;">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:${r.accent1};margin-top:10px;flex-shrink:0;"></span>
|
||||
<span style="font-size:1.1rem;line-height:1.65;color:${r.text};">${esc(item)}</span>
|
||||
const list = (slide.items || []).filter(Boolean)
|
||||
// Dense layout: 2-col grid when 4+ bullets (Hermes/OpenClaw: avoid sparse empty slides)
|
||||
const twoCol = list.length >= 4
|
||||
const items = list.map((item) => `
|
||||
<div class="reveal" style="display:flex;align-items:flex-start;gap:14px;padding:12px 14px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:12px;">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:${r.accent1};margin-top:8px;flex-shrink:0;"></span>
|
||||
<span style="font-size:clamp(0.95rem,1.6vw,1.15rem);line-height:1.55;color:${r.text};">${esc(item)}</span>
|
||||
</div>`).join('')
|
||||
return `<div class="slide" data-slide="${idx}">
|
||||
${mesh(r)}
|
||||
<div class="content">
|
||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.6rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
|
||||
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:32px;"></div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;">${items}</div>
|
||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 20px;">${esc(slide.title)}</h2>
|
||||
<div style="display:grid;grid-template-columns:${twoCol ? '1fr 1fr' : '1fr'};gap:12px;">${items}</div>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
@@ -109,8 +111,11 @@ function renderChart(slide: SlideChart, r: Recipe, idx: number): string {
|
||||
}
|
||||
|
||||
function renderStats(slide: SlideStats, r: Recipe, idx: number): string {
|
||||
const cols = Math.min(slide.stats.length, 4)
|
||||
const items = slide.stats.map(s => `
|
||||
const stats = (slide.stats || []).filter((s) => s && (s.value || s.label))
|
||||
// Never force empty columns: 1 stat = full width, 2 = half, 3–4 = equal
|
||||
const n = Math.max(1, stats.length)
|
||||
const cols = n === 1 ? 1 : n === 2 ? 2 : n === 3 ? 3 : 4
|
||||
const items = stats.map(s => `
|
||||
<div class="reveal" style="text-align:center;padding:28px 16px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:16px;">
|
||||
<div style="font-size:clamp(2.2rem,4vw,3.5rem);font-weight:900;line-height:1;background:linear-gradient(135deg,${r.accent1},${r.accent2});-webkit-background-clip:text;-webkit-text-fill-color:transparent;" data-count="${extractNum(s.value)}" data-suffix="${extractSuffix(s.value)}">${esc(s.value)}</div>
|
||||
<div style="font-size:0.8rem;color:${r.textMuted};margin-top:10px;letter-spacing:0.12em;text-transform:uppercase;font-weight:600;">${esc(s.label)}</div>
|
||||
@@ -118,9 +123,8 @@ function renderStats(slide: SlideStats, r: Recipe, idx: number): string {
|
||||
return `<div class="slide" data-slide="${idx}">
|
||||
${mesh(r)}
|
||||
<div class="content">
|
||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.6rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
|
||||
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:32px;"></div>
|
||||
<div style="display:grid;grid-template-columns:repeat(${cols},1fr);gap:20px;">${items}</div>
|
||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 24px;">${esc(slide.title)}</h2>
|
||||
<div style="display:grid;grid-template-columns:repeat(${cols},minmax(0,1fr));gap:16px;">${items}</div>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
@@ -144,20 +148,22 @@ function renderTable(slide: SlideTable, r: Recipe, idx: number): string {
|
||||
}
|
||||
|
||||
function renderCards(slide: SlideCards, r: Recipe, idx: number): string {
|
||||
const cols = slide.cards.length <= 2 ? 2 : slide.cards.length === 4 ? 2 : 3
|
||||
const items = slide.cards.map((c, i) => `
|
||||
<div class="reveal" style="padding:24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:14px;position:relative;overflow:hidden;">
|
||||
const cards = (slide.cards || []).filter((c) => c && (c.title || c.description))
|
||||
// 1 card → full width (no empty second column). 2 → 2 cols. 3 → 3. 4+ → 2x2
|
||||
const n = Math.max(1, cards.length)
|
||||
const cols = n === 1 ? 1 : n === 2 ? 2 : n === 3 ? 3 : 2
|
||||
const items = cards.map((c, i) => `
|
||||
<div class="reveal" style="padding:24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:14px;position:relative;overflow:hidden;min-height:120px;">
|
||||
<span style="position:absolute;top:10px;right:14px;font-size:1.8rem;font-weight:900;color:${r.accent1};opacity:0.12;">${String(i + 1).padStart(2, '0')}</span>
|
||||
<div style="width:24px;height:3px;background:${r.accent1};border-radius:2px;margin-bottom:12px;"></div>
|
||||
<div style="font-size:1rem;font-weight:700;color:${r.text};margin-bottom:8px;">${esc(c.title)}</div>
|
||||
<div style="font-size:0.88rem;color:${r.textSecondary};line-height:1.6;">${esc(c.description)}</div>
|
||||
<div style="font-size:1.05rem;font-weight:700;color:${r.text};margin-bottom:8px;">${esc(c.title)}</div>
|
||||
<div style="font-size:0.9rem;color:${r.textSecondary};line-height:1.6;">${esc(c.description)}</div>
|
||||
</div>`).join('')
|
||||
return `<div class="slide" data-slide="${idx}">
|
||||
${mesh(r)}
|
||||
<div class="content">
|
||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.6rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
|
||||
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:32px;"></div>
|
||||
<div style="display:grid;grid-template-columns:repeat(${cols},1fr);gap:16px;">${items}</div>
|
||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 24px;">${esc(slide.title)}</h2>
|
||||
<div style="display:grid;grid-template-columns:repeat(${cols},minmax(0,1fr));gap:16px;">${items}</div>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
@@ -214,18 +220,22 @@ function renderComparison(slide: SlideComparison, r: Recipe, idx: number): strin
|
||||
}
|
||||
|
||||
function renderEquation(slide: SlideEquation, r: Recipe, idx: number): string {
|
||||
const eqs = slide.equations.map(eq => `
|
||||
<div class="reveal" style="text-align:center;padding:24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:12px;margin-bottom:12px;">
|
||||
<div style="font-size:clamp(1.4rem,3vw,2.2rem);font-family:'KaTeX_Main',serif;color:${r.text};letter-spacing:0.02em;">${esc(eq.latex)}</div>
|
||||
${eq.label ? `<div style="font-size:0.8rem;color:${r.textMuted};margin-top:8px;">${esc(eq.label)}</div>` : ''}
|
||||
</div>`).join('')
|
||||
const eqs = (slide.equations || []).map((eq) => {
|
||||
// data-latex for KaTeX client render; also show fallback monospaced text
|
||||
const latex = eq.latex || ''
|
||||
return `
|
||||
<div class="reveal" style="text-align:center;padding:22px 24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:14px;margin-bottom:14px;">
|
||||
<div class="eq-latex" data-latex="${esc(latex)}" style="font-size:clamp(1.25rem,2.6vw,1.85rem);color:${r.text};overflow-x:auto;padding:8px 0;"></div>
|
||||
<noscript><code style="font-size:1.1rem;">${esc(latex)}</code></noscript>
|
||||
${eq.label ? `<div style="font-size:0.85rem;color:${r.textMuted};margin-top:10px;font-weight:600;">${esc(eq.label)}</div>` : ''}
|
||||
</div>`
|
||||
}).join('')
|
||||
return `<div class="slide" data-slide="${idx}">
|
||||
${mesh(r)}
|
||||
<div class="content">
|
||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
|
||||
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:28px;"></div>
|
||||
${eqs}
|
||||
${slide.explanation ? `<p class="reveal" style="font-size:0.95rem;color:${r.textSecondary};line-height:1.7;margin-top:16px;text-align:center;max-width:700px;margin-inline:auto;">${esc(slide.explanation)}</p>` : ''}
|
||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 20px;">${esc(slide.title)}</h2>
|
||||
${eqs || `<div class="reveal" style="color:${r.textMuted};">Aucune équation</div>`}
|
||||
${slide.explanation ? `<p class="reveal" style="font-size:1rem;color:${r.textSecondary};line-height:1.7;margin-top:18px;max-width:780px;">${esc(slide.explanation)}</p>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
@@ -429,14 +439,19 @@ export function buildPresentationHTML(input: PresentationInput): string {
|
||||
<title>${esc(input.title)}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="${r.fontUrl}" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||
<style>
|
||||
:root{--color-bg:${r.bg};--color-text:${r.text};--color-text-secondary:${r.textSecondary};--color-text-muted:${r.textMuted};--color-accent-1:${r.accent1};--color-accent-2:${r.accent2};--color-glass-bg:${r.glassBg};--color-glass-border:${r.glassBorder};--svg-grid-line:${r.svgGrid};--font-display:${r.fontDisplay},serif;--font-body:${r.fontBody},system-ui,sans-serif;}
|
||||
*,*::before,*::after{box-sizing:border-box;}
|
||||
html,body{margin:0;padding:0;overflow:hidden;background:var(--color-bg);color:var(--color-text);font-family:var(--font-body);-webkit-font-smoothing:antialiased;}
|
||||
.deck{width:100vw;height:100vh;position:relative;}
|
||||
.slide{position:absolute;top:0;left:0;width:100%;height:100%;background:var(--color-bg);opacity:0;transform:scale(0.96);transition:opacity 0.6s ease,transform 0.6s ease;pointer-events:none;overflow:hidden;display:flex;align-items:center;justify-content:center;}
|
||||
html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:var(--color-bg);color:var(--color-text);font-family:var(--font-body);-webkit-font-smoothing:antialiased;}
|
||||
/* Fill iframe/container — use % not 100vw (vw breaks lab preview letterboxing) */
|
||||
.deck{width:100%;height:100%;position:relative;overflow:hidden;}
|
||||
.slide{position:absolute;top:0;left:0;width:100%;height:100%;background:var(--color-bg);opacity:0;transform:scale(0.98);transition:opacity 0.45s ease,transform 0.45s ease;pointer-events:none;overflow:hidden;display:flex;align-items:center;justify-content:center;}
|
||||
.slide.active{opacity:1;transform:scale(1);pointer-events:all;}
|
||||
.slide>.content{position:relative;z-index:2;width:100%;max-width:1100px;padding:clamp(1.5rem,4vw,4rem);}
|
||||
.slide>.content{position:relative;z-index:2;width:100%;max-width:min(1100px,94%);height:100%;max-height:100%;padding:clamp(1.25rem,3.5vmin,3.5rem);display:flex;flex-direction:column;justify-content:center;box-sizing:border-box;}
|
||||
.eq-latex{max-width:100%;}
|
||||
.eq-latex .katex-display{margin:0.4em 0;overflow-x:auto;overflow-y:hidden;}
|
||||
.gradient-mesh{position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:0;}
|
||||
.blob{position:absolute;border-radius:50%;filter:blur(80px);animation:float-slow var(--dur,18s) ease-in-out infinite;}
|
||||
@keyframes float-slow{0%{transform:translate(0,0) scale(1);}50%{transform:translate(-40px,40px) scale(0.92);}100%{transform:translate(0,0) scale(1);}}
|
||||
@@ -464,13 +479,14 @@ ${slidesHtml}
|
||||
<script>
|
||||
var current=1,slides=document.querySelectorAll('.slide'),total=slides.length;
|
||||
(function(){var d=document.getElementById('nav-dots');for(var i=1;i<=total;i++){var b=document.createElement('button');b.className='nav-dot'+(i===1?' active':'');(function(n){b.addEventListener('click',function(){goToSlide(n)});})(i);d.appendChild(b);}updateNav();})();
|
||||
function goToSlide(n){if(n<1||n>total||n===current)return;var p=document.querySelector('.slide.active'),nx=document.querySelector('.slide[data-slide="'+n+'"]');if(!nx)return;if(p)p.classList.remove('active');nx.classList.add('active');current=n;updateNav();animateSlide(nx);}
|
||||
function renderKatex(){if(typeof katex==='undefined')return;document.querySelectorAll('.eq-latex[data-latex]').forEach(function(el){var tex=el.getAttribute('data-latex')||'';try{katex.render(tex,el,{throwOnError:false,displayMode:true});}catch(e){el.textContent=tex;}});}
|
||||
function goToSlide(n){if(n<1||n>total||n===current)return;var p=document.querySelector('.slide.active'),nx=document.querySelector('.slide[data-slide="'+n+'"]');if(!nx)return;if(p)p.classList.remove('active');nx.classList.add('active');current=n;updateNav();animateSlide(nx);setTimeout(renderKatex,40);}
|
||||
function changeSlide(dir){var n=current+dir;if(n<1)n=total;if(n>total)n=1;goToSlide(n);}
|
||||
function updateNav(){document.querySelectorAll('.nav-dot').forEach(function(d,i){d.classList.toggle('active',(i+1)===current);});var c=document.getElementById('slide-counter');if(c)c.textContent=current+' / '+total;try{parent.postMessage({type:'slideChange',current:current,total:total},'*');}catch(e){}}
|
||||
document.addEventListener('keydown',function(e){if(e.key==='ArrowRight'||e.key===' ')changeSlide(1);if(e.key==='ArrowLeft')changeSlide(-1);});
|
||||
var tx=0;document.addEventListener('touchstart',function(e){tx=e.touches[0].clientX;},{passive:true});document.addEventListener('touchend',function(e){var dx=tx-e.changedTouches[0].clientX;if(Math.abs(dx)>50)changeSlide(dx>0?1:-1);},{passive:true});
|
||||
function animateSlide(s){s.querySelectorAll('.reveal').forEach(function(el,i){el.style.transition='none';el.style.opacity='0';el.style.transform='translateY(18px)';el.offsetHeight;el.style.transition='opacity 0.35s ease '+(i*0.07)+'s, transform 0.35s ease '+(i*0.07)+'s';el.style.opacity='1';el.style.transform='translateY(0)';});s.querySelectorAll('.bar[data-height]').forEach(function(b){b.style.height='0%';setTimeout(function(){b.style.height=b.dataset.height+'%';},100);});s.querySelectorAll('.bar-fill[data-width]').forEach(function(b){b.style.width='0%';setTimeout(function(){b.style.width=b.dataset.width+'%';},100);});s.querySelectorAll('.line-path').forEach(function(p){var l=p.getTotalLength?p.getTotalLength():2000;p.style.strokeDasharray=l;p.style.strokeDashoffset=l;setTimeout(function(){p.style.strokeDashoffset='0';},100);});s.querySelectorAll('[data-count]').forEach(function(el){var t=parseFloat(el.dataset.count),sf=el.dataset.suffix||'',st=30,inc=t/st,i=0,v=0;var iv=setInterval(function(){v+=inc;i++;el.textContent=(i>=st?t:Math.round(v))+sf;if(i>=st)clearInterval(iv);},30);});}
|
||||
var first=document.querySelector('.slide[data-slide="1"]');if(first){first.classList.add('active');setTimeout(function(){animateSlide(first);},300);}
|
||||
var first=document.querySelector('.slide[data-slide="1"]');if(first){first.classList.add('active');setTimeout(function(){animateSlide(first);renderKatex();},300);}else{setTimeout(renderKatex,400);}
|
||||
// Particles
|
||||
document.querySelectorAll('canvas[id^="particles-"]').forEach(function(c){c.width=window.innerWidth;c.height=window.innerHeight;var ctx=c.getContext('2d'),pts=[];for(var i=0;i<50;i++)pts.push({x:Math.random()*c.width,y:Math.random()*c.height,vx:(Math.random()-0.5)*0.3,vy:(Math.random()-0.5)*0.3,r:Math.random()*2+0.5});function draw(){ctx.clearRect(0,0,c.width,c.height);pts.forEach(function(p){p.x+=p.vx;p.y+=p.vy;if(p.x<0)p.x=c.width;if(p.x>c.width)p.x=0;if(p.y<0)p.y=c.height;if(p.y>c.height)p.y=0;ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,Math.PI*2);ctx.fillStyle='${r.accent1}80';ctx.fill();});requestAnimationFrame(draw);}draw();});
|
||||
</script>
|
||||
|
||||
@@ -5,100 +5,193 @@ import { z } from 'zod'
|
||||
import { toolRegistry } from './registry'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { buildPresentationHTML } from './slides-html-builder'
|
||||
import { normalizeSlideDeck, SLIDE_HARD_CAP } from '@/lib/ai/services/slide-content-quality'
|
||||
|
||||
const notesField = z.string().optional().describe('Optional speaker notes (1–2 sentences for the presenter, not shown on slide)')
|
||||
|
||||
const slideSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('title'), title: z.string(), subtitle: z.string().optional() }),
|
||||
z.object({ type: z.literal('bullets'), title: z.string(), items: z.array(z.string()) }),
|
||||
z.object({ type: z.literal('chart'), title: z.string(), chartType: z.enum(['bar', 'horizontal-bar', 'line', 'donut', 'radar']), data: z.array(z.object({ label: z.string(), value: z.number() })), subtitle: z.string().optional() }),
|
||||
z.object({ type: z.literal('stats'), title: z.string(), stats: z.array(z.object({ value: z.string(), label: z.string() })) }),
|
||||
z.object({ type: z.literal('table'), title: z.string(), headers: z.array(z.string()), rows: z.array(z.array(z.string())) }),
|
||||
z.object({ type: z.literal('cards'), title: z.string(), cards: z.array(z.object({ title: z.string(), description: z.string() })) }),
|
||||
z.object({ type: z.literal('timeline'), title: z.string(), events: z.array(z.object({ date: z.string(), title: z.string(), description: z.string().optional() })) }),
|
||||
z.object({ type: z.literal('quote'), quote: z.string(), author: z.string().optional(), context: z.string().optional() }),
|
||||
z.object({ type: z.literal('comparison'), title: z.string(), left: z.object({ title: z.string(), points: z.array(z.string()), score: z.string().optional() }), right: z.object({ title: z.string(), points: z.array(z.string()), score: z.string().optional() }) }),
|
||||
z.object({ type: z.literal('equation'), title: z.string(), equations: z.array(z.object({ latex: z.string(), label: z.string().optional() })), explanation: z.string().optional() }),
|
||||
z.object({ type: z.literal('image'), title: z.string(), url: z.string().optional(), caption: z.string().optional() }),
|
||||
z.object({ type: z.literal('summary'), title: z.string(), items: z.array(z.string()) }),
|
||||
z.object({
|
||||
type: z.literal('title'),
|
||||
title: z.string().describe('Action title / deck name'),
|
||||
subtitle: z.string().optional().describe('One-line context or audience promise'),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('bullets'),
|
||||
title: z.string().describe('Action title = the insight, not a topic label'),
|
||||
items: z.array(z.string()).max(5).describe('3–5 short claims (8–18 words), each distinct, from the note'),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('chart'),
|
||||
title: z.string().describe('Action title stating what the chart proves'),
|
||||
chartType: z.enum(['bar', 'horizontal-bar', 'line', 'donut', 'radar']),
|
||||
data: z.array(z.object({ label: z.string(), value: z.number() })).min(2).max(8),
|
||||
subtitle: z.string().optional(),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('stats'),
|
||||
title: z.string(),
|
||||
stats: z.array(z.object({ value: z.string(), label: z.string() })).min(2).max(4),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('table'),
|
||||
title: z.string(),
|
||||
headers: z.array(z.string()).max(6),
|
||||
rows: z.array(z.array(z.string())).max(8),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('cards'),
|
||||
title: z.string(),
|
||||
cards: z
|
||||
.array(z.object({ title: z.string(), description: z.string() }))
|
||||
.min(2)
|
||||
.max(6)
|
||||
.describe('2–4 cards preferred; short titles + 1–2 sentence descriptions'),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('timeline'),
|
||||
title: z.string(),
|
||||
events: z
|
||||
.array(z.object({ date: z.string(), title: z.string(), description: z.string().optional() }))
|
||||
.min(2)
|
||||
.max(6),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('quote'),
|
||||
quote: z.string().describe('Exact or faithful quote from the note'),
|
||||
author: z.string().optional(),
|
||||
context: z.string().optional().describe('Why this quote matters'),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('comparison'),
|
||||
title: z.string().describe('Action title of the comparison insight'),
|
||||
left: z.object({
|
||||
title: z.string(),
|
||||
points: z.array(z.string()).max(4),
|
||||
score: z.string().optional(),
|
||||
}),
|
||||
right: z.object({
|
||||
title: z.string(),
|
||||
points: z.array(z.string()).max(4),
|
||||
score: z.string().optional(),
|
||||
}),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('equation'),
|
||||
title: z.string(),
|
||||
equations: z.array(z.object({ latex: z.string(), label: z.string().optional() })).max(4),
|
||||
explanation: z.string().optional(),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('image'),
|
||||
title: z.string(),
|
||||
url: z.string().optional().describe('Only if a real image URL exists in the note'),
|
||||
caption: z.string().optional(),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('summary'),
|
||||
title: z.string().describe('e.g. Next steps / Key takeaways'),
|
||||
items: z.array(z.string()).max(5).describe('3–5 actionable takeaways'),
|
||||
notes: notesField,
|
||||
}),
|
||||
])
|
||||
|
||||
toolRegistry.register({
|
||||
name: 'generate_slides',
|
||||
description: 'Renders a structured presentation from JSON data into a full animated HTML file and saves it.',
|
||||
description: `Create an executive presentation from structured slide data (rendered to HTML by the server).
|
||||
|
||||
CONTENT RULES (quality > quantity):
|
||||
- One idea per slide; titles are ACTION TITLES (insights), not topic labels like "Context" or "Analysis"
|
||||
- Narrative: title → problem/context → insight → evidence → implications → summary
|
||||
- Bullets: 3–5 max, short claims (≈8–18 words), no filler paragraphs
|
||||
- chart/stats ONLY with real numbers from the source note — NEVER invent KPIs or radar scores
|
||||
- First slide type "title", last type "summary"
|
||||
- Hard limit: ${SLIDE_HARD_CAP} slides; fewer for short notes
|
||||
- All facts from the note; optional "notes" field for speaker cues
|
||||
|
||||
Slide types: title | bullets | chart | stats | table | cards | timeline | quote | comparison | equation | image | summary`,
|
||||
isInternal: true,
|
||||
buildTool: (ctx) =>
|
||||
tool({
|
||||
description: `Create a presentation from structured slide data. Each slide has a type and corresponding content.
|
||||
description: `Create a high-quality executive deck. Prefer short action titles and sparse, high-signal content over dense filler.
|
||||
|
||||
Available slide types:
|
||||
- "title": title, subtitle (opening slide with particles)
|
||||
- "bullets": title, items[] (bullet list with 4-6 items of 15+ words each)
|
||||
- "chart": title, chartType (bar|horizontal-bar|line|donut|radar), data[{label,value}], subtitle
|
||||
- "stats": title, stats[{value:"98%", label:"KPI name"}] (3-4 animated KPIs)
|
||||
- "table": title, headers[], rows[][] (data table)
|
||||
- "cards": title, cards[{title, description}] (3-6 info cards)
|
||||
- "timeline": title, events[{date, title, description}] (chronological events)
|
||||
- "quote": quote, author, context (citation with analysis)
|
||||
- "comparison": title, left{title, points[], score}, right{...} (A vs B)
|
||||
- "equation": title, equations[{latex, label}], explanation (math formulas)
|
||||
- "image": title, url, caption (image slide)
|
||||
- "summary": title, items[] (conclusion with checkmarks)
|
||||
Types:
|
||||
- title: title, subtitle?
|
||||
- bullets: title (insight), items[3-5] short claims
|
||||
- chart: ONLY real numbers — chartType bar|horizontal-bar|line|donut|radar, data[{label,value}]
|
||||
- stats: 2–4 real KPIs {value,label}
|
||||
- table / cards / timeline / quote / comparison / equation / image / summary
|
||||
|
||||
RULES:
|
||||
- Count the plain-text words in the note (ignore markdown, URLs, metadata, headers)
|
||||
- <50 words → 2-3 slides MAXIMUM (title + 1 content + summary)
|
||||
- 50-150 words → 3-4 slides max
|
||||
- 150-400 words → 5-6 slides max
|
||||
- >400 words → 7-8 slides max — HARD LIMIT, NEVER exceed 8
|
||||
- First slide MUST be type "title"
|
||||
- Last slide MUST be type "summary"
|
||||
- Include "chart" ONLY if real numeric data exists in the note — otherwise FORBIDDEN
|
||||
- Use VARIED types — never 2 identical types in a row
|
||||
- All text must come from the source note — never invent data`,
|
||||
Rules: first=title, last=summary, max ${SLIDE_HARD_CAP} slides, no invented data, no fake charts.`,
|
||||
inputSchema: z.object({
|
||||
title: z.string().describe('Short presentation title (6 words max)'),
|
||||
theme: z.string().optional().describe('Visual recipe: architectural-saas, midnight-cathedral, aurora-borealis, venture-pitch, clinical-precision, coastal-morning, etc.'),
|
||||
slides: z.array(slideSchema).max(8).describe('Array of slide objects, 3-8 slides MAX'),
|
||||
title: z.string().describe('Short deck title (max ~8 words)'),
|
||||
theme: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Visual recipe: architectural-saas, midnight-cathedral, aurora-borealis, venture-pitch, clinical-precision, coastal-morning, etc.',
|
||||
),
|
||||
slides: z.array(slideSchema).max(SLIDE_HARD_CAP).describe(`Slide array, 2–${SLIDE_HARD_CAP} items`),
|
||||
}),
|
||||
|
||||
execute: async ({ title, theme, slides }) => {
|
||||
try {
|
||||
// Hard cap: never more than 8 slides regardless of what the model outputs
|
||||
const cappedSlides = slides.slice(0, 8)
|
||||
const normalized = normalizeSlideDeck({ title, theme, slides: slides as unknown[] })
|
||||
|
||||
const html = buildPresentationHTML({ title, theme, slides: cappedSlides as any })
|
||||
const html = buildPresentationHTML({
|
||||
title: normalized.title,
|
||||
theme: normalized.theme,
|
||||
slides: normalized.slides as any,
|
||||
})
|
||||
|
||||
const canvas = await prisma.canvas.create({
|
||||
data: {
|
||||
name: title || 'Présentation',
|
||||
name: normalized.title || 'Présentation',
|
||||
data: JSON.stringify({
|
||||
type: 'slides',
|
||||
title: title || 'Présentation',
|
||||
title: normalized.title || 'Présentation',
|
||||
html,
|
||||
slideCount: cappedSlides.length,
|
||||
theme: theme || 'architectural-saas',
|
||||
spec: { title, theme, slides: cappedSlides },
|
||||
slideCount: normalized.slides.length,
|
||||
theme: normalized.theme || 'architectural-saas',
|
||||
spec: {
|
||||
title: normalized.title,
|
||||
theme: normalized.theme,
|
||||
slides: normalized.slides,
|
||||
},
|
||||
}),
|
||||
userId: ctx.userId,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
if (ctx.actionId) {
|
||||
await prisma.agentAction.update({
|
||||
where: { id: ctx.actionId },
|
||||
data: {
|
||||
status: 'success',
|
||||
result: canvas.id,
|
||||
log: `Slides generated: ${cappedSlides.length} slides, ${Math.round(html.length / 1024)}KB`,
|
||||
},
|
||||
}).catch(err => console.error('[Slides Tool] Failed to update action status:', err))
|
||||
await prisma.agentAction
|
||||
.update({
|
||||
where: { id: ctx.actionId },
|
||||
data: {
|
||||
status: 'success',
|
||||
result: canvas.id,
|
||||
log: `Slides generated: ${normalized.slides.length} slides, ${Math.round(html.length / 1024)}KB (quality-normalized)`,
|
||||
},
|
||||
})
|
||||
.catch((err) => console.error('[Slides Tool] Failed to update action status:', err))
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
canvasId: canvas.id,
|
||||
canvasName: canvas.name,
|
||||
slideCount: cappedSlides.length,
|
||||
message: `Presentation "${canvas.name}" created with ${cappedSlides.length} slides.`,
|
||||
slideCount: normalized.slides.length,
|
||||
message: `Presentation "${canvas.name}" created with ${normalized.slides.length} slides.`,
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('[Slides Tool] FATAL:', e)
|
||||
|
||||
@@ -17,6 +17,12 @@ export interface ToolUseOptions {
|
||||
systemPrompt?: string
|
||||
messages?: any[]
|
||||
prompt?: string
|
||||
/**
|
||||
* Force tool usage when models (e.g. lite Gemini) prefer free text.
|
||||
* - 'auto' | 'required' | 'none'
|
||||
* - { type: 'tool', toolName: 'generate_slides' }
|
||||
*/
|
||||
toolChoice?: any
|
||||
}
|
||||
|
||||
export interface ToolCallResult {
|
||||
|
||||
Reference in New Issue
Block a user