|
|
|
|
@ -574,15 +574,32 @@ function httpError(statusCode, message) {
|
|
|
|
|
// job; a runaway click is not.
|
|
|
|
|
var MAX_ACTIVE_JOBS = 3;
|
|
|
|
|
|
|
|
|
|
// One writer at a time, counted across generating and modifying alike: a
|
|
|
|
|
// modification does the same library search and the same long model call as a
|
|
|
|
|
// generation, so letting ten of them run at once is the cost the cap bounds.
|
|
|
|
|
async function assertJobSlot(userId) {
|
|
|
|
|
var active = await db.get("SELECT COUNT(*)::int AS n FROM user_resource_jobs WHERE user_id = ? AND status IN ('queued', 'running')", [userId]);
|
|
|
|
|
if (active && active.n >= MAX_ACTIVE_JOBS) {
|
|
|
|
|
throw httpError(409, MAX_ACTIVE_JOBS + ' resources are already being written. Wait for one to finish.');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Run one queued job to its end and record how it ended. Never throws. */
|
|
|
|
|
async function runResourceJob(jobId, userId, body) {
|
|
|
|
|
async function runResourceJob(jobId, userId, body, kind, resourceId) {
|
|
|
|
|
try {
|
|
|
|
|
await db.run("UPDATE user_resource_jobs SET status = 'running', started_at = NOW() WHERE id = ?", [jobId]);
|
|
|
|
|
var result = await generateResource(userId, body);
|
|
|
|
|
// A modification needs the author, not just their id: the deck is rendered for
|
|
|
|
|
// sight and each figure is read back under their ownership. Loaded here rather
|
|
|
|
|
// than carried in the request, so the job holds no stale session.
|
|
|
|
|
var result = kind === 'refine'
|
|
|
|
|
? await refineResource(await db.get('SELECT id, email, name, role, totp_enabled, disabled FROM users WHERE id = ?', [userId]), resourceId, body)
|
|
|
|
|
: await generateResource(userId, body);
|
|
|
|
|
var summary = {
|
|
|
|
|
resource: result.resource, grounding: result.grounding, imageJobs: result.imageJobs,
|
|
|
|
|
imageFailures: result.imageFailures, searches: result.searches, review: result.review,
|
|
|
|
|
deckFallback: result.deckFallback, model: result.model
|
|
|
|
|
deckFallback: result.deckFallback, model: result.model,
|
|
|
|
|
// Read back by the page for a modification; absent for a generation.
|
|
|
|
|
unchanged: result.unchanged, saw: result.saw
|
|
|
|
|
};
|
|
|
|
|
await db.run("UPDATE user_resource_jobs SET status = 'done', finished_at = NOW(), resource_id = ?, result = ? WHERE id = ?",
|
|
|
|
|
[result.resource.id, JSON.stringify(summary), jobId]);
|
|
|
|
|
@ -614,10 +631,7 @@ 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 active = await db.get("SELECT COUNT(*)::int AS n FROM user_resource_jobs WHERE user_id = ? AND status IN ('queued', 'running')", [req.user.id]);
|
|
|
|
|
if (active && active.n >= MAX_ACTIVE_JOBS) {
|
|
|
|
|
return res.status(409).json({ error: MAX_ACTIVE_JOBS + ' resources are already being written. Wait for one to finish.' });
|
|
|
|
|
}
|
|
|
|
|
await assertJobSlot(req.user.id);
|
|
|
|
|
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.' });
|
|
|
|
|
@ -626,7 +640,7 @@ router.post('/my-resources/generate', async function (req, res) {
|
|
|
|
|
'INSERT INTO user_resource_jobs (user_id, topic, kind, request) VALUES (?, ?, ?, ?) ' +
|
|
|
|
|
'RETURNING id, topic, kind, status, created_at',
|
|
|
|
|
[req.user.id, topic.slice(0, 500), normalizeKind(req.body.kind), JSON.stringify(req.body || {})]);
|
|
|
|
|
runResourceJob(job.id, req.user.id, req.body || {});
|
|
|
|
|
runResourceJob(job.id, req.user.id, req.body || {}, 'generate', null);
|
|
|
|
|
res.status(202).json({ success: true, job: job });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[my-resources] generate:', err.message);
|
|
|
|
|
@ -1026,246 +1040,272 @@ router.put('/my-resources/:id/theme', async function (req, res) {
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Modify a resource. The same shape as generating: the caller queues the work
|
|
|
|
|
// and the page follows it as a job, so the tab can be closed while it runs.
|
|
|
|
|
// Ownership is re-checked here rather than only at enqueue, because the
|
|
|
|
|
// resource can be deleted while the job waits its turn.
|
|
|
|
|
async function refineResource(user, resourceId, body) {
|
|
|
|
|
var instructions = String(body.instructions || '').trim();
|
|
|
|
|
if (!instructions) throw httpError(400, 'Say what to change');
|
|
|
|
|
|
|
|
|
|
var existing = await db.get(
|
|
|
|
|
'SELECT id, kind, topic, markdown, deck, image_ids, theme FROM user_resources WHERE id = ? AND user_id = ?',
|
|
|
|
|
[resourceId, user.id]
|
|
|
|
|
);
|
|
|
|
|
if (!existing) throw httpError(404, '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, 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, 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) +
|
|
|
|
|
(existingDeck.format ? '\n\n' + deckFormats.instructions(existingDeck.format) : '') +
|
|
|
|
|
'\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: body.model ? await resolveModel(body.model)
|
|
|
|
|
: (visionModel && slideViews.length ? visionModel : await resolveModel('')),
|
|
|
|
|
temperature: 0.2, maxTokens: 16000,
|
|
|
|
|
// A revision is writing. This is the call a thirteen-slide deck made the
|
|
|
|
|
// browser wait six minutes for: DeepSeek reasoned for a minute and a half
|
|
|
|
|
// and wrote nothing on the first attempt.
|
|
|
|
|
reasoningEffort: 'none'
|
|
|
|
|
};
|
|
|
|
|
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: user.id, body: 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 });
|
|
|
|
|
throw httpError(502, '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;
|
|
|
|
|
// The look is the author's, not the model's: a modification never
|
|
|
|
|
// resets the theme. The column keeps it too, but the two should agree.
|
|
|
|
|
revisedDeck.theme = existingDeck.theme || existing.theme || revisedDeck.theme;
|
|
|
|
|
// And the shape it was written in.
|
|
|
|
|
revisedDeck.format = existingDeck.format || revisedDeck.format;
|
|
|
|
|
// 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: user.id, body: body, subject: subject, imageModel: sources.imageModel
|
|
|
|
|
});
|
|
|
|
|
ai = Object.assign({}, ai, { imageJobs: drawn.jobs, imageFailures: drawn.failures });
|
|
|
|
|
} else {
|
|
|
|
|
// Illustration off: a figure the revision newly asks for keeps its
|
|
|
|
|
// place as an empty labelled frame; figures already drawn are kept.
|
|
|
|
|
await deckBuild.drawFigures(revisedDeck, { imageModel: '' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
reasoningEffort: options.reasoningEffort,
|
|
|
|
|
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) throw httpError(502, '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, 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
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
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
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Queue a modification and answer at once, exactly as generating does. The work
|
|
|
|
|
// is a job on the server, so a browser that gives up waiting — Firefox abandons
|
|
|
|
|
// a non-streaming fetch at five minutes — no longer takes the modification down
|
|
|
|
|
// with it, and a reload loses nothing.
|
|
|
|
|
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, theme FROM user_resources WHERE id = ? AND user_id = ?',
|
|
|
|
|
[parseInt(req.params.id, 10), req.user.id]
|
|
|
|
|
);
|
|
|
|
|
var resourceId = parseInt(req.params.id, 10);
|
|
|
|
|
var existing = await db.get('SELECT id, topic FROM user_resources WHERE id = ? AND user_id = ?',
|
|
|
|
|
[resourceId, 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) +
|
|
|
|
|
(existingDeck.format ? '\n\n' + deckFormats.instructions(existingDeck.format) : '') +
|
|
|
|
|
'\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,
|
|
|
|
|
// A revision is writing. This is the call a thirteen-slide deck made the
|
|
|
|
|
// browser wait six minutes for: DeepSeek reasoned for a minute and a half
|
|
|
|
|
// and wrote nothing on the first attempt.
|
|
|
|
|
reasoningEffort: 'none'
|
|
|
|
|
};
|
|
|
|
|
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;
|
|
|
|
|
// The look is the author's, not the model's: a modification never
|
|
|
|
|
// resets the theme. The column keeps it too, but the two should agree.
|
|
|
|
|
revisedDeck.theme = existingDeck.theme || existing.theme || revisedDeck.theme;
|
|
|
|
|
// And the shape it was written in.
|
|
|
|
|
revisedDeck.format = existingDeck.format || revisedDeck.format;
|
|
|
|
|
// 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 });
|
|
|
|
|
} else {
|
|
|
|
|
// Illustration off: a figure the revision newly asks for keeps its
|
|
|
|
|
// place as an empty labelled frame; figures already drawn are kept.
|
|
|
|
|
await deckBuild.drawFigures(revisedDeck, { imageModel: '' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
reasoningEffort: options.reasoningEffort,
|
|
|
|
|
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
|
|
|
|
|
});
|
|
|
|
|
await assertJobSlot(req.user.id);
|
|
|
|
|
var job = await db.get(
|
|
|
|
|
'INSERT INTO user_resource_jobs (user_id, topic, kind, resource_id, request) VALUES (?, ?, ?, ?, ?) ' +
|
|
|
|
|
'RETURNING id, topic, kind, status, created_at',
|
|
|
|
|
[req.user.id, String(existing.topic || instructions).slice(0, 500), 'refine', resourceId,
|
|
|
|
|
JSON.stringify(req.body || {})]);
|
|
|
|
|
runResourceJob(job.id, req.user.id, req.body || {}, 'refine', resourceId);
|
|
|
|
|
res.status(202).json({ success: true, job: job });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[my-resources] refine:', err.message);
|
|
|
|
|
res.status(err.statusCode || 500).json({ error: err.statusCode ? err.message : 'Refinement failed' });
|
|
|
|
|
res.status(err.statusCode || 500).json({ error: err.statusCode ? err.message : 'Modification could not be started' });
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ── 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
|
|
|
|
|
@ -1438,4 +1478,5 @@ module.exports = router;
|
|
|
|
|
// The job runner's body, reachable for tests that exercise a generation
|
|
|
|
|
// end to end without waiting on a detached job.
|
|
|
|
|
module.exports.generateResource = generateResource;
|
|
|
|
|
module.exports.refineResource = refineResource;
|
|
|
|
|
module.exports.runResourceJob = runResourceJob;
|
|
|
|
|
|