Repository navigation
feat(rag): agent harness for quiz generation (plan → generate → critique → repair) - #309
longobucco wants to merge 12 commits into
Conversation
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
left a comment
There was a problem hiding this comment.
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:
LlmDisabledErroris swallowed._generate_one's docstring says it propagates to abort the run, butrun()catches it at the plan, generate and repair steps, and_critique_onecatches 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 theexcept LlmDisabledError: raisearound the quiz step inlesson_serviceno longer fires. Catch onlyStructuredGenerationErrorandhttpx.HTTPErrorfor the per-question fallbacks and letLlmDisabledErrorpropagate.- 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):
traceis 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.- Up to 17 sequential calls in one request, and
10/minutenow 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. - A quiz can now be persisted with a single question (the old schema required 3-4). Worth deciding a minimum.
Nits:
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 underdocs/with the other specs and drop the implementation plan, which goes stale once merged.- The
generate_quiz_questionsdocstring 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 becausepersist_quizreturnsNonefor 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
|
Addressed both blocking items in b850bf2:
Also took nit 6: moved the design doc to Left 3–5 as flagged, not blocking:
Didn't take the optional Verified: |
lucaosti
left a comment
There was a problem hiding this comment.
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
LlmDisabledErrorinto a generic 500, same as before. A 503 with a clear message would help the study page.
Approving with these nits.
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`:
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.
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.
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
(ratchet 76%).
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.
full reasoning trail (call-budget rationale, rejected alternatives, what was explicitly left to
the builder).
Things reviewers should watch for
(`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.
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.
visualize it is left to a follow-up issue.
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.