pediatric-ai-scribe-v3/test/learning-retrieval.test.js
Daniel 4f5687982d
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s
feat: Learning resources can be grounded in the clinical corpus
Learning generated everything from the model alone. A deck on bronchiolitis was
whatever the model remembered about bronchiolitis, with no connection to the
documents this institution actually indexed — while the assistant had been
searching that corpus all along.

Same collection, deliberately. mcp_bge_m3_1024 is already embedded with
openrouter-bge-m3 at 1024 dimensions; a second index over the same documents
with the same embedder would be a copy that drifts. What differs is the budget:
a chat answer wants a few tight excerpts because the reader is waiting, a
teaching resource synthesises a whole topic. So learning.search_limit and
learning.context_chars default to 30 and 2500 against the assistant's 8 and
1400, and are separate keys so tuning one cannot move the other.

Not unbounded, though. "No limit" only moves the ceiling from a setting to the
model's context window, where overflow truncates the middle of the prompt
silently — the worst place to lose source material. 60 results and 8000
characters per excerpt are the caps.

Opt in per generation: a resource on something the library does not cover is
better written without it than padded with the nearest unrelated excerpts.
Retrieval never fails a generation — the resource is then written from the model
alone, which is what happened before this existed — and every response reports
what it was grounded on, so a caller can say "24 excerpts" or "the library had
nothing on this" rather than quietly serving ungrounded material.

Verified against the live corpus: bronchiolitis, neonatal jaundice and febrile
seizure each returned 12 excerpts and ~23k characters from Nelson, Rudolph and
the Pediatric Clinical Practice Guidelines. A deck generated through the full
chain came back with textbook specificity that is not general recall —
bronchiolar diameter, birth-weight thresholds, the full pathogen list.

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

120 lines
5.1 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');
// 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);
});