feat: PubMed search for My Resources, and an image tool that actually fires
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s

PubMed joins web search as an optional source for a generated resource: a
literature search on the topic, with abstracts, cited by PMID in References.
Off by default, admin-enabled, with its own optional API key (NCBI raises the
rate limit from 3/sec to 10/sec; it works without one).

Neither search is a tool any more, and that is the point. Offering them as
function calls meant the model decided whether to search, and with a prompt
ending "Output ONLY Pandoc markdown" it decided not to — every time, with and
without corpus grounding, no matter how the tool description was worded.
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 now run up
front and their results go into the prompt as findings, exactly the way corpus
excerpts do. Ticking the box now means the search happened.

Verified live against deepseek-v4-flash: 30 corpus excerpts and 6 PubMed
results, and a References slide carrying both the library sources and four real
PMIDs (29562151, 38506440, 35721052, 28814254).

Three fixes to illustration, which had never once fired:

- The dispatch call had been lost in a refactor. The tool was still offered, the
  model still called it, and the call was dropped, so no job was ever enqueued.
- imageContext was passed as a bare topic string where dispatch expects
  { request, history }, which made the bound request undefined.
- The prompt never mentioned the tool existed while explicitly demanding only
  markdown — the same suppression that killed the searches. It now says an
  illustration is available and that calling it is not a violation of that rule.

my_resources is its own image workflow rather than a reuse of learning_hub,
because generated_image_links only accepts learning_hub assets, and that is
exactly the barrier that keeps a private illustration out of published content.
The illustration renders in the panel, rather than a toast pointing at an image
history this feature does not have.

Verified end to end: job queued, rendered, and the asset served to its owner as
a correctly labelled subglottic-anatomy teaching diagram.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-11 16:47:08 +02:00
parent 60556aae28
commit 1270899dcb
15 changed files with 491 additions and 94 deletions

View file

@ -53,7 +53,7 @@ clamped on read so a bad value cannot break a search.
| Feature | Keys | Default | Clamp |
|---|---|---|---|
| Clinical Assistant | `clinical_assistant.search_limit`, `clinical_assistant.context_chars` | 12, 1400 | 320, 3004000 |
| Clinical Assistant | `clinical_assistant.search_limit`, `clinical_assistant.context_chars` | 8, 1400 | 320, 3004000 |
| Learning Hub | `learning.search_limit`, `learning.context_chars` | 30, 2500 | 360, 3008000 |
| My Resources | *the same `learning.*` keys* | 30, 2500 | 360, 3008000 |

View file

@ -0,0 +1,24 @@
// Illustration for a person's own resources.
//
// A third image workflow rather than a reuse of learning_hub, because the two
// have opposite visibility rules. A learning_hub asset can be linked into
// published content and served to everyone; generated_image_links enforces
// that by requiring workflow='learning_hub', which is exactly the guarantee
// this feature needs to keep. Filing a private illustration under learning_hub
// would make it linkable into published content by anyone who knew its id.
//
// Nothing else is needed to serve them: asset() already grants the owner, so a
// my_resources image is visible to the person who made it and to nobody else.
exports.up = pgm => pgm.sql(`
ALTER TABLE generated_image_jobs DROP CONSTRAINT IF EXISTS generated_image_jobs_workflow_check;
ALTER TABLE generated_image_jobs ADD CONSTRAINT generated_image_jobs_workflow_check
CHECK (workflow IN ('clinical_assistant', 'learning_hub', 'my_resources'));
`);
exports.down = pgm => pgm.sql(`
DELETE FROM generated_image_jobs WHERE workflow='my_resources';
ALTER TABLE generated_image_jobs DROP CONSTRAINT IF EXISTS generated_image_jobs_workflow_check;
ALTER TABLE generated_image_jobs ADD CONSTRAINT generated_image_jobs_workflow_check
CHECK (workflow IN ('clinical_assistant', 'learning_hub'));
`);

View file

