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