fix: every presentation takes a theme, not only the ones the model designed
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Docker Build / Build Docker image (push) Successful in 6s
Forgejo Docker Build / End-to-end (browser) (push) Failing after 8s

"Change template" answered "no slide layout" for 28 of the 41 presentations
in production: the theme lived only inside the deck JSON, and a presentation
whose deck reply failed twice and fell back to markdown slides had nowhere
to keep one. The theme is a column now, written at generation and by the
picker, and the markdown slide builder carries it to the same renderer field
a designed deck uses. A deck's own theme field is kept in step. Articles are
the only thing refused — they have no slides.

A deck reply that fails to parse is logged with its first 240 characters, so
the next "the reply was not a deck" can be read rather than guessed at.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-13 13:32:24 +02:00
parent 1c5e218382
commit 0163d40811
6 changed files with 63 additions and 20 deletions

View file

@ -0,0 +1,16 @@
// A presentation's theme, whether or not the model produced a structured deck.
//
// The theme lived only inside the deck JSON, so a presentation that fell back
// to markdown slides — 28 of the 41 in production — had nowhere to keep one,
// and "change template" answered that it had no layout to change. The column
// holds the author's choice for every presentation; a deck's own theme field
// is kept in step with it.
exports.up = pgm => pgm.sql(`
ALTER TABLE user_resources ADD COLUMN IF NOT EXISTS theme TEXT;
UPDATE user_resources SET theme = deck->>'theme' WHERE theme IS NULL AND deck IS NOT NULL AND deck->>'theme' IS NOT NULL;
`);
exports.down = pgm => pgm.sql(`
ALTER TABLE user_resources DROP COLUMN IF EXISTS theme;
`);

View file

