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>
310 lines
8.9 KiB
TypeScript
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(/ /g, ' ')
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/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 }
|
|
}
|