fix: a deck reply that closed a slide early is repaired, and a modification keeps the theme
Three generations in a row fell back to plain slides for the same reason, visible once a failed reply was kept whole: the model wrote "rows":[[…]]} and then ,"notes":"…"} — the slide closed before its notes, which is not JSON. On parse failure the one premature brace is removed, bounded to the schema's slide keys; a brace that legitimately closes an inner object is left alone. Both test generations now come back as designed decks. Modify used to hand the model's new deck back without its theme; the look is the author's, so it is carried over, and the column agrees. Failed deck replies are kept under data/logs/deck-failures for the next time "the reply was not a deck" needs reading rather than guessing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
parent
0163d40811
commit
89f1aca8ed
6 changed files with 67 additions and 5 deletions
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
1
test/fixtures/deck-reply-early-close.json
vendored
Normal file
1
test/fixtures/deck-reply-early-close.json
vendored
Normal file
|
|
@ -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"]]}]}
|
||||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue