feat(credits): solde IA unique (option M), packs Stripe et polish billing

Un pot de crédits global (BASIC 100 / PRO 1 000 / BUSINESS 4 000) remplace
les seaux par fonction côté débit. Packs one-shot S/M/L, admin sans seaux
legacy, usage multi-features, hints de coût, anti-flash thème et hydratation
admin. Inclut aussi le pipeline slides renforcé et la page publique polishée.
This commit is contained in:
Antigravity
2026-07-16 20:43:18 +00:00
parent 704fed1191
commit 556a0b2f3f
77 changed files with 35745 additions and 10430 deletions

View File

@@ -0,0 +1,103 @@
/**
* Server-side extraction of usable assets from a note BEFORE any slide LLM call.
* Inspired by PPTAgent (retrieve relevant content per slide) and DeckForge (data_needs).
* Critical for STEM notes: formulas must be harvested, not hoped for from the model.
*/
export interface SourceAssets {
formulas: string[]
numbers: Array<{ label: string; value: number; raw: string }>
keySentences: string[]
hasMath: boolean
hasNumbers: boolean
wordCount: number
}
/** Extract LaTeX / equation-like fragments from note plain text or HTML. */
export function extractFormulas(raw: string): string[] {
if (!raw) return []
const found: string[] = []
const push = (s: string) => {
const t = s.replace(/\s+/g, ' ').trim()
if (t.length >= 2 && t.length <= 280 && !found.includes(t)) found.push(t)
}
// $$ ... $$
for (const m of raw.matchAll(/\$\$([\s\S]+?)\$\$/g)) push(m[1] || '')
// \[ ... \]
for (const m of raw.matchAll(/\\\[([\s\S]+?)\\\]/g)) push(m[1] || '')
// \( ... \)
for (const m of raw.matchAll(/\\\(([\s\S]+?)\\\)/g)) push(m[1] || '')
// $ ... $ (single line)
for (const m of raw.matchAll(/(?<![\\$])\$([^$\n]{2,120})\$(?!\$)/g)) push(m[1] || '')
// Common latex commands in free text
for (const m of raw.matchAll(
/(?:^|[\s(])((?:\\frac\{[^}]+\}\{[^}]+\}|\\partial|\\nabla|\\sum|\\int|\\frac|\\cdot|\\times|\\left|\\right|y'|y''|d[xy]\/d[xy]|∂)[^\n]{0,100})/g,
)) {
push(m[1] || '')
}
// Equation-like: f(x) = ..., dy/dx = ...
for (const m of raw.matchAll(
/(?:^|\n)\s*([A-Za-zΑ-ω∂∇][A-Za-z0-9_'"]*\s*(?:\([^)]*\))?\s*[=≈≡]\s*[^\n]{3,100})/g,
)) {
const line = (m[1] || '').trim()
if (/[=≈]/.test(line) && /[a-zA-Z0-9]/.test(line)) push(line)
}
return found.slice(0, 24)
}
export function extractNumbers(raw: string): Array<{ label: string; value: number; raw: string }> {
if (!raw) return []
const out: Array<{ label: string; value: number; raw: string }> = []
const re =
/(?:^|[^\d])((?:≈|~)?\s*-?\d+(?:[.,]\d+)?)\s*(%|€|\$|k|m|bn|ms|s|°C)?\b/gi
let m: RegExpExecArray | null
while ((m = re.exec(raw)) !== null && out.length < 20) {
const numStr = (m[1] || '').replace(/[≈~\s]/g, '').replace(',', '.')
const value = parseFloat(numStr)
if (!Number.isFinite(value)) continue
const unit = m[2] || ''
// crude label: 40 chars before
const start = Math.max(0, m.index - 40)
const ctx = raw.slice(start, m.index).replace(/\s+/g, ' ').trim()
const label = ctx.split(/[.;:!?\n]/).pop()?.trim().slice(-32) || `n${out.length + 1}`
out.push({ label, value: unit === '%' ? value : value, raw: `${numStr}${unit}` })
}
return out
}
export function extractKeySentences(raw: string, max = 16): string[] {
if (!raw) return []
const plain = raw
.replace(/<[^>]+>/g, ' ')
.replace(/\$\$[\s\S]+?\$\$/g, ' ')
.replace(/\\\[[\s\S]+?\\\]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
const parts = plain
.split(/(?<=[.!?])\s+/)
.map((s) => s.trim())
.filter((s) => s.length >= 40 && s.length <= 220)
const out: string[] = []
for (const p of parts) {
if (out.length >= max) break
if (!out.some((x) => x.slice(0, 40) === p.slice(0, 40))) out.push(p)
}
return out
}
export function extractSourceAssets(raw: string): SourceAssets {
const formulas = extractFormulas(raw)
const numbers = extractNumbers(raw)
const keySentences = extractKeySentences(raw)
const wordCount = raw.replace(/<[^>]+>/g, ' ').split(/\s+/).filter(Boolean).length
return {
formulas,
numbers,
keySentences,
hasMath: formulas.length > 0 || /équat|equat|différen|differen|dériv|deriv|intégr|integr|EDO|ODE|PDE|latex/i.test(raw),
hasNumbers: numbers.length >= 2,
wordCount,
}
}