pediatric-ai-scribe-v3/src/routes/myResources.js
Daniel 1ad72b134b
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 54s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m22s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
feat: a resource can have several illustrations, on its own path
My Resources used imageTool.dispatch, which permits exactly one image per
request — "Only one image tool invocation is permitted per request". That is the
right rule for a chat reply and the wrong one for a twelve-slide deck where the
author asked for three figures. Rather than relax a limit the clinical assistant
and the Learning Hub also depend on, this adds a separate dispatcher for this
feature. Same queue, same storage, same my_resources workflow, same asset
endpoint — only the number of figures differs, bounded at six because each one
is a paid request.

"Use 3 diagrams" in the instructions is read as the number it is, and the
illustration option now says several are possible rather than promising one.

Three things had to be got right, each found by measuring rather than assuming:

The illustration guidance has to be the last thing in the prompt. Placed before
the output rules it lost — with the tool offered and the paragraph present, the
model returned 3297 characters of markdown and zero tool calls, while the same
tool and wording in a shorter prompt produced three calls.

Even last, it loses to a prompt carrying thirty library excerpts:
deterministically, with the library off "use 3 diagrams" made three calls and
with the library on it made none and wrote a longer deck instead. So when the
author names a number the call is required rather than merely offered. With no
number named the choice stays the model's.

And a model that has just made three tool calls tends to sign off instead of
writing: "I'll create the presentation and the three teaching diagrams." was
returned as the resource — 61 characters — because only a completely empty body
counted as missing. A body with no title block and no heading is not a Pandoc
document whatever its length, and if the continuation is no better than the
first attempt, whichever actually reads like a resource is kept.

Verified end to end with the library on: generate produced three figures and an
eight-slide deck; modify added two more figures and a ninth slide. The figures
were fetched and looked at — labelled airway anatomy, and a croup/epiglottitis/
bacterial tracheitis comparison.

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

508 lines
25 KiB
JavaScript

