Skip to content

feat(rag): agent harness for quiz generation (plan → generate → critique → repair) - #309

Open
longobucco wants to merge 12 commits into
masterfrom
feat/quiz-agent-harness
Open

longobucco wants to merge 12 commits into
masterfrom
feat/quiz-agent-harness

Conversation

@longobucco

Copy link
Copy Markdown
Contributor

What changed and why

Quiz generation previously made a single schema-validated `/generate_json` call: it guaranteed
well-formed questions, not good ones (guessable distractors, general-knowledge questions,
multiple defensible answers, coverage clustered on one passage, answers not actually grounded in
the cited chunk).

This replaces that single call with a bounded four-stage loop in a new
`app/services/quiz_harness.py`:

  1. Plan — one call selects which passages to test and at what cognitive level (recall /
    application / analysis), referring to passages by position rather than by chunk id, so an
    invented or out-of-range reference is corrected deterministically rather than trusted.
  2. Generate — one call per planned target, pinned to that target's single source chunk.
  3. Critique — one call per generated question, checked against five explicit criteria: the
    answer is entailed by the source, every distractor is plausible but demonstrably wrong, exactly
    one option is defensible, the question isn't answerable from general knowledge, and it doesn't
    duplicate another question in the set. Fails closed (treated as a rejection, not a pass) if the
    critic itself is unavailable.
  4. Repair — exactly one regenerate-and-recritique attempt for a question that fails critique;
    still-failing questions are dropped and reported, never silently discarded.

Both existing callers (course-builder per-lesson quizzes, study-page ad-hoc quizzes) now flow
through this harness via `quiz_generation.generate_quiz_questions`, which stays a thin
delegating wrapper with its public signature unchanged.

Call budget: bounded by construction — at most `4 × target_count + 1` calls (worst case 17
for the default `target_count = 4`), enforced by the loop's structure rather than a mid-loop
check. No background job: generation stays synchronous, same request shape as before.

Prerequisite fix (Task 1): the study-page caller's `context_items` never carried a resolvable
chunk id (only `doc_id`/`label`) — switched it to read from `result.evidence_pack.chunks`,
which does, using data it already had in hand.

Trace: `generate_quiz_questions` now always returns `{"questions": [...], "trace": {...}}`
(never `None`), with the trace (targets planned, generated, rejected with reasons, repaired,
final count, calls used/budget) logged whenever generation produces nothing, and threaded onto the
`POST /courses/{course_id}/quizzes/generate` response as an additive `trace` field.

Persistence: `persist_quiz` now sets `Question.source_ref` from each question's `chunk_id`
(the column already existed, unused) — no migration needed.

`docs/specifications.md` §24 updated to describe the new behavior.

Issue

Refs #134. Not closing it yet — the issue's acceptance criteria include a manual review of 20
generated questions against real course material on a live QVAC service, which this PR's
automated tests cannot satisfy. That review is the one remaining step before #134 can close.

How it was verified

  • Full backend suite: `cd services/ai && uv run --no-sync pytest` — 1120 passed, 85.54% coverage
    (ratchet 76%).
  • `uv run --no-sync mypy app` — clean, 88 source files.
  • Built via Subagent-Driven Development: a fresh implementer + fresh task reviewer per task (7
    tasks), then an independent whole-branch review on the most capable model, one consolidated fix
    wave for its 6 Important findings, and a scoped re-review confirming all 6 addressed with no new
    breakage.
  • Design doc and implementation plan committed under `docs/superpowers/` for anyone who wants the
    full reasoning trail (call-budget rationale, rejected alternatives, what was explicitly left to
    the builder).

