feat: a resource is generated as a job, and DeepSeek writes it without thinking
Writing a resource held the request open for as long as it took: a library search, one or two long model calls, a review, then figures. Measured end to end that was six minutes on 2026-09-16 (00:40 to 00:46:16, resource 53), and Firefox abandons a request that has said nothing for five — the browser reported "NetworkError when attempting to fetch resource" while the server carried on and saved the deck anyway, so a generation that worked looked like a failure and left no status line. The request now records what was asked and answers 202; the work runs on the server as a job; the page lists what is being written, what landed and what failed, polls while anything is in flight, and reloads the library when one lands. Several can run at once, a reload loses nothing, and a boot pass marks jobs stranded by a restart as failed rather than spinning for ever. The same generation also ran with DeepSeek's thinking on, which is what made it take minutes rather than seconds: the 16,000-token write spent the whole budget reasoning and returned an empty reply (completion_tokens=16000, reasoning_chars=51573), which fired the automatic retry at four times the budget, and the 2,000-token reviews of that deck starved the same way four times over. Thinking is now off for the writing, the review of it and a revision — DeepSeek's own field, sent by the model wrapper. Other clinical routes are deliberately untouched and keep the provider default. The review inherits the writer's rule rather than hard-coding it, so a task that wants reasoning can still ask. Migration 1781500000000_resource-jobs.js adds user_resource_jobs; the container entrypoint applies it before the app starts.
This commit is contained in:
parent
102249cc10
commit
ef574eddcb
9 changed files with 393 additions and 74 deletions
30
migrations/1781500000000_resource-jobs.js
Normal file
30
migrations/1781500000000_resource-jobs.js
Normal file
|
|
@ -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;
|
||||||
|
`);
|
||||||
|
|
@ -195,6 +195,10 @@
|
||||||
<div id="mr-docs-search" style="padding:12px 16px 0;">
|
<div id="mr-docs-search" style="padding:12px 16px 0;">
|
||||||
<input id="mr-search" type="search" class="admin-control" placeholder="Search your resources by title or topic" autocomplete="off">
|
<input id="mr-search" type="search" class="admin-control" placeholder="Search your resources by title or topic" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
|
<!-- What is being written right now, and what just landed or failed. A
|
||||||
|
generation is a job on the server; this list follows it, so the page
|
||||||
|
can be left and come back to, and several can run at once. -->
|
||||||
|
<div id="mr-jobs" hidden aria-live="polite" style="padding:8px 16px 0;font-size:13px;"></div>
|
||||||
<!-- Bounded rather than unlimited: a long library otherwise pushes everything
|
<!-- Bounded rather than unlimited: a long library otherwise pushes everything
|
||||||
else off the page. Tall enough to show several at a glance, and it
|
else off the page. Tall enough to show several at a glance, and it
|
||||||
collapses to the content when there are only a few. -->
|
collapses to the content when there are only a few. -->
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
else clearResults();
|
else clearResults();
|
||||||
acceptPendingShare();
|
acceptPendingShare();
|
||||||
loadLibrary();
|
loadLibrary();
|
||||||
|
refreshJobs();
|
||||||
});
|
});
|
||||||
|
|
||||||
// What an administrator has switched on. Read once, used by both option
|
// 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');
|
wireImageIntent('mr-modify-instructions', 'mr-modify-images', 'mr-modify-image-hint');
|
||||||
|
|
||||||
loadOptions();
|
loadOptions();
|
||||||
|
refreshJobs();
|
||||||
}
|
}
|
||||||
|
|
||||||
// What an administrator has approved. The model row stays hidden unless there
|
// What an administrator has approved. The model row stays hidden unless there
|
||||||
|
|
@ -303,9 +305,14 @@
|
||||||
|
|
||||||
clearResults();
|
clearResults();
|
||||||
var btn = document.getElementById('btn-mr-generate');
|
var btn = document.getElementById('btn-mr-generate');
|
||||||
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating...'; }
|
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Queuing...'; }
|
||||||
status('Searching the library and writing. This takes a moment.');
|
|
||||||
|
|
||||||
|
// 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');
|
var corpusBox = document.getElementById('mr-use-corpus');
|
||||||
fetch('/api/my-resources/generate', {
|
fetch('/api/my-resources/generate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -326,29 +333,12 @@
|
||||||
withPubmed: (document.getElementById('mr-pubmed') || {}).checked ? 'true' : 'false'
|
withPubmed: (document.getElementById('mr-pubmed') || {}).checked ? 'true' : 'false'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json().then(function (data) { return { ok: r.ok, data: data || {} }; }); })
|
||||||
.then(function (data) {
|
.then(function (out) {
|
||||||
if (!data.success) throw new Error(data.error || 'Generation failed');
|
if (!out.ok || !out.data.success) throw new Error(out.data.error || 'Generation could not be started');
|
||||||
// Say what it was written from. Ungrounded material presented as
|
status('Queued. It is being written on the server — you can leave this page or start another. ' +
|
||||||
// grounded is the failure worth preventing.
|
'It appears in your library when it is done.', 'good');
|
||||||
var g = data.grounding || {};
|
refreshJobs();
|
||||||
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();
|
|
||||||
})
|
})
|
||||||
.catch(function (err) { status(err.message, 'bad'); })
|
.catch(function (err) { status(err.message, 'bad'); })
|
||||||
.finally(function () {
|
.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
|
// Say what was searched for. A query that left the network is worth showing
|
||||||
// plainly rather than leaving someone to wonder whether it happened.
|
// plainly rather than leaving someone to wonder whether it happened.
|
||||||
function reportSearches(searches) {
|
function reportSearches(searches) {
|
||||||
|
|
|
||||||
|
|
@ -330,23 +330,26 @@ async function gatherSources(subject, body, keywords) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Generate ────────────────────────────────────────────────
|
// ── Generate ────────────────────────────────────────────────
|
||||||
router.post('/my-resources/generate', async function (req, res) {
|
/** Everything a generation is, given who asked and what they asked for.
|
||||||
try {
|
* Throws an error carrying statusCode for the caller's own mistakes; anything
|
||||||
var topic = String(req.body.topic || '').trim();
|
* else is a failure of the generation. Runs inside a job, never inside the
|
||||||
if (!topic) return res.status(400).json({ error: 'A topic is required' });
|
* 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 kind = normalizeKind(body.kind);
|
||||||
var refinement = String(req.body.refinement || '').slice(0, 2000);
|
var refinement = String(body.refinement || '').slice(0, 2000);
|
||||||
// What the resource must cover, at length: a topic list, a case, an
|
// What the resource must cover, at length: a topic list, a case, an
|
||||||
// outline. Separate from instructions, which say how, not what.
|
// 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) {
|
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 corpus = sources.corpus;
|
||||||
var searches = sources.searches;
|
var searches = sources.searches;
|
||||||
var wantsImages = sources.wantsImages;
|
var wantsImages = sources.wantsImages;
|
||||||
|
|
@ -361,20 +364,24 @@ router.post('/my-resources/generate', async function (req, res) {
|
||||||
literature: sources.literature, webFindings: sources.webFindings,
|
literature: sources.literature, webFindings: sources.webFindings,
|
||||||
searchedAndFoundNothing: sources.searchedAndFoundNothing,
|
searchedAndFoundNothing: sources.searchedAndFoundNothing,
|
||||||
wantsImages: wantsImages, deckMode: deckMode,
|
wantsImages: wantsImages, deckMode: deckMode,
|
||||||
format: deckMode ? deckFormats.formatId(req.body.format) : '',
|
format: deckMode ? deckFormats.formatId(body.format) : '',
|
||||||
figureCount: wantsImages ? (resourceImages.requestedCount(refinement) || 0) : 0,
|
figureCount: wantsImages ? (resourceImages.requestedCount(refinement) || 0) : 0,
|
||||||
slideCount: clampInt(req.body.slideCount, 3, 30, 8),
|
slideCount: clampInt(body.slideCount, 3, 30, 8),
|
||||||
wordCount: clampInt(req.body.wordCount, 200, 3000, 800)
|
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 }];
|
var messages = [{ role: 'user', content: prompt }];
|
||||||
// A deck's JSON is several times the size of the prose it contains, and the
|
// 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
|
// 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
|
// 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
|
// ask for a figure, so the model wrote "![Placeholder: flow diagram]" into a
|
||||||
// bullet instead. That is why decks were arriving with no images.
|
// 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;
|
if (deckMode) options.maxTokens = 16000;
|
||||||
|
|
||||||
// Tools the model may reach for on this generation, and only these.
|
// 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
|
// single image per request, which is right for a chat reply and wrong for
|
||||||
// a resource. Same queue, same storage, same my_resources workflow.
|
// a resource. Same queue, same storage, same my_resources workflow.
|
||||||
ai = await resourceImages.dispatch(ai, {
|
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
|
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;
|
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
|
// 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.
|
// 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
|
// The format travels with the deck so a later revision keeps the shape it
|
||||||
// was written in.
|
// was written in.
|
||||||
if (deck) deck.format = deckFormats.formatId(req.body.format);
|
if (deck) deck.format = deckFormats.formatId(body.format);
|
||||||
reportVocabularyGaps(vocabularyGaps, topic);
|
reportVocabularyGaps(vocabularyGaps, topic);
|
||||||
// Why a deck became plain slides, when it did. Reported to the caller as
|
// 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
|
// 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,
|
literature: sources.literature, webFindings: sources.webFindings,
|
||||||
searchedAndFoundNothing: sources.searchedAndFoundNothing,
|
searchedAndFoundNothing: sources.searchedAndFoundNothing,
|
||||||
wantsImages: false, deckMode: false,
|
wantsImages: false, deckMode: false,
|
||||||
slideCount: clampInt(req.body.slideCount, 3, 30, 8),
|
slideCount: clampInt(body.slideCount, 3, 30, 8),
|
||||||
wordCount: clampInt(req.body.wordCount, 200, 3000, 800)
|
wordCount: clampInt(body.wordCount, 200, 3000, 800)
|
||||||
});
|
});
|
||||||
ai = await callAI([{ role: 'user', content: plain }], options);
|
ai = await callAI([{ role: 'user', content: plain }], options);
|
||||||
}
|
}
|
||||||
if (deck) {
|
if (deck) {
|
||||||
var drawn = await deckBuild.drawFigures(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 });
|
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) {
|
if (reviewModel) {
|
||||||
reviewed = await deckReview.review(deck, {
|
reviewed = await deckReview.review(deck, {
|
||||||
model: reviewModel,
|
model: reviewModel,
|
||||||
|
// The rule the writing follows: reading a rendered deck for overflow
|
||||||
|
// needs no reasoning budget either.
|
||||||
|
reasoningEffort: options.reasoningEffort,
|
||||||
callAI: callAI,
|
callAI: callAI,
|
||||||
extractJson: deckBuild.extractJson,
|
extractJson: deckBuild.extractJson,
|
||||||
gotenberg: documentExport.GOTENBERG,
|
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
|
// and what a text edit edits. In deck mode it is serialised from the deck
|
||||||
// rather than written by the model.
|
// rather than written by the model.
|
||||||
var markdown = deck ? deckSchema.toMarkdown(deck) : String((ai && ai.content) || '').trim();
|
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(
|
var row = await db.get(
|
||||||
'INSERT INTO user_resources (user_id, title, kind, markdown, topic, grounded_count, image_ids, deck, theme) ' +
|
'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',
|
'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),
|
topic.slice(0, 500), corpus.sources.length, JSON.stringify(savedFigureIds),
|
||||||
deck ? JSON.stringify(deck) : null,
|
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,
|
success: true,
|
||||||
resource: row,
|
resource: row,
|
||||||
markdown: markdown,
|
markdown: markdown,
|
||||||
|
|
@ -551,13 +561,106 @@ router.post('/my-resources/generate', async function (req, res) {
|
||||||
review: { applied: reviewed.reviewed, reason: reviewed.reason },
|
review: { applied: reviewed.reviewed, reason: reviewed.reason },
|
||||||
deckFallback: deckFallback,
|
deckFallback: deckFallback,
|
||||||
model: ai && ai.model
|
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) {
|
} catch (err) {
|
||||||
console.error('[my-resources] generate:', err.message);
|
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 ────────────────────────────
|
// ── Send a resource to Nextcloud ────────────────────────────
|
||||||
// The rendered file, not the markdown. A .pptx landing in someone's own storage
|
// 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
|
// 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 = {
|
var options = {
|
||||||
model: req.body.model ? await resolveModel(req.body.model)
|
model: req.body.model ? await resolveModel(req.body.model)
|
||||||
: (visionModel && slideViews.length ? visionModel : await resolveModel('')),
|
: (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;
|
if (slideViews.length) options.images = slideViews;
|
||||||
// Deck mode declares its figures; only the markdown path needs the tool.
|
// 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) {
|
if (revisedDeck && visionModel) {
|
||||||
verified = await deckReview.review(revisedDeck, {
|
verified = await deckReview.review(revisedDeck, {
|
||||||
model: visionModel,
|
model: visionModel,
|
||||||
|
reasoningEffort: options.reasoningEffort,
|
||||||
callAI: callAI,
|
callAI: callAI,
|
||||||
extractJson: deckBuild.extractJson,
|
extractJson: deckBuild.extractJson,
|
||||||
gotenberg: documentExport.GOTENBERG,
|
gotenberg: documentExport.GOTENBERG,
|
||||||
|
|
@ -1327,3 +1435,7 @@ router.delete('/my-resources/:id', async function (req, res) {
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
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;
|
||||||
|
|
|
||||||
|
|
@ -232,10 +232,16 @@ async function review(deck, options) {
|
||||||
var images = await slideImages(options.pptx, options.gotenberg, options.mime);
|
var images = await slideImages(options.pptx, options.gotenberg, options.mime);
|
||||||
if (!images.length) return { deck: deck, reviewed: false, reason: 'could not render the deck' };
|
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(
|
var ai = await options.callAI(
|
||||||
[{ role: 'user', content: instructions(deck.slides.length) +
|
[{ role: 'user', content: instructions(deck.slides.length) +
|
||||||
'\n\nDECK JSON:\n' + JSON.stringify({ slides: deck.slides }) }],
|
'\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
|
// A list of changes, not a deck. Asking for the whole deck back put the
|
||||||
|
|
|
||||||
|
|
@ -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\)/);
|
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', () => {
|
test('a deck whose slide was closed before its notes still parses', () => {
|
||||||
// A saved reply from production's fallback path: the model wrote
|
// A saved reply from production's fallback path: the model wrote
|
||||||
// `"rows":[[…]]}` and then `,"notes":"…"}` — the slide closed early and the
|
// `"rows":[[…]]}` and then `,"notes":"…"}` — the slide closed early and the
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ function router(t, overrides = {}) {
|
||||||
const aiCalls = [];
|
const aiCalls = [];
|
||||||
const replies = overrides.replies ? overrides.replies.slice() : null;
|
const replies = overrides.replies ? overrides.replies.slice() : null;
|
||||||
const reviewCalls = [];
|
const reviewCalls = [];
|
||||||
|
const reviewOptions = [];
|
||||||
const updates = [];
|
const updates = [];
|
||||||
const row = Object.assign({
|
const row = Object.assign({
|
||||||
id: 5, user_id: 7, title: 'Croup', kind: 'presentation', topic: 'croup',
|
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/deckSample': require('../src/utils/deckSample'),
|
||||||
'../utils/deckReview': {
|
'../utils/deckReview': {
|
||||||
slideImages: async () => (overrides.slideImages || []),
|
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
|
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') },
|
'../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);
|
await handler({ body, params: { id: '5' }, query: {}, user: { id: 7 } }, res);
|
||||||
return 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 () => {
|
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
|
// the layouts the model chose. Measured on the stored library, this happened
|
||||||
// once in eight generations.
|
// once in eight generations.
|
||||||
const r = router(null, { replies: ['Sorry, I cannot do that.', GOOD_DECK] });
|
const r = router(null, { replies: ['Sorry, I cannot do that.', GOOD_DECK] });
|
||||||
const res = await r.request('post', '/my-resources/generate', {
|
const out = await r.generate({ topic: 'croup', kind: 'presentation' });
|
||||||
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.equal(r.aiCalls.length, 2, 'the deck is asked for twice before giving up');
|
||||||
assert.match(r.aiCalls[1].messages[0].content, /teaching presentation/,
|
assert.match(r.aiCalls[1].messages[0].content, /teaching presentation/,
|
||||||
'the retry is the same deck prompt, not the weaker markdown one');
|
'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 () => {
|
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 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', {
|
const out = await r.generate({ topic: 'croup', kind: 'presentation' });
|
||||||
topic: 'croup', kind: 'presentation'
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(res.statusCode, 200);
|
|
||||||
assert.equal(r.aiCalls.length, 3, 'two deck attempts, then markdown');
|
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');
|
'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.
|
// "}" either, and naming that "cut short" points at the wrong fix.
|
||||||
const cut = GOOD_DECK.slice(0, GOOD_DECK.length - 30);
|
const cut = GOOD_DECK.slice(0, GOOD_DECK.length - 30);
|
||||||
const r = router(null, { replies: [cut, cut, '# Croup\n'] });
|
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 () => {
|
test('a deck that parses first time is never asked for twice', async () => {
|
||||||
const r = router(null, { replies: [GOOD_DECK] });
|
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(res.statusCode, 200);
|
||||||
assert.equal(r.aiCalls.length, 1, 'the retry costs a call and must only happen on failure');
|
assert.equal(r.aiCalls[0].options.reasoningEffort, 'none');
|
||||||
assert.equal(res.body.deckFallback, null);
|
});
|
||||||
|
|
||||||
|
// ── 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 ───────────────────────────────────────────────────
|
// ── Modifying with sight ───────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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`
|
// stringified it, and every generation failed on a NOT NULL constraint. `var`
|
||||||
// is function-scoped, so nothing complained until the database did. This is
|
// is function-scoped, so nothing complained until the database did. This is
|
||||||
// the second bug of exactly this shape in this file.
|
// 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 declared = body.indexOf('var savedFigureIds =');
|
||||||
const reviewUses = body.indexOf('renderDeck(deck, [], savedFigureIds)');
|
const reviewUses = body.indexOf('renderDeck(deck, [], savedFigureIds)');
|
||||||
const insertUses = body.indexOf('JSON.stringify(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
|
// the one-line topic box and not the instructions. It reaches the prompt
|
||||||
// as quoted material, in both the deck and the markdown shapes.
|
// as quoted material, in both the deck and the markdown shapes.
|
||||||
const route = fs.readFileSync(path.join(__dirname, '..', 'src/routes/myResources.js'), 'utf8');
|
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(/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.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\)/);
|
assert.match(route, /DETAILS FROM THE AUTHOR \(what this must cover, in their words\)/);
|
||||||
|
|
|
||||||
|
|
@ -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
|
// One function, so generating and modifying cannot drift into offering
|
||||||
// different sources or searching them differently.
|
// different sources or searching them differently.
|
||||||
assert.match(route, /async function gatherSources\(subject, body, keywords\)/);
|
assert.match(route, /async function gatherSources\(subject, body, keywords\)/);
|
||||||
assert.match(route, /var sources = await gatherSources\(topic, 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\);/,
|
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');
|
'and modify, whose library search gets the instruction for context and whose keyword searches do not');
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue