/** * 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(/(?') .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, } }