Edit a scheme by describing the change (#22) #130

Merged
claude-bot merged 1 commit from feat/22-refine into main 2026-09-05 00:50:27 +00:00
Contributor

Closes #22.

A box on the scheme editor that takes "make it warmer and slower" and returns an edited scheme — reusing the existing provider abstraction, response validator, one-shot retry and daily cap, exactly as the issue anticipated.

Two decisions carry the feature

It proposes; it never writes

The endpoint returns a before and an after, and accepting one loads it into the editor's own controls. The user then saves with the ordinary Save — so an AI edit travels exactly the path a manual edit does: promotion to source: user (#99), staleness cleared, audit entry written.

Persisting directly would have let an AI edit of an approved scheme reach the lights with nobody seeing it, which inverts the review model the rest of the project is built on. It's also why refining an approved scheme is allowed at all — generation refuses to touch one, but a proposal that writes nothing is safe, and it's the case a user most wants.

A failed refinement raises rather than falling back

Generation falls back to a built-in template when the model fails, and that's right: generation has nothing to lose, and a template scheme beats no scheme.

Refinement starts from the user's own work. The same fallback would replace what they were editing with a generic template — losing work rather than declining to change it. So RefinementFailedError is a distinct exception, surfacing as a 502 whose message says the scheme is unchanged. And it is.

Smaller things that took thought

A segment the model forgets keeps its current values. "Make it warmer" is not an instruction to delete a segment, and an omission is far likelier to be the model forgetting than the user asking. The on flag is likewise carried from the current scheme rather than defaulted to true, which would quietly switch a deliberately-off segment back on.

The refine prompt deliberately omits generation's few-shot examples. Those are whole festive schemes; showing them during an edit invites the model to replace the user's scheme with something that resembles an example rather than adjusting what it was handed. It does share the effect catalogue, the response schema and the retry convention.

The instruction is length-bounded and rejected when blank. The daily cap counts requests, not tokens — so an unbounded instruction makes one pasted novel a single request against the cap and a very large bill. And whitespace satisfies min_length while having nothing to act on, which would spend a cap slot on a question nobody asked. My own test caught the second case: I'd written it expecting a rejection and got a 503 from the provider path instead, which meant a stray space was reaching the model.

A failed call still counts against the cap. Charging only for successes would make a broken provider an unlimited retry loop.

Tests

27 new backend tests, provider-mocked, covering the happy path and invalid responses as the issue asks. The ones that matter most assert what must not happen:

  • test_a_provider_failure_raises_rather_than_falling_back
  • test_the_scheme_survives_a_failed_refinement
  • test_it_writes_nothing
  • test_a_segment_the_model_forgot_keeps_its_current_values

test_the_current_scheme_is_in_the_prompt parses the scheme back out of the prompt and compares it, rather than substring-matching — the point is that the model receives the scheme faithfully, and a formatting assertion would pass on a prompt carrying the wrong one.

test_the_proposal_can_be_applied_with_put goes end to end: refine, send proposal straight back to PUT /schemes/{id}, confirm the colours land and the source promotes to user.

Verification

Backend ruff clean, mypy clean, 749 tests passing. Frontend in node:22: tsc -b clean, eslint clean.

🤖 Generated with Claude Code

Closes #22. A box on the scheme editor that takes "make it warmer and slower" and returns an edited scheme — reusing the existing provider abstraction, response validator, one-shot retry and daily cap, exactly as the issue anticipated. ## Two decisions carry the feature ### It proposes; it never writes The endpoint returns a **before** and an **after**, and accepting one loads it into the editor's own controls. The user then saves with the ordinary **Save** — so an AI edit travels exactly the path a manual edit does: promotion to `source: user` (#99), staleness cleared, audit entry written. Persisting directly would have let an AI edit of an **approved** scheme reach the lights with nobody seeing it, which inverts the review model the rest of the project is built on. It's also *why* refining an approved scheme is allowed at all — generation refuses to touch one, but a proposal that writes nothing is safe, and it's the case a user most wants. ### A failed refinement raises rather than falling back Generation falls back to a built-in template when the model fails, and that's right: generation has nothing to lose, and a template scheme beats no scheme. Refinement starts from the user's own work. The same fallback would **replace what they were editing with a generic template** — losing work rather than declining to change it. So `RefinementFailedError` is a distinct exception, surfacing as a 502 whose message says the scheme is unchanged. And it is. ## Smaller things that took thought **A segment the model forgets keeps its current values.** "Make it warmer" is not an instruction to delete a segment, and an omission is far likelier to be the model forgetting than the user asking. The `on` flag is likewise carried from the current scheme rather than defaulted to `true`, which would quietly switch a deliberately-off segment back on. **The refine prompt deliberately omits generation's few-shot examples.** Those are whole festive schemes; showing them during an *edit* invites the model to replace the user's scheme with something that resembles an example rather than adjusting what it was handed. It does share the effect catalogue, the response schema and the retry convention. **The instruction is length-bounded and rejected when blank.** The daily cap counts *requests*, not tokens — so an unbounded instruction makes one pasted novel a single request against the cap and a very large bill. And whitespace satisfies `min_length` while having nothing to act on, which would spend a cap slot on a question nobody asked. **My own test caught the second case**: I'd written it expecting a rejection and got a 503 from the provider path instead, which meant a stray space was reaching the model. **A failed call still counts against the cap.** Charging only for successes would make a broken provider an unlimited retry loop. ## Tests 27 new backend tests, provider-mocked, covering the happy path and invalid responses as the issue asks. The ones that matter most assert what must *not* happen: - `test_a_provider_failure_raises_rather_than_falling_back` - `test_the_scheme_survives_a_failed_refinement` - `test_it_writes_nothing` - `test_a_segment_the_model_forgot_keeps_its_current_values` `test_the_current_scheme_is_in_the_prompt` parses the scheme back out of the prompt and compares it, rather than substring-matching — the point is that the model receives the scheme *faithfully*, and a formatting assertion would pass on a prompt carrying the wrong one. `test_the_proposal_can_be_applied_with_put` goes end to end: refine, send `proposal` straight back to `PUT /schemes/{id}`, confirm the colours land and the source promotes to `user`. ## Verification Backend `ruff` clean, `mypy` clean, **749 tests passing**. Frontend in `node:22`: `tsc -b` clean, `eslint` clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Edit a scheme by describing the change (#22)
All checks were successful
CI / Dockerfile lint (pull_request) Successful in 7s
CI / Alembic migration check (pull_request) Successful in 38s
CI / Pre-commit hooks (pull_request) Successful in 49s
CI / Python lint & type-check (pull_request) Successful in 1m6s
CI / Frontend lint, test & build (pull_request) Successful in 1m12s
CI / Python tests (pull_request) Successful in 3m36s
CI / Docker build, health smoke & E2E (pull_request) Successful in 1m53s
079587a891
A box on the scheme editor that takes "make it warmer and slower" and
returns an edited scheme, reusing the existing provider abstraction,
response validator, one-shot retry and daily cap.

Two decisions carry the feature.

It proposes; it never writes. The endpoint returns a before and an after,
and accepting one loads it into the editor's own controls, so the user
saves it with the ordinary Save. That keeps an AI edit on exactly the
path a manual edit takes -- promotion to source: user (#99), staleness
cleared, audit entry written. Persisting directly would have let an AI
edit of an approved scheme reach the lights with nobody seeing it, which
inverts the review model the rest of the project is built on. It is also
why refining an approved scheme is allowed at all.

A failed refinement raises rather than falling back. Generation falls
back to a built-in template when the model fails, and that is right --
generation has nothing to lose. Refinement starts from the user's own
work, so the same fallback would replace what they were editing with a
generic template: losing work rather than declining to change it. The
failure is a 502 whose message says the scheme is unchanged, and it is.

Smaller things that took thought:

A segment the model forgets to mention keeps its current values instead
of vanishing. "Make it warmer" is not an instruction to delete a segment,
and an omission is far likelier to be the model forgetting than the user
asking. The `on` flag is likewise carried from the current scheme rather
than defaulted to true, which would switch a deliberately-off segment
back on.

The refine prompt deliberately does not share the few-shot examples with
generation. Those are whole festive schemes, and showing them during an
edit invites the model to replace the user's scheme with something that
resembles an example rather than adjusting what it was handed.

The instruction is length-bounded and rejected when blank. The daily cap
counts requests, not tokens, so an unbounded instruction makes one pasted
novel a single request against the cap and a very large bill; and
whitespace satisfies min_length while having nothing to act on, which
would spend a slot on a question nobody asked. My own test caught the
second case.

A failed call still counts against the cap: charging only for successes
would make a broken provider an unlimited retry loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
claude-bot deleted branch feat/22-refine 2026-09-05 00:50:27 +00:00
Sign in to join this conversation.
No description provided.