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:
@@ -2,12 +2,15 @@ import { willUseByokForLane } from '@/lib/ai/provider-for-user'
|
||||
import type { AiFeatureLane } from '@/lib/ai/router'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import {
|
||||
reserveUsageOrThrow,
|
||||
releaseUsage,
|
||||
checkSessionEntitlementOrThrow,
|
||||
QuotaExceededError,
|
||||
QuotaServiceUnavailableError,
|
||||
} from '@/lib/entitlements'
|
||||
import {
|
||||
releaseCredits,
|
||||
reserveCreditsOrThrow,
|
||||
resolveCreditCost,
|
||||
} from '@/lib/credits'
|
||||
import type { FeatureName } from '@/lib/quota-utils'
|
||||
import { emitAiUsageChanged } from '@/lib/ai-usage-sync'
|
||||
|
||||
@@ -15,22 +18,34 @@ export type ReserveAiUsageOptions = {
|
||||
lane?: AiFeatureLane
|
||||
/** Defaults to userId — host-pays brainstorm bills this account. */
|
||||
billingUserId?: string
|
||||
/** Units / crédits (default 1). Multi-step features (slides) pass amount > 1. */
|
||||
amount?: number
|
||||
/** Pour coût slides précis (1+N). */
|
||||
slideCount?: number | null
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Reserve one AI unit; skips when BYOK will be used for the lane. */
|
||||
/** Reserve crédits IA ; saute si BYOK pour le lane. */
|
||||
export async function reserveAiUsageOrThrow(
|
||||
userId: string,
|
||||
feature: FeatureName,
|
||||
options?: ReserveAiUsageOptions,
|
||||
): Promise<{ usedByok: boolean; billedUserId: string }> {
|
||||
): Promise<{ usedByok: boolean; billedUserId: string; amount: number }> {
|
||||
const billedUserId = options?.billingUserId ?? userId
|
||||
const lane = options?.lane ?? 'chat'
|
||||
const cost = resolveCreditCost(feature, {
|
||||
amount: options?.amount,
|
||||
slideCount: options?.slideCount,
|
||||
})
|
||||
const config = await getSystemConfig()
|
||||
const { usedByok } = await willUseByokForLane(lane, config, billedUserId)
|
||||
if (!usedByok) {
|
||||
await reserveUsageOrThrow(billedUserId, feature)
|
||||
await reserveCreditsOrThrow(billedUserId, cost, {
|
||||
feature,
|
||||
metadata: options?.metadata,
|
||||
})
|
||||
}
|
||||
return { usedByok, billedUserId }
|
||||
return { usedByok, billedUserId, amount: cost }
|
||||
}
|
||||
|
||||
/** Host-pays: reserve on session owner with guest metadata on 402. */
|
||||
@@ -39,30 +54,49 @@ export async function reserveSessionAiUsageOrThrow(
|
||||
triggeredByUserId: string,
|
||||
isGuestActor: boolean,
|
||||
feature: FeatureName,
|
||||
options?: { lane?: AiFeatureLane },
|
||||
): Promise<{ usedByok: boolean }> {
|
||||
options?: { lane?: AiFeatureLane; amount?: number; slideCount?: number | null },
|
||||
): Promise<{ usedByok: boolean; amount: number }> {
|
||||
const lane = options?.lane ?? 'chat'
|
||||
const cost = resolveCreditCost(feature, {
|
||||
amount: options?.amount,
|
||||
slideCount: options?.slideCount,
|
||||
})
|
||||
const config = await getSystemConfig()
|
||||
const { usedByok } = await willUseByokForLane(lane, config, billingOwnerId)
|
||||
if (!usedByok) {
|
||||
await checkSessionEntitlementOrThrow(
|
||||
billingOwnerId,
|
||||
triggeredByUserId,
|
||||
isGuestActor,
|
||||
feature,
|
||||
)
|
||||
try {
|
||||
await reserveCreditsOrThrow(billingOwnerId, cost, { feature })
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) {
|
||||
err.billingOwnerId = billingOwnerId
|
||||
err.triggeredByUserId = triggeredByUserId
|
||||
err.isGuestActor = isGuestActor
|
||||
// Also keep session path for legacy callers
|
||||
try {
|
||||
await checkSessionEntitlementOrThrow(
|
||||
billingOwnerId,
|
||||
triggeredByUserId,
|
||||
isGuestActor,
|
||||
feature,
|
||||
)
|
||||
} catch {
|
||||
/* already QuotaExceeded from credits */
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
return { usedByok }
|
||||
return { usedByok, amount: cost }
|
||||
}
|
||||
|
||||
/** Run fn under quota; releases the unit if fn throws (unless BYOK). */
|
||||
/** Run fn under crédits ; libère si fn throw (sauf BYOK). */
|
||||
export async function withAiQuota<T>(
|
||||
userId: string,
|
||||
feature: FeatureName,
|
||||
fn: () => Promise<T>,
|
||||
options?: ReserveAiUsageOptions,
|
||||
): Promise<T> {
|
||||
const { usedByok, billedUserId } = await reserveAiUsageOrThrow(userId, feature, options)
|
||||
const { usedByok, billedUserId, amount } = await reserveAiUsageOrThrow(userId, feature, options)
|
||||
try {
|
||||
const result = await fn()
|
||||
if (!usedByok) {
|
||||
@@ -70,8 +104,8 @@ export async function withAiQuota<T>(
|
||||
}
|
||||
return result
|
||||
} catch (err) {
|
||||
if (!usedByok) {
|
||||
await releaseUsage(billedUserId, feature)
|
||||
if (!usedByok && amount > 0) {
|
||||
await releaseCredits(billedUserId, amount, { feature })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
@@ -84,9 +118,9 @@ export async function withSessionAiQuota<T>(
|
||||
isGuestActor: boolean,
|
||||
feature: FeatureName,
|
||||
fn: () => Promise<T>,
|
||||
options?: { lane?: AiFeatureLane },
|
||||
options?: { lane?: AiFeatureLane; amount?: number; slideCount?: number | null },
|
||||
): Promise<T> {
|
||||
const { usedByok } = await reserveSessionAiUsageOrThrow(
|
||||
const { usedByok, amount } = await reserveSessionAiUsageOrThrow(
|
||||
billingOwnerId,
|
||||
triggeredByUserId,
|
||||
isGuestActor,
|
||||
@@ -100,8 +134,8 @@ export async function withSessionAiQuota<T>(
|
||||
}
|
||||
return result
|
||||
} catch (err) {
|
||||
if (!usedByok) {
|
||||
await releaseUsage(billingOwnerId, feature)
|
||||
if (!usedByok && amount > 0) {
|
||||
await releaseCredits(billingOwnerId, amount, { feature })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
@@ -281,5 +281,40 @@ export function getChatProvider(config?: Record<string, string>): AIProvider {
|
||||
return getProviderInstance(route.providerType as ProviderType, cfg, route.modelName, route.embeddingModelName, route.ollamaBaseUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider for slide deck generation.
|
||||
* Uses AI_PROVIDER_SLIDES + AI_MODEL_SLIDES when both are set in admin.
|
||||
* If only AI_MODEL_SLIDES is set, reuses the chat provider with that exact model id.
|
||||
* Otherwise falls back to the chat route — never rewrites or invents model names.
|
||||
*/
|
||||
export function getSlidesProvider(config?: Record<string, string>): AIProvider {
|
||||
const cfg = config || {};
|
||||
const slidesModel = (cfg.AI_MODEL_SLIDES || process.env.AI_MODEL_SLIDES || '').trim();
|
||||
const slidesProvider = (cfg.AI_PROVIDER_SLIDES || process.env.AI_PROVIDER_SLIDES || '').trim();
|
||||
const route = resolveAiRoute('chat', cfg);
|
||||
|
||||
if (slidesModel && slidesProvider) {
|
||||
return getProviderInstance(
|
||||
slidesProvider as ProviderType,
|
||||
cfg,
|
||||
slidesModel,
|
||||
route.embeddingModelName,
|
||||
route.ollamaBaseUrl,
|
||||
);
|
||||
}
|
||||
|
||||
if (slidesModel) {
|
||||
return getProviderInstance(
|
||||
route.providerType as ProviderType,
|
||||
cfg,
|
||||
slidesModel,
|
||||
route.embeddingModelName,
|
||||
route.ollamaBaseUrl,
|
||||
);
|
||||
}
|
||||
|
||||
return getChatProvider(cfg);
|
||||
}
|
||||
|
||||
// Export for use by admin settings form and deploy scripts
|
||||
export { getProviderConfigKeys };
|
||||
|
||||
@@ -18,7 +18,26 @@ import { calculateNextRun } from '@/lib/agents/schedule'
|
||||
import { markdownToHtml } from '@/lib/markdown-to-html'
|
||||
import { createNotification } from '@/app/actions/notifications'
|
||||
import { withAiQuota } from '@/lib/ai-quota'
|
||||
import { releaseCredits, slideGenerateCreditCost } from '@/lib/credits'
|
||||
import type { FeatureName } from '@/lib/quota-utils'
|
||||
import {
|
||||
countNoteWords,
|
||||
prepareNoteTextForSlides,
|
||||
slideLimitFromWordCount,
|
||||
SLIDE_CONTENT_PRINCIPLES_EN,
|
||||
SLIDE_CONTENT_PRINCIPLES_FR,
|
||||
normalizeSlideDeck,
|
||||
} from '@/lib/ai/services/slide-content-quality'
|
||||
import { generateSlideDeck } from '@/lib/ai/services/slide-deck-generator.service'
|
||||
import { decodeSlideIntentDescription } from '@/lib/ai/services/slide-intent'
|
||||
|
||||
export type ExecuteAgentOptions = {
|
||||
/**
|
||||
* When true, caller already reserved quota (e.g. run-for-note multi-unit pre-check).
|
||||
* Avoids double-charging on fire-and-forget generation.
|
||||
*/
|
||||
skipQuota?: boolean
|
||||
}
|
||||
|
||||
// Import tools for side-effect registration
|
||||
import '../tools'
|
||||
@@ -915,94 +934,54 @@ This format AUTOMATICALLY creates shapes, text, AND arrows with correct bindings
|
||||
},
|
||||
|
||||
'slide-generator': {
|
||||
fr: `Tu es un expert en design de présentations exécutives. Tu analyses la note et génères une structure JSON qui sera rendue en HTML automatiquement par le serveur.
|
||||
fr: `Tu es un consultant senior (niveau McKinsey/BCG) qui transforme une note Memento en deck exécutif de classe mondiale.
|
||||
|
||||
APPELLE OBLIGATOIREMENT generate_slides avec le JSON structuré. NE GÉNÈRE JAMAIS de HTML brut.
|
||||
|
||||
═══ ÉTAPE 1 — LECTURE (OBLIGATOIRE) ═══
|
||||
1. Lis la note avec note_read. Extrais TOUTES les données exploitables (chiffres, listes, citations, comparaisons, étapes).
|
||||
2. Identifie les types de slides les plus adaptés au contenu réel.
|
||||
${SLIDE_CONTENT_PRINCIPLES_FR}
|
||||
|
||||
═══ ÉTAPE 2 — APPEL generate_slides ═══
|
||||
Appelle generate_slides avec un objet JSON structuré :
|
||||
═══ MÉTHODE ═══
|
||||
1. Lis la note (contenu fourni + note_read si besoin). Extrais : claims, chiffres, citations, étapes, oppositions, décisions.
|
||||
2. Construis un storyboard mental (max N slides selon le message user) avant d'appeler l'outil.
|
||||
3. Appelle generate_slides :
|
||||
{
|
||||
"title": "Titre court (6 mots max)",
|
||||
"theme": "architectural-saas", // ou midnight-cathedral, venture-pitch, clinical-precision, etc.
|
||||
"slides": [ ... tableau de slides ... ]
|
||||
"title": "Titre du deck (≤8 mots)",
|
||||
"theme": "architectural-saas",
|
||||
"slides": [ ... ]
|
||||
}
|
||||
|
||||
═══ TYPES DE SLIDES DISPONIBLES ═══
|
||||
1. "title" → { type:"title", title:"...", subtitle:"..." }
|
||||
2. "bullets" → { type:"bullets", title:"...", items:["phrase 1 (15+ mots)", "phrase 2", ...] }
|
||||
3. "chart" → { type:"chart", title:"...", chartType:"bar|horizontal-bar|line|donut|radar", data:[{label:"Q1",value:65}, ...], subtitle:"..." }
|
||||
4. "stats" → { type:"stats", title:"...", stats:[{value:"98%", label:"Satisfaction"}, ...] }
|
||||
5. "table" → { type:"table", title:"...", headers:["Col A","Col B"], rows:[["val1","val2"], ...] }
|
||||
6. "cards" → { type:"cards", title:"...", cards:[{title:"Titre", description:"Description détaillée..."}, ...] }
|
||||
7. "timeline" → { type:"timeline", title:"...", events:[{date:"Jan 2024", title:"Lancement", description:"..."}, ...] }
|
||||
8. "quote" → { type:"quote", quote:"Citation exacte", author:"Auteur", context:"Analyse..." }
|
||||
9. "comparison" → { type:"comparison", title:"...", left:{title:"A", points:["..."], score:"8/10"}, right:{title:"B", points:["..."], score:"6/10"} }
|
||||
10. "equation" → { type:"equation", title:"...", equations:[{latex:"E=mc^2", label:"Énergie"}], explanation:"..." }
|
||||
11. "image" → { type:"image", title:"...", url:"https://...", caption:"..." }
|
||||
12. "summary" → { type:"summary", title:"...", items:["Point clé 1", "Point clé 2", ...] }
|
||||
═══ TYPES (choisir selon le contenu, pas pour « remplir ») ═══
|
||||
title | bullets | chart* | stats* | table | cards | timeline | quote | comparison | equation | image | summary
|
||||
* chart/stats seulement avec chiffres réels dans la note.
|
||||
|
||||
═══ RÈGLES ═══
|
||||
- Nombre de slides : adapté au contenu réel de la note (la règle ⚠️ dans le message utilisateur est ABSOLUE)
|
||||
- Slide 1 OBLIGATOIREMENT type "title"
|
||||
- Dernière slide OBLIGATOIREMENT type "summary"
|
||||
- Au moins 1 slide "chart" ou "stats" si des chiffres existent dans la note
|
||||
- VARIER les types — jamais 2 types identiques consécutifs
|
||||
- Chaque texte (bullet, description) = 15+ mots, phrase complète
|
||||
- TOUTES les données viennent de la note (JAMAIS inventer de chiffres)
|
||||
- Les données chart doivent refléter les vrais chiffres de la note
|
||||
|
||||
═══ THÈMES DISPONIBLES ═══
|
||||
═══ THÈMES ═══
|
||||
Sombres : midnight-cathedral, aurora-borealis, tokyo-neon, venture-pitch, forest-floor, steel-glass, cyberpunk-terminal
|
||||
Clairs : sunlit-gallery, clinical-precision, editorial-ink, coastal-morning, paper-studio, architectural-saas
|
||||
Choisir selon le sujet : business/board → architectural-saas ou midnight-cathedral, tech → cyberpunk-terminal ou clinical-precision, créatif → aurora-borealis ou tokyo-neon`,
|
||||
en: `You are an executive presentation design expert. You analyze the note and generate a structured JSON spec that will be rendered to HTML automatically by the server.
|
||||
Business → architectural-saas / midnight-cathedral ; tech → clinical-precision ; créatif → aurora-borealis`,
|
||||
en: `You are a senior consultant (McKinsey/BCG level) turning a Memento note into a world-class executive deck.
|
||||
|
||||
You MUST call generate_slides with structured JSON. NEVER output raw HTML.
|
||||
|
||||
═══ STEP 1 — READ (MANDATORY) ═══
|
||||
1. Read the note with note_read. Extract ALL usable data (numbers, lists, quotes, comparisons, steps).
|
||||
2. Identify the best slide types matching the actual content.
|
||||
${SLIDE_CONTENT_PRINCIPLES_EN}
|
||||
|
||||
═══ STEP 2 — CALL generate_slides ═══
|
||||
Call generate_slides with a structured JSON object:
|
||||
═══ METHOD ═══
|
||||
1. Read the note (provided content + note_read if needed). Extract: claims, numbers, quotes, steps, trade-offs, decisions.
|
||||
2. Storyboard mentally (respect max N slides from the user message) before calling the tool.
|
||||
3. Call generate_slides:
|
||||
{
|
||||
"title": "Short title (6 words max)",
|
||||
"title": "Deck title (≤8 words)",
|
||||
"theme": "architectural-saas",
|
||||
"slides": [ ... array of slides ... ]
|
||||
"slides": [ ... ]
|
||||
}
|
||||
|
||||
═══ AVAILABLE SLIDE TYPES ═══
|
||||
1. "title" → { type:"title", title:"...", subtitle:"..." }
|
||||
2. "bullets" → { type:"bullets", title:"...", items:["sentence 1 (15+ words)", "sentence 2", ...] }
|
||||
3. "chart" → { type:"chart", title:"...", chartType:"bar|horizontal-bar|line|donut|radar", data:[{label:"Q1",value:65}, ...], subtitle:"..." }
|
||||
4. "stats" → { type:"stats", title:"...", stats:[{value:"98%", label:"Satisfaction"}, ...] }
|
||||
5. "table" → { type:"table", title:"...", headers:["Col A","Col B"], rows:[["val1","val2"], ...] }
|
||||
6. "cards" → { type:"cards", title:"...", cards:[{title:"Title", description:"Detailed description..."}, ...] }
|
||||
7. "timeline" → { type:"timeline", title:"...", events:[{date:"Jan 2024", title:"Launch", description:"..."}, ...] }
|
||||
8. "quote" → { type:"quote", quote:"Exact quote", author:"Author", context:"Analysis..." }
|
||||
9. "comparison" → { type:"comparison", title:"...", left:{title:"A", points:["..."], score:"8/10"}, right:{title:"B", points:["..."], score:"6/10"} }
|
||||
10. "equation" → { type:"equation", title:"...", equations:[{latex:"E=mc^2", label:"Energy"}], explanation:"..." }
|
||||
11. "image" → { type:"image", title:"...", url:"https://...", caption:"..." }
|
||||
12. "summary" → { type:"summary", title:"...", items:["Key point 1", "Key point 2", ...] }
|
||||
═══ TYPES (match content — do not pad) ═══
|
||||
title | bullets | chart* | stats* | table | cards | timeline | quote | comparison | equation | image | summary
|
||||
* chart/stats only with real numbers from the note.
|
||||
|
||||
═══ RULES ═══
|
||||
- Slide count: adapt to the real content (the ⚠️ rule in the user message is ABSOLUTE)
|
||||
- Slide 1 MUST be type "title"
|
||||
- Last slide MUST be type "summary"
|
||||
- At least 1 "chart" or "stats" slide if numbers exist in the note
|
||||
- VARY types — never 2 identical types in a row
|
||||
- Each text (bullet, description) = 15+ words, complete sentence
|
||||
- ALL data comes from the note (NEVER invent numbers)
|
||||
- Chart data must reflect actual numbers from the note
|
||||
|
||||
═══ AVAILABLE THEMES ═══
|
||||
═══ THEMES ═══
|
||||
Dark: midnight-cathedral, aurora-borealis, tokyo-neon, venture-pitch, forest-floor, steel-glass, cyberpunk-terminal
|
||||
Light: sunlit-gallery, clinical-precision, editorial-ink, coastal-morning, paper-studio, architectural-saas
|
||||
Choose based on topic: business/board → architectural-saas or midnight-cathedral, tech → cyberpunk-terminal or clinical-precision, creative → aurora-borealis or tokyo-neon`,
|
||||
Business → architectural-saas / midnight-cathedral; tech → clinical-precision; creative → aurora-borealis`,
|
||||
},
|
||||
'task-extractor': {
|
||||
fr: `Tu es un expert en gestion de tâches et extraction d'action items. Tu analyses des notes et documents pour identifier toutes les tâches, TODOs, et actions à accomplir.
|
||||
@@ -1028,49 +1007,6 @@ You MUST use the task_extract tool. Do NOT respond with text, call the tool dire
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip markdown syntax, URLs, metadata blocks, and frontmatter from note content.
|
||||
* Returns only the semantic plain text, used to count real words before generating slides.
|
||||
*/
|
||||
function stripNoteMarkdown(raw: string): string {
|
||||
return raw
|
||||
// Remove frontmatter / YAML blocks
|
||||
.replace(/^---[\s\S]*?---/m, '')
|
||||
// Remove markdown headers
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
// Remove bold/italic
|
||||
.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1')
|
||||
// Remove inline links — keep label only
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
// Remove bare URLs
|
||||
.replace(/https?:\/\/\S+/g, '')
|
||||
// Remove metadata lines (Connection to seed, Novelty score, Source brainstorm, Origin, Derived from, etc.)
|
||||
.replace(/^\*{0,2}(Connection to seed|Novelty score|Source brainstorm|Source note|Origin|Derived from|## Origin)[^:\n]*:?.*$/gim, '')
|
||||
// Remove horizontal rules
|
||||
.replace(/^---+$/gm, '')
|
||||
// Remove blockquote markers
|
||||
.replace(/^>\s*/gm, '')
|
||||
// Remove code blocks
|
||||
.replace(/```[\s\S]*?```/g, '')
|
||||
.replace(/`[^`]+`/g, '')
|
||||
// Collapse whitespace
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** Count real semantic words in a note */
|
||||
function countNoteWords(raw: string): number {
|
||||
return stripNoteMarkdown(raw).split(/\s+/).filter(Boolean).length
|
||||
}
|
||||
|
||||
/** Return max slides based on real word count */
|
||||
function slideLimit(wordCount: number): { max: number; label: string } {
|
||||
if (wordCount < 50) return { max: 3, label: '3 slides MAX (note très courte)' }
|
||||
if (wordCount < 150) return { max: 4, label: '4 slides MAX (note courte)' }
|
||||
if (wordCount < 350) return { max: 6, label: '6 slides MAX (note moyenne)' }
|
||||
return { max: 8, label: '8 slides MAX (note longue)' }
|
||||
}
|
||||
|
||||
function extractJsonFromText(text: string): any {
|
||||
if (!text) return null
|
||||
|
||||
@@ -1323,52 +1259,54 @@ async function executeToolUseAgent(
|
||||
? LANG_NAMES[noteLanguage]
|
||||
: (lang === 'fr' ? 'French' : 'English')
|
||||
|
||||
prompt = `Create a professional presentation using the content from the notes below. Call generate_slides with a structured JSON object (title, theme, slides[]).`
|
||||
prompt += `\n\n⚠️ LANGUAGE RULE: ALL slide text (titles, bullets, labels, subtitles) MUST be written in ${contentLang}. Do NOT translate to another language.`
|
||||
prompt = lang === 'fr'
|
||||
? `Construis un deck exécutif de haute qualité à partir des notes ci-dessous. Appelle generate_slides avec {title, theme, slides[]}.`
|
||||
: `Build a high-quality executive deck from the notes below. Call generate_slides with {title, theme, slides[]}.`
|
||||
prompt += `\n\n⚠️ LANGUAGE: ALL on-slide text (titles, bullets, labels, subtitles, summary) MUST be in ${contentLang}.`
|
||||
|
||||
if (notes.length > 0) {
|
||||
const notesContext = notes.map(n =>
|
||||
`### ${n.title || untitled} (${n.createdAt.toLocaleDateString(dateLocale)})\n${n.content.substring(0, 2000)}`
|
||||
`### ${n.title || untitled} (${n.createdAt.toLocaleDateString(dateLocale)})\n${prepareNoteTextForSlides(n.content || '', 12_000)}`
|
||||
).join('\n\n')
|
||||
|
||||
// Compute real word count (stripped of markdown/metadata) to determine slide limit
|
||||
const totalWords = notes.reduce((sum, n) => sum + countNoteWords(n.content || ''), 0)
|
||||
const { max: maxSlides, label: slideRule } = slideLimit(totalWords)
|
||||
const limit = slideLimitFromWordCount(totalWords)
|
||||
const slideRule = lang === 'fr' ? limit.labelFr : limit.labelEn
|
||||
|
||||
prompt += `\n\n${lang === 'fr' ? 'Notes source' : 'Source notes'}:\n\n${notesContext}`
|
||||
prompt += `\n\n${lang === 'fr' ? '## Notes source (texte intégral utile)' : '## Source notes (usable full text)'}:\n\n${notesContext}`
|
||||
prompt += `\n\n${lang === 'fr'
|
||||
? `⚠️ RÈGLE ABSOLUE : Cette note fait ${totalWords} mots réels (hors markdown/URLs/métadonnées). Tu DOIS générer EXACTEMENT ${slideRule}. Respecter cette limite est PLUS IMPORTANT que la diversité ou la densité.`
|
||||
: `⚠️ ABSOLUTE RULE: This note has ${totalWords} real words (excluding markdown/URLs/metadata). You MUST generate EXACTLY ${slideRule}. This limit is MORE IMPORTANT than diversity or density.`
|
||||
? `⚠️ LIMITE DECK : ${totalWords} mots source → ${slideRule}. Mieux vaut ${limit.min} slides excellentes que ${limit.max} slides diluées. Ne dépasse JAMAIS ${limit.max}.`
|
||||
: `⚠️ DECK LIMIT: ${totalWords} source words → ${slideRule}. Prefer ${limit.min} excellent slides over ${limit.max} diluted ones. NEVER exceed ${limit.max}.`
|
||||
}`
|
||||
}
|
||||
|
||||
// ── Executive Template Structure (HTML-compatible) ──
|
||||
// ── Executive templates (max 8 slides — matches hard cap) ──
|
||||
if (slideTemplate && slideTemplate !== 'auto') {
|
||||
const templates: Record<string, { fr: string; en: string }> = {
|
||||
'board-update': {
|
||||
fr: `Structure imposée "Board Update" (10 slides) :
|
||||
1. TITRE avec date | 2. KPIs majeurs (3-4 gros chiffres) | 3. Bar chart progression objectifs | 4. Grille de métriques (6 cards) | 5. Timeline jalons | 6. Cards réalisations | 7. Line chart tendance | 8. Deux colonnes risques/mitigations | 9. Bullets prochaines étapes | 10. Conclusion`,
|
||||
en: `Mandatory "Board Update" structure (10 slides):
|
||||
1. TITLE with date | 2. Key KPIs (3-4 big numbers) | 3. Bar chart goal progress | 4. Metrics grid (6 cards) | 5. Timeline milestones | 6. Achievement cards | 7. Line chart trend | 8. Two-column risks/mitigations | 9. Next steps bullets | 10. Conclusion`
|
||||
fr: `Structure "Board Update" (≤8 slides, titres d'action) :
|
||||
1. title | 2. stats KPIs réels seulement | 3. insight + bullets sur performance | 4. chart progression SI chiffres | 5. cards réalisations | 6. comparison risques/mitigations | 7. bullets prochaines décisions | 8. summary`,
|
||||
en: `"Board Update" structure (≤8 slides, action titles):
|
||||
1. title | 2. stats real KPIs only | 3. insight + performance bullets | 4. chart ONLY if real numbers | 5. achievement cards | 6. risks/mitigations comparison | 7. next decisions bullets | 8. summary`,
|
||||
},
|
||||
'project-status': {
|
||||
fr: `Structure imposée "Project Status" (10 slides) :
|
||||
1. TITRE + statut 🟢🟡🔴 | 2. KPIs (% avancement, jours, budget) | 3. Bar chart par workstream | 4. Cards livrables | 5. Timeline roadmap | 6. Line chart burn-down | 7. Deux colonnes blockers/actions | 8. Tableau de risques | 9. Bullets décisions requises | 10. Synthèse`,
|
||||
en: `Mandatory "Project Status" structure (10 slides):
|
||||
1. TITLE + status 🟢🟡🔴 | 2. KPIs (% complete, days, budget) | 3. Bar chart by workstream | 4. Deliverable cards | 5. Timeline roadmap | 6. Line chart burn-down | 7. Two-column blockers/actions | 8. Risk table | 9. Required decisions bullets | 10. Summary`
|
||||
fr: `Structure "Project Status" (≤8) :
|
||||
1. title + statut | 2. stats avancement (réels) | 3. cards livrables | 4. timeline jalons | 5. comparison blockers/actions | 6. table risques SI présent | 7. bullets décisions | 8. summary`,
|
||||
en: `"Project Status" structure (≤8):
|
||||
1. title + status | 2. real progress stats | 3. deliverable cards | 4. milestone timeline | 5. blockers/actions comparison | 6. risk table IF present | 7. decisions bullets | 8. summary`,
|
||||
},
|
||||
'strategy-review': {
|
||||
fr: `Structure imposée "Strategy Review" (10 slides) :
|
||||
1. TITRE stratégique | 2. Contexte marché (5+ bullets) | 3. Radar/pie positionnement | 4. SWOT deux colonnes | 5. Area chart tendances | 6. Cards axes stratégiques | 7. Timeline roadmap 12 mois | 8. Bar chart projections | 9. KPIs objectifs cibles | 10. Call to action`,
|
||||
en: `Mandatory "Strategy Review" structure (10 slides):
|
||||
1. Strategic TITLE | 2. Market context (5+ bullets) | 3. Radar/pie positioning | 4. SWOT two-column | 5. Area chart trends | 6. Strategic axes cards | 7. 12-month roadmap timeline | 8. Bar chart projections | 9. Target KPIs | 10. Call to action`
|
||||
fr: `Structure "Strategy Review" (≤8) :
|
||||
1. title stratégique | 2. bullets contexte (claims) | 3. comparison options ou SWOT | 4. cards axes stratégiques | 5. timeline roadmap | 6. stats/chart UNIQUEMENT si données | 7. implications | 8. summary CTA`,
|
||||
en: `"Strategy Review" structure (≤8):
|
||||
1. strategic title | 2. context claim bullets | 3. options/SWOT comparison | 4. strategic pillars cards | 5. roadmap timeline | 6. stats/chart ONLY if data | 7. implications | 8. summary CTA`,
|
||||
},
|
||||
'quarterly-results': {
|
||||
fr: `Structure imposée "Quarterly Results" (10 slides) :
|
||||
1. TITRE "Résultats Q? Année" | 2. 4 KPIs (Revenue, Croissance, Marge, NPS) | 3. Bar chart revenue/mois | 4. Line chart croissance YoY | 5. Cards faits marquants | 6. Grille métriques opérationnelles | 7. Donut répartition clients | 8. Deux colonnes succès/challenges | 9. Area chart forecast | 10. Conclusion outlook`,
|
||||
en: `Mandatory "Quarterly Results" structure (10 slides):
|
||||
1. TITLE "Q? Year Results" | 2. 4 KPIs (Revenue, Growth, Margin, NPS) | 3. Bar chart revenue/month | 4. Line chart YoY growth | 5. Highlights cards | 6. Operational metrics grid | 7. Donut client breakdown | 8. Two-column successes/challenges | 9. Area chart forecast | 10. Conclusion outlook`
|
||||
}
|
||||
fr: `Structure "Quarterly Results" (≤8) :
|
||||
1. title trimestre | 2. stats 3–4 KPIs réels | 3. chart SI séries chiffrées | 4. cards faits marquants | 5. comparison succès/défis | 6. bullets outlook | 7. summary`,
|
||||
en: `"Quarterly Results" structure (≤8):
|
||||
1. quarter title | 2. 3–4 real KPI stats | 3. chart IF numeric series | 4. highlight cards | 5. wins/challenges comparison | 6. outlook bullets | 7. summary`,
|
||||
},
|
||||
}
|
||||
const tmpl = templates[slideTemplate]
|
||||
if (tmpl) {
|
||||
@@ -1376,19 +1314,24 @@ async function executeToolUseAgent(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Density rules ──
|
||||
prompt += lang === 'fr'
|
||||
? `\n\nDENSITÉ OBLIGATOIRE : Chaque slide doit avoir un CONTENU RICHE. Minimum 5 bullets par slide "bullets" (≥15 mots chaque). Minimum 4 cards quand tu fais "cards". Minimum 4 data points quand tu fais "chart". Inclus OBLIGATOIREMENT au moins 1 slide "chart" avec données RÉELLES extraites des notes. Si pas de chiffres dans la note, fais un radar de maturité ou une comparaison qualitative notée sur 5.`
|
||||
: `\n\nMANDATORY DENSITY: Every slide must have RICH CONTENT. Minimum 5 items per "bullets" slide (≥15 words each). Minimum 4 cards when using "cards". Minimum 4 data points for "chart". You MUST include at least 1 "chart" slide with REAL data from the notes. If no numbers in notes, create a maturity radar or qualitative comparison scored out of 5.`
|
||||
|
||||
prompt += `\n\n${lang === 'fr'
|
||||
? 'IMPORTANT : Appelle OBLIGATOIREMENT generate_slides avec le JSON structuré {title, theme, slides:[...]}. Ne réponds JAMAIS avec du texte brut. Respecte ABSOLUMENT la limite de slides indiquée.'
|
||||
: 'IMPORTANT: You MUST call generate_slides with structured JSON {title, theme, slides:[...]}. NEVER respond with plain text. STRICTLY respect the slide count limit indicated.'}`
|
||||
? `\n\nQUALITÉ > QUANTITÉ :
|
||||
- Titres d'action (insight), une idée par slide
|
||||
- 3–5 bullets courts max ; pas de filler ni de paragraphes
|
||||
- Graphiques UNIQUEMENT avec chiffres de la note — JAMAIS inventer de KPI/radar
|
||||
- Arc : problème → insight → preuve → suite
|
||||
- Appelle generate_slides maintenant (JSON structuré uniquement).`
|
||||
: `\n\nQUALITY > QUANTITY:
|
||||
- Action titles (insights), one idea per slide
|
||||
- 3–5 short bullets max; no filler or paragraphs
|
||||
- Charts ONLY with numbers from the note — NEVER invent KPI/radar scores
|
||||
- Arc: problem → insight → evidence → next steps
|
||||
- Call generate_slides now (structured JSON only).`
|
||||
|
||||
if (agent.slideTheme && agent.slideTheme !== 'auto') {
|
||||
prompt += `\n${lang === 'fr'
|
||||
? `Thème visuel imposé : utilise "theme":"${agent.slideTheme}" dans l'appel generate_slides.`
|
||||
: `Visual theme required: use "theme":"${agent.slideTheme}" in the generate_slides call.`}`
|
||||
? `Thème visuel imposé : "theme":"${agent.slideTheme}" dans generate_slides.`
|
||||
: `Required visual theme: "theme":"${agent.slideTheme}" in the generate_slides call.`}`
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -1510,7 +1453,12 @@ async function executeToolUseAgent(
|
||||
const registered = toolRegistry.get('generate_slides')
|
||||
if (registered) {
|
||||
const slideTool = registered.buildTool(ctx)
|
||||
const executionResult = await slideTool.execute({ title, theme, slides })
|
||||
const normalized = normalizeSlideDeck({ title, theme, slides })
|
||||
const executionResult = await slideTool.execute({
|
||||
title: normalized.title,
|
||||
theme: normalized.theme,
|
||||
slides: normalized.slides as any,
|
||||
})
|
||||
if (executionResult && executionResult.success && executionResult.canvasId) {
|
||||
canvasId = executionResult.canvasId
|
||||
specificToolCalled = true
|
||||
@@ -1747,9 +1695,124 @@ async function sendAgentEmail(agentId: string, userId: string, agentName: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated slide path: structured JSON → normalize → HTML canvas.
|
||||
* Never depends on the model calling generate_slides.
|
||||
*/
|
||||
async function executeSlideGeneratorAgent(
|
||||
agent: {
|
||||
id: string
|
||||
name: string
|
||||
description?: string | null
|
||||
userId: string
|
||||
sourceNoteIds?: string | null
|
||||
sourceNotebookId?: string | null
|
||||
slideTheme?: string | null
|
||||
},
|
||||
actionId: string,
|
||||
lang: Lang,
|
||||
): Promise<AgentExecutionResult> {
|
||||
const startTime = Date.now()
|
||||
|
||||
let noteIds: string[] = []
|
||||
try {
|
||||
noteIds = agent.sourceNoteIds ? JSON.parse(agent.sourceNoteIds) : []
|
||||
} catch {
|
||||
noteIds = []
|
||||
}
|
||||
|
||||
const intent = decodeSlideIntentDescription(agent.description)
|
||||
const template = intent.template || 'auto'
|
||||
|
||||
await prisma.agentAction.update({
|
||||
where: { id: actionId },
|
||||
data: {
|
||||
input: JSON.stringify({
|
||||
pipeline: 'structured-slide-deck',
|
||||
noteIds,
|
||||
sourceNotebookId: agent.sourceNotebookId,
|
||||
theme: agent.slideTheme,
|
||||
template,
|
||||
intent,
|
||||
lang,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const result = await generateSlideDeck({
|
||||
userId: agent.userId,
|
||||
noteIds: noteIds.length > 0 ? noteIds : undefined,
|
||||
sourceNotebookId: agent.sourceNotebookId,
|
||||
theme: agent.slideTheme,
|
||||
template,
|
||||
intent,
|
||||
lang,
|
||||
actionId,
|
||||
})
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
if (!result.success || !result.canvasId) {
|
||||
await prisma.agentAction.update({
|
||||
where: { id: actionId },
|
||||
data: {
|
||||
status: 'failure',
|
||||
log:
|
||||
(result.error ||
|
||||
(lang === 'fr'
|
||||
? 'Échec de la génération de présentation (pipeline structuré).'
|
||||
: 'Slide generation failed (structured pipeline).')) +
|
||||
` (${Math.round(duration / 1000)}s)`,
|
||||
},
|
||||
})
|
||||
return {
|
||||
success: false,
|
||||
actionId,
|
||||
error: result.error || 'Slide generation failed',
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.agentAction.update({
|
||||
where: { id: actionId },
|
||||
data: {
|
||||
status: 'success',
|
||||
result: result.canvasId,
|
||||
log: `Slides OK (${result.mode || 'structured'}): ${result.slideCount} slides — ${result.canvasName} (${Math.round(duration / 1000)}s)`,
|
||||
toolLog: JSON.stringify([
|
||||
{
|
||||
step: 1,
|
||||
text: 'structured-slide-deck pipeline (no tool-calling)',
|
||||
toolCalls: [],
|
||||
toolResults: [
|
||||
{
|
||||
toolName: 'generate_slides_structured',
|
||||
preview: JSON.stringify({
|
||||
canvasId: result.canvasId,
|
||||
slideCount: result.slideCount,
|
||||
mode: result.mode,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
actionId,
|
||||
canvasId: result.canvasId,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main Executor ---
|
||||
|
||||
export async function executeAgent(agentId: string, userId: string, promptOverride?: string): Promise<AgentExecutionResult> {
|
||||
export async function executeAgent(
|
||||
agentId: string,
|
||||
userId: string,
|
||||
promptOverride?: string,
|
||||
options?: ExecuteAgentOptions,
|
||||
): Promise<AgentExecutionResult> {
|
||||
const agent = await prisma.agent.findUnique({
|
||||
where: { id: agentId }
|
||||
})
|
||||
@@ -1768,44 +1831,53 @@ export async function executeAgent(agentId: string, userId: string, promptOverri
|
||||
// Detect user language
|
||||
const lang = await getUserLanguage(userId)
|
||||
const quotaFeature = quotaFeatureForAgentType(agent.type || 'scraper')
|
||||
const agentType = agent.type || 'scraper'
|
||||
|
||||
try {
|
||||
const result = await withAiQuota(
|
||||
userId,
|
||||
quotaFeature,
|
||||
async () => {
|
||||
let inner: AgentExecutionResult
|
||||
// Multi-step slide pipeline: 1 + N crédits (sauf si déjà réservé par run-for-note)
|
||||
let quotaAmount = 1
|
||||
let slideCountForCost: number | null = null
|
||||
if (agentType === 'slide-generator') {
|
||||
const intent = decodeSlideIntentDescription(agent.description)
|
||||
slideCountForCost = intent.slideCount ?? null
|
||||
quotaAmount = slideGenerateCreditCost(intent.slideCount)
|
||||
}
|
||||
|
||||
const runAgent = async (): Promise<AgentExecutionResult> => {
|
||||
// Slide generation uses a dedicated structured-output pipeline (no tool-calling).
|
||||
// Lite models often fail at function calling; generateObject/JSON is reliable.
|
||||
if (agentType === 'slide-generator') {
|
||||
return executeSlideGeneratorAgent(agent, action.id, lang)
|
||||
}
|
||||
|
||||
const hasTools = agent.tools && agent.tools !== '[]' && agent.tools !== 'null'
|
||||
|
||||
if (hasTools) {
|
||||
inner = await executeToolUseAgent(agent, action.id, lang, promptOverride)
|
||||
} else {
|
||||
switch ((agent.type || 'scraper') as AgentType) {
|
||||
case 'scraper':
|
||||
inner = await executeScraperAgent(agent, action.id, lang)
|
||||
break
|
||||
case 'researcher':
|
||||
inner = await executeResearcherAgent(agent, action.id, lang)
|
||||
break
|
||||
case 'monitor':
|
||||
inner = await executeMonitorAgent(agent, action.id, lang)
|
||||
break
|
||||
case 'custom':
|
||||
inner = await executeCustomAgent(agent, action.id, lang)
|
||||
break
|
||||
case 'slide-generator':
|
||||
case 'excalidraw-generator':
|
||||
inner = await executeToolUseAgent(agent, action.id, lang, promptOverride)
|
||||
break
|
||||
default:
|
||||
inner = await executeScraperAgent(agent, action.id, lang)
|
||||
}
|
||||
return executeToolUseAgent(agent, action.id, lang, promptOverride)
|
||||
}
|
||||
return inner
|
||||
},
|
||||
{ lane: 'chat' },
|
||||
)
|
||||
|
||||
switch (agentType as AgentType) {
|
||||
case 'scraper':
|
||||
return executeScraperAgent(agent, action.id, lang)
|
||||
case 'researcher':
|
||||
return executeResearcherAgent(agent, action.id, lang)
|
||||
case 'monitor':
|
||||
return executeMonitorAgent(agent, action.id, lang)
|
||||
case 'custom':
|
||||
return executeCustomAgent(agent, action.id, lang)
|
||||
case 'excalidraw-generator':
|
||||
return executeToolUseAgent(agent, action.id, lang, promptOverride)
|
||||
default:
|
||||
return executeScraperAgent(agent, action.id, lang)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = options?.skipQuota
|
||||
? await runAgent()
|
||||
: await withAiQuota(userId, quotaFeature, runAgent, {
|
||||
lane: 'chat',
|
||||
amount: quotaAmount,
|
||||
slideCount: slideCountForCost,
|
||||
})
|
||||
|
||||
const nextRunUpdate: Record<string, Date | null> = {}
|
||||
if (agent.frequency !== 'manual') {
|
||||
@@ -1861,6 +1933,11 @@ export async function executeAgent(agentId: string, userId: string, promptOverri
|
||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
||||
console.error(`[AgentExecutor] Agent ${agentId} failed:`, message)
|
||||
|
||||
// Pré-réservé par run-for-note : rembourser les crédits
|
||||
if (options?.skipQuota && quotaAmount > 0) {
|
||||
await releaseCredits(userId, quotaAmount, { feature: quotaFeature }).catch(() => {})
|
||||
}
|
||||
|
||||
await prisma.agentAction.update({
|
||||
where: { id: action.id },
|
||||
data: { status: 'failure', log: message }
|
||||
|
||||
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).
|
||||
`
|
||||
692
memento-note/lib/ai/services/slide-deck-generator.service.ts
Normal file
692
memento-note/lib/ai/services/slide-deck-generator.service.ts
Normal file
@@ -0,0 +1,692 @@
|
||||
/**
|
||||
* Slide deck generation — DeckForge / PPTAgent / Hermes-inspired pipeline.
|
||||
*
|
||||
* Sources of truth (research):
|
||||
* - DeckForge: intent → outline → expand PER SLIDE → refine → validate IR
|
||||
* https://github.com/Whatsonyourmind/deckforge
|
||||
* - PPTAgent: outline with retrieved document content, then iterative fill
|
||||
* https://github.com/icip-cas/PPTAgent arXiv:2501.03936
|
||||
* - Hermes PowerPoint skill: domain palettes, no empty slides, QA before ship
|
||||
* https://hermes-agent.nousresearch.com/docs/user-guide/skills/bundled/productivity/productivity-powerpoint
|
||||
* - OpenClaw ppt-html-pipeline: per-page blueprint, no large blank regions
|
||||
* https://github.com/Yuancircle/openclaw-ppt-html-pipeline
|
||||
*
|
||||
* NEVER persists a deck that fails substance validation.
|
||||
*/
|
||||
|
||||
import { generateObject, generateText } from 'ai'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { buildPresentationHTML } from '@/lib/ai/tools/slides-html-builder'
|
||||
import {
|
||||
assertDeckHasSubstance,
|
||||
countNoteWords,
|
||||
injectEquationSlidesFromFormulas,
|
||||
normalizeSlideDeck,
|
||||
prepareNoteTextForSlides,
|
||||
slideLimitFromWordCount,
|
||||
SLIDE_HARD_CAP,
|
||||
} from '@/lib/ai/services/slide-content-quality'
|
||||
import { extractSourceAssets, type SourceAssets } from '@/lib/ai/services/slide-source-assets'
|
||||
import {
|
||||
intentPromptHints,
|
||||
normalizeSlideIntent,
|
||||
type SlideIntent,
|
||||
} from '@/lib/ai/services/slide-intent'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
import { getSlidesProvider } from '@/lib/ai/factory'
|
||||
|
||||
// ── Types catalog (includes equation — critical for STEM notes) ──────────────
|
||||
|
||||
const SlideTypeEnum = z.enum([
|
||||
'title',
|
||||
'bullets',
|
||||
'equation',
|
||||
'cards',
|
||||
'comparison',
|
||||
'timeline',
|
||||
'stats',
|
||||
'chart',
|
||||
'table',
|
||||
'quote',
|
||||
'summary',
|
||||
])
|
||||
|
||||
const OutlineSlideSchema = z.object({
|
||||
position: z.number(),
|
||||
type: SlideTypeEnum,
|
||||
/** Assertive headline, max ~10 words (DeckForge: max 8) */
|
||||
headline: z.string(),
|
||||
/** 2–5 key points that MUST appear on the slide body */
|
||||
keyPoints: z.array(z.string()).min(1).max(6),
|
||||
/** For equation slides: latex strings to include */
|
||||
formulas: z.array(z.string()).optional(),
|
||||
narrativeRole: z.enum(['opening', 'evidence', 'transition', 'conclusion', 'data']).optional(),
|
||||
})
|
||||
|
||||
const OutlineSchema = z.object({
|
||||
title: z.string(),
|
||||
narrativeArc: z.enum(['pyramid', 'scr', 'mece', 'chronological', 'pedagogical']).optional(),
|
||||
slides: z.array(OutlineSlideSchema).min(2).max(SLIDE_HARD_CAP),
|
||||
})
|
||||
|
||||
const ExpandedSlideSchema = z.object({
|
||||
type: SlideTypeEnum,
|
||||
title: z.string(),
|
||||
subtitle: z.string().optional(),
|
||||
items: z.array(z.string()).optional(),
|
||||
equations: z.array(z.object({ latex: z.string(), label: z.string().optional() })).optional(),
|
||||
explanation: z.string().optional(),
|
||||
cards: z.array(z.object({ title: z.string(), description: z.string() })).optional(),
|
||||
left: z
|
||||
.object({ title: z.string(), points: z.array(z.string()), score: z.string().optional() })
|
||||
.optional(),
|
||||
right: z
|
||||
.object({ title: z.string(), points: z.array(z.string()), score: z.string().optional() })
|
||||
.optional(),
|
||||
events: z
|
||||
.array(z.object({ date: z.string(), title: z.string(), description: z.string().optional() }))
|
||||
.optional(),
|
||||
stats: z.array(z.object({ value: z.string(), label: z.string() })).optional(),
|
||||
chartType: z.enum(['bar', 'horizontal-bar', 'line', 'donut', 'radar']).optional(),
|
||||
data: z.array(z.object({ label: z.string(), value: z.number() })).optional(),
|
||||
headers: z.array(z.string()).optional(),
|
||||
rows: z.array(z.array(z.string())).optional(),
|
||||
quote: z.string().optional(),
|
||||
author: z.string().optional(),
|
||||
context: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
export interface GenerateSlideDeckParams {
|
||||
userId: string
|
||||
noteIds?: string[]
|
||||
sourceNotebookId?: string | null
|
||||
theme?: string | null
|
||||
template?: string | null
|
||||
lang?: 'fr' | 'en'
|
||||
contentLanguage?: string | null
|
||||
actionId?: string | null
|
||||
/** Product intent: purpose, audience, slide count */
|
||||
intent?: SlideIntent | null
|
||||
}
|
||||
|
||||
export interface GenerateSlideDeckResult {
|
||||
success: boolean
|
||||
canvasId?: string
|
||||
canvasName?: string
|
||||
slideCount?: number
|
||||
error?: string
|
||||
mode?: string
|
||||
}
|
||||
|
||||
const LANG_NAMES: Record<string, string> = {
|
||||
fr: 'French',
|
||||
en: 'English',
|
||||
es: 'Spanish',
|
||||
de: 'German',
|
||||
it: 'Italian',
|
||||
pt: 'Portuguese',
|
||||
nl: 'Dutch',
|
||||
pl: 'Polish',
|
||||
ru: 'Russian',
|
||||
zh: 'Chinese',
|
||||
ja: 'Japanese',
|
||||
ko: 'Korean',
|
||||
ar: 'Arabic',
|
||||
fa: 'Persian (Farsi)',
|
||||
hi: 'Hindi',
|
||||
}
|
||||
|
||||
// ── LLM helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function extractJsonPayload(text: string): unknown | null {
|
||||
if (!text) return null
|
||||
const raw = text.trim()
|
||||
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i)
|
||||
const candidate = fenced?.[1]?.trim() || raw
|
||||
const tryParse = (s: string) => {
|
||||
try {
|
||||
return JSON.parse(s)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
let parsed = tryParse(candidate)
|
||||
if (parsed) return parsed
|
||||
const brace = candidate.match(/\{[\s\S]*\}/)
|
||||
if (brace) parsed = tryParse(brace[0])
|
||||
return parsed
|
||||
}
|
||||
|
||||
async function llmObject<T>(opts: {
|
||||
model: any
|
||||
schema: z.ZodType<T>
|
||||
system: string
|
||||
prompt: string
|
||||
}): Promise<T> {
|
||||
const { model, schema, system, prompt } = opts
|
||||
try {
|
||||
const { object } = await generateObject({ model, schema, system, prompt })
|
||||
return object as T
|
||||
} catch (err) {
|
||||
console.warn('[SlideDeck] generateObject failed → text JSON:', (err as Error)?.message)
|
||||
const { text } = await generateText({
|
||||
model,
|
||||
system,
|
||||
prompt: `${prompt}\n\nOutput ONE JSON object only.`,
|
||||
})
|
||||
const raw = extractJsonPayload(text)
|
||||
if (!raw) throw new Error(`Non-JSON model output: ${String(text).slice(0, 180)}`)
|
||||
const parsed = schema.safeParse(raw)
|
||||
if (parsed.success) return parsed.data
|
||||
return raw as T
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notes load ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadNotes(params: GenerateSlideDeckParams) {
|
||||
if (params.noteIds?.length) {
|
||||
return prisma.note.findMany({
|
||||
where: {
|
||||
id: { in: params.noteIds },
|
||||
userId: params.userId,
|
||||
isArchived: false,
|
||||
trashedAt: null,
|
||||
},
|
||||
select: { id: true, title: true, content: true, createdAt: true, language: true },
|
||||
})
|
||||
}
|
||||
if (params.sourceNotebookId) {
|
||||
return prisma.note.findMany({
|
||||
where: {
|
||||
notebookId: params.sourceNotebookId,
|
||||
userId: params.userId,
|
||||
isArchived: false,
|
||||
trashedAt: null,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 15,
|
||||
select: { id: true, title: true, content: true, createdAt: true, language: true },
|
||||
})
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// ── Stage prompts (DeckForge-style) ──────────────────────────────────────────
|
||||
|
||||
function outlineSystem(lang: 'fr' | 'en', maxSlides: number, assets: SourceAssets): string {
|
||||
const mathRule = assets.hasMath
|
||||
? lang === 'fr'
|
||||
? `DOMAINE MATH/STEM DÉTECTÉ: tu DOIS inclure au moins ${Math.min(2, Math.max(1, assets.formulas.length))} slides de type "equation" qui portent les formules extraites. Ne remplace PAS les formules par des bullets vagues.`
|
||||
: `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.`
|
||||
: ''
|
||||
|
||||
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).
|
||||
|
||||
Règles (DeckForge + think-cell):
|
||||
- Max ${maxSlides} slides
|
||||
- Headline ASSERTIVE, max 10 mots (pas "Contexte", "Introduction")
|
||||
- keyPoints: 2–5 faits de la note par slide
|
||||
- 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
|
||||
- Slide 1 = title, dernière = summary
|
||||
${mathRule}
|
||||
Réponds en JSON OutlineSchema.`
|
||||
}
|
||||
return `You are DeckForge/PPTAgent narrative architect for Memento.
|
||||
Output ONLY outline JSON (not full slide bodies).
|
||||
|
||||
Rules:
|
||||
- Max ${maxSlides} slides
|
||||
- ASSERTIVE headlines, max 10 words
|
||||
- keyPoints: 2–5 facts from the note per slide
|
||||
- Pedagogical arc for lessons: title → definitions/equations → properties → examples → summary
|
||||
- Types: title | equation | bullets | cards | comparison | timeline | stats | chart | table | quote | summary
|
||||
- First=title, last=summary
|
||||
${mathRule}
|
||||
JSON OutlineSchema only.`
|
||||
}
|
||||
|
||||
function expandOneSystem(lang: 'fr' | 'en'): string {
|
||||
if (lang === 'fr') {
|
||||
return `Tu es le rédacteur de slides DeckForge. Tu EXPANDES UNE slide à la fois.
|
||||
|
||||
INTERDIT: slide avec seulement un titre.
|
||||
OBLIGATOIRE selon type:
|
||||
- title: title + subtitle (1 phrase)
|
||||
- equation: title + equations[{latex,label?}] (min 1, latex fidèle à la note) + explanation courte
|
||||
- bullets: title + items[3-5] claims ≤18 mots
|
||||
- cards: title + cards[2-4] {title, description}
|
||||
- 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
|
||||
- summary: title + items[3-5] actionnables
|
||||
- quote: quote non vide
|
||||
|
||||
JSON ExpandedSlide uniquement.`
|
||||
}
|
||||
return `You are DeckForge slide writer. Expand ONE slide only.
|
||||
|
||||
FORBIDDEN: title-only slides.
|
||||
REQUIRED by type:
|
||||
- title: title + subtitle
|
||||
- equation: title + equations[{latex,label?}] min 1 + short explanation
|
||||
- bullets: title + items[3-5] ≤18 words each
|
||||
- cards: title + cards[2-4]
|
||||
- comparison: left/right points[2-4] each
|
||||
- timeline: events[2-5]
|
||||
- stats/chart: only with real numbers provided
|
||||
- summary: items[3-5]
|
||||
- quote: non-empty quote
|
||||
|
||||
ExpandedSlide JSON only.`
|
||||
}
|
||||
|
||||
/** Force math slides into outline when server extracted formulas (PPTAgent retrieval). */
|
||||
function enforceMathOutline(
|
||||
outline: z.infer<typeof OutlineSchema>,
|
||||
assets: SourceAssets,
|
||||
maxSlides: number,
|
||||
): z.infer<typeof OutlineSchema> {
|
||||
if (!assets.hasMath || assets.formulas.length === 0) return outline
|
||||
const hasEq = outline.slides.some((s) => s.type === 'equation')
|
||||
if (hasEq) {
|
||||
// Ensure equation slides list formulas
|
||||
return {
|
||||
...outline,
|
||||
slides: outline.slides.map((s, i) => {
|
||||
if (s.type !== 'equation') return s
|
||||
const chunk = assets.formulas.slice(i % assets.formulas.length, (i % assets.formulas.length) + 2)
|
||||
return {
|
||||
...s,
|
||||
formulas: s.formulas?.length ? s.formulas : chunk.length ? chunk : assets.formulas.slice(0, 2),
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// Inject equation slides after title
|
||||
const title = outline.slides.find((s) => s.type === 'title') || {
|
||||
position: 1,
|
||||
type: 'title' as const,
|
||||
headline: outline.title,
|
||||
keyPoints: assets.keySentences.slice(0, 2),
|
||||
narrativeRole: 'opening' as const,
|
||||
}
|
||||
const rest = outline.slides.filter((s) => s.type !== 'title' && s.type !== 'summary')
|
||||
const summary = outline.slides.find((s) => s.type === 'summary') || {
|
||||
position: 99,
|
||||
type: 'summary' as const,
|
||||
headline: 'Points clés',
|
||||
keyPoints: assets.keySentences.slice(0, 3),
|
||||
narrativeRole: 'conclusion' as const,
|
||||
}
|
||||
|
||||
const eqSlides: z.infer<typeof OutlineSlideSchema>[] = []
|
||||
const groups = Math.min(2, Math.ceil(assets.formulas.length / 2))
|
||||
for (let g = 0; g < groups; g++) {
|
||||
const formulas = assets.formulas.slice(g * 2, g * 2 + 2)
|
||||
eqSlides.push({
|
||||
position: g + 2,
|
||||
type: 'equation',
|
||||
headline: g === 0 ? 'Équations fondamentales' : 'Formules complémentaires',
|
||||
keyPoints: formulas.map((f) => f.slice(0, 80)),
|
||||
formulas,
|
||||
narrativeRole: 'evidence',
|
||||
})
|
||||
}
|
||||
|
||||
let slides = [title, ...eqSlides, ...rest, summary].slice(0, maxSlides)
|
||||
// Ensure last is summary
|
||||
if (slides[slides.length - 1]?.type !== 'summary') {
|
||||
slides = [...slides.slice(0, maxSlides - 1), summary]
|
||||
}
|
||||
return {
|
||||
...outline,
|
||||
slides: slides.map((s, i) => ({ ...s, position: i + 1 })),
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackExpandFromOutline(
|
||||
o: z.infer<typeof OutlineSlideSchema>,
|
||||
assets: SourceAssets,
|
||||
): z.infer<typeof ExpandedSlideSchema> {
|
||||
const type = o.type
|
||||
const title = o.headline
|
||||
if (type === 'title') {
|
||||
return {
|
||||
type: 'title',
|
||||
title,
|
||||
subtitle: o.keyPoints[0] || assets.keySentences[0] || '',
|
||||
}
|
||||
}
|
||||
if (type === 'equation') {
|
||||
const forms = o.formulas?.length ? o.formulas : assets.formulas.slice(0, 3)
|
||||
return {
|
||||
type: 'equation',
|
||||
title,
|
||||
equations: (forms.length ? forms : ['f(x) = ?']).map((latex, i) => ({
|
||||
latex,
|
||||
label: o.keyPoints[i] || `Formule ${i + 1}`,
|
||||
})),
|
||||
explanation: o.keyPoints.join(' · ').slice(0, 200),
|
||||
}
|
||||
}
|
||||
if (type === 'cards') {
|
||||
const pts = o.keyPoints.length >= 2 ? o.keyPoints : assets.keySentences.slice(0, 3)
|
||||
return {
|
||||
type: 'cards',
|
||||
title,
|
||||
cards: pts.slice(0, 4).map((p, i) => ({
|
||||
title: `Point ${i + 1}`,
|
||||
description: p,
|
||||
})),
|
||||
}
|
||||
}
|
||||
if (type === 'summary') {
|
||||
return {
|
||||
type: 'summary',
|
||||
title,
|
||||
items: (o.keyPoints.length ? o.keyPoints : assets.keySentences).slice(0, 5),
|
||||
}
|
||||
}
|
||||
if (type === 'stats' && assets.numbers.length >= 2) {
|
||||
return {
|
||||
type: 'stats',
|
||||
title,
|
||||
stats: assets.numbers.slice(0, 4).map((n) => ({
|
||||
value: n.raw,
|
||||
label: n.label || title,
|
||||
})),
|
||||
}
|
||||
}
|
||||
if (type === 'chart' && assets.numbers.length >= 2) {
|
||||
return {
|
||||
type: 'chart',
|
||||
title,
|
||||
chartType: 'bar',
|
||||
data: assets.numbers.slice(0, 6).map((n) => ({
|
||||
label: n.label.slice(0, 20) || n.raw,
|
||||
value: n.value,
|
||||
})),
|
||||
}
|
||||
}
|
||||
// default bullets — use keyPoints + sentences to fill density
|
||||
const items = [
|
||||
...o.keyPoints,
|
||||
...assets.keySentences.filter((s) => !o.keyPoints.some((k) => s.includes(k.slice(0, 20)))),
|
||||
].slice(0, 5)
|
||||
while (items.length < 3 && assets.keySentences[items.length]) {
|
||||
items.push(assets.keySentences[items.length]!)
|
||||
}
|
||||
return {
|
||||
type: type === 'quote' ? 'bullets' : type === 'comparison' ? 'bullets' : type === 'timeline' ? 'bullets' : 'bullets',
|
||||
title,
|
||||
items: items.length >= 2 ? items : [title, assets.keySentences[0] || 'Voir la note source'].filter(Boolean),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Persist ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function persist(
|
||||
userId: string,
|
||||
deck: ReturnType<typeof normalizeSlideDeck>,
|
||||
actionId: string | null | undefined,
|
||||
mode: string,
|
||||
) {
|
||||
const html = buildPresentationHTML({
|
||||
title: deck.title,
|
||||
theme: deck.theme,
|
||||
slides: deck.slides as any,
|
||||
})
|
||||
const canvas = await prisma.canvas.create({
|
||||
data: {
|
||||
name: deck.title || 'Présentation',
|
||||
data: JSON.stringify({
|
||||
type: 'slides',
|
||||
title: deck.title,
|
||||
html,
|
||||
slideCount: deck.slides.length,
|
||||
theme: deck.theme,
|
||||
mode,
|
||||
spec: { title: deck.title, theme: deck.theme, slides: deck.slides },
|
||||
}),
|
||||
userId,
|
||||
},
|
||||
})
|
||||
if (actionId) {
|
||||
await prisma.agentAction
|
||||
.update({
|
||||
where: { id: actionId },
|
||||
data: {
|
||||
status: 'success',
|
||||
result: canvas.id,
|
||||
log: `Slides OK (${mode}): ${deck.slides.length} slides, ${Math.round(html.length / 1024)}KB, substance OK`,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
return canvas
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function generateSlideDeck(params: GenerateSlideDeckParams): Promise<GenerateSlideDeckResult> {
|
||||
const lang = params.lang === 'en' ? 'en' : 'fr'
|
||||
const notes = await loadNotes(params)
|
||||
if (!notes.length) {
|
||||
return {
|
||||
success: false,
|
||||
error: lang === 'fr' ? 'Aucune note source.' : 'No source notes.',
|
||||
}
|
||||
}
|
||||
|
||||
const noteLang = notes[0]?.language
|
||||
const contentLang =
|
||||
params.contentLanguage ||
|
||||
(noteLang && LANG_NAMES[noteLang] ? LANG_NAMES[noteLang] : lang === 'fr' ? 'French' : 'English')
|
||||
|
||||
const combined = notes.map((n) => n.content || '').join('\n\n')
|
||||
const assets = extractSourceAssets(combined)
|
||||
const notesText = notes
|
||||
.map((n) => `### ${n.title || 'Note'}\n${prepareNoteTextForSlides(n.content || '', 10_000)}`)
|
||||
.join('\n\n')
|
||||
const wordCount = notes.reduce((s, n) => s + countNoteWords(n.content || ''), 0)
|
||||
const limit = slideLimitFromWordCount(wordCount)
|
||||
const intent = normalizeSlideIntent(params.intent || { template: params.template || 'auto' })
|
||||
// User-chosen count overrides word-count heuristic
|
||||
const targetMax = intent.slideCount || limit.max
|
||||
const targetMin = intent.slideCount || limit.min
|
||||
const theme =
|
||||
params.theme && params.theme !== 'auto'
|
||||
? params.theme
|
||||
: assets.hasMath || intent.purpose === 'course'
|
||||
? 'clinical-precision'
|
||||
: 'architectural-saas'
|
||||
|
||||
// Admin-configured slides model (or chat if slides not set). Never rename model IDs.
|
||||
const sysConfig = await getSystemConfig()
|
||||
const model = getSlidesProvider(sysConfig).getModel()
|
||||
|
||||
const intentHints = intentPromptHints(intent, lang)
|
||||
|
||||
try {
|
||||
// ── Stage 1: Outline (DeckForge outliner) ──
|
||||
const assetsBlock = [
|
||||
assets.formulas.length
|
||||
? `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')}`
|
||||
: '',
|
||||
assets.keySentences.length
|
||||
? `PHRASES CLÉS:\n${assets.keySentences
|
||||
.slice(0, 8)
|
||||
.map((s) => `- ${s}`)
|
||||
.join('\n')}`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
|
||||
let outline = await llmObject({
|
||||
model,
|
||||
schema: OutlineSchema,
|
||||
system: outlineSystem(lang, targetMax, assets),
|
||||
prompt:
|
||||
lang === 'fr'
|
||||
? `Langue du deck: ${contentLang}.\nNombre de slides cible: ${targetMax} (min ${targetMin}).\nThème: ${theme}.\n${intentHints ? `\n## Intent produit\n${intentHints}\n` : ''}\n## Assets serveur\n${assetsBlock || '(aucun)'}\n\n## Note\n${notesText}\n\nProduis l'outline JSON.`
|
||||
: `Deck language: ${contentLang}.\nTarget slides: ${targetMax} (min ${targetMin}).\nTheme: ${theme}.\n${intentHints ? `\n## Product intent\n${intentHints}\n` : ''}\n## Server assets\n${assetsBlock || '(none)'}\n\n## Note\n${notesText}\n\nProduce outline JSON.`,
|
||||
})
|
||||
|
||||
// STEM or course purpose → force equation slides when formulas exist
|
||||
if (assets.hasMath || intent.purpose === 'course') {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stage 3: Normalize + STEM inject + substance gate ──
|
||||
let normalized = normalizeSlideDeck({
|
||||
title: outline.title,
|
||||
theme,
|
||||
slides: expanded as unknown[],
|
||||
})
|
||||
|
||||
// Always inject formulas if missing (deterministic — never ship STEM without equations)
|
||||
if (assets.formulas.length > 0) {
|
||||
normalized = injectEquationSlidesFromFormulas(normalized, assets.formulas, { lang })
|
||||
}
|
||||
|
||||
const stemOpts = {
|
||||
requireFormulas: assets.formulas.length > 0 ? assets.formulas : undefined,
|
||||
}
|
||||
|
||||
let gate = assertDeckHasSubstance(normalized, stemOpts)
|
||||
let mode = 'outline+per-slide-expand'
|
||||
|
||||
if (!gate.ok) {
|
||||
// Deterministic fill for empty body slides using outline + assets
|
||||
const slides = normalized.slides.map((s, i) => {
|
||||
if (s.type === 'title') return s
|
||||
if (!gate.ok && gate.emptyIndexes.includes(i)) {
|
||||
const o = outline.slides[Math.min(i, outline.slides.length - 1)]!
|
||||
return fallbackExpandFromOutline(o, assets) as unknown as Record<string, unknown>
|
||||
}
|
||||
return s
|
||||
})
|
||||
normalized = normalizeSlideDeck({
|
||||
title: outline.title,
|
||||
theme,
|
||||
slides,
|
||||
})
|
||||
if (assets.formulas.length > 0) {
|
||||
normalized = injectEquationSlidesFromFormulas(normalized, assets.formulas, { lang })
|
||||
}
|
||||
gate = assertDeckHasSubstance(normalized, stemOpts)
|
||||
mode = 'outline+expand+deterministic-fill'
|
||||
}
|
||||
|
||||
if (!gate.ok) {
|
||||
const msg =
|
||||
lang === 'fr'
|
||||
? `Refus de livrer un deck incomplet: ${gate.reason}`
|
||||
: `Refusing incomplete deck: ${gate.reason}`
|
||||
if (params.actionId) {
|
||||
await prisma.agentAction
|
||||
.update({ where: { id: params.actionId }, data: { status: 'failure', log: msg } })
|
||||
.catch(() => {})
|
||||
}
|
||||
return { success: false, error: msg, mode }
|
||||
}
|
||||
|
||||
if (!normalized.theme) normalized.theme = theme
|
||||
|
||||
const canvas = await persist(params.userId, normalized, params.actionId, mode)
|
||||
return {
|
||||
success: true,
|
||||
canvasId: canvas.id,
|
||||
canvasName: canvas.name,
|
||||
slideCount: normalized.slides.length,
|
||||
mode,
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('[SlideDeck] FATAL', e)
|
||||
const message = e?.message || String(e)
|
||||
if (params.actionId) {
|
||||
await prisma.agentAction
|
||||
.update({
|
||||
where: { id: params.actionId },
|
||||
data: {
|
||||
status: 'failure',
|
||||
log: lang === 'fr' ? `Échec slides: ${message}` : `Slide fail: ${message}`,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
return { success: false, error: message }
|
||||
}
|
||||
}
|
||||
151
memento-note/lib/ai/services/slide-intent.ts
Normal file
151
memento-note/lib/ai/services/slide-intent.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Slide generation intent (Sprint A product layer).
|
||||
* Passed from UI → API → agent.description → generateSlideDeck.
|
||||
*/
|
||||
|
||||
export type SlidePurpose =
|
||||
| 'auto'
|
||||
| 'course'
|
||||
| 'board'
|
||||
| 'project'
|
||||
| 'strategy'
|
||||
| 'pitch'
|
||||
| 'summary'
|
||||
|
||||
export type SlideAudience = 'auto' | 'student' | 'board' | 'team' | 'client' | 'general'
|
||||
|
||||
export interface SlideIntent {
|
||||
purpose?: SlidePurpose
|
||||
audience?: SlideAudience
|
||||
/** Target body slides including title+summary, clamped 3–8 */
|
||||
slideCount?: number
|
||||
/** Existing executive template id or auto */
|
||||
template?: string
|
||||
}
|
||||
|
||||
const PURPOSE_SET = new Set<string>([
|
||||
'auto',
|
||||
'course',
|
||||
'board',
|
||||
'project',
|
||||
'strategy',
|
||||
'pitch',
|
||||
'summary',
|
||||
])
|
||||
|
||||
const AUDIENCE_SET = new Set<string>([
|
||||
'auto',
|
||||
'student',
|
||||
'board',
|
||||
'team',
|
||||
'client',
|
||||
'general',
|
||||
])
|
||||
|
||||
export function clampSlideCount(n: unknown): number | undefined {
|
||||
const v = typeof n === 'number' ? n : typeof n === 'string' ? parseInt(n, 10) : NaN
|
||||
if (!Number.isFinite(v)) return undefined
|
||||
return Math.min(8, Math.max(3, Math.round(v)))
|
||||
}
|
||||
|
||||
export function normalizeSlideIntent(raw: Partial<SlideIntent> | null | undefined): SlideIntent {
|
||||
if (!raw || typeof raw !== 'object') return {}
|
||||
const purpose = raw.purpose && PURPOSE_SET.has(raw.purpose) ? raw.purpose : 'auto'
|
||||
const audience = raw.audience && AUDIENCE_SET.has(raw.audience) ? raw.audience : 'auto'
|
||||
const slideCount = clampSlideCount(raw.slideCount)
|
||||
const template =
|
||||
typeof raw.template === 'string' && raw.template.trim() ? raw.template.trim() : 'auto'
|
||||
return { purpose, audience, slideCount, template }
|
||||
}
|
||||
|
||||
/** Encode intent into agent.description (backward compatible with template:xxx). */
|
||||
export function encodeSlideIntentDescription(intent: SlideIntent): string {
|
||||
const n = normalizeSlideIntent(intent)
|
||||
// Prefer structured JSON for the structured pipeline
|
||||
return `slide-intent:${JSON.stringify(n)}`
|
||||
}
|
||||
|
||||
/** Decode agent.description → intent */
|
||||
export function decodeSlideIntentDescription(description?: string | null): SlideIntent {
|
||||
if (!description) return {}
|
||||
if (description.startsWith('slide-intent:')) {
|
||||
try {
|
||||
return normalizeSlideIntent(JSON.parse(description.slice('slide-intent:'.length)))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
if (description.startsWith('template:')) {
|
||||
const template = description.replace('template:', '').trim() || 'auto'
|
||||
// Map legacy templates to purpose
|
||||
const purposeMap: Record<string, SlidePurpose> = {
|
||||
'board-update': 'board',
|
||||
'project-status': 'project',
|
||||
'strategy-review': 'strategy',
|
||||
'quarterly-results': 'board',
|
||||
}
|
||||
return normalizeSlideIntent({
|
||||
template,
|
||||
purpose: purposeMap[template] || 'auto',
|
||||
})
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
/** Human guidance injected into outline/expand prompts */
|
||||
export function intentPromptHints(intent: SlideIntent, lang: 'fr' | 'en'): string {
|
||||
const n = normalizeSlideIntent(intent)
|
||||
const lines: string[] = []
|
||||
|
||||
if (n.purpose && n.purpose !== 'auto') {
|
||||
const mapFr: Record<SlidePurpose, string> = {
|
||||
auto: '',
|
||||
course: 'Type: COURS / leçon — arc pédagogique (définitions → formules → exemples → résumé). Priorité aux slides equation si maths.',
|
||||
board: 'Type: COMITÉ / board — KPIs, décisions, risques, next steps.',
|
||||
project: 'Type: SUIVI PROJET — avancement, blockers, livrables, roadmap.',
|
||||
strategy: 'Type: STRATÉGIE — options, trade-offs, recommandations.',
|
||||
pitch: 'Type: PITCH — problème, solution, preuve, call to action.',
|
||||
summary: 'Type: SYNTHÈSE — peu de slides, dense, takeaways actionnables.',
|
||||
}
|
||||
const mapEn: Record<SlidePurpose, string> = {
|
||||
auto: '',
|
||||
course: 'Type: LESSON — pedagogical arc (defs → formulas → examples → summary). Prefer equation slides for math.',
|
||||
board: 'Type: BOARD — KPIs, decisions, risks, next steps.',
|
||||
project: 'Type: PROJECT STATUS — progress, blockers, deliverables, roadmap.',
|
||||
strategy: 'Type: STRATEGY — options, trade-offs, recommendations.',
|
||||
pitch: 'Type: PITCH — problem, solution, proof, CTA.',
|
||||
summary: 'Type: SUMMARY — few dense slides, actionable takeaways.',
|
||||
}
|
||||
lines.push(lang === 'fr' ? mapFr[n.purpose] : mapEn[n.purpose])
|
||||
}
|
||||
|
||||
if (n.audience && n.audience !== 'auto') {
|
||||
const mapFr: Record<SlideAudience, string> = {
|
||||
auto: '',
|
||||
student: 'Audience: étudiants — clarté, définitions, exemples, formules explicites.',
|
||||
board: 'Audience: comité de direction — concis, chiffres, décisions.',
|
||||
team: 'Audience: équipe — opérationnel, actions, responsabilités.',
|
||||
client: 'Audience: client — bénéfices, preuves, sans jargon interne.',
|
||||
general: 'Audience: grand public — langage simple.',
|
||||
}
|
||||
const mapEn: Record<SlideAudience, string> = {
|
||||
auto: '',
|
||||
student: 'Audience: students — clarity, definitions, examples, explicit formulas.',
|
||||
board: 'Audience: board — concise, numbers, decisions.',
|
||||
team: 'Audience: team — operational, actions, ownership.',
|
||||
client: 'Audience: client — benefits, proof, no internal jargon.',
|
||||
general: 'Audience: general — plain language.',
|
||||
}
|
||||
lines.push(lang === 'fr' ? mapFr[n.audience] : mapEn[n.audience])
|
||||
}
|
||||
|
||||
if (n.slideCount) {
|
||||
lines.push(
|
||||
lang === 'fr'
|
||||
? `Nombre de slides imposé: EXACTEMENT ${n.slideCount} (title + corps + summary inclus).`
|
||||
: `Slide count required: EXACTLY ${n.slideCount} (including title + body + summary).`,
|
||||
)
|
||||
}
|
||||
|
||||
return lines.filter(Boolean).join('\n')
|
||||
}
|
||||
103
memento-note/lib/ai/services/slide-source-assets.ts
Normal file
103
memento-note/lib/ai/services/slide-source-assets.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Server-side extraction of usable assets from a note BEFORE any slide LLM call.
|
||||
* Inspired by PPTAgent (retrieve relevant content per slide) and DeckForge (data_needs).
|
||||
* Critical for STEM notes: formulas must be harvested, not hoped for from the model.
|
||||
*/
|
||||
|
||||
export interface SourceAssets {
|
||||
formulas: string[]
|
||||
numbers: Array<{ label: string; value: number; raw: string }>
|
||||
keySentences: string[]
|
||||
hasMath: boolean
|
||||
hasNumbers: boolean
|
||||
wordCount: number
|
||||
}
|
||||
|
||||
/** Extract LaTeX / equation-like fragments from note plain text or HTML. */
|
||||
export function extractFormulas(raw: string): string[] {
|
||||
if (!raw) return []
|
||||
const found: string[] = []
|
||||
const push = (s: string) => {
|
||||
const t = s.replace(/\s+/g, ' ').trim()
|
||||
if (t.length >= 2 && t.length <= 280 && !found.includes(t)) found.push(t)
|
||||
}
|
||||
|
||||
// $$ ... $$
|
||||
for (const m of raw.matchAll(/\$\$([\s\S]+?)\$\$/g)) push(m[1] || '')
|
||||
// \[ ... \]
|
||||
for (const m of raw.matchAll(/\\\[([\s\S]+?)\\\]/g)) push(m[1] || '')
|
||||
// \( ... \)
|
||||
for (const m of raw.matchAll(/\\\(([\s\S]+?)\\\)/g)) push(m[1] || '')
|
||||
// $ ... $ (single line)
|
||||
for (const m of raw.matchAll(/(?<![\\$])\$([^$\n]{2,120})\$(?!\$)/g)) push(m[1] || '')
|
||||
// Common latex commands in free text
|
||||
for (const m of raw.matchAll(
|
||||
/(?:^|[\s(])((?:\\frac\{[^}]+\}\{[^}]+\}|\\partial|\\nabla|\\sum|\\int|\\frac|\\cdot|\\times|\\left|\\right|y'|y''|d[xy]\/d[xy]|∂)[^\n]{0,100})/g,
|
||||
)) {
|
||||
push(m[1] || '')
|
||||
}
|
||||
// Equation-like: f(x) = ..., dy/dx = ...
|
||||
for (const m of raw.matchAll(
|
||||
/(?:^|\n)\s*([A-Za-zΑ-ω∂∇][A-Za-z0-9_'"′]*\s*(?:\([^)]*\))?\s*[=≈≡]\s*[^\n]{3,100})/g,
|
||||
)) {
|
||||
const line = (m[1] || '').trim()
|
||||
if (/[=≈]/.test(line) && /[a-zA-Z0-9]/.test(line)) push(line)
|
||||
}
|
||||
|
||||
return found.slice(0, 24)
|
||||
}
|
||||
|
||||
export function extractNumbers(raw: string): Array<{ label: string; value: number; raw: string }> {
|
||||
if (!raw) return []
|
||||
const out: Array<{ label: string; value: number; raw: string }> = []
|
||||
const re =
|
||||
/(?:^|[^\d])((?:≈|~)?\s*-?\d+(?:[.,]\d+)?)\s*(%|€|\$|k|m|bn|ms|s|°C)?\b/gi
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(raw)) !== null && out.length < 20) {
|
||||
const numStr = (m[1] || '').replace(/[≈~\s]/g, '').replace(',', '.')
|
||||
const value = parseFloat(numStr)
|
||||
if (!Number.isFinite(value)) continue
|
||||
const unit = m[2] || ''
|
||||
// crude label: 40 chars before
|
||||
const start = Math.max(0, m.index - 40)
|
||||
const ctx = raw.slice(start, m.index).replace(/\s+/g, ' ').trim()
|
||||
const label = ctx.split(/[.;:!?\n]/).pop()?.trim().slice(-32) || `n${out.length + 1}`
|
||||
out.push({ label, value: unit === '%' ? value : value, raw: `${numStr}${unit}` })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function extractKeySentences(raw: string, max = 16): string[] {
|
||||
if (!raw) return []
|
||||
const plain = raw
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\$\$[\s\S]+?\$\$/g, ' ')
|
||||
.replace(/\\\[[\s\S]+?\\\]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
const parts = plain
|
||||
.split(/(?<=[.!?])\s+/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length >= 40 && s.length <= 220)
|
||||
const out: string[] = []
|
||||
for (const p of parts) {
|
||||
if (out.length >= max) break
|
||||
if (!out.some((x) => x.slice(0, 40) === p.slice(0, 40))) out.push(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function extractSourceAssets(raw: string): SourceAssets {
|
||||
const formulas = extractFormulas(raw)
|
||||
const numbers = extractNumbers(raw)
|
||||
const keySentences = extractKeySentences(raw)
|
||||
const wordCount = raw.replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length
|
||||
return {
|
||||
formulas,
|
||||
numbers,
|
||||
keySentences,
|
||||
hasMath: formulas.length > 0 || /équat|equat|différen|differen|dériv|deriv|intégr|integr|EDO|ODE|PDE|latex/i.test(raw),
|
||||
hasNumbers: numbers.length >= 2,
|
||||
wordCount,
|
||||
}
|
||||
}
|
||||
@@ -72,17 +72,19 @@ function renderTitle(slide: SlideTitle, r: Recipe, idx: number): string {
|
||||
}
|
||||
|
||||
function renderBullets(slide: SlideBullets, r: Recipe, idx: number): string {
|
||||
const items = slide.items.map((item, i) => `
|
||||
<div class="reveal" style="display:flex;align-items:flex-start;gap:16px;padding:8px 0;">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:${r.accent1};margin-top:10px;flex-shrink:0;"></span>
|
||||
<span style="font-size:1.1rem;line-height:1.65;color:${r.text};">${esc(item)}</span>
|
||||
const list = (slide.items || []).filter(Boolean)
|
||||
// Dense layout: 2-col grid when 4+ bullets (Hermes/OpenClaw: avoid sparse empty slides)
|
||||
const twoCol = list.length >= 4
|
||||
const items = list.map((item) => `
|
||||
<div class="reveal" style="display:flex;align-items:flex-start;gap:14px;padding:12px 14px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:12px;">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:${r.accent1};margin-top:8px;flex-shrink:0;"></span>
|
||||
<span style="font-size:clamp(0.95rem,1.6vw,1.15rem);line-height:1.55;color:${r.text};">${esc(item)}</span>
|
||||
</div>`).join('')
|
||||
return `<div class="slide" data-slide="${idx}">
|
||||
${mesh(r)}
|
||||
<div class="content">
|
||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.6rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
|
||||
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:32px;"></div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px;">${items}</div>
|
||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 20px;">${esc(slide.title)}</h2>
|
||||
<div style="display:grid;grid-template-columns:${twoCol ? '1fr 1fr' : '1fr'};gap:12px;">${items}</div>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
@@ -109,8 +111,11 @@ function renderChart(slide: SlideChart, r: Recipe, idx: number): string {
|
||||
}
|
||||
|
||||
function renderStats(slide: SlideStats, r: Recipe, idx: number): string {
|
||||
const cols = Math.min(slide.stats.length, 4)
|
||||
const items = slide.stats.map(s => `
|
||||
const stats = (slide.stats || []).filter((s) => s && (s.value || s.label))
|
||||
// Never force empty columns: 1 stat = full width, 2 = half, 3–4 = equal
|
||||
const n = Math.max(1, stats.length)
|
||||
const cols = n === 1 ? 1 : n === 2 ? 2 : n === 3 ? 3 : 4
|
||||
const items = stats.map(s => `
|
||||
<div class="reveal" style="text-align:center;padding:28px 16px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:16px;">
|
||||
<div style="font-size:clamp(2.2rem,4vw,3.5rem);font-weight:900;line-height:1;background:linear-gradient(135deg,${r.accent1},${r.accent2});-webkit-background-clip:text;-webkit-text-fill-color:transparent;" data-count="${extractNum(s.value)}" data-suffix="${extractSuffix(s.value)}">${esc(s.value)}</div>
|
||||
<div style="font-size:0.8rem;color:${r.textMuted};margin-top:10px;letter-spacing:0.12em;text-transform:uppercase;font-weight:600;">${esc(s.label)}</div>
|
||||
@@ -118,9 +123,8 @@ function renderStats(slide: SlideStats, r: Recipe, idx: number): string {
|
||||
return `<div class="slide" data-slide="${idx}">
|
||||
${mesh(r)}
|
||||
<div class="content">
|
||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.6rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
|
||||
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:32px;"></div>
|
||||
<div style="display:grid;grid-template-columns:repeat(${cols},1fr);gap:20px;">${items}</div>
|
||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 24px;">${esc(slide.title)}</h2>
|
||||
<div style="display:grid;grid-template-columns:repeat(${cols},minmax(0,1fr));gap:16px;">${items}</div>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
@@ -144,20 +148,22 @@ function renderTable(slide: SlideTable, r: Recipe, idx: number): string {
|
||||
}
|
||||
|
||||
function renderCards(slide: SlideCards, r: Recipe, idx: number): string {
|
||||
const cols = slide.cards.length <= 2 ? 2 : slide.cards.length === 4 ? 2 : 3
|
||||
const items = slide.cards.map((c, i) => `
|
||||
<div class="reveal" style="padding:24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:14px;position:relative;overflow:hidden;">
|
||||
const cards = (slide.cards || []).filter((c) => c && (c.title || c.description))
|
||||
// 1 card → full width (no empty second column). 2 → 2 cols. 3 → 3. 4+ → 2x2
|
||||
const n = Math.max(1, cards.length)
|
||||
const cols = n === 1 ? 1 : n === 2 ? 2 : n === 3 ? 3 : 2
|
||||
const items = cards.map((c, i) => `
|
||||
<div class="reveal" style="padding:24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:14px;position:relative;overflow:hidden;min-height:120px;">
|
||||
<span style="position:absolute;top:10px;right:14px;font-size:1.8rem;font-weight:900;color:${r.accent1};opacity:0.12;">${String(i + 1).padStart(2, '0')}</span>
|
||||
<div style="width:24px;height:3px;background:${r.accent1};border-radius:2px;margin-bottom:12px;"></div>
|
||||
<div style="font-size:1rem;font-weight:700;color:${r.text};margin-bottom:8px;">${esc(c.title)}</div>
|
||||
<div style="font-size:0.88rem;color:${r.textSecondary};line-height:1.6;">${esc(c.description)}</div>
|
||||
<div style="font-size:1.05rem;font-weight:700;color:${r.text};margin-bottom:8px;">${esc(c.title)}</div>
|
||||
<div style="font-size:0.9rem;color:${r.textSecondary};line-height:1.6;">${esc(c.description)}</div>
|
||||
</div>`).join('')
|
||||
return `<div class="slide" data-slide="${idx}">
|
||||
${mesh(r)}
|
||||
<div class="content">
|
||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.6rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
|
||||
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:32px;"></div>
|
||||
<div style="display:grid;grid-template-columns:repeat(${cols},1fr);gap:16px;">${items}</div>
|
||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 24px;">${esc(slide.title)}</h2>
|
||||
<div style="display:grid;grid-template-columns:repeat(${cols},minmax(0,1fr));gap:16px;">${items}</div>
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
@@ -214,18 +220,22 @@ function renderComparison(slide: SlideComparison, r: Recipe, idx: number): strin
|
||||
}
|
||||
|
||||
function renderEquation(slide: SlideEquation, r: Recipe, idx: number): string {
|
||||
const eqs = slide.equations.map(eq => `
|
||||
<div class="reveal" style="text-align:center;padding:24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:12px;margin-bottom:12px;">
|
||||
<div style="font-size:clamp(1.4rem,3vw,2.2rem);font-family:'KaTeX_Main',serif;color:${r.text};letter-spacing:0.02em;">${esc(eq.latex)}</div>
|
||||
${eq.label ? `<div style="font-size:0.8rem;color:${r.textMuted};margin-top:8px;">${esc(eq.label)}</div>` : ''}
|
||||
</div>`).join('')
|
||||
const eqs = (slide.equations || []).map((eq) => {
|
||||
// data-latex for KaTeX client render; also show fallback monospaced text
|
||||
const latex = eq.latex || ''
|
||||
return `
|
||||
<div class="reveal" style="text-align:center;padding:22px 24px;background:${r.glassBg};border:1px solid ${r.glassBorder};border-radius:14px;margin-bottom:14px;">
|
||||
<div class="eq-latex" data-latex="${esc(latex)}" style="font-size:clamp(1.25rem,2.6vw,1.85rem);color:${r.text};overflow-x:auto;padding:8px 0;"></div>
|
||||
<noscript><code style="font-size:1.1rem;">${esc(latex)}</code></noscript>
|
||||
${eq.label ? `<div style="font-size:0.85rem;color:${r.textMuted};margin-top:10px;font-weight:600;">${esc(eq.label)}</div>` : ''}
|
||||
</div>`
|
||||
}).join('')
|
||||
return `<div class="slide" data-slide="${idx}">
|
||||
${mesh(r)}
|
||||
<div class="content">
|
||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.8rem,3.5vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 8px;">${esc(slide.title)}</h2>
|
||||
<div class="reveal" style="width:48px;height:4px;background:${r.accent1};border-radius:2px;margin-bottom:28px;"></div>
|
||||
${eqs}
|
||||
${slide.explanation ? `<p class="reveal" style="font-size:0.95rem;color:${r.textSecondary};line-height:1.7;margin-top:16px;text-align:center;max-width:700px;margin-inline:auto;">${esc(slide.explanation)}</p>` : ''}
|
||||
<h2 class="reveal" style="font-family:${r.fontDisplay},serif;font-size:clamp(1.6rem,3.2vw,2.4rem);font-weight:800;letter-spacing:-0.03em;margin:0 0 20px;">${esc(slide.title)}</h2>
|
||||
${eqs || `<div class="reveal" style="color:${r.textMuted};">Aucune équation</div>`}
|
||||
${slide.explanation ? `<p class="reveal" style="font-size:1rem;color:${r.textSecondary};line-height:1.7;margin-top:18px;max-width:780px;">${esc(slide.explanation)}</p>` : ''}
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
@@ -429,14 +439,19 @@ export function buildPresentationHTML(input: PresentationInput): string {
|
||||
<title>${esc(input.title)}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="${r.fontUrl}" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
|
||||
<style>
|
||||
:root{--color-bg:${r.bg};--color-text:${r.text};--color-text-secondary:${r.textSecondary};--color-text-muted:${r.textMuted};--color-accent-1:${r.accent1};--color-accent-2:${r.accent2};--color-glass-bg:${r.glassBg};--color-glass-border:${r.glassBorder};--svg-grid-line:${r.svgGrid};--font-display:${r.fontDisplay},serif;--font-body:${r.fontBody},system-ui,sans-serif;}
|
||||
*,*::before,*::after{box-sizing:border-box;}
|
||||
html,body{margin:0;padding:0;overflow:hidden;background:var(--color-bg);color:var(--color-text);font-family:var(--font-body);-webkit-font-smoothing:antialiased;}
|
||||
.deck{width:100vw;height:100vh;position:relative;}
|
||||
.slide{position:absolute;top:0;left:0;width:100%;height:100%;background:var(--color-bg);opacity:0;transform:scale(0.96);transition:opacity 0.6s ease,transform 0.6s ease;pointer-events:none;overflow:hidden;display:flex;align-items:center;justify-content:center;}
|
||||
html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:var(--color-bg);color:var(--color-text);font-family:var(--font-body);-webkit-font-smoothing:antialiased;}
|
||||
/* Fill iframe/container — use % not 100vw (vw breaks lab preview letterboxing) */
|
||||
.deck{width:100%;height:100%;position:relative;overflow:hidden;}
|
||||
.slide{position:absolute;top:0;left:0;width:100%;height:100%;background:var(--color-bg);opacity:0;transform:scale(0.98);transition:opacity 0.45s ease,transform 0.45s ease;pointer-events:none;overflow:hidden;display:flex;align-items:center;justify-content:center;}
|
||||
.slide.active{opacity:1;transform:scale(1);pointer-events:all;}
|
||||
.slide>.content{position:relative;z-index:2;width:100%;max-width:1100px;padding:clamp(1.5rem,4vw,4rem);}
|
||||
.slide>.content{position:relative;z-index:2;width:100%;max-width:min(1100px,94%);height:100%;max-height:100%;padding:clamp(1.25rem,3.5vmin,3.5rem);display:flex;flex-direction:column;justify-content:center;box-sizing:border-box;}
|
||||
.eq-latex{max-width:100%;}
|
||||
.eq-latex .katex-display{margin:0.4em 0;overflow-x:auto;overflow-y:hidden;}
|
||||
.gradient-mesh{position:absolute;inset:0;overflow:hidden;pointer-events:none;z-index:0;}
|
||||
.blob{position:absolute;border-radius:50%;filter:blur(80px);animation:float-slow var(--dur,18s) ease-in-out infinite;}
|
||||
@keyframes float-slow{0%{transform:translate(0,0) scale(1);}50%{transform:translate(-40px,40px) scale(0.92);}100%{transform:translate(0,0) scale(1);}}
|
||||
@@ -464,13 +479,14 @@ ${slidesHtml}
|
||||
<script>
|
||||
var current=1,slides=document.querySelectorAll('.slide'),total=slides.length;
|
||||
(function(){var d=document.getElementById('nav-dots');for(var i=1;i<=total;i++){var b=document.createElement('button');b.className='nav-dot'+(i===1?' active':'');(function(n){b.addEventListener('click',function(){goToSlide(n)});})(i);d.appendChild(b);}updateNav();})();
|
||||
function goToSlide(n){if(n<1||n>total||n===current)return;var p=document.querySelector('.slide.active'),nx=document.querySelector('.slide[data-slide="'+n+'"]');if(!nx)return;if(p)p.classList.remove('active');nx.classList.add('active');current=n;updateNav();animateSlide(nx);}
|
||||
function renderKatex(){if(typeof katex==='undefined')return;document.querySelectorAll('.eq-latex[data-latex]').forEach(function(el){var tex=el.getAttribute('data-latex')||'';try{katex.render(tex,el,{throwOnError:false,displayMode:true});}catch(e){el.textContent=tex;}});}
|
||||
function goToSlide(n){if(n<1||n>total||n===current)return;var p=document.querySelector('.slide.active'),nx=document.querySelector('.slide[data-slide="'+n+'"]');if(!nx)return;if(p)p.classList.remove('active');nx.classList.add('active');current=n;updateNav();animateSlide(nx);setTimeout(renderKatex,40);}
|
||||
function changeSlide(dir){var n=current+dir;if(n<1)n=total;if(n>total)n=1;goToSlide(n);}
|
||||
function updateNav(){document.querySelectorAll('.nav-dot').forEach(function(d,i){d.classList.toggle('active',(i+1)===current);});var c=document.getElementById('slide-counter');if(c)c.textContent=current+' / '+total;try{parent.postMessage({type:'slideChange',current:current,total:total},'*');}catch(e){}}
|
||||
document.addEventListener('keydown',function(e){if(e.key==='ArrowRight'||e.key===' ')changeSlide(1);if(e.key==='ArrowLeft')changeSlide(-1);});
|
||||
var tx=0;document.addEventListener('touchstart',function(e){tx=e.touches[0].clientX;},{passive:true});document.addEventListener('touchend',function(e){var dx=tx-e.changedTouches[0].clientX;if(Math.abs(dx)>50)changeSlide(dx>0?1:-1);},{passive:true});
|
||||
function animateSlide(s){s.querySelectorAll('.reveal').forEach(function(el,i){el.style.transition='none';el.style.opacity='0';el.style.transform='translateY(18px)';el.offsetHeight;el.style.transition='opacity 0.35s ease '+(i*0.07)+'s, transform 0.35s ease '+(i*0.07)+'s';el.style.opacity='1';el.style.transform='translateY(0)';});s.querySelectorAll('.bar[data-height]').forEach(function(b){b.style.height='0%';setTimeout(function(){b.style.height=b.dataset.height+'%';},100);});s.querySelectorAll('.bar-fill[data-width]').forEach(function(b){b.style.width='0%';setTimeout(function(){b.style.width=b.dataset.width+'%';},100);});s.querySelectorAll('.line-path').forEach(function(p){var l=p.getTotalLength?p.getTotalLength():2000;p.style.strokeDasharray=l;p.style.strokeDashoffset=l;setTimeout(function(){p.style.strokeDashoffset='0';},100);});s.querySelectorAll('[data-count]').forEach(function(el){var t=parseFloat(el.dataset.count),sf=el.dataset.suffix||'',st=30,inc=t/st,i=0,v=0;var iv=setInterval(function(){v+=inc;i++;el.textContent=(i>=st?t:Math.round(v))+sf;if(i>=st)clearInterval(iv);},30);});}
|
||||
var first=document.querySelector('.slide[data-slide="1"]');if(first){first.classList.add('active');setTimeout(function(){animateSlide(first);},300);}
|
||||
var first=document.querySelector('.slide[data-slide="1"]');if(first){first.classList.add('active');setTimeout(function(){animateSlide(first);renderKatex();},300);}else{setTimeout(renderKatex,400);}
|
||||
// Particles
|
||||
document.querySelectorAll('canvas[id^="particles-"]').forEach(function(c){c.width=window.innerWidth;c.height=window.innerHeight;var ctx=c.getContext('2d'),pts=[];for(var i=0;i<50;i++)pts.push({x:Math.random()*c.width,y:Math.random()*c.height,vx:(Math.random()-0.5)*0.3,vy:(Math.random()-0.5)*0.3,r:Math.random()*2+0.5});function draw(){ctx.clearRect(0,0,c.width,c.height);pts.forEach(function(p){p.x+=p.vx;p.y+=p.vy;if(p.x<0)p.x=c.width;if(p.x>c.width)p.x=0;if(p.y<0)p.y=c.height;if(p.y>c.height)p.y=0;ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,Math.PI*2);ctx.fillStyle='${r.accent1}80';ctx.fill();});requestAnimationFrame(draw);}draw();});
|
||||
</script>
|
||||
|
||||
@@ -5,100 +5,193 @@ import { z } from 'zod'
|
||||
import { toolRegistry } from './registry'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { buildPresentationHTML } from './slides-html-builder'
|
||||
import { normalizeSlideDeck, SLIDE_HARD_CAP } from '@/lib/ai/services/slide-content-quality'
|
||||
|
||||
const notesField = z.string().optional().describe('Optional speaker notes (1–2 sentences for the presenter, not shown on slide)')
|
||||
|
||||
const slideSchema = z.discriminatedUnion('type', [
|
||||
z.object({ type: z.literal('title'), title: z.string(), subtitle: z.string().optional() }),
|
||||
z.object({ type: z.literal('bullets'), title: z.string(), items: z.array(z.string()) }),
|
||||
z.object({ type: z.literal('chart'), title: z.string(), chartType: z.enum(['bar', 'horizontal-bar', 'line', 'donut', 'radar']), data: z.array(z.object({ label: z.string(), value: z.number() })), subtitle: z.string().optional() }),
|
||||
z.object({ type: z.literal('stats'), title: z.string(), stats: z.array(z.object({ value: z.string(), label: z.string() })) }),
|
||||
z.object({ type: z.literal('table'), title: z.string(), headers: z.array(z.string()), rows: z.array(z.array(z.string())) }),
|
||||
z.object({ type: z.literal('cards'), title: z.string(), cards: z.array(z.object({ title: z.string(), description: z.string() })) }),
|
||||
z.object({ type: z.literal('timeline'), title: z.string(), events: z.array(z.object({ date: z.string(), title: z.string(), description: z.string().optional() })) }),
|
||||
z.object({ type: z.literal('quote'), quote: z.string(), author: z.string().optional(), context: z.string().optional() }),
|
||||
z.object({ type: z.literal('comparison'), title: z.string(), left: z.object({ title: z.string(), points: z.array(z.string()), score: z.string().optional() }), right: z.object({ title: z.string(), points: z.array(z.string()), score: z.string().optional() }) }),
|
||||
z.object({ type: z.literal('equation'), title: z.string(), equations: z.array(z.object({ latex: z.string(), label: z.string().optional() })), explanation: z.string().optional() }),
|
||||
z.object({ type: z.literal('image'), title: z.string(), url: z.string().optional(), caption: z.string().optional() }),
|
||||
z.object({ type: z.literal('summary'), title: z.string(), items: z.array(z.string()) }),
|
||||
z.object({
|
||||
type: z.literal('title'),
|
||||
title: z.string().describe('Action title / deck name'),
|
||||
subtitle: z.string().optional().describe('One-line context or audience promise'),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('bullets'),
|
||||
title: z.string().describe('Action title = the insight, not a topic label'),
|
||||
items: z.array(z.string()).max(5).describe('3–5 short claims (8–18 words), each distinct, from the note'),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('chart'),
|
||||
title: z.string().describe('Action title stating what the chart proves'),
|
||||
chartType: z.enum(['bar', 'horizontal-bar', 'line', 'donut', 'radar']),
|
||||
data: z.array(z.object({ label: z.string(), value: z.number() })).min(2).max(8),
|
||||
subtitle: z.string().optional(),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('stats'),
|
||||
title: z.string(),
|
||||
stats: z.array(z.object({ value: z.string(), label: z.string() })).min(2).max(4),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('table'),
|
||||
title: z.string(),
|
||||
headers: z.array(z.string()).max(6),
|
||||
rows: z.array(z.array(z.string())).max(8),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('cards'),
|
||||
title: z.string(),
|
||||
cards: z
|
||||
.array(z.object({ title: z.string(), description: z.string() }))
|
||||
.min(2)
|
||||
.max(6)
|
||||
.describe('2–4 cards preferred; short titles + 1–2 sentence descriptions'),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('timeline'),
|
||||
title: z.string(),
|
||||
events: z
|
||||
.array(z.object({ date: z.string(), title: z.string(), description: z.string().optional() }))
|
||||
.min(2)
|
||||
.max(6),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('quote'),
|
||||
quote: z.string().describe('Exact or faithful quote from the note'),
|
||||
author: z.string().optional(),
|
||||
context: z.string().optional().describe('Why this quote matters'),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('comparison'),
|
||||
title: z.string().describe('Action title of the comparison insight'),
|
||||
left: z.object({
|
||||
title: z.string(),
|
||||
points: z.array(z.string()).max(4),
|
||||
score: z.string().optional(),
|
||||
}),
|
||||
right: z.object({
|
||||
title: z.string(),
|
||||
points: z.array(z.string()).max(4),
|
||||
score: z.string().optional(),
|
||||
}),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('equation'),
|
||||
title: z.string(),
|
||||
equations: z.array(z.object({ latex: z.string(), label: z.string().optional() })).max(4),
|
||||
explanation: z.string().optional(),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('image'),
|
||||
title: z.string(),
|
||||
url: z.string().optional().describe('Only if a real image URL exists in the note'),
|
||||
caption: z.string().optional(),
|
||||
notes: notesField,
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('summary'),
|
||||
title: z.string().describe('e.g. Next steps / Key takeaways'),
|
||||
items: z.array(z.string()).max(5).describe('3–5 actionable takeaways'),
|
||||
notes: notesField,
|
||||
}),
|
||||
])
|
||||
|
||||
toolRegistry.register({
|
||||
name: 'generate_slides',
|
||||
description: 'Renders a structured presentation from JSON data into a full animated HTML file and saves it.',
|
||||
description: `Create an executive presentation from structured slide data (rendered to HTML by the server).
|
||||
|
||||
CONTENT RULES (quality > quantity):
|
||||
- One idea per slide; titles are ACTION TITLES (insights), not topic labels like "Context" or "Analysis"
|
||||
- Narrative: title → problem/context → insight → evidence → implications → summary
|
||||
- Bullets: 3–5 max, short claims (≈8–18 words), no filler paragraphs
|
||||
- chart/stats ONLY with real numbers from the source note — NEVER invent KPIs or radar scores
|
||||
- First slide type "title", last type "summary"
|
||||
- Hard limit: ${SLIDE_HARD_CAP} slides; fewer for short notes
|
||||
- All facts from the note; optional "notes" field for speaker cues
|
||||
|
||||
Slide types: title | bullets | chart | stats | table | cards | timeline | quote | comparison | equation | image | summary`,
|
||||
isInternal: true,
|
||||
buildTool: (ctx) =>
|
||||
tool({
|
||||
description: `Create a presentation from structured slide data. Each slide has a type and corresponding content.
|
||||
description: `Create a high-quality executive deck. Prefer short action titles and sparse, high-signal content over dense filler.
|
||||
|
||||
Available slide types:
|
||||
- "title": title, subtitle (opening slide with particles)
|
||||
- "bullets": title, items[] (bullet list with 4-6 items of 15+ words each)
|
||||
- "chart": title, chartType (bar|horizontal-bar|line|donut|radar), data[{label,value}], subtitle
|
||||
- "stats": title, stats[{value:"98%", label:"KPI name"}] (3-4 animated KPIs)
|
||||
- "table": title, headers[], rows[][] (data table)
|
||||
- "cards": title, cards[{title, description}] (3-6 info cards)
|
||||
- "timeline": title, events[{date, title, description}] (chronological events)
|
||||
- "quote": quote, author, context (citation with analysis)
|
||||
- "comparison": title, left{title, points[], score}, right{...} (A vs B)
|
||||
- "equation": title, equations[{latex, label}], explanation (math formulas)
|
||||
- "image": title, url, caption (image slide)
|
||||
- "summary": title, items[] (conclusion with checkmarks)
|
||||
Types:
|
||||
- title: title, subtitle?
|
||||
- bullets: title (insight), items[3-5] short claims
|
||||
- chart: ONLY real numbers — chartType bar|horizontal-bar|line|donut|radar, data[{label,value}]
|
||||
- stats: 2–4 real KPIs {value,label}
|
||||
- table / cards / timeline / quote / comparison / equation / image / summary
|
||||
|
||||
RULES:
|
||||
- Count the plain-text words in the note (ignore markdown, URLs, metadata, headers)
|
||||
- <50 words → 2-3 slides MAXIMUM (title + 1 content + summary)
|
||||
- 50-150 words → 3-4 slides max
|
||||
- 150-400 words → 5-6 slides max
|
||||
- >400 words → 7-8 slides max — HARD LIMIT, NEVER exceed 8
|
||||
- First slide MUST be type "title"
|
||||
- Last slide MUST be type "summary"
|
||||
- Include "chart" ONLY if real numeric data exists in the note — otherwise FORBIDDEN
|
||||
- Use VARIED types — never 2 identical types in a row
|
||||
- All text must come from the source note — never invent data`,
|
||||
Rules: first=title, last=summary, max ${SLIDE_HARD_CAP} slides, no invented data, no fake charts.`,
|
||||
inputSchema: z.object({
|
||||
title: z.string().describe('Short presentation title (6 words max)'),
|
||||
theme: z.string().optional().describe('Visual recipe: architectural-saas, midnight-cathedral, aurora-borealis, venture-pitch, clinical-precision, coastal-morning, etc.'),
|
||||
slides: z.array(slideSchema).max(8).describe('Array of slide objects, 3-8 slides MAX'),
|
||||
title: z.string().describe('Short deck title (max ~8 words)'),
|
||||
theme: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Visual recipe: architectural-saas, midnight-cathedral, aurora-borealis, venture-pitch, clinical-precision, coastal-morning, etc.',
|
||||
),
|
||||
slides: z.array(slideSchema).max(SLIDE_HARD_CAP).describe(`Slide array, 2–${SLIDE_HARD_CAP} items`),
|
||||
}),
|
||||
|
||||
execute: async ({ title, theme, slides }) => {
|
||||
try {
|
||||
// Hard cap: never more than 8 slides regardless of what the model outputs
|
||||
const cappedSlides = slides.slice(0, 8)
|
||||
const normalized = normalizeSlideDeck({ title, theme, slides: slides as unknown[] })
|
||||
|
||||
const html = buildPresentationHTML({ title, theme, slides: cappedSlides as any })
|
||||
const html = buildPresentationHTML({
|
||||
title: normalized.title,
|
||||
theme: normalized.theme,
|
||||
slides: normalized.slides as any,
|
||||
})
|
||||
|
||||
const canvas = await prisma.canvas.create({
|
||||
data: {
|
||||
name: title || 'Présentation',
|
||||
name: normalized.title || 'Présentation',
|
||||
data: JSON.stringify({
|
||||
type: 'slides',
|
||||
title: title || 'Présentation',
|
||||
title: normalized.title || 'Présentation',
|
||||
html,
|
||||
slideCount: cappedSlides.length,
|
||||
theme: theme || 'architectural-saas',
|
||||
spec: { title, theme, slides: cappedSlides },
|
||||
slideCount: normalized.slides.length,
|
||||
theme: normalized.theme || 'architectural-saas',
|
||||
spec: {
|
||||
title: normalized.title,
|
||||
theme: normalized.theme,
|
||||
slides: normalized.slides,
|
||||
},
|
||||
}),
|
||||
userId: ctx.userId,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
if (ctx.actionId) {
|
||||
await prisma.agentAction.update({
|
||||
where: { id: ctx.actionId },
|
||||
data: {
|
||||
status: 'success',
|
||||
result: canvas.id,
|
||||
log: `Slides generated: ${cappedSlides.length} slides, ${Math.round(html.length / 1024)}KB`,
|
||||
},
|
||||
}).catch(err => console.error('[Slides Tool] Failed to update action status:', err))
|
||||
await prisma.agentAction
|
||||
.update({
|
||||
where: { id: ctx.actionId },
|
||||
data: {
|
||||
status: 'success',
|
||||
result: canvas.id,
|
||||
log: `Slides generated: ${normalized.slides.length} slides, ${Math.round(html.length / 1024)}KB (quality-normalized)`,
|
||||
},
|
||||
})
|
||||
.catch((err) => console.error('[Slides Tool] Failed to update action status:', err))
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
canvasId: canvas.id,
|
||||
canvasName: canvas.name,
|
||||
slideCount: cappedSlides.length,
|
||||
message: `Presentation "${canvas.name}" created with ${cappedSlides.length} slides.`,
|
||||
slideCount: normalized.slides.length,
|
||||
message: `Presentation "${canvas.name}" created with ${normalized.slides.length} slides.`,
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('[Slides Tool] FATAL:', e)
|
||||
|
||||
@@ -17,6 +17,12 @@ export interface ToolUseOptions {
|
||||
systemPrompt?: string
|
||||
messages?: any[]
|
||||
prompt?: string
|
||||
/**
|
||||
* Force tool usage when models (e.g. lite Gemini) prefer free text.
|
||||
* - 'auto' | 'required' | 'none'
|
||||
* - { type: 'tool', toolName: 'generate_slides' }
|
||||
*/
|
||||
toolChoice?: any
|
||||
}
|
||||
|
||||
export interface ToolCallResult {
|
||||
|
||||
@@ -43,41 +43,62 @@ export function normalizeThemeId(raw: string | null | undefined): ThemeId {
|
||||
return 'light'
|
||||
}
|
||||
|
||||
/** Cookie lu par le layout serveur pour éviter le flash clair au changement de route. */
|
||||
export const THEME_COOKIE_NAME = 'memento-theme'
|
||||
|
||||
/**
|
||||
* Applique le thème sur `<html>` : `class="dark"` et/ou `data-theme`.
|
||||
* - `light` → papier par défaut (`:root`), pas de `data-theme`
|
||||
* - `dark` → sombre global (`.dark`)
|
||||
* - `auto` → `.dark` si prefers-color-scheme: dark
|
||||
* - palettes nommées → `data-theme="<id>"` ; `midnight` force aussi `.dark` (variante sombre du thème)
|
||||
*
|
||||
* Important : on n’enlève JAMAIS `dark` avant de le remettre (sinon flash clair
|
||||
* d’une frame à chaque navigation / re-render de ThemeInitializer).
|
||||
*/
|
||||
export function applyDocumentTheme(theme: string): void {
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
const t = normalizeThemeId(theme)
|
||||
const root = document.documentElement
|
||||
root.classList.remove('dark')
|
||||
root.removeAttribute('data-theme')
|
||||
|
||||
if (t === 'auto') {
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
root.classList.add('dark')
|
||||
}
|
||||
return
|
||||
}
|
||||
const wantsDark =
|
||||
t === 'dark' ||
|
||||
t === 'midnight' ||
|
||||
(t === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
|
||||
if (t === 'dark') {
|
||||
// Classe dark : bascule atomique (pas de remove puis add)
|
||||
if (wantsDark) {
|
||||
root.classList.add('dark')
|
||||
return
|
||||
} else {
|
||||
root.classList.remove('dark')
|
||||
}
|
||||
|
||||
if (t === 'light') {
|
||||
return
|
||||
}
|
||||
|
||||
if ((NAMED_PALETTE_IDS as readonly string[]).includes(t)) {
|
||||
// data-theme pour les palettes nommées uniquement
|
||||
if (t === 'auto' || t === 'dark' || t === 'light') {
|
||||
root.removeAttribute('data-theme')
|
||||
} else if ((NAMED_PALETTE_IDS as readonly string[]).includes(t)) {
|
||||
root.setAttribute('data-theme', t)
|
||||
if (t === 'midnight') {
|
||||
root.classList.add('dark')
|
||||
}
|
||||
} else {
|
||||
root.removeAttribute('data-theme')
|
||||
}
|
||||
|
||||
root.dataset.appliedTheme = t
|
||||
}
|
||||
|
||||
/** Persiste le thème (localStorage + cookie) pour que le serveur connaisse le dark. */
|
||||
export function persistThemePreference(theme: string): void {
|
||||
const t = normalizeThemeId(theme)
|
||||
try {
|
||||
localStorage.setItem('theme-preference', t)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
// 1 an — lu par le layout racine via cookies()
|
||||
document.cookie = `${THEME_COOKIE_NAME}=${encodeURIComponent(t)}; path=/; max-age=31536000; SameSite=Lax`
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
applyDocumentTheme(t)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export type AuditAction =
|
||||
| 'SUBSCRIPTION_OVERRIDE'
|
||||
| 'BILLING_CONFIG_UPDATED'
|
||||
| 'PLAN_ENTITLEMENT_UPDATED'
|
||||
| 'QUOTA_RESET'
|
||||
|
||||
export interface AuditLogParams {
|
||||
userId?: string | null
|
||||
|
||||
158
memento-note/lib/billing/credit-packs.ts
Normal file
158
memento-note/lib/billing/credit-packs.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Packs de crédits IA (achat one-shot Stripe, mode payment).
|
||||
* Les crédits achetés s’ajoutent à `purchasedBalance` (reportables entre mois).
|
||||
*/
|
||||
|
||||
import { getConfigValue } from '@/lib/config'
|
||||
import { stripe } from '@/lib/stripe'
|
||||
|
||||
export type CreditPackId = 'S' | 'M' | 'L'
|
||||
|
||||
export type CreditPackDef = {
|
||||
id: CreditPackId
|
||||
credits: number
|
||||
/** Prix d’affichage par défaut (si Stripe non configuré) */
|
||||
defaultDisplay: string
|
||||
defaultAmount: number
|
||||
currency: string
|
||||
priceConfigKey: string
|
||||
}
|
||||
|
||||
/** Catalogue fixe — crédits par pack (option M). */
|
||||
export const CREDIT_PACKS: Record<CreditPackId, CreditPackDef> = {
|
||||
S: {
|
||||
id: 'S',
|
||||
credits: 100,
|
||||
defaultDisplay: '4,90 €',
|
||||
defaultAmount: 4.9,
|
||||
currency: 'EUR',
|
||||
priceConfigKey: 'STRIPE_PRICE_CREDITS_S',
|
||||
},
|
||||
M: {
|
||||
id: 'M',
|
||||
credits: 500,
|
||||
defaultDisplay: '19,90 €',
|
||||
defaultAmount: 19.9,
|
||||
currency: 'EUR',
|
||||
priceConfigKey: 'STRIPE_PRICE_CREDITS_M',
|
||||
},
|
||||
L: {
|
||||
id: 'L',
|
||||
credits: 2000,
|
||||
defaultDisplay: '49,90 €',
|
||||
defaultAmount: 49.9,
|
||||
currency: 'EUR',
|
||||
priceConfigKey: 'STRIPE_PRICE_CREDITS_L',
|
||||
},
|
||||
}
|
||||
|
||||
export const CREDIT_PACK_IDS = Object.keys(CREDIT_PACKS) as CreditPackId[]
|
||||
|
||||
export function isCreditPackId(value: string): value is CreditPackId {
|
||||
return value === 'S' || value === 'M' || value === 'L'
|
||||
}
|
||||
|
||||
export function getCreditPack(id: CreditPackId): CreditPackDef {
|
||||
return CREDIT_PACKS[id]
|
||||
}
|
||||
|
||||
export async function resolvePackPriceId(packId: CreditPackId): Promise<string> {
|
||||
const pack = CREDIT_PACKS[packId]
|
||||
const fromDb = await getConfigValue(pack.priceConfigKey, '')
|
||||
const priceId = fromDb || process.env[pack.priceConfigKey] || ''
|
||||
if (priceId) return priceId
|
||||
|
||||
const isMock =
|
||||
!process.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder'
|
||||
if (isMock && process.env.NODE_ENV !== 'test') {
|
||||
return `price_mock_credits_${packId.toLowerCase()}`
|
||||
}
|
||||
throw new Error(`No Stripe price ID configured for credit pack ${packId}`)
|
||||
}
|
||||
|
||||
export type PackPublicPrice = {
|
||||
id: CreditPackId
|
||||
credits: number
|
||||
display: string
|
||||
amount: number
|
||||
currency: string
|
||||
configured: boolean
|
||||
}
|
||||
|
||||
function formatMoney(amount: number, currency: string): string {
|
||||
const cur = currency.toUpperCase()
|
||||
if (cur === 'EUR') {
|
||||
return `${amount.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })} €`
|
||||
}
|
||||
if (cur === 'USD') {
|
||||
return `$${amount.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
if (cur === 'GBP') {
|
||||
return `£${amount.toLocaleString('en-GB', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
return `${amount} ${cur}`
|
||||
}
|
||||
|
||||
export async function getPackPublicPrices(): Promise<PackPublicPrice[]> {
|
||||
const isMock =
|
||||
!process.env.STRIPE_SECRET_KEY || process.env.STRIPE_SECRET_KEY === 'sk_test_placeholder'
|
||||
|
||||
return Promise.all(
|
||||
CREDIT_PACK_IDS.map(async (id) => {
|
||||
const pack = CREDIT_PACKS[id]
|
||||
let display = pack.defaultDisplay
|
||||
let amount = pack.defaultAmount
|
||||
let currency = pack.currency
|
||||
let configured = false
|
||||
|
||||
try {
|
||||
const priceId = await resolvePackPriceId(id)
|
||||
if (priceId && !priceId.startsWith('price_mock_')) {
|
||||
configured = true
|
||||
if (!isMock) {
|
||||
const price = await stripe.prices.retrieve(priceId)
|
||||
if (price.unit_amount != null) {
|
||||
amount = price.unit_amount / 100
|
||||
currency = price.currency.toUpperCase()
|
||||
display = formatMoney(amount, currency)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* pack non configuré — on garde le prix catalogue */
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
credits: pack.credits,
|
||||
display,
|
||||
amount,
|
||||
currency,
|
||||
configured: configured || isMock,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/** Retrouve packId + crédits depuis un price ID Stripe (webhook). */
|
||||
export async function resolvePackFromPriceId(
|
||||
priceId: string,
|
||||
): Promise<{ packId: CreditPackId; credits: number } | null> {
|
||||
if (!priceId) return null
|
||||
if (priceId.startsWith('price_mock_credits_')) {
|
||||
const suffix = priceId.replace('price_mock_credits_', '').toUpperCase()
|
||||
if (isCreditPackId(suffix)) {
|
||||
return { packId: suffix, credits: CREDIT_PACKS[suffix].credits }
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of CREDIT_PACK_IDS) {
|
||||
const pack = CREDIT_PACKS[id]
|
||||
const fromDb = await getConfigValue(pack.priceConfigKey, '')
|
||||
const configured = fromDb || process.env[pack.priceConfigKey] || ''
|
||||
if (configured && configured === priceId) {
|
||||
return { packId: id, credits: pack.credits }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -11,6 +11,9 @@ const ENV_FALLBACKS: Record<string, string> = {
|
||||
AI_MODEL_CHAT: process.env.AI_MODEL_CHAT || '',
|
||||
AI_PROVIDER_CHAT_FALLBACK: process.env.AI_PROVIDER_CHAT_FALLBACK || '',
|
||||
AI_MODEL_CHAT_FALLBACK: process.env.AI_MODEL_CHAT_FALLBACK || '',
|
||||
// Slides generation — optional dedicated model (never auto-renamed)
|
||||
AI_PROVIDER_SLIDES: process.env.AI_PROVIDER_SLIDES || '',
|
||||
AI_MODEL_SLIDES: process.env.AI_MODEL_SLIDES || '',
|
||||
AI_PROVIDER_TAGS_FALLBACK: process.env.AI_PROVIDER_TAGS_FALLBACK || '',
|
||||
AI_MODEL_TAGS_FALLBACK: process.env.AI_MODEL_TAGS_FALLBACK || '',
|
||||
AI_PROVIDER_EMBEDDING_FALLBACK: process.env.AI_PROVIDER_EMBEDDING_FALLBACK || '',
|
||||
|
||||
753
memento-note/lib/credits.ts
Normal file
753
memento-note/lib/credits.ts
Normal file
@@ -0,0 +1,753 @@
|
||||
/**
|
||||
* Crédits IA globaux (modèle A).
|
||||
* Un seul solde ; chaque action a un coût ; le tableau d'usage agrège le ledger par feature.
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { redis } from '@/lib/redis'
|
||||
import { getCurrentPeriodKey, VALID_FEATURES, type FeatureName } from '@/lib/quota-utils'
|
||||
import type { SubscriptionTier } from '@/lib/plan-entitlements'
|
||||
|
||||
// Imports dynamiques vers entitlements pour éviter les cycles
|
||||
// (entitlements délègue aussi vers credits pour reserveUsageOrThrow).
|
||||
|
||||
async function getEffectiveTier(userId: string): Promise<SubscriptionTier> {
|
||||
const { getEffectiveTier: fn } = await import('@/lib/entitlements')
|
||||
return fn(userId)
|
||||
}
|
||||
|
||||
async function makeQuotaExceeded(
|
||||
feature: string,
|
||||
limit: number,
|
||||
used: number,
|
||||
userId: string,
|
||||
) {
|
||||
const { QuotaExceededError } = await import('@/lib/entitlements')
|
||||
const tier = await getEffectiveTier(userId)
|
||||
return new QuotaExceededError('PRO', feature, limit, used, false, { currentTier: tier })
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocations mensuelles — option M (alignée marché IA 2025–2026).
|
||||
* Sources marché : enveloppes type 100 / ~1 000 / ~4 000 (agrégateurs multi-modèles,
|
||||
* ordres de grandeur Copilot ~1 000 crédits Pro, grilles Starter/Pro/Ultra).
|
||||
* BASIC 100 · PRO 1 000 · BUSINESS 4 000 · ENTERPRISE illimité.
|
||||
*/
|
||||
export const CREDIT_ALLOCATIONS: Record<SubscriptionTier, number | 'unlimited'> = {
|
||||
BASIC: 100,
|
||||
PRO: 1000,
|
||||
BUSINESS: 4000,
|
||||
ENTERPRISE: 'unlimited',
|
||||
}
|
||||
|
||||
/** Coût unitaire fixe par feature (slides : voir slideGenerateCreditCost). */
|
||||
export const CREDIT_COSTS: Record<string, number> = {
|
||||
semantic_search: 1,
|
||||
auto_tag: 1,
|
||||
auto_title: 1,
|
||||
reformulate: 1,
|
||||
chat: 1,
|
||||
brainstorm_create: 5,
|
||||
brainstorm_expand: 1,
|
||||
brainstorm_enrich: 1,
|
||||
suggest_charts: 1,
|
||||
publish_enhance: 3,
|
||||
ai_flashcard: 3,
|
||||
voice_transcribe: 1,
|
||||
excalidraw_generate: 4,
|
||||
slide_generate: 7, // défaut si pas de slideCount (1+6)
|
||||
}
|
||||
|
||||
export function slideGenerateCreditCost(slideCount?: number | null): number {
|
||||
const n =
|
||||
typeof slideCount === 'number' && Number.isFinite(slideCount)
|
||||
? Math.min(8, Math.max(3, Math.round(slideCount)))
|
||||
: 6
|
||||
return 1 + n
|
||||
}
|
||||
|
||||
export function notebookSlideCreditCost(opts: {
|
||||
slideCount?: number | null
|
||||
noteCount?: number | null
|
||||
}): number {
|
||||
const n =
|
||||
typeof opts.slideCount === 'number' && Number.isFinite(opts.slideCount)
|
||||
? Math.min(12, Math.max(5, Math.round(opts.slideCount)))
|
||||
: 8
|
||||
const notes =
|
||||
typeof opts.noteCount === 'number' && Number.isFinite(opts.noteCount)
|
||||
? Math.max(0, Math.round(opts.noteCount))
|
||||
: 0
|
||||
return 2 + n + Math.floor(notes / 5)
|
||||
}
|
||||
|
||||
export function resolveCreditCost(
|
||||
feature: string,
|
||||
options?: { amount?: number; slideCount?: number | null },
|
||||
): number {
|
||||
if (feature === 'slide_generate') {
|
||||
if (options?.slideCount != null) return slideGenerateCreditCost(options.slideCount)
|
||||
if (options?.amount != null && options.amount > 1) return Math.max(1, Math.floor(options.amount))
|
||||
return slideGenerateCreditCost(null)
|
||||
}
|
||||
if (options?.amount != null && options.amount > 1) {
|
||||
return Math.max(1, Math.floor(options.amount))
|
||||
}
|
||||
return CREDIT_COSTS[feature] ?? 1
|
||||
}
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type CreditBalance = {
|
||||
period: string
|
||||
unlimited: boolean
|
||||
totalRemaining: number
|
||||
subscriptionRemaining: number
|
||||
purchasedRemaining: number
|
||||
subscriptionGranted: number
|
||||
subscriptionSpent: number
|
||||
spentThisPeriod: number
|
||||
}
|
||||
|
||||
export type CreditBreakdownRow = {
|
||||
feature: string
|
||||
creditsUsed: number
|
||||
actionsCount: number
|
||||
percentOfSpent: number
|
||||
}
|
||||
|
||||
export type CreditUsageSummary = {
|
||||
tier: SubscriptionTier
|
||||
period: string
|
||||
balance: CreditBalance
|
||||
breakdown: CreditBreakdownRow[]
|
||||
}
|
||||
|
||||
const TTL_SECONDS = 90 * 24 * 60 * 60
|
||||
|
||||
function redisKeys(userId: string, period: string) {
|
||||
return {
|
||||
subSpent: `credits:${userId}:${period}:sub_spent`,
|
||||
purchased: `credits:${userId}:${period}:purchased`,
|
||||
granted: `credits:${userId}:${period}:granted`,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ensure account / monthly grant ──────────────────────────────────────────
|
||||
|
||||
export async function ensureCreditAccount(userId: string): Promise<{
|
||||
periodKey: string
|
||||
subscriptionGranted: number
|
||||
subscriptionSpent: number
|
||||
purchasedBalance: number
|
||||
unlimited: boolean
|
||||
}> {
|
||||
const periodKey = getCurrentPeriodKey()
|
||||
const tier = await getEffectiveTier(userId)
|
||||
const allocation = CREDIT_ALLOCATIONS[tier]
|
||||
const unlimited = allocation === 'unlimited'
|
||||
const grant = unlimited ? 0 : (allocation as number)
|
||||
|
||||
let account = await prisma.aiCreditAccount.findUnique({ where: { userId } })
|
||||
|
||||
if (!account) {
|
||||
account = await prisma.aiCreditAccount.create({
|
||||
data: {
|
||||
userId,
|
||||
periodKey,
|
||||
subscriptionGranted: grant,
|
||||
subscriptionSpent: 0,
|
||||
purchasedBalance: 0,
|
||||
},
|
||||
})
|
||||
if (!unlimited && grant > 0) {
|
||||
await appendLedger(userId, {
|
||||
kind: 'GRANT_SUBSCRIPTION',
|
||||
amount: grant,
|
||||
feature: null,
|
||||
balanceAfter: grant,
|
||||
metadata: { periodKey, tier },
|
||||
})
|
||||
}
|
||||
await syncRedisFromAccount(userId, account)
|
||||
return {
|
||||
periodKey,
|
||||
subscriptionGranted: account.subscriptionGranted,
|
||||
subscriptionSpent: account.subscriptionSpent,
|
||||
purchasedBalance: account.purchasedBalance,
|
||||
unlimited,
|
||||
}
|
||||
}
|
||||
|
||||
// Nouvelle période : expire reliquat abonnement, conserve packs, re-grant
|
||||
if (account.periodKey !== periodKey) {
|
||||
const purchased = account.purchasedBalance
|
||||
account = await prisma.aiCreditAccount.update({
|
||||
where: { userId },
|
||||
data: {
|
||||
periodKey,
|
||||
subscriptionGranted: grant,
|
||||
subscriptionSpent: 0,
|
||||
purchasedBalance: purchased,
|
||||
},
|
||||
})
|
||||
if (!unlimited && grant > 0) {
|
||||
await appendLedger(userId, {
|
||||
kind: 'GRANT_SUBSCRIPTION',
|
||||
amount: grant,
|
||||
feature: null,
|
||||
balanceAfter: grant + purchased,
|
||||
metadata: { periodKey, tier, rolledPurchased: purchased },
|
||||
})
|
||||
}
|
||||
await syncRedisFromAccount(userId, account)
|
||||
} else if (!unlimited && account.subscriptionGranted !== grant) {
|
||||
// Palier changé en cours de mois : met à jour l'allocation (ne retire pas le déjà consommé)
|
||||
account = await prisma.aiCreditAccount.update({
|
||||
where: { userId },
|
||||
data: { subscriptionGranted: grant },
|
||||
})
|
||||
await syncRedisFromAccount(userId, account)
|
||||
}
|
||||
|
||||
return {
|
||||
periodKey: account.periodKey,
|
||||
subscriptionGranted: account.subscriptionGranted,
|
||||
subscriptionSpent: account.subscriptionSpent,
|
||||
purchasedBalance: account.purchasedBalance,
|
||||
unlimited,
|
||||
}
|
||||
}
|
||||
|
||||
async function syncRedisFromAccount(
|
||||
userId: string,
|
||||
account: {
|
||||
periodKey: string
|
||||
subscriptionGranted: number
|
||||
subscriptionSpent: number
|
||||
purchasedBalance: number
|
||||
},
|
||||
) {
|
||||
const k = redisKeys(userId, account.periodKey)
|
||||
try {
|
||||
await redis
|
||||
.multi()
|
||||
.set(k.granted, String(account.subscriptionGranted), 'EX', TTL_SECONDS)
|
||||
.set(k.subSpent, String(account.subscriptionSpent), 'EX', TTL_SECONDS)
|
||||
.set(k.purchased, String(account.purchasedBalance), 'EX', TTL_SECONDS)
|
||||
.exec()
|
||||
} catch (err) {
|
||||
console.error('[credits] Redis sync failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function appendLedger(
|
||||
userId: string,
|
||||
entry: {
|
||||
kind: string
|
||||
amount: number
|
||||
feature: string | null
|
||||
balanceAfter: number | null
|
||||
metadata?: Record<string, unknown>
|
||||
},
|
||||
) {
|
||||
try {
|
||||
await prisma.aiCreditLedger.create({
|
||||
data: {
|
||||
userId,
|
||||
kind: entry.kind,
|
||||
amount: entry.amount,
|
||||
feature: entry.feature,
|
||||
balanceAfter: entry.balanceAfter,
|
||||
metadata: entry.metadata ? JSON.stringify(entry.metadata) : null,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[credits] ledger write failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Balance ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getCreditBalance(userId: string): Promise<CreditBalance> {
|
||||
const acc = await ensureCreditAccount(userId)
|
||||
const period = acc.periodKey
|
||||
|
||||
if (acc.unlimited) {
|
||||
return {
|
||||
period,
|
||||
unlimited: true,
|
||||
totalRemaining: Infinity,
|
||||
subscriptionRemaining: Infinity,
|
||||
purchasedRemaining: acc.purchasedBalance,
|
||||
subscriptionGranted: Infinity,
|
||||
subscriptionSpent: acc.subscriptionSpent,
|
||||
spentThisPeriod: acc.subscriptionSpent,
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer Redis if present
|
||||
const k = redisKeys(userId, period)
|
||||
try {
|
||||
const [subSpentRaw, purchasedRaw, grantedRaw] = await redis.mget(
|
||||
k.subSpent,
|
||||
k.purchased,
|
||||
k.granted,
|
||||
)
|
||||
if (subSpentRaw != null || purchasedRaw != null) {
|
||||
const subscriptionSpent = Math.max(0, parseInt(subSpentRaw || '0', 10) || 0)
|
||||
const purchasedRemaining = Math.max(0, parseInt(purchasedRaw || '0', 10) || 0)
|
||||
const subscriptionGranted = Math.max(
|
||||
0,
|
||||
parseInt(grantedRaw || String(acc.subscriptionGranted), 10) || acc.subscriptionGranted,
|
||||
)
|
||||
const subscriptionRemaining = Math.max(0, subscriptionGranted - subscriptionSpent)
|
||||
return {
|
||||
period,
|
||||
unlimited: false,
|
||||
totalRemaining: subscriptionRemaining + purchasedRemaining,
|
||||
subscriptionRemaining,
|
||||
purchasedRemaining,
|
||||
subscriptionGranted,
|
||||
subscriptionSpent,
|
||||
spentThisPeriod: subscriptionSpent, // packs spend tracked separately via ledger
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* fall through to DB */
|
||||
}
|
||||
|
||||
const subscriptionRemaining = Math.max(0, acc.subscriptionGranted - acc.subscriptionSpent)
|
||||
return {
|
||||
period,
|
||||
unlimited: false,
|
||||
totalRemaining: subscriptionRemaining + acc.purchasedBalance,
|
||||
subscriptionRemaining,
|
||||
purchasedRemaining: acc.purchasedBalance,
|
||||
subscriptionGranted: acc.subscriptionGranted,
|
||||
subscriptionSpent: acc.subscriptionSpent,
|
||||
spentThisPeriod: acc.subscriptionSpent,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reserve / release ───────────────────────────────────────────────────────
|
||||
|
||||
const RESERVE_CREDITS_LUA = `
|
||||
local cost = tonumber(ARGV[1]) or 1
|
||||
local granted = tonumber(redis.call('GET', KEYS[1]) or '0')
|
||||
local subSpent = tonumber(redis.call('GET', KEYS[2]) or '0')
|
||||
local purchased = tonumber(redis.call('GET', KEYS[3]) or '0')
|
||||
local ttl = tonumber(ARGV[2]) or 7776000
|
||||
if cost < 1 then cost = 1 end
|
||||
local subRem = math.max(0, granted - subSpent)
|
||||
local total = subRem + purchased
|
||||
if total < cost then
|
||||
return {-1, subSpent, purchased}
|
||||
end
|
||||
local fromSub = math.min(subRem, cost)
|
||||
local fromPurch = cost - fromSub
|
||||
subSpent = subSpent + fromSub
|
||||
purchased = purchased - fromPurch
|
||||
redis.call('SET', KEYS[2], tostring(subSpent))
|
||||
redis.call('EXPIRE', KEYS[2], ttl)
|
||||
redis.call('SET', KEYS[3], tostring(purchased))
|
||||
redis.call('EXPIRE', KEYS[3], ttl)
|
||||
if redis.call('TTL', KEYS[1]) < 0 then
|
||||
redis.call('EXPIRE', KEYS[1], ttl)
|
||||
end
|
||||
return {cost, subSpent, purchased, fromSub, fromPurch}
|
||||
`
|
||||
|
||||
/**
|
||||
* Réserve `cost` crédits. Lance QuotaExceededError si insuffisant.
|
||||
* Enterprise unlimited : no-op.
|
||||
*/
|
||||
export async function reserveCreditsOrThrow(
|
||||
userId: string,
|
||||
cost: number,
|
||||
options?: { feature?: string; metadata?: Record<string, unknown> },
|
||||
): Promise<{ cost: number; unlimited: boolean }> {
|
||||
const units = Math.max(1, Math.floor(Number(cost) || 1))
|
||||
const acc = await ensureCreditAccount(userId)
|
||||
|
||||
if (acc.unlimited) {
|
||||
// Journal informatif (montant 0) pour le tableau si feature fournie
|
||||
if (options?.feature) {
|
||||
await appendLedger(userId, {
|
||||
kind: 'CONSUME',
|
||||
amount: 0,
|
||||
feature: options.feature,
|
||||
balanceAfter: null,
|
||||
metadata: { ...(options.metadata || {}), unlimited: true, notionalCost: units },
|
||||
})
|
||||
}
|
||||
return { cost: 0, unlimited: true }
|
||||
}
|
||||
|
||||
const period = acc.periodKey
|
||||
const k = redisKeys(userId, period)
|
||||
|
||||
// Ensure granted key is set
|
||||
try {
|
||||
const g = await redis.get(k.granted)
|
||||
if (g == null) {
|
||||
await syncRedisFromAccount(userId, {
|
||||
periodKey: period,
|
||||
subscriptionGranted: acc.subscriptionGranted,
|
||||
subscriptionSpent: acc.subscriptionSpent,
|
||||
purchasedBalance: acc.purchasedBalance,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
/* continue */
|
||||
}
|
||||
|
||||
let fromSub = units
|
||||
let fromPurch = 0
|
||||
let newSubSpent = acc.subscriptionSpent + units
|
||||
let newPurchased = acc.purchasedBalance
|
||||
|
||||
try {
|
||||
const raw = (await redis.eval(
|
||||
RESERVE_CREDITS_LUA,
|
||||
3,
|
||||
k.granted,
|
||||
k.subSpent,
|
||||
k.purchased,
|
||||
String(units),
|
||||
String(TTL_SECONDS),
|
||||
)) as number[]
|
||||
|
||||
if (!raw || raw[0] === -1) {
|
||||
const bal = await getCreditBalance(userId)
|
||||
throw await makeQuotaExceeded(
|
||||
options?.feature || 'credits',
|
||||
bal.subscriptionGranted + bal.purchasedRemaining,
|
||||
bal.spentThisPeriod,
|
||||
userId,
|
||||
)
|
||||
}
|
||||
|
||||
newSubSpent = Number(raw[1]) || 0
|
||||
newPurchased = Number(raw[2]) || 0
|
||||
fromSub = Number(raw[3]) ?? units
|
||||
fromPurch = Number(raw[4]) ?? 0
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) throw err
|
||||
|
||||
// DB fallback
|
||||
const subRem = Math.max(0, acc.subscriptionGranted - acc.subscriptionSpent)
|
||||
const total = subRem + acc.purchasedBalance
|
||||
if (total < units) {
|
||||
throw await makeQuotaExceeded(
|
||||
options?.feature || 'credits',
|
||||
acc.subscriptionGranted + acc.purchasedBalance,
|
||||
acc.subscriptionSpent,
|
||||
userId,
|
||||
)
|
||||
}
|
||||
fromSub = Math.min(subRem, units)
|
||||
fromPurch = units - fromSub
|
||||
newSubSpent = acc.subscriptionSpent + fromSub
|
||||
newPurchased = acc.purchasedBalance - fromPurch
|
||||
}
|
||||
|
||||
await prisma.aiCreditAccount.update({
|
||||
where: { userId },
|
||||
data: {
|
||||
subscriptionSpent: newSubSpent,
|
||||
purchasedBalance: newPurchased,
|
||||
},
|
||||
})
|
||||
|
||||
const remaining =
|
||||
Math.max(0, acc.subscriptionGranted - newSubSpent) + Math.max(0, newPurchased)
|
||||
|
||||
await appendLedger(userId, {
|
||||
kind: 'CONSUME',
|
||||
amount: -units,
|
||||
feature: options?.feature ?? null,
|
||||
balanceAfter: remaining,
|
||||
metadata: {
|
||||
...(options?.metadata || {}),
|
||||
fromSubscription: fromSub,
|
||||
fromPurchased: fromPurch,
|
||||
},
|
||||
})
|
||||
|
||||
return { cost: units, unlimited: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remet des crédits après échec (best-effort).
|
||||
* Simplification : recrédite d'abord l'abonnement (réduit subscriptionSpent).
|
||||
*/
|
||||
export async function releaseCredits(
|
||||
userId: string,
|
||||
cost: number,
|
||||
options?: { feature?: string },
|
||||
): Promise<void> {
|
||||
const units = Math.max(1, Math.floor(Number(cost) || 1))
|
||||
const acc = await ensureCreditAccount(userId)
|
||||
if (acc.unlimited) return
|
||||
|
||||
const period = acc.periodKey
|
||||
const k = redisKeys(userId, period)
|
||||
|
||||
let newSubSpent = Math.max(0, acc.subscriptionSpent - units)
|
||||
// Si on a plus dépensé en abonnement que available, le reste va en packs
|
||||
let refundPurch = 0
|
||||
const subRefund = Math.min(units, acc.subscriptionSpent)
|
||||
refundPurch = units - subRefund
|
||||
newSubSpent = acc.subscriptionSpent - subRefund
|
||||
const newPurchased = acc.purchasedBalance + refundPurch
|
||||
|
||||
try {
|
||||
await redis
|
||||
.multi()
|
||||
.set(k.subSpent, String(newSubSpent), 'EX', TTL_SECONDS)
|
||||
.set(k.purchased, String(newPurchased), 'EX', TTL_SECONDS)
|
||||
.exec()
|
||||
} catch (err) {
|
||||
console.error('[credits] release Redis failed:', err)
|
||||
}
|
||||
|
||||
await prisma.aiCreditAccount.update({
|
||||
where: { userId },
|
||||
data: {
|
||||
subscriptionSpent: newSubSpent,
|
||||
purchasedBalance: newPurchased,
|
||||
},
|
||||
})
|
||||
|
||||
const remaining =
|
||||
Math.max(0, acc.subscriptionGranted - newSubSpent) + Math.max(0, newPurchased)
|
||||
|
||||
await appendLedger(userId, {
|
||||
kind: 'RELEASE',
|
||||
amount: units,
|
||||
feature: options?.feature ?? null,
|
||||
balanceAfter: remaining,
|
||||
metadata: { refundSubscription: subRefund, refundPurchased: refundPurch },
|
||||
})
|
||||
}
|
||||
|
||||
export async function addPurchasedCredits(
|
||||
userId: string,
|
||||
amount: number,
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<{ applied: boolean; units: number }> {
|
||||
const units = Math.max(1, Math.floor(amount))
|
||||
|
||||
// Idempotence webhook : un même checkout Stripe ne crédite qu'une fois
|
||||
const stripeSessionId =
|
||||
typeof metadata?.stripeSessionId === 'string' ? metadata.stripeSessionId : null
|
||||
if (stripeSessionId) {
|
||||
const existing = await prisma.aiCreditLedger.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
kind: 'PURCHASE_PACK',
|
||||
metadata: { contains: stripeSessionId },
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
if (existing) {
|
||||
return { applied: false, units }
|
||||
}
|
||||
}
|
||||
|
||||
const acc = await ensureCreditAccount(userId)
|
||||
const newPurchased = acc.purchasedBalance + units
|
||||
|
||||
await prisma.aiCreditAccount.update({
|
||||
where: { userId },
|
||||
data: { purchasedBalance: newPurchased },
|
||||
})
|
||||
|
||||
const period = acc.periodKey
|
||||
const k = redisKeys(userId, period)
|
||||
try {
|
||||
await redis.set(k.purchased, String(newPurchased), 'EX', TTL_SECONDS)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
const bal = await getCreditBalance(userId)
|
||||
await appendLedger(userId, {
|
||||
kind: 'PURCHASE_PACK',
|
||||
amount: units,
|
||||
feature: null,
|
||||
balanceAfter: bal.unlimited ? null : bal.totalRemaining,
|
||||
metadata,
|
||||
})
|
||||
|
||||
return { applied: true, units }
|
||||
}
|
||||
|
||||
/** Reset admin : remet conso à 0 et re-grant allocation, packs optionnels conservés. */
|
||||
export async function adminResetCredits(
|
||||
userId: string,
|
||||
options?: { clearPurchased?: boolean },
|
||||
): Promise<CreditBalance> {
|
||||
const periodKey = getCurrentPeriodKey()
|
||||
const tier = await getEffectiveTier(userId)
|
||||
const allocation = CREDIT_ALLOCATIONS[tier]
|
||||
const unlimited = allocation === 'unlimited'
|
||||
const grant = unlimited ? 0 : (allocation as number)
|
||||
|
||||
const existing = await prisma.aiCreditAccount.findUnique({ where: { userId } })
|
||||
const purchased = options?.clearPurchased ? 0 : (existing?.purchasedBalance ?? 0)
|
||||
|
||||
const account = await prisma.aiCreditAccount.upsert({
|
||||
where: { userId },
|
||||
create: {
|
||||
userId,
|
||||
periodKey,
|
||||
subscriptionGranted: grant,
|
||||
subscriptionSpent: 0,
|
||||
purchasedBalance: purchased,
|
||||
},
|
||||
update: {
|
||||
periodKey,
|
||||
subscriptionGranted: grant,
|
||||
subscriptionSpent: 0,
|
||||
purchasedBalance: purchased,
|
||||
},
|
||||
})
|
||||
|
||||
await syncRedisFromAccount(userId, account)
|
||||
|
||||
await appendLedger(userId, {
|
||||
kind: 'ADMIN_RESET',
|
||||
amount: grant,
|
||||
feature: null,
|
||||
balanceAfter: grant + purchased,
|
||||
metadata: { clearPurchased: !!options?.clearPurchased, tier },
|
||||
})
|
||||
|
||||
return getCreditBalance(userId)
|
||||
}
|
||||
|
||||
// ── Breakdown for usage table ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Date de début pour le détail d'usage : après le dernier ADMIN_RESET du mois,
|
||||
* sinon début de période. Évite « 300 restants + 16 utilisés » après un reset admin.
|
||||
*/
|
||||
async function usageWindowStart(userId: string): Promise<Date> {
|
||||
const period = getCurrentPeriodKey()
|
||||
const periodStart = new Date(`${period}-01T00:00:00.000Z`)
|
||||
const lastReset = await prisma.aiCreditLedger.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
kind: 'ADMIN_RESET',
|
||||
createdAt: { gte: periodStart },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { createdAt: true },
|
||||
})
|
||||
return lastReset?.createdAt ?? periodStart
|
||||
}
|
||||
|
||||
export async function getCreditBreakdown(userId: string): Promise<CreditBreakdownRow[]> {
|
||||
const since = await usageWindowStart(userId)
|
||||
|
||||
const rows = await prisma.aiCreditLedger.groupBy({
|
||||
by: ['feature'],
|
||||
where: {
|
||||
userId,
|
||||
kind: 'CONSUME',
|
||||
createdAt: { gte: since },
|
||||
feature: { not: null },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
})
|
||||
|
||||
// Toutes les fonctionnalités connues (comme l'ancien tableau) — pas seulement slides
|
||||
const usedMap = new Map<string, { creditsUsed: number; actionsCount: number }>()
|
||||
for (const feature of VALID_FEATURES) {
|
||||
usedMap.set(feature, { creditsUsed: 0, actionsCount: 0 })
|
||||
}
|
||||
for (const r of rows) {
|
||||
if (!r.feature) continue
|
||||
const creditsUsed = Math.abs(r._sum.amount ?? 0)
|
||||
usedMap.set(r.feature, {
|
||||
creditsUsed,
|
||||
actionsCount: r._count._all,
|
||||
})
|
||||
}
|
||||
|
||||
const mapped: CreditBreakdownRow[] = []
|
||||
let totalSpent = 0
|
||||
for (const [feature, v] of usedMap) {
|
||||
totalSpent += v.creditsUsed
|
||||
mapped.push({
|
||||
feature,
|
||||
creditsUsed: v.creditsUsed,
|
||||
actionsCount: v.actionsCount,
|
||||
percentOfSpent: 0,
|
||||
})
|
||||
}
|
||||
|
||||
for (const r of mapped) {
|
||||
r.percentOfSpent =
|
||||
totalSpent > 0 ? Math.round((r.creditsUsed / totalSpent) * 1000) / 10 : 0
|
||||
}
|
||||
|
||||
// Tri : d'abord celles avec usage, puis ordre alphabétique du feature key
|
||||
mapped.sort((a, b) => {
|
||||
if (b.creditsUsed !== a.creditsUsed) return b.creditsUsed - a.creditsUsed
|
||||
if (b.actionsCount !== a.actionsCount) return b.actionsCount - a.actionsCount
|
||||
return a.feature.localeCompare(b.feature)
|
||||
})
|
||||
return mapped
|
||||
}
|
||||
|
||||
export async function getCreditUsageSummary(userId: string): Promise<CreditUsageSummary> {
|
||||
const tier = await getEffectiveTier(userId)
|
||||
const balance = await getCreditBalance(userId)
|
||||
const breakdown = await getCreditBreakdown(userId)
|
||||
|
||||
// Conso affichée = somme réelle des CONSUME dans la fenêtre (après dernier reset)
|
||||
const spentFromBreakdown = breakdown.reduce((s, r) => s + r.creditsUsed, 0)
|
||||
balance.spentThisPeriod = spentFromBreakdown
|
||||
|
||||
return {
|
||||
tier,
|
||||
period: balance.period,
|
||||
balance,
|
||||
breakdown,
|
||||
}
|
||||
}
|
||||
|
||||
/** Compat : fabrique un objet quotas-like pour l'ancien UI le temps de la migration. */
|
||||
export async function getQuotasCompatFromCredits(
|
||||
userId: string,
|
||||
): Promise<Record<string, { remaining: number; limit: number; used: number }>> {
|
||||
const summary = await getCreditUsageSummary(userId)
|
||||
const result: Record<string, { remaining: number; limit: number; used: number }> = {}
|
||||
|
||||
// Lignes du breakdown
|
||||
const usedByFeature = new Map(summary.breakdown.map((b) => [b.feature, b.creditsUsed]))
|
||||
|
||||
for (const feature of VALID_FEATURES) {
|
||||
const used = usedByFeature.get(feature) ?? 0
|
||||
if (summary.balance.unlimited) {
|
||||
result[feature] = { remaining: Infinity, limit: Infinity, used }
|
||||
} else {
|
||||
// Pas de plafond par feature : on expose used + remaining global pour info
|
||||
result[feature] = {
|
||||
used,
|
||||
limit: summary.balance.subscriptionGranted + summary.balance.purchasedRemaining + used,
|
||||
remaining: summary.balance.totalRemaining,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function isCreditFeature(feature: string): feature is FeatureName {
|
||||
return (VALID_FEATURES as readonly string[]).includes(feature as FeatureName)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
getRedisKey,
|
||||
parseRedisInt,
|
||||
isValidFeature,
|
||||
VALID_FEATURES,
|
||||
} from './quota-utils';
|
||||
import {
|
||||
getLimitAsync,
|
||||
@@ -138,15 +139,17 @@ async function reserveUsageInDatabase(
|
||||
userId: string,
|
||||
feature: string,
|
||||
limit: number,
|
||||
amount: number = 1,
|
||||
): Promise<number> {
|
||||
const { periodStart, periodEnd } = getPeriodDates();
|
||||
const units = Math.max(1, Math.floor(amount));
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.usageLog.findUnique({
|
||||
where: { userId_feature_periodStart: { userId, feature, periodStart } },
|
||||
});
|
||||
const current = existing?.requestsCount ?? 0;
|
||||
if (current >= limit) return -1;
|
||||
if (current + units > limit) return -1;
|
||||
|
||||
const updated = await tx.usageLog.upsert({
|
||||
where: { userId_feature_periodStart: { userId, feature, periodStart } },
|
||||
@@ -155,12 +158,12 @@ async function reserveUsageInDatabase(
|
||||
feature,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
requestsCount: 1,
|
||||
requestsCount: units,
|
||||
tokensUsed: 0,
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
requestsCount: { increment: 1 },
|
||||
requestsCount: { increment: units },
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
});
|
||||
@@ -191,53 +194,37 @@ return newCount
|
||||
`;
|
||||
|
||||
const RELEASE_LUA = `
|
||||
local amount = tonumber(ARGV[1]) or 1
|
||||
if amount < 1 then amount = 1 end
|
||||
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
|
||||
if current <= 0 then return 0 end
|
||||
redis.call('DECR', KEYS[1])
|
||||
local dec = math.min(amount, current)
|
||||
redis.call('DECRBY', KEYS[1], dec)
|
||||
return tonumber(redis.call('GET', KEYS[1]) or '0')
|
||||
`;
|
||||
|
||||
export async function releaseUsage(userId: string, feature: string): Promise<void> {
|
||||
/** Libère des crédits précédemment réservés (modèle A). */
|
||||
export async function releaseUsage(
|
||||
userId: string,
|
||||
feature: string,
|
||||
amount: number = 1,
|
||||
): Promise<void> {
|
||||
if (!isValidFeature(feature)) return;
|
||||
|
||||
const tier = await getEffectiveTier(userId);
|
||||
const limit = await getLimitAsync(tier, feature);
|
||||
if (limit === undefined || limit === Infinity) return;
|
||||
|
||||
try {
|
||||
const key = getRedisKey(userId, feature);
|
||||
const raw = await redis.eval(RELEASE_LUA, 1, key);
|
||||
const newCount = parseReserveResult(raw);
|
||||
if (Number.isFinite(newCount) && newCount >= 0) {
|
||||
await mirrorUsageCountToDatabase(userId, feature, newCount);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[entitlements] Redis release failed, trying DB fallback:', err);
|
||||
try {
|
||||
const { periodStart } = getPeriodDates();
|
||||
const existing = await prisma.usageLog.findUnique({
|
||||
where: { userId_feature_periodStart: { userId, feature, periodStart } },
|
||||
});
|
||||
const current = existing?.requestsCount ?? 0;
|
||||
if (current <= 0) return;
|
||||
await prisma.usageLog.update({
|
||||
where: { userId_feature_periodStart: { userId, feature, periodStart } },
|
||||
data: { requestsCount: current - 1, syncedAt: new Date() },
|
||||
});
|
||||
} catch (dbErr) {
|
||||
console.error('[entitlements] DB release fallback failed:', dbErr);
|
||||
}
|
||||
}
|
||||
const units = Math.max(1, Math.floor(Number(amount) || 1));
|
||||
const { releaseCredits } = await import('@/lib/credits');
|
||||
await releaseCredits(userId, units, { feature });
|
||||
}
|
||||
|
||||
const RESERVE_LUA = `
|
||||
local limit = tonumber(ARGV[1])
|
||||
local ttl = tonumber(ARGV[2])
|
||||
local amount = tonumber(ARGV[3]) or 1
|
||||
if amount < 1 then amount = 1 end
|
||||
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
|
||||
if current >= limit then
|
||||
if current + amount > limit then
|
||||
return -1
|
||||
end
|
||||
redis.call('INCRBY', KEYS[1], 1)
|
||||
redis.call('INCRBY', KEYS[1], amount)
|
||||
local ttlResult = redis.call('TTL', KEYS[1])
|
||||
if ttlResult == -1 then
|
||||
redis.call('EXPIRE', KEYS[1], ttl)
|
||||
@@ -370,86 +357,47 @@ export async function checkEntitlementOrThrow(
|
||||
}
|
||||
}
|
||||
|
||||
/** Mappe une feature de quota vers la voie IA (pour le BYOK). */
|
||||
function featureToAiLane(feature: string): 'chat' | 'tags' | 'embedding' {
|
||||
if (feature === 'auto_tag' || feature === 'auto_title') return 'tags';
|
||||
if (feature === 'semantic_search') return 'embedding';
|
||||
return 'chat';
|
||||
}
|
||||
|
||||
/**
|
||||
* Réserve des crédits globaux (modèle A).
|
||||
* `amount` = coût en crédits (ex. slides multi-étapes : 1+N).
|
||||
* Si l'utilisateur a une clé perso (BYOK) pour la voie concernée, aucun débit.
|
||||
* Conserve le nom historique pour ne pas casser tous les appelants.
|
||||
*/
|
||||
export async function reserveUsageOrThrow(
|
||||
userId: string,
|
||||
feature: string,
|
||||
amount: number = 1,
|
||||
): Promise<void> {
|
||||
if (!isValidFeature(feature)) {
|
||||
throw new QuotaExceededError('PRO', feature, 0, 0, false, { currentTier: 'BASIC' });
|
||||
}
|
||||
|
||||
const tier = await getEffectiveTier(userId);
|
||||
const limit = await getLimitAsync(tier, feature);
|
||||
const { getSystemConfig } = await import('@/lib/config');
|
||||
const { willUseByokForLane } = await import('@/lib/ai/provider-for-user');
|
||||
const config = await getSystemConfig();
|
||||
const lane = featureToAiLane(feature);
|
||||
const { usedByok } = await willUseByokForLane(lane, config, userId);
|
||||
if (usedByok) return;
|
||||
|
||||
if (limit === undefined) {
|
||||
throw new QuotaExceededError(
|
||||
tier === 'BASIC' ? 'PRO' : 'BUSINESS',
|
||||
feature,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
{ currentTier: tier },
|
||||
);
|
||||
}
|
||||
const { reserveCreditsOrThrow, resolveCreditCost } = await import('@/lib/credits');
|
||||
const cost = resolveCreditCost(feature, { amount });
|
||||
await reserveCreditsOrThrow(userId, cost, { feature });
|
||||
}
|
||||
|
||||
if (limit === Infinity) return;
|
||||
|
||||
try {
|
||||
const key = getRedisKey(userId, feature);
|
||||
const raw = await redis.eval(
|
||||
RESERVE_LUA,
|
||||
1,
|
||||
key,
|
||||
String(limit),
|
||||
String(TTL_SECONDS),
|
||||
);
|
||||
const newCount = parseReserveResult(raw);
|
||||
|
||||
if (newCount === -1) {
|
||||
const byokConfigured = await hasAnyActiveByok(userId);
|
||||
throw new QuotaExceededError(
|
||||
tier === 'BASIC' ? 'PRO' : 'BUSINESS',
|
||||
feature,
|
||||
limit,
|
||||
limit,
|
||||
byokConfigured,
|
||||
{ currentTier: tier },
|
||||
);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(newCount) || newCount < 1) {
|
||||
throw new Error(`Invalid Redis reserve result: ${String(raw)}`);
|
||||
}
|
||||
|
||||
await mirrorUsageCountToDatabase(userId, feature, newCount);
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaExceededError) throw err;
|
||||
|
||||
console.error('[entitlements] Redis reserve failed, trying DB fallback:', err);
|
||||
|
||||
try {
|
||||
const dbCount = await reserveUsageInDatabase(userId, feature, limit);
|
||||
if (dbCount === -1) {
|
||||
const byokConfigured = await hasAnyActiveByok(userId);
|
||||
throw new QuotaExceededError(
|
||||
tier === 'BASIC' ? 'PRO' : 'BUSINESS',
|
||||
feature,
|
||||
limit,
|
||||
limit,
|
||||
byokConfigured,
|
||||
{ currentTier: tier },
|
||||
);
|
||||
}
|
||||
return;
|
||||
} catch (dbErr) {
|
||||
if (dbErr instanceof QuotaExceededError) throw dbErr;
|
||||
console.error('[entitlements] DB reserve fallback failed:', dbErr);
|
||||
}
|
||||
|
||||
if (shouldFailClosedOnRedisError()) {
|
||||
throw new QuotaServiceUnavailableError();
|
||||
}
|
||||
}
|
||||
/** Units charged for one multi-step slide generation (outline + per-slide expand). */
|
||||
export function slideGenerateQuotaUnits(slideCount?: number | null): number {
|
||||
// 1 outline + N expand calls (clamped). Reflects real multi-call cost.
|
||||
const n = typeof slideCount === 'number' && Number.isFinite(slideCount)
|
||||
? Math.min(8, Math.max(3, Math.round(slideCount)))
|
||||
: 6;
|
||||
return 1 + n;
|
||||
}
|
||||
|
||||
/** Host-pays: bill session owner, attach actor metadata for 402 responses (Story 3.4). */
|
||||
@@ -480,6 +428,68 @@ export function incrementUsageAsync(userId: string, feature: string, count: numb
|
||||
});
|
||||
}
|
||||
|
||||
export type ResetUserUsageOptions = {
|
||||
/** Si fourni, ne remet à zéro que ces fonctionnalités. Sinon : toutes. */
|
||||
features?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Remet à zéro la consommation IA d’un utilisateur pour la période mensuelle en cours
|
||||
* (compteurs Redis + lignes UsageLog en base).
|
||||
* Réservé aux actions admin.
|
||||
*/
|
||||
export async function resetUserUsageForCurrentPeriod(
|
||||
userId: string,
|
||||
options?: ResetUserUsageOptions,
|
||||
): Promise<{ period: string; featuresReset: string[]; redisDeleted: number; dbUpdated: number }> {
|
||||
if (!userId?.trim()) {
|
||||
throw new Error('userId required');
|
||||
}
|
||||
|
||||
const period = getCurrentPeriodKey();
|
||||
const { periodStart } = getPeriodDates();
|
||||
|
||||
const features = (options?.features?.length
|
||||
? options.features.filter((f) => isValidFeature(f))
|
||||
: [...VALID_FEATURES]) as string[];
|
||||
|
||||
if (features.length === 0) {
|
||||
return { period, featuresReset: [], redisDeleted: 0, dbUpdated: 0 };
|
||||
}
|
||||
|
||||
const keys = features.map((f) => getRedisKey(userId, f));
|
||||
let redisDeleted = 0;
|
||||
try {
|
||||
if (keys.length === 1) {
|
||||
redisDeleted = await redis.del(keys[0]);
|
||||
} else {
|
||||
redisDeleted = await redis.del(...keys);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[entitlements] resetUserUsage Redis del failed:', err);
|
||||
}
|
||||
|
||||
const dbResult = await prisma.usageLog.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
periodStart,
|
||||
feature: { in: features },
|
||||
},
|
||||
data: {
|
||||
requestsCount: 0,
|
||||
tokensUsed: 0,
|
||||
syncedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
period,
|
||||
featuresReset: features,
|
||||
redisDeleted,
|
||||
dbUpdated: dbResult.count,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUserQuotas(
|
||||
userId: string,
|
||||
): Promise<Record<string, { remaining: number; limit: number; used: number }>> {
|
||||
|
||||
58
memento-note/lib/inline-boot-scripts.ts
Normal file
58
memento-note/lib/inline-boot-scripts.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Scripts boot inline dans le <head> (évite next/script + warning React 19
|
||||
* « Scripts inside React components are never executed… »).
|
||||
* Doivent rester synchrones et sans import.
|
||||
*/
|
||||
|
||||
export const THEME_INIT_SCRIPT = `(function () {
|
||||
try {
|
||||
var root = document.documentElement;
|
||||
var fallback = root.getAttribute('data-server-theme') || 'light';
|
||||
var defaultAccent = root.getAttribute('data-server-accent') || '#A47148';
|
||||
var stored = localStorage.getItem('theme-preference');
|
||||
var raw = stored || fallback;
|
||||
if (raw === 'slate') raw = 'light';
|
||||
var allowed = {
|
||||
light: 1, dark: 1, auto: 1, sepia: 1, midnight: 1,
|
||||
rose: 1, green: 1, lavender: 1, sand: 1, ocean: 1, sunset: 1, blue: 1
|
||||
};
|
||||
var theme = allowed[raw] ? raw : 'light';
|
||||
try {
|
||||
document.cookie = 'memento-theme=' + encodeURIComponent(theme) + '; path=/; max-age=31536000; SameSite=Lax';
|
||||
if (!stored) localStorage.setItem('theme-preference', theme);
|
||||
} catch (e) {}
|
||||
var wantsDark = false;
|
||||
if (theme === 'auto') {
|
||||
wantsDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
} else if (theme === 'dark' || theme === 'midnight') {
|
||||
wantsDark = true;
|
||||
}
|
||||
if (wantsDark) root.classList.add('dark');
|
||||
else root.classList.remove('dark');
|
||||
if (theme === 'auto' || theme === 'dark' || theme === 'light') {
|
||||
root.removeAttribute('data-theme');
|
||||
} else {
|
||||
root.setAttribute('data-theme', theme);
|
||||
}
|
||||
root.setAttribute('data-applied-theme', theme);
|
||||
var accentStored = localStorage.getItem('accent-color');
|
||||
root.style.setProperty('--color-brand-accent', accentStored || defaultAccent);
|
||||
} catch (e) {
|
||||
console.error('Theme script error', e);
|
||||
}
|
||||
})();`
|
||||
|
||||
export const DIRECTION_INIT_SCRIPT = `(function () {
|
||||
try {
|
||||
var lang = localStorage.getItem('user-language');
|
||||
if (!lang) {
|
||||
var c = document.cookie.split(';').map(function (s) { return s.trim(); })
|
||||
.find(function (s) { return s.startsWith('user-language='); });
|
||||
if (c) lang = c.split('=')[1];
|
||||
}
|
||||
if (lang === 'fa' || lang === 'ar') {
|
||||
document.documentElement.dir = 'rtl';
|
||||
document.documentElement.lang = lang;
|
||||
}
|
||||
} catch (e) {}
|
||||
})();`
|
||||
@@ -19,6 +19,9 @@ export const FALLBACK_TIER_LIMITS: Record<
|
||||
brainstorm_expand: 10,
|
||||
brainstorm_enrich: 20,
|
||||
suggest_charts: 5,
|
||||
// Multi-step pipeline charges ~4–9 units per deck → low monthly budget
|
||||
slide_generate: 10,
|
||||
excalidraw_generate: 5,
|
||||
ai_flashcard: 5,
|
||||
voice_transcribe: 20,
|
||||
publish_enhance: 2,
|
||||
@@ -33,8 +36,9 @@ export const FALLBACK_TIER_LIMITS: Record<
|
||||
brainstorm_expand: 100,
|
||||
brainstorm_enrich: 200,
|
||||
suggest_charts: 50,
|
||||
slide_generate: 20,
|
||||
excalidraw_generate: 20,
|
||||
// ~7 units/deck → ~20 decks/month at 140 units
|
||||
slide_generate: 140,
|
||||
excalidraw_generate: 40,
|
||||
ai_flashcard: 100,
|
||||
voice_transcribe: 500,
|
||||
publish_enhance: 15,
|
||||
@@ -49,8 +53,9 @@ export const FALLBACK_TIER_LIMITS: Record<
|
||||
brainstorm_expand: 500,
|
||||
brainstorm_enrich: 1000,
|
||||
suggest_charts: 200,
|
||||
slide_generate: 100,
|
||||
excalidraw_generate: 100,
|
||||
// ~7 units/deck → ~70 decks/month
|
||||
slide_generate: 500,
|
||||
excalidraw_generate: 200,
|
||||
ai_flashcard: 'unlimited',
|
||||
voice_transcribe: 'unlimited',
|
||||
publish_enhance: 100,
|
||||
|
||||
@@ -23,27 +23,29 @@ export const REWRITE_SHARED_CSS = `
|
||||
${KATEX_PUBLISH_CSS}
|
||||
/* ─── Résumé introductif ─────────────────────────────── */
|
||||
.pub-rewrite-summary {
|
||||
font-size: 1.2em;
|
||||
font-size: 1.18em;
|
||||
line-height: 1.7;
|
||||
font-style: italic;
|
||||
color: var(--pub-summary-color, #444);
|
||||
margin-bottom: 2em;
|
||||
padding-bottom: 1.5em;
|
||||
padding: 0 0 1.5em;
|
||||
border-bottom: 2px solid var(--pub-accent, #A47148);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
/* ─── Exercices ──────────────────────────────────────── */
|
||||
.pub-rewrite-body .pub-exercise {
|
||||
border: 2px solid var(--pub-exercise-border, #e0d4c3);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--pub-exercise-border, #e0d4c3);
|
||||
border-radius: 14px;
|
||||
margin: 1.75em 0;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 28px rgba(0,0,0,0.04);
|
||||
}
|
||||
.pub-rewrite-body .pub-exercise-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 18px;
|
||||
padding: 12px 18px;
|
||||
background: var(--pub-exercise-header-bg, #f6f1ea);
|
||||
border-bottom: 1px solid var(--pub-exercise-border, #e0d4c3);
|
||||
}
|
||||
|
||||
@@ -16,18 +16,18 @@ export function getThemeScript(serverTheme: string = 'light', serverAccentColor:
|
||||
var allowed = { light:1, dark:1, auto:1, sepia:1, midnight:1, rose:1, green:1, lavender:1, sand:1, ocean:1, sunset:1, blue:1 };
|
||||
var theme = allowed[raw] ? raw : 'light';
|
||||
var root = document.documentElement;
|
||||
root.classList.remove('dark');
|
||||
root.removeAttribute('data-theme');
|
||||
if (theme === 'auto') {
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) root.classList.add('dark');
|
||||
} else if (theme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
} else if (theme === 'light') {
|
||||
/* :root papier */
|
||||
} else {
|
||||
try {
|
||||
document.cookie = 'memento-theme=' + encodeURIComponent(theme) + '; path=/; max-age=31536000; SameSite=Lax';
|
||||
if (!stored) localStorage.setItem('theme-preference', theme);
|
||||
} catch (e) {}
|
||||
var wantsDark = theme === 'dark' || theme === 'midnight' || (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
if (wantsDark) root.classList.add('dark');
|
||||
else root.classList.remove('dark');
|
||||
if (theme === 'auto' || theme === 'dark' || theme === 'light') root.removeAttribute('data-theme');
|
||||
else {
|
||||
root.setAttribute('data-theme', theme);
|
||||
if (theme === 'midnight') root.classList.add('dark');
|
||||
}
|
||||
root.setAttribute('data-applied-theme', theme);
|
||||
var accentStored = localStorage.getItem('accent-color');
|
||||
var effectiveAccent = accentStored || ${JSON.stringify(defaultAccent)};
|
||||
root.style.setProperty('--color-brand-accent', effectiveAccent);
|
||||
|
||||
Reference in New Issue
Block a user