Files
Momento/memento-note/lib/ai/services/interactive-page-llm.service.ts
Antigravity 80ccc1f6de
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m14s
CI / Deploy production (on server) (push) Successful in 1m25s
feat: dashboard Second Brain, essai 7 jours et vérification e-mail
Rendre le dashboard actionnable (inbox, peek, carte mentale), aligner la facturation sur l’essai 7 jours, et bloquer le login e-mail tant que l’adresse n’est pas confirmée.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-30 07:19:36 +00:00

560 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* LLM generation for interactive pages (spec §7) — split into short calls:
* 1. generatePagePlan → hero + overview + section list (one fast call)
* 2. generatePageSection → blocks of ONE section, incl. a content-matched demo
*
* Splitting keeps every LLM round-trip well under the client abort (~75 s),
* unlike the original single-shot page generation that timed out (~150 s).
* Validation failures are fed back verbatim (max 2 attempts per call).
*/
import { generateText } from 'ai'
import { z } from 'zod'
import type { AIProvider } from '@/lib/ai/types'
import { cleanAIJsonResponse } from '@/lib/ai/utils/clean-ai-response'
import { extractSourceAssets } from '@/lib/ai/services/slide-source-assets'
import {
INTERACTIVE_DEMO_SCHEMA_VERSION,
PATTERN_IDS,
PANEL_TYPES,
INTENT_IDS,
SPEAK_WRITING_RULE,
} from '@/lib/interactive-demo'
import {
CALLOUT_KINDS,
INTERACTIVE_PAGE_CAPS,
validateInteractivePage,
normalizeInteractivePageCandidate,
type PageSection,
type PageValidationIssue,
} from '@/lib/interactive-page'
import { catalogForPrompt } from '@/lib/simulators'
import thermoFixture from '@/lib/interactive-page/fixtures/thermo-page.json'
const MAX_ATTEMPTS = 3
// ── Shared helpers ───────────────────────────────────────────────────────────
function stripToPlain(html: string): string {
return html
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim()
}
function extractJsonObject(raw: string): unknown | null {
if (!raw) return null
const cleaned = cleanAIJsonResponse(raw)
const tryParse = (s: string): unknown | null => {
try {
return JSON.parse(s)
} catch {
return null
}
}
const stripTrailingCommas = (s: string) => s.replace(/,\s*([}\]])/g, '$1')
let parsed = tryParse(cleaned) ?? tryParse(stripTrailingCommas(cleaned))
if (parsed) return parsed
const fence = cleaned.match(/```(?:json)?\s*([\s\S]*?)```/i)
const candidate = fence?.[1]?.trim() ?? cleaned
parsed = tryParse(candidate) ?? tryParse(stripTrailingCommas(candidate))
if (parsed) return parsed
const start = candidate.indexOf('{')
const end = candidate.lastIndexOf('}')
if (start >= 0 && end > start) {
const slice = candidate.slice(start, end + 1)
parsed = tryParse(slice) ?? tryParse(stripTrailingCommas(slice))
if (parsed) return parsed
}
return null
}
function assetsBlock(assets: ReturnType<typeof extractSourceAssets>): string {
let msg = ''
if (assets.formulas.length) {
msg += `\nFORMULES_EXTRAITES (réutilise telles quelles en $...$ — KaTeX inline):\n`
msg += assets.formulas
.slice(0, 14)
.map((f, i) => `${i + 1}. ${f}`)
.join('\n')
msg += '\n'
}
if (assets.keySentences.length) {
msg += `\nPHRASES_CLES:\n`
msg += assets.keySentences
.slice(0, 8)
.map((s) => `- ${s}`)
.join('\n')
msg += '\n'
}
if (assets.numbers.length) {
msg += `\nDONNEES_NUMERIQUES (source des blocs "stats"/"table" — ne pas inventer d'autres chiffres):\n${JSON.stringify(assets.numbers.slice(0, 10))}\n`
}
return msg
}
// ── Golden demo examples (from the hand-crafted thermo fixture) ─────────────
type FixtureDemoBlock = { type: string; demo: unknown }
function fixtureDemo(panelType: string): string | null {
for (const section of thermoFixture.sections) {
for (const block of section.blocks as FixtureDemoBlock[]) {
if (block.type !== 'demo') continue
const demo = block.demo as {
scene?: { panels?: { type: string }[] }
}
const panels = demo?.scene?.panels ?? []
if (panels.some((p) => p.type === panelType)) {
return JSON.stringify(block.demo)
}
}
}
return null
}
// ── 1. Page plan ─────────────────────────────────────────────────────────────
const DEMO_KINDS = ['steps', 'svg-scene', 'chart', 'heatmap-matrix', 'simulation', 'none'] as const
export type PagePlanDemoKind = (typeof DEMO_KINDS)[number]
const planSectionSchema = z.object({
title: z.string().min(1),
goal: z.string().min(1),
demoKind: z.enum(DEMO_KINDS),
demoGoal: z.string().nullish(),
})
const pagePlanSchema = z.object({
heroTitle: z.string().min(1),
heroSubtitle: z.string().nullish(),
overviewLead: z.string().min(1),
overviewCards: z
.array(
z.object({
badge: z.string().min(1),
title: z.string().min(1),
body: z.string().min(1),
intent: z.enum(INTENT_IDS).nullish(),
})
)
.min(INTERACTIVE_PAGE_CAPS.minOverviewCards)
.max(INTERACTIVE_PAGE_CAPS.maxOverviewCards),
sections: z.array(planSectionSchema).min(2).max(5),
})
export type PagePlanSection = z.infer<typeof planSectionSchema>
export type PagePlan = z.infer<typeof pagePlanSchema>
export type GeneratePagePlanResult =
| { ok: true; plan: PagePlan; attempts: number }
| { ok: false; error: string; reason?: string; attempts: number }
function buildPlanSystemPrompt(lang: string): string {
return `You plan an interactive pedagogical page (PageSpecV1) for a Memento note, in the style of the Kimi "Attention Residuals" explainer.
You output ONLY the PLAN as one JSON object — sections content is generated later, one call per section.
OUTPUT: a single JSON object only. No markdown fences. No commentary. No <think> blocks.
SCHEMA:
{
"heroTitle": string, // the SUBJECT of the note, never a generic title
"heroSubtitle": string, // one sentence, autoportant
"overviewLead": string, // the central idea in ONE self-contained paragraph (may use $KaTeX$ inline)
"overviewCards": [ { "badge": string, "title": string, "body": string, "intent?": ${JSON.stringify(INTENT_IDS)} } ], // 34 key concepts, small-caps badges (ex. PROBLEM / APPROACH / RESULT)
"sections": [ { "title": string, "goal": string, "demoKind": ${JSON.stringify(DEMO_KINDS)}, "demoGoal?": string } ] // 25
}
COUVERTURE (règle n°1):
- The page covers the CORE of the source: its 25 major ideas, one section each.
- NEVER build the page on the note's simplest example.
- First section = the problem / context; last section = synthesis / results.
- If the content does not benefit from an interactive page, REFUSE: return { "error": "unsuitable_content", "reason": "…" } instead.
MATCHING CONTENU → DÉMO (choose demoKind per section, "none" when no visual helps):
- Dérivation, démonstration, résolution d'équation, calcul pas à pas (maths, physique) → "steps" — JAMAIS de svg-scene pour du contenu mathématique
- Catégories × propriétés (comparatif, matrice, échanges) → "heatmap-matrix"
- Loi / relation / courbe / évolution chiffrée → "chart"
- Processus / flux / cycle / architecture → "svg-scene"
- Avant/après, comparaison d'états → "svg-scene" (2 groupes de nœuds) et précise-le dans demoGoal
- Loi chiffrée avec paramètres manipulables (η = 1 Tc/Th, COP, loi physique, modèle) → "simulation" (l'apprenant manipule des curseurs)
- Une démo seulement si elle ENSEIGNE mieux que le texte. Max 3 sections avec démo/simulation.
RULES:
- Language of ALL strings = "${lang}" (the note's language).
- demoGoal: one sentence stating what the demo must show (used by the next LLM call).
- No colors anywhere. Intents only.`
}
export async function generatePagePlan(input: {
content: string
lang?: string
provider: AIProvider
}): Promise<GeneratePagePlanResult> {
const lang = input.lang || 'fr'
const assets = extractSourceAssets(input.content)
const plain = stripToPlain(input.content).slice(0, 6000)
const system = buildPlanSystemPrompt(lang)
const model = input.provider.getModel()
let lastError = 'unknown'
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
let user = `Plan the interactive page for THIS note.
EXCERPT_START
${plain}
EXCERPT_END
${assetsBlock(assets)}`
if (attempt > 1) {
user += `\nPREVIOUS_ANSWER_INVALID: ${lastError}\nReturn a corrected FULL JSON object matching the schema exactly.`
}
const { text: raw } = await generateText({
model,
system,
prompt: user,
temperature: attempt === 1 ? 0.3 : 0.1,
})
const parsed = extractJsonObject(raw)
if (!parsed) {
lastError = 'Model did not return parseable JSON'
console.warn('[interactive-page/plan] unparseable JSON', raw.slice(0, 300))
continue
}
const refusal = parsed as { error?: string; reason?: string }
if (refusal?.error === 'unsuitable_content') {
return {
ok: false,
error: 'unsuitable_content',
reason: refusal.reason || 'Contenu inadapté à une page interactive',
attempts: attempt,
}
}
const result = pagePlanSchema.safeParse(parsed)
if (result.success) {
return { ok: true, plan: result.data, attempts: attempt }
}
lastError = result.error.issues
.slice(0, 6)
.map((i) => `${i.path.join('.')}: ${i.message}`)
.join(' | ')
console.warn('[interactive-page/plan] schema failed', lastError)
}
return { ok: false, error: 'plan_failed', reason: lastError, attempts: MAX_ATTEMPTS }
}
// ── 2. Section generation ────────────────────────────────────────────────────
export type GeneratePageSectionInput = {
content: string
lang?: string
provider: AIProvider
pageTitle: string
sectionId: string
section: PagePlanSection
}
export type GeneratePageSectionResult =
| { ok: true; section: PageSection; attempts: number }
| { ok: false; issues?: PageValidationIssue[]; error?: string; attempts: number }
const SECTION_NARRATION_RULES = `NARRATION des démos (champ "speak"):
- 12 phrases, 25 mots max, une seule idée par étape, gras sur le concept clé, KaTeX inline ($...$) pour les formules.
- Voix off de prof, jamais de description mécanique.
- Jamais annoter ce qu'on pointTo dans la même étape.
- Chaque élément d'une scène est désigné au moins une fois dans l'acte.
- Scènes denses : svg-scene ≥ 5 nœuds ; heatmap avec valeurs réalistes et variées (intensité ∝ valeur, valeurs affichées).
- Étape finale d'acte : reveal ["*"] + pattern overview.
- Valeurs illustratives → "disclaimer" obligatoire.`
function buildSectionSystemPrompt(lang: string, hasMath: boolean): string {
return `You generate ONE section of an interactive pedagogical page (PageSpecV1) for a Memento note, in the style of the Kimi "Attention Residuals" explainer.
OUTPUT: a single JSON object only. No markdown fences. No commentary. No <think> blocks.
SCHEMA:
{
"title": string,
"blocks": [ Block, ... ] // 26 blocks
}
Block types (discriminated by "type"):
- { "type": "prose", "md": string } // light markdown + $KaTeX$ inline
- { "type": "formula", "tex": string, "caption?": string } // KaTeX block for key relations
- { "type": "callout", "kind": ${JSON.stringify(CALLOUT_KINDS)}, "title": string, "md": string }
- { "type": "chart", "payload": { "chartType": "line"|"bar"|"area", "series": [{ "id": string, "label?": string, "values": number[], "intent?": IntentId }] }, "caption?": string }
- { "type": "stats", "items": [{ "value": string, "label": string, "intent?": IntentId }] } // 25, REAL figures from the note only
- { "type": "table", "columns": string[], "rows": string[][], "caption?": string } // every row.length === columns.length
- { "type": "demo", "demo": InteractiveDemoV1, "caption?": string }
- { "type": "sim", "sim": SimRef, "caption?": string } // interactive simulation with sliders
- { "type": "steps", "title?": string, "steps": [{ "tex": string, "rule?": string, "speak?": string }] (212), "caption?": string } // step-by-step derivation: tex = KaTeX of the equation state, rule = transformation applied (short, e.g. "on sépare les variables")
IntentId: ${JSON.stringify(INTENT_IDS)}
BLOCK "steps" (Symbolab-style derivation) — MANDATORY for math/derivation content:
- Each step = the equation state AFTER applying the rule; steps must chain logically (each follows from the previous).
- rule = the transformation applied to reach THIS state (short verb phrase). speak = 1 sentence teacher narration.
- FORBIDDEN: using "demo" svg-scene (boxes with arrows) for equations, derivations, proofs, or calculus content — always "steps" instead.
SimRef — TWO forms:
(A) CATALOG simulator (PREFERRED when the section matches one): { "simId": "<id from catalog>", "title?": string, "preset?": { "<paramId>": number }, "disclaimer?": string }
→ You ONLY pick the simId and preset values (from the note's real numbers within the allowed ranges). The app runs the simulation.
(B) GENERIC formula simulator: { "simId": "generic-formula", "title": string, "params": [{ "id", "symbol", "label", "min", "max", "step", "defaultValue", "unit?", "intent?" }] (14), "computed": [{ "id", "symbol", "label", "expr", "unit?", "intent?" }] (16), "visual": { "kind": "gauges" } | { "kind": "bars" } | { "kind": "curve", "xParamId", "expr" }, "disclaimer?": string }
→ expr = plain math over param ids: + - * / ^ % parentheses, functions sqrt abs exp ln log round min max, constants pi e. NO other identifiers.
SIMULATOR CATALOG (pick from this, else generic-formula):
${catalogForPrompt(lang)}
InteractiveDemoV1 (schemaVersion ${INTERACTIVE_DEMO_SCHEMA_VERSION}):
{
"schemaVersion": 1, "id": "demo.<slug>", "lang": "${lang}", "disclaimer?": string,
"scene": { "id": string, "panels": [Panel] },
"acts": [ { "id": "a1", "title": string, "pattern?": Pattern, "steps": [Step] } ]
}
Panel types: ${PANEL_TYPES.join(', ')} (max 2 panels; heatmap cells ids: r{row}.c{col}, triangular:"lower" when appropriate)
Patterns: ${PATTERN_IDS.join(', ')}
Step: { "id": "a1.s1", "speak": string, "pattern?", "pointTo?": string[], "reveal?": [{"ids": string[], "scope": "transient|act|scene"}], "annotate?": [...] }
svg-scene: nodes[{id,label?,intent?}] (labels may hold $KaTeX$), edges[{id,from,to,style?,weight?,intent?}]
${SECTION_NARRATION_RULES}
MÉCANIQUE (the validator rejects otherwise):
- Unique semantic ids; NO colors (hex/rgb) anywhere — intents only.
- Blocks per section ≤ ${INTERACTIVE_PAGE_CAPS.maxBlocksPerSection}.
- Every pointTo/reveal/annotate reference must be an id of the active scene.
- "stats"/"table" ONLY with figures actually present in the source.
- All human-facing strings in "${lang}".
RÈGLE D'OR — AUCUN VISUEL DÉCORATIF (checked after generation, violations are rejected):
- Every visual block must TEACH something the text alone cannot. Ask: "what does the learner understand after, that they didn't before?" No answer → no visual block.
- "heatmap-matrix" ONLY when the note contains genuinely matrix-shaped data (table of values, correlations, confusion matrix). Otherwise FORBIDDEN.
- "chart" ONLY with numeric series actually present in the source note. Otherwise FORBIDDEN.
- svg-scene: ≥ 5 nodes, dense, real vocabulary of the note — never 3 generic boxes.
- When in doubt: prose/formula/callout only.
${hasMath ? '- STEM: reuse formulas from FORMULES_EXTRAITES verbatim in formula blocks, prose and demo speak ($...$).' : ''}`
}
function buildSectionUserPrompt(
input: GeneratePageSectionInput,
assets: ReturnType<typeof extractSourceAssets>,
opts?: { issues?: PageValidationIssue[]; previousJson?: string }
): string {
const plain = stripToPlain(input.content).slice(0, 6000)
const { section } = input
let msg = `Generate the section "${section.title}" of the interactive page "${input.pageTitle}".
SECTION_GOAL: ${section.goal}
${
section.demoKind === 'steps'
? `STEPS_REQUIRED: include ONE "steps" block — the step-by-step derivation of this section's key result. Real equations from the note, logically chained, a short "rule" per step.
STEPS_GOAL: ${section.demoGoal || section.goal}`
: section.demoKind === 'simulation'
? `SIMULATION_REQUIRED: include ONE "sim" block. Prefer a catalog simulator if the section matches one (bind the note's real values into "preset"); otherwise "generic-formula" with the section's key relation.
SIM_GOAL: ${section.demoGoal || section.goal}`
: section.demoKind !== 'none'
? `DEMO_REQUIRED: include ONE "demo" block of kind "${section.demoKind}".
DEMO_GOAL: ${section.demoGoal || section.goal}`
: `NO demo/sim/steps block for this section — rich prose/formula/callout/stats/table only.`
}
EXCERPT_START
${plain}
EXCERPT_END
${assetsBlock(assets)}`
if (section.demoKind === 'heatmap-matrix' || section.demoKind === 'svg-scene') {
const golden = fixtureDemo(section.demoKind)
if (golden) {
msg += `\nEXEMPLE_D_OR (imite sa densité et sa qualité de narration — jamais moins ; adapte au contenu de CETTE note):\n${golden}\n`
}
}
msg += `\nCONTRAINTES: section autoportante, fidèle au contenu réel de la note (jamais un exemple jouet). 26 blocks.`
if (opts?.issues?.length) {
msg += `\n\nPREVIOUS_JSON_FAILED_VALIDATION. Fix ALL issues and return a corrected FULL JSON.\n`
msg += opts.issues
.slice(0, 12)
.map((i) => `- [${i.code}] ${i.path}: ${i.message}`)
.join('\n')
}
if (opts?.previousJson) {
msg += `\n\nPREVIOUS_JSON_START\n${opts.previousJson.slice(0, 10000)}\nPREVIOUS_JSON_END`
}
return msg
}
/** Wrap a section candidate in a minimal page so the shared validator runs (incl. demo delegation). */
function validateSectionCandidate(
candidate: unknown,
sectionId: string,
lang: string
): { ok: true; section: PageSection } | { ok: false; issues: PageValidationIssue[] } {
const wrapped = {
schemaVersion: 1,
id: 'page.section-check',
lang,
hero: { kicker: 'CHECK', title: 'Section check' },
sections: [
typeof candidate === 'object' && candidate !== null
? { ...(candidate as Record<string, unknown>), id: sectionId }
: candidate,
// schema requires ≥2 sections — inert filler for the wrap check
{ id: 's99', title: '—', blocks: [{ type: 'prose', md: '—' }] },
],
}
const normalized = normalizeInteractivePageCandidate(wrapped, lang)
if (!normalized) {
return {
ok: false,
issues: [
{ code: 'normalize_failed', path: '', message: 'Section not normalizable' },
],
}
}
const result = validateInteractivePage(normalized)
if (!result.ok) return { ok: false, issues: result.issues }
const section = result.page.sections.find((s) => s.id === sectionId)
if (!section) {
return {
ok: false,
issues: [{ code: 'section_missing', path: 'sections', message: 'Section lost in normalization' }],
}
}
return { ok: true, section }
}
/**
* Quality gate (règle d'or): reject decorative visuals AFTER schema validation —
* heatmap/chart whose values don't come from the note, svg-scene too sparse.
* Issues are fed back to the LLM for a repair attempt.
*/
function qualityGateSection(
section: PageSection,
assets: ReturnType<typeof extractSourceAssets>
): PageValidationIssue[] {
const out: PageValidationIssue[] = []
const sourceValues = assets.numbers.map((n) => n.value)
const valueInSource = (v: number) =>
sourceValues.some((sv) => sv !== 0 && Math.abs(sv - v) / Math.max(1, Math.abs(sv)) < 0.06)
for (const [bi, block] of section.blocks.entries()) {
const path = `blocks[${bi}]`
if (block.type === 'chart') {
const values = block.payload.series.flatMap((s) => s.values)
const grounded = values.filter(valueInSource).length
if (values.length > 0 && grounded / values.length < 0.5) {
out.push({
code: 'decorative_data',
path,
message:
'Chart values are not from the note (decorative data). Use real series from the source or remove the chart.',
})
}
}
if (block.type === 'demo') {
for (const panel of block.demo.scene.panels) {
if (panel.type === 'heatmap-matrix') {
const values = panel.payload.values.flat()
const grounded = values.filter(valueInSource).length
if (values.length > 0 && grounded / values.length < 0.5) {
out.push({
code: 'decorative_data',
path,
message:
'Heatmap values are not from the note (decorative data). Only use heatmap-matrix for real matrix-shaped source data.',
})
}
}
if (panel.type === 'svg-scene' && panel.payload.nodes.length < 5) {
out.push({
code: 'scene_too_poor',
path,
message:
'svg-scene has fewer than 5 nodes (too poor pedagogically). Densify: ≥5 nodes with real vocabulary and formulas from the note.',
})
}
}
}
}
return out
}
export async function generatePageSection(
input: GeneratePageSectionInput
): Promise<GeneratePageSectionResult> {
const lang = input.lang || 'fr'
const assets = extractSourceAssets(input.content)
const system = buildSectionSystemPrompt(
lang,
assets.hasMath || assets.formulas.length > 0
)
const model = input.provider.getModel()
let lastIssues: PageValidationIssue[] = []
let lastRaw = ''
let lastNormalizedJson = ''
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const user = buildSectionUserPrompt(input, assets, {
issues: attempt > 1 ? lastIssues : undefined,
previousJson: attempt > 1 ? lastNormalizedJson || lastRaw : undefined,
})
const { text: raw } = await generateText({
model,
system,
prompt: user,
temperature: attempt === 1 ? 0.3 : 0.1,
})
lastRaw = raw
const parsed = extractJsonObject(raw)
if (!parsed) {
lastIssues = [
{
code: 'invalid_json',
path: '',
message: 'Model did not return parseable JSON',
},
]
console.warn(
`[interactive-page/section ${input.sectionId}] attempt ${attempt}: unparseable JSON`,
raw.slice(0, 300)
)
continue
}
lastNormalizedJson = JSON.stringify(parsed)
const result = validateSectionCandidate(parsed, input.sectionId, lang)
if (result.ok) {
const qualityIssues = qualityGateSection(result.section, assets)
if (!qualityIssues.length) {
return { ok: true, section: result.section, attempts: attempt }
}
lastIssues = qualityIssues
console.warn(
`[interactive-page/section ${input.sectionId}] attempt ${attempt}: quality gate`,
qualityIssues.slice(0, 6)
)
continue
}
lastIssues = result.issues
console.warn(
`[interactive-page/section ${input.sectionId}] attempt ${attempt}: validation failed`,
result.issues.slice(0, 8)
)
}
return { ok: false, issues: lastIssues, attempts: MAX_ATTEMPTS }
}