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