fix: every presentation takes a theme, not only the ones the model designed
"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:
parent
1c5e218382
commit
0163d40811
6 changed files with 63 additions and 20 deletions
16
migrations/1781200000000_resource-theme.js
Normal file
16
migrations/1781200000000_resource-theme.js
Normal 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;
|
||||
`);
|
||||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue