// ============================================================ // 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;