From d2a06b0fcf698bb5ac9ee55041912d8d9fb10ef5 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 16 Sep 2026 23:28:42 +0200 Subject: [PATCH] feat: modifying a resource is a job, the same as generating one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- public/js/myResources.js | 59 ++-- src/routes/myResources.js | 517 +++++++++++++++++-------------- test/deck-review.test.js | 4 +- test/deck-themes.test.js | 2 +- test/my-resources-refine.test.js | 37 ++- test/my-resources.test.js | 2 +- test/resource-sharing.test.js | 2 +- test/web-search.test.js | 2 +- 8 files changed, 344 insertions(+), 281 deletions(-) diff --git a/public/js/myResources.js b/public/js/myResources.js index b5793517..b494ef35 100644 --- a/public/js/myResources.js +++ b/public/js/myResources.js @@ -381,6 +381,29 @@ var r = job.result || {}; var g = r.grounding || {}; 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 ? 'Written from ' + g.count + ' library excerpt' + (g.count === 1 ? '' : 's') + '.' : '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)'; var label = document.createElement('span'); 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'); state.style.cssText = 'color:var(--g600);white-space:nowrap;'; 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' : 'Failed — ' + (job.error || 'Generation failed'); row.appendChild(icon); row.appendChild(label); row.appendChild(state); @@ -864,6 +888,11 @@ if (btn) { btn.disabled = true; btn.innerHTML = ' Applying'; } clearResults(); 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', { method: 'POST', headers: getAuthHeaders(), @@ -879,29 +908,9 @@ .then(function (r) { return r.json(); }) .then(function (data) { if (!data.success) throw new Error(data.error || 'Could not apply the changes'); - // Same reporting as generating: what it was written from, what was - // searched, and any figure that came back. - var g = data.grounding || {}; - // 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(); + say('Rewriting in the background. You can leave this page — it keeps going.', 'good'); + if (box) box.value = ''; + refreshJobs(); }) .catch(function (err) { say(err.message, 'bad'); }) .finally(function () { diff --git a/src/routes/myResources.js b/src/routes/myResources.js index 55e0184d..068726e2 100644 --- a/src/routes/myResources.js +++ b/src/routes/myResources.js @@ -574,15 +574,32 @@ function httpError(statusCode, message) { // job; a runaway click is not. var MAX_ACTIVE_JOBS = 3; +// One writer at a time, counted across generating and modifying alike: a +// modification does the same library search and the same long model call as a +// generation, so letting ten of them run at once is the cost the cap bounds. +async function assertJobSlot(userId) { + var active = await db.get("SELECT COUNT(*)::int AS n FROM user_resource_jobs WHERE user_id = ? AND status IN ('queued', 'running')", [userId]); + if (active && active.n >= MAX_ACTIVE_JOBS) { + throw httpError(409, MAX_ACTIVE_JOBS + ' resources are already being written. Wait for one to finish.'); + } +} + /** Run one queued job to its end and record how it ended. Never throws. */ -async function runResourceJob(jobId, userId, body) { +async function runResourceJob(jobId, userId, body, kind, resourceId) { try { await db.run("UPDATE user_resource_jobs SET status = 'running', started_at = NOW() WHERE id = ?", [jobId]); - var result = await generateResource(userId, body); + // A modification needs the author, not just their id: the deck is rendered for + // sight and each figure is read back under their ownership. Loaded here rather + // than carried in the request, so the job holds no stale session. + var result = kind === 'refine' + ? await refineResource(await db.get('SELECT id, email, name, role, totp_enabled, disabled FROM users WHERE id = ?', [userId]), resourceId, body) + : await generateResource(userId, body); var summary = { resource: result.resource, grounding: result.grounding, imageJobs: result.imageJobs, imageFailures: result.imageFailures, searches: result.searches, review: result.review, - deckFallback: result.deckFallback, model: result.model + deckFallback: result.deckFallback, model: result.model, + // Read back by the page for a modification; absent for a generation. + unchanged: result.unchanged, saw: result.saw }; await db.run("UPDATE user_resource_jobs SET status = 'done', finished_at = NOW(), resource_id = ?, result = ? WHERE id = ?", [result.resource.id, JSON.stringify(summary), jobId]); @@ -614,10 +631,7 @@ router.post('/my-resources/generate', async function (req, res) { try { var topic = String(req.body.topic || '').trim(); if (!topic) return res.status(400).json({ error: 'A topic is required' }); - var active = await db.get("SELECT COUNT(*)::int AS n FROM user_resource_jobs WHERE user_id = ? AND status IN ('queued', 'running')", [req.user.id]); - if (active && active.n >= MAX_ACTIVE_JOBS) { - return res.status(409).json({ error: MAX_ACTIVE_JOBS + ' resources are already being written. Wait for one to finish.' }); - } + await assertJobSlot(req.user.id); var count = await db.get('SELECT COUNT(*)::int AS n FROM user_resources WHERE user_id = ?', [req.user.id]); if (count && count.n >= MAX_PER_USER) { return res.status(409).json({ error: 'You have reached ' + MAX_PER_USER + ' saved resources. Delete one first.' }); @@ -626,7 +640,7 @@ router.post('/my-resources/generate', async function (req, res) { 'INSERT INTO user_resource_jobs (user_id, topic, kind, request) VALUES (?, ?, ?, ?) ' + 'RETURNING id, topic, kind, status, created_at', [req.user.id, topic.slice(0, 500), normalizeKind(req.body.kind), JSON.stringify(req.body || {})]); - runResourceJob(job.id, req.user.id, req.body || {}); + runResourceJob(job.id, req.user.id, req.body || {}, 'generate', null); res.status(202).json({ success: true, job: job }); } catch (err) { console.error('[my-resources] generate:', err.message); @@ -1026,246 +1040,272 @@ router.put('/my-resources/:id/theme', async function (req, res) { } }); +// Modify a resource. The same shape as generating: the caller queues the work +// and the page follows it as a job, so the tab can be closed while it runs. +// Ownership is re-checked here rather than only at enqueue, because the +// resource can be deleted while the job waits its turn. +async function refineResource(user, resourceId, body) { + var instructions = String(body.instructions || '').trim(); + if (!instructions) throw httpError(400, 'Say what to change'); + + var existing = await db.get( + 'SELECT id, kind, topic, markdown, deck, image_ids, theme FROM user_resources WHERE id = ? AND user_id = ?', + [resourceId, user.id] + ); + if (!existing) throw httpError(404, 'Not found'); + + // Modifying can reach for the same sources as generating: "add what the + // 2024 trial showed" is a request for material, not just a rewording, and + // without this it would be answered from the model's memory alone. The + // subject searched is the resource's own topic plus the instruction, so a + // request about something not in the original still finds it. + var subject = [existing.topic, instructions].filter(Boolean).join(' \u2014 ').slice(0, 500); + var sources = await gatherSources(subject, body, existing.topic || instructions); + + var material = ''; + if (sources.corpus.context) { + material += '\n\nLIBRARY EXCERPTS (prefer these over your own recall; add anything you use ' + + 'to the References section):\n"""\n' + sources.corpus.context + '\n"""'; + } + if (sources.literature) { + material += '\n\nPUBMED RESULTS (cite by PMID in the References section; cite nothing not ' + + 'listed here):\n"""\n' + sources.literature + '\n"""'; + } + if (sources.webFindings) { + material += '\n\nWEB RESULTS (list what you use in the References section by title and ' + + 'URL):\n"""\n' + sources.webFindings + '\n"""'; + } + if (sources.searchedAndFoundNothing) { + material += '\n\nThe search for this returned nothing. Do not invent a citation, a PMID or ' + + 'a URL to fill the gap: leave the References section as it is.'; + } + + // A presentation with a stored deck is edited as a deck. Editing its + // markdown instead changed only the markdown: export renders from the deck, + // so a modification succeeded, said so, and produced an identical download. + // + // Declared here, above its first reader, and not further down where it used + // to sit. `var` hoisted it, so the illustration branch below read it as + // undefined and composed the markdown instruction for a deck: the model was + // told to return markdown and deck JSON in the same reply, the reply parsed + // as neither, and modifying a deck with illustration on failed outright. + var existingDeck = existing.deck + ? (typeof existing.deck === 'string' ? JSON.parse(existing.deck) : existing.deck) : null; + if (existingDeck && !(existingDeck.slides || []).length) existingDeck = null; + + // How a figure is asked for depends on which thing is being edited. A deck + // places figures by declaring them on a slide; markdown has nowhere to put + // one, so it uses the tool. Offering the tool while editing a deck queued a + // figure that no slide referenced — it was generated, paid for, recorded + // against the resource, and never appeared in the export. + var illustration = !sources.wantsImages ? '' + : existingDeck + ? '\n\nThe author has asked for illustration. Add "image_prompt" to the slides that ' + + 'should carry a figure — schematic or anatomical teaching artwork only, never a real ' + + 'patient — and keep the "image_job" value of any slide that already has one. ' + + resourceImages.guidance(instructions) + : '\n\nAn illustration tool is available and the author has asked for illustration. ' + + resourceImages.guidance(instructions) + ' Schematic or anatomical teaching artwork only, ' + + 'never a real patient. Returning the markdown is still required; a tool call is not a ' + + 'substitute for it, and no image tag or URL goes into the markdown.'; + + // Let it look at the deck it is about to edit, when a vision model is + // configured. Measured before building this: the text model returned the + // deck unchanged for "make it better", and a stronger model fixed that + // without seeing anything — but sight is what makes the visual half of the + // instructions answerable at all, and that is most of what people ask for + // while modifying. + var visionModel = existingDeck + ? String(await db.getSetting('my_resources.review_model', '') || '') : ''; + var slideViews = visionModel + ? await renderDeckForSight(existingDeck, existing.image_ids, user) + : []; + var sight = !slideViews.length ? '' : + '\n\nYou can see the deck as it renders now: ' + slideViews.length + ' image' + + (slideViews.length === 1 ? '' : 's') + ', one per slide, in order. The first image is ' + + 'slide index 0 in the JSON below. Use them for anything the instruction says about how ' + + 'a slide looks — crowded, empty, a figure in the wrong place, text running off the ' + + 'bottom. The JSON is what you edit; the images only tell you what it currently ' + + 'produces.'; + + var messages = [{ role: 'user', content: existingDeck + ? 'Revise the following slide deck according to the instruction. Return ONLY the ' + + 'complete revised JSON object, in exactly the same shape, no commentary and no code ' + + 'fences. Keep every slide that the instruction does not ask you to change, including ' + + 'its layout and its "image_job" values, and keep any References slide last.\n\n' + + 'INSTRUCTION: ' + instructions + illustration + material + sight + + '\n\nThe layouts available are:\n' + deckSchema.instructions( + (existingDeck.slides || []).length, 0) + + (existingDeck.format ? '\n\n' + deckFormats.instructions(existingDeck.format) : '') + + '\n\nDECK JSON:\n' + JSON.stringify({ slides: existingDeck.slides }) + : 'Revise the following Pandoc markdown according to the instruction. ' + + 'Return ONLY the complete revised markdown, no commentary, no code fences. ' + + 'Keep the same overall structure unless the instruction asks otherwise, and keep any ' + + 'References section at the end.\n\nINSTRUCTION: ' + instructions + illustration + material + + '\n\nMARKDOWN:\n"""\n' + existing.markdown + '\n"""' }]; + // The reply restates the whole resource, so it needs room for one. + // + // The vision model does the editing when there is one — both because it is + // the only one that can act on what it sees, and because it follows a vague + // instruction better: measured on a real 20-slide deck, the text model + // echoed "make it better" back unchanged and the vision model did not. + // An explicit choice by the author still wins over both. + var options = { + model: body.model ? await resolveModel(body.model) + : (visionModel && slideViews.length ? visionModel : await resolveModel('')), + temperature: 0.2, maxTokens: 16000, + // A revision is writing. This is the call a thirteen-slide deck made the + // browser wait six minutes for: DeepSeek reasoned for a minute and a half + // and wrote nothing on the first attempt. + reasoningEffort: 'none' + }; + if (slideViews.length) options.images = slideViews; + // Deck mode declares its figures; only the markdown path needs the tool. + var tools = sources.wantsImages && !existingDeck ? resourceImages.tools : []; + var callOptions = tools.length ? Object.assign({}, options, { tools: tools }) : options; + if (tools.length && resourceImages.requestedCount(instructions)) callOptions.toolChoice = 'required'; + + var ai = await callAI(messages, callOptions); + if (sources.wantsImages && !existingDeck) { + ai = await resourceImages.dispatch(ai, { + owner: user.id, body: body, subject: subject, imageModel: sources.imageModel, + messages: messages, options: options, callAI: callAI + }); + } + + var revisedDeck = existingDeck ? deckBuild.parse(ai && ai.content) : null; + if (existingDeck && !revisedDeck) { + // A reply that is not a deck would otherwise be saved as the markdown and + // silently drop every layout the deck held. + logRefine({ id: existing.id, path: 'deck', outcome: 'refused', + detail: 'the model did not return a usable deck', instructions: instructions }); + throw httpError(502, 'That change could not be applied. Try wording it differently.'); + } + if (revisedDeck) { + // Carried through rather than trusted from the reply. + revisedDeck.title = existingDeck.title; + revisedDeck.subtitle = existingDeck.subtitle; + revisedDeck.date = existingDeck.date; + // The look is the author's, not the model's: a modification never + // resets the theme. The column keeps it too, but the two should agree. + revisedDeck.theme = existingDeck.theme || existing.theme || revisedDeck.theme; + // And the shape it was written in. + revisedDeck.format = existingDeck.format || revisedDeck.format; + // Draw whatever the revised deck asked for, the same way generating does, + // so each new figure belongs to the slide that wanted it. + if (sources.wantsImages) { + var drawn = await deckBuild.drawFigures(revisedDeck, { + owner: user.id, body: body, subject: subject, imageModel: sources.imageModel + }); + ai = Object.assign({}, ai, { imageJobs: drawn.jobs, imageFailures: drawn.failures }); + } else { + // Illustration off: a figure the revision newly asks for keeps its + // place as an empty labelled frame; figures already drawn are kept. + await deckBuild.drawFigures(revisedDeck, { imageModel: '' }); + } + } + + // Look at the result. The edit was made against how the deck looked before + // it; a slide that gained two bullets now overflows, and only rendering it + // again shows that. Same reviewer as generation, which may reposition but + // is held to the same words — so a verification pass cannot quietly undo + // the change that was just asked for. + // Judged before the reviewer touches it. The question "did my instruction do + // anything" is about the model's edit; a reviewer that nudged a slide into + // two columns would otherwise mask an instruction that achieved nothing. + var echoedBack = revisedDeck + ? JSON.stringify(existingDeck.slides) === JSON.stringify(revisedDeck.slides) + : false; + + var verified = { reviewed: false, reason: 'not attempted' }; + if (revisedDeck && visionModel) { + verified = await deckReview.review(revisedDeck, { + model: visionModel, + reasoningEffort: options.reasoningEffort, + callAI: callAI, + extractJson: deckBuild.extractJson, + gotenberg: documentExport.GOTENBERG, + mime: documentExport.FORMATS.pptx.mime, + pptx: await documentExport.renderDeck(revisedDeck, [], figureIdList(existing.image_ids)) + }); + revisedDeck = verified.deck; + } + + var revised = revisedDeck ? deckSchema.toMarkdown(revisedDeck) + : String((ai && ai.content) || '').trim(); + if (!revised) throw httpError(502, 'The model returned nothing. Try again.'); + + // Figures from a modification are added to the ones already there, not + // swapped for them: "add two more diagrams" means more, not instead. + var added = (ai.imageJobs || []).map(function (job) { return job.jobId; }).filter(Boolean); + var row = await db.get( + 'UPDATE user_resources SET markdown = ?, title = ?, updated_at = NOW(), ' + + 'image_ids = image_ids || ?::jsonb, deck = COALESCE(?::jsonb, deck) ' + + 'WHERE id = ? AND user_id = ? RETURNING id, title, updated_at', + [revised, firstHeading(revised), JSON.stringify(added), + revisedDeck ? JSON.stringify(revisedDeck) : null, existing.id, user.id] + ); + // Said out loud, every time. A modification that changes nothing is the + // failure worth catching, and it is invisible from the response: the row + // updates, the title updates, and the file is identical. + // Computed once, because the caller is told this too. Logging it server-side + // while answering "Applied" left the one person who could do something about + // it — the person who asked for the change — reading a success message next + // to an identical file. + var unchanged = revisedDeck ? echoedBack + : revised.trim() === String(existing.markdown || '').trim(); + logRefine({ + id: existing.id, + path: revisedDeck ? 'deck' : 'markdown', + outcome: 'applied', + before: existingDeck ? (existingDeck.slides || []).length : existing.markdown.length, + after: revisedDeck ? (revisedDeck.slides || []).length : revised.length, + unchanged: unchanged, + figures: added.length, + model: ai && ai.model, + instructions: instructions + }); + + return { + success: true, resource: row, markdown: revised, unchanged: unchanged, + saw: slideViews.length, + review: { applied: verified.reviewed, reason: verified.reason }, + grounding: { used: Boolean(sources.corpus.context), count: sources.corpus.sources.length, + reason: sources.corpus.reason || null }, + searches: sources.searches, + imageJobs: ai.imageJobs || [], + imageFailures: ai.imageFailures || [], + model: ai && ai.model + }; +} + +// Queue a modification and answer at once, exactly as generating does. The work +// is a job on the server, so a browser that gives up waiting — Firefox abandons +// a non-streaming fetch at five minutes — no longer takes the modification down +// with it, and a reload loses nothing. router.post('/my-resources/:id/refine', async function (req, res) { try { var instructions = String(req.body.instructions || '').trim(); if (!instructions) return res.status(400).json({ error: 'Say what to change' }); - - var existing = await db.get( - 'SELECT id, kind, topic, markdown, deck, image_ids, theme FROM user_resources WHERE id = ? AND user_id = ?', - [parseInt(req.params.id, 10), req.user.id] - ); + var resourceId = parseInt(req.params.id, 10); + var existing = await db.get('SELECT id, topic FROM user_resources WHERE id = ? AND user_id = ?', + [resourceId, req.user.id]); if (!existing) return res.status(404).json({ error: 'Not found' }); - - // Modifying can reach for the same sources as generating: "add what the - // 2024 trial showed" is a request for material, not just a rewording, and - // without this it would be answered from the model's memory alone. The - // subject searched is the resource's own topic plus the instruction, so a - // request about something not in the original still finds it. - var subject = [existing.topic, instructions].filter(Boolean).join(' \u2014 ').slice(0, 500); - var sources = await gatherSources(subject, req.body, existing.topic || instructions); - - var material = ''; - if (sources.corpus.context) { - material += '\n\nLIBRARY EXCERPTS (prefer these over your own recall; add anything you use ' + - 'to the References section):\n"""\n' + sources.corpus.context + '\n"""'; - } - if (sources.literature) { - material += '\n\nPUBMED RESULTS (cite by PMID in the References section; cite nothing not ' + - 'listed here):\n"""\n' + sources.literature + '\n"""'; - } - if (sources.webFindings) { - material += '\n\nWEB RESULTS (list what you use in the References section by title and ' + - 'URL):\n"""\n' + sources.webFindings + '\n"""'; - } - if (sources.searchedAndFoundNothing) { - material += '\n\nThe search for this returned nothing. Do not invent a citation, a PMID or ' + - 'a URL to fill the gap: leave the References section as it is.'; - } - - // A presentation with a stored deck is edited as a deck. Editing its - // markdown instead changed only the markdown: export renders from the deck, - // so a modification succeeded, said so, and produced an identical download. - // - // Declared here, above its first reader, and not further down where it used - // to sit. `var` hoisted it, so the illustration branch below read it as - // undefined and composed the markdown instruction for a deck: the model was - // told to return markdown and deck JSON in the same reply, the reply parsed - // as neither, and modifying a deck with illustration on failed outright. - var existingDeck = existing.deck - ? (typeof existing.deck === 'string' ? JSON.parse(existing.deck) : existing.deck) : null; - if (existingDeck && !(existingDeck.slides || []).length) existingDeck = null; - - // How a figure is asked for depends on which thing is being edited. A deck - // places figures by declaring them on a slide; markdown has nowhere to put - // one, so it uses the tool. Offering the tool while editing a deck queued a - // figure that no slide referenced — it was generated, paid for, recorded - // against the resource, and never appeared in the export. - var illustration = !sources.wantsImages ? '' - : existingDeck - ? '\n\nThe author has asked for illustration. Add "image_prompt" to the slides that ' + - 'should carry a figure — schematic or anatomical teaching artwork only, never a real ' + - 'patient — and keep the "image_job" value of any slide that already has one. ' + - resourceImages.guidance(instructions) - : '\n\nAn illustration tool is available and the author has asked for illustration. ' + - resourceImages.guidance(instructions) + ' Schematic or anatomical teaching artwork only, ' + - 'never a real patient. Returning the markdown is still required; a tool call is not a ' + - 'substitute for it, and no image tag or URL goes into the markdown.'; - - // Let it look at the deck it is about to edit, when a vision model is - // configured. Measured before building this: the text model returned the - // deck unchanged for "make it better", and a stronger model fixed that - // without seeing anything — but sight is what makes the visual half of the - // instructions answerable at all, and that is most of what people ask for - // while modifying. - var visionModel = existingDeck - ? String(await db.getSetting('my_resources.review_model', '') || '') : ''; - var slideViews = visionModel - ? await renderDeckForSight(existingDeck, existing.image_ids, req.user) - : []; - var sight = !slideViews.length ? '' : - '\n\nYou can see the deck as it renders now: ' + slideViews.length + ' image' + - (slideViews.length === 1 ? '' : 's') + ', one per slide, in order. The first image is ' + - 'slide index 0 in the JSON below. Use them for anything the instruction says about how ' + - 'a slide looks — crowded, empty, a figure in the wrong place, text running off the ' + - 'bottom. The JSON is what you edit; the images only tell you what it currently ' + - 'produces.'; - - var messages = [{ role: 'user', content: existingDeck - ? 'Revise the following slide deck according to the instruction. Return ONLY the ' + - 'complete revised JSON object, in exactly the same shape, no commentary and no code ' + - 'fences. Keep every slide that the instruction does not ask you to change, including ' + - 'its layout and its "image_job" values, and keep any References slide last.\n\n' + - 'INSTRUCTION: ' + instructions + illustration + material + sight + - '\n\nThe layouts available are:\n' + deckSchema.instructions( - (existingDeck.slides || []).length, 0) + - (existingDeck.format ? '\n\n' + deckFormats.instructions(existingDeck.format) : '') + - '\n\nDECK JSON:\n' + JSON.stringify({ slides: existingDeck.slides }) - : 'Revise the following Pandoc markdown according to the instruction. ' + - 'Return ONLY the complete revised markdown, no commentary, no code fences. ' + - 'Keep the same overall structure unless the instruction asks otherwise, and keep any ' + - 'References section at the end.\n\nINSTRUCTION: ' + instructions + illustration + material + - '\n\nMARKDOWN:\n"""\n' + existing.markdown + '\n"""' }]; - // The reply restates the whole resource, so it needs room for one. - // - // The vision model does the editing when there is one — both because it is - // the only one that can act on what it sees, and because it follows a vague - // instruction better: measured on a real 20-slide deck, the text model - // echoed "make it better" back unchanged and the vision model did not. - // An explicit choice by the author still wins over both. - var options = { - model: req.body.model ? await resolveModel(req.body.model) - : (visionModel && slideViews.length ? visionModel : await resolveModel('')), - temperature: 0.2, maxTokens: 16000, - // A revision is writing. This is the call a thirteen-slide deck made the - // browser wait six minutes for: DeepSeek reasoned for a minute and a half - // and wrote nothing on the first attempt. - reasoningEffort: 'none' - }; - if (slideViews.length) options.images = slideViews; - // Deck mode declares its figures; only the markdown path needs the tool. - var tools = sources.wantsImages && !existingDeck ? resourceImages.tools : []; - var callOptions = tools.length ? Object.assign({}, options, { tools: tools }) : options; - if (tools.length && resourceImages.requestedCount(instructions)) callOptions.toolChoice = 'required'; - - var ai = await callAI(messages, callOptions); - if (sources.wantsImages && !existingDeck) { - ai = await resourceImages.dispatch(ai, { - owner: req.user.id, body: req.body, subject: subject, imageModel: sources.imageModel, - messages: messages, options: options, callAI: callAI - }); - } - - var revisedDeck = existingDeck ? deckBuild.parse(ai && ai.content) : null; - if (existingDeck && !revisedDeck) { - // A reply that is not a deck would otherwise be saved as the markdown and - // silently drop every layout the deck held. - logRefine({ id: existing.id, path: 'deck', outcome: 'refused', - detail: 'the model did not return a usable deck', instructions: instructions }); - return res.status(502).json({ error: 'That change could not be applied. Try wording it differently.' }); - } - if (revisedDeck) { - // Carried through rather than trusted from the reply. - revisedDeck.title = existingDeck.title; - revisedDeck.subtitle = existingDeck.subtitle; - revisedDeck.date = existingDeck.date; - // The look is the author's, not the model's: a modification never - // resets the theme. The column keeps it too, but the two should agree. - revisedDeck.theme = existingDeck.theme || existing.theme || revisedDeck.theme; - // And the shape it was written in. - revisedDeck.format = existingDeck.format || revisedDeck.format; - // Draw whatever the revised deck asked for, the same way generating does, - // so each new figure belongs to the slide that wanted it. - if (sources.wantsImages) { - var drawn = await deckBuild.drawFigures(revisedDeck, { - owner: req.user.id, body: req.body, subject: subject, imageModel: sources.imageModel - }); - ai = Object.assign({}, ai, { imageJobs: drawn.jobs, imageFailures: drawn.failures }); - } else { - // Illustration off: a figure the revision newly asks for keeps its - // place as an empty labelled frame; figures already drawn are kept. - await deckBuild.drawFigures(revisedDeck, { imageModel: '' }); - } - } - - // Look at the result. The edit was made against how the deck looked before - // it; a slide that gained two bullets now overflows, and only rendering it - // again shows that. Same reviewer as generation, which may reposition but - // is held to the same words — so a verification pass cannot quietly undo - // the change that was just asked for. - // Judged before the reviewer touches it. The question "did my instruction do - // anything" is about the model's edit; a reviewer that nudged a slide into - // two columns would otherwise mask an instruction that achieved nothing. - var echoedBack = revisedDeck - ? JSON.stringify(existingDeck.slides) === JSON.stringify(revisedDeck.slides) - : false; - - var verified = { reviewed: false, reason: 'not attempted' }; - if (revisedDeck && visionModel) { - verified = await deckReview.review(revisedDeck, { - model: visionModel, - reasoningEffort: options.reasoningEffort, - callAI: callAI, - extractJson: deckBuild.extractJson, - gotenberg: documentExport.GOTENBERG, - mime: documentExport.FORMATS.pptx.mime, - pptx: await documentExport.renderDeck(revisedDeck, [], figureIdList(existing.image_ids)) - }); - revisedDeck = verified.deck; - } - - var revised = revisedDeck ? deckSchema.toMarkdown(revisedDeck) - : String((ai && ai.content) || '').trim(); - if (!revised) return res.status(502).json({ error: 'The model returned nothing. Try again.' }); - - // Figures from a modification are added to the ones already there, not - // swapped for them: "add two more diagrams" means more, not instead. - var added = (ai.imageJobs || []).map(function (job) { return job.jobId; }).filter(Boolean); - var row = await db.get( - 'UPDATE user_resources SET markdown = ?, title = ?, updated_at = NOW(), ' + - 'image_ids = image_ids || ?::jsonb, deck = COALESCE(?::jsonb, deck) ' + - 'WHERE id = ? AND user_id = ? RETURNING id, title, updated_at', - [revised, firstHeading(revised), JSON.stringify(added), - revisedDeck ? JSON.stringify(revisedDeck) : null, existing.id, req.user.id] - ); - // Said out loud, every time. A modification that changes nothing is the - // failure worth catching, and it is invisible from the response: the row - // updates, the title updates, and the file is identical. - // Computed once, because the caller is told this too. Logging it server-side - // while answering "Applied" left the one person who could do something about - // it — the person who asked for the change — reading a success message next - // to an identical file. - var unchanged = revisedDeck ? echoedBack - : revised.trim() === String(existing.markdown || '').trim(); - logRefine({ - id: existing.id, - path: revisedDeck ? 'deck' : 'markdown', - outcome: 'applied', - before: existingDeck ? (existingDeck.slides || []).length : existing.markdown.length, - after: revisedDeck ? (revisedDeck.slides || []).length : revised.length, - unchanged: unchanged, - figures: added.length, - model: ai && ai.model, - instructions: instructions - }); - - res.json({ - success: true, resource: row, markdown: revised, unchanged: unchanged, - saw: slideViews.length, - review: { applied: verified.reviewed, reason: verified.reason }, - grounding: { used: Boolean(sources.corpus.context), count: sources.corpus.sources.length, - reason: sources.corpus.reason || null }, - searches: sources.searches, - imageJobs: ai.imageJobs || [], - imageFailures: ai.imageFailures || [], - model: ai && ai.model - }); + await assertJobSlot(req.user.id); + var job = await db.get( + 'INSERT INTO user_resource_jobs (user_id, topic, kind, resource_id, request) VALUES (?, ?, ?, ?, ?) ' + + 'RETURNING id, topic, kind, status, created_at', + [req.user.id, String(existing.topic || instructions).slice(0, 500), 'refine', resourceId, + JSON.stringify(req.body || {})]); + runResourceJob(job.id, req.user.id, req.body || {}, 'refine', resourceId); + res.status(202).json({ success: true, job: job }); } catch (err) { console.error('[my-resources] refine:', err.message); - res.status(err.statusCode || 500).json({ error: err.statusCode ? err.message : 'Refinement failed' }); + res.status(err.statusCode || 500).json({ error: err.statusCode ? err.message : 'Modification could not be started' }); } }); + // ── Showing the deck to the model that is about to change it ──────────────── // The model that writes a deck never sees it, and that is just as true when it // is editing one. Most of what people ask for while modifying is about the @@ -1438,4 +1478,5 @@ module.exports = router; // The job runner's body, reachable for tests that exercise a generation // end to end without waiting on a detached job. module.exports.generateResource = generateResource; +module.exports.refineResource = refineResource; module.exports.runResourceJob = runResourceJob; diff --git a/test/deck-review.test.js b/test/deck-review.test.js index 854e6eed..a2f27d20 100644 --- a/test/deck-review.test.js +++ b/test/deck-review.test.js @@ -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 // old rule assumed refining was a text edit; it is a layout edit as often as // 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, /if \(revisedDeck && visionModel\)/, 'and only when one is configured'); // 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 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'); - 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.equal((refine.match(/reasoningEffort: options\.reasoningEffort,/g) || []).length, 1, 'and its review is asked the same way'); diff --git a/test/deck-themes.test.js b/test/deck-themes.test.js index 6ee3de12..816d5409 100644 --- a/test/deck-themes.test.js +++ b/test/deck-themes.test.js @@ -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 // column keeps the theme regardless, but the deck field must agree with it. 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.slice(0, 2500), /SELECT id, kind, topic, markdown, deck, image_ids, theme FROM user_resources/); }); diff --git a/test/my-resources-refine.test.js b/test/my-resources-refine.test.js index 587bfa38..fe09753d 100644 --- a/test/my-resources-refine.test.js +++ b/test/my-resources-refine.test.js @@ -120,7 +120,18 @@ function router(t, overrides = {}) { async function generate(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 () => { @@ -133,7 +144,7 @@ test('modifying a deck asks for a deck, even when illustration is on', async () const r = router(null, { 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' }); @@ -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 // could not see it. 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.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, { 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(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 () => { 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' }); @@ -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, /edited \? 'modified '/); 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 ────────────────────────────────────────────────────── @@ -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 // word, which is past the point a browser waits for the request. 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(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 // it is answerable from the JSON alone. 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' }); @@ -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 // gained two bullets only overflows once it is rendered again. 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(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 // for a modification to fail. 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.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 // the author their modification. 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.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. const r = router(null, { visionModel: 'seeing-model', slideImages: TWO_PNGS, 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'); }); diff --git a/test/my-resources.test.js b/test/my-resources.test.js index e7351f4c..2ab4b802 100644 --- a/test/my-resources.test.js +++ b/test/my-resources.test.js @@ -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\/'/); // And it renders where the person is looking, rather than pointing them at an // 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(route, /imageJobs: ai\.imageJobs \|\| \[\]/, 'and reported back'); diff --git a/test/resource-sharing.test.js b/test/resource-sharing.test.js index 36cd45dc..c1963c49 100644 --- a/test/resource-sharing.test.js +++ b/test/resource-sharing.test.js @@ -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))); 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))); assert.match(body, /AND user_id = \?/, marker + ' stays the owner\'s'); assert.doesNotMatch(body, /readableResource/, marker + ' is not opened to readers'); diff --git a/test/web-search.test.js b/test/web-search.test.js index 69e8540b..ac45e308 100644 --- a/test/web-search.test.js +++ b/test/web-search.test.js @@ -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. 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\(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'); // Declared before they are used. They were not, once: `var` hoisting made