---
url: https://spellingcreator.org/docs/developers/web-app/ai-lesson-fixes.md
---

# AI lesson fixes

For how to use this, see [AI lesson fixes](../../guide/ai-lesson-fixes.md).

Some things the [lesson checks](./lesson-checks.md) find can be fixed by a
script (see [quick fixes](./lesson-checks.md#quick-fixes)). Most can't, so the
Check panel offers **Fix with AI**, which asks the Worker for a fix, checks it,
and previews it before anything changes.

## Which findings

`AI_FIX_CODES` in `packages/core/src/lessonAiFixes.js` lists them. They are the
findings about one section whose fix is a change to that section's text,
questions or spelling words:

* answers not in the passage, or in it when they shouldn't be
  (`E_GROUNDING_*`, `E_ORANGE_PARAPHRASED`, `E_BACKGROUND_IN_TEXT`)
* orange questions that give their answers away, aren't a list, have the wrong
  number of answers, multi-word answers or no blank (`E_ORANGE_ANSWER_IN_PROMPT`,
  `E_ORANGE_NOT_A_LIST`, `W_ORANGE_ANSWER_COUNT`, `W_ORANGE_MULTIWORD`,
  `W_ORANGE_NO_BLANK`)
* prompts that give another question's answer away (`E_ANSWER_REVEALED_CROSS`,
  `W_ANSWER_REVEALED_OPEN`)
* spelling words that are the wrong length, repeated, hidden in an answer or
  already vocabulary (`E_SPELLING_LENGTH`, `E_SPELLING_DUPLICATE`,
  `E_SPELLING_COLLISION`, `W_SPELLING_IN_CAPS`)
* answers or numbers used twice (`E_ANSWER_WORD_REUSED`, `E_NUMBER_DUPLICATE`)
* question wording (`E_RETIRED_STEM`, `W_WYR_SHAPE`, `W_OPEN_SPLIT`,
  `W_NUMBER_NO_STEPS`)

Left out are the codes with a quick fix (`W_ORANGE_ORDER` and
`E_ORANGE_PARTIAL_LIST` among them), the shape of the lesson or a section
(`W_SECTION_COUNT`, `W_SPELLING_COUNT`, `W_QUESTION_SHAPE`, `W_NO_QUESTION`;
adding six sections is not a fix), and `E_UNKNOWN_SOURCE`. `hasAiFix(finding)`
is what the panel asks.

## How a fix is made

1. The editor sends the lesson and the finding's key to the Worker with
   `mode: "fix"`.
2. The Worker finds the finding again, shows the model the section
   (`fixContext`), and turns the model's edits into operations
   (`aiEditsToOperations`).
3. `checkFix` decides whether the fix passes. If it doesn't, the model is told
   why and tries once more.
4. The Worker answers with `replace_block` operations, the explanation and a
   count of new warnings.
5. The editor runs `checkFix` again on the lesson as it is now, shows the
   preview, and applies the fix on **Apply**.

The editor sends the whole lesson (`suggestFix()` in `aiSuggest.js`), since the
checks are lesson-wide, and the `key` of the finding. Image blocks go as their
id and type only, since the checks never read an image and an old lesson can
hold one inline as a data URL. The Worker runs the checks itself and looks the
finding up by its key (`findFinding`) rather than trusting a description of
it, so the prompt carries the checker's own `message`: prose already written
for a model, naming the fix. A finding that is no longer there is refused with
a 409, and one that isn't in `AI_FIX_CODES` with a 400.

Only the request's shape and size are checked before Turnstile. The finding
lookup runs the full lesson checks, so it happens behind the verification and
the rate limiter, where an unverified request can't make the Worker spend that
CPU for free. The lookup's one pass is reused as the "before" side of every
`checkFix` below, so a request validates the unchanged lesson exactly once. The
price is that a stale finding costs its requester a token like any other
checked request.

The model sees the finding's section block by block, in the same input shape the
[MCP server](../mcp-server/tools.md) takes: text blocks as markup (so formatting
and `^[...]` footnotes survive), every list as plain strings. Images and VAKT
activities are shown but marked as not editable. It is also given the spelling
words and answers used in other sections, so a replacement doesn't collide with
one, and a short summary of the question types. The rules it is held to: fix
this one problem, change as little as possible, and never change a fact, number,
date or name.

It answers with `FIX_SCHEMA` (`apps/api/src/lib/lessonFix.js`): an explanation
and a list of edits, one per block, where an empty field means "keep it".
`aiEditsToOperations` turns each edit into a `replace_block` operation for
`@spelling-creator/core/lessonPatch`, merging it with the block as it was. An
edit that names a block outside the section, or one that may not change, is
refused.

`checkFix` then makes the operations on a copy and runs the checks before and
after. A fix passes only if:

* the finding's key is gone,
* no new error appeared (`newFindings`, the same filter `patch_lesson` uses), and
* no text block lost a footnote.

One allowance: a formatting finding's key is the formatted words themselves, so
a fix that rewords a passage re-keys a formatting defect that predates it. A
formatting finding whose code already fired in the same section is treated as
pre-existing rather than new. Formatting only; for every other code a changed
key means the fix changed the thing the check is about (a new answer, say), and
the fix answers for it.

If the first try fails, the model is shown its edits and what was wrong with
them, and asked once more (`FIX_ATTEMPTS`, 2 in all). If that fails too, the
Worker answers 422 and the dialog shows its message.

The editor runs `checkFix` again on the lesson as it is when the fix arrives,
and once more when Apply is pressed (`applyAiFix` in `EditorPage.jsx`), since
the author may have edited the lesson in the meantime. Operations address
blocks by id, so a fix still lands on the right block after other edits, and is
only refused when it no longer passes.

`applyFixOperations` keeps everything the fix didn't touch as the very same
objects, keeps the ids of list items whose text didn't change (so the editor's
fields stay put), and stores a changed text block as a document, the way the
editor does (see [Rich text](./rich-text.md)). An applied fix goes through the
same path as a quick fix, so the lesson is committed as a version first and the
panel's Undo works the same way.

## Cost and limits

A fix is one Turnstile check and one rate-limit token, like the other
[AI helpers](./ai-suggestions.md), and one or two model calls. Nothing is
cached: asking again should give a different fix. A request is refused with a
413 if the lesson is over 300,000 characters as JSON (`MAX_FIX_DOC_CHARS`).

## Where the code is

| File                                                   | Does                                                                                              |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
| `packages/core/src/lessonAiFixes.js`                   | `AI_FIX_CODES`, `fixContext`, `aiEditsToOperations`, `applyFixOperations`, `checkFix`. Shared.    |
| `apps/api/src/lib/lessonFix.js`                        | The prompt, `FIX_SCHEMA`, finding the finding again, and the two tries.                           |
| `apps/api/src/routes/ai.js`                            | The `fix` mode: input checks, Turnstile, rate limit.                                              |
| `packages/core/src/aiSuggest.js`                       | `suggestFix()`, the browser's call to the Worker.                                                 |
| `apps/web/src/components/editor/AiFixDialog.jsx`       | The dialog, the preview and the word diff (`diff`'s `diffWordsWithSpace`).                        |
| `apps/web/src/components/editor/LessonChecksSheet.jsx` | The **Fix with AI** button, shown only when the instance has an API and a Turnstile key.          |
| `apps/web/src/pages/EditorPage.jsx`                    | `applyAiFix`, which checks the fix once more and applies it through the same path as a quick fix. |