@ -419,7 +419,7 @@
<!-- ── Web search ─────────────────────────────────────────────── -->
<div class="card">
<div class="card-header">
<h3><i class="fas fa-globe"></i> Web Search</h3>
<h3><i class="fas fa-globe"></i> Search Sources</h3>
<span style="font-size:12px;color:var(--g500);">Off by default</span>
</div>
<div style="padding:16px;display:flex;flex-direction:column;gap:14px;">
@ -470,8 +470,51 @@
</div>
</div>
<div style="border-top:1px solid var(--g100);padding-top:12px;">
<div style="font-size:13px;font-weight:600;color:var(--g700);margin-bottom:8px;">
<i class="fas fa-book-medical"></i> PubMed
</div>
<p style="margin:0 0 10px;font-size:12px;color:var(--g500);">
A separate source from the web providers above, and a separate tick box for
authors. It returns structured records &mdash; title, journal, year, PMID &mdash;
so a reference can be exact rather than reconstructed from a page title. Queries
go to NCBI, not to a commercial provider.
</p>
<div class="admin-row">
<strong class="admin-row-label">Enabled</strong>
<div style="flex:1;min-width:0;">
<select id="pm-enabled" class="admin-control" style="max-width:200px;">
<option value="false">Disabled</option>
<option value="true">Enabled</option>
</select>
</div>
</div>
<div class="admin-row">
<label for="pm-api-key" class="admin-row-label">NCBI API key</label>
<div style="flex:1;display:flex;flex-direction:column;gap:4px;min-width:0;">
<input id="pm-api-key" type="password" class="admin-control" placeholder="Optional — leave blank to keep the current key" autocomplete="off">
<p style="margin:0;font-size:12px;color:var(--g500);">
Optional. PubMed works without one; a key raises the rate limit from 3
requests per second to 10, which matters for bulk work rather than for one
person writing a resource. Create one at
<a href="https://account.ncbi.nlm.nih.gov/settings/" target="_blank" rel="noopener noreferrer">account.ncbi.nlm.nih.gov/settings</a> &mdash; sign in, then <strong>API Key Management</strong> &rarr; <strong>Create an API Key</strong>.
</p>
</div>
</div>
<div class="admin-row">
<label for="pm-email" class="admin-row-label">Contact email</label>
<div style="flex:1;display:flex;flex-direction:column;gap:4px;min-width:0;">
<input id="pm-email" type="email" class="admin-control" placeholder="you@example.com">
<p style="margin:0;font-size:12px;color:var(--g500);">NCBI asks callers to identify themselves so they can contact you before blocking you. Optional but courteous.</p>
</div>
</div>
</div>
<div style="border-top:1px solid var(--g100);padding-top:12px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
<button id="btn-save-websearch" class="btn-sm btn-primary" type="button"><i class="fas fa-floppy-disk"></i> Save web search settings</button>
<button id="btn-save-websearch" class="btn-sm btn-primary" type="button"><i class="fas fa-floppy-disk"></i> Save search settings</button>
<button id="btn-test-websearch" class="btn-sm btn-ghost" type="button"><i class="fas fa-vial"></i> Test</button>
<span id="ws-status" role="status" style="font-size:12px;color:var(--g500);"></span>
</div>

View file

@ -89,6 +89,21 @@
</div>
</div>
<div class="admin-row" id="mr-pubmed-row" hidden>
<strong class="admin-row-label">PubMed</strong>
<div style="flex:1;display:flex;flex-direction:column;gap:4px;min-width:0;">
<label style="display:flex;align-items:center;gap:8px;font-size:13px;">
<input type="checkbox" id="mr-pubmed">
Let the model cite published literature
</label>
<p style="margin:0;font-size:12px;color:var(--g500);">
Searches PubMed and returns structured records, so a reference carries a PMID somebody
can look up. <strong>The search query goes to NCBI</strong>; the library excerpts and
the resource itself do not.
</p>
</div>
</div>
<div class="admin-row" style="align-items:flex-start;">
<label for="mr-refinement" class="admin-row-label">Instructions <span style="color:var(--g400);font-weight:400;">(optional)</span></label>
<textarea id="mr-refinement" class="admin-control" style="min-height:70px;resize:vertical;font-family:inherit;" placeholder="e.g. for FY1s, case-based, emphasise red flags"></textarea>
@ -98,6 +113,11 @@
<button id="btn-mr-generate" class="btn-sm btn-primary" type="button"><i class="fas fa-wand-magic-sparkles"></i> Generate</button>
<span id="mr-status" role="status" style="font-size:12px;color:var(--g600);"></span>
</div>
<!-- An illustration the model asked for. It renders here rather than in an
image history, because this feature does not have one and telling
someone to look somewhere that does not exist is worse than silence. -->
<div id="mr-images" style="display:flex;flex-direction:column;gap:10px;"></div>
</div>
</div>

View file

