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

Un pot de crédits global (BASIC 100 / PRO 1 000 / BUSINESS 4 000) remplace
les seaux par fonction côté débit. Packs one-shot S/M/L, admin sans seaux
legacy, usage multi-features, hints de coût, anti-flash thème et hydratation
admin. Inclut aussi le pipeline slides renforcé et la page publique polishée.
This commit is contained in:
Antigravity
2026-07-16 20:43:18 +00:00
parent 704fed1191
commit 556a0b2f3f
77 changed files with 35745 additions and 10430 deletions

View File

@@ -0,0 +1,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: '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).
`