/** * 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[] } { 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 => Boolean(s) && typeof s === 'object') .map((s) => normalizeOneSlide(s)) .filter(Boolean) as Record[] 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): Record | 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): 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[]): 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[] }, 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[] }, formulas: string[], opts?: { lang?: 'fr' | 'en' }, ): { title: string; theme: string; slides: Record[] } { 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[] { 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). `