diff --git a/src/features/workspaces/components/ai-chat/normalize-llm-markdown.ts b/src/features/workspaces/components/ai-chat/normalize-llm-markdown.ts index 61cb5032..a18bbeea 100644 --- a/src/features/workspaces/components/ai-chat/normalize-llm-markdown.ts +++ b/src/features/workspaces/components/ai-chat/normalize-llm-markdown.ts @@ -20,6 +20,8 @@ // currency inspection is more careful than theirs so `$6$` / `$5.99$` // numeric inline math is preserved. +import { mathLikeInlineBody } from "#/lib/math-like-text"; + // Inline bracket math: \(x\) or \\(x\\) (JSON-double-escaped from tool outputs). // Single-line only — remark-math treats inline math the same way. const BRACKET_INLINE = /\\{1,2}\(([^\n]+?)\\{1,2}\)/g; @@ -58,44 +60,6 @@ function restoreCodeRuns(text: string, preserved: string[]): string { return text.replace(RESTORE, (_, i: string) => preserved[Number(i)] ?? ""); } -/** - * True when the body between two adjacent `$` marks looks like a real math - * expression the model intended. Used to decide whether a currency-signature - * `$` should be escaped or left as a math opener. - * - * | Body | Verdict | Reason | - * | ----------------------------------- | ------- | ----------------------------- | - * | `6`, `5.99`, `1,299` | math | bare numeric literal | - * | `x^2 + 5x`, `n = 3` | math | algebra with short variables | - * | `\frac{a}{b}`, `\sum_{i=1}^n` | math | LaTeX macro present | - * | `5 total`, `today, or `, `5 and 6` | prose | 3+ letter English word | - * | `45 + `, `= 60`, `1,299–` | prose | dangling operator (cut off) | - * | `**bold**`, blank lines, >200 chars | prose | markdown structural / too long | - */ -function mathLikeInlineBody(body: string): boolean { - const t = body.trim(); - if (!t) return false; - if (t.length > 200) return false; - // Structural markdown characters bleeding through mean the `$…$` pair - // wrapped around formatted prose, not math. - if (/\*\*|__|~~|\n\n/.test(t)) return false; - // LaTeX command — definitely math. - if (/\\[a-zA-Z]/.test(t)) return true; - // Dangling operator at start or end signals cut-off currency arithmetic - // ("45 + " came from `$45 + $3.60`, not a complete math expression). - if (/[+\-−–×÷=/*→←]\s*$/.test(t)) return false; - if (/^\s*[+\-−–×÷=/*→←]/.test(t)) return false; - // Currency codes should not count as prose words for this test. - const withoutCcy = t.replace(/\b(?:USD|EUR|GBP|JPY|CAD|AUD|CHF|CNY|INR|k|M|B)\b/gi, ""); - // A word of 3+ letters is prose ("today", "and", "total") — not math. - if (/[a-zA-Z]{3,}/.test(withoutCcy)) return false; - // Pure numeric with math operators. - if (/^[\s\d.,+\-*/=×÷^_(){}[\]<>|:;'"\\]+$/.test(t)) return true; - // Numeric with 1-2 letter algebraic variables. - if (/^[\sa-zA-Z\d.,+\-*/=×÷^_(){}[\]<>|:;'"\\]+$/.test(t)) return true; - return false; -} - // Rewrite the alternative delimiter dialects some models emit into the // `$…$` / `$$…$$` form remark-math parses. export function rewriteAlternativeDelimiters(text: string): string { diff --git a/src/features/workspaces/documents/document-ai-html.test.ts b/src/features/workspaces/documents/document-ai-html.test.ts index 06c3fc0e..1f83f4bc 100644 --- a/src/features/workspaces/documents/document-ai-html.test.ts +++ b/src/features/workspaces/documents/document-ai-html.test.ts @@ -40,6 +40,46 @@ describe("document AI HTML", () => { expect(html).not.toContain(""); }); + it("rescues dollar-delimited math the model writes as prose", async () => { + // Math renders only as a math node, never as `$…$`. The model reaches for + // dollars by habit, so repair them instead of storing literal text. + const html = await serializeTiptapDocumentToAiHtml( + ensureTiptapDocumentBlockIds( + parseDocumentAiHtml( + "

The energy is $E = mc^2$ where $c$ is the speed of light.

$$\\int_0^1 x^2 dx$$

", + ), + ).document, + ); + + expect(html).toContain('data-type="inline-math"'); + expect(html).toContain('data-latex="E = mc^2"'); + expect(html).toContain('data-latex="c"'); + expect(html).toContain('data-type="block-math"'); + expect(html).toContain('data-latex="\\int_0^1 x^2 dx"'); + expect(html).not.toContain("$"); + }); + + it("leaves plain money and prose dollar signs alone", async () => { + const html = await serializeTiptapDocumentToAiHtml( + ensureTiptapDocumentBlockIds(parseDocumentAiHtml("

Lunch was $5 and dinner was $10.

")) + .document, + ); + + expect(html).not.toContain("inline-math"); + expect(html).toContain("$5 and dinner was $10"); + }); + + it("keeps dollar math inside code and widgets literal", () => { + const document = parseDocumentAiHtml( + '

Write $x = 1$ in LaTeX.

const label = "$x$";
', + ); + + const json = JSON.stringify(document); + expect(json).not.toContain("inlineMath"); + expect(json).toContain("$x = 1$"); + expect(json).toContain("$x$"); + }); + it("ignores refs supplied in model-authored HTML", async () => { const html = await serializeTiptapDocumentToAiHtml( ensureTiptapDocumentBlockIds(parseDocumentAiHtml('

Hello

')) diff --git a/src/features/workspaces/documents/document-ai-html.ts b/src/features/workspaces/documents/document-ai-html.ts index 6eeafeff..57464806 100644 --- a/src/features/workspaces/documents/document-ai-html.ts +++ b/src/features/workspaces/documents/document-ai-html.ts @@ -13,6 +13,7 @@ import { tiptapDocumentAiRefAttribute, } from "#/features/workspaces/documents/tiptap-schema"; import { sha256Base64UrlText } from "#/lib/binary"; +import { mathLikeInlineBody } from "#/lib/math-like-text"; const TEXT_NODE = 3; const documentBlockIdPattern = /^b_[A-Za-z0-9_-]{12}$/; @@ -210,19 +211,21 @@ function serializeTiptapFragmentToAiHtml(fragment: Fragment) { } /** - * Rescues the two tags ProseMirror parses lossily. + * Rescues the tags and math dialects ProseMirror parses lossily. * * Everything else outside the schema is already handled by the parser: unknown * wrappers are skipped with their children kept, and `