diff --git a/public/js/admin-docs.js b/public/js/admin-docs.js index 13a04d7b..4036eb3f 100644 --- a/public/js/admin-docs.js +++ b/public/js/admin-docs.js @@ -21,13 +21,29 @@ function $(id) { return document.getElementById(id); } function escHtml(s) { return String(s == null ? '' : s).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. diff --git a/src/routes/myResources.js b/src/routes/myResources.js index fde2d229..6296e2e9 100644 --- a/src/routes/myResources.js +++ b/src/routes/myResources.js @@ -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, diff --git a/src/utils/deckSchema.js b/src/utils/deckSchema.js index 5eaf3561..dbc2ae82 100644 --- a/src/utils/deckSchema.js +++ b/src/utils/deckSchema.js @@ -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) { diff --git a/src/utils/slideSpec.js b/src/utils/slideSpec.js index fb5af5dc..63adf26a 100644 --- a/src/utils/slideSpec.js +++ b/src/utils/slideSpec.js @@ -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); diff --git a/test/slide-spec.test.js b/test/slide-spec.test.js index cf16cff0..5c267eae 100644 --- a/test/slide-spec.test.js +++ b/test/slide-spec.test.js @@ -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/); +});