The export styles pinned the last column of every table to one unbreakable line (width:1%; white-space:nowrap) — right for a numbers column, wrong for a two-column clinical table whose second column is the whole finding: the table grew to the width of its longest sentence and iOS then inflated the text in those cells, so the "Finding" column read in a bigger font than the "Feature" column. The pin goes, and the sheet opts out of text inflation. With citations switched off the answer is still grounded in the retrieved sources; only the markers are removed. The stripper now also removes the escaped \[1, 2\] form and [src] placeholders, which used to survive as text. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
373 lines
23 KiB
JavaScript
373 lines
23 KiB
JavaScript
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs/promises');
|
|
const path = require('node:path');
|
|
const MarkdownIt = require('markdown-it');
|
|
|
|
let modulePromise;
|
|
|
|
// The module is real ESM with no window; it finds its parser on globalThis,
|
|
// which is what window is in the browser.
|
|
globalThis.markdownit = require('markdown-it');
|
|
|
|
async function loadCitationModule() {
|
|
if (!modulePromise) {
|
|
modulePromise = fs.readFile(path.join(__dirname, '..', 'public', 'js', 'assistant', 'citations.js'), 'utf8')
|
|
.then((source) => import('data:text/javascript;charset=utf-8,' + encodeURIComponent(source)));
|
|
}
|
|
return modulePromise;
|
|
}
|
|
|
|
const sources = [
|
|
{ title: 'Nelson Textbook of Pediatrics' },
|
|
{ title: 'Pediatric Asthma Guideline' },
|
|
{ resource: 'Emergency Medicine Review' }
|
|
];
|
|
|
|
test('renders citation clusters as links to matching source cards', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const html = renderAssistantMarkdown('Give magnesium for severe exacerbation [1, 2].', sources);
|
|
assert.match(html, /href="#assistant-source-1"/);
|
|
assert.match(html, /href="#assistant-source-2"/);
|
|
assert.match(html, /title="Source 1: Nelson Textbook of Pediatrics"/);
|
|
assert.match(html, /data-source-number="1"/);
|
|
assert.match(html, /data-source-number="2"/);
|
|
assert.match(html, /<a class="assistant-cite"[^>]*>\d+<\/a> <a class="assistant-cite"[^>]*>\d+<\/a>/);
|
|
});
|
|
|
|
test('renders escaped citation tokens as source links, not display math', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const katex = { renderToString: function () { throw new Error('citation sent to KaTeX'); } };
|
|
const html = renderAssistantMarkdown('Acquired hypothyroidism is uncommon. \\[1, 2\\].', sources, { katex });
|
|
assert.match(html, /<a class="assistant-cite"[^>]*>\d+<\/a> <a class="assistant-cite"[^>]*>\d+<\/a>/);
|
|
assert.doesNotMatch(html, /katex-display/);
|
|
});
|
|
|
|
test('can render citation labels as numbers for PDF export', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const html = renderAssistantMarkdown('Give magnesium for severe exacerbation [1, 2].', sources, { citationLabel: 'number' });
|
|
assert.match(html, /<a class="assistant-cite"[^>]*>1<\/a> <a class="assistant-cite"[^>]*>2<\/a>/);
|
|
});
|
|
|
|
test('strips [src]/[source] placeholder tokens instead of inventing numbers', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const html = renderAssistantMarkdown('Give fluids [src] and review in 24h [source].', []);
|
|
assert.doesNotMatch(html, /\[src\]|\[source\]/i);
|
|
assert.match(html, /Give fluids/);
|
|
const kept = renderAssistantMarkdown('Give fluids [1] and review.', [{ number: 1, title: 'S', page: 2 }]);
|
|
assert.match(kept, /data-source-number="1"/, 'real citation numbers still render');
|
|
});
|
|
|
|
test('leaves unknown citation clusters untouched rather than guessing', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const html = renderAssistantMarkdown('Dose statement [4].', sources);
|
|
assert.match(html, /\[4\]/);
|
|
assert.doesNotMatch(html, /assistant-source-4/);
|
|
});
|
|
|
|
test('does not merge separate citations across separate claims', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const html = renderAssistantMarkdown('Use oxygen [1]. Give bronchodilator [2].', sources);
|
|
assert.match(html, /oxygen <a class="assistant-cite"[^>]*data-source-number="1"[^>]*>\d+<\/a>/);
|
|
assert.match(html, /bronchodilator <a class="assistant-cite"[^>]*data-source-number="2"[^>]*>\d+<\/a>/);
|
|
assert.doesNotMatch(html, /data-source-number="1"[^>]*>\d+<\/a><a class="assistant-cite"[^>]*data-source-number="2"/);
|
|
});
|
|
|
|
test('adjacent citations render as one run of chips, numbered by first appearance', async () => {
|
|
// They used to be sorted into ascending source order. Nothing is sorted now:
|
|
// the chip shows the display number, which is first-appearance order, so a
|
|
// reader sees 1 2 3 4 in either case — and the text underneath is untouched,
|
|
// which is what keeps a saved answer's markers meaning what they meant.
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const html = renderAssistantMarkdown('Deteriorating course [1][4][2][3].', [
|
|
{ title: 'A' }, { title: 'B' }, { title: 'C' }, { title: 'D' }
|
|
]);
|
|
assert.match(html, /data-source-number="1"[^>]*>1<\/a> <a class="assistant-cite"[^>]*data-source-number="4"[^>]*>2<\/a> <a class="assistant-cite"[^>]*data-source-number="2"[^>]*>3<\/a> <a class="assistant-cite"[^>]*data-source-number="3"[^>]*>4<\/a>/);
|
|
assert.doesNotMatch(html, /\]<span|\]\[/);
|
|
return;
|
|
assert.match(html, /data-source-number="1"[^>]*>\d+<\/a> <a class="assistant-cite"[^>]*data-source-number="2"[^>]*>\d+<\/a> <a class="assistant-cite"[^>]*data-source-number="3"[^>]*>\d+<\/a> <a class="assistant-cite"[^>]*data-source-number="4"[^>]*>\d+<\/a>/);
|
|
assert.doesNotMatch(html, /\]<span|\]\[/);
|
|
});
|
|
|
|
test('repairs a clearly adjacent trailing citation missing its closing bracket', async () => {
|
|
// "[1][4][2][3" at the very end is a bracket the model ran out of room for.
|
|
// Repaired before parsing, so the rule sees a complete cluster; numbered by
|
|
// first appearance like any other.
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const html = renderAssistantMarkdown('Deteriorating course [1][4][2][3', [
|
|
{ title: 'A' }, { title: 'B' }, { title: 'C' }, { title: 'D' }
|
|
]);
|
|
assert.match(html, /data-source-number="1"[^>]*>1<\/a> <a class="assistant-cite"[^>]*data-source-number="4"[^>]*>2<\/a> <a class="assistant-cite"[^>]*data-source-number="2"[^>]*>3<\/a> <a class="assistant-cite"[^>]*data-source-number="3"[^>]*>4<\/a>/);
|
|
assert.doesNotMatch(html, /\[3(?!\])/, 'the unterminated bracket is gone');
|
|
});
|
|
|
|
test('does not normalize adjacent citations if any source number is unknown', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const html = renderAssistantMarkdown('Unsupported claim [1][9].', sources);
|
|
assert.match(html, /\[9\]/);
|
|
assert.doesNotMatch(html, /assistant-source-9/);
|
|
});
|
|
|
|
test('escapes source titles inside citation link titles', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const html = renderAssistantMarkdown('Unsafe title [1].', [{ title: 'Bad "Title" <script>' }]);
|
|
assert.match(html, /title="Source 1: Bad "Title" <script>"/);
|
|
assert.doesNotMatch(html, /<script>/);
|
|
});
|
|
|
|
test('does not strip sentences that mention available sources', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const html = renderAssistantMarkdown('Pneumonia can cause parapneumonic effusion [1]. However, the available sources do not specifically identify adenovirus as a typical cause [2]. In summary, evidence is insufficient [3].', sources);
|
|
assert.match(html, /available sources do not specifically identify adenovirus/);
|
|
assert.match(html, /In summary, evidence is insufficient/);
|
|
});
|
|
|
|
test('normalizes inline markdown headings before rendering', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const html = renderAssistantMarkdown('Intro ### Management\n- oxygen', sources);
|
|
assert.match(html, /<h3>Management<\/h3>/);
|
|
assert.match(html, /<li>oxygen<\/li>/);
|
|
});
|
|
|
|
test('normalizes old saved assistant answer formatting', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const html = renderAssistantMarkdown('Comparison of Bronchiolitis and Asthma Management in Infants ### Bronchiolitis Management - Mainstay: Supportive care [1, 2]. - Medications: bronchodilators are not routine [2]. ### Key Differences | Aspect | Bronchiolitis | Asthma | |---|---|---| | Therapy | Supportive | SABA | --- References: [1] duplicate', sources);
|
|
assert.match(html, /<h3>Bronchiolitis Management<\/h3>/);
|
|
assert.match(html, /<li>Mainstay: Supportive care/);
|
|
assert.match(html, /Key Differences/);
|
|
});
|
|
|
|
test('does not turn citation-delimited prose into a list', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const html = renderAssistantMarkdown('Admission is indicated for: Apnoea [1, 2]- **Persistent oxygen saturation <90%** [1, 2].', sources);
|
|
assert.match(html, /<\/a>- <strong>Persistent oxygen saturation <90%<\/strong>/);
|
|
assert.doesNotMatch(html, /<ul>/);
|
|
});
|
|
|
|
test('joins a citation-only paragraph back to its claim', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const html = renderAssistantMarkdown('Acquired hypothyroidism is uncommon.\n\n[1, 2]\n\n.\n\n- Next point', sources);
|
|
assert.match(html, /uncommon\. <a class="assistant-cite"[^>]*>\d+<\/a> <a class="assistant-cite"[^>]*>\d+<\/a>\./);
|
|
assert.doesNotMatch(html, /<p>\s*<a class="assistant-cite"/);
|
|
});
|
|
|
|
test('keeps citations inline before clinical terms', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const html = renderAssistantMarkdown('Assess for lethargy [1] and dehydration [2].', sources);
|
|
assert.match(html, /lethargy <a class="assistant-cite"[^>]*>\d+<\/a> and dehydration <a class="assistant-cite"[^>]*>\d+<\/a>/);
|
|
assert.doesNotMatch(html, /<\/a><br>\s*and dehydration/);
|
|
});
|
|
|
|
test('keeps citation-delimited clinical prose inline', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const html = renderAssistantMarkdown('CT is indicated for: [1, 2, 3]- Focal neurologic deficits [1].', sources);
|
|
assert.match(html, /<\/a>- Focal neurologic deficits/);
|
|
assert.doesNotMatch(html, /<br>|<ul>/);
|
|
});
|
|
|
|
test('preserves code block contents without creating citation links inside code', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const html = renderAssistantMarkdown('```js\nconst ref = "[1]";\n```', sources);
|
|
assert.match(html, /<pre><code>const ref = "\[1\]"/);
|
|
assert.doesNotMatch(html, /assistant-source-1/);
|
|
});
|
|
|
|
test('does not repair source tables inside code blocks', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const html = renderAssistantMarkdown('```md\n| Feature | Source(s) |\n|---|---|\n| Example | 1, 2 |\n```', sources);
|
|
assert.match(html, /\| Example \| 1, 2 \|/);
|
|
assert.doesNotMatch(html, /assistant-source-1/);
|
|
});
|
|
|
|
test('does not repair model-split markdown tables', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const html = renderAssistantMarkdown('Summary Table\n| Indication for Use | Dose (IV) | Max Dose\n\n| Infusion Time | Monitoring/Precautions |\n|-----------------------------------------|----------------------------|----------|--------------|---------------------------------------|\n| Severe exacerbation unresponsive to SABA/anticholinergic | 25-75 mg/kg | 2 g\n| 20 min | BP, reflexes, respiratory status[1, 2] |', sources);
|
|
assert.doesNotMatch(html, /Max Dose Infusion Time/);
|
|
assert.match(html, /Infusion Time/);
|
|
assert.match(html, /assistant-cite/);
|
|
});
|
|
|
|
test('breaks inline numbered clinical sections onto new lines', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const html = renderAssistantMarkdown('Monitor with serial abdominal exams [1]. 2. Imaging\nImmediate abdominal X-ray for obstruction [1, 2]. 3. Laboratory Studies\nAssess electrolytes if ill-appearing [3]. 4. Surgical Consultation\nUrgent pediatric surgical consultation [1].', sources);
|
|
assert.match(html, /2\. Imaging/);
|
|
assert.match(html, /3\. Laboratory Studies/);
|
|
assert.match(html, /4\. Surgical Consultation/);
|
|
assert.doesNotMatch(html, /exams[^<]*2\. Imaging/);
|
|
assert.doesNotMatch(html, /obstruction[^<]*3\. Laboratory Studies/);
|
|
});
|
|
|
|
test('strips orphan trailing markdown emphasis markers', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const html = renderAssistantMarkdown('Summary Table\n\nAge Group\tEmpiric Antibiotics\n<1 month\tAmpicillin + cefotaxime\n**', sources);
|
|
assert.match(html, /Summary Table/);
|
|
assert.doesNotMatch(html, /\*\*/);
|
|
});
|
|
|
|
test('strips orphan trailing markdown marker after tab-separated text', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const markdownIt = new MarkdownIt({ html: false, linkify: true, typographer: true, breaks: true });
|
|
const input = 'Summary Table\nStep\tRecommendation\nBlood culture\tObtain before antibiotics\nHospitalization\tYes, for all febrile neonates <=28 days old\n**';
|
|
const html = renderAssistantMarkdown(input, sources, { markdownIt });
|
|
assert.doesNotMatch(html, /<table>/);
|
|
assert.match(html, /Hospitalization/);
|
|
assert.doesNotMatch(html, /\*\*/);
|
|
});
|
|
|
|
test('keeps summary paragraph outside tab-separated text', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const markdownIt = new MarkdownIt({ html: false, linkify: true, typographer: true, breaks: true });
|
|
const input = 'Summary Table\nStep/Indication\tRecommendation/Threshold\tCitation\nInitial assessment\tABCs and focused exam\t[1]\nActivated charcoal window\tWithin 1 hour\t[2]\nIn summary: rapidly assess ABCs and consider activated charcoal when appropriate [1, 2].';
|
|
const html = renderAssistantMarkdown(input, sources, { markdownIt });
|
|
assert.doesNotMatch(html, /<table>/);
|
|
assert.match(html, /In summary:/);
|
|
assert.doesNotMatch(html, /<td>In summary:/);
|
|
});
|
|
|
|
test('strips trailing marker after notes followed by tab-separated text', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const markdownIt = new MarkdownIt({ html: false, linkify: true, typographer: true, breaks: true });
|
|
const input = 'Additional Notes\nListeria coverage: Ampicillin should be added for infants <3 months [1].\nVancomycin: Added for resistant pneumococcus [2].\nAge Group\tEmpiric Antibiotics\n<1 month\tAmpicillin + Cefotaxime\n1-23 months\tVancomycin + Cefotaxime or Ceftriaxone\n>=24 months\tVancomycin + Cefotaxime or Ceftriaxone\n**';
|
|
const html = renderAssistantMarkdown(input, sources, { markdownIt });
|
|
assert.match(html, /Additional Notes/);
|
|
assert.doesNotMatch(html, /<table>/);
|
|
assert.doesNotMatch(html, /\*\*/);
|
|
});
|
|
|
|
test('links citation-only cells in markdown pipe tables', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const markdownIt = new MarkdownIt({ html: false, linkify: true, typographer: true, breaks: true });
|
|
const input = '| Step | Purpose | Source(s) |\n|---|---|---|\n| Surgical consult | Urgent assessment | [1, 2] |\n| X-ray | Assess obstruction | [3] |';
|
|
const html = renderAssistantMarkdown(input, sources, { markdownIt });
|
|
assert.match(html, /<table>/);
|
|
assert.match(html, /data-source-number="1"/);
|
|
assert.match(html, /data-source-number="2"/);
|
|
assert.match(html, /data-source-number="3"/);
|
|
assert.doesNotMatch(html, /<td>\[1, 2\]<\/td>/);
|
|
});
|
|
|
|
test('repairs bare source numbers in markdown table source columns', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const markdownIt = new MarkdownIt({ html: false, linkify: true, typographer: true, breaks: true });
|
|
const input = '| Feature | Possible Surgical Cause(s) | Source(s) |\n|---|---|---|\n| Bilious vomiting | Malrotation with volvulus | 1, 2, 3 |\n| Projectile vomiting | Pyloric stenosis | 2 |';
|
|
const html = renderAssistantMarkdown(input, sources, { markdownIt });
|
|
assert.match(html, /<table>/);
|
|
assert.match(html, /data-source-number="1"/);
|
|
assert.match(html, /data-source-number="2"/);
|
|
assert.match(html, /data-source-number="3"/);
|
|
assert.doesNotMatch(html, /<td>1, 2, 3<\/td>/);
|
|
});
|
|
|
|
test('uses markdown-it stack for GFM-style tables when provided', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const markdownIt = new MarkdownIt({ html: false, linkify: true, typographer: true, breaks: true });
|
|
const html = renderAssistantMarkdown('| Age | Antibiotics |\n|---|---|\n| <1 month | Ampicillin + cefotaxime [1] |', sources, { markdownIt });
|
|
assert.match(html, /<table>/);
|
|
assert.match(html, /<td><1 month<\/td>/);
|
|
assert.match(html, /assistant-cite/);
|
|
});
|
|
|
|
test('renders pipe summary tables and removes trailing emphasis junk', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const markdownIt = new MarkdownIt({ html: false, linkify: true, typographer: true, breaks: true });
|
|
const input = 'Summary Table\n\n| Situation | Imaging Recommended | Timing/Notes |\n|---|---|---|\n| First febrile UTI | Renal/bladder ultrasound | After recovery |\n| Routine use of DMSA scan | Not recommended | |\n**';
|
|
const html = renderAssistantMarkdown(input, sources, { markdownIt });
|
|
assert.match(html, /<table>/);
|
|
assert.match(html, /<th>Situation<\/th>/);
|
|
assert.match(html, /<td>Routine use of DMSA scan<\/td>/);
|
|
assert.doesNotMatch(html, /\*\*/);
|
|
});
|
|
|
|
test('does not repair malformed tab-separated clinical summary rows into a table', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const markdownIt = new MarkdownIt({ html: false, linkify: true, typographer: true, breaks: true });
|
|
const input = 'Summary Table\nStep\tDetails\tSource\nRed Flags\tShock, peritonitis, distension\t3 4 7\nInitial Imaging\tAbdominal X-ray if perforation suspected, then ultrasound\n2 3 7\t\tDiagnostic Gold Std\nAbdominal ultrasound target sign\t2 3 7 Nonoperative Reduction\tAir or contrast enema if stable\nSurgical Indications\tShock, peritonitis, perforation, failed reduction\n1 3 7\t\tRecurrence Rate\n~10% after nonsurgical reduction\t5 6 7\tIf you need details on recurrence management, please specify.';
|
|
const html = renderAssistantMarkdown(input, sources, { markdownIt, citationLabel: 'number' });
|
|
assert.doesNotMatch(html, /<table>/);
|
|
assert.match(html, /Initial Imaging/);
|
|
assert.match(html, /If you need details/);
|
|
});
|
|
|
|
test('a streaming table renders block by block, not as a raw text dump', async () => {
|
|
// Previously a long or pipe-heavy answer gave up on markdown entirely and
|
|
// went into an unstyled <pre> — the dark flash while a table streamed. Now
|
|
// the finished blocks are markdown and only the unfinished tail is text.
|
|
// Behaviour lives in test/assistant-streaming-blocks.test.js; this guards
|
|
// against the old bail-out returning.
|
|
const source = await fs.readFile(path.join(__dirname, '..', 'public', 'js', 'clinicalAssistant.js'), 'utf8');
|
|
assert.doesNotMatch(source, /STREAM_MARKDOWN_LIMIT/);
|
|
assert.doesNotMatch(source, /pipeRows >= 8/);
|
|
assert.match(source, /function settledMarkdownLength\(text, from\)/);
|
|
assert.match(source, /renderStreamingInto\(bubble, partial, streamSources\)/);
|
|
});
|
|
|
|
test('mermaid source survives the sanitiser and round-trips', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const flowchart = 'graph TD; A[Start]-->B[Give O2];';
|
|
const html = renderAssistantMarkdown('```mermaid\n' + flowchart + '\n```', []);
|
|
const attr = html.match(/data-mermaid="([^"]*)"/);
|
|
assert.ok(attr, 'the placeholder must carry the diagram source');
|
|
// Encoded, because DOMPurify strips an attribute whose value contains "-->"
|
|
// and every flowchart has one, which left diagrams stuck on their placeholder.
|
|
assert.ok(!attr[1].includes('-->'), 'the stored value must not contain a raw arrow');
|
|
assert.equal(decodeURIComponent(attr[1].replace(/&/g, '&')).trim(), flowchart);
|
|
});
|
|
|
|
// ── Admin sources display ──────────────────────────────────────────────────
|
|
test('the prompt is identical whether or not sources are displayed', () => {
|
|
const { buildSystemPrompt } = require('../src/utils/clinicalAnswer');
|
|
// Branching the prompt on a display setting would change how the model
|
|
// reasons and cites, so the same question could get a different answer
|
|
// depending on what the admin chose to show. That is the bias this prevents.
|
|
const base = buildSystemPrompt('BEHAVIOR');
|
|
assert.equal(buildSystemPrompt('BEHAVIOR', { citations: false }), base, 'options cannot alter it');
|
|
assert.equal(buildSystemPrompt('BEHAVIOR', { showSources: false }), base);
|
|
assert.equal(buildSystemPrompt('BEHAVIOR', {}), base);
|
|
assert.match(base, /Cite factual claims immediately with numbered citations/,
|
|
'citations are always requested, so the stored answer keeps them');
|
|
});
|
|
|
|
test('hiding sources is display-only and reversible', () => {
|
|
const { stripCitationMarkers } = require('../src/utils/clinicalAnswer');
|
|
assert.equal(stripCitationMarkers('Amoxicillin 90 mg/kg/day [1][2]. Reassess in 48 h [3].'),
|
|
'Amoxicillin 90 mg/kg/day. Reassess in 48 h.');
|
|
assert.equal(stripCitationMarkers('Give fluids [1, 3] and rest [2].'), 'Give fluids and rest.');
|
|
assert.equal(stripCitationMarkers('No citations here.'), 'No citations here.');
|
|
// The escaped form and the placeholder go too; the answer stays grounded either way.
|
|
assert.equal(stripCitationMarkers('Uncommon \\[1, 2\\]. Review [src] weekly [1][4].'), 'Uncommon. Review weekly.');
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const route = fs.readFileSync(path.join(__dirname, '..', 'src/routes/clinicalAssistant.js'), 'utf8');
|
|
// Stripping happens on the way OUT, so the answer is generated and stored with
|
|
// citations intact and turning the setting back on restores them.
|
|
assert.match(route, /answer: prepared\.showSources \? answer : stripCitationMarkers\(answer\)/);
|
|
// The same sanitised list either way: showSources gates only what leaves the
|
|
// route, never what was retrieved. Both answer paths compute it, so match the
|
|
// guarantee rather than one spelling of it.
|
|
assert.match(route, /sources: prepared\.showSources \? (sanitizeSourcesForClient\(prepared\.sources\)|fallbackSources|safeSources) : \[\]/);
|
|
assert.match(route, /var fallbackSources = sanitizeSourcesForClient\(prepared\.sources\);/);
|
|
assert.match(route, /content: buildSystemPrompt\(behavior\)/, 'the prompt takes no display argument');
|
|
});
|
|
|
|
test('the sources toggle is a boolean the server enforces, with the legacy key honoured', () => {
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const root = path.join(__dirname, '..');
|
|
const admin = fs.readFileSync(path.join(root, 'src/routes/adminConfig.js'), 'utf8');
|
|
assert.match(admin, /clinical_assistant\.show_sources' && !\['true', 'false'\]/);
|
|
const route = fs.readFileSync(path.join(root, 'src/routes/clinicalAssistant.js'), 'utf8');
|
|
assert.match(route, /clinical_assistant\.citations_enabled', 'true'\)/, 'the old key still applies');
|
|
assert.match(fs.readFileSync(path.join(root, 'public/components/admin.html'), 'utf8'),
|
|
/id="assistant-show-sources"/);
|
|
});
|
|
|
|
test('a markdown link or footnote whose text is a bare number stays a link, not a citation', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
// The citation rule runs before markdown-it's link rule, so it has to step
|
|
// aside for "[1](url)" and "[1]: url" or every numbered link would become a chip.
|
|
const html = renderAssistantMarkdown('See [1](https://example.org/guide) and [2].', sources);
|
|
assert.match(html, /<a[^>]+href="https:\/\/example\.org\/guide"[^>]*>1<\/a>/);
|
|
assert.equal((html.match(/assistant-cite/g) || []).length, 1);
|
|
assert.match(html, /data-source-number="2"/);
|
|
});
|