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
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
120 lines
5.1 KiB
JavaScript
120 lines
5.1 KiB
JavaScript
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 assistant’s', 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);
|
||
});
|