Files
Momento/memento-note/lib/ai/services/slide-content-quality.ts
Antigravity 556a0b2f3f 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.
2026-07-16 20:43:18 +00:00

644 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Slide deck 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: '23 slides MAX (note très courte)',
labelEn: '23 slides MAX (very short note)',
}
}
if (wordCount < 150) {
return {
max: 4,
min: 3,
labelFr: '34 slides MAX (note courte)',
labelEn: '34 slides MAX (short note)',
}
}
if (wordCount < 400) {
return {
max: 6,
min: 4,
labelFr: '46 slides MAX (note moyenne)',
labelEn: '46 slides MAX (medium note)',
}
}
return {
max: SLIDE_HARD_CAP,
min: 5,
labelFr: '58 slides MAX (note longue) — ne jamais dépasser 8',
labelEn: '58 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 (818 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 35 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" : 12 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: 35 MAX, one distinct claim per line (ideally 818 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 35 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: 12 sentences for the presenter (not shown on the slide).
`