fix: a deck asked for a figure and never got one — three causes, one symptom
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 52s
Forgejo Docker Build / Root app tests (push) Successful in 52s
Forgejo Android APK / Build signed APK (push) Successful in 2m15s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s

"Include a diagram" produced decks with no picture. Three separate faults, each
hiding the next, found by generating the same deck after fixing each one.

First, deck generation ran on the default 4000-token budget. A deck's JSON is
several times the size of the prose it holds, so a long deck came back truncated,
failed to parse, and fell back to markdown — which has no way to request a
figure, so the model described one instead and the slide rendered a literal
"![Placeholder: Flow diagram ...]" as its first bullet. Deck generation now gets
room, and the fallback says how the reply failed: empty, cut short at N
characters, or not a deck.

Second, the figure request sat inside the layout vocabulary, one line among
forty, and the model passed over it. It goes last now, after the author's own
instructions — the same placement lesson the image tool taught earlier.

Third, and the one that actually mattered: image_prompt is only read on the
figure and image types, so an image_prompt on a bullets slide was dropped in
silence. The instruction said "add image_prompt to N slides" without saying
which types carry one. It now names them, and a misplaced request is honoured
rather than discarded — a slide with words becomes a figure, one without becomes
a full-slide image.

Image markup is also stripped wherever text enters a slide, on both paths: a
described figure is not a figure, and a bullet of raw markdown is worse than no
bullet.

Verified end to end after: the same request produced a deck with one figure, the
job completed, and the exported pptx carries one embedded image across 21 slides.

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 04:28:30 +02:00
parent 2c3fbbcf37
commit 5614a436be
3 changed files with 56 additions and 2 deletions

View file

@ -132,11 +132,29 @@ function buildPrompt(opts) {
// has no way to ask for any of them. Articles stay markdown, which is the
// right shape for prose.
if (kind === 'presentation' && opts.deckMode) {
// The figure request goes last, after the author's own instructions. Inside
// the layout vocabulary it was one line among forty and the model passed
// over it: a twelve-slide deck asked to "include a diagram of the diagnostic
// pathway" came back with no image_prompt at all. Placement was the same
// lesson the tool path taught — whatever must not be missed goes last.
var figures = !opts.wantsImages ? '' : '\n\n' + (opts.figureCount
? 'Before you finish: this deck must carry ' + opts.figureCount + ' figure' +
(opts.figureCount === 1 ? '' : 's') + '. Make ' + opts.figureCount + ' of the slides ' +
'type "figure" (bullets on the left, the picture on the right) or type "image" (the ' +
'picture is the whole slide), and give each one an "image_prompt" describing what to ' +
'draw. Those two types are the only ones that carry a picture. A deck returned without ' +
(opts.figureCount === 1 ? 'it' : 'them') + ' has not followed the instruction.'
: 'Before you finish: where a diagram or labelled figure would genuinely help, make that ' +
'slide type "figure" or type "image" and give it an "image_prompt" describing what to ' +
'draw. Those two types are the only ones that carry a picture. Schematic or anatomical ' +
'teaching artwork only, never a real patient.');
return 'You are building a teaching presentation for a medical professional ' +
'audience (pediatrics / primary care).\n\nTOPIC: ' + opts.topic + '\n' +
grounding + findings + '\n' +
deckSchema.instructions(opts.slideCount, opts.figureCount) +
(opts.refinement ? '\n\nAdditional instructions: ' + opts.refinement + '\n' : '');
(opts.refinement ? '\n\nAdditional instructions: ' + opts.refinement + '\n' : '') +
figures;
}
var shape = kind === 'presentation'

View file

@ -57,7 +57,8 @@ function instructions(slideCount, figureCount) {
slideShapes.instructions(),
'',
figureCount
? 'Include exactly ' + figureCount + ' slides carrying an image_prompt, spread through the deck.'
? 'Include exactly ' + figureCount + ' slide' + (figureCount === 1 ? '' : 's') +
' carrying an image_prompt, spread through the deck.'
: 'Include an image_prompt only where a picture genuinely earns its place, and at most three.',
'',
'Vary the layouts: a deck of nothing but "bullets" is the thing to avoid.',
@ -137,6 +138,17 @@ function normalise(raw, gaps) {
if (type === 'bullets' && !out.bullets.length && !out.heading) return;
}
// An image_prompt on a slide that does not carry pictures used to be dropped
// in silence, so a model that asked for a figure the wrong way got none and
// no one found out. The intent is honoured instead: a slide with words
// becomes a figure, one without becomes a full-slide image.
if (slide.image_prompt && type !== 'figure' && type !== 'image'
&& type !== 'title' && type !== 'custom') {
out.type = type = (out.bullets && out.bullets.length) || (slide.bullets || []).length
? 'figure' : 'image';
if (type === 'figure' && !(out.bullets || []).length) out.bullets = bullets(slide.bullets);
}
// A figure request is a prompt, not a path. The route turns the ones it can
// afford into jobs and hands back files; anything unfulfilled degrades to a
// slide of text rather than an empty frame.

View file

@ -218,3 +218,27 @@ test('a deck is given room to be a deck', () => {
assert.match(route, /cut short at ' \+ replyLength \+ ' chars'/);
assert.match(route, /empty reply/);
});
test('a figure asked for the wrong way is honoured, not dropped', () => {
// image_prompt is only carried by the figure and image types, so a model that
// put one on a bullets slide had it silently discarded — it asked for a
// picture, got none, and nothing said why. Measured on a real generation: a
// twenty-slide deck, an explicit "include a diagram", zero image_prompt.
const deck = deckSchema.normalise({ slides: [
{ type: 'bullets', heading: 'Pathway', bullets: ['a', 'b'], image_prompt: 'a flow diagram' },
{ type: 'section', heading: 'Overview', image_prompt: 'a full-bleed picture' },
]});
// Words beside a picture is a figure; no words is a full-slide image.
assert.equal(deck.slides[0].type, 'figure');
assert.equal(deck.slides[0].image_prompt, 'a flow diagram');
assert.equal(deck.slides[1].type, 'image');
// And the instruction now names the only two types that carry one, last in
// the prompt, after the author's own instructions.
const route = read('src/routes/myResources.js');
assert.match(route, /Those two types are the only ones that carry a picture/);
const deckBranch = route.slice(route.indexOf("if (kind === 'presentation' && opts.deckMode)"));
const built = deckBranch.indexOf('var figures =');
const used = deckBranch.indexOf("+\n figures;");
assert.ok(built > -1 && used > built, 'the figure request is appended last');
});