pediatric-ai-scribe-v3/test/learning-retrieval.test.js
Daniel f7cd8b39a3
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m0s
Forgejo Docker Build / Build Docker image (push) Successful in 8s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
feat: a grounded resource ends with the references it drew on
The grounding metadata went to the API response and the logs, which is no use to
someone holding the deck. A teaching resource shown to trainees should carry its
own provenance, so a grounded one now ends with a References section — the final
slide in a presentation — listing the library excerpts it actually used, by
title and page.

Restricted deliberately: only excerpts actually drawn on, nothing invented. That
was worth checking rather than trusting. Generated a deck and compared every
citation against the source metadata: "Kliegman R. Nelson Textbook of
Pediatrics, 22nd ed., 2024, p. 2604" against a stored title of "Kliegman R.
Nelson Textbook of Pediatrics 2-Volume Set 22ed 2024" at page 2604, and the same
for Fleisher & Ludwig, Rosen's, Understanding Pathophysiology and the AAP
compendium. The model reformatted filename-derived titles into readable
citations using only what it was given.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 14:23:49 +02:00

124 lines
5.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const root = path.join(__dirname, '..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
// Load the module with its two dependencies replaced, so these tests exercise
// the budgeting and failure handling without a Milvus behind them.
function load(mocks) {
const module = { exports: {} };
vm.runInNewContext(read('src/utils/learningRetrieval.js'), {
module, exports: module.exports, console: { warn() {}, info() {} },
require(name) {
if (Object.hasOwn(mocks, name)) return mocks[name];
throw new Error('unexpected import: ' + name);
}
});
return module.exports;
}
const passthrough = {
'./clinicalRetrieval': {
normalizeMcpSearchResponse: r => r.results || [],
dedupeSources: s => s,
cleanSourceExcerpt: t => String(t || '')
}
};
test('Learning retrieves with its own budget, not the assistants', async () => {
let captured = null;
const lib = load({
...passthrough,
'./clinicalMcpClient': { semanticSearch: async (q, opts) => { captured = { q, opts }; return { results: [] }; } }
});
// A chat answer wants a few tight excerpts because the reader is waiting. A
// teaching resource synthesises a whole topic, so it wants many more and
// longer ones. The assistant's defaults are 8 and 1400.
assert.equal(lib.DEFAULTS.limit, 30);
assert.equal(lib.DEFAULTS.contextChars, 2500);
await lib.retrieve('bronchiolitis', async () => null);
assert.equal(captured.q, 'bronchiolitis');
assert.equal(captured.opts.limit, 30);
assert.equal(captured.opts.contextChars, 2500);
assert.equal(captured.opts.includeContext, true);
});
test('the budget is settable but bounded', async () => {
let captured = null;
const lib = load({
...passthrough,
'./clinicalMcpClient': { semanticSearch: async (q, opts) => { captured = opts; return { results: [] }; } }
});
const settings = { 'learning.search_limit': '999', 'learning.context_chars': '999999' };
await lib.retrieve('topic', async key => settings[key]);
// "No limit" only moves the ceiling from a setting to the model's context
// window, where overflow truncates the middle of the prompt silently.
assert.equal(captured.limit, lib.BOUNDS.limit[1]);
assert.equal(captured.contextChars, lib.BOUNDS.contextChars[1]);
const low = { 'learning.search_limit': '0', 'learning.context_chars': '1' };
await lib.retrieve('topic', async key => low[key]);
assert.equal(captured.limit, lib.BOUNDS.limit[0]);
assert.equal(captured.contextChars, lib.BOUNDS.contextChars[0]);
});
test('retrieval failing never fails the generation', async () => {
const lib = load({
...passthrough,
'./clinicalMcpClient': { semanticSearch: async () => { throw new Error('MCP unreachable'); } }
});
// Writing the resource from the model alone is what happened before this
// existed, and is a far better outcome than an error page.
const out = await lib.retrieve('bronchiolitis', async () => null);
// Length, not deepEqual: the array is created inside the VM realm, so its
// prototype is not this realm's Array and a structural compare fails.
assert.equal(out.sources.length, 0);
assert.equal(out.context, '');
assert.match(out.reason, /MCP unreachable/, 'and the caller is told why');
});
test('an empty corpus result is reported, not silently passed off as grounded', async () => {
const lib = load({
...passthrough,
'./clinicalMcpClient': { semanticSearch: async () => ({ results: [] }) }
});
const out = await lib.retrieve('something nobody indexed', async () => null);
assert.equal(out.context, '');
assert.match(out.reason, /nothing indexed matched/);
});
test('excerpts are numbered the way the assistant numbers them', async () => {
const lib = load({
...passthrough,
'./clinicalMcpClient': { semanticSearch: async () => ({ results: [
{ number: 1, title: 'Bronchiolitis chapter', page: 12, excerpt: 'Supportive care.' },
{ number: 2, title: 'RSV guidance', excerpt: 'Peak 3-6 months.' }
] }) }
});
const out = await lib.retrieve('bronchiolitis', async () => null);
assert.equal(out.sources.length, 2);
assert.match(out.context, /^\[1\] Bronchiolitis chapter, page 12\nSupportive care\./);
assert.match(out.context, /\[2\] RSV guidance\nPeak 3-6 months\./);
assert.equal(out.reason, null);
});
test('the corpus block tells the model to prefer it over recall', () => {
const route = read('src/routes/learningAI.js');
assert.match(route, /Prefer them over your own recall wherever they disagree/);
assert.match(route, /reference material, not a template/, 'so it writes rather than copies');
// Provenance a clinician can check, on the deck itself rather than only in a
// log. Restricted to excerpts actually used, and explicitly not invented.
assert.match(route, /In a presentation this is the final slide, titled References/);
assert.match(route, /Do not invent references, and do not list an excerpt you did not use/);
// Opt in: a resource on something the library does not cover is better
// written without it than padded with the nearest unrelated excerpts.
assert.match(route, /var useCorpus\s+= String\(req\.body\.useCorpus\) === 'true'/);
assert.match(route, /if \(useCorpus\) \{/);
// And every success response says what it was grounded on.
assert.equal((route.match(/grounding: \{ used: Boolean\(corpus\.context\)/g) || []).length, 3);
});