Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 2m1s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
My Resources generates better slides than Learning Hub ever did — a typed deck the model fills in, rendered by python-pptx with fit-to-slide text, figures, a vision review and themes, against Learning Hub's markdown-through-pandoc — and the articles and quizzes now live in the quiz app. Keeping a second, weaker generator and a whole CMS beside it was not earning its maintenance. Removed: three routers, the Learning Hub and Content Manager tabs, their components and frontend modules, the five database tables, the WebDAV browser, the content embedding column and its vector index. Content was exported first — every article as markdown plus a full SQL dump of all five tables — to ops-backups/learning-hub-export-*. That export is the restore path; the migration's down() can recreate the shape but never the rows, and says so. Two things this simplifies rather than merely deletes: generated_image_links existed only to record which published content an image appeared in, and it was the sole reason a generated image could be read by someone who did not make it. Images are now owner-only — the visibility rule is one WHERE clause instead of a join across two tables and a published flag. embeddings.js keeps the model discovery the admin panel uses and loses searchSimilar and generateContentEmbedding, which queried a table that no longer exists. Kept deliberately: Nextcloud connect, disconnect and export, which are how a generated note reaches a real filesystem and have nothing to do with Learning Hub; learningRetrieval, which despite its name is the clinical corpus search My Resources depends on; and the pandoc reference deck, still the fallback when the python renderer fails, moved from assets/learning to assets/deck now that the old name misleads. Tests: four Learning-Hub-only files removed, and the individual cases inside shared files that asserted its behaviour. Where a test used a Learning endpoint only as a convenient example — the account-boundary token test, the policy matrix — it now uses one that still exists, so the property it proves is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
1030 lines
53 KiB
JavaScript
1030 lines
53 KiB
JavaScript
// ============================================================
|
|
// MY RESOURCES
|
|
// A person's own generated teaching material.
|
|
//
|
|
// Private to whoever made it. 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 a role to do it at all.
|
|
//
|
|
// It began as the counterpart to a moderator-owned Learning Hub; that was
|
|
// retired once this generated better slides, and this is now the only place
|
|
// teaching material is made.
|
|
//
|
|
// 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', '')),
|
|
themes: deckSchema.themes().map(function (t) {
|
|
return { id: t.id, name: t.name, description: t.description || '' };
|
|
}),
|
|
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;
|
|
// Chosen by the author, not the model: a theme is a look, and the person
|
|
// making the deck is the one who knows the room it will be shown in.
|
|
if (deck) deck.theme = deckSchema.themeId(req.body.theme);
|
|
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 and a nine-item list that wants two columns are invisible to it.
|
|
// One pass, and only when an administrator has named a reviewer. Modifying
|
|
// reviews its own result the same way — see the refine handler.
|
|
//
|
|
// The deck is rendered here *without* its figures: they were only enqueued a
|
|
// few lines above and are still being drawn. So this pass judges the space a
|
|
// figure will occupy, not the artwork that lands in it.
|
|
// 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' });
|
|
}
|
|
});
|
|
|
|
// ── Theme previews ──────────────────────────────────────────
|
|
// One representative slide per theme, rendered by the renderer itself and
|
|
// cached. Drawn rather than mocked up: a hand-made swatch drifts the moment a
|
|
// palette or a layout changes, and the point of a preview is to be true.
|
|
//
|
|
// Cached on disk because it costs a pptx render, a Gotenberg round trip and a
|
|
// pdftoppm — far too much to repeat per page view, and the output only changes
|
|
// when the catalogue or the renderer does.
|
|
var THEME_PREVIEW_DIR = require('path').join(require('os').tmpdir(), 'deck-theme-previews');
|
|
|
|
function previewDeck(theme) {
|
|
return {
|
|
theme: theme.id,
|
|
slides: [{
|
|
type: 'compare',
|
|
heading: theme.name,
|
|
columns: [
|
|
{ label: 'MILD', bullets: [{ text: 'Barking cough', level: 0 }, { text: 'No stridor at rest', level: 0 }] },
|
|
{ label: 'SEVERE', bullets: [{ text: 'Stridor at rest', level: 0 }, { text: 'Marked recession', level: 0 }] }
|
|
]
|
|
}]
|
|
};
|
|
}
|
|
|
|
router.get('/my-resources/theme-preview/:id', async function (req, res) {
|
|
var fsp = require('fs/promises');
|
|
var pathMod = require('path');
|
|
try {
|
|
var id = deckSchema.themeId(req.params.id);
|
|
if (!id) return res.status(404).json({ error: 'No such theme' });
|
|
|
|
var cached = pathMod.join(THEME_PREVIEW_DIR, id + '.png');
|
|
try {
|
|
var hit = await fsp.readFile(cached);
|
|
res.setHeader('Content-Type', 'image/png');
|
|
res.setHeader('Cache-Control', 'private, max-age=86400');
|
|
return res.send(hit);
|
|
} catch (e) { /* not rendered yet */ }
|
|
|
|
var theme = deckSchema.themes().filter(function (t) { return t.id === id; })[0];
|
|
var pptx = await documentExport.renderDeck(previewDeck(theme), [], []);
|
|
var images = await deckReview.slideImages(pptx, documentExport.GOTENBERG,
|
|
documentExport.FORMATS.pptx.mime);
|
|
if (!images.length) return res.status(503).json({ error: 'Preview rendering unavailable' });
|
|
|
|
var png = Buffer.from(images[0].dataBase64, 'base64');
|
|
// Best effort: a preview that cannot be cached is still a preview.
|
|
try {
|
|
await fsp.mkdir(THEME_PREVIEW_DIR, { recursive: true });
|
|
await fsp.writeFile(cached, png);
|
|
} catch (e) {}
|
|
res.setHeader('Content-Type', 'image/png');
|
|
res.setHeader('Cache-Control', 'private, max-age=86400');
|
|
res.send(png);
|
|
} catch (err) {
|
|
logger.warn('[my-resources] theme preview', { theme: req.params.id, error: err.message });
|
|
res.status(503).json({ error: 'Preview rendering unavailable' });
|
|
}
|
|
});
|
|
|
|
// ── 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, " +
|
|
"COALESCE(deck->>'theme', '') AS theme " +
|
|
'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' });
|
|
}
|
|
});
|
|
|
|
// Re-skin, not re-generate. Export renders from the stored deck every time, so
|
|
// changing the theme is a column write and the next download looks different.
|
|
// No model call, nothing regenerated, nothing that can reword a slide.
|
|
router.put('/my-resources/:id/theme', async function (req, res) {
|
|
try {
|
|
var theme = deckSchema.themeId(req.body.theme);
|
|
if (!theme && String(req.body.theme || '').trim()) {
|
|
return res.status(400).json({ error: 'That is not one of the available themes.' });
|
|
}
|
|
var row = await db.get(
|
|
'SELECT id, 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' });
|
|
if (!row.deck) {
|
|
return res.status(400).json({ error: 'This resource has no slide layout, so it has no theme to change.' });
|
|
}
|
|
var deck = typeof row.deck === 'string' ? JSON.parse(row.deck) : row.deck;
|
|
deck.theme = theme;
|
|
await db.run(
|
|
'UPDATE user_resources SET deck = ?::jsonb, updated_at = NOW() WHERE id = ? AND user_id = ?',
|
|
[JSON.stringify(deck), row.id, req.user.id]
|
|
);
|
|
res.json({ success: true, theme: theme });
|
|
} catch (err) {
|
|
logger.warn('[my-resources] theme', { error: err.message });
|
|
res.status(500).json({ error: 'Could not change the theme' });
|
|
}
|
|
});
|
|
|
|
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, image_ids 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.';
|
|
|
|
// Let it look at the deck it is about to edit, when a vision model is
|
|
// configured. Measured before building this: the text model returned the
|
|
// deck unchanged for "make it better", and a stronger model fixed that
|
|
// without seeing anything — but sight is what makes the visual half of the
|
|
// instructions answerable at all, and that is most of what people ask for
|
|
// while modifying.
|
|
var visionModel = existingDeck
|
|
? String(await db.getSetting('my_resources.review_model', '') || '') : '';
|
|
var slideViews = visionModel
|
|
? await renderDeckForSight(existingDeck, existing.image_ids, req.user)
|
|
: [];
|
|
var sight = !slideViews.length ? '' :
|
|
'\n\nYou can see the deck as it renders now: ' + slideViews.length + ' image' +
|
|
(slideViews.length === 1 ? '' : 's') + ', one per slide, in order. The first image is ' +
|
|
'slide index 0 in the JSON below. Use them for anything the instruction says about how ' +
|
|
'a slide looks — crowded, empty, a figure in the wrong place, text running off the ' +
|
|
'bottom. The JSON is what you edit; the images only tell you what it currently ' +
|
|
'produces.';
|
|
|
|
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 + sight +
|
|
'\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.
|
|
//
|
|
// The vision model does the editing when there is one — both because it is
|
|
// the only one that can act on what it sees, and because it follows a vague
|
|
// instruction better: measured on a real 20-slide deck, the text model
|
|
// echoed "make it better" back unchanged and the vision model did not.
|
|
// An explicit choice by the author still wins over both.
|
|
var options = {
|
|
model: req.body.model ? await resolveModel(req.body.model)
|
|
: (visionModel && slideViews.length ? visionModel : await resolveModel('')),
|
|
temperature: 0.2, maxTokens: 16000
|
|
};
|
|
if (slideViews.length) options.images = slideViews;
|
|
// 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 });
|
|
}
|
|
}
|
|
|
|
// Look at the result. The edit was made against how the deck looked before
|
|
// it; a slide that gained two bullets now overflows, and only rendering it
|
|
// again shows that. Same reviewer as generation, which may reposition but
|
|
// is held to the same words — so a verification pass cannot quietly undo
|
|
// the change that was just asked for.
|
|
// Judged before the reviewer touches it. The question "did my instruction do
|
|
// anything" is about the model's edit; a reviewer that nudged a slide into
|
|
// two columns would otherwise mask an instruction that achieved nothing.
|
|
var echoedBack = revisedDeck
|
|
? JSON.stringify(existingDeck.slides) === JSON.stringify(revisedDeck.slides)
|
|
: false;
|
|
|
|
var verified = { reviewed: false, reason: 'not attempted' };
|
|
if (revisedDeck && visionModel) {
|
|
verified = await deckReview.review(revisedDeck, {
|
|
model: visionModel,
|
|
callAI: callAI,
|
|
extractJson: deckBuild.extractJson,
|
|
gotenberg: documentExport.GOTENBERG,
|
|
mime: documentExport.FORMATS.pptx.mime,
|
|
pptx: await documentExport.renderDeck(revisedDeck, [], figureIdList(existing.image_ids))
|
|
});
|
|
revisedDeck = verified.deck;
|
|
}
|
|
|
|
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 ? echoedBack
|
|
: 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,
|
|
saw: slideViews.length,
|
|
review: { applied: verified.reviewed, reason: verified.reason },
|
|
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' });
|
|
}
|
|
});
|
|
|
|
// ── Showing the deck to the model that is about to change it ────────────────
|
|
// The model that writes a deck never sees it, and that is just as true when it
|
|
// is editing one. Most of what people ask for while modifying is about the
|
|
// rendered page — "that slide is too crowded", "the diagram is in the wrong
|
|
// place", "this one looks empty" — and none of it is answerable from the JSON.
|
|
//
|
|
// So the deck is rendered the same way a download is, turned into one image per
|
|
// slide, and handed to the model along with the JSON. Same pipeline as the
|
|
// review pass, reused rather than reimplemented: pptx -> Gotenberg -> PDF ->
|
|
// pdftoppm -> PNG, capped at deckReview.MAX_SLIDES.
|
|
//
|
|
// Never fatal. A gateway that is down, a render that fails, a deck too big —
|
|
// all of them fall through to editing the JSON blind, which is what this did
|
|
// before it could see at all.
|
|
async function renderDeckForSight(deck, figureIds, user) {
|
|
var fsp = require('fs/promises');
|
|
var os = require('os');
|
|
var pathMod = require('path');
|
|
var dir = null;
|
|
try {
|
|
dir = await fsp.mkdtemp(pathMod.join(os.tmpdir(), 'sight-'));
|
|
// With its figures, unlike the review pass: a review runs while they are
|
|
// still being drawn, but by the time someone is modifying they exist, and
|
|
// "move the picture" is unanswerable without seeing the picture.
|
|
var figures = await collectFigures(figureIds, user, dir);
|
|
var pptx = await documentExport.renderDeck(deck, figures, figureIdList(figureIds));
|
|
return await deckReview.slideImages(pptx, documentExport.GOTENBERG,
|
|
documentExport.FORMATS.pptx.mime);
|
|
} catch (e) {
|
|
logger.warn('[my-resources] could not render the deck to look at it; editing blind',
|
|
{ error: e.message });
|
|
return [];
|
|
} finally {
|
|
if (dir) await require('fs/promises').rm(dir, { recursive: true, force: true }).catch(function () {});
|
|
}
|
|
}
|
|
|
|
// 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;
|