pediatric-ai-scribe-v3/test/slide-spec.test.js
Daniel 087f717f55
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 59s
Forgejo Docker Build / Root app tests (push) Successful in 53s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 8s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
feat: the model designs the deck instead of writing markdown for a parser to guess at
Markdown could express about five of the things the renderer can draw, so the
model had no way to say "put this figure beside these three bullets" or "make
this a comparison with two labelled columns" — my parser inferred a layout from
the shape of a list, and inferring is what made every deck look the same.

A presentation is now described as a deck: the model returns JSON naming a
layout per slide and the prompt it wants each figure drawn from. Four layouts
were added to the renderer for it — two tinted labelled columns for a
comparison, a callout card for a red flag or a dose, a figure beside its
bullets, and a full-slide figure. Articles stay markdown, which is what prose
wants.

Markdown is still produced, serialised from the deck, so Word export and text
editing keep working and the stored artifact stays readable by a person. The
deck is stored alongside it because that serialisation is lossy by design:
round-tripping through markdown would throw away exactly the layout choices this
was built to capture. A resource made before this, or an article forced into
slides, still renders by inferring from its markdown.

Nothing here can cost more than the thing that went wrong. A reply that is not a
deck falls back to asking for markdown rather than saving the model's apology; a
malformed slide degrades to bullets rather than throwing; a comparison with one
column is not a comparison; a figure that cannot be queued leaves a slide of
text rather than an empty frame; and JSON wrapped in fences or a covering
sentence is read rather than refused.

Verified live on "croup versus epiglottitis": the model chose section, bullets,
table, compare, figure, callout and image layouts across thirteen slides, and
the exported deck was rendered to PDF, rasterised and looked at — the comparison
renders as two tinted cards, the red flag as a callout, and the figure sits
beside its bullets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 19:49:55 +02:00

184 lines
9.2 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\)/);
assert.match(exporter, /spawn\('python3', \[DECK_RENDERER, 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 figureIds = \(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');
}
});