diff --git a/migrations/1781500000000_resource-jobs.js b/migrations/1781500000000_resource-jobs.js new file mode 100644 index 00000000..5f3a2b75 --- /dev/null +++ b/migrations/1781500000000_resource-jobs.js @@ -0,0 +1,30 @@ +// Generating a resource is a job, not a request. Writing a deck takes minutes +// — a library search, one or two long model calls, figures — and a browser +// holding a request open that long gives up on its own (Firefox at five +// minutes, exactly the "NetworkError when attempting to fetch resource" the +// user saw while the server carried on and saved the deck anyway). The request +// now records what was asked and returns at once; the work runs on the server, +// the page polls, and the library updates when a job lands. Several can run at +// once, and a reload loses nothing. + +exports.up = pgm => pgm.sql(` + CREATE TABLE IF NOT EXISTS user_resource_jobs ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + topic TEXT NOT NULL, + kind TEXT NOT NULL, + request JSONB NOT NULL DEFAULT '{}'::jsonb, + status TEXT NOT NULL DEFAULT 'queued', + result JSONB, + error TEXT, + resource_id INTEGER REFERENCES user_resources(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + started_at TIMESTAMPTZ, + finished_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS idx_resource_jobs_user_created ON user_resource_jobs (user_id, created_at DESC); +`); + +exports.down = pgm => pgm.sql(` + DROP TABLE IF EXISTS user_resource_jobs; +`); diff --git a/public/components/my-resources.html b/public/components/my-resources.html index 2d9c9729..9b60a862 100644 --- a/public/components/my-resources.html +++ b/public/components/my-resources.html @@ -195,6 +195,10 @@ + + diff --git a/public/js/myResources.js b/public/js/myResources.js index 3dbd6a06..b5793517 100644 --- a/public/js/myResources.js +++ b/public/js/myResources.js @@ -26,6 +26,7 @@ else clearResults(); acceptPendingShare(); loadLibrary(); + refreshJobs(); }); // What an administrator has switched on. Read once, used by both option @@ -114,6 +115,7 @@ wireImageIntent('mr-modify-instructions', 'mr-modify-images', 'mr-modify-image-hint'); loadOptions(); + refreshJobs(); } // What an administrator has approved. The model row stays hidden unless there @@ -303,9 +305,14 @@ clearResults(); var btn = document.getElementById('btn-mr-generate'); - if (btn) { btn.disabled = true; btn.innerHTML = ' Generating...'; } - status('Searching the library and writing. This takes a moment.'); + if (btn) { btn.disabled = true; btn.innerHTML = ' Queuing...'; } + // Queued, not awaited. Writing a deck takes minutes, and a browser that + // holds a request open that long gives up on its own — Firefox after five + // — and reported a network error over a deck the server went on to save. + // The server answers at once with a job; the list below follows it, the + // library refreshes when it lands, and nothing here depends on this tab + // staying open. Another can be started while one is being written. var corpusBox = document.getElementById('mr-use-corpus'); fetch('/api/my-resources/generate', { method: 'POST', @@ -326,29 +333,12 @@ withPubmed: (document.getElementById('mr-pubmed') || {}).checked ? 'true' : 'false' }) }) - .then(function (r) { return r.json(); }) - .then(function (data) { - if (!data.success) throw new Error(data.error || 'Generation failed'); - // Say what it was written from. Ungrounded material presented as - // grounded is the failure worth preventing. - var g = data.grounding || {}; - status(g.used - ? 'Saved. Written from ' + g.count + ' library excerpt' + (g.count === 1 ? '' : 's') + '.' - : 'Saved. Not grounded' + (g.reason ? ' — ' + g.reason : '') + '; written from the model alone.', - g.used ? 'good' : null); - reportSearches(data.searches); - // A deck that fell back came out as plain slides. Silently handing - // someone the plainer artifact left them comparing two decks with no - // idea why one had layouts and the other did not — and asking again - // usually gets the designed one, which is only worth knowing if the - // fallback is visible. - if (data.deckFallback) { - status('Saved, but as plain slides: the model could not produce a slide ' + - 'design twice (' + data.deckFallback + '). Generating again usually gets one.', 'bad'); - } - showIllustrations(data.imageJobs || []); - reportImageFailures(data.imageFailures); - loadLibrary(); + .then(function (r) { return r.json().then(function (data) { return { ok: r.ok, data: data || {} }; }); }) + .then(function (out) { + if (!out.ok || !out.data.success) throw new Error(out.data.error || 'Generation could not be started'); + status('Queued. It is being written on the server — you can leave this page or start another. ' + + 'It appears in your library when it is done.', 'good'); + refreshJobs(); }) .catch(function (err) { status(err.message, 'bad'); }) .finally(function () { @@ -356,6 +346,99 @@ }); } + // ── Jobs: what is being written, what landed, what failed ── + // + // Polled while anything is in flight, five seconds apart; otherwise not at + // all. A job seen going from running to done is announced once, with what + // the finished request used to say, and the library is reloaded so the new + // row is there without a click. + var jobsSeen = {}; + var jobsTimer = null; + function refreshJobs() { + clearTimeout(jobsTimer); + fetch('/api/my-resources/jobs', { headers: getAuthHeaders() }) + .then(function (r) { return r.json(); }) + .then(function (data) { + var jobs = (data && data.jobs) || []; + var landed = false; + jobs.forEach(function (job) { + var before = jobsSeen[job.id]; + if (before && before !== job.status && (job.status === 'done' || job.status === 'failed')) { + landed = true; + if (job.status === 'done') announceDone(job); + else status('“' + job.topic + '” could not be written: ' + (job.error || 'Generation failed'), 'bad'); + } + jobsSeen[job.id] = job.status; + }); + renderJobs(jobs); + if (landed) loadLibrary(); + var active = jobs.some(function (job) { return job.status === 'queued' || job.status === 'running'; }); + if (active) jobsTimer = setTimeout(refreshJobs, 5000); + }) + .catch(function () { jobsTimer = setTimeout(refreshJobs, 15000); }); + } + function announceDone(job) { + var r = job.result || {}; + var g = r.grounding || {}; + var title = (r.resource && r.resource.title) || job.topic; + 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.'), + g.used ? 'good' : null); + reportSearches(r.searches); + if (r.deckFallback) { + status('Saved, but as plain slides: the model could not produce a slide ' + + 'design twice (' + r.deckFallback + '). Generating again usually gets one.', 'bad'); + } + showIllustrations(r.imageJobs || []); + reportImageFailures(r.imageFailures); + } + function elapsed(job) { + var t = Date.parse(job.started_at || job.created_at); + if (!t) return ''; + var s = Math.max(0, Math.round((Date.now() - t) / 1000)); + return s < 60 ? s + 's' : Math.floor(s / 60) + 'm ' + (s % 60) + 's'; + } + function renderJobs(jobs) { + var box = document.getElementById('mr-jobs'); + if (!box) return; + box.textContent = ''; + if (!jobs.length) { box.hidden = true; return; } + box.hidden = false; + jobs.forEach(function (job) { + var row = document.createElement('div'); + row.className = 'mr-job'; + row.style.cssText = 'display:flex;align-items:center;gap:10px;padding:6px 0;border-bottom:1px solid var(--g200);'; + var icon = document.createElement('i'); + var busy = job.status === 'queued' || job.status === 'running'; + icon.className = busy ? 'fas fa-spinner fa-spin' : job.status === 'done' ? 'fas fa-check' : 'fas fa-triangle-exclamation'; + 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'); + 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 === 'done' ? 'Done' + : 'Failed — ' + (job.error || 'Generation failed'); + row.appendChild(icon); row.appendChild(label); row.appendChild(state); + if (!busy) { + var dismiss = document.createElement('button'); + dismiss.type = 'button'; + dismiss.className = 'btn-sm btn-ghost'; + dismiss.textContent = 'Dismiss'; + dismiss.addEventListener('click', function () { + fetch('/api/my-resources/jobs/' + job.id, { method: 'DELETE', headers: getAuthHeaders() }) + .then(function () { delete jobsSeen[job.id]; refreshJobs(); }) + .catch(function () {}); + }); + row.appendChild(dismiss); + } + box.appendChild(row); + }); + } + // Say what was searched for. A query that left the network is worth showing // plainly rather than leaving someone to wonder whether it happened. function reportSearches(searches) { diff --git a/src/routes/myResources.js b/src/routes/myResources.js index 166205e7..8f6a5823 100644 --- a/src/routes/myResources.js +++ b/src/routes/myResources.js @@ -330,23 +330,26 @@ async function gatherSources(subject, body, keywords) { } // ── Generate ──────────────────────────────────────────────── -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' }); +/** Everything a generation is, given who asked and what they asked for. + * Throws an error carrying statusCode for the caller's own mistakes; anything + * else is a failure of the generation. Runs inside a job, never inside the + * request that asked for it. */ +async function generateResource(userId, body) { + var topic = String(body.topic || '').trim(); + if (!topic) throw httpError(400, 'A topic is required'); - var kind = normalizeKind(req.body.kind); - var refinement = String(req.body.refinement || '').slice(0, 2000); + var kind = normalizeKind(body.kind); + var refinement = String(body.refinement || '').slice(0, 2000); // What the resource must cover, at length: a topic list, a case, an // outline. Separate from instructions, which say how, not what. - var details = String(req.body.details || '').slice(0, 12000).trim(); + var details = String(body.details || '').slice(0, 12000).trim(); - var count = await db.get('SELECT COUNT(*)::int AS n FROM user_resources WHERE user_id = ?', [req.user.id]); + var count = await db.get('SELECT COUNT(*)::int AS n FROM user_resources WHERE user_id = ?', [userId]); if (count && count.n >= MAX_PER_USER) { - return res.status(409).json({ error: 'You have reached ' + MAX_PER_USER + ' saved resources. Delete one first.' }); + throw httpError(409, 'You have reached ' + MAX_PER_USER + ' saved resources. Delete one first.'); } - var sources = await gatherSources(topic, req.body); + var sources = await gatherSources(topic, body); var corpus = sources.corpus; var searches = sources.searches; var wantsImages = sources.wantsImages; @@ -361,20 +364,24 @@ router.post('/my-resources/generate', async function (req, res) { literature: sources.literature, webFindings: sources.webFindings, searchedAndFoundNothing: sources.searchedAndFoundNothing, wantsImages: wantsImages, deckMode: deckMode, - format: deckMode ? deckFormats.formatId(req.body.format) : '', + format: deckMode ? deckFormats.formatId(body.format) : '', figureCount: wantsImages ? (resourceImages.requestedCount(refinement) || 0) : 0, - slideCount: clampInt(req.body.slideCount, 3, 30, 8), - wordCount: clampInt(req.body.wordCount, 200, 3000, 800) + slideCount: clampInt(body.slideCount, 3, 30, 8), + wordCount: clampInt(body.wordCount, 200, 3000, 800) }); - var model = await resolveModel(req.body.model); + var model = await resolveModel(body.model); var messages = [{ role: 'user', content: prompt }]; // A deck's JSON is several times the size of the prose it contains, and the // default budget is 4000 tokens. A sixteen-slide deck ran past it, came back // truncated, failed to parse and fell back to markdown — which has no way to // ask for a figure, so the model wrote "![Placeholder: flow diagram]" into a // bullet instead. That is why decks were arriving with no images. - var options = { model: model, temperature: 0.3 }; + // No thinking. DeepSeek spent a whole 16,000-token budget reasoning about a + // deck and wrote nothing, then did it again on the smaller calls; the + // prompt pool measured the same model faster and no worse with thinking + // off, and a deck is a writing task, not a puzzle. + var options = { model: model, temperature: 0.3, reasoningEffort: 'none' }; if (deckMode) options.maxTokens = 16000; // Tools the model may reach for on this generation, and only these. @@ -403,7 +410,7 @@ router.post('/my-resources/generate', async function (req, res) { // single image per request, which is right for a chat reply and wrong for // a resource. Same queue, same storage, same my_resources workflow. ai = await resourceImages.dispatch(ai, { - owner: req.user.id, body: req.body, subject: topic, imageModel: imageModel, + owner: userId, body: body, subject: topic, imageModel: imageModel, messages: messages, options: options, callAI: callAI }); } @@ -414,10 +421,10 @@ router.post('/my-resources/generate', async function (req, res) { var deck = deckMode ? deckBuild.parse(ai && ai.content, vocabularyGaps) : null; // Chosen by the author, not the model: a theme is a look, and the person // making the deck is the one who knows the room it will be shown in. - if (deck) deck.theme = deckSchema.themeId(req.body.theme); + if (deck) deck.theme = deckSchema.themeId(body.theme); // The format travels with the deck so a later revision keeps the shape it // was written in. - if (deck) deck.format = deckFormats.formatId(req.body.format); + if (deck) deck.format = deckFormats.formatId(body.format); reportVocabularyGaps(vocabularyGaps, topic); // Why a deck became plain slides, when it did. Reported to the caller as // well as logged: the fallback produces a usable but plainer deck, and @@ -480,14 +487,14 @@ router.post('/my-resources/generate', async function (req, res) { literature: sources.literature, webFindings: sources.webFindings, searchedAndFoundNothing: sources.searchedAndFoundNothing, wantsImages: false, deckMode: false, - slideCount: clampInt(req.body.slideCount, 3, 30, 8), - wordCount: clampInt(req.body.wordCount, 200, 3000, 800) + slideCount: clampInt(body.slideCount, 3, 30, 8), + wordCount: clampInt(body.wordCount, 200, 3000, 800) }); ai = await callAI([{ role: 'user', content: plain }], options); } if (deck) { var drawn = await deckBuild.drawFigures(deck, { - owner: req.user.id, body: req.body, subject: topic, imageModel: imageModel + owner: userId, body: body, subject: topic, imageModel: imageModel }); ai = Object.assign({}, ai, { imageJobs: drawn.jobs, imageFailures: drawn.failures }); } @@ -513,6 +520,9 @@ router.post('/my-resources/generate', async function (req, res) { if (reviewModel) { reviewed = await deckReview.review(deck, { model: reviewModel, + // The rule the writing follows: reading a rendered deck for overflow + // needs no reasoning budget either. + reasoningEffort: options.reasoningEffort, callAI: callAI, extractJson: deckBuild.extractJson, gotenberg: documentExport.GOTENBERG, @@ -529,18 +539,18 @@ router.post('/my-resources/generate', async function (req, res) { // and what a text edit edits. In deck mode it is serialised from the deck // rather than written by the model. var markdown = deck ? deckSchema.toMarkdown(deck) : String((ai && ai.content) || '').trim(); - if (!markdown) return res.status(502).json({ error: 'The model returned nothing. Try again.' }); + if (!markdown) throw httpError(502, 'The model returned nothing. Try again.'); var row = await db.get( 'INSERT INTO user_resources (user_id, title, kind, markdown, topic, grounded_count, image_ids, deck, theme) ' + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, title, kind, topic, grounded_count, created_at', - [req.user.id, deck && deck.title ? deck.title : firstHeading(markdown, topic), kind, markdown, + [userId, deck && deck.title ? deck.title : firstHeading(markdown, topic), kind, markdown, topic.slice(0, 500), corpus.sources.length, JSON.stringify(savedFigureIds), deck ? JSON.stringify(deck) : null, - kind === 'presentation' ? (deckSchema.themeId(req.body.theme) || null) : null] + kind === 'presentation' ? (deckSchema.themeId(body.theme) || null) : null] ); - res.json({ + return { success: true, resource: row, markdown: markdown, @@ -551,13 +561,106 @@ router.post('/my-resources/generate', async function (req, res) { review: { applied: reviewed.reviewed, reason: reviewed.reason }, deckFallback: deckFallback, model: ai && ai.model - }); + }; +} + +function httpError(statusCode, message) { + var err = new Error(message); + err.statusCode = statusCode; + return err; +} + +// How many a person may have in flight. Several at once is the point of the +// job; a runaway click is not. +var MAX_ACTIVE_JOBS = 3; + +/** Run one queued job to its end and record how it ended. Never throws. */ +async function runResourceJob(jobId, userId, body) { + try { + await db.run("UPDATE user_resource_jobs SET status = 'running', started_at = NOW() WHERE id = ?", [jobId]); + var result = 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 + }; + 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]); + } catch (err) { + console.error('[my-resources] job ' + jobId + ':', err.message); + try { + await db.run("UPDATE user_resource_jobs SET status = 'failed', finished_at = NOW(), error = ? WHERE id = ?", + [err.statusCode ? err.message : 'Generation failed', jobId]); + } catch (e) { console.error('[my-resources] job ' + jobId + ' could not record its failure:', e.message); } + } +} + +// A job that was running when the process stopped is not running now, and +// nothing will pick it up: the work lived in this process. Say so rather than +// leave "Writing…" on the page for ever. +async function recoverResourceJobs() { + try { + var n = await db.run("UPDATE user_resource_jobs SET status = 'failed', finished_at = NOW(), " + + "error = 'The server restarted while this was being written. Generate it again.' " + + "WHERE status IN ('queued', 'running')"); + if (n && n.changes) logger.warn('[my-resources] ' + n.changes + ' job(s) were interrupted by a restart'); + } catch (err) { console.error('[my-resources] job recovery:', err.message); } +} +setTimeout(recoverResourceJobs, 15000); + +// Queue a generation and answer at once. The work is a job on the server: the +// page can be left, reloaded, or asked for another while it runs. +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.' }); + } + 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.' }); + } + var job = await db.get( + '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 || {}); + res.status(202).json({ success: true, job: job }); } catch (err) { console.error('[my-resources] generate:', err.message); - res.status(err.statusCode || 500).json({ error: err.statusCode ? err.message : 'Generation failed' }); + res.status(err.statusCode || 500).json({ error: err.statusCode ? err.message : 'Generation could not be started' }); } }); +// The person's jobs of the last day, newest first: what is being written, what +// landed, what failed and why. +router.get('/my-resources/jobs', async function (req, res) { + try { + var rows = await db.all( + 'SELECT id, topic, kind, status, result, error, resource_id, created_at, started_at, finished_at ' + + "FROM user_resource_jobs WHERE user_id = ? AND created_at > NOW() - INTERVAL '1 day' " + + 'ORDER BY created_at DESC LIMIT 20', [req.user.id]); + res.json({ success: true, jobs: rows }); + } catch (err) { + console.error('[my-resources] jobs:', err.message); + res.status(500).json({ error: 'Jobs unavailable' }); + } +}); + +// Dismiss a finished job from the list. A running one stays until it ends. +router.delete('/my-resources/jobs/:id', async function (req, res) { + try { + await db.run("DELETE FROM user_resource_jobs WHERE id = ? AND user_id = ? AND status IN ('done', 'failed')", + [parseInt(req.params.id, 10) || 0, req.user.id]); + res.json({ success: true }); + } catch (err) { + res.status(500).json({ error: 'Could not dismiss the job' }); + } +}); + + // ── Send a resource to Nextcloud ──────────────────────────── // The rendered file, not the markdown. A .pptx landing in someone's own storage // is the thing worth having; a text blob is not, and it is not what they would @@ -1033,7 +1136,11 @@ router.post('/my-resources/:id/refine', async function (req, res) { var options = { model: req.body.model ? await resolveModel(req.body.model) : (visionModel && slideViews.length ? visionModel : await resolveModel('')), - temperature: 0.2, maxTokens: 16000 + 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. @@ -1097,6 +1204,7 @@ router.post('/my-resources/:id/refine', async function (req, res) { if (revisedDeck && visionModel) { verified = await deckReview.review(revisedDeck, { model: visionModel, + reasoningEffort: options.reasoningEffort, callAI: callAI, extractJson: deckBuild.extractJson, gotenberg: documentExport.GOTENBERG, @@ -1327,3 +1435,7 @@ router.delete('/my-resources/:id', async function (req, res) { }); 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.runResourceJob = runResourceJob; diff --git a/src/utils/deckReview.js b/src/utils/deckReview.js index 467e3fe4..c3f7124f 100644 --- a/src/utils/deckReview.js +++ b/src/utils/deckReview.js @@ -232,10 +232,16 @@ async function review(deck, options) { var images = await slideImages(options.pptx, options.gotenberg, options.mime); if (!images.length) return { deck: deck, reviewed: false, reason: 'could not render the deck' }; + // A list of layout fixes is not a puzzle: DeepSeek spent four 2,000-token + // budgets thinking about one deck and returned nothing each time, and every + // empty review costs a call to discover. Carried through from the caller + // rather than hard-coded, so a task that wants reasoning can still ask. + var reviewOptions = { model: options.model, temperature: 0.1, images: images, maxTokens: MAX_REPLY_TOKENS }; + if (options.reasoningEffort) reviewOptions.reasoningEffort = options.reasoningEffort; var ai = await options.callAI( [{ role: 'user', content: instructions(deck.slides.length) + '\n\nDECK JSON:\n' + JSON.stringify({ slides: deck.slides }) }], - { model: options.model, temperature: 0.1, images: images, maxTokens: MAX_REPLY_TOKENS } + reviewOptions ); // A list of changes, not a deck. Asking for the whole deck back put the diff --git a/test/deck-review.test.js b/test/deck-review.test.js index a64fdd84..854e6eed 100644 --- a/test/deck-review.test.js +++ b/test/deck-review.test.js @@ -160,6 +160,26 @@ test('the reviewer is admin-chosen, off by default, and runs once per change', ( assert.match(read('src/utils/deckReview.js'), /'pdftoppm', \['-png', '-r', String\(RENDER_DPI\)/); }); +test('the review is asked without thinking when the caller says so', () => { + // One real deck: the writing reasoned its 16,000-token budget away and + // returned nothing, and the reviews of it did the same on four 2,000-token + // budgets. Thinking off is DeepSeek's own field, sent by the model wrapper, + // so all this file has to do is carry the caller's rule to the model call — + // and a task that does want reasoning must still be able to ask for it. + const src = read('src/utils/deckReview.js'); + const call = src.slice(src.indexOf('var reviewOptions = {'), src.indexOf('// A list of changes')); + assert.match(call, /if \(options\.reasoningEffort\) reviewOptions\.reasoningEffort = options\.reasoningEffort;/); + assert.match(call, /options\.callAI\(\s*\[[\s\S]*?\],\s*reviewOptions\s*\)/, 'the rule reaches the model call'); + + 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'")); + 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'); +}); + test('a deck whose slide was closed before its notes still parses', () => { // A saved reply from production's fallback path: the model wrote // `"rows":[[…]]}` and then `,"notes":"…"}` — the slide closed early and the diff --git a/test/my-resources-refine.test.js b/test/my-resources-refine.test.js index 29e5bd3a..587bfa38 100644 --- a/test/my-resources-refine.test.js +++ b/test/my-resources-refine.test.js @@ -28,6 +28,7 @@ function router(t, overrides = {}) { const aiCalls = []; const replies = overrides.replies ? overrides.replies.slice() : null; const reviewCalls = []; + const reviewOptions = []; const updates = []; const row = Object.assign({ id: 5, user_id: 7, title: 'Croup', kind: 'presentation', topic: 'croup', @@ -74,7 +75,11 @@ function router(t, overrides = {}) { '../utils/deckSample': require('../src/utils/deckSample'), '../utils/deckReview': { slideImages: async () => (overrides.slideImages || []), - review: async (deck) => { reviewCalls.push(deck); return { deck: deck, reviewed: true, reason: 'ok' }; }, + review: async (deck, options) => { + reviewCalls.push(deck); + reviewOptions.push(options || {}); + return { deck: deck, reviewed: true, reason: 'ok' }; + }, MAX_SLIDES: 20 }, '../utils/documentExport': { FORMATS: { pptx: { mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' } }, GOTENBERG: 'http://gotenberg:3000', isSupported: () => false, filename: () => 'x', mimeFor: () => '', render: async () => ({}), renderDeck: async () => Buffer.from('pptx') }, @@ -110,7 +115,12 @@ function router(t, overrides = {}) { await handler({ body, params: { id: '5' }, query: {}, user: { id: 7 } }, res); return res; } - return { request, aiCalls, updates, reviewCalls }; + // Generation is queued as a job and written in the background; the tests + // that care about what the writing does call its body directly. + async function generate(body) { + return module.exports.generateResource(7, body); + } + return { request, generate, aiCalls, updates, reviewCalls, reviewOptions }; } test('modifying a deck asks for a deck, even when illustration is on', async () => { @@ -203,26 +213,20 @@ test('a deck the model fumbles once is asked for a second time, not abandoned', // the layouts the model chose. Measured on the stored library, this happened // once in eight generations. const r = router(null, { replies: ['Sorry, I cannot do that.', GOOD_DECK] }); - const res = await r.request('post', '/my-resources/generate', { - topic: 'croup', kind: 'presentation' - }); + const out = await r.generate({ topic: 'croup', kind: 'presentation' }); - assert.equal(res.statusCode, 200); assert.equal(r.aiCalls.length, 2, 'the deck is asked for twice before giving up'); assert.match(r.aiCalls[1].messages[0].content, /teaching presentation/, 'the retry is the same deck prompt, not the weaker markdown one'); - assert.equal(res.body.deckFallback, null, 'and the retry succeeded, so nothing fell back'); + assert.equal(out.deckFallback, null, 'and the retry succeeded, so nothing fell back'); }); test('a deck that fails twice falls back to markdown and says why', async () => { const r = router(null, { replies: ['Sorry, I cannot help with that.', 'I am unable to comply.', '# Croup\n\n- Barking cough\n'] }); - const res = await r.request('post', '/my-resources/generate', { - topic: 'croup', kind: 'presentation' - }); + const out = await r.generate({ topic: 'croup', kind: 'presentation' }); - assert.equal(res.statusCode, 200); assert.equal(r.aiCalls.length, 3, 'two deck attempts, then markdown'); - assert.equal(res.body.deckFallback, 'the reply was not a deck', + assert.equal(out.deckFallback, 'the reply was not a deck', 'the caller is told it came out plain, and why'); }); @@ -233,18 +237,77 @@ test('a truncated deck reply is named as truncated, not as the wrong shape', asy // "}" either, and naming that "cut short" points at the wrong fix. const cut = GOOD_DECK.slice(0, GOOD_DECK.length - 30); const r = router(null, { replies: [cut, cut, '# Croup\n'] }); - const res = await r.request('post', '/my-resources/generate', { topic: 'croup', kind: 'presentation' }); + const out = await r.generate({ topic: 'croup', kind: 'presentation' }); - assert.match(res.body.deckFallback, /cut short at \d+ characters/); + assert.match(out.deckFallback, /cut short at \d+ characters/); }); test('a deck that parses first time is never asked for twice', async () => { const r = router(null, { replies: [GOOD_DECK] }); - const res = await r.request('post', '/my-resources/generate', { topic: 'croup', kind: 'presentation' }); + const out = await r.generate({ topic: 'croup', kind: 'presentation' }); + + assert.equal(r.aiCalls.length, 1, 'the retry costs a call and must only happen on failure'); + assert.equal(out.deckFallback, null); +}); + +// ── Thinking off ─────────────────────────────────────────────────────────── + +test('the writing and the review of it are both asked without thinking', async () => { + // DeepSeek thinks by default, and a deck is a writing task: one generation + // spent a whole 16,000-token budget reasoning and wrote nothing, and the + // 2,000-token reviews of that same deck starved four times over. The flag is + // DeepSeek's own field, sent by the model wrapper; this pins that the review + // inherits the writer's rule instead of falling back to the provider default. + const r = router(null, { visionModel: 'synthetic-vision', replies: [GOOD_DECK] }); + const out = await r.generate({ topic: 'croup', kind: 'presentation' }); + + assert.equal(r.aiCalls[0].options.reasoningEffort, 'none', 'the deck is written without thinking'); + assert.equal(out.review.applied, true, 'and the review ran'); + assert.equal(r.reviewOptions[0].reasoningEffort, 'none', 'the review is asked the same way'); +}); + +test('modifying a resource is writing too, and is asked without thinking', async () => { + // A revision restates the whole resource — 16,000 tokens for a deck — and on + // 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' }); assert.equal(res.statusCode, 200); - assert.equal(r.aiCalls.length, 1, 'the retry costs a call and must only happen on failure'); - assert.equal(res.body.deckFallback, null); + assert.equal(r.aiCalls[0].options.reasoningEffort, 'none'); +}); + +// ── Generation is a job ──────────────────────────────────────────────────── + +test('generate records a job and answers at once; the writing happens after the reply', async () => { + // A deck takes minutes and a browser holding one request open that long + // gives up on its own (Firefox at five minutes: "NetworkError when + // attempting to fetch resource"), while the server carried on and saved the + // deck anyway. So the request only records what was asked. + const r = router(null, { replies: [GOOD_DECK] }); + const res = await r.request('post', '/my-resources/generate', { topic: 'croup', kind: 'presentation' }); + + assert.equal(res.statusCode, 202); + assert.equal(res.body.success, true); + assert.ok(res.body.job && res.body.job.id, 'the caller gets the job to poll for'); + assert.equal(r.aiCalls.length, 0, 'nothing has been asked of the model by the time the reply goes out'); + + const blank = await r.request('post', '/my-resources/generate', { topic: ' ' }); + assert.equal(blank.statusCode, 400, 'a blank topic is refused before a job exists'); +}); + +test('a job records its outcome, and a failure is named without leaking internals', async () => { + const route = fs.readFileSync(path.join(__dirname, '..', 'src/routes/myResources.js'), 'utf8'); + const job = route.slice(route.indexOf('async function runResourceJob('), route.indexOf('async function recoverResourceJobs(')); + assert.match(job, /SET status = 'running', started_at = NOW\(\)/); + assert.match(job, /SET status = 'done', finished_at = NOW\(\), resource_id = \?, result = \?/); + assert.match(job, /SET status = 'failed', finished_at = NOW\(\), error = \?/); + assert.match(job, /err\.statusCode \? err\.message : 'Generation failed'/, + 'only a message written for the user reaches the job row'); + // A restart strands queued and running rows; they are marked so on boot + // rather than spinning for ever in the list. + assert.match(route, /The server restarted while this was being written\. Generate it again\./); + assert.match(route, /setTimeout\(recoverResourceJobs, 15000\)/); }); // ── Modifying with sight ─────────────────────────────────────────────────── diff --git a/test/my-resources.test.js b/test/my-resources.test.js index 4e8f0c48..e7351f4c 100644 --- a/test/my-resources.test.js +++ b/test/my-resources.test.js @@ -307,7 +307,8 @@ test('the figure ids are computed before anything reads them', () => { // stringified it, and every generation failed on a NOT NULL constraint. `var` // is function-scoped, so nothing complained until the database did. This is // the second bug of exactly this shape in this file. - const body = route.slice(route.indexOf("router.post('/my-resources/generate'")); + // Generation now runs inside generateResource, the body the job runner calls. + const body = route.slice(route.indexOf('async function generateResource(')); const declared = body.indexOf('var savedFigureIds ='); const reviewUses = body.indexOf('renderDeck(deck, [], savedFigureIds)'); const insertUses = body.indexOf('JSON.stringify(savedFigureIds)'); @@ -392,7 +393,7 @@ test('Details is material to cover, quoted after the topic and kept apart from i // the one-line topic box and not the instructions. It reaches the prompt // as quoted material, in both the deck and the markdown shapes. const route = fs.readFileSync(path.join(__dirname, '..', 'src/routes/myResources.js'), 'utf8'); - assert.match(route, /var details = String\(req\.body\.details \|\| ''\)\.slice\(0, 12000\)\.trim\(\);/); + assert.match(route, /var details = String\(body\.details \|\| ''\)\.slice\(0, 12000\)\.trim\(\);/); assert.equal((route.match(/details: details, corpusContext/g) || []).length, 2, 'generation and the markdown fallback both carry it'); assert.equal((route.match(/opts\.topic \+ '\\n' \+ detailsBlock\(opts\)/g) || []).length, 2, 'both prompt shapes quote it after the topic'); assert.match(route, /DETAILS FROM THE AUTHOR \(what this must cover, in their words\)/); diff --git a/test/web-search.test.js b/test/web-search.test.js index 31d73582..69e8540b 100644 --- a/test/web-search.test.js +++ b/test/web-search.test.js @@ -106,7 +106,7 @@ test('searching is the route\u2019s job, not something the model is asked to do' // One function, so generating and modifying cannot drift into offering // different sources or searching them differently. assert.match(route, /async function gatherSources\(subject, body, keywords\)/); - assert.match(route, /var sources = await gatherSources\(topic, req\.body\);/, 'generate'); + assert.match(route, /var sources = await gatherSources\(topic, body\);/, 'generate'); assert.match(route, /var sources = await gatherSources\(subject, req\.body, existing\.topic \|\| instructions\);/, 'and modify, whose library search gets the instruction for context and whose keyword searches do not');