fix: decks were falling back to markdown, so no figure could ever be requested
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m3s
Forgejo Docker Build / Build Docker image (push) Successful in 22s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s

A deck's JSON is several times the size of the prose it holds, and generation
used the default 4000-token budget — raised for refine and for slide review, but
never here. A sixteen-slide deck ran past it, came back truncated, failed to
parse, and fell back to markdown. Markdown has no way to ask for a figure, so
the model described one instead and the slide rendered a literal
"![Placeholder: Flow diagram — "Neonate with rash" → ...]" as its first bullet,
above the steps it was meant to illustrate. That is why no generated deck was
arriving with an image.

Deck generation now gets room for a deck. The fallback also says how the reply
failed — empty, cut short at N characters, or simply not a deck — because those
want different fixes and "not usable" covered all three.

Image markup is stripped wherever text enters a slide, on both the deck and
markdown paths, since a described figure is not a figure and a bullet of raw
markdown is worse than no bullet. The model is also told plainly: if a figure is
wanted say so with image_prompt, and if that is not on offer, write the slide
without one rather than describing the picture you would have drawn.

Separately, the Documentation list showed ARCHITECTURE, CLINICAL_ASSISTANT,
DEVELOPMENT, MODULE_CONVENTIONS and SCALING shouting in caps with underscores
intact: the label builder replaced hyphens but not underscores, and uppercased
the first letter of each word rather than normalising the case, so a
SHOUTING_FILENAME stayed shouting. It now reads "Clinical Assistant", keeps
acronyms as acronyms (AI, API, OpenID, LiteLLM) and leaves joining words lower.

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:21:20 +02:00
parent 9b2cb339a1
commit 2c3fbbcf37
5 changed files with 84 additions and 7 deletions

View file

