Files
Momento/memento-note/lib/ai/services/interactive-page-generate.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

310 lines
8.9 KiB
TypeScript

import type { AIProvider } from '@/lib/ai/types'
import { extractSourceAssets } from '@/lib/ai/services/slide-source-assets'
import {
validateInteractiveDemo,
type InteractiveDemoV1,
} from '@/lib/interactive-demo'
import {
validateInteractivePage,
type PageSpecV1,
type PageValidationIssue,
} from '@/lib/interactive-page'
import { normalizeInteractivePageCandidate } from '@/lib/interactive-page/normalize'
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 slugId(title: string): string {
const s = title
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 40)
return s ? `page.${s}` : 'page.generated'
}
function firstSentence(text: string, max = 100): string {
const s = text.split(/[.!?。]/)[0]?.trim() || text.trim()
return s.slice(0, max) || 'Page interactive'
}
function chunkSentences(text: string): string[] {
return text
.split(/(?<=[.!?。])\s+/)
.map((s) => s.trim())
.filter((s) => s.length > 20)
}
export function buildPageFromNote(
content: string,
lang: string,
assets: ReturnType<typeof extractSourceAssets>
): Record<string, unknown> {
const plain = stripToPlain(content)
const sentences = [
...assets.keySentences,
...chunkSentences(plain),
].filter((s, i, arr) => arr.indexOf(s) === i)
const title = firstSentence(sentences[0] || plain, 90)
const lead = sentences[0]?.slice(0, 320) || plain.slice(0, 320) || title
const fr = lang.startsWith('fr')
// Displayable formulas only (KaTeX eats spaces — no prose, bounded length)
const displayFormulas = assets.formulas.filter((f) => f.length <= 120)
const formula = displayFormulas[0]
const formula2 = displayFormulas[1]
const cards = [
{
badge: 'PROBLEM',
title: fr ? 'Contexte' : 'Context',
body: (sentences[0] || lead).slice(0, 160),
intent: 'warning' as const,
},
{
badge: 'APPROACH',
title: fr ? 'Approche' : 'Approach',
body: (sentences[1] || sentences[0] || lead).slice(0, 160),
intent: 'flow' as const,
},
{
badge: 'RESULT',
title: formula ? (fr ? 'Relation' : 'Relation') : fr ? 'Idée clé' : 'Key idea',
body: formula ? `$${formula}$` : (sentences[2] || lead).slice(0, 160),
intent: 'output' as const,
},
]
const sections: Record<string, unknown>[] = [
{
id: 's1',
title: fr ? 'Le problème' : 'The problem',
blocks: [
{ type: 'prose', md: sentences[0] || lead },
{
type: 'callout',
kind: 'definition',
title: fr ? 'En bref' : 'In short',
md: (sentences[1] || plain.slice(0, 180) || title).slice(0, 220),
},
],
},
{
id: 's2',
title: fr ? 'Mécanisme' : 'Mechanism',
blocks: [
{ type: 'prose', md: sentences[1] || sentences[0] || lead },
...(formula ? [{ type: 'formula', tex: formula }] : []),
...(formula2
? [
{
type: 'callout',
kind: 'tip',
title: fr ? 'Aussi' : 'Also',
md: `$${formula2}$`,
},
]
: []),
],
},
{
id: 's3',
title: fr ? 'Synthèse' : 'Synthesis',
blocks: [
{
type: 'prose',
md:
sentences[2] ||
(fr
? 'Retenez le mécanisme — la démo interactive en retrace le flux.'
: 'Keep the mechanism — the interactive demo traces the flow.'),
},
{
type: 'stats',
items: [
{
value: String(Math.max(assets.formulas.length, 1)),
label: fr ? 'Formules' : 'Formulas',
},
{
value: String(Math.min(Math.max(sentences.length, 3), 8)),
label: fr ? 'Idées' : 'Ideas',
},
assets.numbers[0]
? {
value: String(assets.numbers[0].value),
label: assets.numbers[0].label || (fr ? 'Donnée' : 'Figure'),
}
: { value: '→', label: fr ? 'Suite' : 'Next' },
],
},
],
},
]
return {
schemaVersion: 1,
id: slugId(title),
lang,
hero: {
kicker: fr ? 'EXPLAINER INTERACTIF' : 'INTERACTIVE EXPLAINER',
title,
subtitle: (sentences[1] || plain).slice(0, 140),
meta: fr ? 'Généré depuis votre note' : 'Generated from your note',
},
overview: { lead, cards },
sections,
}
}
function injectDemo(
page: Record<string, unknown>,
block: Record<string, unknown>
): Record<string, unknown> {
const sections = Array.isArray(page.sections)
? ([...page.sections] as Record<string, unknown>[])
: []
if (!sections.length) return page
const targetIdx = Math.min(1, sections.length - 1)
const target = { ...sections[targetIdx] }
const blocks = Array.isArray(target.blocks)
? [...(target.blocks as Record<string, unknown>[])]
: []
blocks.push(block)
target.blocks = blocks
sections[targetIdx] = target
return { ...page, sections }
}
export type GenerateInteractivePageInput = {
content: string
lang?: string
/** Kept for API compatibility — page skeleton no longer depends on LLM. */
provider: AIProvider
}
export type GenerateInteractivePageResult =
| { ok: true; page: PageSpecV1; attempts: number }
| {
ok: false
issues?: PageValidationIssue[]
error?: string
reason?: string
raw?: string
attempts: number
}
/**
* Deterministic step-by-step block from extracted formulas (math notes).
* Replaces the old generic box-diagram fallback — boxes are banned.
*/
function buildDeterministicSteps(
content: string,
lang: string,
assets: ReturnType<typeof extractSourceAssets>
): Record<string, unknown> | null {
const fr = lang.startsWith('fr')
const formulas = assets.formulas.filter((f) => f.length <= 120).slice(0, 6)
if (formulas.length < 3) return null
const plain = stripToPlain(content)
const sentences = [
...assets.keySentences,
...chunkSentences(plain),
].filter((s, i, a) => a.indexOf(s) === i)
return {
type: 'steps',
title: fr ? 'Dérivation pas à pas' : 'Step-by-step derivation',
steps: formulas.map((tex, i) => ({
tex,
...(i === 0
? { rule: fr ? 'Point de départ' : 'Starting point' }
: {}),
speak:
sentences[i]?.slice(0, 120) ||
(fr ? `Étape **${i + 1}** de la dérivation.` : `Step **${i + 1}** of the derivation.`),
})),
caption: fr
? 'Formules extraites de la note, déroulées pas à pas.'
: 'Formulas extracted from the note, walked through step by step.',
}
}
/**
* Instant reliable page: deterministic skeleton + deterministic steps (math)
* or Play/Step demo (other content). No LLM round-trip for the page itself.
*/
export async function generateInteractivePageFromContent(
input: GenerateInteractivePageInput
): Promise<GenerateInteractivePageResult> {
const lang = input.lang || 'fr'
const assets = extractSourceAssets(input.content)
const plain = stripToPlain(input.content)
if (plain.split(/\s+/).filter(Boolean).length < 30) {
return {
ok: false,
error: 'unsuitable_content',
reason: 'Contenu trop court pour une page interactive',
attempts: 0,
}
}
let pageObj = buildPageFromNote(input.content, lang, assets)
const stepsBlock = buildDeterministicSteps(input.content, lang, assets)
if (stepsBlock) {
pageObj = injectDemo(pageObj, stepsBlock)
}
const normalized = normalizeInteractivePageCandidate(pageObj, lang)
if (!normalized) {
return {
ok: false,
error: 'normalize_failed',
reason: 'Impossible de normaliser la page',
attempts: 1,
}
}
const result = validateInteractivePage(normalized)
if (!result.ok) {
// Last resort: page without demo
const sections = Array.isArray(normalized.sections)
? (normalized.sections as Record<string, unknown>[]).map((sec) => ({
...sec,
blocks: Array.isArray(sec.blocks)
? (sec.blocks as Record<string, unknown>[]).filter(
(b) => b.type !== 'demo'
)
: [],
}))
: []
const stripped = validateInteractivePage({ ...normalized, sections })
if (stripped.ok) {
return { ok: true, page: stripped.page, attempts: 1 }
}
return {
ok: false,
issues: result.issues,
error: 'validation_failed',
reason: result.issues[0]
? `${result.issues[0].path}: ${result.issues[0].message}`
: 'Page invalide',
attempts: 1,
}
}
return { ok: true, page: result.page, attempts: 1 }
}