@ -430,8 +430,12 @@ function adminTabActive() {
set('ws-enabled', cfg['websearch.enabled'] === 'true' ? 'true' : 'false');
set('ws-provider', cfg['websearch.provider'] || 'tavily');
set('ws-base-url', cfg['websearch.base_url']);
set('pm-enabled', cfg['pubmed.enabled'] === 'true' ? 'true' : 'false');
set('pm-email', cfg['pubmed.contact_email']);
var key = document.getElementById('ws-api-key');
if (key) key.placeholder = cfg['websearch.api_key'] || 'Leave blank to keep the current key';
var pmKey = document.getElementById('pm-api-key');
if (pmKey) pmKey.placeholder = cfg['pubmed.api_key'] || 'Optional — leave blank to keep the current key';
})
.catch(function() {});
}
@ -445,15 +449,20 @@ function adminTabActive() {
enabled: (document.getElementById('ws-enabled') || {}).value,
provider: (document.getElementById('ws-provider') || {}).value,
apiKey: (document.getElementById('ws-api-key') || {}).value,
baseUrl: (document.getElementById('ws-base-url') || {}).value
baseUrl: (document.getElementById('ws-base-url') || {}).value,
pubmedEnabled: (document.getElementById('pm-enabled') || {}).value,
pubmedApiKey: (document.getElementById('pm-api-key') || {}).value,
pubmedEmail: (document.getElementById('pm-email') || {}).value
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) throw new Error(data.error || 'Save failed');
if (status) status.textContent = 'Saved ' + new Date().toLocaleTimeString() + '.';
var key = document.getElementById('ws-api-key');
if (key) key.value = '';
['ws-api-key', 'pm-api-key'].forEach(function (id) {
var field = document.getElementById(id);
if (field) field.value = '';
});
showToast('Web search settings saved', 'success');
loadWebSearch();
})
@ -472,10 +481,12 @@ function adminTabActive() {
.then(function(r) { return r.json(); })
.then(function(data) {
if (!status) return;
status.textContent = data.success
? data.count + ' results from ' + data.provider + ' — ' +
(data.sample || []).map(function(x) { return x.title; }).slice(0, 2).join('; ')
: 'No results: ' + (data.reason || 'unknown');
// Both sources, separately, so one press says which of them works.
var web = data.web || {};
var pubmed = data.pubmed || {};
status.textContent =
'Web: ' + (web.reason ? web.reason : web.count + ' from ' + web.provider) +
' · PubMed: ' + (pubmed.reason ? pubmed.reason : pubmed.count + ' records');
status.style.color = data.success ? 'var(--green)' : 'var(--red)';
})
.catch(function(err) { if (status) { status.textContent = err.message; status.style.color = 'var(--red)'; } });

View file

@ -145,7 +145,7 @@ export function renderImageJobs(container, jobs, workflow, onDone) {
const card = document.createElement('section'); card.className = 'assistant-image-card'; const status = document.createElement('p');
status.setAttribute('role', 'status');
card.append(status); container.append(card);
const base = workflow === 'learning_hub' ? '/api/admin/learning/image/jobs/' : '/api/clinical-assistant/image/jobs/';
const base = { learning_hub: '/api/admin/learning/image/jobs/', my_resources: '/api/my-resources/image/jobs/' }[workflow] || '/api/clinical-assistant/image/jobs/';
async function poll() {
if (!validSharingOwner(ticket) || !card.isConnected) return;
try {

View file

@ -64,6 +64,8 @@
// 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;
})
.catch(function () { /* the defaults still work without this */ });
}
@ -104,7 +106,8 @@
useCorpus: corpusBox && corpusBox.checked === false ? 'false' : 'true',
model: (document.getElementById('mr-model') || {}).value || '',
withImages: (document.getElementById('mr-with-images') || {}).checked ? 'true' : 'false',
withWebSearch: (document.getElementById('mr-web-search') || {}).checked ? 'true' : 'false'
withWebSearch: (document.getElementById('mr-web-search') || {}).checked ? 'true' : 'false',
withPubmed: (document.getElementById('mr-pubmed') || {}).checked ? 'true' : 'false'
})
})
.then(function (r) { return r.json(); })
@ -119,14 +122,14 @@
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.
if (data.webSearch && typeof showToast === 'function') {
showToast(data.webSearch.count
? 'Searched the web for "' + data.webSearch.query + '" — ' + data.webSearch.count + ' results used.'
: 'Web search found nothing for "' + data.webSearch.query + '".', 'info');
}
if ((data.imageJobs || []).length && typeof showToast === 'function') {
showToast('An illustration is being generated; it will appear in your image history.', 'info');
}
(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');
});
showIllustrations(data.imageJobs || []);
loadLibrary();
})
.catch(function (err) { status(err.message, 'bad'); })
@ -135,6 +138,28 @@
});
}
// 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
// import — which also means a failure to load it cannot break generation.
function showIllustrations(jobs) {
var box = document.getElementById('mr-images');
if (!box) return;
box.innerHTML = '';
if (!jobs.length) return;
import('/js/generatedImages.js').then(function (m) {
m.renderImageJobs(box, jobs, 'my_resources', function (card, data) {
var img = document.createElement('img');
img.alt = 'Generated teaching illustration';
img.style.maxWidth = '100%';
card.append(img);
m.hydrateImage(img, data.imageUrl).catch(function () { img.alt = 'Private image unavailable'; });
});
}).catch(function () {
if (typeof showToast === 'function') showToast('An illustration was generated but could not be displayed.', 'info');
});
}
function loadLibrary() {
var list = document.getElementById('mr-list');
if (!list) return;

View file

@ -974,7 +974,8 @@ router.put('/config/:key(*)', async function(req, res) {
// Its own routes rather than the generic config setter, because the key must be
// masked on read and preserved when the field is left blank — the same handling
// the OIDC client secret gets.
var WEBSEARCH_KEYS = ['websearch.enabled', 'websearch.provider', 'websearch.api_key', 'websearch.base_url'];
var WEBSEARCH_KEYS = ['websearch.enabled', 'websearch.provider', 'websearch.api_key', 'websearch.base_url',
'pubmed.enabled', 'pubmed.api_key', 'pubmed.contact_email'];
router.get('/websearch', adminMiddleware, async function (req, res) {
try {
@ -983,9 +984,9 @@ router.get('/websearch', adminMiddleware, async function (req, res) {
out[WEBSEARCH_KEYS[i]] = await db.getSetting(WEBSEARCH_KEYS[i], '') || '';
}
// Never send the key back. Enough tail to recognise which one is set.
if (out['websearch.api_key']) {
out['websearch.api_key'] = '••••••••' + out['websearch.api_key'].slice(-4);
}
['websearch.api_key', 'pubmed.api_key'].forEach(function (k) {
if (out[k]) out[k] = '••••••••' + out[k].slice(-4);
});
res.json({ success: true, config: out });
} catch (err) {
logger.error('GET /admin/websearch', err.message);
@ -1005,8 +1006,16 @@ router.put('/websearch', adminMiddleware, async function (req, res) {
// A blank field means "leave it alone", so editing the provider does not
// silently wipe the key that was already working.
await db.setSetting('pubmed.enabled', String(req.body.pubmedEnabled) === 'true' ? 'true' : 'false');
await db.setSetting('pubmed.contact_email', String(req.body.pubmedEmail || '').trim().slice(0, 200));
// A blank field means "leave it alone", so editing anything else does not
// silently wipe a key that was already working. The mask can never be saved
// back as a key.
var key = String(req.body.apiKey || '').trim();
if (key && key.indexOf('•') === -1) await db.setSetting('websearch.api_key', key.slice(0, 400));
var pmKey = String(req.body.pubmedApiKey || '').trim();
if (pmKey && pmKey.indexOf('•') === -1) await db.setSetting('pubmed.api_key', pmKey.slice(0, 400));
res.json({ success: true });
} catch (err) {
@ -1017,14 +1026,16 @@ router.put('/websearch', adminMiddleware, async function (req, res) {
router.post('/websearch/test', adminMiddleware, async function (req, res) {
try {
var webSearch = require('../utils/webSearch');
var found = await webSearch.search(String(req.body.query || 'paediatric bronchiolitis guideline'));
var query = String(req.body.query || 'paediatric bronchiolitis guideline');
// Both sources, so one press says which of them actually works.
var web = await require('../utils/webSearch').search(query);
var pubmed = await require('../utils/pubmedSearch').search(query);
res.json({
success: !found.reason,
provider: found.provider || null,
count: found.results.length,
reason: found.reason || null,
sample: found.results.slice(0, 3).map(function (r) { return { title: r.title, url: r.url }; })
success: !web.reason || !pubmed.reason,
web: { provider: web.provider || null, count: web.results.length, reason: web.reason || null,
sample: web.results.slice(0, 2).map(function (r) { return { title: r.title, url: r.url }; }) },
pubmed: { count: pubmed.results.length, reason: pubmed.reason || null,
sample: pubmed.results.slice(0, 2).map(function (r) { return { title: r.title, pmid: r.pmid }; }) }
});
} catch (err) {
res.status(502).json({ success: false, reason: err.message });

View file

@ -45,7 +45,7 @@ router.get('/generated-images/:id', async (req, res) => {
});
router.get('/image-jobs/:workflow', async (req, res) => {
try {
if (!['clinical_assistant', 'learning_hub'].includes(req.params.workflow)) throw images.failure(404, 'Workflow not found');
if (!['clinical_assistant', 'learning_hub', 'my_resources'].includes(req.params.workflow)) throw images.failure(404, 'Workflow not found');
const result = await db.query('SELECT id,stage,model,error_code,context_included,context_total,prompt_units,budget FROM generated_image_jobs WHERE owner_id=$1 AND workflow=$2 ORDER BY created_at DESC LIMIT 100', [req.user.id, req.params.workflow]);
res.json({ success: true, jobs: result.rows.map(images.publicJob) });
} catch (e) { fail(res, e); }

View file

@ -21,7 +21,9 @@ var { authMiddleware } = require('../middleware/auth');
var { callAI } = require('../utils/ai');
var learningRetrieval = require('../utils/learningRetrieval');
var imageTool = require('../utils/imageTool');
var generatedImages = require('../utils/generatedImages');
var webSearch = require('../utils/webSearch');
var pubmedSearch = require('../utils/pubmedSearch');
var documentExport = require('../utils/documentExport');
// Scoped to this router's own prefix. Mounted on /api, a bare
@ -78,6 +80,37 @@ function buildPrompt(opts) {
'LIBRARY EXCERPTS:\n"""\n' + opts.corpusContext + '\n"""\n'
: '';
// Literature and web findings arrive the same way corpus excerpts do: as
// material in the prompt, not as something the model has to ask for. An
// earlier version offered these as tools and the model never called them —
// it had been told to output only Pandoc markdown, and it obeyed that
// instead. Searching first is also deterministic: ticking the box now means
// the search happened, rather than that the model was allowed to consider it.
var findings = '';
if (opts.literature) {
findings += "\nPublished literature found for this topic. Cite what you use by PMID in the " +
"References section, and do not cite anything not listed here.\n\n" +
"PUBMED RESULTS:\n\"\"\"\n" + opts.literature + "\n\"\"\"\n";
}
if (opts.webFindings) {
findings += "\nCurrent material from the web. Use it for anything more recent than the " +
"library, and list what you use in the References section by title and URL.\n\n" +
"WEB RESULTS:\n\"\"\"\n" + opts.webFindings + "\n\"\"\"\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
// "Output ONLY Pandoc markdown" reads as a prohibition: the model returned
// prose and never called the tool, exactly as the search tools failed.
var illustration = opts.wantsImages
? '\nAn illustration tool is available and the author has asked for one. If a diagram or ' +
'picture would genuinely help this topic, call generate_image ONCE before writing, with a ' +
'description of the single most useful figure. It must be schematic or anatomical teaching ' +
'artwork, never a depiction of a real patient. The "output only Pandoc markdown" rule below ' +
'is about the written resource; the tool call is not a violation of it. Do not write an ' +
'image tag or a URL into the markdown \u2014 the image is attached separately.\n'
: '';
var shape = kind === 'presentation'
? 'Write a ' + opts.slideCount + '-slide teaching presentation.\n\n' +
'Output ONLY Pandoc markdown:\n' +
@ -92,7 +125,7 @@ function buildPrompt(opts) {
'sections. Use prose, not slide bullets.\n';
return 'You are writing teaching material for a medical professional audience ' +
'(pediatrics / primary care).\n\nTOPIC: ' + opts.topic + '\n' + grounding + '\n' + shape +
'(pediatrics / primary care).\n\nTOPIC: ' + opts.topic + '\n' + grounding + findings + illustration + '\n' + shape +
(opts.refinement ? '\nAdditional instructions: ' + opts.refinement + '\n' : '');
}
@ -103,6 +136,16 @@ function firstHeading(markdown, fallback) {
// What this user may choose. Driven entirely by admin settings, so the screen
// shows a single fixed model until an admin allows more — exactly like chat.
// Polling for an illustration this feature queued. Scoped to the caller and to
// this workflow, so it can only ever report on an image the caller made here.
router.get('/my-resources/image/jobs/:id', async function (req, res) {
try {
res.json(await require('../utils/generatedImages').service().get(req.params.id, req.user.id, 'my_resources'));
} catch (err) {
res.status(err.statusCode || 500).json({ error: err.statusCode ? err.message : 'Image status unavailable' });
}
});
router.get('/my-resources/options', async function (req, res) {
try {
var models = await allowedModels();
@ -111,7 +154,8 @@ router.get('/my-resources/options', async function (req, res) {
models: models.allowed,
defaultModel: models.configured,
imagesAvailable: Boolean(await db.getSetting('clinical_assistant.image_model', '')),
webSearchAvailable: await webSearch.isAvailable()
webSearchAvailable: await webSearch.isAvailable(),
pubmedAvailable: await pubmedSearch.isAvailable()
});
} catch (err) {
console.error('[my-resources] options:', err.message);
@ -139,12 +183,6 @@ router.post('/my-resources/generate', async function (req, res) {
var corpus = { sources: [], context: '', reason: 'not requested' };
if (useCorpus) corpus = await learningRetrieval.retrieve(topic, db.getSetting);
var prompt = buildPrompt({
topic: topic, kind: kind, refinement: refinement, corpusContext: corpus.context,
slideCount: clampInt(req.body.slideCount, 3, 30, 8),
wordCount: clampInt(req.body.wordCount, 200, 3000, 800)
});
// 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.
@ -153,50 +191,60 @@ router.post('/my-resources/generate', async function (req, res) {
// 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 prompt = buildPrompt({
topic: topic, kind: kind, refinement: refinement, corpusContext: corpus.context,
literature: literature, webFindings: webFindings,
wantsImages: Boolean(wantsImages && imageModel),
slideCount: clampInt(req.body.slideCount, 3, 30, 8),
wordCount: clampInt(req.body.wordCount, 200, 3000, 800)
});
var model = await resolveModel(req.body.model);
var messages = [{ role: 'user', content: prompt }];
var options = { model: model, temperature: 0.3 };
// Tools the model may reach for on this generation, and only these.
// Only the image tool stays a tool. Illustration genuinely needs the model to
// 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 (wantsWeb) tools = tools.concat(webSearch.tools);
var ai = await callAI(messages, tools.length ? Object.assign({}, options, { tools: tools }) : options);
// A web_search call is answered here and the model asked to continue, so
// the search result reaches the resource as material rather than as a tool
// transcript. One round only: a model allowed to keep searching will.
var webUsed = null;
if (wantsWeb && ai.toolCalls && ai.toolCalls.length) {
var call = ai.toolCalls.find(function (c) {
return c && c.function && c.function.name === 'web_search';
});
if (call) {
var args = {};
try { args = JSON.parse(call.function.arguments || '{}'); } catch (e) { args = {}; }
var found = await webSearch.search(args.query);
webUsed = { query: String(args.query || ''), count: found.results.length, reason: found.reason };
ai = await callAI(messages.concat([
{ role: 'assistant', content: null, tool_calls: [call] },
{ role: 'tool', tool_call_id: call.id, content: found.results.length
? webSearch.formatForPrompt(found.results)
: 'No results. Write the resource without web material.' }
]), Object.assign({}, options, { tools: tools, toolChoice: 'none' }));
}
}
// The same dispatcher the assistant uses, so an image generated here is
// owned, queued and rendered exactly as one generated there.
// 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) {
ai = await imageTool.dispatch(ai, {
owner: req.user.id, workflow: 'my_resources', body: req.body,
imageContext: topic, imageModel: imageModel,
imageContext: generatedImages.imageContext(topic, []), imageModel: imageModel,
messages: messages, options: options, callAI: callAI
});
}
// Searches already ran; the model's only job was to write from what it was
// given. An empty body is a failed generation rather than an empty resource.
var markdown = String((ai && ai.content) || '').trim();
if (!markdown) return res.status(502).json({ error: 'The model returned nothing. Try again.' });
@ -212,7 +260,7 @@ router.post('/my-resources/generate', async function (req, res) {
markdown: markdown,
grounding: { used: Boolean(corpus.context), count: corpus.sources.length, reason: corpus.reason || null },
imageJobs: ai.imageJobs || [],
webSearch: webUsed,
searches: searches,
model: ai && ai.model
});
} catch (err) {

View file

@ -3,7 +3,7 @@ const { DEFAULT_IMAGE_BEHAVIOR, imagePromptForCanvas } = require('./clinicalProm
const storageUtil = require('./generatedImageStorage');
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const failure = (statusCode, message) => Object.assign(new Error(message), { statusCode });
const workflows = ['clinical_assistant', 'learning_hub'];
const workflows = ['clinical_assistant', 'learning_hub', 'my_resources'];
function budgetLimit(value) {
const n = value == null || value === '' ? 32000 : Number(value);
if ((value != null && !['string', 'number'].includes(typeof value)) || !Number.isInteger(n) || n < 1000 || n > 32000) throw failure(400, 'Image budget must be 1000..32000 UTF-16 code units');

167
src/utils/pubmedSearch.js Normal file
View file

@ -0,0 +1,167 @@
// ============================================================
// PUBMED SEARCH
// The literature, as a source of its own.
//
// Deliberately not a provider option under web search. A web result is a page
// whose citation has to be reconstructed from its title; a PubMed record is
// structured — title, journal, year, authors, PMID — so a reference can be
// exact, and a References section can carry a PMID somebody can look up.
//
// A model should also be able to reach for "the literature" distinctly from
// "the web": a resource may legitimately want both, and each gets one search.
//
// NCBI E-utilities needs no key. A key only raises the rate limit from 3
// requests a second to 10, which matters for indexing and not for a person
// generating one resource — so the key is optional and its absence is not a
// misconfiguration. Either way this reaches NCBI, not a commercial third party.
// ============================================================
var db = require('../db/database');
var BASE = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils';
var MAX_RESULTS = 6;
var TIMEOUT_MS = 15000;
// NCBI asks callers to identify themselves so they can contact you before
// blocking you. Free, and the polite thing to do.
var TOOL_NAME = 'ped-ai';
async function settings() {
return {
enabled: String(await db.getSetting('pubmed.enabled', 'false')) === 'true',
apiKey: String(await db.getSetting('pubmed.api_key', '') || ''),
email: String(await db.getSetting('pubmed.contact_email', '') || '')
};
}
async function isAvailable() {
return (await settings()).enabled;
}
function withCommon(url, s) {
url.searchParams.set('tool', TOOL_NAME);
if (s.email) url.searchParams.set('email', s.email);
if (s.apiKey) url.searchParams.set('api_key', s.apiKey);
return url;
}
async function getJson(url) {
var r = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(TIMEOUT_MS) });
if (!r.ok) throw new Error('PubMed returned ' + r.status);
return r.json();
}
function clip(text, n) {
return String(text || '').replace(/\s+/g, ' ').trim().slice(0, n);
}
function yearOf(pubdate) {
var m = String(pubdate || '').match(/\d{4}/);
return m ? m[0] : '';
}
// Abstracts are fetched separately because esummary does not carry them, and
// they are what makes a record useful to write from rather than merely cite.
// Parsed leniently: a missing abstract is normal (editorials, some letters) and
// must not lose the record.
async function fetchAbstracts(pmids, s) {
if (!pmids.length) return {};
try {
var url = withCommon(new URL(BASE + '/efetch.fcgi'), s);
url.searchParams.set('db', 'pubmed');
url.searchParams.set('id', pmids.join(','));
url.searchParams.set('retmode', 'xml');
url.searchParams.set('rettype', 'abstract');
var r = await fetch(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
if (!r.ok) return {};
var xml = await r.text();
var out = {};
// Each <PubmedArticle> holds one PMID and zero or more AbstractText parts,
// which may be labelled (BACKGROUND, METHODS...) in structured abstracts.
String(xml).split('<PubmedArticle>').slice(1).forEach(function (chunk) {
var pmid = (chunk.match(/<PMID[^>]*>(\d+)<\/PMID>/) || [])[1];
if (!pmid) return;
var parts = [];
var re = /<AbstractText[^>]*>([\s\S]*?)<\/AbstractText>/g;
var m;
while ((m = re.exec(chunk)) !== null) {
parts.push(m[1].replace(/<[^>]+>/g, ' '));
}
if (parts.length) out[pmid] = clip(parts.join(' '), 1500);
});
return out;
} catch (e) {
return {};
}
}
/**
* Search PubMed for a topic.
*
* Never throws, for the same reason corpus retrieval and web search do not: a
* lookup failing must not fail the resource someone is writing.
*/
async function search(query) {
var text = String(query || '').trim().slice(0, 400);
if (!text) return { results: [], reason: 'empty query' };
try {
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);
if (!pmids.length) return { results: [], reason: 'no results' };
var summaryUrl = withCommon(new URL(BASE + '/esummary.fcgi'), s);
summaryUrl.searchParams.set('db', 'pubmed');
summaryUrl.searchParams.set('id', pmids.join(','));
summaryUrl.searchParams.set('retmode', 'json');
var summary = (await getJson(summaryUrl)).result || {};
var abstracts = await fetchAbstracts(pmids, s);
var results = pmids.map(function (pmid) {
var record = summary[pmid] || {};
var authors = (record.authors || []).map(function (a) { return a.name; }).filter(Boolean);
return {
pmid: pmid,
title: clip(record.title, 300) || 'Untitled',
journal: clip(record.source, 150),
year: yearOf(record.pubdate),
// Three and "et al" is how a citation reads; the full list is noise here.
authors: authors.slice(0, 3).join(', ') + (authors.length > 3 ? ', et al' : ''),
url: 'https://pubmed.ncbi.nlm.nih.gov/' + pmid + '/',
abstract: abstracts[pmid] || ''
};
});
return { results: results, reason: null };
} catch (e) {
return { results: [], reason: e.message || 'PubMed search failed' };
}
}
// Deliberately not a tool. Offering this as a function call meant the model
// decided whether to search, and with a prompt that ends "output only Pandoc
// markdown" it decided not to, every time. The search only ever needs the
// topic, and the topic is known before the model is called, so the route runs
// it up front and puts the findings in the prompt.
// Formatted so a model can cite it exactly: the PMID is right there, and the
// References section can carry it instead of an approximation.
function formatForPrompt(results) {
return results.map(function (r) {
return [
r.title,
[r.authors, r.journal, r.year].filter(Boolean).join('. '),
'PMID: ' + r.pmid + ' — ' + r.url,
r.abstract || '(no abstract available)'
].join('\n');
}).join('\n\n---\n\n');
}
module.exports = { search, isAvailable, settings, formatForPrompt, MAX_RESULTS };

View file

@ -137,20 +137,12 @@ async function search(query) {
// The tool a model may call. Described tightly: a model given a search tool will
// reach for it constantly unless told when not to.
var tools = [{
type: 'function',
function: {
name: 'web_search',
description: 'Search the public web for current information — a guideline published after your training, a drug approval, an outbreak. ' +
'Do not use it for settled clinical knowledge, which the indexed library and your own training already cover. ' +
'One search per resource at most. Never send patient details or anything identifying.',
parameters: {
type: 'object', additionalProperties: false,
properties: { query: { type: 'string', minLength: 3, maxLength: 400 } },
required: ['query']
}
}
}];
// Deliberately not a tool. Offering this as a function call meant the model
// decided whether to search, and with a prompt that ends "output only Pandoc
// markdown" it decided not to, every time. The search only ever needs the
// topic, and the topic is known before the model is called, so the route runs
// it up front and puts the findings in the prompt.
function formatForPrompt(results) {
return results.map(function (r, i) {
@ -158,4 +150,4 @@ function formatForPrompt(results) {
}).join('\n\n');
}
module.exports = { search, isAvailable, settings, tools, formatForPrompt, PROVIDERS, MAX_RESULTS };
module.exports = { search, isAvailable, settings, formatForPrompt, PROVIDERS, MAX_RESULTS };

View file

@ -133,12 +133,45 @@ test('illustration is opt-in, and reuses the assistants image tool', () => {
// only offered when the author asked for one.
assert.match(route, /var wantsImages = String\(req\.body\.withImages\) === 'true'/);
// Tools are assembled per generation: only what the author asked for.
// Illustration is the only thing left that is genuinely a tool: it needs the
// 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 \(wantsWeb\) tools = tools\.concat\(webSearch\.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.
assert.match(route, /imageTool\.dispatch\(ai, \{/);
assert.match(route, /workflow: 'my_resources'/, 'but attributed to this feature');
// The dispatch call itself. It was lost once in a refactor: the tool was
// still offered, the model still called it, and the call was silently
// dropped, so no job was ever enqueued and imageJobs was always empty.
assert.match(route, /ai = await imageTool\.dispatch\(ai, \{/);
// dispatch expects { request, history }; a bare topic string made the bound
// request undefined and lost the topic entirely.
assert.match(route, /imageContext: generatedImages\.imageContext\(topic, \[\]\)/);
// A model handed a tool schema and then told to "Output ONLY Pandoc markdown"
// obeys the sentence, not the schema — measured: zero tool calls until the
// prompt said the tool existed and that calling it was not a violation.
assert.match(route, /call generate_image ONCE before writing/);
assert.match(route, /is about the written resource; the tool call is not a violation of it/);
assert.match(route, /wantsImages: Boolean\(wantsImages && imageModel\)/);
// Its own workflow, not a reuse of learning_hub: generated_image_links only
// accepts learning_hub assets, and that is exactly the barrier keeping a
// private illustration out of published content.
assert.match(read('src/utils/generatedImages.js'), /const workflows = \['clinical_assistant', 'learning_hub', 'my_resources'\];/);
assert.match(read('migrations/1780400000000_my-resources-images.js'), /CHECK \(workflow IN \('clinical_assistant', 'learning_hub', 'my_resources'\)\)/);
// Status polling is owner-scoped and workflow-scoped, so it can only report
// on an image the caller made here.
assert.match(route, /service\(\)\.get\(req\.params\.id, req\.user\.id, 'my_resources'\)/);
assert.match(read('public/js/generatedImages.js'), /my_resources: '\/api\/my-resources\/image\/jobs\/'/);
// And it renders where the person is looking, rather than pointing them at an
// image history this feature does not have.
assert.match(read('public/js/myResources.js'), /showIllustrations\(data\.imageJobs \|\| \[\]\)/);
assert.match(read('public/components/my-resources.html'), /id="mr-images"/);
assert.match(route, /imageJobs: ai\.imageJobs \|\| \[\]/, 'and reported back');
// The row is hidden entirely when no image model is configured.

View file

@ -86,27 +86,50 @@ test('results are bounded, and a result with no URL is dropped', async () => {
assert.ok(out.results.every(r => r.snippet.length <= 1200), 'snippets clipped');
});
test('the tool tells the model when NOT to search', () => {
const src = read('src/utils/webSearch.js');
// A model given a search tool will reach for it constantly unless told
// otherwise, and settled clinical knowledge is what the corpus is for.
assert.match(src, /Do not use it for settled clinical knowledge/);
assert.match(src, /One search per resource at most/);
assert.match(src, /Never send patient details or anything identifying/);
// One round only, enforced in the route rather than trusted to the model.
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, /toolChoice: 'none'/);
assert.match(route, /var wantsWeb = \(String\(req\.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\);/);
// 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: 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: 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('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.
assert.match(admin, /out\['websearch\.api_key'\] = '••••••••' \+ out\['websearch\.api_key'\]\.slice\(-4\)/);
// 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');
});