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>
361 lines
11 KiB
TypeScript
361 lines
11 KiB
TypeScript
/**
|
|
* Deterministic repairs for LLM PageSpecV1 before Zod validation.
|
|
* Mirrors interactive-demo/normalize — fix common shape/field aliases.
|
|
*/
|
|
|
|
import { normalizeInteractiveDemoCandidate } from '@/lib/interactive-demo/normalize'
|
|
import { CALLOUT_KINDS, INTERACTIVE_PAGE_CAPS } from './constants'
|
|
|
|
const CALLOUT_SET = new Set<string>(CALLOUT_KINDS)
|
|
|
|
const CALLOUT_ALIASES: Record<string, string> = {
|
|
definition: 'definition',
|
|
def: 'definition',
|
|
warning: 'warning',
|
|
warn: 'warning',
|
|
danger: 'warning',
|
|
alert: 'warning',
|
|
tip: 'tip',
|
|
hint: 'tip',
|
|
advice: 'tip',
|
|
note: 'note',
|
|
info: 'note',
|
|
remark: 'note',
|
|
}
|
|
|
|
function asRecord(v: unknown): Record<string, unknown> | null {
|
|
return v && typeof v === 'object' && !Array.isArray(v)
|
|
? (v as Record<string, unknown>)
|
|
: null
|
|
}
|
|
|
|
function asString(v: unknown): string | undefined {
|
|
if (typeof v === 'string' && v.trim()) return v.trim()
|
|
if (typeof v === 'number' && Number.isFinite(v)) return String(v)
|
|
return undefined
|
|
}
|
|
|
|
function slugId(title: string, fallback: 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}` : fallback
|
|
}
|
|
|
|
function pickMd(obj: Record<string, unknown>): string | undefined {
|
|
return (
|
|
asString(obj.md) ||
|
|
asString(obj.content) ||
|
|
asString(obj.text) ||
|
|
asString(obj.body) ||
|
|
asString(obj.html) ||
|
|
asString(obj.markdown)
|
|
)
|
|
}
|
|
|
|
function pickTex(obj: Record<string, unknown>): string | undefined {
|
|
return (
|
|
asString(obj.tex) ||
|
|
asString(obj.latex) ||
|
|
asString(obj.formula) ||
|
|
asString(obj.math) ||
|
|
asString(obj.equation)
|
|
)
|
|
}
|
|
|
|
function normalizeCalloutKind(v: unknown): string {
|
|
if (typeof v !== 'string') return 'note'
|
|
const key = v.trim().toLowerCase()
|
|
const mapped = CALLOUT_ALIASES[key] || key
|
|
return CALLOUT_SET.has(mapped) ? mapped : 'note'
|
|
}
|
|
|
|
function normalizeBlock(
|
|
raw: unknown,
|
|
index: number
|
|
): Record<string, unknown> | null {
|
|
const obj = asRecord(raw)
|
|
if (!obj) return null
|
|
const type = asString(obj.type)?.toLowerCase()
|
|
if (!type) return null
|
|
|
|
if (type === 'prose' || type === 'text' || type === 'markdown' || type === 'paragraph') {
|
|
const md = pickMd(obj)
|
|
if (!md) return null
|
|
return { type: 'prose', md }
|
|
}
|
|
|
|
if (type === 'formula' || type === 'math' || type === 'equation' || type === 'katex') {
|
|
const tex = pickTex(obj)
|
|
if (!tex) return null
|
|
const out: Record<string, unknown> = { type: 'formula', tex }
|
|
const caption = asString(obj.caption)
|
|
if (caption) out.caption = caption
|
|
return out
|
|
}
|
|
|
|
if (type === 'callout' || type === 'aside' || type === 'box') {
|
|
const md = pickMd(obj) || '—'
|
|
const title = asString(obj.title) || asString(obj.heading) || 'Note'
|
|
return {
|
|
type: 'callout',
|
|
kind: normalizeCalloutKind(obj.kind || obj.variant || obj.style),
|
|
title,
|
|
md,
|
|
}
|
|
}
|
|
|
|
if (type === 'demo' || type === 'interactive' || type === 'animation') {
|
|
const nested = obj.demo ?? obj.spec ?? obj.interactiveDemo
|
|
if (!nested) return null
|
|
const normalized = normalizeInteractiveDemoCandidate(nested)
|
|
const out: Record<string, unknown> = { type: 'demo', demo: normalized }
|
|
const caption = asString(obj.caption)
|
|
if (caption) out.caption = caption
|
|
return out
|
|
}
|
|
|
|
if (type === 'chart' || type === 'graph') {
|
|
const payload = asRecord(obj.payload) || asRecord(obj.chart) || obj
|
|
const series = Array.isArray((payload as Record<string, unknown>).series)
|
|
? (payload as Record<string, unknown>).series
|
|
: null
|
|
if (!series) return null
|
|
const chartType =
|
|
asString((payload as Record<string, unknown>).chartType) ||
|
|
asString(obj.chartType) ||
|
|
'line'
|
|
const out: Record<string, unknown> = {
|
|
type: 'chart',
|
|
payload: {
|
|
chartType: ['line', 'bar', 'area'].includes(chartType) ? chartType : 'line',
|
|
series,
|
|
},
|
|
}
|
|
const caption = asString(obj.caption)
|
|
if (caption) out.caption = caption
|
|
return out
|
|
}
|
|
|
|
if (type === 'stats' || type === 'metrics' || type === 'kpis') {
|
|
const items = Array.isArray(obj.items) ? obj.items : Array.isArray(obj.stats) ? obj.stats : null
|
|
if (!items || items.length < 2) return null
|
|
return {
|
|
type: 'stats',
|
|
items: items.slice(0, INTERACTIVE_PAGE_CAPS.maxStatsItems).map((it) => {
|
|
const r = asRecord(it) || {}
|
|
return {
|
|
value: asString(r.value) || asString(r.v) || '—',
|
|
label: asString(r.label) || asString(r.name) || '—',
|
|
...(asString(r.intent) ? { intent: asString(r.intent) } : {}),
|
|
}
|
|
}),
|
|
}
|
|
}
|
|
|
|
if (type === 'table') {
|
|
const columns = Array.isArray(obj.columns)
|
|
? obj.columns.map((c) => asString(c) || '—')
|
|
: []
|
|
const rows = Array.isArray(obj.rows) ? obj.rows : []
|
|
if (!columns.length) return null
|
|
const out: Record<string, unknown> = { type: 'table', columns, rows }
|
|
const caption = asString(obj.caption)
|
|
if (caption) out.caption = caption
|
|
return out
|
|
}
|
|
|
|
if (type === 'image' || type === 'img' || type === 'figure') {
|
|
const src = asString(obj.src) || asString(obj.url)
|
|
const alt = asString(obj.alt) || asString(obj.title) || 'Image'
|
|
if (!src) return null
|
|
const out: Record<string, unknown> = { type: 'image', src, alt }
|
|
const caption = asString(obj.caption)
|
|
if (caption) out.caption = caption
|
|
return out
|
|
}
|
|
|
|
if (type === 'sim' || type === 'simulation' || type === 'slider' || type === 'interactive-sim') {
|
|
const sim = asRecord(obj.sim) || asRecord(obj.simulator) || obj
|
|
const simId = asString(sim.simId) || asString(sim.simulator) || asString(sim.id)
|
|
if (!simId) return null
|
|
const out: Record<string, unknown> = { type: 'sim', sim: { ...sim, simId } }
|
|
const caption = asString(obj.caption)
|
|
if (caption) out.caption = caption
|
|
return out
|
|
}
|
|
|
|
if (type === 'steps' || type === 'derivation' || type === 'walkthrough' || type === 'solution' || type === 'proof') {
|
|
const rawSteps = Array.isArray(obj.steps) ? obj.steps : []
|
|
const steps = rawSteps
|
|
.map((st) => {
|
|
const r = asRecord(st)
|
|
if (!r) return null
|
|
const tex = asString(r.tex) || asString(r.latex) || asString(r.equation) || asString(r.math)
|
|
if (!tex) return null
|
|
const out: Record<string, unknown> = { tex }
|
|
const rule = asString(r.rule) || asString(r.transform) || asString(r.action) || asString(r.operation)
|
|
const speak = asString(r.speak) || asString(r.note) || asString(r.comment)
|
|
if (rule) out.rule = rule
|
|
if (speak) out.speak = speak
|
|
return out
|
|
})
|
|
.filter(Boolean)
|
|
if (steps.length < 2) return null
|
|
const out: Record<string, unknown> = { type: 'steps', steps }
|
|
const title = asString(obj.title)
|
|
if (title) out.title = title
|
|
const caption = asString(obj.caption)
|
|
if (caption) out.caption = caption
|
|
return out
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function normalizeSection(
|
|
raw: unknown,
|
|
index: number
|
|
): Record<string, unknown> | null {
|
|
const obj = asRecord(raw)
|
|
if (!obj) return null
|
|
const title = asString(obj.title) || asString(obj.heading) || `Section ${index + 1}`
|
|
const id = asString(obj.id) || `s${index + 1}`
|
|
const blocksRaw = Array.isArray(obj.blocks)
|
|
? obj.blocks
|
|
: Array.isArray(obj.content)
|
|
? obj.content
|
|
: []
|
|
const blocks = blocksRaw
|
|
.map((b, i) => normalizeBlock(b, i))
|
|
.filter(Boolean)
|
|
.slice(0, INTERACTIVE_PAGE_CAPS.maxBlocksPerSection) as Record<string, unknown>[]
|
|
if (!blocks.length) {
|
|
blocks.push({
|
|
type: 'prose',
|
|
md: asString(obj.summary) || asString(obj.lead) || title,
|
|
})
|
|
}
|
|
return { id, title, blocks }
|
|
}
|
|
|
|
/**
|
|
* Best-effort normalize of an LLM page candidate.
|
|
* Returns a plain object ready for validateInteractivePage.
|
|
*/
|
|
export function normalizeInteractivePageCandidate(
|
|
input: unknown,
|
|
lang = 'fr'
|
|
): Record<string, unknown> | null {
|
|
const root = asRecord(input)
|
|
if (!root) return null
|
|
|
|
const heroIn = asRecord(root.hero) || {}
|
|
const title =
|
|
asString(heroIn.title) ||
|
|
asString(root.title) ||
|
|
'Page interactive'
|
|
const kicker =
|
|
asString(heroIn.kicker) ||
|
|
asString(heroIn.eyebrow) ||
|
|
asString(heroIn.label) ||
|
|
(lang.startsWith('fr') ? 'EXPLAINER INTERACTIF' : 'INTERACTIVE EXPLAINER')
|
|
|
|
const hero: Record<string, unknown> = {
|
|
kicker,
|
|
title,
|
|
}
|
|
const subtitle = asString(heroIn.subtitle) || asString(root.subtitle)
|
|
const meta = asString(heroIn.meta) || asString(root.meta)
|
|
if (subtitle) hero.subtitle = subtitle
|
|
if (meta) hero.meta = meta
|
|
|
|
let overview: Record<string, unknown> | undefined
|
|
const overviewIn = asRecord(root.overview)
|
|
if (overviewIn) {
|
|
const lead =
|
|
asString(overviewIn.lead) ||
|
|
asString(overviewIn.summary) ||
|
|
asString(overviewIn.text)
|
|
const cardsRaw = Array.isArray(overviewIn.cards) ? overviewIn.cards : []
|
|
const cards = cardsRaw
|
|
.map((c) => {
|
|
const r = asRecord(c)
|
|
if (!r) return null
|
|
const badge = asString(r.badge) || asString(r.label) || 'IDEA'
|
|
const cTitle = asString(r.title) || badge
|
|
const body = asString(r.body) || asString(r.text) || asString(r.md) || cTitle
|
|
return {
|
|
badge,
|
|
title: cTitle,
|
|
body,
|
|
...(asString(r.intent) ? { intent: asString(r.intent) } : {}),
|
|
}
|
|
})
|
|
.filter(Boolean)
|
|
if (lead && cards.length >= INTERACTIVE_PAGE_CAPS.minOverviewCards) {
|
|
overview = {
|
|
lead,
|
|
cards: cards.slice(0, INTERACTIVE_PAGE_CAPS.maxOverviewCards),
|
|
}
|
|
} else if (lead) {
|
|
// Pad to min cards so validation can pass
|
|
const padded = [...cards]
|
|
while (padded.length < INTERACTIVE_PAGE_CAPS.minOverviewCards) {
|
|
padded.push({
|
|
badge: `C${padded.length + 1}`,
|
|
title: title,
|
|
body: lead.slice(0, 120),
|
|
})
|
|
}
|
|
overview = {
|
|
lead,
|
|
cards: padded.slice(0, INTERACTIVE_PAGE_CAPS.maxOverviewCards),
|
|
}
|
|
}
|
|
}
|
|
|
|
const sectionsRaw = Array.isArray(root.sections) ? root.sections : []
|
|
let sections = sectionsRaw
|
|
.map((s, i) => normalizeSection(s, i))
|
|
.filter(Boolean) as Record<string, unknown>[]
|
|
|
|
// Cap demos (speed + reliability): keep first 2
|
|
let demoCount = 0
|
|
sections = sections.map((sec) => {
|
|
const blocks = (sec.blocks as Record<string, unknown>[]).filter((b) => {
|
|
if (b.type !== 'demo') return true
|
|
demoCount += 1
|
|
return demoCount <= 2
|
|
})
|
|
return { ...sec, blocks }
|
|
})
|
|
|
|
sections = sections.slice(0, 5)
|
|
if (!sections.length) {
|
|
sections = [
|
|
{
|
|
id: 's1',
|
|
title: title,
|
|
blocks: [{ type: 'prose', md: asString(root.summary) || title }],
|
|
},
|
|
]
|
|
}
|
|
|
|
const out: Record<string, unknown> = {
|
|
schemaVersion: 1,
|
|
id: asString(root.id) || slugId(title, 'page.generated'),
|
|
lang: asString(root.lang) || lang,
|
|
hero,
|
|
sections,
|
|
}
|
|
if (overview) out.overview = overview
|
|
const footer = asString(root.footer)
|
|
if (footer) out.footer = footer
|
|
return out
|
|
}
|