diff --git a/src/routes/myResources.js b/src/routes/myResources.js index 92594d0c..1a2bfc94 100644 --- a/src/routes/myResources.js +++ b/src/routes/myResources.js @@ -408,7 +408,19 @@ router.post('/my-resources/generate', async function (req, res) { return 'the reply was not a deck'; } + // A deck reply that did not parse is kept whole under data/logs, because a + // 240-character excerpt of a 6,000-character reply says where it began + // and nothing about where it broke. Rare, and the only way to read one. + function keepFailedReply(attempt, reply) { + try { + var dir = require('path').join(require('../utils/fileLog').LOG_DIR, 'deck-failures'); + require('fs').mkdirSync(dir, { recursive: true }); + require('fs').writeFileSync(require('path').join(dir, new Date().toISOString().replace(/[:.]/g, '-') + '-attempt' + attempt + '.txt'), String(reply || '')); + } catch (e) { /* diagnostics only */ } + } + if (deckMode && !deck) { + keepFailedReply(1, ai && ai.content); // One more attempt at a deck before giving up on the layout. Models are // stochastic and this is the same prompt, not a weaker one: measured on // the stored library, a deck failed once in eight generations, and a @@ -425,6 +437,7 @@ router.post('/my-resources/generate', async function (req, res) { } if (deckMode && !deck) { + keepFailedReply(2, ai && ai.content); // Twice is enough. Falling back to markdown beats saving nothing, and // beats saving the model's apology. deckFallback = deckFailure(ai && ai.content); @@ -685,7 +698,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, deck, image_ids FROM user_resources WHERE id = ? AND user_id = ?', + 'SELECT id, kind, topic, markdown, deck, image_ids, theme 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' }); diff --git a/src/utils/deckBuild.js b/src/utils/deckBuild.js index abaa0175..6955a23b 100644 --- a/src/utils/deckBuild.js +++ b/src/utils/deckBuild.js @@ -16,6 +16,20 @@ var MAX_FIGURES = 6; // Models wrap JSON in fences or a sentence often enough that refusing it would // be pedantry. The first balanced object is the deck. +// The one way a model's deck JSON is seen to break: the slide object is closed +// as soon as its main value ends — `"rows":[[…]]}` — and the notes that belong +// to that slide follow as `,"notes":"…"}` outside it, which is not JSON. Read +// off a saved failed reply; three generations in a row broke in that one +// place, and every one of them fell back to plain slides. The premature brace +// is the only thing removed: the `}` that follows the notes then closes the +// slide as intended. Bounded to the schema's own slide keys, and a brace that +// closes an inner object before another object begins (`},{`) is not touched. +var SLIDE_KEYS = 'notes|heading|bullets|columns|header|rows|text|image_prompt|type'; +var EARLY_CLOSE = new RegExp('([\\]}"\\d])\\s*\\}\\s*,\\s*"(' + SLIDE_KEYS + ')"\\s*:', 'g'); +function repairEarlyClose(json) { + return json.replace(EARLY_CLOSE, '$1,"$2":'); +} + function extractJson(content) { var text = String(content || '').trim(); text = text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, ''); @@ -30,10 +44,18 @@ function extractJson(content) { if (inString) continue; if (ch === '{') depth++; else if (ch === '}' && --depth === 0) { - try { return JSON.parse(text.slice(start, i + 1)); } catch (e) { return null; } + try { return JSON.parse(text.slice(start, i + 1)); } catch (e) { break; } } } - return null; + // The brace walk above ends early on an early-closed slide too, so the + // repair is tried on the whole text rather than on the slice it stopped at. + var repaired = repairEarlyClose(text.slice(start)); + if (repaired === text.slice(start)) return null; + try { + var parsed = JSON.parse(repaired); + console.info('[deck] reply parsed after repairing an early-closed slide'); + return parsed; + } catch (e) { return null; } } /** @@ -127,4 +149,4 @@ function parse(content, gaps) { return deck; } -module.exports = { parse, drawFigures, extractJson, MAX_FIGURES }; +module.exports = { parse, drawFigures, extractJson, repairEarlyClose, MAX_FIGURES }; diff --git a/test/deck-review.test.js b/test/deck-review.test.js index 7f3c8f4a..71f38e29 100644 --- a/test/deck-review.test.js +++ b/test/deck-review.test.js @@ -159,3 +159,20 @@ test('the reviewer is admin-chosen, off by default, and runs once per change', ( assert.match(read('Dockerfile'), /poppler-utils/); assert.match(read('src/utils/deckReview.js'), /'pdftoppm', \['-png', '-r', String\(RENDER_DPI\)/); }); + +test('a deck whose slide was closed before its notes still parses', () => { + // A saved reply from production's fallback path: the model wrote + // `"rows":[[…]]}` and then `,"notes":"…"}` — the slide closed early and the + // whole deck was thrown away for it, three generations in a row. + const deckBuild = require('../src/utils/deckBuild'); + const reply = fs.readFileSync(path.join(__dirname, 'fixtures/deck-reply-early-close.json'), 'utf8'); + assert.throws(() => JSON.parse(reply), 'the fixture really is broken'); + const deck = deckBuild.parse(reply, []); + assert.ok(deck && deck.slides.length >= 6, 'parsed after repair'); + assert.ok(deck.slides.some(s => s.type === 'table'), 'the table slide survived'); + // The repair is narrow: a brace that legitimately closes an inner object + // before the next one begins is left alone. + const fine = '{"title":"t","slides":[{"type":"compare","heading":"h","columns":[{"label":"A","bullets":[{"text":"x"}]},{"label":"B","bullets":[{"text":"y"}]}],"notes":"n"}]}'; + assert.equal(deckBuild.repairEarlyClose(fine), fine); + assert.equal(deckBuild.parse(fine, []).slides[0].type, 'compare'); +}); diff --git a/test/deck-themes.test.js b/test/deck-themes.test.js index f99a31b4..c2f48798 100644 --- a/test/deck-themes.test.js +++ b/test/deck-themes.test.js @@ -87,3 +87,12 @@ test('the library offers a theme only where there is a deck to re-skin', () => { // resource does not have. assert.match(ui, /if \(previous\) select\.value = previous;/); }); + +test('a modification keeps the deck\'s theme', () => { + // The model returns a new deck; the look was never its to choose. The + // column keeps the theme regardless, but the deck field must agree with it. + const route = read('src/routes/myResources.js'); + const refine = route.slice(route.indexOf("router.post('/my-resources/:id/refine'")); + assert.match(refine, /revisedDeck\.theme = existingDeck\.theme \|\| existing\.theme \|\| revisedDeck\.theme/); + assert.match(refine.slice(0, 2500), /SELECT id, kind, topic, markdown, deck, image_ids, theme FROM user_resources/); +}); diff --git a/test/fixtures/deck-reply-early-close.json b/test/fixtures/deck-reply-early-close.json new file mode 100644 index 00000000..03ddbdbd --- /dev/null +++ b/test/fixtures/deck-reply-early-close.json @@ -0,0 +1 @@ +{"title":"Bronchiolitis in Infants Under 12 Months","subtitle":"Assessment and Admission Criteria","date":"2024-06-01","slides":[{"type":"section","heading":"Introduction to Bronchiolitis"},{"type":"bullets","heading":"Overview of Bronchiolitis","bullets":[{"text":"Common viral lower respiratory tract infection in infants <12 months"},{"text":"Most frequently caused by Respiratory Syncytial Virus (RSV)"},{"text":"Peak incidence between 2-6 months of age"},{"text":"Characterized by inflammation, edema, and mucus in small airways"},{"text":"Seasonal peaks in fall and winter months"}],"notes":"Introduce bronchiolitis as a common and important condition in young infants, emphasizing viral etiology and typical age group."},{"type":"figure","heading":"Clinical Features of Bronchiolitis","bullets":[{"text":"Initial symptoms: rhinorrhea, mild cough, low-grade fever"},{"text":"Progression: increased cough, wheezing, tachypnea"},{"text":"Signs of respiratory distress: nasal flaring, chest retractions, grunting"},{"text":"Feeding difficulties and irritability common"},{"text":"Apnea may occur in very young or high-risk infants"}],"image_prompt":"schematic infant with highlighted nasal flaring, chest retractions, wheezing lungs, and feeding difficulty icons"},{"type":"compare","heading":"Assessment: Mild vs Severe Bronchiolitis","columns":[{"label":"MILD","bullets":["Normal or mildly increased respiratory rate","No or mild chest retractions","Feeding well, >50% usual intake","No or mild hypoxia (SpO2 ≥ 92%)","Alert and consolable"]},{"label":"SEVERE","bullets":["Marked tachypnea (>70 breaths/min)","Moderate to severe chest retractions and nasal flaring","Feeding poorly, <50% usual intake or vomiting","Hypoxia (SpO2 < 92%) or cyanosis","Lethargy, apnea, or altered consciousness"]}]},{"type":"table","heading":"Admission Criteria for Infants with Bronchiolitis","header":["Criteria","Indication for Admission"],"rows":[["Respiratory distress (severe retractions, grunting)","Admit for monitoring and supportive care"],["Hypoxia (SpO2 < 92%) on room air","Admit for oxygen therapy"],["Poor oral intake (<50% usual) or dehydration","Admit for IV fluids"],["Apnea or history of apnea","Admit for close monitoring"],["Underlying risk factors (prematurity, chronic lung disease, congenital heart disease)","Lower threshold for admission"],["Age < 3 months with bronchiolitis","Consider admission due to higher risk"]]},"notes":"Use this table to guide decisions on when to admit infants based on clinical severity and risk factors."},{"type":"callout","heading":"Key Red Flag","text":"Apnea or cyanosis in any infant with bronchiolitis requires urgent admission and monitoring."},{"type":"bullets","heading":"Summary and Management Principles","bullets":[{"text":"Supportive care is mainstay: oxygen, hydration, and monitoring"},{"text":"Avoid routine use of bronchodilators, steroids, or antibiotics"},{"text":"Educate caregivers on signs of deterioration"},{"text":"Close follow-up for infants discharged home"},{"text":"Consider admission for infants meeting criteria or with social concerns"}],"notes":"Reinforce that management is mostly supportive and highlight importance of caregiver education and follow-up."},{"type":"table","heading":"References","header":["Source"],"rows":[["American Academy of Pediatrics Clinical Practice Guideline, 2014"],["NICE Guideline NG9, Bronchiolitis in children, 2015"],["UpToDate: Bronchiolitis in infants and children"]]}]} \ No newline at end of file diff --git a/test/my-resources.test.js b/test/my-resources.test.js index 57591b09..24e80d83 100644 --- a/test/my-resources.test.js +++ b/test/my-resources.test.js @@ -320,7 +320,7 @@ test('modifying a presentation edits the deck, not only its markdown', () => { // 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, image_ids FROM user_resources/); + assert.match(route, /SELECT id, kind, topic, markdown, deck, image_ids, theme 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');