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:
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).
|
||||
`
|
||||
Reference in New Issue
Block a user