diff --git a/src/routes/myResources.js b/src/routes/myResources.js index 9a470583..d043b631 100644 --- a/src/routes/myResources.js +++ b/src/routes/myResources.js @@ -456,7 +456,7 @@ router.post('/my-resources/:id/refine', async function (req, res) { if (!instructions) return res.status(400).json({ error: 'Say what to change' }); var existing = await db.get( - 'SELECT id, kind, topic, markdown FROM user_resources WHERE id = ? AND user_id = ?', + 'SELECT id, kind, topic, markdown, deck FROM user_resources WHERE id = ? AND user_id = ?', [parseInt(req.params.id, 10), req.user.id] ); if (!existing) return res.status(404).json({ error: 'Not found' }); @@ -494,15 +494,29 @@ router.post('/my-resources/:id/refine', async function (req, res) { 'substitute for it, and no image tag or URL goes into the markdown.' : ''; - // The markdown is the thing being edited, which is the whole reason it is - // what gets stored: "change slide 4" is a text edit, not a binary patch. - var messages = [{ role: 'user', content: - 'Revise the following Pandoc markdown according to the instruction. ' + - 'Return ONLY the complete revised markdown, no commentary, no code fences. ' + - 'Keep the same overall structure unless the instruction asks otherwise, and keep any ' + - 'References section at the end.\n\nINSTRUCTION: ' + instructions + illustration + material + - '\n\nMARKDOWN:\n"""\n' + existing.markdown + '\n"""' }]; - var options = { model: await resolveModel(req.body.model), temperature: 0.2 }; + // A presentation with a stored deck is edited as a deck. Editing its + // markdown instead changed only the markdown: export renders from the deck, + // so a modification succeeded, said so, and produced an identical download. + var existingDeck = existing.deck + ? (typeof existing.deck === 'string' ? JSON.parse(existing.deck) : existing.deck) : null; + if (existingDeck && !(existingDeck.slides || []).length) existingDeck = null; + + var messages = [{ role: 'user', content: existingDeck + ? 'Revise the following slide deck according to the instruction. Return ONLY the ' + + 'complete revised JSON object, in exactly the same shape, no commentary and no code ' + + 'fences. Keep every slide that the instruction does not ask you to change, including ' + + 'its layout and its "image_job" values, and keep any References slide last.\n\n' + + 'INSTRUCTION: ' + instructions + illustration + material + + '\n\nThe layouts available are:\n' + deckSchema.instructions( + (existingDeck.slides || []).length, 0) + + '\n\nDECK JSON:\n' + JSON.stringify({ slides: existingDeck.slides }) + : 'Revise the following Pandoc markdown according to the instruction. ' + + 'Return ONLY the complete revised markdown, no commentary, no code fences. ' + + 'Keep the same overall structure unless the instruction asks otherwise, and keep any ' + + 'References section at the end.\n\nINSTRUCTION: ' + instructions + illustration + material + + '\n\nMARKDOWN:\n"""\n' + existing.markdown + '\n"""' }]; + // The reply restates the whole resource, so it needs room for one. + var options = { model: await resolveModel(req.body.model), temperature: 0.2, maxTokens: 16000 }; var tools = sources.wantsImages ? resourceImages.tools : []; var callOptions = tools.length ? Object.assign({}, options, { tools: tools }) : options; if (tools.length && resourceImages.requestedCount(instructions)) callOptions.toolChoice = 'required'; @@ -515,7 +529,22 @@ router.post('/my-resources/:id/refine', async function (req, res) { }); } - var revised = String((ai && ai.content) || '').trim(); + var revisedDeck = existingDeck ? deckBuild.parse(ai && ai.content) : null; + if (existingDeck && !revisedDeck) { + // A reply that is not a deck would otherwise be saved as the markdown and + // silently drop every layout the deck held. + console.warn('[my-resources] refine did not return a usable deck; nothing changed'); + return res.status(502).json({ error: 'That change could not be applied. Try wording it differently.' }); + } + if (revisedDeck) { + // Carried through rather than trusted from the reply. + revisedDeck.title = existingDeck.title; + revisedDeck.subtitle = existingDeck.subtitle; + revisedDeck.date = existingDeck.date; + } + + var revised = revisedDeck ? deckSchema.toMarkdown(revisedDeck) + : String((ai && ai.content) || '').trim(); if (!revised) return res.status(502).json({ error: 'The model returned nothing. Try again.' }); // Figures from a modification are added to the ones already there, not @@ -523,9 +552,10 @@ router.post('/my-resources/:id/refine', async function (req, res) { var added = (ai.imageJobs || []).map(function (job) { return job.jobId; }).filter(Boolean); var row = await db.get( 'UPDATE user_resources SET markdown = ?, title = ?, updated_at = NOW(), ' + - 'image_ids = image_ids || ?::jsonb ' + + 'image_ids = image_ids || ?::jsonb, deck = COALESCE(?::jsonb, deck) ' + 'WHERE id = ? AND user_id = ? RETURNING id, title, updated_at', - [revised, firstHeading(revised), JSON.stringify(added), existing.id, req.user.id] + [revised, firstHeading(revised), JSON.stringify(added), + revisedDeck ? JSON.stringify(revisedDeck) : null, existing.id, req.user.id] ); res.json({ success: true, resource: row, markdown: revised, diff --git a/test/my-resources.test.js b/test/my-resources.test.js index 4ba60f26..ef837457 100644 --- a/test/my-resources.test.js +++ b/test/my-resources.test.js @@ -279,6 +279,27 @@ test('the library is bounded, searchable, and drives the modify picker', () => { assert.match(js, /library\.length\s*\n?\s*\? 'Nothing matches/); }); +test('modifying a presentation edits the deck, not only its markdown', () => { + const route = read('src/routes/myResources.js'); + // Export renders from the stored deck. Refine edited the markdown beside it + // and left the deck alone, so a modification reported success, updated the + // library row, and produced a byte-identical download. Measured: markdown + // gained the new slide, the deck did not, and the exported pptx did not. + assert.match(route, /SELECT id, kind, topic, markdown, deck FROM user_resources/); + assert.match(route, /var revisedDeck = existingDeck \? deckBuild\.parse\(ai && ai\.content\) : null;/); + assert.match(route, /deck = COALESCE\(\?::jsonb, deck\)/, 'and the deck is written back'); + + // A reply that is not a deck must not be saved as markdown: that would drop + // every layout the deck held while looking like it worked. + assert.match(route, /refine did not return a usable deck; nothing changed/); + assert.match(route, /That change could not be applied\. Try wording it differently\./); + + // An article has no deck and keeps the markdown path. + assert.match(route, /: 'Revise the following Pandoc markdown according to the instruction\. '/); + // The reply restates the whole resource, so it needs room for one. + assert.match(route, /temperature: 0\.2, maxTokens: 16000/); +}); + test('modify revises something already generated, in place', () => { const js = read('public/js/myResources.js'); // The endpoint existed with no way to reach it: the markdown is what is