@ -416,7 +416,7 @@ router.post('/my-resources/generate', async function (req, res) {
// after a single unlucky reply.
var firstFailure = deckFailure(ai && ai.content);
logger.warn('[my-resources] deck reply was not usable (' + firstFailure + '); asking once more',
{ topic: topic, attempt: 1, model: ai && ai.model });
{ topic: topic, attempt: 1, model: ai && ai.model, reply: String((ai && ai.content) || '').slice(0, 240) });
var retryGaps = [];
ai = await callAI([{ role: 'user', content: prompt }], options);
deck = deckBuild.parse(ai && ai.content, retryGaps);
@ -429,7 +429,7 @@ router.post('/my-resources/generate', async function (req, res) {
// beats saving the model's apology.
deckFallback = deckFailure(ai && ai.content);
logger.warn('[my-resources] deck reply was not usable twice (' + deckFallback +
'); retrying as markdown', { topic: topic, attempt: 2, model: ai && ai.model });
'); retrying as markdown', { topic: topic, attempt: 2, model: ai && ai.model, reply: String((ai && ai.content) || '').slice(0, 240) });
var plain = buildPrompt({
topic: topic, kind: kind, refinement: refinement, corpusContext: corpus.context,
literature: sources.literature, webFindings: sources.webFindings,
@ -487,11 +487,12 @@ router.post('/my-resources/generate', async function (req, res) {
if (!markdown) return res.status(502).json({ error: 'The model returned nothing. Try again.' });
var row = await db.get(
'INSERT INTO user_resources (user_id, title, kind, markdown, topic, grounded_count, image_ids, deck) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, title, kind, topic, grounded_count, created_at',
'INSERT INTO user_resources (user_id, title, kind, markdown, topic, grounded_count, image_ids, deck, theme) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, title, kind, topic, grounded_count, created_at',
[req.user.id, deck && deck.title ? deck.title : firstHeading(markdown, topic), kind, markdown,
topic.slice(0, 500), corpus.sources.length, JSON.stringify(savedFigureIds),
deck ? JSON.stringify(deck) : null]
deck ? JSON.stringify(deck) : null,
kind === 'presentation' ? (deckSchema.themeId(req.body.theme) || null) : null]
);
res.json({
@ -655,18 +656,21 @@ router.put('/my-resources/:id/theme', async function (req, res) {
return res.status(400).json({ error: 'That is not one of the available themes.' });
}
var row = await db.get(
'SELECT id, deck FROM user_resources WHERE id = ? AND user_id = ?',
'SELECT id, kind, deck FROM user_resources WHERE id = ? AND user_id = ?',
[parseInt(req.params.id, 10), req.user.id]
);
if (!row) return res.status(404).json({ error: 'Not found' });
if (!row.deck) {
return res.status(400).json({ error: 'This resource has no slide layout, so it has no theme to change.' });
if (row.kind !== 'presentation') {
return res.status(400).json({ error: 'An article has no slides, so it has no theme to change.' });
}
var deck = typeof row.deck === 'string' ? JSON.parse(row.deck) : row.deck;
deck.theme = theme;
// Every presentation has a theme, structured deck or markdown slides: the
// renderer applies it to both. It used to be refused for the markdown
// ones — most of them — because the theme lived only inside the deck.
var deck = row.deck ? (typeof row.deck === 'string' ? JSON.parse(row.deck) : row.deck) : null;
if (deck) deck.theme = theme;
await db.run(
'UPDATE user_resources SET deck = ?::jsonb, updated_at = NOW() WHERE id = ? AND user_id = ?',
[JSON.stringify(deck), row.id, req.user.id]
'UPDATE user_resources SET theme = ?, deck = COALESCE(?::jsonb, deck), updated_at = NOW() WHERE id = ? AND user_id = ?',
[theme || null, deck ? JSON.stringify(deck) : null, row.id, req.user.id]
);
res.json({ success: true, theme: theme });
} catch (err) {
@ -1014,7 +1018,7 @@ router.get('/my-resources/:id/export', async function (req, res) {
if (!documentExport.isSupported(format)) return res.status(400).json({ error: 'Unsupported format' });
var row = await db.get(
'SELECT title, kind, markdown, image_ids, deck FROM user_resources WHERE id = ? AND user_id = ?',
'SELECT title, kind, markdown, image_ids, deck, theme FROM user_resources WHERE id = ? AND user_id = ?',
[parseInt(req.params.id, 10), req.user.id]
);
if (!row) return res.status(404).json({ error: 'Not found' });
@ -1037,7 +1041,7 @@ router.get('/my-resources/:id/export', async function (req, res) {
var bytes;
try {
bytes = await documentExport.render(row.markdown, row.kind, format,
{ images: figures, deck: row.deck, figureIds: figureIdList(row.image_ids) });
{ images: figures, deck: row.deck, theme: row.theme, figureIds: figureIdList(row.image_ids) });
} finally {
if (scratch) {
await require('fs/promises').rm(scratch, { recursive: true, force: true })

View file

@ -191,7 +191,10 @@ async function buildDeck(markdown, workdir, images, options) {
// to inferring a layout from markdown.
var spec = options.deck
? attachFigures(options.deck, images, options.figureIds)
: slideSpec.build(markdown, { images: images });
: slideSpec.build(markdown, { images: images, theme: options.theme });
// The author's theme applies to markdown slides exactly as to a designed
// deck; a deck's own field, when present, is the same value kept in step.
if (options.theme && !spec.theme) spec.theme = options.theme;
await runRenderer(DECK_RENDERER, out, spec, workdir);
var built = await fsp.readFile(out);
if (built.length) return;

View file

@ -165,7 +165,7 @@ function build(markdown, options) {
}
return { title: meta.title, subtitle: meta.subtitle, date: meta.date,
slides: slides.concat(content) };
theme: options.theme || undefined, slides: slides.concat(content) };
}
module.exports = { build, TWO_COLUMN_AT };

View file

@ -45,3 +45,21 @@ test('an unknown theme leaves the default rather than failing the render', () =>
const fn = renderer.slice(renderer.indexOf('def apply_theme'));
assert.match(fn.slice(0, 400), /a deck rendering in the wrong colours beats a deck not rendering/);
});
test('a presentation that fell back to markdown slides still takes a theme', () => {
// 28 of the 41 presentations in production had no structured deck, and for
// every one of them "change template" answered that there was no layout to
// change. The theme is a column now, and the markdown slide builder carries
// it to the same renderer field a designed deck uses.
const slideSpec = require('../src/utils/slideSpec');
const spec = slideSpec.build('% Title\n% Author\n% 2026\n\n# One\n\n- a\n- b\n', { theme: 'ward-teal' });
assert.equal(spec.theme, 'ward-teal');
assert.equal(slideSpec.build('# One\n- a').theme, undefined, 'no theme asked for, none invented');
const route = fs.readFileSync(path.join(__dirname, '..', 'src/routes/myResources.js'), 'utf8');
assert.doesNotMatch(route, /has no slide layout, so it has no theme to change/);
assert.match(route, /UPDATE user_resources SET theme = \?, deck = COALESCE\(\?::jsonb, deck\)/);
assert.match(route, /deck: row\.deck, theme: row\.theme, figureIds/);
const exporter = fs.readFileSync(path.join(__dirname, '..', 'src/utils/documentExport.js'), 'utf8');
assert.match(exporter, /slideSpec\.build\(markdown, \{ images: images, theme: options\.theme \}\)/);
assert.match(fs.readFileSync(path.join(__dirname, '..', 'migrations/1781200000000_resource-theme.js'), 'utf8'), /ADD COLUMN IF NOT EXISTS theme TEXT/);
});

View file

@ -55,10 +55,12 @@ test('the renderer rebinds its palette instead of hardcoding one', () => {
test('re-skinning is a column write, never a regeneration', () => {
const route = read('src/routes/myResources.js');
const handler = route.slice(route.indexOf("router.put('/my-resources/:id/theme'"));
assert.match(handler.slice(0, 1400), /UPDATE user_resources SET deck = \?::jsonb/);
assert.doesNotMatch(handler.slice(0, 1400), /callAI/, 'no model call: nothing can reword a slide');
assert.match(handler.slice(0, 1400), /AND user_id = \?/, 'scoped to the owner');
assert.match(handler.slice(0, 1400), /has no slide layout/, 'flat markdown has no theme');
assert.match(handler.slice(0, 1600), /UPDATE user_resources SET theme = \?, deck = COALESCE\(\?::jsonb, deck\)/);
assert.doesNotMatch(handler.slice(0, 1600), /callAI/, 'no model call: nothing can reword a slide');
assert.match(handler.slice(0, 1600), /AND user_id = \?/, 'scoped to the owner');
// Markdown slides take a theme too — the renderer applies it to both — so
// the only thing refused is an article, which has no slides at all.
assert.match(handler.slice(0, 1600), /An article has no slides/, 'articles have no theme');
});
test('the theme is shown by a deck the renderer built, not a mocked-up swatch', () => {