All checks were successful
Forgejo Android APK / Build signed APK (push) Successful in 1m52s
Three rendering defects in the clinical assistant, all confirmed by running the
code rather than reading it.
A spaced hyphen anywhere in a sentence was rewritten as a list item, so every
numeric range written that way was split in two:
"Give dexamethasone 0.15 - 0.6 mg/kg orally."
-> "Give dexamethasone 0.15\n- 0.6 mg/kg orally."
which renders as a truncated sentence followed by a bullet, and a dose range
therefore reads as a different dose. The same applied to SpO2 targets, pH ranges
and age ranges. A hyphen now starts a list item only at the beginning of a line
or after sentence punctuation, which still catches the case the rule was written
for.
An answer ending in a bold phrase lost its closing marker, because trailing
emphasis was stripped unconditionally and the strip ran twice. Only an unpaired
marker is removed now.
Inline maths swallowed dollar amounts: "Costs $5 to $10 per dose" rendered the
text between the signs as an equation and dropped both signs. A $...$ span is
now treated as maths only when it contains something mathematical, so subscripts
and fractions still render.
Citation handling is unchanged and covered by the same tests: clusters still
merge and sort, unknown source numbers stay literal, links still resolve by index,
and bare numbers in a table's Source column still become bracketed tokens.
The state before these fixes is tagged pre-citation-fixes-20260828.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
293 lines
17 KiB
JavaScript
293 lines
17 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;
|
|
|
|
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"[^>]*>src<\/a> <a class="assistant-cite"[^>]*>src<\/a>/);
|
|
});
|
|
|
|
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('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"[^>]*>src<\/a>/);
|
|
assert.match(html, /bronchodilator <a class="assistant-cite"[^>]*data-source-number="2"[^>]*>src<\/a>/);
|
|
assert.doesNotMatch(html, /data-source-number="1"[^>]*>src<\/a><a class="assistant-cite"[^>]*data-source-number="2"/);
|
|
});
|
|
|
|
test('sorts adjacent citation tokens into one safe Vancouver cluster', async () => {
|
|
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"[^>]*>src<\/a> <a class="assistant-cite"[^>]*data-source-number="2"[^>]*>src<\/a> <a class="assistant-cite"[^>]*data-source-number="3"[^>]*>src<\/a> <a class="assistant-cite"[^>]*data-source-number="4"[^>]*>src<\/a>/);
|
|
assert.doesNotMatch(html, /\]<span|\]\[/);
|
|
});
|
|
|
|
test('repairs a clearly adjacent trailing citation missing its closing bracket', async () => {
|
|
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"[^>]*>src<\/a> <a class="assistant-cite"[^>]*data-source-number="2"[^>]*>src<\/a> <a class="assistant-cite"[^>]*data-source-number="3"[^>]*>src<\/a> <a class="assistant-cite"[^>]*data-source-number="4"[^>]*>src<\/a>/);
|
|
});
|
|
|
|
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('repairs smashed admission bullet list after citations', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const html = renderAssistantMarkdown('Admission is indicated for: Apnoea [1, 2]- **Persistent oxygen saturation <90%** [1, 2]- **Severe respiratory distress** [1]', sources);
|
|
assert.match(html, /<li><strong>Persistent oxygen saturation <90%<\/strong>/);
|
|
assert.match(html, /<li><strong>Severe respiratory distress<\/strong>/);
|
|
assert.doesNotMatch(html, /\]-/);
|
|
});
|
|
|
|
test('repairs smashed head injury red flag bullets after citation clusters', async () => {
|
|
const { renderAssistantMarkdown } = await loadCitationModule();
|
|
const html = renderAssistantMarkdown('CT is indicated for: [1, 2, 3]- Focal neurologic deficits [1]- Signs of skull fracture [2]- Recurrent vomiting [3]', sources);
|
|
assert.match(html, /<li>Focal neurologic deficits/);
|
|
assert.match(html, /<li>Signs of skull fracture/);
|
|
assert.match(html, /<li>Recurrent vomiting/);
|
|
assert.doesNotMatch(html, /\]-/);
|
|
});
|
|
|
|
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('clinical assistant streams long table answers as lightweight text before final render', async () => {
|
|
const source = await fs.readFile(path.join(__dirname, '..', 'public', 'js', 'clinicalAssistant.js'), 'utf8');
|
|
assert.match(source, /STREAM_MARKDOWN_LIMIT/);
|
|
assert.match(source, /renderStreamingAnswerHtml\(partial, streamSources\)/);
|
|
assert.match(source, /assistant-streaming-text/);
|
|
assert.match(source, /pipeRows >= 8/);
|
|
});
|
|
|
|
test('numeric ranges written with spaced hyphens are not turned into bullets', async () => {
|
|
const { normalizeMarkdownText } = await loadCitationModule();
|
|
// A dose range split across a line break reads as a different dose.
|
|
for (const text of [
|
|
'Give dexamethasone 0.15 - 0.6 mg/kg orally.',
|
|
'Target SpO2 92 - 96% on room air.',
|
|
'Aim for pH 7.35 - 7.45.',
|
|
'Ages 2 - 5 years.',
|
|
'Use 5 - 10 mL/kg boluses.'
|
|
]) {
|
|
assert.equal(normalizeMarkdownText(text), text, text);
|
|
}
|
|
});
|
|
|
|
test('a hyphen after sentence punctuation still starts a list item', async () => {
|
|
const { normalizeMarkdownText } = await loadCitationModule();
|
|
assert.equal(normalizeMarkdownText('Treatment options. - Dexamethasone first.'), 'Treatment options.\n- Dexamethasone first.');
|
|
assert.equal(normalizeMarkdownText('Options: - Dexamethasone'), 'Options:\n- Dexamethasone');
|
|
assert.equal(normalizeMarkdownText('Works well [1] - Dexamethasone'), 'Works well [1]\n- Dexamethasone');
|
|
assert.equal(normalizeMarkdownText('Intro\n- one\n- two'), 'Intro\n- one\n- two');
|
|
});
|
|
|
|
test('a bold phrase at the end of an answer keeps its closing marker', async () => {
|
|
const { stripOrphanMarkdownMarkers } = await loadCitationModule();
|
|
assert.equal(stripOrphanMarkdownMarkers('Give oxygen. **Monitor closely**'), 'Give oxygen. **Monitor closely**');
|
|
assert.equal(stripOrphanMarkdownMarkers('Note the *caveat*'), 'Note the *caveat*');
|
|
// A genuinely unpaired marker is still removed.
|
|
assert.equal(stripOrphanMarkdownMarkers('Ends with bold **'), 'Ends with bold');
|
|
});
|
|
|
|
test('dollar amounts are not rendered as mathematics', async () => {
|
|
const { renderLatexText } = await loadCitationModule();
|
|
const katex = { renderToString: (expr) => '<KATEX>' + expr + '</KATEX>' };
|
|
assert.equal(renderLatexText('Costs $5 to $10 per dose', katex), 'Costs $5 to $10 per dose');
|
|
// Real mathematics still renders.
|
|
assert.equal(renderLatexText('SpO$_2$ target', katex), 'SpO<KATEX>_2</KATEX> target');
|
|
assert.equal(renderLatexText('Use $\\frac{1}{2}$ dose', katex), 'Use <KATEX>\\frac{1}{2}</KATEX> dose');
|
|
});
|