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
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:
parent
9b2cb339a1
commit
2c3fbbcf37
5 changed files with 84 additions and 7 deletions
|
|
@ -21,13 +21,29 @@
|
|||
function $(id) { return document.getElementById(id); }
|
||||
function escHtml(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||||
|
||||
// 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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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  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/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue