pediatric-ai-scribe-v3/test/web-search.test.js
Daniel 055a86bbb1
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 55s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m59s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
feat: My Resources says what it is, offers its sources in one place, and Modify gets them too
The screen had been trimmed to controls with no explanation of what it was for;
"Private to you" said who could see it, not what it did. It now opens with a
sentence that says what you get and how you get it out, and the header reads
"Only you can see these".

The four choices — clinical library, PubMed, web, illustration — are one "Draw
on" group instead of four separate rows, with the library ticked by default.
Each option hides itself when an administrator has not enabled it, so nothing
appears that a person could tick and then be refused.

Modify offers exactly the same choices. It had none, so "add what the 2024 trial
showed" was answered from the model's memory rather than by looking anything up.
Generate and Modify now go through one gatherSources(), so they cannot drift
into offering different things or searching them differently.

Writing "include a diagram of the airway" in the instructions now switches the
illustration option on and says why, rather than the request being dropped in
silence. Switching it off by hand sticks — the hint then reminds instead of
fighting — and when no image model is configured it says so rather than
pretending. Both the generate and modify boxes behave this way.

Two things found by testing this rather than assuming it.

PubMed ANDs every mapped term, so one unrecognised word takes the query to zero.
"febrile seizures" returns six results and "febrile seizures in under-fives"
returns none; "the anatomy of croup: subglottic narrowing and the steeple sign"
returned none until it was narrowed to "anatomy croup", which returns six. A
query that finds nothing is now retried against progressively shorter versions
of itself, longest first, and the response says which query actually worked so
the screen cannot report one that found nothing. Those extra calls tripped
NCBI's three-a-second limit and produced a 429, so retries are spaced and the
first attempt waits for nothing. Separately, the searches run on the topic while
the library retrieval also gets the instruction: retrieval is semantic and
benefits from the context, but a keyword engine handed a whole sentence returns
nothing.

And when a search was asked for and came back empty, the prompt now says not to
invent a citation, a PMID or a URL to fill the gap. Without that the model
supplies them from memory, and a fabricated PMID looks exactly like a real one.

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

