+ Build a teaching deck or handout on any topic — grounded in the clinical library,
+ the literature, or both — then revise it below and download it as PowerPoint,
+ Word or PDF.
+
@@ -27,16 +32,6 @@
-
- Clinical library
-
-
-
-
-
@@ -44,39 +39,33 @@
-
- Illustrations
-
+
+
+ Draw on
+
-
-
-
-
- Web search
-
-
-
- The search query leaves this network. Do not put anything identifying in the topic.
-
-
-
-
-
- PubMed
-
-
@@ -111,6 +100,35 @@
+
+
+ Draw on
+
+
+
+ The clinical library
+
+
+
+ PubMed — cite published literature
+ The query goes to NCBI.
+
+
+
+ The web — for anything newer than the library
+ The query leaves this network.
+
+
+
+ Add one illustration
+
+
+
+
+
What to change
diff --git a/public/js/myResources.js b/public/js/myResources.js
index bfabfe15..c1310e21 100644
--- a/public/js/myResources.js
+++ b/public/js/myResources.js
@@ -17,6 +17,53 @@
loadLibrary();
});
+ // What an administrator has switched on. Read once, used by both option
+ // groups and by the instruction check.
+ var available = { images: false, web: false, pubmed: false };
+
+ // Someone who writes "add a diagram of the airway" in the instructions has
+ // said what they want as plainly as ticking the box would. Left alone, the
+ // request is simply dropped and the resource comes back without a picture
+ // and without a word about why. These are the ways people actually ask.
+ var IMAGE_REQUEST = /\b(image|images|diagram|diagrams|illustration|illustrations|illustrate|illustrated|figure|figures|picture|pictures|drawing|drawings|chart|charts|graphic|graphics|infographic|visual|visuals|schematic)\b/i;
+
+ function looksLikeImageRequest(text) {
+ return IMAGE_REQUEST.test(String(text || ''));
+ }
+
+ // Turning the option on is the honest response: it does what was asked, it is
+ // visible, and it can be turned straight back off. Silently ignoring the
+ // sentence is not. Once it has been switched off by hand, it stays off —
+ // re-ticking it on every keystroke would be a fight.
+ function wireImageIntent(textareaId, checkboxId, hintId) {
+ var box = document.getElementById(textareaId);
+ var check = document.getElementById(checkboxId);
+ var hint = document.getElementById(hintId);
+ if (!box || !check || !hint) return;
+ var overruled = false;
+ check.addEventListener('change', function () {
+ if (!check.checked) overruled = true;
+ if (check.checked) hint.hidden = true;
+ });
+ box.addEventListener('input', function () {
+ var asked = looksLikeImageRequest(box.value);
+ if (!asked) { hint.hidden = true; return; }
+ if (!available.images) {
+ hint.textContent = 'Your instructions mention a figure, but no image model is configured, so none can be made.';
+ hint.hidden = false;
+ return;
+ }
+ if (check.checked || overruled) {
+ hint.hidden = overruled ? false : true;
+ if (overruled) hint.textContent = 'Your instructions ask for a figure. Tick the illustration option above to get one.';
+ return;
+ }
+ check.checked = true;
+ hint.textContent = 'Illustration switched on, because your instructions ask for a figure. Untick it if you would rather not.';
+ hint.hidden = false;
+ });
+ }
+
function init() {
var kind = document.getElementById('mr-kind');
if (kind) kind.addEventListener('change', syncFormatFields);
@@ -41,6 +88,9 @@
var modify = document.getElementById('btn-mr-modify');
if (modify) modify.addEventListener('click', runModify);
+ wireImageIntent('mr-refinement', 'mr-with-images', 'mr-image-hint');
+ wireImageIntent('mr-modify-instructions', 'mr-modify-images', 'mr-modify-image-hint');
+
loadOptions();
}
@@ -66,14 +116,22 @@
});
}
if (modelRow) modelRow.hidden = models.length < 2;
- var imagesRow = document.getElementById('mr-images-row');
- if (imagesRow) imagesRow.hidden = !data.imagesAvailable;
- // Hidden entirely unless an administrator enabled it, so the option
- // never appears as something a user could turn on and be refused.
- var webRow = document.getElementById('mr-web-row');
- if (webRow) webRow.hidden = !data.webSearchAvailable;
- var pubmedRow = document.getElementById('mr-pubmed-row');
- if (pubmedRow) pubmedRow.hidden = !data.pubmedAvailable;
+
+ // Hidden entirely unless an administrator enabled it, so an option
+ // never appears that someone could tick and then be refused. Both
+ // groups are driven from the same answer: Generate and Modify offer
+ // the same choices, so they must offer the same ones.
+ available = {
+ images: Boolean(data.imagesAvailable),
+ web: Boolean(data.webSearchAvailable),
+ pubmed: Boolean(data.pubmedAvailable)
+ };
+ [['mr-images-row', 'images'], ['mr-web-row', 'web'], ['mr-pubmed-row', 'pubmed'],
+ ['mr-modify-images-row', 'images'], ['mr-modify-web-row', 'web'],
+ ['mr-modify-pubmed-row', 'pubmed']].forEach(function (pair) {
+ var row = document.getElementById(pair[0]);
+ if (row) row.hidden = !available[pair[1]];
+ });
})
.catch(function () { /* the defaults still work without this */ });
}
@@ -128,15 +186,7 @@
? 'Saved. Written from ' + g.count + ' library excerpt' + (g.count === 1 ? '' : 's') + '.'
: 'Saved. Not grounded' + (g.reason ? ' — ' + g.reason : '') + '; written from the model alone.',
g.used ? 'good' : null);
- // Say what was searched for. A query that left the network is worth
- // showing plainly rather than leaving someone to wonder.
- (data.searches || []).forEach(function (s) {
- if (typeof showToast !== 'function') return;
- var where = s.tool === 'pubmed_search' ? 'PubMed' : 'the web';
- showToast(s.count
- ? 'Searched ' + where + ' for "' + s.query + '" — ' + s.count + ' used.'
- : 'Nothing found on ' + where + ' for "' + s.query + '".', 'info');
- });
+ reportSearches(data.searches);
showIllustrations(data.imageJobs || []);
loadLibrary();
})
@@ -146,6 +196,23 @@
});
}
+ // Say what was searched for. A query that left the network is worth showing
+ // plainly rather than leaving someone to wonder whether it happened.
+ function reportSearches(searches) {
+ (searches || []).forEach(function (s) {
+ if (typeof showToast !== 'function') return;
+ var where = s.tool === 'pubmed_search' ? 'PubMed' : 'the web';
+ showToast(s.count
+ ? 'Searched ' + where + ' for "' + s.query + '" \u2014 ' + s.count + ' used.'
+ : 'Nothing found on ' + where + ' for "' + s.query + '".', 'info');
+ });
+ }
+
+ function ticked(id) {
+ var el = document.getElementById(id);
+ return Boolean(el && el.checked);
+ }
+
// The shared poller, so an image made here behaves exactly like one made in
// the assistant: same status line, same durable job, same asset endpoint. It
// lives in an ES module and this file is a classic script, hence the dynamic
@@ -271,13 +338,23 @@
headers: getAuthHeaders(),
body: JSON.stringify({
instructions: instructions,
+ useCorpus: ticked('mr-modify-corpus') ? 'true' : 'false',
+ withPubmed: ticked('mr-modify-pubmed') ? 'true' : 'false',
+ withWebSearch: ticked('mr-modify-web-search') ? 'true' : 'false',
+ withImages: ticked('mr-modify-images') ? 'true' : 'false',
model: (document.getElementById('mr-model') || {}).value || ''
})
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success) throw new Error(data.error || 'Could not apply the changes');
- say('Applied. The library entry is updated; download it to see the result.', 'good');
+ // Same reporting as generating: what it was written from, what was
+ // searched, and any figure that came back.
+ var g = data.grounding || {};
+ say('Applied' + (g.used ? ', using ' + g.count + ' library excerpt' + (g.count === 1 ? '' : 's') : '') +
+ '. Download it to see the result.', 'good');
+ reportSearches(data.searches);
+ showIllustrations(data.imageJobs || []);
if (box) box.value = '';
loadLibrary();
})
diff --git a/src/routes/myResources.js b/src/routes/myResources.js
index 491e7a64..da929c11 100644
--- a/src/routes/myResources.js
+++ b/src/routes/myResources.js
@@ -97,6 +97,13 @@ function buildPrompt(opts) {
"library, and list what you use in the References section by title and URL.\n\n" +
"WEB RESULTS:\n\"\"\"\n" + opts.webFindings + "\n\"\"\"\n";
}
+ // Asked to search, found nothing, and still asked for citations: without this
+ // the model supplies them from memory, and a fabricated PMID looks exactly
+ // like a real one.
+ if (opts.searchedAndFoundNothing) {
+ findings += "\nThe search for this topic returned nothing. Do not invent a citation, a PMID " +
+ "or a URL to fill the gap.\n";
+ }
// The image tool is the one thing still left to the model to decide, so the
// prompt has to say it exists. Handing over a tool schema and then writing
@@ -172,6 +179,58 @@ router.get('/my-resources/options', async function (req, res) {
}
});
+// ── Sources ─────────────────────────────────────────────────
+// What a resource is written from. Generating and modifying ask exactly the
+// same question — which library, which literature, which web — so they ask it
+// through one function rather than two that drift apart.
+//
+// Nothing in here may fail the request. A retrieval or a search that comes back
+// empty is reported as a reason, and the model writes from what it has.
+// `subject` is what the library is searched with: retrieval is semantic, so the
+// more context the better. `keywords` is what PubMed and the web are searched
+// with, and they are keyword engines — handed a whole sentence they return
+// nothing. Measured: "febrile seizures in under-fives — Add a slide on what the
+// randomised trial evidence shows, citing PMIDs." returned 0 results where the
+// topic alone returned six.
+async function gatherSources(subject, body, keywords) {
+ keywords = String(keywords || subject).trim() || subject;
+ var useCorpus = String(body.useCorpus) !== 'false';
+ var wantsImages = String(body.withImages) === 'true' || body.withImages === true;
+ var wantsWeb = (String(body.withWebSearch) === 'true' || body.withWebSearch === true)
+ && await webSearch.isAvailable();
+ var wantsPubmed = (String(body.withPubmed) === 'true' || body.withPubmed === true)
+ && await pubmedSearch.isAvailable();
+
+ var corpus = { sources: [], context: '', reason: 'not requested' };
+ if (useCorpus) corpus = await learningRetrieval.retrieve(subject, db.getSetting);
+
+ var searches = [];
+ var literature = '';
+ var webFindings = '';
+ if (wantsPubmed) {
+ var papers = await pubmedSearch.search(keywords);
+ searches.push({ tool: 'pubmed_search', query: papers.query || keywords, count: papers.results.length, reason: papers.reason });
+ if (papers.results.length) literature = pubmedSearch.formatForPrompt(papers.results);
+ }
+ if (wantsWeb) {
+ var pages = await webSearch.search(keywords);
+ searches.push({ tool: 'web_search', query: keywords, count: pages.results.length, reason: pages.reason });
+ if (pages.results.length) webFindings = webSearch.formatForPrompt(pages.results);
+ }
+
+ // Asking for an illustration when no image model is configured is not an
+ // error, it just cannot happen; the caller reports that rather than failing.
+ var imageModel = wantsImages ? String(await db.getSetting('clinical_assistant.image_model', '') || '') : '';
+ return {
+ corpus: corpus, searches: searches, literature: literature, webFindings: webFindings,
+ // A search that was asked for and came back empty is the dangerous case: the
+ // model is being asked for citations with nothing to cite, and will supply
+ // them from memory unless told not to.
+ searchedAndFoundNothing: searches.length > 0 && !literature && !webFindings,
+ wantsImages: Boolean(wantsImages && imageModel), imageModel: imageModel
+ };
+}
+
// ── Generate ────────────────────────────────────────────────
router.post('/my-resources/generate', async function (req, res) {
try {
@@ -179,7 +238,6 @@ router.post('/my-resources/generate', async function (req, res) {
if (!topic) return res.status(400).json({ error: 'A topic is required' });
var kind = normalizeKind(req.body.kind);
- var useCorpus = String(req.body.useCorpus) !== 'false';
var refinement = String(req.body.refinement || '').slice(0, 2000);
var count = await db.get('SELECT COUNT(*)::int AS n FROM user_resources WHERE user_id = ?', [req.user.id]);
@@ -187,43 +245,17 @@ router.post('/my-resources/generate', async function (req, res) {
return res.status(409).json({ error: 'You have reached ' + MAX_PER_USER + ' saved resources. Delete one first.' });
}
- // Retrieval never fails a generation; the resource is then written from the
- // model alone, and the response says so.
- var corpus = { sources: [], context: '', reason: 'not requested' };
- if (useCorpus) corpus = await learningRetrieval.retrieve(topic, db.getSetting);
-
- // Illustration is opt-in per generation. The tool is only offered when the
- // author ticked the box, because a model handed a drawing tool will find a
- // reason to use it, and most teaching material does not want one.
- var wantsImages = String(req.body.withImages) === 'true' || req.body.withImages === true;
- // Off unless asked for, and unavailable unless an administrator enabled it.
- // This is the only path here that sends text outside the building.
- var wantsWeb = (String(req.body.withWebSearch) === 'true' || req.body.withWebSearch === true)
- && await webSearch.isAvailable();
- var wantsPubmed = (String(req.body.withPubmed) === 'true' || req.body.withPubmed === true)
- && await pubmedSearch.isAvailable();
- var imageModel = wantsImages ? String(await db.getSetting('clinical_assistant.image_model', '') || '') : '';
-
- // Both searches run up front on the topic, so their results are in the
- // prompt before a word is written. Neither can fail the generation.
- var searches = [];
- var literature = '';
- var webFindings = '';
- if (wantsPubmed) {
- var papers = await pubmedSearch.search(topic);
- searches.push({ tool: 'pubmed_search', query: topic, count: papers.results.length, reason: papers.reason });
- if (papers.results.length) literature = pubmedSearch.formatForPrompt(papers.results);
- }
- if (wantsWeb) {
- var pages = await webSearch.search(topic);
- searches.push({ tool: 'web_search', query: topic, count: pages.results.length, reason: pages.reason });
- if (pages.results.length) webFindings = webSearch.formatForPrompt(pages.results);
- }
+ var sources = await gatherSources(topic, req.body);
+ var corpus = sources.corpus;
+ var searches = sources.searches;
+ var wantsImages = sources.wantsImages;
+ var imageModel = sources.imageModel;
var prompt = buildPrompt({
topic: topic, kind: kind, refinement: refinement, corpusContext: corpus.context,
- literature: literature, webFindings: webFindings,
- wantsImages: Boolean(wantsImages && imageModel),
+ literature: sources.literature, webFindings: sources.webFindings,
+ searchedAndFoundNothing: sources.searchedAndFoundNothing,
+ wantsImages: wantsImages,
slideCount: clampInt(req.body.slideCount, 3, 30, 8),
wordCount: clampInt(req.body.wordCount, 200, 3000, 800)
});
@@ -237,14 +269,14 @@ router.post('/my-resources/generate', async function (req, res) {
// decide and to compose a prompt; a search only needs the topic, and the
// topic is already known.
var tools = [];
- if (wantsImages && imageModel) tools = tools.concat(imageTool.tools);
+ if (wantsImages) tools = tools.concat(imageTool.tools);
var ai = await callAI(messages, tools.length ? Object.assign({}, options, { tools: tools }) : options);
// The same dispatcher the assistant uses, so an image generated here is
// owned, queued and rendered exactly as one generated there. Without this
// the model's tool call is simply dropped and no job is ever enqueued.
- if (wantsImages && imageModel) {
+ if (wantsImages) {
ai = await imageTool.dispatch(ai, {
owner: req.user.id, workflow: 'my_resources', body: req.body,
imageContext: generatedImages.imageContext(topic, []), imageModel: imageModel,
@@ -332,20 +364,64 @@ router.post('/my-resources/:id/refine', async function (req, res) {
if (!instructions) return res.status(400).json({ error: 'Say what to change' });
var existing = await db.get(
- 'SELECT id, kind, markdown FROM user_resources WHERE id = ? AND user_id = ?',
+ 'SELECT id, kind, topic, markdown FROM user_resources WHERE id = ? AND user_id = ?',
[parseInt(req.params.id, 10), req.user.id]
);
if (!existing) return res.status(404).json({ error: 'Not found' });
+ // Modifying can reach for the same sources as generating: "add what the
+ // 2024 trial showed" is a request for material, not just a rewording, and
+ // without this it would be answered from the model's memory alone. The
+ // subject searched is the resource's own topic plus the instruction, so a
+ // request about something not in the original still finds it.
+ var subject = [existing.topic, instructions].filter(Boolean).join(' \u2014 ').slice(0, 500);
+ var sources = await gatherSources(subject, req.body, existing.topic || instructions);
+
+ var material = '';
+ if (sources.corpus.context) {
+ material += '\n\nLIBRARY EXCERPTS (prefer these over your own recall; add anything you use ' +
+ 'to the References section):\n"""\n' + sources.corpus.context + '\n"""';
+ }
+ if (sources.literature) {
+ material += '\n\nPUBMED RESULTS (cite by PMID in the References section; cite nothing not ' +
+ 'listed here):\n"""\n' + sources.literature + '\n"""';
+ }
+ if (sources.webFindings) {
+ material += '\n\nWEB RESULTS (list what you use in the References section by title and ' +
+ 'URL):\n"""\n' + sources.webFindings + '\n"""';
+ }
+ if (sources.searchedAndFoundNothing) {
+ material += '\n\nThe search for this returned nothing. Do not invent a citation, a PMID or ' +
+ 'a URL to fill the gap: leave the References section as it is.';
+ }
+
+ var illustration = sources.wantsImages
+ ? '\n\nAn illustration tool is available and the author has asked for one. If the ' +
+ 'instruction asks for a figure, or one would genuinely help, call generate_image ONCE ' +
+ 'with a description of it. Schematic or anatomical teaching artwork only, never a real ' +
+ 'patient. Returning the markdown is still required; the tool call is not a substitute ' +
+ 'for it, and no image tag or URL goes into the markdown.'
+ : '';
+
// The markdown is the thing being edited, which is the whole reason it is
// what gets stored: "change slide 4" is a text edit, not a binary patch.
- var ai = await callAI([{ role: 'user', content:
+ var messages = [{ role: 'user', content:
'Revise the following Pandoc markdown according to the instruction. ' +
'Return ONLY the complete revised markdown, no commentary, no code fences. ' +
'Keep the same overall structure unless the instruction asks otherwise, and keep any ' +
- 'References section at the end.\n\nINSTRUCTION: ' + instructions +
- '\n\nMARKDOWN:\n"""\n' + existing.markdown + '\n"""' }],
- { model: await resolveModel(req.body.model), temperature: 0.2 });
+ 'References section at the end.\n\nINSTRUCTION: ' + instructions + illustration + material +
+ '\n\nMARKDOWN:\n"""\n' + existing.markdown + '\n"""' }];
+ var options = { model: await resolveModel(req.body.model), temperature: 0.2 };
+ var tools = sources.wantsImages ? imageTool.tools : [];
+
+ var ai = await callAI(messages, tools.length ? Object.assign({}, options, { tools: tools }) : options);
+ if (sources.wantsImages) {
+ ai = await imageTool.dispatch(ai, {
+ owner: req.user.id, workflow: 'my_resources', body: req.body,
+ imageContext: generatedImages.imageContext(subject, []), imageModel: sources.imageModel,
+ messages: messages, options: options, callAI: callAI
+ });
+ }
var revised = String((ai && ai.content) || '').trim();
if (!revised) return res.status(502).json({ error: 'The model returned nothing. Try again.' });
@@ -355,7 +431,14 @@ router.post('/my-resources/:id/refine', async function (req, res) {
'WHERE id = ? AND user_id = ? RETURNING id, title, updated_at',
[revised, firstHeading(revised), existing.id, req.user.id]
);
- res.json({ success: true, resource: row, markdown: revised, model: ai && ai.model });
+ res.json({
+ success: true, resource: row, markdown: revised,
+ grounding: { used: Boolean(sources.corpus.context), count: sources.corpus.sources.length,
+ reason: sources.corpus.reason || null },
+ searches: sources.searches,
+ imageJobs: ai.imageJobs || [],
+ model: ai && ai.model
+ });
} catch (err) {
console.error('[my-resources] refine:', err.message);
res.status(err.statusCode || 500).json({ error: err.statusCode ? err.message : 'Refinement failed' });
diff --git a/src/utils/pubmedSearch.js b/src/utils/pubmedSearch.js
index b5e47d5e..67165d6a 100644
--- a/src/utils/pubmedSearch.js
+++ b/src/utils/pubmedSearch.js
@@ -100,6 +100,39 @@ async function fetchAbstracts(pmids, s) {
* Never throws, for the same reason corpus retrieval and web search do not: a
* lookup failing must not fail the resource someone is writing.
*/
+// PubMed ANDs every term after automatic mapping, so a single unrecognised word
+// takes the whole query to zero: "febrile seizures" returns six results,
+// "febrile seizures in under-fives" returns none. A topic written by a person is
+// full of such words, and a resource asking to be grounded in the literature
+// should not come back empty because of one of them.
+//
+// So a query that finds nothing is retried against progressively shorter
+// versions of itself, stopping at the first that finds something. The extra
+// requests only happen on the path that would otherwise have returned nothing.
+var STOPWORDS = new Set(('a an and are as at be but by for from how in into is it its of on or '
+ + 'that the their there these this to under over with what when where which who why show shows '
+ + 'showed evidence review overview update current recent latest new approach approaches '
+ + 'management use using guidance guidelines').split(' '));
+
+function contentWords(text) {
+ // Anything after a colon or dash is almost always a subtitle, not a subject.
+ var head = String(text).split(/[:\u2014\u2013]|\s-\s/)[0];
+ return head.toLowerCase().replace(/[^a-z0-9\s-]/g, ' ').split(/\s+/)
+ .filter(function (w) { return w.length > 2 && !STOPWORDS.has(w); });
+}
+
+// Longest first, so the most specific query that works is the one used.
+function candidates(text) {
+ var words = contentWords(text);
+ var tried = [String(text).trim()];
+ for (var n = Math.min(words.length, 4); n >= 2; n--) {
+ var candidate = words.slice(0, n).join(' ');
+ if (candidate && tried.indexOf(candidate) === -1) tried.push(candidate);
+ }
+ if (words.length === 1 && tried.indexOf(words[0]) === -1) tried.push(words[0]);
+ return tried;
+}
+
async function search(query) {
var text = String(query || '').trim().slice(0, 400);
if (!text) return { results: [], reason: 'empty query' };
@@ -107,14 +140,31 @@ async function search(query) {
var s = await settings();
if (!s.enabled) return { results: [], reason: 'PubMed search is disabled' };
- var searchUrl = withCommon(new URL(BASE + '/esearch.fcgi'), s);
- searchUrl.searchParams.set('db', 'pubmed');
- searchUrl.searchParams.set('term', text);
- searchUrl.searchParams.set('retmode', 'json');
- searchUrl.searchParams.set('retmax', String(MAX_RESULTS));
- searchUrl.searchParams.set('sort', 'relevance');
- var found = await getJson(searchUrl);
- var pmids = ((found.esearchresult || {}).idlist || []).slice(0, MAX_RESULTS);
+ async function idsFor(term) {
+ var searchUrl = withCommon(new URL(BASE + '/esearch.fcgi'), s);
+ searchUrl.searchParams.set('db', 'pubmed');
+ searchUrl.searchParams.set('term', term);
+ searchUrl.searchParams.set('retmode', 'json');
+ searchUrl.searchParams.set('retmax', String(MAX_RESULTS));
+ searchUrl.searchParams.set('sort', 'relevance');
+ var found = await getJson(searchUrl);
+ return ((found.esearchresult || {}).idlist || []).slice(0, MAX_RESULTS);
+ }
+
+ // NCBI allows three requests a second without an API key and ten with one.
+ // The ladder can make three esearch calls before the esummary and efetch
+ // that follow, which is enough to trip that and turn a working search into a
+ // 429 — measured, on exactly this path. So retries are spaced; the first
+ // attempt, which is the usual case, waits for nothing.
+ var tries = candidates(text);
+ var gap = s.apiKey ? 120 : 380;
+ var used = tries[0];
+ var pmids = [];
+ for (var t = 0; t < tries.length && !pmids.length; t++) {
+ if (t) await new Promise(function (done) { setTimeout(done, gap); });
+ used = tries[t];
+ pmids = await idsFor(used);
+ }
if (!pmids.length) return { results: [], reason: 'no results' };
var summaryUrl = withCommon(new URL(BASE + '/esummary.fcgi'), s);
@@ -138,7 +188,10 @@ async function search(query) {
abstract: abstracts[pmid] || ''
};
});
- return { results: results, reason: null };
+ // Saying which query worked matters: the screen reports what was searched,
+ // and reporting the phrase someone typed when a narrowed one found the
+ // results would be a lie.
+ return { results: results, reason: null, query: used };
} catch (e) {
return { results: [], reason: e.message || 'PubMed search failed' };
}
diff --git a/test/my-resources.test.js b/test/my-resources.test.js
index a0e1f53c..c2471352 100644
--- a/test/my-resources.test.js
+++ b/test/my-resources.test.js
@@ -94,7 +94,7 @@ test('the screen is reachable by anyone signed in, and states that it is private
// Said in the header. It used to be repeated in a paragraph below; the claim
// is what matters, not that it was made twice.
- assert.match(component, /Private to you/);
+ assert.match(component, /Only you can see these/);
});
test('a row offers the right formats, and the download carries its auth', () => {
@@ -132,7 +132,7 @@ test('illustration is opt-in, and reuses the assistant’s image tool', () => {
const route = read('src/routes/myResources.js');
// A model handed a drawing tool will find a reason to use it, so the tool is
// only offered when the author asked for one.
- assert.match(route, /var wantsImages = String\(req\.body\.withImages\) === 'true'/);
+ assert.match(route, /var wantsImages = String\(body\.withImages\) === 'true'/);
// Opt-in is the checkbox's own default, which is the fact worth pinning —
// stronger than the sentence that used to explain it.
assert.match(read('public/components/my-resources.html'),
@@ -142,7 +142,7 @@ test('illustration is opt-in, and reuses the assistant’s image tool', () => {
// model to decide there should be a picture and to compose the prompt for it.
// Search does not — see web-search.test.js for why both searches were taken
// away from the model and run by the route instead.
- assert.match(route, /if \(wantsImages && imageModel\) tools = tools\.concat\(imageTool\.tools\);/);
+ assert.match(route, /if \(wantsImages\) tools = tools\.concat\(imageTool\.tools\);/);
assert.doesNotMatch(route, /tools\.concat\((?:webSearch|pubmedSearch)\.tools\)/);
// The same dispatcher the assistant uses, so an image made here is owned,
// queued and rendered identically to one made there.
@@ -180,7 +180,7 @@ test('illustration is opt-in, and reuses the assistant’s image tool', () => {
assert.match(route, /imageJobs: ai\.imageJobs \|\| \[\]/, 'and reported back');
// The row is hidden entirely when no image model is configured.
- assert.match(read('public/js/myResources.js'), /if \(imagesRow\) imagesRow\.hidden = !data\.imagesAvailable;/);
+ assert.match(read('public/js/myResources.js'), /\['mr-images-row', 'images'\]/);
});
test('the author can ask for the illustration, not only leave it to the model', () => {
@@ -200,9 +200,18 @@ test('the author can ask for the illustration, not only leave it to the model',
// Said once on screen too: the label points at Instructions, and the
// Instructions placeholder shows what asking for one looks like.
const html = read('public/components/my-resources.html');
- assert.match(html, /say what to draw in Instructions/);
assert.match(html, /include a diagram of the airway/);
assert.match(html, /Add one illustration/, 'and the count is stated where the choice is made');
+
+ // Saying it in the instructions is as clear as ticking the box, so the box
+ // follows rather than the request being dropped in silence.
+ const js = read('public/js/myResources.js');
+ assert.match(js, /function looksLikeImageRequest/);
+ assert.match(js, /Illustration switched on, because your instructions ask for a figure/);
+ assert.match(js, /no image model is configured, so none can be made/);
+ assert.match(js, /if \(!check\.checked\) overruled = true;/, 'and switching it off by hand sticks');
+ assert.match(js, /wireImageIntent\('mr-refinement', 'mr-with-images', 'mr-image-hint'\)/);
+ assert.match(js, /wireImageIntent\('mr-modify-instructions', 'mr-modify-images', 'mr-modify-image-hint'\)/);
});
test('the library is bounded, searchable, and drives the modify picker', () => {
diff --git a/test/web-search.test.js b/test/web-search.test.js
index c02c6d5f..1edc319e 100644
--- a/test/web-search.test.js
+++ b/test/web-search.test.js
@@ -98,10 +98,16 @@ test('searching is the route\u2019s job, not something the model is asked to do'
// 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\(req\.body\.withWebSearch\) === 'true'/);
+ 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\(topic\);/);
- assert.match(route, /var papers = await pubmedSearch\.search\(topic\);/);
+ 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
@@ -112,8 +118,8 @@ test('searching is the route\u2019s job, not something the model is asked to do'
// 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: topic, count: papers\.results\.length, reason: papers\.reason \}\);/);
- assert.match(route, /searches\.push\(\{ tool: 'web_search', query: topic, count: pages\.results\.length, reason: pages\.reason \}\);/);
+ 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.
@@ -121,6 +127,40 @@ test('searching is the route\u2019s job, not something the model is asked to do'
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.
@@ -137,9 +177,17 @@ 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 search query leaves this network/);
- assert.match(mine, /Do not put anything identifying in the topic/);
+ 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.
- assert.match(read('public/js/myResources.js'), /if \(webRow\) webRow\.hidden = !data\.webSearchAvailable;/);
+ // 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\]\]/);
});