feat: dashboard Second Brain, essai 7 jours et vérification e-mail
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m14s
CI / Deploy production (on server) (push) Successful in 1m25s

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:
Antigravity
2026-08-30 07:19:36 +00:00
parent 69c99e4f4f
commit 80ccc1f6de
95 changed files with 4158 additions and 618 deletions

View File

@@ -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: 25 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: 25 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,