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>
153 lines
5.3 KiB
TypeScript
153 lines
5.3 KiB
TypeScript
/**
|
||
* 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.
|
||
*/
|
||
|
||
import { extractPublishImageUrls } from '@/lib/publish/process-note-html'
|
||
|
||
export interface SourceAssets {
|
||
formulas: string[]
|
||
numbers: Array<{ label: string; value: number; raw: string }>
|
||
keySentences: string[]
|
||
hasMath: boolean
|
||
hasNumbers: boolean
|
||
hasImages: boolean
|
||
wordCount: number
|
||
images: string[]
|
||
}
|
||
|
||
/** Extract LaTeX / equation-like fragments from note plain text or HTML. */
|
||
export function extractFormulas(raw: string): string[] {
|
||
if (!raw) return []
|
||
const found: string[] = []
|
||
// 3+ consecutive plain words at brace depth 0 = prose tail captured after a
|
||
// formula (KaTeX eats spaces in math mode → must never reach $…$).
|
||
const PROSE_TAIL_RE =
|
||
/(?:[.!?]?\s+)[A-ZÀ-ÖØ-Þ]?[a-zA-ZÀ-ÿ'’]{2,}\s+[a-zA-ZÀ-ÿ'’]{2,}\s+[a-zA-ZÀ-ÿ'’]{2,}[\s\S]*$/g
|
||
const braceDepth = (s: string): number => {
|
||
let d = 0
|
||
for (const ch of s) {
|
||
if (ch === '{') d++
|
||
else if (ch === '}') d = Math.max(0, d - 1)
|
||
}
|
||
return d
|
||
}
|
||
const push = (s: string) => {
|
||
let t = s.replace(/\s+/g, ' ').trim()
|
||
t = t.replace(/^\$+|\$+$/g, '').trim()
|
||
PROSE_TAIL_RE.lastIndex = 0
|
||
let m: RegExpExecArray | null
|
||
while ((m = PROSE_TAIL_RE.exec(t)) !== null) {
|
||
if (braceDepth(t.slice(0, m.index)) === 0) {
|
||
t = t.slice(0, m.index).trim()
|
||
break
|
||
}
|
||
}
|
||
t = t.replace(/^\$+|\$+$/g, '').trim()
|
||
if (t.length >= 2 && t.length <= 280 && !found.includes(t)) found.push(t)
|
||
}
|
||
|
||
// TipTap / published HTML: data-latex="..."
|
||
for (const m of raw.matchAll(/data-latex=["']([^"']+)["']/gi)) {
|
||
push(decodeHtmlEntities(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] || '')
|
||
// \( ... \)
|
||
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)
|
||
}
|
||
|
||
function decodeHtmlEntities(s: string): string {
|
||
return s
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, "'")
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/&/g, '&')
|
||
}
|
||
|
||
export function extractNumbers(raw: string): Array<{ label: string; value: number; raw: string }> {
|
||
if (!raw) return []
|
||
// Normalize Persian (۰-۹) and Arabic (٠-٩) numerals to Western (0-9)
|
||
const text = raw
|
||
.replace(/[\u06F0-\u06F9]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0x06f0 + 0x30))
|
||
.replace(/[\u0660-\u0669]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0x0660 + 0x30))
|
||
|
||
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(text)) !== 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 = text.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 plain = raw.replace(/<[^>]+>/g, ' ')
|
||
const formulas = extractFormulas(plain)
|
||
const numbers = extractNumbers(plain)
|
||
const keySentences = extractKeySentences(raw)
|
||
const images = extractPublishImageUrls(raw).slice(0, 8)
|
||
const wordCount = plain.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(plain),
|
||
hasNumbers: numbers.length >= 2,
|
||
hasImages: images.length > 0,
|
||
wordCount,
|
||
images,
|
||
}
|
||
}
|