Things reviewers should watch for

  • Duplicate-repair risk: the course-builder path caps context at 3 chunks
    (`MAX_CONTEXT_CHUNKS`) while the harness always plans 4 targets, so at least one chunk gets
    reused — a single-source lesson could see duplicate→repair→duplicate cycles that burn the call
    budget without the repair fixing anything, since repair feedback for a duplicate rejection is
    just the word "duplicate" (the model isn't shown what it duplicated). Not fixed here pending
    real-LLM behavior data; flagging for the post-merge manual review to actually observe.
  • Rate limit unchanged: `/quizzes/generate` is still `10/minute`, which now permits up to
    170 LLM calls/minute from one user instead of 10. No frontend caller exists for this endpoint
    today, so not urgent, but worth sizing once one does.
  • No frontend changes in this PR — the `trace` field is response-only; a debug-page panel to
    visualize it is left to a follow-up issue.
  • This is a large PR (11 commits) for one coherent, explicitly-planned feature (the full harness);
    happy to discuss splitting further if that's preferred for review, but the stages are tightly
    interdependent (plan output feeds generate, generate output feeds critique, all three feed the
    repair loop) so there wasn't a natural seam that produces several independently-useful PRs.

longobucco added 11 commits November 5, 2026 21:16
The study-page caller built context_items from result.citations, which
has no chunk id anywhere on it. Switch it to result.evidence_pack.chunks,
which does, and guard on an empty/missing evidence_pack instead of an
empty context_items list. The course-builder caller already had the
chunk id under label; add it explicitly as chunk_id for uniformity.

Both callers now emit {label, text, chunk_id} entries, which the
upcoming quiz agent harness (Task 2+) needs to plan questions against
specific source chunks.

Refs #134
Adds app/services/quiz_harness.py with _plan_targets, the first stage of
the plan -> generate -> critique -> repair loop that will replace the
single schema-validated quiz generation call. The model selects source
passages by 1-based position (matching generate-json.js's [N] convention)
rather than by chunk_id text, so an invented or out-of-range reference is
corrected deterministically by round-robin assignment instead of trusted.
_plan_targets always returns exactly target_count targets.

Registers quiz_plan in qvac_router.LADDER_ROUTING as an explicit
server-routed task_type, alongside judge/content_gen.

Refs #134
Add _generate_one, which produces one question per planned target via a
single generate_json call pinned to that target's own chunk, so the model
cannot pull material from elsewhere in the evidence pack. Reuses the
content_gen task type since it is the same model as today's quiz
generation. StructuredGenerationError is caught and turned into None (the
caller treats this as "needs repair or must be dropped"); LlmDisabledError
propagates to abort the run, matching the plan stage's contract.

Refs #134
Add _critique_one, judging a generated question against the five
quality criteria from issue #134 (entailed, distractors_plausible,
single_defensible, requires_course_material, duplicate). Fails closed
on LlmDisabledError/StructuredGenerationError, mirroring
lesson_service._judge_groundedness's pattern — an unavailable critic
is treated as a failed question, never a silent pass.

Register quiz_critique in qvac_router.LADDER_ROUTING alongside the
other quiz harness task types.

Refs #134
… harness

Wire _plan_targets, _generate_one, and _critique_one into run(), the
plan -> generate -> critique -> repair orchestrator that Task 7 will
call from quiz_generation.generate_quiz_questions. Each target gets at
most one generate+critique round plus exactly one repair round if the
first critique fails, bounding the call budget at 4 * target_count + 1
(trace["calls_budget"]) and recorded exactly in trace["calls_used"].

A None generation result (unresolvable chunk_id, schema error, or the
LLM going down mid-run) is dropped immediately rather than retried,
since repeating the same target against the same context would almost
certainly fail identically; this also keeps a failed repair generate
from spending an extra, pointless critique call.

LlmDisabledError/StructuredGenerationError at the plan stage end the
run immediately with no questions produced rather than propagating,
since nothing has been generated yet to return instead.

Refs #134
Wires the harness's new plan/critique/generate prompts into the central
registry so get_prompt()/list_prompts() can inspect them. The quiz_gen
entry now points to quiz_harness.GENERATE_PROMPT_VERSION and
_GENERATE_SYSTEM instead of the quiz_generation equivalents that will
be deleted once Task 7 rewires their only remaining caller.

Refs #134
…PI response

generate_quiz_questions now delegates to quiz_harness.run (plan -> generate
-> critique -> repair) instead of calling generate_json directly, and the
POST /courses/{course_id}/quizzes/generate response carries the harness's
trace alongside the existing quiz fields. Deleted the now-dead
QUIZ_PROMPT_VERSION/QUIZ_SCHEMA/QUIZ_SYSTEM constants, superseded by the
harness's own generate-stage prompt (Task 6 already repointed the
"quiz_gen" registry entry to it).

Also updated two test sites outside the brief's file list
(tests/unit/test_lesson_service.py, tests/integration/test_lesson_review_api.py)
that patched the now-removed app.services.quiz_generation.generate_json
attribute for the course-builder's lesson-processing flow; they now patch
quiz_harness.run with an equivalent {"questions": [...]} shaped return
value.

Refs #134
Fix six Important findings from the whole-branch review before the harness
PR goes up:

1. generate_quiz_questions never collapses an empty-questions harness result
   to None anymore — it always returns the full {questions, trace} dict and
   logs the trace when empty, so the 422/quiz-step paths that most need to
   see rejection reasons actually get them.
2. trace["rejected"] entries now share one shape everywhere (stage,
   chunk_id, cognitive_level, question_preview, reasons) instead of two
   incompatible ones for plan-stage vs per-target failures.
3. _generate_one and _critique_one now catch httpx.HTTPError the same way
   they already catch StructuredGenerationError/LlmDisabledError, so a
   transient transport error drops one target instead of discarding every
   question already accepted in the run.
4. docs/specifications.md's quiz section now describes the plan/generate/
   critique/repair loop, the five critique criteria, and the bounded call
   cap; two stale comments (one claiming a single /generate_json call, one
   pointing at dead quiz_generation.py prompt constants) are corrected.
5. Added coverage for previously-unexercised error paths: a per-target
   LlmDisabledError raised (not returned) from _generate_one, a repair-stage
   generate returning None, and an end-to-end run() test that mocks
   generate_json directly to prove the real plan/generate/critique functions
   are wired together correctly.
6. persist_quiz now writes Question.source_ref from the question's
   chunk_id, so the chunk_id the harness computed is actually persisted.

Refs #134
Two sentences in the Task 5 section described behavior (re-raising
LlmDisabledError at the plan stage, repairing a None generation result)
that contradicted the plan's own Global Constraints and verbatim test
code. Both were authoring errors, ruled on and corrected during SDD
execution per the ledger at .superpowers/sdd/2026-11-05-quiz-agent-harness/progress.md.

Refs #134

@lucaosti lucaosti left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid work: passage references by index with deterministic correction, a critic that fails closed, a call budget bounded by structure, a uniform trace, source_ref finally filled, and real chunk ids from the study caller. The "things to watch" section is very useful. Harness, quiz generation, quizzes API and lesson service tests pass locally (85).

Two changes before merging, both reproduced with a small probe:

  1. LlmDisabledError is swallowed. _generate_one's docstring says it propagates to abort the run, but run() catches it at the plan, generate and repair steps, and _critique_one catches it too. With the LLM disabled, run() returns 0 questions and no exception. So the study endpoint answers "could not generate" instead of reporting the LLM as disabled, and the except LlmDisabledError: raise around the quiz step in lesson_service no longer fires. Catch only StructuredGenerationError and httpx.HTTPError for the per-question fallbacks and let LlmDisabledError propagate.
  2. The plan does not enforce distinct passages. A plan that returns passage 1 four times gives targets c0, c0, c0, c0. That is the duplicate -> repair -> duplicate cycle you flagged, and it can happen with any context size, not only the 3-chunk lesson cap. Dedupe the indices and fill with unused passages round-robin, reusing a passage only when there are fewer passages than targets. target_count = min(4, len(context_items)) for lessons would also help.

Worth considering (not blocking):

  1. trace is returned to any authenticated user, including rejected question previews and chunk ids, and it is outside the response schema. Gate it to reviewer/admin or a debug flag.
  2. Up to 17 sequential calls in one request, and 10/minute now allows 170 calls a minute per user. Fine while no frontend calls it; lower the limit or move it to a job before one does.
  3. A quiz can now be persisted with a single question (the old schema required 3-4). Worth deciding a minimum.

Nits:

  1. docs/superpowers/ adds about 1,300 lines of generated spec and step-by-step plan in a new docs folder, and code comments point there. Keep the design doc under docs/ with the other specs and drop the implementation plan, which goes stale once merged.
  2. The generate_quiz_questions docstring says the result "is still truthy, so this never changes caller behaviour". It is the other way round: if quiz_data: is now always true, and it is safe only because persist_quiz returns None for an empty list.

LlmDisabledError was caught and swallowed at every stage of the quiz
harness, so a disabled LLM silently produced zero questions instead of
reporting the outage to the caller. Only StructuredGenerationError and
httpx.HTTPError are now caught for per-question fallbacks; LlmDisabledError
propagates from planning, generation, repair, and critique.

The plan stage could also assign the same passage to multiple targets
when the model repeated or omitted a passage_index, triggering the
duplicate -> repair -> duplicate cycle noted in the PR's review-watch
section. _plan_targets now tracks used indices and fills gaps round-robin
over unused passages, reusing one only once all have been assigned.

Moved the design doc out of docs/superpowers/ into docs/ alongside the
other specs, and dropped the now-stale implementation plan, per review.

Refs #134
@longobucco

Copy link
Copy Markdown
Contributor Author

Addressed both blocking items in b850bf2:

  1. LlmDisabledError swallowing — run() and _critique_one now only catch StructuredGenerationError/httpx.HTTPError for per-question fallbacks; LlmDisabledError propagates from planning, generation, repair, and critique, so the existing except LlmDisabledError: raise in lesson_service and the study endpoint's disabled-service handling fire again. Tests updated to assert propagation instead of a swallowed-to-zero-questions result.
  2. Duplicate passages in the plan — _plan_targets now tracks used indices and fills any invalid/missing/repeated passage_index round-robin over unused passages, reusing one only after all have been assigned once. Added a test with a model that returns passage_index: 1 four times straight, confirming distinct chunks are assigned until exhaustion.

Also took nit 6: moved the design doc to docs/quiz-agent-harness-design.md (indexed in docs/overview.md) and dropped the implementation plan.

Left 3–5 as flagged, not blocking:

  • Trace visibility (3) and the rate limit (4) are both called out already in "things reviewers should watch for" — no frontend caller exists yet for either to bite, so deferring rather than guessing at the right gate/limit now.
  • Minimum question count (5): the harness can already report a partial/empty set via trace, and issue Agent harness for quiz generation (plan → generate → critique → repair) #134's manual review will surface whether a 1-question quiz is actually a problem in practice rather than hypothetically.

Didn't take the optional target_count = min(4, len(context_items)) suggestion in (2) — the round-robin dedupe already prevents the duplicate-repair cycle at any passage count, so capping target_count would only reduce quiz length without fixing anything the dedupe doesn't already fix.

Verified: pytest — 1123 passed, 85.48% coverage (ratchet 76%). mypy app — clean, 88 files.

@lucaosti lucaosti left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Luca, both blocking points are fixed and I checked them with probes. With the LLM disabled, run() now raises at both the plan and the generation stage, so lesson_service's re-raise works again. A plan that repeats passage 1 four times now gives distinct targets, and reuses a passage only when there are fewer passages than targets. Moving the design doc to docs/ and dropping the plan is much cleaner. Full suite green (1123).

Small leftovers, none blocking:

  • The docstring in quiz_generation.py (lines 35-36) still has a fragment of the old sentence ("callers that check ... or do callers must inspect ..."). The old clause just needs deleting.
  • The three points from last time are still open: the trace (rejected previews and chunk ids) goes to any logged-in student, the 10/minute limit allows up to 170 LLM calls a minute per user, and a 1-question quiz can be saved. A short reply on what you plan for each is enough, even if it is a follow-up issue before the frontend starts calling the endpoint.
  • Optional: the endpoint turns LlmDisabledError into a generic 500, same as before. A 503 with a clear message would help the study page.

Approving with these nits.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants