feat: modifying a resource is a job, the same as generating one
Modify held the request open for a library search, a PubMed search, a web
search and a restating model call. That is minutes, and a browser gives up
first — Firefox abandons a non-streaming fetch at five minutes, the same
failure generating was moved off the request to fix in ef574edd. The server
carried on and saved the result while the person watched an error, and closing
the tab killed the work outright.
POST /my-resources/:id/refine now records the request and answers 202 with the
job, exactly as /generate does. The writing moved into refineResource(), which
the job runner dispatches to by kind; the job list, the five-second polling,
the restart recovery and the three-in-flight cap are all the work they already
did, unchanged. Ownership is checked again inside refineResource because the
resource can be deleted while the job waits.
The page follows the job instead of the response. Reporting is unchanged — the
unchanged reply, what was seen and what was searched — it is only said from the
job list now, so it still reaches the person who asked for it after a reload.
This commit is contained in:
parent
894ce251bb
commit
d2a06b0fcf
8 changed files with 344 additions and 281 deletions
|
|
@ -381,6 +381,29 @@
|
||||||
var r = job.result || {};
|
var r = job.result || {};
|
||||||
var g = r.grounding || {};
|
var g = r.grounding || {};
|
||||||
var title = (r.resource && r.resource.title) || job.topic;
|
var title = (r.resource && r.resource.title) || job.topic;
|
||||||
|
// A modification reports itself exactly as it did when the request waited for
|
||||||
|
// it; only where the answer arrives has changed.
|
||||||
|
if (job.kind === 'refine') {
|
||||||
|
// The model can return the document back unchanged. That is a failed
|
||||||
|
// modification, and saying "Applied" for it sent people off to download
|
||||||
|
// an identical file and conclude the feature was broken.
|
||||||
|
if (r.unchanged) {
|
||||||
|
status('“' + title + '”: the model returned it unchanged — nothing was modified. ' +
|
||||||
|
'Try naming the slide or section to change, and what to change about it.', 'bad');
|
||||||
|
} else {
|
||||||
|
// Whether it could see the slides is worth saying: it is the difference
|
||||||
|
// between "slide 4 looks crowded" being actionable and being guesswork,
|
||||||
|
// and it explains why this took longer.
|
||||||
|
status('“' + title + '” modified' +
|
||||||
|
(r.saw ? ', after looking at all ' + r.saw + ' slides' : '') +
|
||||||
|
(g.used ? ', using ' + g.count + ' library excerpt' + (g.count === 1 ? '' : 's') : '') +
|
||||||
|
'. Download it to see the result.', 'good');
|
||||||
|
}
|
||||||
|
reportSearches(r.searches);
|
||||||
|
showIllustrations(r.imageJobs || []);
|
||||||
|
reportImageFailures(r.imageFailures);
|
||||||
|
return;
|
||||||
|
}
|
||||||
status('“' + title + '” is in your library. ' + (g.used
|
status('“' + title + '” is in your library. ' + (g.used
|
||||||
? 'Written from ' + g.count + ' library excerpt' + (g.count === 1 ? '' : 's') + '.'
|
? 'Written from ' + g.count + ' library excerpt' + (g.count === 1 ? '' : 's') + '.'
|
||||||
: 'Not grounded' + (g.reason ? ' — ' + g.reason : '') + '; written from the model alone.'),
|
: 'Not grounded' + (g.reason ? ' — ' + g.reason : '') + '; written from the model alone.'),
|
||||||
|
|
@ -415,11 +438,12 @@
|
||||||
icon.style.color = busy ? 'var(--g600)' : job.status === 'done' ? 'var(--green)' : 'var(--red)';
|
icon.style.color = busy ? 'var(--g600)' : job.status === 'done' ? 'var(--green)' : 'var(--red)';
|
||||||
var label = document.createElement('span');
|
var label = document.createElement('span');
|
||||||
label.style.cssText = 'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
|
label.style.cssText = 'flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
|
||||||
label.textContent = job.topic + ' · ' + (job.kind === 'article' ? 'article' : 'presentation');
|
label.textContent = job.topic + ' · ' + (job.kind === 'refine' ? 'modification'
|
||||||
|
: job.kind === 'article' ? 'article' : 'presentation');
|
||||||
var state = document.createElement('span');
|
var state = document.createElement('span');
|
||||||
state.style.cssText = 'color:var(--g600);white-space:nowrap;';
|
state.style.cssText = 'color:var(--g600);white-space:nowrap;';
|
||||||
state.textContent = job.status === 'queued' ? 'Queued'
|
state.textContent = job.status === 'queued' ? 'Queued'
|
||||||
: job.status === 'running' ? 'Writing… ' + elapsed(job)
|
: job.status === 'running' ? (job.kind === 'refine' ? 'Rewriting… ' : 'Writing… ') + elapsed(job)
|
||||||
: job.status === 'done' ? 'Done'
|
: job.status === 'done' ? 'Done'
|
||||||
: 'Failed — ' + (job.error || 'Generation failed');
|
: 'Failed — ' + (job.error || 'Generation failed');
|
||||||
row.appendChild(icon); row.appendChild(label); row.appendChild(state);
|
row.appendChild(icon); row.appendChild(label); row.appendChild(state);
|
||||||
|
|
@ -864,6 +888,11 @@
|
||||||
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Applying'; }
|
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Applying'; }
|
||||||
clearResults();
|
clearResults();
|
||||||
say('Rewriting…');
|
say('Rewriting…');
|
||||||
|
// Queued like a generation, not awaited. A revision restates the whole
|
||||||
|
// resource and can take minutes; a request held open that long is abandoned
|
||||||
|
// by the browser while the server carries on, which is exactly how a
|
||||||
|
// modification looked like it had failed and then really had been saved.
|
||||||
|
// The job list reports it, so leaving the page no longer loses the work.
|
||||||
fetch('/api/my-resources/' + encodeURIComponent(id) + '/refine', {
|
fetch('/api/my-resources/' + encodeURIComponent(id) + '/refine', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getAuthHeaders(),
|
headers: getAuthHeaders(),
|
||||||
|
|
@ -879,29 +908,9 @@
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
.then(function (data) {
|
.then(function (data) {
|
||||||
if (!data.success) throw new Error(data.error || 'Could not apply the changes');
|
if (!data.success) throw new Error(data.error || 'Could not apply the changes');
|
||||||
// Same reporting as generating: what it was written from, what was
|
say('Rewriting in the background. You can leave this page — it keeps going.', 'good');
|
||||||
// searched, and any figure that came back.
|
if (box) box.value = '';
|
||||||
var g = data.grounding || {};
|
refreshJobs();
|
||||||
// The model can return the document back unchanged. That is a failed
|
|
||||||
// modification, and saying "Applied" for it sent people off to download
|
|
||||||
// an identical file and conclude the feature was broken.
|
|
||||||
if (data.unchanged) {
|
|
||||||
say('The model returned it unchanged — nothing was modified. ' +
|
|
||||||
'Try naming the slide or section to change, and what to change about it.', 'bad');
|
|
||||||
} else {
|
|
||||||
// Whether it could see the slides is worth saying: it is the
|
|
||||||
// difference between "slide 4 looks crowded" being actionable and
|
|
||||||
// being guesswork, and it explains why this took longer.
|
|
||||||
say('Applied' +
|
|
||||||
(data.saw ? ', after looking at all ' + data.saw + ' slides' : '') +
|
|
||||||
(g.used ? ', using ' + g.count + ' library excerpt' + (g.count === 1 ? '' : 's') : '') +
|
|
||||||
'. Download it to see the result.', 'good');
|
|
||||||
}
|
|
||||||
reportSearches(data.searches);
|
|
||||||
showIllustrations(data.imageJobs || []);
|
|
||||||
reportImageFailures(data.imageFailures);
|
|
||||||
if (box && !data.unchanged) box.value = '';
|
|
||||||
loadLibrary();
|
|
||||||
})
|
})
|
||||||
.catch(function (err) { say(err.message, 'bad'); })
|
.catch(function (err) { say(err.message, 'bad'); })
|
||||||
.finally(function () {
|
.finally(function () {
|
||||||
|
|
|
||||||
|
|
@ -574,15 +574,32 @@ function httpError(statusCode, message) {
|
||||||
// job; a runaway click is not.
|
// job; a runaway click is not.
|
||||||
var MAX_ACTIVE_JOBS = 3;
|
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. */
|
/** 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 {
|
try {
|
||||||
await db.run("UPDATE user_resource_jobs SET status = 'running', started_at = NOW() WHERE id = ?", [jobId]);
|
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 = {
|
var summary = {
|
||||||
resource: result.resource, grounding: result.grounding, imageJobs: result.imageJobs,
|
resource: result.resource, grounding: result.grounding, imageJobs: result.imageJobs,
|
||||||
imageFailures: result.imageFailures, searches: result.searches, review: result.review,
|
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 = ?",
|
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]);
|
[result.resource.id, JSON.stringify(summary), jobId]);
|
||||||
|
|
@ -614,10 +631,7 @@ router.post('/my-resources/generate', async function (req, res) {
|
||||||
try {
|
try {
|
||||||
var topic = String(req.body.topic || '').trim();
|
var topic = String(req.body.topic || '').trim();
|
||||||
if (!topic) return res.status(400).json({ error: 'A topic is required' });
|
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]);
|
await assertJobSlot(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.' });
|
|
||||||
}
|
|
||||||
var count = await db.get('SELECT COUNT(*)::int AS n FROM user_resources WHERE user_id = ?', [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) {
|
if (count && count.n >= MAX_PER_USER) {
|
||||||
return res.status(409).json({ error: 'You have reached ' + MAX_PER_USER + ' saved resources. Delete one first.' });
|
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 (?, ?, ?, ?) ' +
|
'INSERT INTO user_resource_jobs (user_id, topic, kind, request) VALUES (?, ?, ?, ?) ' +
|
||||||
'RETURNING id, topic, kind, status, created_at',
|
'RETURNING id, topic, kind, status, created_at',
|
||||||
[req.user.id, topic.slice(0, 500), normalizeKind(req.body.kind), JSON.stringify(req.body || {})]);
|
[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 });
|
res.status(202).json({ success: true, job: job });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[my-resources] generate:', err.message);
|
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) {
|
router.post('/my-resources/:id/refine', async function (req, res) {
|
||||||
try {
|
try {
|
||||||
var instructions = String(req.body.instructions || '').trim();
|
var instructions = String(req.body.instructions || '').trim();
|
||||||
if (!instructions) return res.status(400).json({ error: 'Say what to change' });
|
if (!instructions) return res.status(400).json({ error: 'Say what to change' });
|
||||||
|
var resourceId = parseInt(req.params.id, 10);
|
||||||
var existing = await db.get(
|
var existing = await db.get('SELECT id, topic FROM user_resources WHERE id = ? AND user_id = ?',
|
||||||
'SELECT id, kind, topic, markdown, deck, image_ids, theme FROM user_resources WHERE id = ? AND user_id = ?',
|
[resourceId, req.user.id]);
|
||||||
[parseInt(req.params.id, 10), req.user.id]
|
|
||||||
);
|
|
||||||
if (!existing) return res.status(404).json({ error: 'Not found' });
|
if (!existing) return res.status(404).json({ error: 'Not found' });
|
||||||
|
await assertJobSlot(req.user.id);
|
||||||
// Modifying can reach for the same sources as generating: "add what the
|
var job = await db.get(
|
||||||
// 2024 trial showed" is a request for material, not just a rewording, and
|
'INSERT INTO user_resource_jobs (user_id, topic, kind, resource_id, request) VALUES (?, ?, ?, ?, ?) ' +
|
||||||
// without this it would be answered from the model's memory alone. The
|
'RETURNING id, topic, kind, status, created_at',
|
||||||
// subject searched is the resource's own topic plus the instruction, so a
|
[req.user.id, String(existing.topic || instructions).slice(0, 500), 'refine', resourceId,
|
||||||
// request about something not in the original still finds it.
|
JSON.stringify(req.body || {})]);
|
||||||
var subject = [existing.topic, instructions].filter(Boolean).join(' \u2014 ').slice(0, 500);
|
runResourceJob(job.id, req.user.id, req.body || {}, 'refine', resourceId);
|
||||||
var sources = await gatherSources(subject, req.body, existing.topic || instructions);
|
res.status(202).json({ success: true, job: job });
|
||||||
|
|
||||||
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
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[my-resources] refine:', err.message);
|
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 ────────────────
|
// ── 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
|
// 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
|
// 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
|
// The job runner's body, reachable for tests that exercise a generation
|
||||||
// end to end without waiting on a detached job.
|
// end to end without waiting on a detached job.
|
||||||
module.exports.generateResource = generateResource;
|
module.exports.generateResource = generateResource;
|
||||||
|
module.exports.refineResource = refineResource;
|
||||||
module.exports.runResourceJob = runResourceJob;
|
module.exports.runResourceJob = runResourceJob;
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@ test('the reviewer is admin-chosen, off by default, and runs once per change', (
|
||||||
// rendered again — exactly the class of fault the reviewer exists for. The
|
// rendered again — exactly the class of fault the reviewer exists for. The
|
||||||
// old rule assumed refining was a text edit; it is a layout edit as often as
|
// old rule assumed refining was a text edit; it is a layout edit as often as
|
||||||
// not.
|
// not.
|
||||||
const refine = route.slice(route.indexOf("router.post('/my-resources/:id/refine'"));
|
const refine = route.slice(route.indexOf("async function refineResource("));
|
||||||
assert.match(refine, /deckReview\.review\(revisedDeck/);
|
assert.match(refine, /deckReview\.review\(revisedDeck/);
|
||||||
assert.match(refine, /if \(revisedDeck && visionModel\)/, 'and only when one is configured');
|
assert.match(refine, /if \(revisedDeck && visionModel\)/, 'and only when one is configured');
|
||||||
// Still one pass. The verification runs on the result, never in a loop.
|
// Still one pass. The verification runs on the result, never in a loop.
|
||||||
|
|
@ -174,7 +174,7 @@ test('the review is asked without thinking when the caller says so', () => {
|
||||||
const route = read('src/routes/myResources.js');
|
const route = read('src/routes/myResources.js');
|
||||||
const gen = route.slice(route.indexOf('async function generateResource('), route.indexOf('async function runResourceJob('));
|
const gen = route.slice(route.indexOf('async function generateResource('), route.indexOf('async function runResourceJob('));
|
||||||
assert.match(gen, /reasoningEffort: options\.reasoningEffort,/, 'generation\u2019s review inherits the writing\u2019s rule');
|
assert.match(gen, /reasoningEffort: options\.reasoningEffort,/, 'generation\u2019s review inherits the writing\u2019s rule');
|
||||||
const refine = route.slice(route.indexOf("router.post('/my-resources/:id/refine'"));
|
const refine = route.slice(route.indexOf("async function refineResource("));
|
||||||
assert.match(refine, /reasoningEffort: 'none'/, 'a revision is writing');
|
assert.match(refine, /reasoningEffort: 'none'/, 'a revision is writing');
|
||||||
assert.equal((refine.match(/reasoningEffort: options\.reasoningEffort,/g) || []).length, 1,
|
assert.equal((refine.match(/reasoningEffort: options\.reasoningEffort,/g) || []).length, 1,
|
||||||
'and its review is asked the same way');
|
'and its review is asked the same way');
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ test('a modification keeps the deck\'s theme', () => {
|
||||||
// The model returns a new deck; the look was never its to choose. The
|
// The model returns a new deck; the look was never its to choose. The
|
||||||
// column keeps the theme regardless, but the deck field must agree with it.
|
// column keeps the theme regardless, but the deck field must agree with it.
|
||||||
const route = read('src/routes/myResources.js');
|
const route = read('src/routes/myResources.js');
|
||||||
const refine = route.slice(route.indexOf("router.post('/my-resources/:id/refine'"));
|
const refine = route.slice(route.indexOf("async function refineResource("));
|
||||||
assert.match(refine, /revisedDeck\.theme = existingDeck\.theme \|\| existing\.theme \|\| revisedDeck\.theme/);
|
assert.match(refine, /revisedDeck\.theme = existingDeck\.theme \|\| existing\.theme \|\| revisedDeck\.theme/);
|
||||||
assert.match(refine.slice(0, 2500), /SELECT id, kind, topic, markdown, deck, image_ids, theme FROM user_resources/);
|
assert.match(refine.slice(0, 2500), /SELECT id, kind, topic, markdown, deck, image_ids, theme FROM user_resources/);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,18 @@ function router(t, overrides = {}) {
|
||||||
async function generate(body) {
|
async function generate(body) {
|
||||||
return module.exports.generateResource(7, body);
|
return module.exports.generateResource(7, body);
|
||||||
}
|
}
|
||||||
return { request, generate, aiCalls, updates, reviewCalls, reviewOptions };
|
// Modifying is queued as a job too, so the tests that care what the writing
|
||||||
|
// does call its body directly, exactly as the generation tests do. The reply
|
||||||
|
// is shaped like the old synchronous one so the assertions stay about
|
||||||
|
// behaviour rather than about how the work is scheduled.
|
||||||
|
async function refine(body, id) {
|
||||||
|
try {
|
||||||
|
return { statusCode: 200, body: await module.exports.refineResource({ id: 7 }, id || 5, body) };
|
||||||
|
} catch (err) {
|
||||||
|
return { statusCode: err.statusCode || 500, body: { error: err.message } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { request, generate, refine, aiCalls, updates, reviewCalls, reviewOptions };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('modifying a deck asks for a deck, even when illustration is on', async () => {
|
test('modifying a deck asks for a deck, even when illustration is on', async () => {
|
||||||
|
|
@ -133,7 +144,7 @@ test('modifying a deck asks for a deck, even when illustration is on', async ()
|
||||||
const r = router(null, {
|
const r = router(null, {
|
||||||
reply: JSON.stringify({ slides: [DECK.slides[0], { type: 'bullets', title: 'Features', bullets: ['Barking cough', 'Stridor', 'Hoarse voice'] }] })
|
reply: JSON.stringify({ slides: [DECK.slides[0], { type: 'bullets', title: 'Features', bullets: ['Barking cough', 'Stridor', 'Hoarse voice'] }] })
|
||||||
});
|
});
|
||||||
const res = await r.request('post', '/my-resources/:id/refine', {
|
const res = await r.refine( {
|
||||||
instructions: 'add a third feature', withImages: 'true'
|
instructions: 'add a third feature', withImages: 'true'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -154,7 +165,7 @@ test('a modification the model returned unchanged says so instead of claiming su
|
||||||
// logged server-side, where the person who could reword the instruction
|
// logged server-side, where the person who could reword the instruction
|
||||||
// could not see it.
|
// could not see it.
|
||||||
const r = router(null, { reply: JSON.stringify({ slides: DECK.slides }) });
|
const r = router(null, { reply: JSON.stringify({ slides: DECK.slides }) });
|
||||||
const res = await r.request('post', '/my-resources/:id/refine', { instructions: 'make it better' });
|
const res = await r.refine( { instructions: 'make it better' });
|
||||||
|
|
||||||
assert.equal(res.statusCode, 200);
|
assert.equal(res.statusCode, 200);
|
||||||
assert.equal(res.body.unchanged, true, 'the caller is told the deck came back identical');
|
assert.equal(res.body.unchanged, true, 'the caller is told the deck came back identical');
|
||||||
|
|
@ -164,7 +175,7 @@ test('a modification that did change the deck reports itself as changed', async
|
||||||
const r = router(null, {
|
const r = router(null, {
|
||||||
reply: JSON.stringify({ slides: [DECK.slides[0], { type: 'bullets', title: 'Features', bullets: ['Barking cough', 'Stridor', 'Hoarse voice'] }] })
|
reply: JSON.stringify({ slides: [DECK.slides[0], { type: 'bullets', title: 'Features', bullets: ['Barking cough', 'Stridor', 'Hoarse voice'] }] })
|
||||||
});
|
});
|
||||||
const res = await r.request('post', '/my-resources/:id/refine', { instructions: 'add a third feature' });
|
const res = await r.refine( { instructions: 'add a third feature' });
|
||||||
|
|
||||||
assert.equal(res.body.unchanged, false);
|
assert.equal(res.body.unchanged, false);
|
||||||
assert.equal(r.updates.length, 1, 'and the row is written');
|
assert.equal(r.updates.length, 1, 'and the row is written');
|
||||||
|
|
@ -173,7 +184,7 @@ test('a modification that did change the deck reports itself as changed', async
|
||||||
|
|
||||||
test('a presentation with no deck still modifies, through the markdown path', async () => {
|
test('a presentation with no deck still modifies, through the markdown path', async () => {
|
||||||
const r = router(null, { row: { deck: null }, reply: '# Croup\n\n- Barking cough\n- Stridor\n- Hoarse voice\n' });
|
const r = router(null, { row: { deck: null }, reply: '# Croup\n\n- Barking cough\n- Stridor\n- Hoarse voice\n' });
|
||||||
const res = await r.request('post', '/my-resources/:id/refine', {
|
const res = await r.refine( {
|
||||||
instructions: 'add a third feature', withImages: 'true'
|
instructions: 'add a third feature', withImages: 'true'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -195,7 +206,9 @@ test('the library says which presentations carry a deck, and the row shows when
|
||||||
assert.match(ui, /row\.updated_at \? new Date\(row\.updated_at\)/);
|
assert.match(ui, /row\.updated_at \? new Date\(row\.updated_at\)/);
|
||||||
assert.match(ui, /edited \? 'modified '/);
|
assert.match(ui, /edited \? 'modified '/);
|
||||||
assert.match(ui, /has_deck === false \? ' · plain text, no slide layout' : ''/);
|
assert.match(ui, /has_deck === false \? ' · plain text, no slide layout' : ''/);
|
||||||
assert.match(ui, /if \(data\.unchanged\) \{/);
|
// A modification reports from the job list now that it runs in the background,
|
||||||
|
// so "it came back unchanged" still reaches the person who asked for it.
|
||||||
|
assert.match(ui, /if \(r\.unchanged\) \{/);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Generating a deck ──────────────────────────────────────────────────────
|
// ── Generating a deck ──────────────────────────────────────────────────────
|
||||||
|
|
@ -271,7 +284,7 @@ test('modifying a resource is writing too, and is asked without thinking', async
|
||||||
// a thirteen-slide deck it reasoned for a minute and a half before writing a
|
// a thirteen-slide deck it reasoned for a minute and a half before writing a
|
||||||
// word, which is past the point a browser waits for the request.
|
// word, which is past the point a browser waits for the request.
|
||||||
const r = router(null, { reply: JSON.stringify({ slides: DECK.slides }) });
|
const r = router(null, { reply: JSON.stringify({ slides: DECK.slides }) });
|
||||||
const res = await r.request('post', '/my-resources/:id/refine', { instructions: 'make it better' });
|
const res = await r.refine( { instructions: 'make it better' });
|
||||||
|
|
||||||
assert.equal(res.statusCode, 200);
|
assert.equal(res.statusCode, 200);
|
||||||
assert.equal(r.aiCalls[0].options.reasoningEffort, 'none');
|
assert.equal(r.aiCalls[0].options.reasoningEffort, 'none');
|
||||||
|
|
@ -329,7 +342,7 @@ test('modifying a deck shows the model what the deck currently looks like', asyn
|
||||||
// "that slide is crowded", "the diagram is in the wrong place" — and none of
|
// "that slide is crowded", "the diagram is in the wrong place" — and none of
|
||||||
// it is answerable from the JSON alone.
|
// it is answerable from the JSON alone.
|
||||||
const r = router(null, { visionModel: 'seeing-model', slideImages: TWO_PNGS, replies: [EDITED] });
|
const r = router(null, { visionModel: 'seeing-model', slideImages: TWO_PNGS, replies: [EDITED] });
|
||||||
const res = await r.request('post', '/my-resources/:id/refine', {
|
const res = await r.refine( {
|
||||||
instructions: 'slide 2 looks crowded, split it'
|
instructions: 'slide 2 looks crowded, split it'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -346,7 +359,7 @@ test('the edited deck is rendered again and checked', async () => {
|
||||||
// The edit was made against how the deck looked *before* it. A slide that
|
// The edit was made against how the deck looked *before* it. A slide that
|
||||||
// gained two bullets only overflows once it is rendered again.
|
// gained two bullets only overflows once it is rendered again.
|
||||||
const r = router(null, { visionModel: 'seeing-model', slideImages: TWO_PNGS, replies: [EDITED] });
|
const r = router(null, { visionModel: 'seeing-model', slideImages: TWO_PNGS, replies: [EDITED] });
|
||||||
const res = await r.request('post', '/my-resources/:id/refine', { instructions: 'add a feature' });
|
const res = await r.refine( { instructions: 'add a feature' });
|
||||||
|
|
||||||
assert.equal(r.reviewCalls.length, 1, 'the result goes back past the reviewer');
|
assert.equal(r.reviewCalls.length, 1, 'the result goes back past the reviewer');
|
||||||
assert.equal(res.body.review.applied, true);
|
assert.equal(res.body.review.applied, true);
|
||||||
|
|
@ -356,7 +369,7 @@ test('with no vision model configured, modify still works and never renders', as
|
||||||
// Sight is an upgrade, not a dependency. Nothing here may become a new way
|
// Sight is an upgrade, not a dependency. Nothing here may become a new way
|
||||||
// for a modification to fail.
|
// for a modification to fail.
|
||||||
const r = router(null, { replies: [EDITED] });
|
const r = router(null, { replies: [EDITED] });
|
||||||
const res = await r.request('post', '/my-resources/:id/refine', { instructions: 'add a feature' });
|
const res = await r.refine( { instructions: 'add a feature' });
|
||||||
|
|
||||||
assert.equal(res.statusCode, 200);
|
assert.equal(res.statusCode, 200);
|
||||||
assert.equal(res.body.saw, 0);
|
assert.equal(res.body.saw, 0);
|
||||||
|
|
@ -368,7 +381,7 @@ test('a render that fails falls through to editing blind rather than failing', a
|
||||||
// Gotenberg down, LibreOffice wedged, a deck too big: none of them may cost
|
// Gotenberg down, LibreOffice wedged, a deck too big: none of them may cost
|
||||||
// the author their modification.
|
// the author their modification.
|
||||||
const r = router(null, { visionModel: 'seeing-model', slideImages: [], replies: [EDITED] });
|
const r = router(null, { visionModel: 'seeing-model', slideImages: [], replies: [EDITED] });
|
||||||
const res = await r.request('post', '/my-resources/:id/refine', { instructions: 'add a feature' });
|
const res = await r.refine( { instructions: 'add a feature' });
|
||||||
|
|
||||||
assert.equal(res.statusCode, 200);
|
assert.equal(res.statusCode, 200);
|
||||||
assert.equal(res.body.saw, 0);
|
assert.equal(res.body.saw, 0);
|
||||||
|
|
@ -382,7 +395,7 @@ test('an echo is still reported as an echo, even when the reviewer moved somethi
|
||||||
// achieved nothing look like it had worked.
|
// achieved nothing look like it had worked.
|
||||||
const r = router(null, { visionModel: 'seeing-model', slideImages: TWO_PNGS,
|
const r = router(null, { visionModel: 'seeing-model', slideImages: TWO_PNGS,
|
||||||
replies: [JSON.stringify({ slides: DECK.slides })] });
|
replies: [JSON.stringify({ slides: DECK.slides })] });
|
||||||
const res = await r.request('post', '/my-resources/:id/refine', { instructions: 'make it better' });
|
const res = await r.refine( { instructions: 'make it better' });
|
||||||
|
|
||||||
assert.equal(res.body.unchanged, true, 'judged on the model edit, before the reviewer ran');
|
assert.equal(res.body.unchanged, true, 'judged on the model edit, before the reviewer ran');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -184,7 +184,7 @@ test('illustration is opt-in, with its own dispatcher rather than the assistant
|
||||||
assert.match(read('public/js/generatedImages.js'), /my_resources: '\/api\/my-resources\/image\/jobs\/'/);
|
assert.match(read('public/js/generatedImages.js'), /my_resources: '\/api\/my-resources\/image\/jobs\/'/);
|
||||||
// And it renders where the person is looking, rather than pointing them at an
|
// And it renders where the person is looking, rather than pointing them at an
|
||||||
// image history this feature does not have.
|
// image history this feature does not have.
|
||||||
assert.match(read('public/js/myResources.js'), /showIllustrations\(data\.imageJobs \|\| \[\]\)/);
|
assert.match(read('public/js/myResources.js'), /showIllustrations\(r\.imageJobs \|\| \[\]\)/);
|
||||||
assert.match(read('public/components/my-resources.html'), /id="mr-images"/);
|
assert.match(read('public/components/my-resources.html'), /id="mr-images"/);
|
||||||
assert.match(route, /imageJobs: ai\.imageJobs \|\| \[\]/, 'and reported back');
|
assert.match(route, /imageJobs: ai\.imageJobs \|\| \[\]/, 'and reported back');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ test('every read route goes through the reader rule; every write route still fil
|
||||||
const body = route.slice(route.indexOf(marker), route.indexOf('\n});', route.indexOf(marker)));
|
const body = route.slice(route.indexOf(marker), route.indexOf('\n});', route.indexOf(marker)));
|
||||||
assert.match(body, /await readableResource\(req\.params\.id, req\.user\.id/, marker + ' reads through the rule');
|
assert.match(body, /await readableResource\(req\.params\.id, req\.user\.id/, marker + ' reads through the rule');
|
||||||
}
|
}
|
||||||
for (const marker of ["router.put('/my-resources/:id/theme'", "router.post('/my-resources/:id/refine'", "router.delete('/my-resources/:id'", "router.put('/my-resources/:id'"]) {
|
for (const marker of ["router.put('/my-resources/:id/theme'", "async function refineResource(", "router.delete('/my-resources/:id'", "router.put('/my-resources/:id'"]) {
|
||||||
const body = route.slice(route.indexOf(marker), route.indexOf('\n});', route.indexOf(marker)));
|
const body = route.slice(route.indexOf(marker), route.indexOf('\n});', route.indexOf(marker)));
|
||||||
assert.match(body, /AND user_id = \?/, marker + ' stays the owner\'s');
|
assert.match(body, /AND user_id = \?/, marker + ' stays the owner\'s');
|
||||||
assert.doesNotMatch(body, /readableResource/, marker + ' is not opened to readers');
|
assert.doesNotMatch(body, /readableResource/, marker + ' is not opened to readers');
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,7 @@ test('searching is the route\u2019s job, not something the model is asked to do'
|
||||||
// different sources or searching them differently.
|
// different sources or searching them differently.
|
||||||
assert.match(route, /async function gatherSources\(subject, body, keywords\)/);
|
assert.match(route, /async function gatherSources\(subject, body, keywords\)/);
|
||||||
assert.match(route, /var sources = await gatherSources\(topic, body\);/, 'generate');
|
assert.match(route, /var sources = await gatherSources\(topic, body\);/, 'generate');
|
||||||
assert.match(route, /var sources = await gatherSources\(subject, req\.body, existing\.topic \|\| instructions\);/,
|
assert.match(route, /var sources = await gatherSources\(subject, body, existing\.topic \|\| instructions\);/,
|
||||||
'and modify, whose library search gets the instruction for context and whose keyword searches do not');
|
'and modify, whose library search gets the instruction for context and whose keyword searches do not');
|
||||||
|
|
||||||
// Declared before they are used. They were not, once: `var` hoisting made
|
// Declared before they are used. They were not, once: `var` hoisting made
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue