merge: modifying a resource is a job
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Docker Build / Build Docker image (push) Successful in 6s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 6s

This commit is contained in:
Daniel 2026-09-16 23:28:42 +02:00
commit 39272f17c8
8 changed files with 344 additions and 281 deletions

View file

@ -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 () {

View file

@ -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;

View file

@ -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');

View file

@ -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/);
}); });

View file

@ -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');
}); });

View file

@ -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');

View file

@ -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');

View file

@ -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