pediatric-ai-scribe-v3/src/routes/myResources.js
Daniel 491a2b0811
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 52s
Forgejo Android APK / Build signed APK (push) Successful in 2m5s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
fix: the My Resources diagnostics survive a deploy
logRefine writes the one line that answers "did that modification change
anything" — path, before and after size, CHANGED=yes/no, figures, model,
instruction. It went to console, so it lived in the container's stdout and was
destroyed the next time the container was recreated.

That cost a diagnosis today: a modification came back unchanged, the user asked
why, and the evidence had already been deleted by a deploy. The deck-fallback
warnings and the deck-vocabulary gaps had the same problem, and those exist
specifically to be read later — the vocabulary gaps are meant to show which
shapes to build next, which is a question about weeks, not about one container.

All of them now go through logger, which writes the dated file in the
scribe-logs volume and ships to Loki when it is configured, and carries the
event as structured data rather than only as a formatted string.

console.error is left alone: those are failures, and logger.error already
echoes to the console.

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

835 lines
44 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 deckSchema = require('../utils/deckSchema');
var deckBuild = require('../utils/deckBuild');
var deckReview = require('../utils/deckReview');
// The diagnostics below are the record of what a generation or a modification
// actually did. console goes to the container's stdout, which is destroyed every
// time the container is recreated — so the one question these exist to answer,
// "did that modification change anything", became unanswerable after any deploy.
// logger writes to the dated file in the scribe-logs volume, and to Loki when it
// is configured, so the record outlives the container.
var logger = require('../utils/logger');
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'
: '';
// A presentation is described, not written as markdown: the renderer can draw
// comparisons, tables, callouts and a figure beside its text, and markdown
// has no way to ask for any of them. Articles stay markdown, which is the
// right shape for prose.
if (kind === 'presentation' && opts.deckMode) {
// The figure request goes last, after the author's own instructions. Inside
// the layout vocabulary it was one line among forty and the model passed
// over it: a twelve-slide deck asked to "include a diagram of the diagnostic
// pathway" came back with no image_prompt at all. Placement was the same
// lesson the tool path taught — whatever must not be missed goes last.
var figures = !opts.wantsImages ? '' : '\n\n' + (opts.figureCount
? 'Before you finish: this deck must carry ' + opts.figureCount + ' figure' +
(opts.figureCount === 1 ? '' : 's') + '. Make ' + opts.figureCount + ' of the slides ' +
'type "figure" (bullets on the left, the picture on the right) or type "image" (the ' +
'picture is the whole slide), and give each one an "image_prompt" describing what to ' +
'draw. Those two types are the only ones that carry a picture. A deck returned without ' +
(opts.figureCount === 1 ? 'it' : 'them') + ' has not followed the instruction.'
: 'Before you finish: where a diagram or labelled figure would genuinely help, make that ' +
'slide type "figure" or type "image" and give it an "image_prompt" describing what to ' +
'draw. Those two types are the only ones that carry a picture. Schematic or anatomical ' +
'teaching artwork only, never a real patient.');
return 'You are building a teaching presentation for a medical professional ' +
'audience (pediatrics / primary care).\n\nTOPIC: ' + opts.topic + '\n' +
grounding + findings + '\n' +
deckSchema.instructions(opts.slideCount, opts.figureCount) +
(opts.refinement ? '\n\nAdditional instructions: ' + opts.refinement + '\n' : '') +
figures;
}
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;
// A presentation is described as a deck rather than written as markdown, so
// the model can choose a comparison, a table, a callout or a figure beside
// its text. Articles stay markdown: prose is what markdown is for.
var deckMode = kind === 'presentation';
var prompt = buildPrompt({
topic: topic, kind: kind, refinement: refinement, corpusContext: corpus.context,
literature: sources.literature, webFindings: sources.webFindings,
searchedAndFoundNothing: sources.searchedAndFoundNothing,
wantsImages: wantsImages, deckMode: deckMode,
figureCount: wantsImages ? (resourceImages.requestedCount(refinement) || 0) : 0,
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 }];
// A deck's JSON is several times the size of the prose it contains, and the
// default budget is 4000 tokens. A sixteen-slide deck ran past it, came back
// truncated, failed to parse and fell back to markdown — which has no way to
// ask for a figure, so the model wrote "![Placeholder: flow diagram]" into a
// bullet instead. That is why decks were arriving with no images.
var options = { model: model, temperature: 0.3 };
if (deckMode) options.maxTokens = 16000;
// 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.
// In deck mode the figures are named by the slides that want them, so there
// is nothing for the image tool to decide and it is not offered. An article
// still gets the tool, because prose has no structure to hang a figure on.
var tools = [];
if (wantsImages && !deckMode) 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);
if (wantsImages && !deckMode) {
// 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 resource. Same queue, same storage, same my_resources workflow.
ai = await resourceImages.dispatch(ai, {
owner: req.user.id, body: req.body, subject: topic, imageModel: imageModel,
messages: messages, options: options, callAI: callAI
});
}
// What the model reached for and could not have. Recorded rather than
// guessed at: the shape vocabulary should grow from real demand.
var vocabularyGaps = [];
var deck = deckMode ? deckBuild.parse(ai && ai.content, vocabularyGaps) : null;
reportVocabularyGaps(vocabularyGaps, topic);
// Why a deck became plain slides, when it did. Reported to the caller as
// well as logged: the fallback produces a usable but plainer deck, and
// saying nothing left people with a worse result and no idea it had
// happened, or that asking again would probably fix it.
var deckFallback = null;
function deckFailure(reply) {
// "Not usable" covers a truncated reply, prose instead of JSON, and an
// empty deck, and they want different fixes — a smaller deck, a different
// model, a reworded topic. Truncation is only claimed for something that
// began as JSON and stopped: an apology in prose does not end in "}"
// either, and calling that "cut short" sends the reader after the wrong
// fix.
var text = String(reply || '').trim();
if (!text) return 'the model returned nothing';
var startedJson = text.charAt(0) === '{' || text.charAt(0) === '[';
if (startedJson && text.slice(-1) !== '}' && text.slice(-1) !== ']') {
return 'the reply was cut short at ' + text.length + ' characters';
}
return 'the reply was not a deck';
}
if (deckMode && !deck) {
// One more attempt at a deck before giving up on the layout. Models are
// stochastic and this is the same prompt, not a weaker one: measured on
// the stored library, a deck failed once in eight generations, and a
// plain-markdown deck is a materially worse artifact to fall back to
// after a single unlucky reply.
var firstFailure = deckFailure(ai && ai.content);
logger.warn('[my-resources] deck reply was not usable (' + firstFailure + '); asking once more',
{ topic: topic, attempt: 1, model: ai && ai.model });
var retryGaps = [];
ai = await callAI([{ role: 'user', content: prompt }], options);
deck = deckBuild.parse(ai && ai.content, retryGaps);
reportVocabularyGaps(retryGaps, topic);
if (deck) logger.info('[my-resources] the second deck attempt parsed', { topic: topic });
}
if (deckMode && !deck) {
// Twice is enough. Falling back to markdown beats saving nothing, and
// beats saving the model's apology.
deckFallback = deckFailure(ai && ai.content);
logger.warn('[my-resources] deck reply was not usable twice (' + deckFallback +
'); retrying as markdown', { topic: topic, attempt: 2, model: ai && ai.model });
var plain = buildPrompt({
topic: topic, kind: kind, refinement: refinement, corpusContext: corpus.context,
literature: sources.literature, webFindings: sources.webFindings,
searchedAndFoundNothing: sources.searchedAndFoundNothing,
wantsImages: false, deckMode: false,
slideCount: clampInt(req.body.slideCount, 3, 30, 8),
wordCount: clampInt(req.body.wordCount, 200, 3000, 800)
});
ai = await callAI([{ role: 'user', content: plain }], options);
}
if (deck) {
var drawn = await deckBuild.drawFigures(deck, {
owner: req.user.id, body: req.body, subject: topic, imageModel: imageModel
});
ai = Object.assign({}, ai, { imageJobs: drawn.jobs, imageFailures: drawn.failures });
}
// Look at what was built. The model that wrote the deck never sees it, so
// overflow, a figure on the wrong slide and a nine-item list that wants two
// columns are invisible to it. One pass, on generation only, and only when
// an administrator has named a reviewer — a second pass costs as much as
// the first and fixes far less.
// Computed here, before anything reads it. It used to be declared inside the
// review branch below, so with no reviewer configured — the default — it was
// undefined by the time the INSERT stringified it, and every generation
// failed on a NOT NULL constraint. `var` is function-scoped, so nothing
// complained until the database did.
var savedFigureIds = (ai.imageJobs || []).map(function (job) { return job.jobId; }).filter(Boolean);
var reviewed = { reviewed: false, reason: 'not attempted' };
if (deck) {
var reviewModel = String(await db.getSetting('my_resources.review_model', '') || '');
if (reviewModel) {
reviewed = await deckReview.review(deck, {
model: reviewModel,
callAI: callAI,
extractJson: deckBuild.extractJson,
gotenberg: documentExport.GOTENBERG,
mime: documentExport.FORMATS.pptx.mime,
// Rendered without the figures: they are still being drawn at this
// point, and a reviewer judges layout, not artwork.
pptx: await documentExport.renderDeck(deck, [], savedFigureIds)
});
deck = reviewed.deck;
}
}
// Markdown is still the readable artifact: it is what Word export renders
// and what a text edit edits. In deck mode it is serialised from the deck
// rather than written by the model.
var markdown = deck ? deckSchema.toMarkdown(deck) : 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, image_ids, deck) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, title, kind, topic, grounded_count, created_at',
[req.user.id, deck && deck.title ? deck.title : firstHeading(markdown, topic), kind, markdown,
topic.slice(0, 500), corpus.sources.length, JSON.stringify(savedFigureIds),
deck ? JSON.stringify(deck) : null]
);
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,
review: { applied: reviewed.reviewed, reason: reviewed.reason },
deckFallback: deckFallback,
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(
// has_deck, not the deck itself: the list does not need the slides, but a
// presentation without one behaves differently enough — flat layout, a
// weaker modification path — that the owner should be able to see which
// kind they have.
'SELECT id, title, kind, topic, grounded_count, created_at, updated_at, ' +
"(deck IS NOT NULL AND jsonb_array_length(COALESCE(deck->'slides', '[]'::jsonb)) > 0) AS has_deck " +
'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, deck 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.';
}
// A presentation with a stored deck is edited as a deck. Editing its
// markdown instead changed only the markdown: export renders from the deck,
// so a modification succeeded, said so, and produced an identical download.
//
// Declared here, above its first reader, and not further down where it used
// to sit. `var` hoisted it, so the illustration branch below read it as
// undefined and composed the markdown instruction for a deck: the model was
// told to return markdown and deck JSON in the same reply, the reply parsed
// as neither, and modifying a deck with illustration on failed outright.
var existingDeck = existing.deck
? (typeof existing.deck === 'string' ? JSON.parse(existing.deck) : existing.deck) : null;
if (existingDeck && !(existingDeck.slides || []).length) existingDeck = null;
// How a figure is asked for depends on which thing is being edited. A deck
// places figures by declaring them on a slide; markdown has nowhere to put
// one, so it uses the tool. Offering the tool while editing a deck queued a
// figure that no slide referenced — it was generated, paid for, recorded
// against the resource, and never appeared in the export.
var illustration = !sources.wantsImages ? ''
: existingDeck
? '\n\nThe author has asked for illustration. Add "image_prompt" to the slides that ' +
'should carry a figure — schematic or anatomical teaching artwork only, never a real ' +
'patient — and keep the "image_job" value of any slide that already has one. ' +
resourceImages.guidance(instructions)
: '\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.';
var messages = [{ role: 'user', content: existingDeck
? 'Revise the following slide deck according to the instruction. Return ONLY the ' +
'complete revised JSON object, in exactly the same shape, no commentary and no code ' +
'fences. Keep every slide that the instruction does not ask you to change, including ' +
'its layout and its "image_job" values, and keep any References slide last.\n\n' +
'INSTRUCTION: ' + instructions + illustration + material +
'\n\nThe layouts available are:\n' + deckSchema.instructions(
(existingDeck.slides || []).length, 0) +
'\n\nDECK JSON:\n' + JSON.stringify({ slides: existingDeck.slides })
: '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"""' }];
// The reply restates the whole resource, so it needs room for one.
var options = { model: await resolveModel(req.body.model), temperature: 0.2, maxTokens: 16000 };
// Deck mode declares its figures; only the markdown path needs the tool.
var tools = sources.wantsImages && !existingDeck ? 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 && !existingDeck) {
ai = await resourceImages.dispatch(ai, {
owner: req.user.id, body: req.body, subject: subject, imageModel: sources.imageModel,
messages: messages, options: options, callAI: callAI
});
}
var revisedDeck = existingDeck ? deckBuild.parse(ai && ai.content) : null;
if (existingDeck && !revisedDeck) {
// A reply that is not a deck would otherwise be saved as the markdown and
// silently drop every layout the deck held.
logRefine({ id: existing.id, path: 'deck', outcome: 'refused',
detail: 'the model did not return a usable deck', instructions: instructions });
return res.status(502).json({ error: 'That change could not be applied. Try wording it differently.' });
}
if (revisedDeck) {
// Carried through rather than trusted from the reply.
revisedDeck.title = existingDeck.title;
revisedDeck.subtitle = existingDeck.subtitle;
revisedDeck.date = existingDeck.date;
// Draw whatever the revised deck asked for, the same way generating does,
// so each new figure belongs to the slide that wanted it.
if (sources.wantsImages) {
var drawn = await deckBuild.drawFigures(revisedDeck, {
owner: req.user.id, body: req.body, subject: subject, imageModel: sources.imageModel
});
ai = Object.assign({}, ai, { imageJobs: drawn.jobs, imageFailures: drawn.failures });
}
}
var revised = revisedDeck ? deckSchema.toMarkdown(revisedDeck)
: String((ai && ai.content) || '').trim();
if (!revised) return res.status(502).json({ error: 'The model returned nothing. Try again.' });
// Figures from a modification are added to the ones already there, not
// swapped for them: "add two more diagrams" means more, not instead.
var added = (ai.imageJobs || []).map(function (job) { return job.jobId; }).filter(Boolean);
var row = await db.get(
'UPDATE user_resources SET markdown = ?, title = ?, updated_at = NOW(), ' +
'image_ids = image_ids || ?::jsonb, deck = COALESCE(?::jsonb, deck) ' +
'WHERE id = ? AND user_id = ? RETURNING id, title, updated_at',
[revised, firstHeading(revised), JSON.stringify(added),
revisedDeck ? JSON.stringify(revisedDeck) : null, existing.id, req.user.id]
);
// Said out loud, every time. A modification that changes nothing is the
// failure worth catching, and it is invisible from the response: the row
// updates, the title updates, and the file is identical.
// Computed once, because the caller is told this too. Logging it server-side
// while answering "Applied" left the one person who could do something about
// it — the person who asked for the change — reading a success message next
// to an identical file.
var unchanged = revisedDeck
? JSON.stringify(existingDeck.slides) === JSON.stringify(revisedDeck.slides)
: revised.trim() === String(existing.markdown || '').trim();
logRefine({
id: existing.id,
path: revisedDeck ? 'deck' : 'markdown',
outcome: 'applied',
before: existingDeck ? (existingDeck.slides || []).length : existing.markdown.length,
after: revisedDeck ? (revisedDeck.slides || []).length : revised.length,
unchanged: unchanged,
figures: added.length,
model: ai && ai.model,
instructions: instructions
});
res.json({
success: true, resource: row, markdown: revised, unchanged: unchanged,
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' });
}
});
// What a modification actually did, in one line, so "it spat out the same thing"
// can be checked rather than guessed at. The unchanged case is the one worth
// watching: the response says success either way, and the download is identical.
function logRefine(event) {
var unit = event.path === 'deck' ? ' slides' : ' chars';
var parts = ['[my-resources] refine id=' + event.id, 'path=' + event.path, 'outcome=' + event.outcome];
if (event.outcome === 'applied') {
parts.push(event.before + '\u2192' + event.after + unit);
parts.push(event.unchanged ? 'CHANGED=no' : 'changed=yes');
if (event.figures) parts.push('figures=+' + event.figures);
if (event.model) parts.push('model=' + event.model);
} else if (event.detail) {
parts.push('(' + event.detail + ')');
}
parts.push('instruction="' + String(event.instructions || '').slice(0, 70).replace(/\s+/g, ' ') + '"');
var line = parts.join(' ');
if (event.outcome !== 'applied' || event.unchanged) logger.warn(line, event);
else logger.info(line, event);
try {
require('../utils/metrics').resourceRefines.inc({
path: event.path,
outcome: event.outcome === 'applied' ? (event.unchanged ? 'unchanged' : 'changed') : event.outcome
});
} catch (e) { /* never worth an error here */ }
}
// One line per distinct thing a deck wanted and could not have, plus a counter
// so it can be watched over time. Never fails anything: this is a note to
// whoever decides what to build next, not part of the generation.
function reportVocabularyGaps(gaps, topic) {
if (!gaps || !gaps.length) return;
var seen = Object.create(null);
gaps.forEach(function (gap) {
if (seen[gap.wanted]) return;
seen[gap.wanted] = true;
try {
require('../utils/metrics').deckVocabularyGaps.inc({ wanted: gap.wanted });
} catch (e) { /* metrics are never worth an error here */ }
logger.warn('[deck-vocabulary] wanted "' + gap.wanted + '" (' + gap.detail +
') while generating: ' + String(topic || '').slice(0, 80));
});
}
// Fetches a resource's finished figures onto disk in the order they were made.
// asset() already scopes to the owner, so this cannot reach anyone else's.
function figureIdList(ids) {
try { return Array.isArray(ids) ? ids : JSON.parse(ids || '[]'); } catch (e) { return []; }
}
async function collectFigures(ids, user, dir) {
var list = figureIdList(ids);
var fsp = require('fs/promises');
var pathMod = require('path');
var service = require('../utils/generatedImages');
var out = [];
for (var i = 0; i < list.length && out.length < 12; i++) {
try {
var asset = await service.service().asset(String(list[i]), user);
var ext = ({ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp' })[asset.mime] || 'png';
var file = pathMod.join(dir, 'figure-' + i + '.' + ext);
await fsp.writeFile(file, asset.bytes);
out.push(file);
} catch (e) {
// Still generating, failed, or deleted. The deck is fine without it.
logger.warn('[my-resources] figure unavailable for export', { error: e.message });
}
}
return out;
}
// ── 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, image_ids, deck 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.' });
}
// Figures are written to a scratch directory for the renderer and removed
// afterwards. A figure that cannot be fetched is left out rather than
// failing a download that works without it.
// Every format now carries the figures: Word embeds them too, since it is
// built from the same typed spec rather than from flattened markdown.
var figures = [];
var scratch = await require('fs/promises').mkdtemp(
require('path').join(require('os').tmpdir(), 'figs-'));
figures = await collectFigures(row.image_ids, req.user, scratch);
var bytes;
try {
bytes = await documentExport.render(row.markdown, row.kind, format,
{ images: figures, deck: row.deck, figureIds: figureIdList(row.image_ids) });
} finally {
if (scratch) {
await require('fs/promises').rm(scratch, { recursive: true, force: true })
.catch(function (e) { console.warn('[my-resources] scratch cleanup:', e.message); });
}
}
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;