pediatric-ai-scribe-v3/test/slide-spec.test.js
Daniel 07d1c541a0
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 23s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
fix: a deck the model fumbles once is asked for again, not abandoned
A presentation is generated as a typed deck — the model chooses each slide's
layout — and that deck is what scripts/render_pptx.py renders. When the reply
did not parse as a deck, the route fell straight back to asking for markdown,
and slideSpec.build() then inferred slides from it. Both paths go through
python-pptx, but the fallback's layout is guessed from heading and list
structure rather than chosen, so everything lands as title-and-bullets.

Measured on the stored library: since decks landed, 7 of 8 generations produced
one and 1 did not. Models are stochastic, so one unlucky reply was costing the
whole layout. It now asks a second time with the same prompt before giving up.

The fallback was also invisible. It warned to the console, where the person who
would simply have generated again could not see it, so they kept the plainer
deck without knowing a better one was one click away. The response now carries
deckFallback and the UI says it came out as plain slides, and why.

Fixed the reason heuristic while adding tests for it: truncation was claimed for
any reply not ending in "}", which is every prose refusal. It is now only
claimed for a reply that began as JSON and stopped.

The four generate tests run the handler. Verified against a mutation: removing
the retry fails tests 6 and 7 and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 05:34:14 +02:00

247 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. The three
// outcomes are covered behaviourally in my-resources-refine.test.js; this
// only pins that the budget and the three causes still exist here.
assert.match(route, /cut short at ' \+ text\.length \+ ' characters'/);
assert.match(route, /the model returned nothing/);
assert.match(route, /the reply was not a deck/);
});
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');
});