fix: a figure asked for while modifying a deck now belongs to a slide
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 8s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s

Ticking "Add illustrations" on Modify offered the image tool regardless of what
was being edited. The tool returns job ids and has no way to place them, which is
fine for markdown — there is nowhere to put a figure in markdown anyway — and
wrong for a deck, where figures are placed by a slide declaring them.

So modifying a deck with illustrations on generated a figure, paid for it,
recorded it against the resource, and referenced it from nothing. Measured: one
figure recorded, zero referenced by a slide, and absent from the export.

Deck mode now asks the revised deck to declare its figures and draws them with
the same drawFigures() generation uses, so each one belongs to the slide that
wanted it. Slides that already have a figure keep it. The tool path stays for
markdown resources, where it is the only option.

Verified: the same modification now records one figure, one slide references it,
and the exported deck embeds one image.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-12 01:24:40 +02:00
parent 012346528c
commit 3ec65a91f6
2 changed files with 41 additions and 8 deletions

View file

@ -490,12 +490,21 @@ router.post('/my-resources/:id/refine', async function (req, res) {
'a URL to fill the gap: leave the References section as it is.';
}
var illustration = sources.wantsImages
? '\n\nAn illustration tool is available and the author has asked for illustration. ' +
resourceImages.guidance(instructions) + ' Schematic or anatomical teaching artwork only, ' +
'never a real patient. Returning the markdown is still required; a tool call is not a ' +
'substitute for it, and no image tag or URL goes into the markdown.'
: '';
// How a figure is asked for depends on which thing is being edited. A deck
// places figures by declaring them on a slide; markdown has nowhere to put
// one, so it uses the tool. Offering the tool while editing a deck queued a
// figure that no slide referenced — it was generated, paid for, recorded
// against the resource, and never appeared in the export.
var illustration = !sources.wantsImages ? ''
: existingDeck
? '\n\nThe author has asked for illustration. Add "image_prompt" to the slides that ' +
'should carry a figure — schematic or anatomical teaching artwork only, never a real ' +
'patient — and keep the "image_job" value of any slide that already has one. ' +
resourceImages.guidance(instructions)
: '\n\nAn illustration tool is available and the author has asked for illustration. ' +
resourceImages.guidance(instructions) + ' Schematic or anatomical teaching artwork only, ' +
'never a real patient. Returning the markdown is still required; a tool call is not a ' +
'substitute for it, and no image tag or URL goes into the markdown.';
// A presentation with a stored deck is edited as a deck. Editing its
// markdown instead changed only the markdown: export renders from the deck,
@ -520,12 +529,13 @@ router.post('/my-resources/:id/refine', async function (req, res) {
'\n\nMARKDOWN:\n"""\n' + existing.markdown + '\n"""' }];
// The reply restates the whole resource, so it needs room for one.
var options = { model: await resolveModel(req.body.model), temperature: 0.2, maxTokens: 16000 };
var tools = sources.wantsImages ? resourceImages.tools : [];
// Deck mode declares its figures; only the markdown path needs the tool.
var tools = sources.wantsImages && !existingDeck ? resourceImages.tools : [];
var callOptions = tools.length ? Object.assign({}, options, { tools: tools }) : options;
if (tools.length && resourceImages.requestedCount(instructions)) callOptions.toolChoice = 'required';
var ai = await callAI(messages, callOptions);
if (sources.wantsImages) {
if (sources.wantsImages && !existingDeck) {
ai = await resourceImages.dispatch(ai, {
owner: req.user.id, body: req.body, subject: subject, imageModel: sources.imageModel,
messages: messages, options: options, callAI: callAI
@ -545,6 +555,14 @@ router.post('/my-resources/:id/refine', async function (req, res) {
revisedDeck.title = existingDeck.title;
revisedDeck.subtitle = existingDeck.subtitle;
revisedDeck.date = existingDeck.date;
// Draw whatever the revised deck asked for, the same way generating does,
// so each new figure belongs to the slide that wanted it.
if (sources.wantsImages) {
var drawn = await deckBuild.drawFigures(revisedDeck, {
owner: req.user.id, body: req.body, subject: subject, imageModel: sources.imageModel
});
ai = Object.assign({}, ai, { imageJobs: drawn.jobs, imageFailures: drawn.failures });
}
}
var revised = revisedDeck ? deckSchema.toMarkdown(revisedDeck)

View file

@ -279,6 +279,21 @@ test('the library is bounded, searchable, and drives the modify picker', () => {
assert.match(js, /library\.length\s*\n?\s*\? 'Nothing matches/);
});
test('a figure asked for while modifying belongs to a slide', () => {
const route = read('src/routes/myResources.js');
// How a figure is asked for depends on what is being edited. A deck places
// figures by declaring them on a slide; markdown has nowhere to put one, so
// it uses the tool. Offering the tool while editing a deck queued a figure
// that no slide referenced — generated, paid for, recorded against the
// resource, and absent from the export. Measured: 1 recorded, 0 on a slide.
assert.match(route, /var tools = sources\.wantsImages && !existingDeck \? resourceImages\.tools : \[\];/);
assert.match(route, /if \(sources\.wantsImages && !existingDeck\) \{\s*\n\s*ai = await resourceImages\.dispatch/);
// The deck path draws what the revised deck asked for, as generating does.
assert.match(route, /if \(sources\.wantsImages\) \{\s*\n\s*var drawn = await deckBuild\.drawFigures\(revisedDeck/);
// And a slide that already has a figure keeps it.
assert.match(route, /keep the "image_job" value of any slide that already has one/);
});
test('the figure ids are computed before anything reads them', () => {
const route = read('src/routes/myResources.js');
// They were declared inside the slide-review branch, so with no reviewer