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>
548 lines
22 KiB
TypeScript
548 lines
22 KiB
TypeScript
/**
|
||
* 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 }
|
||
}
|