// ============================================================
// MY RESOURCES
// A person's own generated teaching material.
//
// Deliberately a separate pathway from Learning. Learning is moderator-owned:
// content published into categories that everyone sees. This is the other
// thing — somewhere any signed-in user can generate a deck for tomorrow's
// session, keep it, refine it and export it, without it becoming institutional
// content and without needing to be a moderator to do it at all.
//
// Nothing here is shared. Every statement filters on the owner, and there is no
// route that returns another person's work. Sharing, if it is ever wanted,
// should be a deliberate feature rather than something that leaks out of a
// forgotten WHERE clause.
// ============================================================
var express = require('express');
var router = express.Router();
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var { callAI } = require('../utils/ai');
var learningRetrieval = require('../utils/learningRetrieval');
var resourceImages = require('../utils/resourceImages');
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
// router.use(authMiddleware) would gate every /api path below it in server.js.
router.use('/my-resources', authMiddleware);
var MAX_PER_USER = 100;
var MAX_TITLE = 160;
function clampInt(value, min, max, fallback) {
var n = parseInt(value, 10);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, n));
}
// Presentation and article are the two shapes markdown renders well into, and
// the only two the exporter knows. Anything else is rejected rather than
// guessed at.
// The models a user may choose from are the ones an admin already curates for
// the assistant. A second allow-list would be another thing to keep in step,
// and would let this feature reach a model the institution never approved.
async function allowedModels() {
var configured = String(await db.getSetting('clinical_assistant.chat_model', '') || '').trim();
var allowed = String(await db.getSetting('clinical_assistant.allowed_models', '') || '')
.split(',').map(function (m) { return m.trim(); }).filter(Boolean);
if (configured && allowed.indexOf(configured) === -1) allowed.unshift(configured);
return { configured: configured, allowed: allowed };
}
// Anything not on the list falls back to the configured default rather than
// being refused: a stale option in a browser tab should not cost someone their
// generation.
async function resolveModel(requested) {
var models = await allowedModels();
var wanted = String(requested || '').trim();
return wanted && models.allowed.indexOf(wanted) !== -1 ? wanted : (models.configured || undefined);
}
function normalizeKind(kind) {
return String(kind) === 'article' ? 'article' : 'presentation';
}
function buildPrompt(opts) {
var kind = opts.kind;
var grounding = opts.corpusContext
? '\nThe following excerpts come from this institution\'s indexed clinical library. ' +
'Prefer them over your own recall wherever they disagree, and do not contradict them. ' +
'They are reference material, not a template: write the resource in your own words.\n\n' +
'Do NOT cite in the body: no [1] markers, no bracketed numbers, no parenthetical ' +
'"(Nelson, p. 2604)" inside sentences.\n\n' +
'End with a References section listing only the excerpts you actually drew on, by title ' +
'and page. In a presentation that is the final slide, titled References. Do not invent ' +
'references, and do not list an excerpt you did not use.\n\n' +
'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";
}
// 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
// "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 illustration. ' +
resourceImages.guidance(opts.refinement) + ' It must be schematic or anatomical teaching ' +
'artwork, never a depiction of a real patient. The "output only Pandoc markdown" rule above ' +
'is about the written resource; a tool call is not a violation of it. Do not write an ' +
'image tag or a URL into the markdown \u2014 the images are attached separately.\n' +
// Otherwise the decision is the model's alone, and an author who wants a
// figure of something specific has no way to say so. Instructions are
// free text, so this is what makes "illustrate the airway anatomy" or
// "use three diagrams" actually reach the illustration choice instead of
// only steering the prose.
'If the author\'s additional instructions above name what a figure should show, follow ' +
'them: treat that as the decision already made and compose the image description from ' +
'what they asked for.\n'
: '';
var shape = kind === 'presentation'
? 'Write a ' + opts.slideCount + '-slide teaching presentation.\n\n' +
'Output ONLY Pandoc markdown:\n' +
'- Start with three lines each beginning with %: title, author, date\n' +
'- One level-1 heading (#) per slide; the heading is the slide\'s subject, not "Slide 3:"\n' +
'- Bullets, ordered lists, bold and italics are fine; do not nest lists more than one level\n' +
'- A slide containing a table contains ONLY that table, and a table needs a blank line\n' +
' before and after it, or it will not render as a table\n' +
'- Prefer more slides with less on each; one idea per slide\n'
: 'Write a teaching article of roughly ' + opts.wordCount + ' words.\n\n' +
'Output ONLY Pandoc markdown: a level-1 heading for the title, then level-2 headings for ' +
'sections. Use prose, not slide bullets.\n';
// The illustration paragraph goes last, after the output rules and the
// author's instructions. Placed before them it lost: measured with the tool
// offered, the model returned 3297 characters of markdown and zero tool
// calls, while the same tool and the same wording in a shorter prompt
// produced three calls. A long, emphatic "Output ONLY Pandoc markdown" block
// read afterwards is simply the more recent instruction.
return 'You are writing teaching material for a medical professional audience ' +
'(pediatrics / primary care).\n\nTOPIC: ' + opts.topic + '\n' + grounding + findings + '\n' + shape +
(opts.refinement ? '\nAdditional instructions: ' + opts.refinement + '\n' : '') + illustration;
}
function firstHeading(markdown, fallback) {
var m = String(markdown || '').match(/^%\s*(.+)$/m) || String(markdown || '').match(/^#\s+(.+)$/m);
return (m ? m[1] : fallback || 'Untitled').trim().slice(0, MAX_TITLE);
}
// 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();
res.json({
success: true,
models: models.allowed,
defaultModel: models.configured,
imagesAvailable: Boolean(await db.getSetting('clinical_assistant.image_model', '')),
webSearchAvailable: await webSearch.isAvailable(),
pubmedAvailable: await pubmedSearch.isAvailable()
});
} catch (err) {
console.error('[my-resources] options:', err.message);
res.status(500).json({ error: 'Could not load options' });
}
});
// ── 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 {
var topic = String(req.body.topic || '').trim();
if (!topic) return res.status(400).json({ error: 'A topic is required' });
var kind = normalizeKind(req.body.kind);
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]);
if (count && count.n >= MAX_PER_USER) {
return res.status(409).json({ error: 'You have reached ' + MAX_PER_USER + ' saved resources. Delete one first.' });
}
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: 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)
});
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) tools = tools.concat(resourceImages.tools);
// When the author names a number — "use 3 diagrams" — the call is required
// rather than merely offered. Measured, deterministically: with the library
// switched off the model made three calls, and with thirty library excerpts
// in the prompt it made none and wrote a longer deck instead. The excerpts
// are not wrong to dominate; the request for figures simply has to survive
// them. With no number given the choice stays the model's.
var callOptions = tools.length ? Object.assign({}, options, { tools: tools }) : options;
if (tools.length && resourceImages.requestedCount(refinement)) callOptions.toolChoice = 'required';
var ai = await callAI(messages, callOptions);
// My Resources' own dispatcher, not the assistant's: that one permits a
// single image per request, which is right for a chat reply and wrong for a
// deck. Same queue, same storage, same my_resources workflow — only the
// number of figures differs.
if (wantsImages) {
ai = await resourceImages.dispatch(ai, {
owner: req.user.id, body: req.body, subject: 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.' });
var row = await db.get(
'INSERT INTO user_resources (user_id, title, kind, markdown, topic, grounded_count) ' +
'VALUES (?, ?, ?, ?, ?, ?) RETURNING id, title, kind, topic, grounded_count, created_at',
[req.user.id, firstHeading(markdown, topic), kind, markdown, topic.slice(0, 500), corpus.sources.length]
);
res.json({
success: true,
resource: row,
markdown: markdown,
grounding: { used: Boolean(corpus.context), count: corpus.sources.length, reason: corpus.reason || null },
imageJobs: ai.imageJobs || [],
imageFailures: ai.imageFailures || [],
searches: searches,
model: ai && ai.model
});
} catch (err) {
console.error('[my-resources] generate:', err.message);
res.status(err.statusCode || 500).json({ error: err.statusCode ? err.message : 'Generation failed' });
}
});
// ── The owner's library ─────────────────────────────────────
router.get('/my-resources', async function (req, res) {
try {
var rows = await db.all(
'SELECT id, title, kind, topic, grounded_count, created_at, updated_at ' +
'FROM user_resources WHERE user_id = ? ORDER BY created_at DESC LIMIT ?',
[req.user.id, MAX_PER_USER]
);
res.json({ success: true, resources: rows });
} catch (err) {
console.error('[my-resources] list:', err.message);
res.status(500).json({ error: 'Could not load your resources' });
}
});
router.get('/my-resources/:id', async function (req, res) {
try {
var row = await db.get(
'SELECT id, title, kind, topic, markdown, grounded_count, created_at, updated_at ' +
'FROM user_resources WHERE id = ? AND user_id = ?',
[parseInt(req.params.id, 10), req.user.id]
);
if (!row) return res.status(404).json({ error: 'Not found' });
res.json({ success: true, resource: row });
} catch (err) {
console.error('[my-resources] get:', err.message);
res.status(500).json({ error: 'Could not load that resource' });
}
});
// ── Edit and refine ─────────────────────────────────────────
router.put('/my-resources/:id', async function (req, res) {
try {
var markdown = String(req.body.markdown || '');
if (!markdown.trim()) return res.status(400).json({ error: 'markdown is required' });
var row = await db.get(
'UPDATE user_resources SET markdown = ?, title = ?, updated_at = NOW() ' +
'WHERE id = ? AND user_id = ? RETURNING id, title, updated_at',
[markdown, firstHeading(markdown), parseInt(req.params.id, 10), req.user.id]
);
if (!row) return res.status(404).json({ error: 'Not found' });
res.json({ success: true, resource: row });
} catch (err) {
console.error('[my-resources] update:', err.message);
res.status(500).json({ error: 'Could not save' });
}
});
router.post('/my-resources/:id/refine', async function (req, res) {
try {
var instructions = String(req.body.instructions || '').trim();
if (!instructions) return res.status(400).json({ error: 'Say what to change' });
var existing = await db.get(
'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 illustration. ' +
resourceImages.guidance(instructions) + ' Schematic or anatomical teaching artwork only, ' +
'never a real patient. Returning the markdown is still required; a 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 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 + illustration + material +
'\n\nMARKDOWN:\n"""\n' + existing.markdown + '\n"""' }];
var options = { model: await resolveModel(req.body.model), temperature: 0.2 };
var tools = sources.wantsImages ? resourceImages.tools : [];
var callOptions = tools.length ? Object.assign({}, options, { tools: tools }) : options;
if (tools.length && resourceImages.requestedCount(instructions)) callOptions.toolChoice = 'required';
var ai = await callAI(messages, callOptions);
if (sources.wantsImages) {
ai = await resourceImages.dispatch(ai, {
owner: req.user.id, body: req.body, subject: 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.' });
var row = await db.get(
'UPDATE user_resources SET markdown = ?, title = ?, updated_at = NOW() ' +
'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,
grounding: { used: Boolean(sources.corpus.context), count: sources.corpus.sources.length,
reason: sources.corpus.reason || null },
searches: sources.searches,
imageJobs: ai.imageJobs || [],
imageFailures: ai.imageFailures || [],
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' });
}
});
// ── Export ──────────────────────────────────────────────────
router.get('/my-resources/:id/export', async function (req, res) {
try {
var format = String(req.query.format || 'pptx');
if (!documentExport.isSupported(format)) return res.status(400).json({ error: 'Unsupported format' });
var row = await db.get(
'SELECT title, kind, markdown FROM user_resources WHERE id = ? AND user_id = ?',
[parseInt(req.params.id, 10), req.user.id]
);
if (!row) return res.status(404).json({ error: 'Not found' });
// The UI does not offer it, but the route is the boundary that matters:
// rendering an article as slides produces a deck of paragraphs.
if (row.kind === 'article' && format === 'pptx') {
return res.status(400).json({ error: 'An article has no slides. Download it as Word or PDF.' });
}
var bytes = await documentExport.render(row.markdown, row.kind, format);
res.setHeader('Content-Type', documentExport.mimeFor(format));
res.setHeader('Content-Disposition',
'attachment; filename="' + documentExport.filename(row.title, format) + '"');
res.send(bytes);
} catch (err) {
console.error('[my-resources] export:', err.message);
// PDF is the one export that depends on another service. Say which failed
// rather than reporting a generic error for a download that works in two
// other formats.
res.status(502).json({ error: String(req.query.format) === 'pdf'
? 'PDF conversion is unavailable right now. PowerPoint and Word still work.'
: 'Could not build that file' });
}
});
router.delete('/my-resources/:id', async function (req, res) {
try {
var result = await db.run('DELETE FROM user_resources WHERE id = ? AND user_id = ?',
[parseInt(req.params.id, 10), req.user.id]);
if (!result.changes) return res.status(404).json({ error: 'Not found' });
res.json({ success: true });
} catch (err) {
console.error('[my-resources] delete:', err.message);
res.status(500).json({ error: 'Could not delete' });
}
});
module.exports = router;