diff --git a/backend/src/agents/main_agent/core/system_prompt_builder.py b/backend/src/agents/main_agent/core/system_prompt_builder.py index f2e92828..93ec1aa8 100644 --- a/backend/src/agents/main_agent/core/system_prompt_builder.py +++ b/backend/src/agents/main_agent/core/system_prompt_builder.py @@ -80,13 +80,10 @@ RESPONSE GUIDELINES: - Respond using markdown. - You can ONLY use tools that are explicitly provided to you in each conversation -- When approriate, you may use KaTeX to render mathematical equations. -- KaTeX treats $ as a math delimiter, so in your own chat replies write other - uses of $ as the HTML entity $. This applies ONLY to the markdown you - send to the user. Never use the entity inside a file you generate, inside - code, or inside a tool argument -- a spreadsheet cell or slide holding - "$100K" is simply wrong, and it stays wrong when the user opens the file. - There, write a plain $. +- When appropriate, you may use KaTeX to render mathematical equations: + $...$ or \(...\) for inline math, $$...$$ or \[...\] for display math. + Write currency as a plain $ -- "$100K" renders correctly on its own and + needs no escaping or HTML entity, in chat or in a file you generate. - When the user asks for a diagram or chart, you may use Mermaid to render it. - Available tools may change throughout the conversation based on user preferences - When multiple tools are available, select and use the most appropriate combination in the optimal order to fulfill the user's request diff --git a/backend/tests/agents/main_agent/core/test_system_prompt_builder.py b/backend/tests/agents/main_agent/core/test_system_prompt_builder.py index cb579b10..f56e9859 100644 --- a/backend/tests/agents/main_agent/core/test_system_prompt_builder.py +++ b/backend/tests/agents/main_agent/core/test_system_prompt_builder.py @@ -159,3 +159,28 @@ def test_build_without_date_includes_floor_and_user_prompt(self): assert result.startswith(PLATFORM_SAFETY_FLOOR) assert user_prompt in result + + +# --------------------------------------------------------------------------- +# KaTeX guidance: the SPA does not treat a bare "$" as a math delimiter +# --------------------------------------------------------------------------- +class TestKatexGuidance: + """The prompt must not resurrect the HTML-entity workaround for "$". + + The prompt once told the model to write other uses of "$" as "$". + That never worked: marked emits the entity into innerHTML, the browser + decodes it to a literal "$" in the text node, and KaTeX walks the DOM + afterwards -- so the entity form broke identically. It did, however, leak + the 9-character string "$100K" into generated .pptx/.xlsx cells. The + real fix is in the SPA (see katex-delimiters.ts), which drops the bare + "$...$" delimiter, so the model should write currency as a plain "$". + """ + + def test_does_not_tell_the_model_to_escape_dollar_signs(self): + assert "$" not in DEFAULT_SYSTEM_PROMPT + + def test_names_the_supported_inline_math_delimiters(self): + assert r"$...$ or \(...\) for inline math" in DEFAULT_SYSTEM_PROMPT + + def test_still_offers_katex_for_equations(self): + assert "KaTeX" in DEFAULT_SYSTEM_PROMPT diff --git a/frontend/ai.client/src/app/app.config.ts b/frontend/ai.client/src/app/app.config.ts index f68e9f75..ebcfe68d 100644 --- a/frontend/ai.client/src/app/app.config.ts +++ b/frontend/ai.client/src/app/app.config.ts @@ -14,6 +14,7 @@ import { AnnouncementModalService } from './services/announcements/announcement- import { ConfigService } from './services/config.service'; import { durableDownloadUrlFromHref } from './shared/utils/file-download-url'; import { installLazyMermaid } from './shared/utils/lazy-mermaid'; +import { installKatexMathExtensions } from './shared/utils/katex-math-markdown'; function markedOptionsFactory(config: ConfigService): MarkedOptions { const renderer = new MarkedRenderer(); @@ -88,5 +89,10 @@ export const appConfig: ApplicationConfig = { // markdown renders lets the real 3.57 MB library stay in a lazy chunk that // is only fetched when a message actually contains a diagram. provideAppInitializer(() => { installLazyMermaid(); }), + + // marked reads `\(` as an escaped paren and drops the backslash, so + // LaTeX's inline-math delimiters never reached KaTeX. These tokenizers + // claim the span first and pass the delimiters through verbatim. + provideAppInitializer(() => { installKatexMathExtensions(); }), ] }; diff --git a/frontend/ai.client/src/app/session/components/message-list/components/streaming-text.component.spec.ts b/frontend/ai.client/src/app/session/components/message-list/components/streaming-text.component.spec.ts index 105e2765..8562b4cf 100644 --- a/frontend/ai.client/src/app/session/components/message-list/components/streaming-text.component.spec.ts +++ b/frontend/ai.client/src/app/session/components/message-list/components/streaming-text.component.spec.ts @@ -1,7 +1,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { describe, it, expect, beforeEach } from 'vitest'; -import { provideMarkdown, MarkdownService } from 'ngx-markdown'; +import { By } from '@angular/platform-browser'; +import { provideMarkdown, MarkdownComponent, MarkdownService } from 'ngx-markdown'; import { StreamingTextComponent } from './streaming-text.component'; +import { KATEX_OPTIONS } from '../../../../shared/utils/katex-delimiters'; describe('StreamingTextComponent', () => { let fixture: ComponentFixture; @@ -68,4 +70,16 @@ describe('StreamingTextComponent', () => { expect(component.displayedText()).toBe('Partial answer, now complete.'); }); + + it('hands the markdown component explicit KaTeX delimiters', () => { + // Left unbound, ngx-markdown falls back to its own DEFAULT_KATEX_OPTIONS, + // which pair bare `$…$` and swallow the prose between two currency + // amounts. The binding is the whole fix — assert it actually arrives. + fixture.componentRef.setInput('text', 'Q1 ($100K), Q2 ($115K)'); + fixture.componentRef.setInput('isStreaming', false); + fixture.detectChanges(); + + const markdown = fixture.debugElement.query(By.directive(MarkdownComponent)); + expect(markdown.componentInstance.katexOptions).toBe(KATEX_OPTIONS); + }); }); diff --git a/frontend/ai.client/src/app/session/components/message-list/components/streaming-text.component.ts b/frontend/ai.client/src/app/session/components/message-list/components/streaming-text.component.ts index 7f8eaa53..fbd3c225 100644 --- a/frontend/ai.client/src/app/session/components/message-list/components/streaming-text.component.ts +++ b/frontend/ai.client/src/app/session/components/message-list/components/streaming-text.component.ts @@ -11,6 +11,7 @@ import { import { isPlatformBrowser } from '@angular/common'; import { MarkdownComponent } from 'ngx-markdown'; import { CodeBlockClipboardButtonComponent } from './code-block-clipboard-button.component'; +import { KATEX_OPTIONS } from '../../../../shared/utils/katex-delimiters'; /** * StreamingTextComponent provides smooth character-by-character typing animation @@ -32,6 +33,7 @@ import { CodeBlockClipboardButtonComponent } from './code-block-clipboard-button [clipboardButtonComponent]="ClipboardButton" mermaid katex + [katexOptions]="katexOptions" [data]="displayedText()" > `, @@ -47,6 +49,13 @@ export class StreamingTextComponent implements OnDestroy { protected readonly ClipboardButton = CodeBlockClipboardButtonComponent; + /** + * Explicit KaTeX delimiters. Without this binding ngx-markdown supplies its + * own defaults, which pair bare `$…$` and mangle any line carrying two + * currency amounts. See `katex-delimiters.ts`. + */ + protected readonly katexOptions = KATEX_OPTIONS; + /** The full text content to display */ text = input.required(); diff --git a/frontend/ai.client/src/app/session/components/message-list/components/streaming-text.katex.spec.ts b/frontend/ai.client/src/app/session/components/message-list/components/streaming-text.katex.spec.ts new file mode 100644 index 00000000..aabf5701 --- /dev/null +++ b/frontend/ai.client/src/app/session/components/message-list/components/streaming-text.katex.spec.ts @@ -0,0 +1,189 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { describe, it, expect, beforeEach, beforeAll, afterAll } from 'vitest'; +import { provideMarkdown } from 'ngx-markdown'; +import katex from 'katex'; +import renderMathInElement from 'katex/contrib/auto-render'; +import ClipboardJS from 'clipboard'; +import { StreamingTextComponent } from './streaming-text.component'; +import { installLazyMermaid } from '../../../../shared/utils/lazy-mermaid'; +import { installKatexMathExtensions } from '../../../../shared/utils/katex-math-markdown'; + +/** + * End-to-end math rendering through the real pipeline: marked parses the + * markdown, Angular's sanitizer cleans it, then KaTeX's `renderMathInElement` + * walks the resulting DOM. Nothing is stubbed — `katex.min.js` and + * `auto-render.min.js` are in angular.json's `scripts`, so they are on the + * global scope here exactly as they are in the browser. + * + * Regression under test: ngx-markdown's DEFAULT_KATEX_OPTIONS pair bare + * `$…$`, and `renderMathInElement` pairs delimiters positionally within a + * text node. Two currency amounts on one line therefore became an inline + * formula that swallowed the prose between them. + */ +describe('StreamingTextComponent KaTeX rendering', () => { + let fixture: ComponentFixture; + + // In the browser these arrive as globals from angular.json's `scripts`, + // which the test build emits but does not evaluate. Publish them the same + // way here — ngx-markdown reads them off the global scope — and take them + // back down afterwards so no other spec file inherits them. + const globals = globalThis as Record; + beforeAll(() => { + globals['katex'] = katex; + globals['renderMathInElement'] = renderMathInElement; + // `clipboard` and `mermaid` are the template's other ngx-markdown + // plugins; both throw on a missing global even for markdown that uses + // neither. angular.json's `scripts` supplies ClipboardJS in the browser. + globals['ClipboardJS'] = ClipboardJS; + // The template also carries `mermaid`, whose plugin throws on a missing + // global even for markdown with no diagram in it. app.config installs the + // same stand-in at bootstrap. + installLazyMermaid(); + // app.config registers these at bootstrap; they keep `\(…\)` from being + // eaten by marked's escape rule before KaTeX sees it. + installKatexMathExtensions(); + }); + afterAll(() => { + delete globals['katex']; + delete globals['renderMathInElement']; + delete globals['ClipboardJS']; + delete globals['mermaid']; + }); + + /** Render `markdown` as a finished (non-streaming) assistant message. */ + async function render(markdown: string): Promise { + fixture.componentRef.setInput('text', markdown); + fixture.componentRef.setInput('isStreaming', false); + fixture.detectChanges(); + await fixture.whenStable(); + return fixture.nativeElement as HTMLElement; + } + + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [StreamingTextComponent], + providers: [provideMarkdown()], + }).compileComponents(); + + fixture = TestBed.createComponent(StreamingTextComponent); + }); + + it('has KaTeX available, so the assertions below are meaningful', () => { + // Without this guard a missing global would make every "no math rendered" + // expectation below pass for the wrong reason. + expect(typeof (globalThis as Record)['katex']).not.toBe('undefined'); + expect(typeof (globalThis as Record)['renderMathInElement']).not.toBe( + 'undefined', + ); + }); + + it('leaves a sentence of currency amounts intact', async () => { + // The reported conversation, verbatim. Previously rendered as + // "Q1 (100K),Q2(115K), total ($215K)" — the prose vanished into a formula. + const el = await render('A table slide showing Q1 ($100K), Q2 ($115K), and the total ($215K)'); + + expect(el.querySelectorAll('.katex')).toHaveLength(0); + expect(el.textContent).toContain('Q1 ($100K), Q2 ($115K), and the total ($215K)'); + }); + + it('leaves a markdown table of dollar amounts intact', async () => { + const el = await render( + ['| Term | Tuition |', '| --- | --- |', '| Fall | $4,500 |', '| Spring | $9,000 |'].join( + '\n', + ), + ); + + expect(el.querySelectorAll('.katex')).toHaveLength(0); + expect(el.textContent).toContain('$4,500'); + expect(el.textContent).toContain('$9,000'); + }); + + it('renders inline math written as \\(...\\)', async () => { + const el = await render('Let \\(x^2 + y^2 = r^2\\) hold.'); + + expect(el.querySelectorAll('.katex').length).toBeGreaterThan(0); + // Rendered inline, inside the sentence — not lifted into its own block. + expect(el.querySelectorAll('.katex-display')).toHaveLength(0); + expect(el.textContent).toContain('Let '); + expect(el.textContent).toContain(' hold.'); + }); + + it('renders display math written as \\[...\\]', async () => { + const el = await render('\\[a^2 + b^2 = c^2\\]'); + + expect(el.querySelectorAll('.katex').length).toBeGreaterThan(0); + }); + + it('leaves math delimiters inside a code span alone', async () => { + // The tokenizers must not reach into code. KaTeX already skips , + // so a rewrite here would corrupt the displayed source instead. + const el = await render('Write `\\(x^2\\)` for inline math.'); + + expect(el.querySelectorAll('.katex')).toHaveLength(0); + expect(el.querySelector('code')?.textContent).toBe('\\(x^2\\)'); + }); + + it('renders display math written as $$...$$', async () => { + const el = await render('$$\\int_0^1 x^2 dx$$'); + + expect(el.querySelectorAll('.katex').length).toBeGreaterThan(0); + }); + + it('renders a matrix, whose \\\\ row breaks markdown would otherwise collapse', async () => { + const el = await render('$$\\begin{pmatrix} a \\\\ b \\end{pmatrix}$$'); + + expect(el.querySelectorAll('.katex').length).toBeGreaterThan(0); + // A collapsed `\\` leaves KaTeX an unknown control sequence, which it + // renders in its error colour rather than as a matrix. + expect(el.querySelector('.katex-error')).toBeNull(); + }); + + it('renders display math containing asterisks', async () => { + // Emphasis used to eat the asterisks and split the text node, leaving the + // `$$` unpaired so nothing rendered at all. + const el = await render('$$a*b*c$$'); + + expect(el.querySelectorAll('.katex').length).toBeGreaterThan(0); + expect(el.querySelector('em')).toBeNull(); + }); + + it('renders a bare \\begin{align} block', async () => { + const el = await render('\\begin{align} a &= b \\\\ c &= d \\end{align}'); + + expect(el.querySelectorAll('.katex').length).toBeGreaterThan(0); + expect(el.querySelector('.katex-error')).toBeNull(); + }); + + it('renders inline math the model wrote as $...$', async () => { + // Measured from a live turn: the models write `$...$` for inline math + // whatever the system prompt asks for, so it has to render. + const el = await render("Euler's identity states that $e^{i\\pi} + 1 = 0$, elegantly."); + + expect(el.querySelectorAll('.katex').length).toBe(1); + expect(el.querySelectorAll('.katex-display')).toHaveLength(0); + expect(el.textContent).toContain('elegantly.'); + }); + + it('tells currency and inline math apart in the same sentence', async () => { + const el = await render('Revenue rose from $1M to $2M, i.e. $r = 2$.'); + + expect(el.querySelectorAll('.katex').length).toBe(1); + expect(el.textContent).toContain('$1M'); + expect(el.textContent).toContain('$2M'); + }); + + it('does not treat an HTML-entity dollar sign as escaped', async () => { + // Documents why the old system-prompt rule was removed rather than kept as + // a belt-and-braces measure: marked passes `$` through to innerHTML, + // the browser decodes it to a literal `$` in the text node, and KaTeX + // walks the DOM afterwards. The entity is indistinguishable from a plain + // `$` by the time math rendering happens — it only ever leaked into + // generated files. With the delimiter gone, both forms are now safe. + const el = await render('Q1 ($100K), Q2 ($115K)'); + + expect(el.querySelectorAll('.katex')).toHaveLength(0); + expect(el.textContent).toContain('$100K'); + expect(el.textContent).not.toContain('$'); + }); +}); diff --git a/frontend/ai.client/src/app/shared/utils/katex-delimiters.spec.ts b/frontend/ai.client/src/app/shared/utils/katex-delimiters.spec.ts new file mode 100644 index 00000000..ffa09126 --- /dev/null +++ b/frontend/ai.client/src/app/shared/utils/katex-delimiters.spec.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { KATEX_DELIMITERS, KATEX_OPTIONS } from './katex-delimiters'; + +/** + * Configuration guards. The rendering behaviour these produce is covered + * end-to-end, against the real KaTeX, in + * `session/components/message-list/components/streaming-text.katex.spec.ts`. + */ +describe('KATEX_DELIMITERS', () => { + it('does not pair bare dollar signs', () => { + // The whole point of the file: ngx-markdown's own DEFAULT_KATEX_OPTIONS + // add `{ left: '$', right: '$' }`, which KaTeX upstream deliberately + // omits because it mangles currency in prose. + expect(KATEX_DELIMITERS).not.toContainEqual( + expect.objectContaining({ left: '$', right: '$' }), + ); + }); + + it('keeps the delimiters that do work', () => { + const pairs = (KATEX_DELIMITERS ?? []).map(({ left, right }) => `${left}${right}`); + + expect(pairs).toContain('$$$$'); + expect(pairs).toContain('\\(\\)'); + expect(pairs).toContain('\\[\\]'); + expect(pairs).toContain('\\begin{align}\\end{align}'); + }); + + it('keeps throwOnError off so a half-streamed formula cannot abort the pass', () => { + // Every partially-arrived formula is malformed while the typewriter runs. + expect(KATEX_OPTIONS.throwOnError).toBe(false); + }); + + it('passes the delimiter list through as its options', () => { + expect(KATEX_OPTIONS.delimiters).toBe(KATEX_DELIMITERS); + }); +}); diff --git a/frontend/ai.client/src/app/shared/utils/katex-delimiters.ts b/frontend/ai.client/src/app/shared/utils/katex-delimiters.ts new file mode 100644 index 00000000..d1df4170 --- /dev/null +++ b/frontend/ai.client/src/app/shared/utils/katex-delimiters.ts @@ -0,0 +1,58 @@ +import type { KatexOptions } from 'ngx-markdown'; + +/** + * Delimiters handed to KaTeX's `renderMathInElement` for assistant markdown. + * + * This list exists to REMOVE one entry. ngx-markdown's own + * `DEFAULT_KATEX_OPTIONS` adds `{ left: '$', right: '$' }`, which KaTeX + * upstream deliberately leaves out of its defaults — the commented-out line in + * `katex/dist/contrib/auto-render.js` says why: + * + * // LaTeX uses $…$, but it ruins the display of normal `$` in text + * + * It ruins it because `renderMathInElement` walks text nodes and pairs dollar + * signs positionally. Any two currency amounts on one line become a delimiter + * pair and everything between them is swallowed into math mode, so + * + * Q1 ($100K), Q2 ($115K), total ($215K) + * + * rendered as `Q1 (100K),Q2(115K), total ($215K)` — the sentence, not just the + * numbers. Currency in prose is far more common here than inline math, and the + * failure is destructive rather than cosmetic, so `$…$` goes. + * + * Inline `$…$` that a model writes still renders, but it is resolved one layer + * earlier: `katex-math-markdown.ts` decides, with surrounding context that + * KaTeX does not have here, whether a given `$` opens math or precedes an + * amount, and rewrites only the former to `\(…\)`. Keeping bare `$` out of + * THIS list is what makes that safe — KaTeX can never re-pair the dollars it + * sees in the DOM and undo that decision. + * + * The same file is also why `\(…\)` and `\[…\]` appear here at all: marked + * reads `\(` as an escaped paren, so without that protection these are not an + * alternative to `$…$`, they are nothing at all. + * + * Note that escaping the dollar sign is NOT an alternative fix. A `$` the + * model writes is decoded to a literal `$` by the browser when marked's output + * is assigned to `innerHTML`, which happens BEFORE KaTeX walks the DOM — the + * entity form breaks identically. Verified against the app's own KaTeX build. + */ +export const KATEX_DELIMITERS: KatexOptions['delimiters'] = [ + { left: '$$', right: '$$', display: true }, + { left: '\\(', right: '\\)', display: false }, + { left: '\\[', right: '\\]', display: true }, + { left: '\\begin{equation}', right: '\\end{equation}', display: true }, + { left: '\\begin{align}', right: '\\end{align}', display: true }, + { left: '\\begin{alignat}', right: '\\end{alignat}', display: true }, + { left: '\\begin{gather}', right: '\\end{gather}', display: true }, + { left: '\\begin{CD}', right: '\\end{CD}', display: true }, +]; + +/** + * KaTeX options for assistant markdown. `throwOnError` is off so a malformed + * expression renders as red source text instead of aborting the whole pass — + * during streaming, every partially-arrived formula is malformed. + */ +export const KATEX_OPTIONS: KatexOptions = { + delimiters: KATEX_DELIMITERS, + throwOnError: false, +}; diff --git a/frontend/ai.client/src/app/shared/utils/katex-math-markdown.spec.ts b/frontend/ai.client/src/app/shared/utils/katex-math-markdown.spec.ts new file mode 100644 index 00000000..02af0675 --- /dev/null +++ b/frontend/ai.client/src/app/shared/utils/katex-math-markdown.spec.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { marked } from 'marked'; +import { installKatexMathExtensions } from './katex-math-markdown'; + +describe('installKatexMathExtensions', () => { + beforeAll(() => { + installKatexMathExtensions(); + }); + + it('preserves the backslashes in \\(...\\) that marked would otherwise eat', () => { + // Unpatched, marked reads `\(` as an escaped paren and emits + // `

Let (x^2) hold.

` — no delimiter left for KaTeX to match. + expect(marked.parse('Let \\(x^2\\) hold.')).toContain('\\(x^2\\)'); + }); + + it('preserves the backslashes in \\[...\\]', () => { + expect(marked.parse('\\[a^2 + b^2 = c^2\\]')).toContain('\\[a^2 + b^2 = c^2\\]'); + }); + + it('escapes the body so the sanitizer and innerHTML cannot alter it', () => { + // `&` matters: AMS alignment environments are built from it, and it must + // survive as a literal `&` in the text node KaTeX reads. + const html = marked.parse('\\(a &= b\\)') as string; + + expect(html).toContain('&='); + + const el = document.createElement('div'); + el.innerHTML = html; + expect(el.textContent).toContain('\\(a &= b\\)'); + }); + + it('leaves currency alone', () => { + expect(marked.parse('Q1 ($100K), Q2 ($115K)')).toContain('Q1 ($100K), Q2 ($115K)'); + }); + + it('does not reach into a code span', () => { + const html = marked.parse('Write `\\(x^2\\)` for inline math.') as string; + + expect(html).toContain('\\(x^2\\)'); + }); + + it('does not reach into a fenced code block', () => { + const html = marked.parse(['```python', 'print("\\\\(not math\\\\)")', '```'].join('\n')) as string; + + expect(html).toContain('\\\\(not math\\\\)'); + }); + + it('leaves an unclosed delimiter as ordinary markdown', () => { + // A half-streamed formula must not swallow the rest of the message. + const html = marked.parse('Let \\(x^2 and then some more prose.') as string; + + expect(html).toContain('and then some more prose.'); + }); +}); + +describe('display math contents survive markdown', () => { + beforeAll(() => { + installKatexMathExtensions(); + }); + + it('keeps asterisks in $$...$$ instead of turning them into emphasis', () => { + // Unpatched: `$$abc$$`. The asterisks are deleted AND the text + // node is split in three, so KaTeX's `splitAtDelimiters` — which works + // within one text node — no longer pairs the `$$` and renders nothing. + const html = marked.parse('$$a*b*c$$') as string; + + expect(html).not.toContain(''); + expect(html).toContain('$$a*b*c$$'); + }); + + it('keeps \\\\ row breaks in $$...$$ instead of collapsing them', () => { + // Unpatched: `\\` becomes `\`, which breaks every matrix and every + // multi-row alignment. + const html = marked.parse('$$\\begin{pmatrix} a \\\\ b \\end{pmatrix}$$') as string; + + expect(html).toContain('a \\\\ b'); + }); + + it('keeps \\\\ row breaks in a bare \\begin{align} block', () => { + const html = marked.parse('\\begin{align} a &= b \\\\ c &= d \\end{align}') as string; + + expect(html).toContain('\\\\'); + expect(html).toContain('\\begin{align}'); + expect(html).toContain('\\end{align}'); + }); + + it('requires \\end to name the same environment as \\begin', () => { + // The backreference keeps the rule from claiming an arbitrary span + // between two unrelated environment markers. A mismatch is simply not + // math, so it falls through to ordinary markdown — which is observable + // because the `\\` is then collapsed rather than protected. + const matched = marked.parse('\\begin{align} a \\\\ b \\end{align}') as string; + const mismatched = marked.parse('\\begin{align} a \\\\ b \\end{gather}') as string; + + expect(matched).toContain('a \\\\ b'); + expect(mismatched).toContain('a \\ b'); + expect(mismatched).not.toContain('a \\\\ b'); + }); + + it('leaves currency untouched by the $$ rule', () => { + expect(marked.parse('Costs $5 and $10.')).toContain('Costs $5 and $10.'); + }); + + it('does not let one $$ block swallow the next', () => { + const html = marked.parse('$$a$$ and then $$b$$') as string; + + expect(html).toContain('$$a$$ and then $$b$$'); + }); +}); + +describe('guarded $...$ inline math', () => { + beforeAll(() => { + installKatexMathExtensions(); + }); + + /** What marked emits, with `\(…\)` marking what became math. */ + const parse = (src: string) => marked.parse(src) as string; + + it('rewrites genuine inline math to a delimiter KaTeX actually has', () => { + // Never back to `$…$`: bare `$` is deliberately absent from + // KATEX_DELIMITERS, because KaTeX pairs it positionally in the DOM. + expect(parse('$ax^2 + bx + c = 0$')).toContain('\\(ax^2 + bx + c = 0\\)'); + }); + + it.each([ + ['two amounts in prose', 'Q1 ($100K), Q2 ($115K), and the total ($215K)'], + ['a range', 'Costs between $5 and $10 per seat.'], + ['a price list', 'a tutoring session costs $40 and a full package is $300.'], + ['a table row', '| Fall | $4,500 | $9,000 |'], + ['a space after the sign', 'Prices $ 5 and $ 10'], + ['non-numeric currency codes', '$USD 100 and $EUR 50'], + ])('leaves %s alone', (_label, src) => { + expect(parse(src)).not.toContain('\\('); + }); + + it('separates currency from math in one sentence', () => { + const html = parse('Revenue rose from $1M to $2M, i.e. $r = 2$.'); + + expect(html).toContain('$1M'); + expect(html).toContain('$2M'); + expect(html).toContain('\\(r = 2\\)'); + }); + + it('does not span a line break', () => { + // Without the newline bound, an unmatched `$` would reach across + // paragraphs and swallow them. + expect(parse('Costs $5\nand later $9 too.')).not.toContain('\\('); + }); + + it('does not reach into code', () => { + expect(parse('Use `$HOME` and `$PATH` here.')).not.toContain('\\('); + }); + + it('leaves $$ display math to the display rule', () => { + const html = parse('$$E = mc^2$$'); + + expect(html).toContain('$$E = mc^2$$'); + expect(html).not.toContain('\\('); + }); +}); diff --git a/frontend/ai.client/src/app/shared/utils/katex-math-markdown.ts b/frontend/ai.client/src/app/shared/utils/katex-math-markdown.ts new file mode 100644 index 00000000..9492b9ac --- /dev/null +++ b/frontend/ai.client/src/app/shared/utils/katex-math-markdown.ts @@ -0,0 +1,166 @@ +import { marked, type TokenizerAndRendererExtension } from 'marked'; + +/** + * Keeps LaTeX's `\(…\)` and `\[…\]` math delimiters intact through markdown + * parsing, so KaTeX can find them when it walks the rendered DOM. + * + * Without this they never survive. CommonMark lists `(`, `)`, `[` and `]` as + * escapable punctuation, so marked reads `\(` as "a literal paren" and drops + * the backslash: + * + * marked.parse('Let \\(x^2\\) hold.') -> '

Let (x^2) hold.

' + * + * By the time `renderMathInElement` runs there is no delimiter left to match, + * which is why `\(…\)` silently rendered as plain text in this app while + * `$$…$$` worked — a dollar sign is not escapable, so marked passes it + * through untouched. + * + * These tokenizers claim the span before marked's escape rule can, and emit + * the delimiters back verbatim with an HTML-escaped body. The output is plain + * text, so Angular's sanitizer has nothing to strip, and the browser decodes + * the entities back to their characters in the text node — leaving exactly + * what KaTeX expects, including the `&` that AMS alignment environments use. + * + * Fenced code and code spans are unaffected: block-level fences never reach an + * inline tokenizer, and marked's codespan rule consumes a span's contents + * whole without re-tokenizing them. + */ + +/** Escape only what would change the text node's meaning in innerHTML. */ +function escapeHtml(value: string): string { + return value.replace(/&/g, '&').replace(//g, '>'); +} + +function mathExtension( + name: string, + open: string, + close: string, + rule: RegExp, +): TokenizerAndRendererExtension { + return { + name, + level: 'inline', + start: (src: string) => { + const at = src.indexOf(open); + return at === -1 ? undefined : at; + }, + tokenizer(src: string) { + const match = rule.exec(src); + if (!match) return undefined; + return { type: name, raw: match[0], text: match[1] }; + }, + renderer: (token) => `${open}${escapeHtml(String(token['text']))}${close}`, + }; +} + +/** `\(x^2\)` — inline math. */ +const inlineMath = mathExtension('katexInlineMath', '\\(', '\\)', /^\\\(([\s\S]+?)\\\)/); + +/** `\[x^2\]` — display math. */ +const displayMath = mathExtension('katexDisplayMath', '\\[', '\\]', /^\\\[([\s\S]+?)\\\]/); + +/** + * `$$…$$` — display math. A dollar sign survives markdown on its own, so this + * delimiter always reached KaTeX. Its *contents* did not: + * + * `$$a*b*c$$` -> `$$abc$$` + * `$$\begin{pmatrix} a \\ b \end{…}$$` -> `$$\begin{pmatrix} a \ b \end{…}$$` + * + * Emphasis deletes the asterisks AND splits the text node in three, and + * `splitAtDelimiters` works within a single text node — so the `$$` no longer + * pair and nothing renders at all. `\\`, markdown's escaped backslash, + * collapses to one, which breaks every matrix and every multi-row alignment. + * Claiming the span keeps the body out of marked's inline rules entirely. + */ +const dollarDisplayMath = mathExtension('katexDollarMath', '$$', '$$', /^\$\$([\s\S]+?)\$\$/); + +/** + * `\begin{align}…\end{align}` and friends, used bare rather than wrapped in a + * `$$`. The delimiters themselves survive — a backslash before a letter is not + * a markdown escape — but the body has the same `\\` problem as above. + */ +const environmentMath: TokenizerAndRendererExtension = { + name: 'katexEnvironmentMath', + level: 'inline', + start: (src: string) => { + const at = src.indexOf('\\begin{'); + return at === -1 ? undefined : at; + }, + tokenizer(src: string) { + const match = /^\\begin\{([A-Za-z]+\*?)\}([\s\S]+?)\\end\{\1\}/.exec(src); + if (!match) return undefined; + return { type: 'katexEnvironmentMath', raw: match[0], environment: match[1], text: match[2] }; + }, + renderer: (token) => { + const environment = String(token['environment']); + return `\\begin{${environment}}${escapeHtml(String(token['text']))}\\end{${environment}}`; + }, +}; + +/** + * `$x^2$` — inline math, but only where it cannot be currency. + * + * Bare `$…$` is deliberately NOT in `KATEX_DELIMITERS`, because KaTeX pairs + * delimiters positionally inside a text node and would swallow the prose + * between two dollar amounts. But the models keep writing it: `$…$` is the + * dominant LaTeX convention, and a system-prompt line asking for `\(…\)` + * does not reliably override that (measured — Haiku 4.5 wrote `$e^{i\pi} + + * 1 = 0$` on the very turn the new instruction was live). Dropping it + * outright therefore means inline math usually renders as literal source. + * + * So this rule recognises inline math here, where there is enough context to + * tell it from money, and rewrites it to `\(…\)` — a delimiter KaTeX does + * have. Currency is never rewritten, and because bare `$` never enters the + * delimiter list, KaTeX's positional pairing cannot resurrect the bug no + * matter what this rule does. + * + * The test is Pandoc's, plus a digit check for currency: + * - the opening `$` is not followed by whitespace or a digit (`$40`) + * - the closing `$` is not preceded by whitespace (`$ 5 … $`) + * - the closing `$` is not followed by a digit + * - the span does not cross a line break + * + * "Revenue rose from $1M to $2M, i.e. $r = 2$." resolves exactly right: + * both amounts are skipped and only `$r = 2$` becomes math. + */ +const DOLLAR_INLINE_MATH = /^\$(?![\s\d])([^\n$]+?)\$(?!\d)/; + +const guardedInlineMath: TokenizerAndRendererExtension = { + name: 'katexGuardedInlineMath', + level: 'inline', + start: (src: string) => { + const at = src.indexOf('$'); + return at === -1 ? undefined : at; + }, + tokenizer(src: string) { + const match = DOLLAR_INLINE_MATH.exec(src); + // A trailing space before the closing `$` means this is prose, not math. + if (!match || /\s$/.test(match[1])) return undefined; + return { type: 'katexGuardedInlineMath', raw: match[0], text: match[1] }; + }, + // Emitted as `\(…\)` rather than `$…$`: KaTeX renders the former and never + // pairs the latter, which is what keeps currency safe. + renderer: (token) => `\\(${escapeHtml(String(token['text']))}\\)`, +}; + +/** + * Order matters. `$$` is tried before the guarded single-`$` rule so that + * display math is never mistaken for two inline spans, and before the + * environment rule so `$$\begin{align}…\end{align}$$` is claimed by the outer + * delimiter — the one KaTeX will render from. + */ +export const KATEX_MARKED_EXTENSIONS = [ + dollarDisplayMath, + inlineMath, + displayMath, + environmentMath, + guardedInlineMath, +]; + +/** + * Registers the extensions on the module-level `marked` instance, which is + * the one ngx-markdown parses with. Called once at bootstrap. + */ +export function installKatexMathExtensions(): void { + marked.use({ extensions: KATEX_MARKED_EXTENSIONS }); +} diff --git a/frontend/ai.client/src/types/katex-auto-render.d.ts b/frontend/ai.client/src/types/katex-auto-render.d.ts new file mode 100644 index 00000000..3e80bb0b --- /dev/null +++ b/frontend/ai.client/src/types/katex-auto-render.d.ts @@ -0,0 +1,27 @@ +/** + * KaTeX ships `contrib/auto-render` as JavaScript with no bundled typings. + * In the browser it reaches the app as the `renderMathInElement` global via + * angular.json's `scripts`, so nothing imports it in application code — only + * the KaTeX render specs, which publish it onto the global scope themselves. + */ +declare module 'katex/contrib/auto-render' { + import type { KatexOptions } from 'katex'; + + export interface AutoRenderDelimiter { + left: string; + right: string; + display: boolean; + } + + export interface AutoRenderOptions extends KatexOptions { + delimiters?: AutoRenderDelimiter[]; + ignoredTags?: string[]; + ignoredClasses?: string[]; + preProcess?: (math: string) => string; + } + + export default function renderMathInElement( + element: HTMLElement, + options?: AutoRenderOptions, + ): void; +}