pediatric-ai-scribe-v3/test/slide-spec.test.js
Daniel 5614a436be
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
fix: a deck asked for a figure and never got one — three causes, one symptom
"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
2026-09-12 04:28:30 +02:00

244 lines
13 KiB
JavaScript

// ============================================================
// SLIDE SPEC
// ============================================================
// The half of deck rendering that decides what each slide is. Pandoc had no
// such step — every slide became bullets on a reference layout — so this is
// where most of the difference in a generated deck now comes from.
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const slideSpec = require('../src/utils/slideSpec');
const read = p => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
const DECK = [
'% Croup in Children', '% Teaching Resource', '% 2026', '',
'# What Croup Is', '', '- A viral illness', '- Peaks at 12-18 months', '',
'# Severity', '',
'| Feature | Mild | Severe |', '|---|---|---|',
'| Stridor | Absent | Present |', '| Retractions | None | Marked |', '',
'# Long list', '',
'- one', '- two', '- three', '- four', '- five', '- six', '- seven', '- eight', '',
'# References', '', '- Nelson, p. 2606'
].join('\n');
test('a title block becomes a title slide, not a bullet', () => {
const spec = slideSpec.build(DECK, {});
assert.equal(spec.slides[0].type, 'title');
assert.equal(spec.slides[0].heading, 'Croup in Children');
assert.equal(spec.slides[0].subtitle, 'Teaching Resource');
assert.equal(spec.slides[0].date, '2026');
});
test('each slide gets the layout its content needs', () => {
const byHeading = {};
slideSpec.build(DECK, {}).slides.forEach(s => { if (s.heading) byHeading[s.heading] = s; });
assert.equal(byHeading['What Croup Is'].type, 'bullets');
// A pipe table is a table, not eight lines of text with pipes in them.
assert.equal(byHeading['Severity'].type, 'table');
assert.deepEqual(byHeading['Severity'].header, ['Feature', 'Mild', 'Severe']);
assert.equal(byHeading['Severity'].rows.length, 2, 'the alignment rule is not a row');
// A long list is unreadable at any legible size in one column.
assert.equal(byHeading['Long list'].type, 'two');
assert.equal(byHeading['Long list'].left.length + byHeading['Long list'].right.length, 8);
});
test('figures are spread through the deck, and References stays last', () => {
const spec = slideSpec.build(DECK, { images: ['/tmp/a.png', '/tmp/b.png'] });
const types = spec.slides.map(s => s.type);
assert.equal(types.filter(t => t === 'image').length, 2);
// Appending them would end the deck with unexplained pictures.
assert.notEqual(types[types.length - 1], 'image');
assert.equal(spec.slides[spec.slides.length - 1].heading, 'References');
// And never before the first content slide.
assert.ok(types.indexOf('image') > 1);
});
test('a slide with no list still becomes a slide', () => {
const spec = slideSpec.build('# Just a heading\n', {});
assert.equal(spec.slides[0].type, 'section');
assert.equal(spec.slides[0].heading, 'Just a heading');
});
test('the renderer sizes text to fit rather than trusting autofit', () => {
const py = read('scripts/render_pptx.py');
// LibreOffice ignores <a:normAutofit/> when converting to PDF, which is how
// slides were being cut off mid-sentence.
assert.match(py, /def _fit_size\(/);
assert.match(py, /BULLET_SIZES = /);
// 16:9. Pandoc's reference doc is 4:3.
assert.match(py, /SLIDE_W = Emu\(12192000\)/);
// An image keeps its own aspect ratio; the old pptxgenjs path stretched every
// one of them to the target box.
assert.match(py, /ratio = img\.width \/ float\(img\.height\)/);
assert.match(py, /width = int\(height \* ratio\)/);
// Wrapped lines hang under the text.
assert.match(py, /pPr\.set\("indent", str\(-indent\)\)/);
});
test('the deck renderer replaces pandoc, and pandoc still catches it if it falls', () => {
const exporter = read('src/utils/documentExport.js');
assert.match(exporter, /async function buildDeck\(markdown, workdir, images, options\)/);
// One spawn helper, now that the document renderer uses it too.
assert.match(exporter, /runRenderer\(DECK_RENDERER, out, spec, workdir\)/);
assert.match(exporter, /spawn\('python3', \[script, out\]/);
// A plainer deck beats a failed download.
assert.match(exporter, /deck renderer failed, falling back to pandoc/);
assert.match(exporter, /runPandoc\(\['doc\.md', '--reference-doc=' \+ REFERENCE_DECK/);
// Word is still pandoc's, where its output is good.
assert.match(exporter, /runPandoc\(\['doc\.md', '-o', 'doc\.docx'\]/);
// And the runtime actually has it.
assert.match(read('Dockerfile'), /python-pptx==1\.0\.2/);
});
test('a resource remembers its figures, so an export can include them', () => {
const route = read('src/routes/myResources.js');
// They were queued and shown on screen, but nothing tied them to the
// resource, so an exported deck could never contain them.
assert.match(read('migrations/1780500000000_resource-images.js'), /image_ids JSONB/);
assert.match(route, /var savedFigureIds = \(ai\.imageJobs \|\| \[\]\)\.map/);
// "Add two more diagrams" means more, not instead.
assert.match(route, /image_ids = image_ids \|\| \?::jsonb/);
assert.match(route, /async function collectFigures\(ids, user, dir\)/);
// A figure that cannot be fetched is left out rather than failing a download.
assert.match(route, /figure unavailable for export/);
assert.match(route, /documentExport\.render\(row\.markdown, row\.kind, format,\s*\n?\s*\{ images: figures, deck: row\.deck/);
});
// ── Deck mode ───────────────────────────────────────────────
// The model designs the deck instead of writing markdown for a parser to guess
// at. Measured on a live generation: the model chose section, bullets, table,
// compare, figure, callout and image layouts for one topic — none of which
// markdown can ask for.
const deckSchema = require('../src/utils/deckSchema');
const deckBuild = require('../src/utils/deckBuild');
test('a model reply is read as a deck even when it is wrapped', () => {
// Fences and a covering sentence are common enough that refusing them would
// be pedantry.
assert.ok(deckBuild.extractJson('```json\n{"title":"A","slides":[]}\n```'));
assert.ok(deckBuild.extractJson('Here you go: {"title":"A","slides":[]} hope that helps'));
// A brace inside a string is not the end of the object.
assert.equal(deckBuild.extractJson('{"title":"a } b","slides":[]}').title, 'a } b');
assert.equal(deckBuild.extractJson('no json at all'), null);
// Nothing usable means fall back to markdown, not save an apology.
assert.equal(deckBuild.parse('{"slides":[]}'), null);
});
test('a malformed slide costs that slide, not the deck', () => {
const deck = deckSchema.normalise({
title: 'T',
slides: [
{ type: 'bullets', heading: 'Fine', bullets: ['one', { text: 'two', level: 1 }] },
{ type: 'nonsense', heading: 'Unknown type', bullets: ['kept'] },
{ type: 'compare', heading: 'Only one column', columns: [{ label: 'A', bullets: ['x'] }] },
{ type: 'table', heading: 'No rows', header: ['a'], rows: [] },
{ type: 'callout', heading: 'Empty', text: '' },
],
});
assert.equal(deck.slides[0].bullets.length, 2);
assert.equal(deck.slides[0].bullets[1].level, 1);
assert.equal(deck.slides[1].type, 'bullets', 'an unknown type degrades rather than throwing');
assert.equal(deck.slides[2].type, 'bullets', 'a comparison needs two columns to be one');
assert.equal(deck.slides[3].type, 'bullets', 'an empty table is not a table');
assert.equal(deck.slides.length, 4, 'a callout with nothing to say is dropped');
});
test('the deck still serialises to markdown, because Word and text edits need it', () => {
const md = deckSchema.toMarkdown(deckSchema.normalise({
title: 'Croup', subtitle: 'Teaching', date: '2026',
slides: [
{ type: 'compare', heading: 'Versus', columns: [
{ label: 'CROUP', bullets: ['barking cough'] },
{ label: 'EPIGLOTTITIS', bullets: ['drooling'] }] },
{ type: 'table', heading: 'Features', header: ['Feature', 'Croup'], rows: [['Onset', 'Days']] },
{ type: 'callout', heading: 'Red flag', text: 'Do not examine the throat.' },
],
}));
assert.match(md, /^% Croup/m);
assert.match(md, /^## CROUP$/m);
assert.match(md, /^\| Feature \| Croup \|$/m);
assert.match(md, /\*\*Do not examine the throat\.\*\*/);
});
test('a figure that never arrives leaves a slide of text, not an empty frame', () => {
const exporter = read('src/utils/documentExport.js');
assert.match(exporter, /function attachFigures\(deck, files, figureIds\)/);
assert.match(exporter, /if \(copy\.image_job && byJob\[copy\.image_job\]\) copy\.image = byJob\[copy\.image_job\];/);
assert.match(exporter, /copy\.type = copy\.type === 'image' \? 'section' : 'bullets';/);
// And a deck the model designed is rendered as designed; only older resources
// fall back to inferring a layout from their markdown.
assert.match(exporter, /options\.deck\s*\n?\s*\? attachFigures/);
const build = read('src/utils/deckBuild.js');
assert.match(build, /if \(!wanted\.length \|\| !opts\.imageModel\)/, 'no image model configured is not an error');
assert.match(build, /a figure could not be queued/);
});
test('the renderer can draw what the schema offers', () => {
const py = read('scripts/render_pptx.py');
for (const type of deckSchema.VALID) {
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/);
});
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');
});