@ -21,13 +21,29 @@
function $(id) { return document.getElementById(id); }
function escHtml(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
// Pretty label from a file/dir basename. Drops .md, replaces dashes
// with spaces, title-cases the first letter of each word. README is
// shown as "Index" so the entry-point doc is more obvious in the tree.
// Pretty label from a file/dir basename. README is shown as "Index" so the
// entry-point doc is more obvious in the tree.
//
// Both separators, and the case is normalised rather than only capitalised:
// uppercasing the first letter of each word leaves a SHOUTING_FILENAME
// shouting, which is why CLINICAL_ASSISTANT and MODULE_CONVENTIONS sat in the
// list looking like constants next to "Learning Hub".
var ACRONYMS = { ai: 'AI', api: 'API', ui: 'UI', id: 'ID', oidc: 'OIDC', sso: 'SSO',
stt: 'STT', tts: 'TTS', pdf: 'PDF', faq: 'FAQ', mcp: 'MCP', ped: 'Ped',
openid: 'OpenID', litellm: 'LiteLLM', milvus: 'Milvus' };
var MINOR = { and: 1, or: 1, the: 1, a: 1, an: 1, of: 1, to: 1, in: 1, for: 1, with: 1 };
function prettyName(name, isDir) {
if (!isDir && /^readme\.md$/i.test(name)) return 'Index';
var base = name.replace(/\.md$/i, '');
return base.replace(/-/g, ' ').replace(/\b\w/g, function (c) { return c.toUpperCase(); });
var words = base.replace(/[-_]+/g, ' ').trim().split(/\s+/);
return words.map(function (word, i) {
var lower = word.toLowerCase();
if (ACRONYMS[lower]) return ACRONYMS[lower];
// Small joining words stay lowercase unless they open the title.
if (i > 0 && MINOR[lower]) return lower;
return lower.charAt(0).toUpperCase() + lower.slice(1);
}).join(' ');
}
// Recursively render the tree into HTML.

View file

@ -285,7 +285,13 @@ router.post('/my-resources/generate', async function (req, res) {
var model = await resolveModel(req.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 };
if (deckMode) options.maxTokens = 16000;
// Tools the model may reach for on this generation, and only these.
// Only the image tool stays a tool. Illustration genuinely needs the model to
@ -326,7 +332,13 @@ router.post('/my-resources/generate', async function (req, res) {
if (deckMode && !deck) {
// The model returned something that is not a deck. Falling back to
// markdown beats saving nothing, and beats saving its apology.
console.warn('[my-resources] deck reply was not usable; retrying as markdown');
// Say how it failed. "Not usable" covers a truncated reply, prose instead
// of JSON, and an empty deck, and they want different fixes.
var replyLength = String((ai && ai.content) || '').length;
var looksCut = replyLength > 0 && String(ai.content).trim().slice(-1) !== '}';
console.warn('[my-resources] deck reply was not usable (' +
(replyLength === 0 ? 'empty reply' : looksCut ? 'cut short at ' + replyLength + ' chars' : 'not a deck') +
'); retrying as markdown');
var plain = buildPrompt({
topic: topic, kind: kind, refinement: refinement, corpusContext: corpus.context,
literature: sources.literature, webFindings: sources.webFindings,

View file

@ -61,13 +61,23 @@ function instructions(slideCount, figureCount) {
: '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.',
'Never write an image placeholder into any text. If a slide should carry a',
'figure, say so with "image_prompt"; if that is not offered above, write the',
'slide without one rather than describing the picture you would have drawn.',
'Put a References slide last if you used sources, as a "table" with one column',
'or a "bullets" slide. "notes" is optional speaker notes.'
].join('\n');
}
// A model with no way to request a figure will describe one instead, and
// "![Placeholder: flow diagram — ...]" then renders as a bullet of literal
// markdown on the slide. It is never drawable, so it never reaches one.
var IMAGE_MARKUP = /!\[[^\]]*\](?:\([^)]*\))?/g;
function text(value, max) {
return String(value === undefined || value === null ? '' : value).slice(0, max || 400).trim();
return String(value === undefined || value === null ? '' : value)
.replace(IMAGE_MARKUP, '')
.slice(0, max || 400).trim();
}
function bullets(list) {

View file

@ -58,13 +58,18 @@ function parseTable(body) {
return { header: header, rows: data };
}
// Same reason as deckSchema: a described figure is not a figure, and a bullet
// of raw markdown on a slide is worse than no bullet.
var IMAGE_MARKUP = /!\[[^\]]*\](?:\([^)]*\))?/g;
function parseBullets(body) {
var items = [];
for (var i = 0; i < body.length; i++) {
var line = body[i];
var bullet = /^(\s*)(?:[-*+]|\d+\.)\s+(.*)$/.exec(line);
if (bullet) {
items.push({ text: bullet[2].trim(), level: Math.floor(bullet[1].length / 2) });
var bulletText = bullet[2].replace(IMAGE_MARKUP, '').trim();
if (bulletText) items.push({ text: bulletText, level: Math.floor(bullet[1].length / 2) });
continue;
}
var sub = /^##+\s+(.*)$/.exec(line);

View file

@ -184,3 +184,37 @@ test('the renderer can draw what the schema offers', () => {
assert.match(py, new RegExp('"' + type + '": slide_'), type + ' has a builder');
}
});
test('a described figure never reaches a slide as text', () => {
// A model with no way to request a figure describes one instead, and
// "![Placeholder: Flow diagram — ...]" then renders as a bullet of literal
// markdown. Seen on a real deck: the placeholder was the first bullet of the
// slide, above the steps it was meant to illustrate.
const deck = deckSchema.normalise({ slides: [{ type: 'bullets', heading: 'Pathway', bullets: [
{ text: '![Placeholder: Flow diagram — "Neonate with rash" → branch]' },
{ text: 'Step 1: Is the infant well?' },
{ text: 'See ![this](fig.png) for detail' },
]}]});
assert.deepEqual(deck.slides[0].bullets.map(b => b.text),
['Step 1: Is the infant well?', 'See for detail']);
// The markdown path is where it actually appeared, so it is guarded too.
const spec = slideSpec.build('# Pathway\n\n- ![Placeholder: a diagram]\n- Step 1: real content\n', {});
assert.deepEqual(spec.slides[0].bullets.map(b => b.text), ['Step 1: real content']);
// And the model is told to stop doing it.
assert.match(deckSchema.instructions(8, 0), /Never write an image placeholder into any text/);
});
test('a deck is given room to be a deck', () => {
const route = read('src/routes/myResources.js');
// The default budget is 4000 tokens. A deck's JSON is several times the size
// of the prose it holds, so a sixteen-slide deck ran past it, came back
// truncated, failed to parse, and fell back to markdown — which cannot ask
// for a figure at all. That is why decks were arriving with no images.
assert.match(route, /if \(deckMode\) options\.maxTokens = 16000;/);
// And the fallback says how it failed, because "not usable" covers a
// truncated reply, prose instead of JSON, and an empty deck.
assert.match(route, /cut short at ' \+ replyLength \+ ' chars'/);
assert.match(route, /empty reply/);
});