193 lines
11 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');
function load(settings, fetchImpl) {
const module = { exports: {} };
vm.runInNewContext(read('src/utils/webSearch.js'), {
module, exports: module.exports, console: { warn() {}, info() {} },
fetch: fetchImpl, AbortSignal: { timeout: () => null },
require(name) {
if (name === '../db/database') return { getSetting: async (k, d) => (k in settings ? settings[k] : d) };
throw new Error('unexpected import: ' + name);
}
});
return module.exports;
}
const ON = {
'websearch.enabled': 'true', 'websearch.provider': 'tavily', 'websearch.api_key': 'k', 'websearch.base_url': ''
};
test('web search is off until an administrator turns it on', async () => {
// This is the only path that sends text outside the building, so the default
// has to be the safe one and nothing should be able to flip it implicitly.
const off = load({}, async () => { throw new Error('must not be called'); });
assert.equal(await off.isAvailable(), false);
const out = await off.search('anything');
assert.equal(out.results.length, 0);
assert.match(out.reason, /disabled/);
// Enabled but unconfigured is still unavailable — no silent half-state.
const noKey = load({ 'websearch.enabled': 'true', 'websearch.provider': 'tavily' },
async () => { throw new Error('must not be called'); });
assert.equal(await noKey.isAvailable(), false);
assert.match((await noKey.search('x')).reason, /not configured/);
// SearXNG needs a URL rather than a key, and is judged on that.
const searx = load({ 'websearch.enabled': 'true', 'websearch.provider': 'searxng', 'websearch.base_url': 'https://s.example' },
async () => ({ ok: true, json: async () => ({ results: [] }) }));
assert.equal(await searx.isAvailable(), true);
});
test('every provider comes back in the same shape', async () => {
const cases = [
['tavily', { results: [{ title: 'T', url: 'https://a', content: 'snippet a' }] }],
['serper', { organic: [{ title: 'T', link: 'https://a', snippet: 'snippet a' }] }],
['brave', { web: { results: [{ title: 'T', url: 'https://a', description: 'snippet a' }] } }],
['searxng', { results: [{ title: 'T', url: 'https://a', content: 'snippet a' }] }]
];
for (const [provider, payload] of cases) {
const lib = load(
{ 'websearch.enabled': 'true', 'websearch.provider': provider, 'websearch.api_key': 'k', 'websearch.base_url': 'https://s.example' },
async () => ({ ok: true, status: 200, json: async () => payload }));
const out = await lib.search('bronchiolitis');
assert.equal(out.results.length, 1, provider + ' returned a result');
assert.deepEqual(Object.keys(out.results[0]).sort(), ['snippet', 'title', 'url'],
provider + ' normalises to one shape');
assert.equal(out.provider, provider);
}
});
test('a failed search never fails the generation', async () => {
// Same contract as corpus retrieval: the resource is written without it, and
// the caller is told why rather than shown an error page.
const lib = load(ON, async () => { throw new Error('provider unreachable'); });
const out = await lib.search('bronchiolitis');
assert.equal(out.results.length, 0);
assert.match(out.reason, /provider unreachable/);
const http = load(ON, async () => ({ ok: false, status: 429, json: async () => ({}) }));
assert.match((await http.search('x')).reason, /429/);
});
test('results are bounded, and a result with no URL is dropped', async () => {
const many = Array.from({ length: 40 }, (_, i) => ({ title: 'T' + i, url: 'https://a/' + i, content: 'x'.repeat(4000) }));
many.push({ title: 'no url', url: '', content: 'y' });
const lib = load(ON, async () => ({ ok: true, json: async () => ({ results: many }) }));
const out = await lib.search('bronchiolitis');
assert.equal(out.results.length, lib.MAX_RESULTS, 'capped');
assert.ok(out.results.every(r => r.url), 'nothing without a URL');
assert.ok(out.results.every(r => r.snippet.length <= 1200), 'snippets clipped');
});
test('searching is the route\u2019s job, not something the model is asked to do', () => {
// This was a tool first. Tested live against a question explicitly about
// recent trials, the model never called it \u2014 with or without corpus
// grounding, and no matter how the description was worded, because the prompt
// ends "Output ONLY Pandoc markdown" and a model told to output only markdown
// does not emit a tool call. Calling callAI with the tool directly produced a
// correct pubmed_search call, so the plumbing was never the problem.
//
// The search only ever needed the topic, and the route knows the topic before
// it calls the model. So both searches run up front and their results go into
// the prompt as findings, the same way corpus excerpts do.
const route = read('src/routes/myResources.js');
assert.match(route, /var wantsWeb = \(String\(body\.withWebSearch\) === 'true'/);
assert.match(route, /&& await webSearch\.isAvailable\(\)/, 'and the server checks, not just the UI');
assert.match(route, /var pages = await webSearch\.search\(keywords\);/);
assert.match(route, /var papers = await pubmedSearch\.search\(keywords\);/);
// One function, so generating and modifying cannot drift into offering
// different sources or searching them differently.
assert.match(route, /async function gatherSources\(subject, body, keywords\)/);
assert.match(route, /var sources = await gatherSources\(topic, req\.body\);/, 'generate');
assert.match(route, /var sources = await gatherSources\(subject, req\.body, existing\.topic \|\| instructions\);/,
'and modify, whose library search gets the instruction for context and whose keyword searches do not');
// Declared before they are used. They were not, once: `var` hoisting made
// wantsPubmed undefined at the point of the test, so the block never ran and
// said nothing about it.
assert.ok(route.indexOf('var wantsPubmed =') < route.indexOf('if (wantsPubmed)'),
'declared above the branch that reads it');
assert.ok(route.indexOf('var wantsWeb =') < route.indexOf('if (wantsWeb)'));
// Neither search may fail a generation, so what happened is reported back
// instead: how many results, and why there were none.
assert.match(route, /searches\.push\(\{ tool: 'pubmed_search', query: papers\.query \|\| keywords/);
assert.match(route, /searches\.push\(\{ tool: 'web_search', query: keywords, count: pages\.results\.length, reason: pages\.reason \}\);/);
assert.match(route, /searches: searches/);
// And neither library still advertises itself as a tool.
assert.doesNotMatch(read('src/utils/webSearch.js'), /name: 'web_search'/);
assert.doesNotMatch(read('src/utils/pubmedSearch.js'), /name: 'pubmed_search'/);
});
test('a PubMed query that finds nothing is narrowed rather than given up on', () => {
const src = read('src/utils/pubmedSearch.js');
// PubMed ANDs every mapped term, so one unrecognised word zeroes the query.
// Measured against the live API: "febrile seizures" → 6 results, "febrile
// seizures in under-fives" → 0, and "the anatomy of croup: subglottic
// narrowing and the steeple sign" → 0 until it was narrowed to "anatomy
// croup", which returns 6.
assert.match(src, /function candidates\(text\)/);
assert.match(src, /var tries = candidates\(text\);/);
// Longest first, so the most specific query that works is the one used.
assert.match(src, /for \(var n = Math\.min\(words\.length, 4\); n >= 2; n--\)/);
// A subtitle is not a subject.
assert.match(src, /split\(\/\[:\\u2014\\u2013\]\|\\s-\\s\/\)\[0\]/);
// Three esearch calls back to back trips NCBI's three-a-second limit without
// a key, which turned a working search into a 429 — measured on this path.
assert.match(src, /var gap = s\.apiKey \? 120 : 380;/);
assert.match(src, /if \(t\) await new Promise/, 'and the first attempt waits for nothing');
// The screen reports what was searched, so it has to be the query that
// actually found the results.
assert.match(src, /return \{ results: results, reason: null, query: used \};/);
assert.match(read('src/routes/myResources.js'), /query: papers\.query \|\| keywords/);
});
test('a search that comes back empty is not an invitation to invent citations', () => {
const route = read('src/routes/myResources.js');
// Asked to search, found nothing, still asked for PMIDs: the model supplies
// them from memory, and a fabricated PMID looks exactly like a real one.
assert.match(route, /searchedAndFoundNothing: searches\.length > 0 && !literature && !webFindings/);
// Said on both paths: refining and generating.
assert.equal((route.match(/Do not invent a citation, a PMID/g) || []).length, 2);
});
test('the key is masked on read and preserved when left blank', () => {
const admin = read('src/routes/adminConfig.js');
// Same handling the OIDC client secret gets.
// Both keys, one rule: never send a key back, enough tail to recognise it.
assert.match(admin, /\['websearch\.api_key', 'pubmed\.api_key'\]\.forEach/);
assert.match(admin, /if \(out\[k\]\) out\[k\] = '••••••••' \+ out\[k\]\.slice\(-4\);/);
// Changing the provider must not silently wipe a working key.
assert.match(admin, /if \(key && key\.indexOf\('•'\) === -1\) await db\.setSetting\('websearch\.api_key'/);
assert.match(admin, /if \(pmKey && pmKey\.indexOf\('•'\) === -1\) await db\.setSetting\('pubmed\.api_key'/);
assert.match(admin, /if \(providers\.indexOf\(provider\) === -1\)/, 'and the provider is validated');
});
test('both screens say plainly that a query leaves the network', () => {
assert.match(read('public/components/admin.html'), /This sends text outside the building/);
assert.match(read('public/components/admin.html'), /SearXNG is the only\s*\n?\s*option here that you host yourself/);
const mine = read('public/components/my-resources.html');
assert.match(mine, /The query leaves this network/);
assert.match(mine, /keep the topic non-identifying/);
assert.match(mine, /The query goes to NCBI/);
// And it is hidden entirely when unavailable, so nobody ticks a box that
// cannot work.
// Both option groups are hidden from the same answer, so Generate and Modify
// cannot end up offering different things.
const js = read('public/js/myResources.js');
assert.match(js, /\['mr-web-row', 'web'\]/);
assert.match(js, /\['mr-modify-web-row', 'web'\]/);
assert.match(js, /\['mr-pubmed-row', 'pubmed'\]/);
assert.match(js, /\['mr-modify-pubmed-row', 'pubmed'\]/);
assert.match(js, /row\.hidden = !available\[pair\[1\]\]/);
});