feat: dashboard Second Brain, essai 7 jours et vérification e-mail
Rendre le dashboard actionnable (inbox, peek, carte mentale), aligner la facturation sur l’essai 7 jours, et bloquer le login e-mail tant que l’adresse n’est pas confirmée. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -49,6 +49,7 @@ const SlideTypeEnum = z.enum([
|
||||
'chart',
|
||||
'table',
|
||||
'quote',
|
||||
'image',
|
||||
'summary',
|
||||
])
|
||||
|
||||
@@ -61,6 +62,8 @@ const OutlineSlideSchema = z.object({
|
||||
keyPoints: z.array(z.string()).min(1).max(6),
|
||||
/** For equation slides: latex strings to include */
|
||||
formulas: z.array(z.string()).optional(),
|
||||
/** For image slides: 0-based index into assets.images */
|
||||
imageIndex: z.number().optional(),
|
||||
narrativeRole: z.enum(['opening', 'evidence', 'transition', 'conclusion', 'data']).optional(),
|
||||
})
|
||||
|
||||
@@ -96,6 +99,8 @@ const ExpandedSlideSchema = z.object({
|
||||
author: z.string().optional(),
|
||||
context: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
caption: z.string().optional(),
|
||||
})
|
||||
|
||||
export interface GenerateSlideDeckParams {
|
||||
@@ -138,6 +143,51 @@ const LANG_NAMES: Record<string, string> = {
|
||||
hi: 'Hindi',
|
||||
}
|
||||
|
||||
/** Detect content language from text via Unicode script ranges + Latin word frequency. */
|
||||
function detectContentLanguage(text: string): string {
|
||||
if (!text) return 'English'
|
||||
// Non-Latin scripts (deterministic)
|
||||
if (/[\u0600-\u06FF\uFB50-\uFDFF\uFE70-\uFEFF]/.test(text)) {
|
||||
if (/[\u06CC\u0698\u06AF\u06A9\u067E\u0686]/.test(text)) return 'Persian (Farsi)'
|
||||
return 'Arabic'
|
||||
}
|
||||
if (/[\uAC00-\uD7AF]/.test(text)) return 'Korean'
|
||||
if (/[\u3040-\u309F\u30A0-\u30FF]/.test(text)) return 'Japanese'
|
||||
if (/[\u4E00-\u9FFF]/.test(text)) return 'Chinese'
|
||||
if (/[\u0400-\u04FF]/.test(text)) return 'Russian'
|
||||
if (/[\u0900-\u097F]/.test(text)) return 'Hindi'
|
||||
|
||||
// Latin script — distinguish via accented chars + function word frequency
|
||||
const lower = text.toLowerCase()
|
||||
const wc = (re: RegExp) => (lower.match(re) || []).length
|
||||
|
||||
// French: distinctive accents (é è ê à ô etc) + function words
|
||||
const frAccents = (lower.match(/[éèêëàâäïîôöùûüç]/g) || []).length
|
||||
const frWords = wc(/\b(dans|avec|pour|cette|être|avoir|fait|plus|sans|sous|entre|après|très|bien|aussi|même|encore|toujours|jamais|pendant|depuis|chez|leurs|mes|tes|ses|notre|votre|celui|ceux|celle|aucun|autre|chaque)\b/g)
|
||||
const frScore = frAccents + frWords
|
||||
|
||||
// Spanish
|
||||
const esAccents = (lower.match(/[áíóúñ¿¡]/g) || []).length
|
||||
const esWords = wc(/\b(pero|como|más|para|con|por|una|los|las|del|al|también|puede|antes|después|siempre|nunca|también|aquí|allí|muy|bien|también|nosotros|vosotros|ellos|suyo|nuestro)\b/g)
|
||||
const esScore = esAccents + esWords
|
||||
|
||||
// German
|
||||
const deUmlauts = (lower.match(/[äöüß]/g) || []).length
|
||||
const deWords = wc(/\b(und|ist|ein|eine|von|mit|für|auf|nicht|auch|sich|bei|zum|zur|den|des|dem|wir|sie|hat|war|wird|sind|kann|muss|noch|schon|immer|wieder)\b/g)
|
||||
const deScore = deUmlauts * 2 + deWords
|
||||
|
||||
// Pick highest scorer (must beat English baseline)
|
||||
const scores: [string, number][] = [
|
||||
['French', frScore],
|
||||
['Spanish', esScore],
|
||||
['German', deScore],
|
||||
]
|
||||
scores.sort((a, b) => b[1] - a[1])
|
||||
// Require a minimum signal (accents/words) to avoid false positives on short English text
|
||||
if (scores[0][1] >= 6) return scores[0][0]
|
||||
return 'English'
|
||||
}
|
||||
|
||||
// ── LLM helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function extractJsonPayload(text: string): unknown | null {
|
||||
@@ -223,6 +273,18 @@ function outlineSystem(lang: 'fr' | 'en', maxSlides: number, assets: SourceAsset
|
||||
: `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.`
|
||||
: ''
|
||||
|
||||
const imageRule = assets.hasImages
|
||||
? lang === 'fr'
|
||||
? `MATÉRIEL VISUEL: ${assets.images.length} image(s) disponible(s). Inclus 1 à ${Math.min(3, assets.images.length)} slide(s) de type "image" pour illustrer le propos. Pour chaque slide image, donne l'index (imageIndex, 0-based) de l'image à utiliser.`
|
||||
: `VISUAL MATERIAL: ${assets.images.length} image(s) available. Include 1 to ${Math.min(3, assets.images.length)} slide(s) of type "image" to illustrate key points. For each image slide, provide imageIndex (0-based) of the image to use.`
|
||||
: ''
|
||||
|
||||
const chartRule = assets.hasNumbers
|
||||
? lang === 'fr'
|
||||
? `DONNÉES NUMÉRIQUES: ${assets.numbers.length} valeurs détectées. Tu DOIS inclure au moins 1 slide de type "chart". chartType: "bar" (comparer des quantités), "line" (tendance temporelle), "donut" (proportions d'un tout).`
|
||||
: `NUMERIC DATA: ${assets.numbers.length} values detected. You MUST include at least 1 slide of type "chart". chartType: "bar" (compare quantities), "line" (time trend), "donut" (proportions of a whole).`
|
||||
: ''
|
||||
|
||||
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).
|
||||
@@ -233,10 +295,13 @@ Règles (DeckForge + think-cell):
|
||||
- keyPoints: 2–5 FAITS CONCRETS tirés de la note (chiffres, noms, formules) — jamais de filler
|
||||
- Chaque slide = UNE idée actionnable (titre = insight, pas libellé de section)
|
||||
- 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
|
||||
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | image | summary
|
||||
- ATTENTION: le type "equation" est RÉSERVÉ aux formules mathématiques réelles (avec LaTeX). N'utilise JAMAIS "equation" pour un concept métaphorique (équilibre, rapport de forces, etc.) → utilise "bullets" ou "comparison" à la place.
|
||||
- Slide 1 = title, dernière = summary
|
||||
- INTERDIT slides vides, listes de 1 mot, ou répéter le titre de la note
|
||||
${mathRule}
|
||||
${imageRule}
|
||||
${chartRule}
|
||||
Réponds en JSON OutlineSchema.`
|
||||
}
|
||||
return `You are DeckForge/PPTAgent narrative architect for Memento.
|
||||
@@ -248,10 +313,13 @@ Rules:
|
||||
- keyPoints: 2–5 CONCRETE facts from the note (numbers, names, formulas) — no filler
|
||||
- One actionable idea per slide (title = insight, not section label)
|
||||
- Pedagogical arc for lessons: title → definitions/equations → properties → examples → summary
|
||||
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | summary
|
||||
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | image | summary
|
||||
- WARNING: type "equation" is RESERVED for real mathematical formulas (with LaTeX). NEVER use "equation" for metaphorical concepts (balance of power, concessions, etc.) → use "bullets" or "comparison" instead.
|
||||
- First=title, last=summary
|
||||
- FORBIDDEN empty slides, one-word lists, or repeating the note title alone
|
||||
${mathRule}
|
||||
${imageRule}
|
||||
${chartRule}
|
||||
JSON OutlineSchema only.`
|
||||
}
|
||||
|
||||
@@ -268,7 +336,10 @@ OBLIGATOIRE selon type:
|
||||
- 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
|
||||
- chart: title + chartType + data[{label, value: NUMBER}] (min 2 entrées)
|
||||
chartType: "bar" (comparer quantités) | "line" (tendance temporelle) | "donut" (proportions) | "horizontal-bar" (libellés longs) | "radar" (comparer dimensions)
|
||||
IMPORTANT: value DOIT être un number (pas string). Utilise les chiffres fournis dans le prompt.
|
||||
- image: title + url (fourni dans le prompt) + caption (1 phrase décrivant ce que l'image illustre)
|
||||
- summary: title + items[3-5] actionnables
|
||||
- quote: quote non vide
|
||||
|
||||
@@ -284,7 +355,11 @@ REQUIRED by type:
|
||||
- cards: title + cards[2-4]
|
||||
- comparison: left/right points[2-4] each
|
||||
- timeline: events[2-5]
|
||||
- stats/chart: only with real numbers provided
|
||||
- stats: only with real numbers provided
|
||||
- chart: title + chartType + data[{label, value: NUMBER}] (min 2 entries)
|
||||
chartType: "bar" (compare quantities) | "line" (time trend) | "donut" (proportions) | "horizontal-bar" (long labels) | "radar" (compare dimensions)
|
||||
IMPORTANT: value MUST be a number (not string). Use the numbers provided in the prompt.
|
||||
- image: title + url (provided in prompt) + caption (1 sentence describing what the image illustrates)
|
||||
- summary: items[3-5]
|
||||
- quote: non-empty quote
|
||||
|
||||
@@ -356,6 +431,72 @@ function enforceMathOutline(
|
||||
}
|
||||
}
|
||||
|
||||
/** Force image slides into outline when server extracted images (like enforceMathOutline for formulas). */
|
||||
function enforceImageOutline(
|
||||
outline: z.infer<typeof OutlineSchema>,
|
||||
assets: SourceAssets,
|
||||
maxSlides: number,
|
||||
): z.infer<typeof OutlineSchema> {
|
||||
if (!assets.hasImages || assets.images.length === 0) return outline
|
||||
const hasImg = outline.slides.some((s) => s.type === 'image')
|
||||
if (hasImg) return outline
|
||||
|
||||
// Inject 1 image slide after title (or after first equation slide)
|
||||
const title = outline.slides.find((s) => s.type === 'title') || outline.slides[0]!
|
||||
const insertAfter = outline.slides.findIndex((s) => s.type === 'title')
|
||||
const insertIdx = insertAfter >= 0 ? insertAfter + 1 : 1
|
||||
|
||||
const imgSlide: z.infer<typeof OutlineSlideSchema> = {
|
||||
position: insertIdx + 1,
|
||||
type: 'image',
|
||||
headline: assets.keySentences[0]?.slice(0, 60) || 'Illustration',
|
||||
keyPoints: [assets.images[0]!],
|
||||
imageIndex: 0,
|
||||
narrativeRole: 'evidence',
|
||||
}
|
||||
|
||||
const before = outline.slides.slice(0, insertIdx)
|
||||
const after = outline.slides.slice(insertIdx, maxSlides - 1)
|
||||
return {
|
||||
...outline,
|
||||
slides: [...before, imgSlide, ...after].map((s, i) => ({ ...s, position: i + 1 })),
|
||||
}
|
||||
}
|
||||
|
||||
function pickChartType(count: number): 'bar' | 'horizontal-bar' | 'donut' {
|
||||
if (count <= 4) return 'donut'
|
||||
if (count <= 6) return 'bar'
|
||||
return 'horizontal-bar'
|
||||
}
|
||||
|
||||
function enforceChartOutline(
|
||||
outline: z.infer<typeof OutlineSchema>,
|
||||
assets: SourceAssets,
|
||||
maxSlides: number,
|
||||
): z.infer<typeof OutlineSchema> {
|
||||
if (!assets.hasNumbers || assets.numbers.length < 2) return outline
|
||||
const hasChart = outline.slides.some((s) => s.type === 'chart')
|
||||
if (hasChart) return outline
|
||||
|
||||
const titleIdx = outline.slides.findIndex((s) => s.type === 'title')
|
||||
const insertIdx = titleIdx >= 0 ? titleIdx + 1 : 1
|
||||
|
||||
const chartSlide: z.infer<typeof OutlineSlideSchema> = {
|
||||
position: insertIdx + 1,
|
||||
type: 'chart',
|
||||
headline: assets.keySentences[0]?.slice(0, 60) || 'Données clés',
|
||||
keyPoints: assets.numbers.slice(0, 4).map((n) => `${n.label}: ${n.raw}`),
|
||||
narrativeRole: 'data',
|
||||
}
|
||||
|
||||
const before = outline.slides.slice(0, insertIdx)
|
||||
const after = outline.slides.slice(insertIdx, maxSlides - 1)
|
||||
return {
|
||||
...outline,
|
||||
slides: [...before, chartSlide, ...after].map((s, i) => ({ ...s, position: i + 1 })),
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackExpandFromOutline(
|
||||
o: z.infer<typeof OutlineSlideSchema>,
|
||||
assets: SourceAssets,
|
||||
@@ -371,10 +512,17 @@ function fallbackExpandFromOutline(
|
||||
}
|
||||
if (type === 'equation') {
|
||||
const forms = o.formulas?.length ? o.formulas : assets.formulas.slice(0, 3)
|
||||
if (!forms.length) {
|
||||
return {
|
||||
type: 'bullets',
|
||||
title,
|
||||
items: (o.keyPoints.length >= 2 ? o.keyPoints : assets.keySentences.slice(0, 3)).slice(0, 5),
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: 'equation',
|
||||
title,
|
||||
equations: (forms.length ? forms : ['f(x) = ?']).map((latex, i) => ({
|
||||
equations: forms.map((latex, i) => ({
|
||||
latex,
|
||||
label: o.keyPoints[i] || `Formule ${i + 1}`,
|
||||
})),
|
||||
@@ -420,6 +568,16 @@ function fallbackExpandFromOutline(
|
||||
})),
|
||||
}
|
||||
}
|
||||
if (type === 'image') {
|
||||
const imgIdx = o.imageIndex ?? 0
|
||||
const url = assets.images[imgIdx] || assets.images[0] || ''
|
||||
return {
|
||||
type: 'image',
|
||||
title,
|
||||
url,
|
||||
caption: o.keyPoints[0] || assets.keySentences[0] || '',
|
||||
}
|
||||
}
|
||||
// default bullets — use keyPoints + sentences to fill density
|
||||
const items = [
|
||||
...o.keyPoints,
|
||||
@@ -490,15 +648,15 @@ export async function generateSlideDeck(params: GenerateSlideDeckParams): Promis
|
||||
}
|
||||
}
|
||||
|
||||
const noteLang = notes[0]?.language
|
||||
const contentLang =
|
||||
params.contentLanguage ||
|
||||
(noteLang && LANG_NAMES[noteLang] ? LANG_NAMES[noteLang] : lang === 'fr' ? 'French' : 'English')
|
||||
|
||||
const noteLang = notes.find((n) => n.language)?.language
|
||||
const combined = notes.map((n) => n.content || '').join('\n\n')
|
||||
const assets = extractSourceAssets(combined)
|
||||
const contentLang =
|
||||
params.contentLanguage ||
|
||||
(noteLang && LANG_NAMES[noteLang] ? LANG_NAMES[noteLang] : detectContentLanguage(combined))
|
||||
const perNoteLimit = notes.length > 5 ? 1200 : 8000
|
||||
const notesText = notes
|
||||
.map((n) => `### ${n.title || 'Note'}\n${prepareNoteTextForSlides(n.content || '', 10_000)}`)
|
||||
.map((n) => `### ${n.title || 'Note'}\n${prepareNoteTextForSlides(n.content || '', perNoteLimit)}`)
|
||||
.join('\n\n')
|
||||
const wordCount = notes.reduce((s, n) => s + countNoteWords(n.content || ''), 0)
|
||||
const limit = slideLimitFromWordCount(wordCount)
|
||||
@@ -526,10 +684,7 @@ export async function generateSlideDeck(params: GenerateSlideDeckParams): Promis
|
||||
? `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')}`
|
||||
? `DONNÉES NUMÉRIQUES (JSON pour slides chart/stats — utilise ces valeurs exactes):\n${JSON.stringify(assets.numbers.slice(0, 10).map((n) => ({ label: n.label.slice(0, 24), value: n.value })))}`
|
||||
: '',
|
||||
assets.keySentences.length
|
||||
? `PHRASES CLÉS:\n${assets.keySentences
|
||||
@@ -537,6 +692,9 @@ export async function generateSlideDeck(params: GenerateSlideDeckParams): Promis
|
||||
.map((s) => `- ${s}`)
|
||||
.join('\n')}`
|
||||
: '',
|
||||
assets.images.length
|
||||
? `IMAGES DISPONIBLES (utilise imageIndex 0-based pour les slides image):\n${assets.images.map((url, i) => `${i}. ${url}`).join('\n')}`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
@@ -556,68 +714,49 @@ export async function generateSlideDeck(params: GenerateSlideDeckParams): Promis
|
||||
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))
|
||||
}
|
||||
// Images → force image slides when images exist but LLM didn't create any
|
||||
if (assets.hasImages) {
|
||||
outline = enforceImageOutline(outline, assets, targetMax)
|
||||
}
|
||||
|
||||
// Charts → force chart slides when numbers exist but LLM didn't create any
|
||||
if (assets.hasNumbers) {
|
||||
outline = enforceChartOutline(outline, assets, targetMax)
|
||||
}
|
||||
|
||||
// ── Stage 2: Expand slides ──
|
||||
// Single-pass: outline LLM call + deterministic fill (1 LLM call total)
|
||||
let mode = 'outline+deterministic-fill'
|
||||
|
||||
const expanded: z.infer<typeof ExpandedSlideSchema>[] = outline.slides.map((slideOutline) => {
|
||||
const one = fallbackExpandFromOutline(slideOutline, assets)
|
||||
one.type = slideOutline.type
|
||||
if (!one.title) one.title = slideOutline.headline
|
||||
return one
|
||||
})
|
||||
|
||||
// ── Stage 3: Normalize + STEM inject + substance gate ──
|
||||
|
||||
// Rescue chart slides with missing/partial data before normalization drops them
|
||||
const rescued = expanded.map((slide) => {
|
||||
if (slide.type !== 'chart') return slide
|
||||
const validData = (slide.data || []).filter(
|
||||
(d: any) => d && typeof d.value === 'number' && Number.isFinite(d.value) && String(d.label || '').trim(),
|
||||
)
|
||||
if (validData.length < 2 && assets.numbers.length >= 2) {
|
||||
return {
|
||||
...slide,
|
||||
data: assets.numbers.slice(0, 6).map((n) => ({ label: n.label.slice(0, 20), value: n.value })),
|
||||
chartType: slide.chartType || pickChartType(assets.numbers.length),
|
||||
}
|
||||
}
|
||||
return slide
|
||||
})
|
||||
|
||||
let normalized = normalizeSlideDeck({
|
||||
title: outline.title,
|
||||
theme,
|
||||
slides: expanded as unknown[],
|
||||
slides: rescued as unknown[],
|
||||
})
|
||||
|
||||
// Always inject formulas if missing (deterministic — never ship STEM without equations)
|
||||
@@ -630,7 +769,6 @@ Expand into full ExpandedSlide JSON (non-empty body).`,
|
||||
}
|
||||
|
||||
let gate = assertDeckHasSubstance(normalized, stemOpts)
|
||||
let mode = 'outline+per-slide-expand'
|
||||
|
||||
if (!gate.ok) {
|
||||
// Deterministic fill for empty body slides using outline + assets
|
||||
@@ -669,6 +807,30 @@ Expand into full ExpandedSlide JSON (non-empty body).`,
|
||||
|
||||
if (!normalized.theme) normalized.theme = theme
|
||||
|
||||
// GUARANTEE: if numbers were extracted but no chart survived the pipeline, force-inject one
|
||||
if (assets.hasNumbers && assets.numbers.length >= 2) {
|
||||
const hasChart = normalized.slides.some((s) => s.type === 'chart')
|
||||
if (!hasChart) {
|
||||
const chartSlide: Record<string, unknown> = {
|
||||
type: 'chart',
|
||||
title: lang === 'fr' ? 'Données clés' : 'Key data',
|
||||
chartType: pickChartType(assets.numbers.length),
|
||||
data: assets.numbers.slice(0, 6).map((n) => ({
|
||||
label: (n.label.slice(0, 20) || n.raw),
|
||||
value: n.value,
|
||||
})),
|
||||
}
|
||||
const summaryIdx = normalized.slides.findIndex((s) => s.type === 'summary')
|
||||
if (summaryIdx >= 0) {
|
||||
normalized.slides.splice(summaryIdx, 0, chartSlide)
|
||||
} else if (normalized.slides.length < 8) {
|
||||
normalized.slides.push(chartSlide)
|
||||
} else {
|
||||
normalized.slides[normalized.slides.length - 2] = chartSlide
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const canvas = await persist(params.userId, normalized, params.actionId, mode)
|
||||
return {
|
||||
success: true,
|
||||
|
||||
Reference in New Issue
Block a user