DocDoeAI / src /lib /ai /math.ts
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
6.73 kB
/**
* Math / formula normalisation for student-facing AI answers.
*
* Pipeline (applied after sanitizeAIResponse):
* 1. Convert LaTeX delimiters \( … \) → $ … $ (inline math)
* 2. Convert LaTeX delimiters \[ … \] → $$ … $$ (display math)
* 3. Collapse excess whitespace produced by stripping reasoning blocks.
*
* Formulas inside the resulting $ … $ blocks are left untouched — KaTeX
* handles them at render time via remark-math + rehype-katex.
*
* chemicalText() is exported separately as a fallback plain-text formatter
* for contexts where ReactMarkdown / KaTeX is not available (e.g. tooltips,
* clipboard copy, alt-text). It converts common TeX subscripts/arrows to
* Unicode equivalents.
*/
// ---------------------------------------------------------------------------
// 1. LaTeX delimiter normalisation → remark-math compatible $ / $$ delimiters
// ---------------------------------------------------------------------------
/**
* Convert \( … \) and \[ … \] delimiters to $ … $ and $$ … $$.
* Also cleans up a handful of stray TeX artefacts that appear outside math
* when models hallucinate raw LaTeX into prose.
*/
export function normalizeMathMarkdown(text: string): string {
if (!text) return "";
let t = text;
// 1a. Display math \[ … \] → $$ … $$
// Use non-greedy multiline match so one call handles multiple blocks.
t = t.replace(/\\\[\s*([\s\S]*?)\s*\\\]/g, (_m, inner: string) => `$$${inner.trim()}$$`);
// 1b. Inline math \( … \) → $ … $
t = t.replace(/\\\(\s*([\s\S]*?)\s*\\\)/g, (_m, inner: string) => `$${inner.trim()}$`);
// 1c. Strip stray \text{} wrappers that ended up outside any math block
// (models sometimes write \text{something} directly in prose).
// Inside $…$ they are valid and must be left alone — handled by KaTeX.
// This regex only removes them when NOT preceded by $ on the same line.
t = t.replace(/\\text\{([^}]*)\}/g, (match, inner: string, offset: number) => {
// Check if inside a $…$ block by counting $ signs before this position
const before = t.slice(0, offset);
const dollarsBefore = (before.match(/\$/g) ?? []).length;
// If odd number of $ before → inside inline math → leave it (return original)
if (dollarsBefore % 2 === 1) return match;
return inner; // outside math — just keep the inner text
});
// 1d. Replace \rightarrow and \to outside math blocks with →
t = replaceOutsideMath(t, /\\(?:rightarrow|to)\b/g, "→");
// 1e. Replace \Rightarrow outside math blocks with ⟹
t = replaceOutsideMath(t, /\\Rightarrow\b/g, "⟹");
// 1f. Replace \cdot outside math with ·
t = replaceOutsideMath(t, /\\cdot\b/g, "·");
// 1g. Replace \times outside math with ×
t = replaceOutsideMath(t, /\\times\b/g, "×");
// 1h. Collapse 3+ blank lines (side-effect of removing reasoning blocks)
t = t.replace(/\n{3,}/g, "\n\n").trim();
return t;
}
/**
* Apply a regex replacement only to text segments that are outside $ math $ delimiters.
* Walks the string, alternating between "outside math" and "inside math" segments.
*/
function replaceOutsideMath(text: string, pattern: RegExp, replacement: string): string {
// Split on $ boundaries while preserving the delimiters
const segments = text.split(/(\$\$[\s\S]*?\$\$|\$[^$\n]*?\$)/);
return segments
.map((seg, i) => {
// Even indices → outside math; odd indices → inside a $…$ or $$…$$ block
if (i % 2 === 0) return seg.replace(pattern, replacement);
return seg;
})
.join("");
}
// ---------------------------------------------------------------------------
// 2. Plain-text chemical / math formatter (fallback / clipboard)
// ---------------------------------------------------------------------------
const SUB_MAP: Record<string, string> = {
"0": "₀", "1": "₁", "2": "₂", "3": "₃", "4": "₄",
"5": "₅", "6": "₆", "7": "₇", "8": "₈", "9": "₉",
};
const SUP_MAP: Record<string, string> = {
"0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴",
"5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹",
"+": "⁺", "-": "⁻", "n": "ⁿ",
};
function toSubscript(digits: string): string {
return [...digits].map((d) => SUB_MAP[d] ?? d).join("");
}
function toSuperscript(digits: string): string {
return [...digits].map((d) => SUP_MAP[d] ?? d).join("");
}
/**
* Convert chemical/math TeX into readable Unicode plain-text.
* Designed for clipboard copy, tooltips, and accessibility alt-text.
*
* Examples:
* C_6H_{12}O_6 → C₆H₁₂O₆
* CO_2 → CO₂
* H_2O → H₂O
* x^2 → x²
* \rightarrow → →
* \text{foo} → foo
* \( E = mc^2 \) → E = mc²
*/
export function chemicalText(raw: string): string {
if (!raw) return "";
let t = raw;
// Strip LaTeX display/inline delimiters
t = t.replace(/\\\[|\\\]/g, "").replace(/\\\(|\\\)/g, "");
// \text{…} → inner text
t = t.replace(/\\text\{([^}]*)\}/g, "$1");
// Arrows and operators — keep trailing space (→ B, × 3 etc.)
t = t.replace(/\\(?:rightarrow|to)\b/g, "→");
t = t.replace(/\\Rightarrow\b/g, "⟹");
t = t.replace(/\\(?:leftarrow)\b/g, "←");
t = t.replace(/\\cdot\b/g, "·");
t = t.replace(/\\times\b/g, "×");
t = t.replace(/\\pm\b/g, "±");
t = t.replace(/\\infty\b/g, "∞");
// Uppercase Greek used as thermodynamic prefix symbols (ΔH, ΔG, ΔS, ΣF …)
// These appear as "\Delta H" in LaTeX — the space is a LaTeX artifact, not prose space.
// Consume optional trailing space so output is "ΔH" not "Δ H".
t = t.replace(/\\Delta\b\s*/g, "Δ");
t = t.replace(/\\Gamma\b\s*/g, "Γ");
t = t.replace(/\\Sigma\b\s*/g, "Σ");
// Lowercase Greek — keep trailing space (λ = 500, not λ=500)
t = t.replace(/\\alpha\b/g, "α");
t = t.replace(/\\beta\b/g, "β");
t = t.replace(/\\gamma\b/g, "γ");
t = t.replace(/\\lambda\b/g, "λ");
t = t.replace(/\\mu\b/g, "μ");
t = t.replace(/\\pi\b/g, "π");
t = t.replace(/\\theta\b/g, "θ");
t = t.replace(/\\omega\b/g, "ω");
t = t.replace(/\\sigma\b/g, "σ");
// Subscript: _{12} or _2
t = t.replace(/\_\{([0-9+\-n]+)\}/g, (_m, d: string) => toSubscript(d));
t = t.replace(/\_([0-9])/g, (_m, d: string) => toSubscript(d));
// Superscript: ^{2} or ^2
t = t.replace(/\^\{([0-9+\-n]+)\}/g, (_m, d: string) => toSuperscript(d));
t = t.replace(/\^([0-9+\-n])/g, (_m, d: string) => toSuperscript(d));
// Strip remaining bare backslash-commands we didn't handle
t = t.replace(/\\[a-zA-Z]+\b/g, "");
// Strip leftover braces
t = t.replace(/[{}]/g, "");
return t.trim();
}