Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions backend/src/agents/main_agent/core/system_prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions backend/tests/agents/main_agent/core/test_system_prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions frontend/ai.client/src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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(); }),
]
};
Original file line number Diff line number Diff line change
@@ -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<StreamingTextComponent>;
Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,6 +33,7 @@ import { CodeBlockClipboardButtonComponent } from './code-block-clipboard-button
[clipboardButtonComponent]="ClipboardButton"
mermaid
katex
[katexOptions]="katexOptions"
[data]="displayedText()"
></markdown>
`,
Expand All @@ -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<string>();

Expand Down
Original file line number Diff line number Diff line change
@@ -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<StreamingTextComponent>;

// 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<string, unknown>;
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<HTMLElement> {
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<string, unknown>)['katex']).not.toBe('undefined');
expect(typeof (globalThis as Record<string, unknown>)['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 <code>,
// 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 `&#36;` 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 (&#36;100K), Q2 (&#36;115K)');

expect(el.querySelectorAll('.katex')).toHaveLength(0);
expect(el.textContent).toContain('$100K');
expect(el.textContent).not.toContain('&#36;');
});
});
36 changes: 36 additions & 0 deletions frontend/ai.client/src/app/shared/utils/katex-delimiters.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading