feat: page interactive, démos Play/Step et simulateur Carnot
Ajoute le pipeline PageSpec (validation, rendu, publication /p/{slug}),
les démos TipTap /demo, et le simulateur Carnot (modes frigo/PAC/moteur,
énergie kJ vs puissance W, unités K/°C/°F) avec correctifs d’équations KaTeX.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import type { InteractiveDemoV1, ValidationIssue } from '@/lib/interactive-demo'
|
||||
|
||||
export type GenerateInteractiveDemoResponse =
|
||||
| { ok: true; demo: InteractiveDemoV1; attempts: number }
|
||||
| {
|
||||
ok: false
|
||||
error: string
|
||||
issues?: ValidationIssue[]
|
||||
quotaExceeded?: boolean
|
||||
status?: number
|
||||
}
|
||||
|
||||
export async function generateInteractiveDemo(params: {
|
||||
content: string
|
||||
selection?: string | null
|
||||
lang?: string
|
||||
noteId?: string
|
||||
}): Promise<GenerateInteractiveDemoResponse> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 170_000)
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch('/api/ai/interactive-demo', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: params.content,
|
||||
selection: params.selection,
|
||||
lang: params.lang,
|
||||
noteId: params.noteId,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
} catch (err) {
|
||||
clearTimeout(timeout)
|
||||
const aborted = err instanceof Error && err.name === 'AbortError'
|
||||
return {
|
||||
ok: false,
|
||||
error: aborted
|
||||
? 'Génération trop longue — réessaie (ou raccourcis la note)'
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: 'Network error',
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}))
|
||||
|
||||
if (res.status === 402) {
|
||||
return {
|
||||
ok: false,
|
||||
error: data.error || 'Quota exceeded',
|
||||
quotaExceeded: true,
|
||||
status: 402,
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: data.error || `HTTP ${res.status}`,
|
||||
issues: data.issues,
|
||||
status: res.status,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
demo: data.demo,
|
||||
attempts: data.attempts ?? 1,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { generateText } from 'ai'
|
||||
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,
|
||||
ANNOTATION_KINDS,
|
||||
SPEAK_WRITING_RULE,
|
||||
validateInteractiveDemo,
|
||||
normalizeInteractiveDemoCandidate,
|
||||
type InteractiveDemoV1,
|
||||
type ValidationIssue,
|
||||
} from '@/lib/interactive-demo'
|
||||
|
||||
const MAX_ATTEMPTS = 2
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
/** STEM cycle example — equations in speak + node labels via $...$ */
|
||||
const STEM_CYCLE_EXAMPLE = `{
|
||||
"schemaVersion": 1,
|
||||
"id": "demo.refrigeration-cycle",
|
||||
"lang": "fr",
|
||||
"disclaimer": "Schéma pédagogique — grandeurs illustratives.",
|
||||
"scene": {
|
||||
"id": "scene.cycle",
|
||||
"panels": [{
|
||||
"id": "panel.cycle",
|
||||
"type": "svg-scene",
|
||||
"payload": {
|
||||
"nodes": [
|
||||
{ "id": "compressor", "label": "1 · Compresseur\\n$W = h_2 - h_1$", "intent": "compute" },
|
||||
{ "id": "condenser", "label": "2 · Condenseur\\n$Q_c = h_2 - h_3$", "intent": "output" },
|
||||
{ "id": "expansion", "label": "3 · Détente\\n$h_3 = h_4$", "intent": "flow" },
|
||||
{ "id": "evaporator", "label": "4 · Évaporateur\\n$Q_e = h_1 - h_4$", "intent": "cache" }
|
||||
],
|
||||
"edges": [
|
||||
{ "id": "e12", "from": "compressor", "to": "condenser", "style": "solid", "intent": "flow" },
|
||||
{ "id": "e23", "from": "condenser", "to": "expansion", "style": "solid", "intent": "flow" },
|
||||
{ "id": "e34", "from": "expansion", "to": "evaporator", "style": "solid", "intent": "flow" },
|
||||
{ "id": "e41", "from": "evaporator", "to": "compressor", "style": "solid", "intent": "flow" }
|
||||
]
|
||||
}
|
||||
}]
|
||||
},
|
||||
"acts": [{
|
||||
"id": "a1",
|
||||
"title": "Cycle frigorifique",
|
||||
"pattern": "flowTrace",
|
||||
"steps": [
|
||||
{ "id": "a1.s1", "speak": "Compression : le travail fourni est $W = h_2 - h_1$.", "pattern": "spotlightTour", "pointTo": ["compressor"], "reveal": [{"ids":["compressor"],"scope":"act"}] },
|
||||
{ "id": "a1.s2", "speak": "Au condenseur, la chaleur rejetée : $Q_c = h_2 - h_3$.", "pattern": "spotlightTour", "pointTo": ["condenser"], "reveal": [{"ids":["condenser","e12"],"scope":"act"}] },
|
||||
{ "id": "a1.s3", "speak": "Détente isenthalpique : $h_3 = h_4$.", "pattern": "spotlightTour", "pointTo": ["expansion"], "reveal": [{"ids":["expansion","e23"],"scope":"act"}] },
|
||||
{ "id": "a1.s4", "speak": "Évaporateur : $Q_e = h_1 - h_4$. COP $= Q_e / W$.", "pattern": "overview", "pointTo": ["evaporator"], "reveal": [{"ids":["evaporator","e34","e41"],"scope":"act"}] }
|
||||
]
|
||||
}]
|
||||
}`
|
||||
|
||||
function buildSystemPrompt(lang: string, hasMath: boolean): string {
|
||||
return `You generate Interactive Demo JSON for Memento notes (schemaVersion ${INTERACTIVE_DEMO_SCHEMA_VERSION}).
|
||||
|
||||
OUTPUT: a single JSON object only. No markdown fences. No commentary. No <think> blocks.
|
||||
|
||||
MISSION: teach the ACTUAL content of the note — concepts, quantities, AND equations.
|
||||
You are NOT allowed to invent a toy 2-node graph ("A" → "B") that ignores the note.
|
||||
|
||||
CONTENT FIDELITY (critical):
|
||||
- Read EVERY extracted formula. Put the important ones in speak as inline KaTeX: $...$
|
||||
- Put key equations on the matching node labels too (use \\n then $latex$).
|
||||
- Use the note's real names (Compresseur, Condenseur, COP, …) — never vague placeholders.
|
||||
- For a physical CYCLE / loop (frigo, Carnot, Rankine, feedback…): 4+ stage nodes + cycle edges closing the loop.
|
||||
- For a derivation: nodes = successive expressions / steps, speak cites the formula at each step.
|
||||
- Min 3 nodes for svg-scene (4+ for cycles). Min 3 steps. Prefer 4–6 steps.
|
||||
- NEVER output only 2 boxes with plain words and zero equations when the note has math.
|
||||
|
||||
PANEL CHOICE:
|
||||
- Process / cycle / architecture → svg-scene (rich graph)
|
||||
- Time series / comparison of measured values → chart
|
||||
- Matrix / attention / correlation → heatmap-matrix
|
||||
- Max 2 panels. May combine svg-scene + chart if useful.
|
||||
|
||||
SCHEMA:
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "demo.<slug>",
|
||||
"lang": "${lang}",
|
||||
"disclaimer?": string,
|
||||
"scene": { "id?": string, "panels": [Panel] },
|
||||
"acts": [ { "id": "a1", "title": string, "pattern?", "steps": [Step] } ]
|
||||
}
|
||||
Step: { "id": "a1.s1", "speak": string, "pattern?", "pointTo?", "reveal?":[{"ids":[],"scope":"transient|act|scene"}], "annotate?":[{"kind","targetIds","scope","text?","intent?"}] }
|
||||
Panel types: ${PANEL_TYPES.join(', ')}
|
||||
Patterns: ${PATTERN_IDS.join(', ')}
|
||||
Intents: ${INTENT_IDS.join(', ')}
|
||||
Annotation kinds: ${ANNOTATION_KINDS.join(', ')}
|
||||
chartType: line | bar | area
|
||||
Heatmap cells: r{row}.c{col}; triangular:"lower" when appropriate.
|
||||
svg-scene: nodes[{id,label?,intent?}], edges[{id,from,to,style?,weight?,intent?}]
|
||||
chart: series[{id,label?,values:number[],intent?}]
|
||||
|
||||
${hasMath ? `STEM MODE ON: formulas were extracted from the note. You MUST:
|
||||
- Include at least 2 distinct equations from FORMULES_EXTRAITES in speak and/or node labels as $latex$
|
||||
- Prefer flowTrace / spotlightTour over vague overview-only demos
|
||||
- Example shape to imitate (adapt to THIS note's real formulas/stages):
|
||||
${STEM_CYCLE_EXAMPLE}` : `If the note has little math, still build a faithful conceptual diagram (≥3 nodes) from the real vocabulary of the note.`}
|
||||
|
||||
RULES:
|
||||
- ${SPEAK_WRITING_RULE}
|
||||
- speak may include light markdown + inline $KaTeX$ (required for STEM).
|
||||
- Never put hex/rgb colors — intents only.
|
||||
- Never annotate an id that is also in pointTo on the same step.
|
||||
- Step ids = a1.s1, a1.s2… matching act id.
|
||||
- All pointTo/reveal/annotate ids must exist in the active scene.
|
||||
- Wildcard "*" reveal ONLY for heatmap/chart panels — not svg-scene alone.
|
||||
- Narration language = lang ("${lang}").
|
||||
- disclaimer when values are pedagogical/illustrative.`
|
||||
}
|
||||
|
||||
function buildUserPrompt(
|
||||
content: string,
|
||||
assets: ReturnType<typeof extractSourceAssets>,
|
||||
opts?: {
|
||||
issues?: ValidationIssue[]
|
||||
previousJson?: string
|
||||
}
|
||||
): string {
|
||||
const plain = stripToPlain(content).slice(0, 7000)
|
||||
let msg = `Create an Interactive Demo that teaches THIS note faithfully (equations included).
|
||||
|
||||
EXCERPT_START
|
||||
${plain}
|
||||
EXCERPT_END
|
||||
`
|
||||
|
||||
if (assets.formulas.length) {
|
||||
msg += `\nFORMULES_EXTRAITES (OBLIGATOIRE — réutilise telles quelles en $...$ dans speak et labels):\n`
|
||||
msg += assets.formulas
|
||||
.slice(0, 16)
|
||||
.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:\n${JSON.stringify(assets.numbers.slice(0, 8))}\n`
|
||||
}
|
||||
|
||||
msg += `\nCONTRAINTES: ≥3 nœuds svg (cycle → 4+ et boucle fermée). speak avec $équations$ si FORMULES_EXTRAITES non vide. Interdit: schéma jouet à 2 boîtes sans maths.\n`
|
||||
|
||||
if (opts?.issues?.length) {
|
||||
msg += `\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, 12000)}\nPREVIOUS_JSON_END`
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
export type GenerateInteractiveDemoInput = {
|
||||
content: string
|
||||
lang?: string
|
||||
provider: AIProvider
|
||||
}
|
||||
|
||||
export type GenerateInteractiveDemoResult =
|
||||
| { ok: true; demo: InteractiveDemoV1; attempts: number }
|
||||
| { ok: false; issues: ValidationIssue[]; raw?: string; attempts: number }
|
||||
|
||||
/**
|
||||
* LLM → JSON → normalize → validateInteractiveDemo, with repair passes.
|
||||
* Harvests formulas like slide generation before prompting.
|
||||
*/
|
||||
export async function generateInteractiveDemoFromContent(
|
||||
input: GenerateInteractiveDemoInput
|
||||
): Promise<GenerateInteractiveDemoResult> {
|
||||
const lang = input.lang || 'fr'
|
||||
const assets = extractSourceAssets(input.content)
|
||||
const system = buildSystemPrompt(lang, assets.hasMath || assets.formulas.length > 0)
|
||||
const model = input.provider.getModel()
|
||||
let lastIssues: ValidationIssue[] = []
|
||||
let lastRaw = ''
|
||||
let lastNormalizedJson = ''
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
const user = buildUserPrompt(input.content, 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.25 : 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-demo] attempt ${attempt}: unparseable JSON`,
|
||||
raw.slice(0, 400)
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const normalized = normalizeInteractiveDemoCandidate(parsed, lang)
|
||||
lastNormalizedJson = JSON.stringify(normalized)
|
||||
|
||||
const result = validateInteractiveDemo(normalized)
|
||||
if (result.ok) {
|
||||
return { ok: true, demo: result.demo, attempts: attempt }
|
||||
}
|
||||
lastIssues = result.issues
|
||||
console.warn(
|
||||
`[interactive-demo] attempt ${attempt}: validation failed`,
|
||||
result.issues.slice(0, 8)
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
issues: lastIssues,
|
||||
raw: lastRaw,
|
||||
attempts: MAX_ATTEMPTS,
|
||||
}
|
||||
}
|
||||
211
memento-note/lib/ai/services/interactive-page-client.service.ts
Normal file
211
memento-note/lib/ai/services/interactive-page-client.service.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import type {
|
||||
PageSection,
|
||||
PageSpecV1,
|
||||
PageValidationIssue,
|
||||
} from '@/lib/interactive-page'
|
||||
|
||||
export type PagePlanDemoKind = 'svg-scene' | 'chart' | 'heatmap-matrix' | 'simulation' | 'none'
|
||||
|
||||
export type PagePlanSection = {
|
||||
title: string
|
||||
goal: string
|
||||
demoKind: PagePlanDemoKind
|
||||
demoGoal?: string
|
||||
}
|
||||
|
||||
export type PagePlan = {
|
||||
heroTitle: string
|
||||
heroSubtitle?: string
|
||||
overviewLead: string
|
||||
overviewCards: {
|
||||
badge: string
|
||||
title: string
|
||||
body: string
|
||||
intent?: string
|
||||
}[]
|
||||
sections: PagePlanSection[]
|
||||
}
|
||||
|
||||
export type GenerateInteractivePageResponse =
|
||||
| { ok: true; page: PageSpecV1; attempts: number }
|
||||
| {
|
||||
ok: false
|
||||
error: string
|
||||
reason?: string
|
||||
issues?: PageValidationIssue[]
|
||||
quotaExceeded?: boolean
|
||||
status?: number
|
||||
}
|
||||
|
||||
export type GeneratePlanResponse =
|
||||
| { ok: true; plan: PagePlan; attempts: number }
|
||||
| {
|
||||
ok: false
|
||||
error: string
|
||||
reason?: string
|
||||
quotaExceeded?: boolean
|
||||
status?: number
|
||||
}
|
||||
|
||||
export type GenerateSectionResponse =
|
||||
| { ok: true; section: PageSection; attempts: number }
|
||||
| { ok: false; error: string; status?: number }
|
||||
|
||||
async function postJson(
|
||||
body: Record<string, unknown>,
|
||||
timeoutMs = 55_000
|
||||
): Promise<{ res: Response; data: Record<string, never> } | { abortError: true }> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
const res = await fetch('/api/ai/interactive-page', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
})
|
||||
const data = await res.json().catch(() => ({}))
|
||||
return { res, data }
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return { abortError: true }
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
/** LLM plan (billed — 20 crédits). */
|
||||
export async function generateInteractivePagePlan(params: {
|
||||
content: string
|
||||
lang?: string
|
||||
noteId?: string
|
||||
}): Promise<GeneratePlanResponse> {
|
||||
let out: Awaited<ReturnType<typeof postJson>>
|
||||
try {
|
||||
out = await postJson({ ...params, action: 'plan' })
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : 'Network error',
|
||||
}
|
||||
}
|
||||
if ('abortError' in out) {
|
||||
return { ok: false, error: 'Génération trop longue — réessaie' }
|
||||
}
|
||||
const { res, data } = out as {
|
||||
res: Response
|
||||
|
||||
data: any
|
||||
}
|
||||
if (res.status === 402) {
|
||||
return {
|
||||
ok: false,
|
||||
error: data.error || 'Quota exceeded',
|
||||
quotaExceeded: true,
|
||||
status: 402,
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: data.error || `HTTP ${res.status}`,
|
||||
reason: data.reason,
|
||||
status: res.status,
|
||||
}
|
||||
}
|
||||
return { ok: true, plan: data.plan, attempts: data.attempts ?? 1 }
|
||||
}
|
||||
|
||||
/** LLM single section (not billed — page billed at plan time). */
|
||||
export async function generateInteractivePageSection(params: {
|
||||
content: string
|
||||
lang?: string
|
||||
noteId?: string
|
||||
pageTitle: string
|
||||
sectionId: string
|
||||
section: PagePlanSection
|
||||
}): Promise<GenerateSectionResponse> {
|
||||
let out: Awaited<ReturnType<typeof postJson>>
|
||||
try {
|
||||
out = await postJson({ ...params, action: 'section' })
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : 'Network error',
|
||||
}
|
||||
}
|
||||
if ('abortError' in out) {
|
||||
return { ok: false, error: 'timeout' }
|
||||
}
|
||||
const { res, data } = out as {
|
||||
res: Response
|
||||
|
||||
data: any
|
||||
}
|
||||
if (!res.ok) {
|
||||
return { ok: false, error: data.error || `HTTP ${res.status}`, status: res.status }
|
||||
}
|
||||
return { ok: true, section: data.section, attempts: data.attempts ?? 1 }
|
||||
}
|
||||
|
||||
/** Legacy deterministic full page (fallback, no quota). */
|
||||
export async function generateInteractivePage(params: {
|
||||
content: string
|
||||
lang?: string
|
||||
noteId?: string
|
||||
notebookId?: string
|
||||
}): Promise<GenerateInteractivePageResponse> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 75_000)
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch('/api/ai/interactive-page', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
signal: controller.signal,
|
||||
})
|
||||
} catch (err) {
|
||||
clearTimeout(timeout)
|
||||
const aborted = err instanceof Error && err.name === 'AbortError'
|
||||
return {
|
||||
ok: false,
|
||||
error: aborted
|
||||
? 'Génération trop longue — réessaie'
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: 'Network error',
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
|
||||
const data = await res.json().catch(() => ({}))
|
||||
|
||||
if (res.status === 402) {
|
||||
return {
|
||||
ok: false,
|
||||
error: data.error || 'Quota exceeded',
|
||||
quotaExceeded: true,
|
||||
status: 402,
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: data.error || `HTTP ${res.status}`,
|
||||
reason: data.reason,
|
||||
issues: data.issues,
|
||||
status: res.status,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
page: data.page,
|
||||
attempts: data.attempts ?? 1,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
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)
|
||||
}
|
||||
|
||||
const INTENT_CYCLE = ['compute', 'flow', 'output', 'cache'] as const
|
||||
|
||||
/**
|
||||
* Instant Play/Step demo from note vocabulary — no LLM.
|
||||
* 3–4 nodes + spotlight steps; formulas in speak when available.
|
||||
*/
|
||||
export function buildDeterministicDemo(
|
||||
content: string,
|
||||
lang: string,
|
||||
assets: ReturnType<typeof extractSourceAssets>
|
||||
): InteractiveDemoV1 | null {
|
||||
const fr = lang.startsWith('fr')
|
||||
const plain = stripToPlain(content)
|
||||
const sentences = [
|
||||
...assets.keySentences,
|
||||
...chunkSentences(plain),
|
||||
].filter((s, i, a) => a.indexOf(s) === i)
|
||||
|
||||
// Prefer short phrase labels from key sentences — never raw formula fragments
|
||||
const labels: string[] = []
|
||||
for (const s of sentences.slice(0, 8)) {
|
||||
const words = s
|
||||
.replace(/\$[^$]*\$/g, ' ')
|
||||
.replace(/[\\{}]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 4)
|
||||
.join(' ')
|
||||
.trim()
|
||||
if (words.length >= 6 && words.length <= 40 && !labels.includes(words)) {
|
||||
labels.push(words)
|
||||
}
|
||||
if (labels.length >= 4) break
|
||||
}
|
||||
// Clean formulas usable inside $…$ (KaTeX eats spaces → no prose, bounded)
|
||||
const cleanFormulas = assets.formulas.filter((f) => f.length <= 80)
|
||||
if (labels.length < 3) {
|
||||
const fallbacks = fr
|
||||
? ['Entrée', 'Transformation', 'Résultat', 'Retour']
|
||||
: ['Input', 'Transform', 'Output', 'Loop']
|
||||
for (const fb of fallbacks) {
|
||||
if (labels.length >= 4) break
|
||||
if (!labels.includes(fb)) labels.push(fb)
|
||||
}
|
||||
}
|
||||
while (labels.length < 3) labels.push(`Étape ${labels.length + 1}`)
|
||||
const nodeCount = Math.min(4, Math.max(3, labels.length))
|
||||
|
||||
const nodes = labels.slice(0, nodeCount).map((label, i) => ({
|
||||
id: `n${i + 1}`,
|
||||
label:
|
||||
cleanFormulas[i] && i < 2
|
||||
? `${i + 1} · ${label.split('\n')[0]}\n$${cleanFormulas[i]}$`
|
||||
: `${i + 1} · ${label}`,
|
||||
intent: INTENT_CYCLE[i % INTENT_CYCLE.length],
|
||||
}))
|
||||
|
||||
const edges = nodes.map((n, i) => {
|
||||
const next = nodes[(i + 1) % nodes.length]
|
||||
return {
|
||||
id: `e${i + 1}`,
|
||||
from: n.id,
|
||||
to: next.id,
|
||||
style: 'solid' as const,
|
||||
intent: 'flow' as const,
|
||||
}
|
||||
})
|
||||
|
||||
const steps = nodes.map((n, i) => {
|
||||
const formula = cleanFormulas[i]
|
||||
const speakBase =
|
||||
sentences[i]?.slice(0, 100) ||
|
||||
(fr ? `Étape **${i + 1}** du mécanisme.` : `Step **${i + 1}** of the mechanism.`)
|
||||
const speak = formula
|
||||
? `${speakBase.split('.')[0]}. $${formula}$`
|
||||
: speakBase
|
||||
const revealed = nodes.slice(0, i + 1).map((x) => x.id)
|
||||
if (i > 0) revealed.push(edges[i - 1].id)
|
||||
const isLast = i === nodes.length - 1
|
||||
return {
|
||||
id: `a1.s${i + 1}`,
|
||||
speak: speak.slice(0, 160),
|
||||
pattern: isLast ? ('overview' as const) : ('spotlightTour' as const),
|
||||
pointTo: [n.id],
|
||||
reveal: [{ ids: isLast ? nodes.map((x) => x.id).concat(edges.map((e) => e.id)) : revealed, scope: 'act' as const }],
|
||||
}
|
||||
})
|
||||
|
||||
const raw = {
|
||||
schemaVersion: 1 as const,
|
||||
id: 'demo.page-auto',
|
||||
lang,
|
||||
disclaimer: fr
|
||||
? 'Schéma pédagogique généré depuis la note — valeurs illustratives.'
|
||||
: 'Pedagogical diagram from your note — illustrative values.',
|
||||
scene: {
|
||||
id: 'scene.main',
|
||||
panels: [
|
||||
{
|
||||
id: 'panel.main',
|
||||
type: 'svg-scene' as const,
|
||||
payload: { nodes, edges },
|
||||
},
|
||||
],
|
||||
},
|
||||
acts: [
|
||||
{
|
||||
id: 'a1',
|
||||
title: fr ? 'Parcours' : 'Walkthrough',
|
||||
pattern: 'flowTrace' as const,
|
||||
steps,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const validated = validateInteractiveDemo(raw)
|
||||
if (!validated.ok) {
|
||||
console.warn(
|
||||
'[interactive-page] deterministic demo invalid',
|
||||
validated.issues.slice(0, 5)
|
||||
)
|
||||
return null
|
||||
}
|
||||
return validated.demo
|
||||
}
|
||||
|
||||
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>,
|
||||
demo: 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({ type: 'demo', demo, caption: 'Démo interactive' })
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Instant reliable page: deterministic skeleton + deterministic Play/Step demo.
|
||||
* No LLM round-trip for the page itself (LLM demos were timing out past client abort).
|
||||
*/
|
||||
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 demo = buildDeterministicDemo(input.content, lang, assets)
|
||||
if (demo) {
|
||||
pageObj = injectDemo(pageObj, demo)
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
547
memento-note/lib/ai/services/interactive-page-llm.service.ts
Normal file
547
memento-note/lib/ai/services/interactive-page-llm.service.ts
Normal file
@@ -0,0 +1,547 @@
|
||||
/**
|
||||
* 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 = 2
|
||||
|
||||
// ── Shared helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
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 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 = ['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().optional(),
|
||||
})
|
||||
|
||||
const pagePlanSchema = z.object({
|
||||
heroTitle: z.string().min(1),
|
||||
heroSubtitle: z.string().optional(),
|
||||
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).optional(),
|
||||
})
|
||||
)
|
||||
.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)} } ], // 2–4 key concepts, small-caps badges (ex. PROBLEM / APPROACH / RESULT)
|
||||
"sections": [ { "title": string, "goal": string, "demoKind": ${JSON.stringify(DEMO_KINDS)}, "demoGoal?": string } ] // 2–5
|
||||
}
|
||||
|
||||
COUVERTURE (règle n°1):
|
||||
- The page covers the CORE of the source: its 2–5 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):
|
||||
- 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"):
|
||||
- 1–2 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, ... ] // 2–6 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 }] } // 2–5, 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
|
||||
IntentId: ${JSON.stringify(INTENT_IDS)}
|
||||
|
||||
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?" }] (1–4), "computed": [{ "id", "symbol", "label", "expr", "unit?", "intent?" }] (1–6), "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 === '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 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). 2–6 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
|
||||
? { id: sectionId, ...(candidate as Record<string, unknown>) }
|
||||
: candidate,
|
||||
],
|
||||
}
|
||||
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 }
|
||||
}
|
||||
@@ -8,6 +8,7 @@ const TEMPLATE_HINTS: Record<PublishTemplateId, string> = {
|
||||
magazine: `Chapô accrocheur + une citation mise en avant (pullQuote). Style journalistique.`,
|
||||
brief: `Résumé exécutif dense + 3 à 5 points clés (keyPoints). Ton professionnel actionnable.`,
|
||||
essay: `Épigraphe inspirante + chapô réfléchi. Ton littéraire mais clair.`,
|
||||
'interactive-page': `Page immersive avec hero, sections et démos Play/Step (canal dédié — ne pas utiliser ici).`,
|
||||
}
|
||||
|
||||
/* ─── MODE ÉDITORIAL (pas de réécriture) ─────────────────────────────────── */
|
||||
|
||||
Reference in New Issue
Block a user