Spend fewer tokens: repair only broken slides
Cut the token cost of self-repair: find each slide's position in the deck text, send only the broken slides to the model, and splice the fixes back in untouched.
The repair loop from lesson 7.3 sends the whole deck back and asks for the whole deck again, even when one slide out of ten has a problem. That's the most expensive way to fix one slide, and the other nine can come back slightly changed, or broken.
Resend the whole deck (in + out)~5,200 tokens
Send only the broken slide~700 tokens
Where the cost is
- Output tokens usually cost several times more than input tokens, and they're what makes a call slow: the model writes them one at a time. A whole deck is thousands of them.
- A fix for one slide is a few hundred. Components already made each slide short (lesson 6.2); this makes the repair short too. See the for yourself in the Calls tab of any Gemini exercise.
Editing the deck as text
To replace slide 3, we need to know exactly where slide 3 starts and ends in the XML text. The parser builds a tree, but throws away positions. So slideRanges walks the tags directly and tracks depth:
<deck> depth 0 → 1
<TitleSlide …/> self-closing at depth 1: a whole slide
<BulletsSlide …> opening at depth 1: a slide starts here → 2
<Bullet>…</Bullet> deeper tags just go down and back up
</BulletsSlide> closing brings us back to depth 1: slide ends → 1
</deck> → 0Splicing fixes in
replaceSlides(xml, fixes) cuts out each broken slide and puts its fix in. It works from the last slide backwards: replacing slide 2 with something longer would shift where slide 3 starts, but replacing slide 3 first leaves slide 2's position unchanged.
In the pipeline
This lesson updates pipeline.js with repairSlides: it sends each broken slide wrapped as <fix slide="3">…</fix> with its own problems, and reads the fixes back from tags in the same shape. Deck-level problems (slide 0, like unreadable XML) still need the whole deck, so those use the old repairDeck.
Under the hood — Why not ask for JSON with the fixed slides?
Slide XML inside JSON strings needs every quote escaped, which models get wrong and which costs extra tokens. Tags like <fix> keep the XML exactly as it will be used, and the reply is easy to scan with one regular expression.
Your project so far
22 files · 2 new or changed in this lesson
src/pipeline.js
// The whole generator, rebuilt on LangChain chat models:
//
// (optional) research → outline → write the deck → compile → repair what's broken
//
// It replaces generate.js. The steps are the same; what changed is that each
// step receives a `model`, so we can swap in a different model, or a fake one in tests.
//
// Every model call goes through `call()`: a timeout per attempt, retries with
// backoff for errors worth retrying, and a fallback model when one is unhealthy.
import { HumanMessage, SystemMessage } from "@langchain/core/messages";
import { contentText, createModel } from "./llm.js";
import { generateOutline } from "./outline.js";
import { researchTopic } from "./research.js";
import { buildSystemPrompt, extractTag } from "./prompt.js";
import { LIBRARY } from "./library.js";
import { compileDeck } from "./compile.js";
import { withRetry, withTimeout } from "./retry.js";
import { createModelPool } from "./fallback.js";
import { replaceSlides, splitSlides } from "./deck-edit.js";
const MAX_REPAIR_ROUNDS = 2;
const CALL_TIMEOUT_MS = 90_000; // one model call
const DECK_DEADLINE_MS = 240_000; // the whole deck, inside a typical 300s serverless limit
/** The models to use, best first. The second is lighter and usually less busy. */
export function defaultModels() {
return [createModel(), createModel({ model: "gemini-flash-lite-latest" })];
}
/** Outline → SlideML, following the component library. */
export async function writeDeck(model, outline, { signal } = {}) {
const reply = await model.invoke(
[new SystemMessage(buildSystemPrompt(LIBRARY)), new HumanMessage(`Write the deck for this outline:\n${JSON.stringify(outline, null, 2)}`)],
{ signal },
);
const xml = extractTag(contentText(reply), "deck");
if (!xml) throw new Error("The model didn't reply with a <deck> element.");
return xml;
}
/**
* Send only the broken slides back, each with its own problems, and ask for fixed
* versions of just those. Returns a Map of slide number → fixed XML.
*/
export async function repairSlides(model, xml, problems, { signal } = {}) {
const slides = splitSlides(xml);
const numbers = [...new Set(problems.map((problem) => problem.slide))].filter((number) => slides[number - 1]);
const request = numbers
.map((number) => {
const list = problems.filter((problem) => problem.slide === number).map((problem) => `- ${problem.message}`).join("\n");
return `<fix slide="${number}">\n${slides[number - 1]}\n</fix>\nProblems:\n${list}`;
})
.join("\n\n");
const reply = await model.invoke(
[
new SystemMessage(buildSystemPrompt(LIBRARY)),
new HumanMessage(
"These slides from a deck have problems. Fix each one. Reply with one " +
'<fix slide="N">…the corrected slide…</fix> per slide, and nothing else.\n\n' +
request,
),
],
{ signal },
);
const fixes = new Map();
for (const match of contentText(reply).matchAll(/<fix slide="(\d+)">([\s\S]*?)<\/fix>/g)) {
fixes.set(Number(match[1]), match[2].trim());
}
return fixes;
}
/** Send the deck back with the compiler's problems and ask for a corrected deck. */
export async function repairDeck(model, xml, problems, { signal } = {}) {
const list = problems.map((problem) => `- Slide ${problem.slide}: ${problem.message}`).join("\n");
const reply = await model.invoke(
[
new SystemMessage(buildSystemPrompt(LIBRARY)),
new HumanMessage(`This deck has problems:\n${list}\n\nFix them and reply with the whole corrected <deck>.\n\n${xml}`),
],
{ signal },
);
return extractTag(contentText(reply), "deck");
}
/**
* options:
* models chat models, best first (default: defaultModels()); or `model` for just one
* research true to search the web first
* slideCount how many slides to plan (default 6)
* theme "light" or "dark"
* compile the compiler to use (tests pass a fake one)
* onEvent called with { type: "retry" | "fallback", … } so a UI can show what's happening
* wait how retries wait (tests pass a fake one)
*/
export async function generateDeck(topic, options = {}) {
const { research = false, slideCount = 6, theme, compile = compileDeck, onEvent = () => {}, wait } = options;
const models = options.model ? [options.model] : (options.models ?? defaultModels());
const pool = createModelPool(models, { onFallback: (info) => onEvent({ type: "fallback", ...info }) });
const deadline = Date.now() + DECK_DEADLINE_MS;
// fallback ⟶ retries ⟶ timeout ⟶ the actual call
const call = (stage, fn) =>
pool.run((model) =>
withRetry(() => withTimeout((signal) => fn(model, signal), CALL_TIMEOUT_MS), {
retries: 2,
deadline,
wait,
onRetry: (info) => onEvent({ type: "retry", stage, ...info }),
}),
);
const findings = research ? await call("research", (model, signal) => researchTopic(model, topic, { signal })) : { notes: "", sources: [] };
const outline = await call("outline", (model, signal) => generateOutline(model, topic, { slideCount, research: findings.notes, signal }));
let xml = await call("deck", (model, signal) => writeDeck(model, outline, { signal }));
let result = compile(xml, theme);
let repairs = 0;
while (result.problems.length > 0 && repairs < MAX_REPAIR_ROUNDS) {
repairs += 1;
// Deck-level problems (slide 0, like unreadable XML) need the whole deck; otherwise fix only the broken slides.
const wholeDeck = result.problems.some((problem) => problem.slide === 0);
const fixed = wholeDeck
? await call("repair", (model, signal) => repairDeck(model, xml, result.problems, { signal }))
: replaceSlides(xml, await call("repair", (model, signal) => repairSlides(model, xml, result.problems, { signal })));
if (!fixed) break;
const next = compile(fixed, theme);
if (next.problems.length >= result.problems.length) break;
xml = fixed;
result = next;
}
return { outline, xml, slides: result.slides, problems: result.problems, repairs, sources: findings.sources };
}Key takeaways
- Output tokens are the expensive and slow ones: don't ask the model to rewrite what's already right.
- Find each slide's start and end in the text, so a fix can replace exactly that slide.
- Replace from the last slide backwards, so earlier positions stay valid.
Sign in to run the exercise
Reading is free. Writing code here needs an account so we have somewhere to keep your Gemini key and the +40 XP you are about to earn.