Skip to content

Plan 76 R6

github-actions[bot] edited this page Oct 14, 2026 · 1 revision

R6 — Question types and scoring (v0.13.0)

The deck draws question types the product does not have. Since the fourth export (2026-10-04) the Student page draws True / False / Not given, matching, a multiple choice capped at two picks and a word limit on the take-test screen (DG-60, DG-194). The import screen offers True / False / Not given. Since the 2026-10-04 export the builder draws the authoring side of Matching and an "Include “Not given”" switch on True / False (DG-113); R4 leaves both out. Grading scores essays on a band with rubric tiles. DG-60 names one canonical set and DG-61 an interim rubric model. After R6 the set is:

  • single choice
  • multiple choice, optionally "choose exactly N"
  • true / false
  • true / false / not given
  • fill in the blank
  • short answer, with an optional word limit
  • essay, scored on a band
  • matching
  • cloze groups (question groups; the engine shows a run of gap-bound members as one stop, T-D4.10)

true_false stays. Production holds it and the deck's question editor draws it, although DG-60 leaves it out.

The work runs through every high-risk area in AGENTS: questions, test_versions, the student projection, attempts, marking, grading, the engine and the importer. It is therefore one vertical track, ordered schema → contract → bank → snapshot → projection → marking → grading → result, with the screens after. Every server task on the track runs the five canaries on its branch point and on its last commit:

  • server/internal/modules/tests/application/tests/publish_snapshot_test.go
  • web/tests/units/media/audio-player.test.tsx
  • server/internal/modules/attempts/application/tests/events_test.go
  • web/tests/units/api/client.refresh.test.ts
  • web/tests/integration/router-chunks.test.ts

Every change is additive: new enum values, nullable columns and new tables. R6 therefore has no contract step in R7. The release needs R4 (question skill, frozen on test_version_questions) and R3's engine, with its unknown-type block. Branch work/redesign-r6 is cut from develop.

Deliverable: every type in the canonical set is authored in the editor, the builder and question groups. Each is frozen at publish, delivered through a student-safe projection, auto-marked or hand-graded (bands and rubric criteria for essays), shown on the result with band, rubric, part and skill tiles, and mapped by the Word import where the deck says so.

Exit criteria:

  • publish_snapshot_test.go and the other four canaries pass untouched.
  • A new live spec takes a paper of every type from authoring to result.
  • E2E 9 passes, extended to that paper and to the result-only keys.
  • The migrations pass goose up/down/up and are rehearsed on a Neon branch.
  • The engine's new types and the band result match the deck at the five widths.
  • The release ships to production as v0.13.0.

Decisions taken in this file, worth a second look.

  • True / false / not given is its own type, stored as three fixed options and answered like a single choice. tfng rows hold three question_options in fixed order (True, False, Not given) with exactly one correct. Students answer with the existing choice shape, and marking reuses the choice rule. The engine labels the options through t() by position, so vi shows Vietnamese whatever text is stored. Options are never shuffled. No new answer shape is needed, and an engine that predates R6 would at worst show the reload block. The deck's builder (2026-10-04) draws it as the switch "Include “Not given”" on a True / False question, while its import review lists the type on its own. Default: the stored type stays tfng, and the switch is how the question editor chooses between true_false and tfng (DG-113).

  • "Choose exactly N" is multiple_choice with selectCount, marked per correct pick. IELTS counts each letter of a "Choose TWO" item, so the question scores points × correct picks ÷ N. More than N picks scores 0; the engine prevents that, because the (N+1)th pick replaces the oldest. A plain multiple choice keeps its all-or-nothing rule, so existing questions do not change.

  • The word limit is advice, never truncation. maxWords applies to short answers. The engine counts words live and flags "too many". The server stores whatever was typed, because autosave must never lose text, and the grader sees the over-limit badge and the deck's "Word limit exceeded" snippet. The penalty is the teacher's call.

  • An essay's band is converted to points, and the band is stored beside them. Essays score 3–9 in half steps, as the approved plan says. The deck's grading mock uses a nine-value set from 3 to 8, and the design team is asked to redraw it.

    • manual_score = round(band × points ÷ 9, 2). An essay's default is 9 points, so points equal the band.
    • final_score, finishGrading and every points total are unchanged.
    • AttemptScore.band is the essays' points-weighted mean band, rounded to 0.5, and appears only when every scored question on the paper is a graded essay. A Writing Task 2 worth twice the points counts twice.
  • Rubric criteria are defined per question (DG-61, interim). A question has up to six criteria, frozen at publish. When criteria exist, the band is their mean rounded half up to 0.5: floor(mean × 2 + 0.5) ÷ 2, so 6.25 → 6.5 and 6.75 → 7.0. The editor offers an "IELTS Writing" preset of four localised labels. Without criteria, the teacher sets the band directly.

  • Band keys are the digit for a whole band and Shift+digit for the half above. Thirteen values do not fit the deck's position keys 1…n. Digits 3–9 by event.code set that band, and Shift adds 0.5. Each button shows its hint. Points questions keep position keys.

  • Matching reuses question_options as its choice list and adds items. The choices are options, keyed with roman numerals as the builder draws them; the items carry letters. None of the options is marked correct. Each item is a row keyed to one choice by a composite FK (question_id, correct_option_id), so a key cannot point into another question. The FK is NO ACTION: it is checked at the end of the statement, so a purge that cascades to options and items together passes, where RESTRICT would depend on the cascade order. Choices keep their order, because authors refer to their keys. Marking is per item, like fill-in. The duplicate path copies items, and R7's copy-on-write reuses that path. The deck's builder (2026-10-04) stops both lists at ten. Default: the editor takes its layout and keeps this file's bounds, 12 options and 20 items, until the design team confirms a limit (DG-113).

  • The student projection is written out in full (T-R6.5) and holds only rules and labels. selectCount and maxWords are rules, like caseSensitive. Match items carry no key, and the rubric is not sent before submission. Correct matches, bands and criteria appear only on the result, behind the review policy.

  • Result tiles follow one rule, all behind showScore. The first match wins:

    • a single essay with criteria shows its criteria
    • otherwise two or more parts show part tiles (R3)
    • otherwise two or more skills show skill tiles, from R4's frozen skill
    • otherwise there are no tiles

    The deck's Result variant 2 ("Unit 3 · Grammar check") draws topic tiles (Present perfect 4/5, Past simple 4/5) on a one-part, one-skill paper. Topics have no model, so R6 does not build that variant: the paper shows no tiles (open item, and a DG entry through T-R6.15).

    When a paper's only hand-graded answer has a comment, the result shows it in the deck's comment callout, as the deck's Writing Task 1 does. Otherwise comments stay on their items.

  • True / false marking accepts the answer the engine actually sends. The engine's Choice renderer (take-test/components/QuestionBody.tsx) answers true / false with {type: "choice", optionIds: [id]}. gradeTrueFalse in attempts/domain/grading.go once read only {value}; it now reads optionIds too (grading.go:100-110), so the fix is made and R6's grader keeps both shapes.

  • The Word import maps TFNG, "Choose N" and word limits, and nothing else. Matching stays "Matching (not supported)", as the deck's import screen draws it. Essays are not recognised (open item).


T-R6.1a — Migrations: types, select count, word limit, essay sample answers

Depends on: — Touches: migrations/NNNNN_add_question_types.sql, migrations/NNNNN_add_question_select_count.sql, migrations/NNNNN_add_question_max_words.sql, migrations/NNNNN_allow_essay_sample_answer.sql (all new), server/internal/platform/db/tests/{constraints,migrate}_test.go, docs/plan/20-data-model.md Size: M Done when:

  • add_question_types runs ALTER TYPE app.question_type ADD VALUE IF NOT EXISTS for 'tfng', 'matching' and 'essay', so the second up of up/down/up finds them present. - No statement in the file uses the new values: a value added in a transaction cannot be used before it commits, so every CHECK that names them is in a later file. - Down raises if any questions or test_version_questions row uses a new value, and otherwise leaves the three values in place. PostgreSQL cannot drop an enum value, and rebuilding the type fails: every CHECK that compares type (00011's questions_sample_answer_only_short_answer, and 00034's questions_check and test_version_questions_check with type <> 'fill_blank') is re-parsed during ALTER COLUMN … TYPE and fails with operator does not exist. An unused value is harmless, so this is still a working Down. It is a §12 deviation in 20-data-model.md.
  • add_question_select_count adds select_count smallint to questions and test_version_questions, with CHECK (select_count IS NULL OR (type = 'multiple_choice' AND select_count BETWEEN 2 AND 7)) added NOT VALID and validated in the same file. Seven is the 8-option maximum (DG-63) minus one. Down drops both columns.
  • add_question_max_words adds max_words smallint to both tables in the same pattern, with CHECK (max_words IS NULL OR (type = 'short_answer' AND max_words BETWEEN 1 AND 100)).
  • allow_essay_sample_answer replaces questions_sample_answer_only_short_answer with questions_sample_answer_only_written (sample_answer IS NULL OR type IN ('short_answer', 'essay')), added NOT VALID and then validated. Down refuses while an essay has a sample answer, and otherwise restores the original.
  • constraints_test.go shows each CHECK firing: a select_count on a single choice, max_words 0, and a sample answer on a matching question. A question inserted with the v0.12.0 column list still inserts, which is the old binary's way.
  • goose up/down/up passes in CI.
  • 20-data-model.md gains the R6 section for these columns, explaining why the values are enum values rather than text and why populated tables get NOT VALID plus VALIDATE. The §12 deviation entries go with it, including the enum Down that leaves the new values in place.

T-R6.1b — Migrations: rubric, matching and band grades

Depends on: T-R6.1a Touches: migrations/NNNNN_create_question_rubric_criteria.sql, migrations/NNNNN_index_question_options_identity.sql, migrations/NNNNN_create_question_match_items.sql, migrations/NNNNN_add_attempt_answer_bands.sql (all new), server/internal/platform/db/tests/{constraints,privileges}_test.go, docs/plan/20-data-model.md Size: M Done when:

  • create_question_rubric_criteria creates two tables. Down drops both. - app.question_rubric_criteria (id uuid PK DEFAULT uuidv7(), question_id → questions ON DELETE CASCADE, ordinal smallint ≥ 0, label text 1..80), with UNIQUE (question_id, ordinal) DEFERRABLE INITIALLY IMMEDIATE, the D-13 reorder pattern. - app.test_version_rubric_criteria, the same shape against test_version_questions, with REVOKE UPDATE … FROM quizzivy_app for frozen rows, as 00039 does.
  • index_question_options_identity is marked -- +goose NO TRANSACTION. - It runs CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS question_options_question_id_id_key ON app.question_options (question_id, id) and the same on app.test_version_options (test_version_question_id, id). - Both tables are populated, hence CONCURRENTLY. The existing unique key cannot serve the FK, because an FK cannot reference a deferrable constraint. A non-partial unique index can serve it directly, so the index is never attached as a constraint. - Down is DROP INDEX CONCURRENTLY IF EXISTS for both. - An interrupted build is handled by the v0.6.0 release runbook: drop the INVALID index and re-run.
  • create_question_match_items: - Its FKs reference (question_id, id) and (test_version_question_id, id), backed by the plain unique indexes from index_question_options_identity. Nothing is attached with ADD CONSTRAINT … UNIQUE USING INDEX: that hands the index to the constraint, and dropping the constraint would drop the index. - It creates app.question_match_items (id, question_id → questions ON DELETE CASCADE, ordinal, text 1..500, correct_option_id) with FOREIGN KEY (question_id, correct_option_id) REFERENCES app.question_options (question_id, id) ON DELETE NO ACTION, UNIQUE (question_id, ordinal) DEFERRABLE INITIALLY IMMEDIATE, and an index on (question_id, correct_option_id). - It creates app.test_version_match_items against the frozen tables, with REVOKE UPDATE. - It adds match_allow_reuse boolean to both question tables with CHECK ((type = 'matching') = (match_allow_reuse IS NOT NULL)), NOT VALID then validated. - Down drops the tables and the columns. The indexes stay for the index file's Down.
  • add_attempt_answer_bands: - It adds attempt_answers.band numeric(3,1) with CHECK (band IS NULL OR (band BETWEEN 3 AND 9 AND band * 2 = trunc(band * 2))) and CHECK (band IS NULL OR manual_score IS NOT NULL), both NOT VALID then validated. - It creates app.attempt_answer_criteria (attempt_id, question_id, criterion_id → test_version_rubric_criteria RESTRICT, band numeric(3,1) NOT NULL, PRIMARY KEY (attempt_id, question_id, criterion_id), FOREIGN KEY (attempt_id, question_id) → attempt_answers ON DELETE CASCADE), with the same step CHECK on band and a (criterion_id) index. - The table is not append-only: regrading replaces rows. A grade write is not audited in R4 (last write wins, ledger T-11); whether regrading keeps a history in the audit log is an open decision for the user (spec §13.4), so this task records only what it builds. - Down refuses while any band exists.
  • constraints_test.go covers each CHECK and FK: an item keyed to another question's option fails, a band of 6.25 fails, and a band without manual_score fails.
  • privileges_test.go asserts that the app role cannot UPDATE the two frozen tables.
  • goose up/down/up passes. The migrations are rehearsed on a Neon branch of production, and the CONCURRENTLY and VALIDATE timings go in the PR.
  • 20-data-model.md gains these sections and their deviations: matching reuses options, and bands are stored beside points.

T-R6.2 — Contract for the new types

Depends on: — Touches: api/openapi.yaml, server/gen/openapi/, web/src/lib/api/schema.d.ts, web/tests/support/{fixtures,handlers}.ts, the exhaustive type maps and switches in web/src/features/{question-bank,tests,question-groups,imports,take-test,attempts,results}/ (a mechanical commit), web/src/lib/i18n/locales/{vi,en}.json, web/tests/units/contract/openapi-contract.test.ts Size: M Done when:

  • QuestionType and QuestionTypeFacets gain tfng, matching and essay.

  • Teacher shapes gain: - selectCount, maxWords and matchAllowReuse - matchItems[] {id, ordinal, text, correctOptionId} - rubric[] {id, ordinal, label}

    These shapes are `AdminQuestion`, the create and update bodies, and the grading and
    review rows. `sampleAnswer` is documented for short answer and essay.
    
  • Student shapes follow T-R6.5's table exactly: - StudentQuestion gains selectCount, maxWords, matchAllowReuse and matchItems[] {id, text} - Answer gains MatchingAnswer {type: matching, pairs: {itemId: optionId}} - ResultQuestion gains correctMatches (only with showCorrectAnswers), band and criteria[] {id, label, band} (only with showScore), and skill - AttemptScore gains band

  • Grade items for gradeAttempt and the by-question grade: - points becomes optional; band is 3–9, multipleOf: 0.5; criteria[] is {criterionId, band} - the description states the handler rule the schema cannot express: an essay takes a band or complete criteria, anything else takes points - it lists the 422 codes - x-permission stays teaching.grading

  • ImportDraftQuestion.type gains tfng, and the draft gains selectCount and maxWords.

  • No operation is added, and no x-permission changes.

  • make gen is its own commit. A second, mechanical commit adds the new members to every exhaustive switch and label map, with vi and en labels. Until T-R6.10 the engine routes the new types to R3's reload block, and pnpm typecheck passes in this PR.

  • openapi-contract.test.ts: - every schema reachable from /app/* still lacks the four banned keys - StudentMatchItem has no correctOptionId - the session schemas carry none of correctOptionIds, correctAnswers, correctMatches, band or criteria

    MSW fixtures validate with ajv.
    

T-R6.3 — Question bank: validation and storage for every type

Depends on: T-R6.1a, T-R6.1b, T-R6.2 Touches: server/internal/modules/questions/domain/{question,input,content,manager}.go, server/internal/modules/questions/repositories/{store,option_content,question_content,facets}.go, server/internal/modules/questions/application/command/{create,update,duplicate}.go, server/internal/modules/questions/http/{input,questions_handlers}.go, server/internal/modules/questions/domain/tests/{validate,cardinality}_test.go, server/internal/modules/questions/application/tests/{crud,children}_test.go Size: L Done when:

  • The domain accepts each type with its own fields and rejects every field that belongs to another type. Errors use the existing field-error format. - tfng: three options in fixed order, whose texts the server sets to True, False and Not given; exactly one is correct. - multiple_choice with selectCount N: 2 ≤ N ≤ options − 1, with exactly N correct. Without selectCount, nothing changes. - short_answer: maxWords is 1–100, or absent. - essay: no options or blanks. Up to six rubric criteria with distinct labels, 1–80 characters, compared case-insensitively. A sample answer is allowed. - matching: - 2–12 choices; matching-headings lists run to about ten - choices are options, none marked correct - 2–20 items of 1–500 characters, each keyed to a choice of this question - matchAllowReuse is required; without reuse, items ≤ choices and the keys are distinct
  • The store writes and reads match items and rubric criteria in the question's transaction. It deletes orphaned items before the options they reference, because of the NO ACTION FK. Reorders use the deferred ordinal constraints.
  • Duplicating a question copies its match items, re-keyed to the copied options, and its criteria. R7's copy-on-write reuses this path.
  • Facets count the new types.
  • Group members accept every new type. LockForDraftUse is unchanged, and TestLockForDraftUseSerialisesAgainstDelete passes.
  • Each rule has a unit test. Integration tests cover create, update, reorder and duplicate for each type.

T-R6.4 — Publish, draft from version, duplicate and preview

Depends on: T-R6.3 Touches: server/internal/modules/tests/repositories/{publish_snapshot,publish_load,publish_publisher,version_draft,duplicate,duplicate_graph,preview}.go, server/internal/modules/tests/domain/{publish_validation,preview,diff}.go (diff.go from R4), server/internal/modules/tests/application/tests/publish_question_types_test.go (new) Size: L Done when:

  • publish_snapshot_test.go passes, untouched, before the first commit and after the last.
  • Publishing freezes select_count, max_words, match_allow_reuse, match items (keyed to the frozen options) and rubric criteria. publish_question_types_test.go edits every new field of the bank question after publishing and shows that the version is unchanged.
  • Publish validation refuses three cases, in the builder's existing problem format: - a "Choose N" whose number of correct options differs from N - a matching question with an unkeyed item - a TFNG without exactly one correct option
  • "Edit from this version" and test duplication copy the new fields and tables back into drafts.
  • R4's domain/diff.go compares the new fields (DG-66): - a changed selectCount, maxWords, match item text, choice text or rubric label is a changed - a changed match key or correct option is an answer - reordering alone is still not a change - its unit tests gain a case for each
  • The teacher preview returns the student projection from T-R6.5.
  • LockForVersionUse is unchanged, and TestLockForVersionUseSerialisesAgainstDelete passes.
  • New versions are group_v1. PresentVersion still refuses a section_v1 snapshot that carries a fixed-order question (an existing guard).

T-R6.5 — Student projection, dealing and autosave

Depends on: T-R6.4 Touches: server/internal/modules/attempts/domain/{paper,deal}.go, server/internal/modules/attempts/repositories/{store,autosave}.go, server/internal/modules/attempts/http/attempts.go, server/internal/modules/attempts/application/tests/payload_test.go, server/internal/modules/attempts/domain/tests/{shuffle,delivery}_test.go Size: M

The projection. "Session" means POST /app/assignments/{id}/attempts and GET /app/attempts/{id}; "result" means GET /app/attempts/{id}/result.

Type Session adds Result adds, gated Never under /app/*
every type nothing beyond today's fields today's fields and gates isCorrect, sampleAnswer, acceptedAnswers; transcript on the session
tfng options[] {id, text} in fixed order correctOptionIds (showCorrectAnswers) a per-option key
multiple_choice with N selectCount correctOptionIds (showCorrectAnswers) —
short_answer with a limit maxWords — sampleAnswer
essay nothing band, criteria[] {id, label, band} (showScore, once graded) sampleAnswer; the rubric before submission
matching options[] {id, key, text} in fixed order, matchItems[] {id, key, text}, matchAllowReuse correctMatches[] {itemId, optionId} (showCorrectAnswers) an item's correctOptionId

Done when:

  • The paper types in attempts/domain/paper.go gain only the session fields above. payload_test.go reflects over them and fails on any field whose name contains Correct, Accepted, Sample, Transcript or Rubric.
  • tfng and matching are dealt with FixedOptionOrder, set in the question load beside the group-bound case. Items keep their authored order, and the within-section shuffle is unchanged: shuffle_test.go and delivery_test.go pass.
  • Autosave keeps only answers that belong to the question: - pairs keys must be the question's items, and values its options, the same check optionIds gets today - a choice answer with more picks than selectCount is stored, so nothing typed is lost, and marked 0 (T-R6.6)
  • The projection carries each matching item's and option's key as authored (key beside id and text), so the engine can draw the keys the teacher wrote (DG-194). payload_test.go still fails on any field whose name contains Correct, Accepted, Sample, Transcript or Rubric: a key is a label, never an answer.
  • requires_manual is true for short_answer and essay.
  • events_test.go passes untouched.

T-R6.6 — Auto-marking

Depends on: T-R6.5 Touches: server/internal/modules/attempts/domain/grading.go, server/internal/modules/attempts/repositories/submit.go, server/internal/modules/assignments/repositories/student.go (the answered count from T-R3.4), server/internal/modules/attempts/domain/tests/grading_autograde_test.go, server/internal/modules/assignments/repositories/tests/student_test.go, web/tests/units/take-test/answered.test.ts Size: M Done when:

  • tfng scores full points for the one correct option, otherwise 0.

  • "Choose N" scores points × correct picks ÷ N, rounded to hundredths. More than N picks scores 0. A plain multiple_choice keeps all-or-nothing.

  • matching scores points × correctly paired items ÷ items, rounded to hundredths, as fill-in does.

  • essay requires manual grading. short_answer is unchanged.

  • true_false accepts both the choice answer the engine sends (one option id, checked against that option's key) and {type: true_false, value}, with a test for each shape. Past attempts are an open item.

  • The answered count on the student card counts a new question as answered when: - "Choose N": exactly N picks - matching: every item is paired - essay: the text is non-blank

    The shared case list gains these cases on both sides.
    
  • Table tests cover every type with empty, wrong, partial, right and malformed answers.


T-R6.7 — Manual grading with bands and rubrics

Depends on: T-R6.6 Touches: server/internal/modules/attempts/domain/{grading,review}.go, server/internal/modules/attempts/repositories/{review_grade,review_byquestion,review_review}.go, server/internal/modules/attempts/application/command/grade.go, the grading handlers in server/internal/modules/attempts/http/, server/internal/modules/attempts/application/tests/{review_grade,review_byquestion}_test.go Size: M Done when:

  • An essay item takes either band, when its question has no criteria, or criteria covering every criterion. - With criteria, the band is floor(mean × 2 + 0.5) ÷ 2 (DG-61 interim). - manual_score = round(band × points ÷ 9, 2). - The band and the criteria rows are written in the grade's transaction.
  • Anything else is refused with a 422: GRADE_NEEDS_BAND, GRADE_CRITERIA_INCOMPLETE or GRADE_NEEDS_POINTS.
  • Items for other types keep points ≤ the question's points, as today.
  • The grade's audit entry records the band and criteria in its diff.
  • finishGrading still sums final_score. AttemptScore.band is computed as the decisions section says, wherever AttemptScore is built.
  • Teacher review and by-question rows carry the band, criteria, rubric and maxWords. The web counts words, and the server does not.
  • Tests cover: - band arithmetic at the half boundaries: 6.25 → 6.5, 6.75 → 7.0, 6.125 → 6.0 - incomplete criteria - a re-grade that replaces criteria - the points-weighted mean across two essays - a mixed paper, which has no band

T-R6.8 — Result projection

Depends on: T-R6.7 Touches: server/internal/modules/attempts/repositories/result.go, server/internal/modules/attempts/application/query/result.go, server/internal/modules/attempts/http/result.go, server/internal/modules/assignments/repositories/student.go, R5's data-export writer, server/internal/modules/attempts/application/tests/result_test.go, R4's result.ready producer test (T-R4.10b) Size: M Done when:

  • Gates on the result: - correctMatches appears only with showCorrectAnswers - band and criteria appear only with showScore, once graded - skill comes from R4's frozen column under every policy

    A gated-off field is absent, not null, as the endpoint does today.
    
  • Home cards carry score.band under the same showScore rule.

  • No notification stores the band. result.ready keeps carrying only the attempt id and the title (T-R4.10b), and the web reads the band from the result query, which applies the policy in force when it is read. R4's test that no result.ready row holds a score also asserts that none holds a band.

  • R5's data export writes band and the criteria for essays, formula-escaped like every other cell.

  • result_test.go tests every new field under each policy combination. When the score is withheld, no band appears anywhere in the body.


T-R6.9a — Authoring: the question editor

Depends on: T-R6.3 Touches: web/src/features/question-bank/components/{QuestionEditor,OptionsEditor,OptionField}.tsx, web/src/features/question-bank/components/{MatchingEditor,RubricEditor}.tsx (new), web/src/features/question-bank/questionSchema.ts, locales, web/tests/units/question-bank/{editor,structure-validation}.test.ts(x) Size: L Done when:

  • The type control offers Single choice, Multiple choice, True / False, Fill in the blank, Short answer, Essay and Matching. True / False / Not given is not a separate entry: it is the True / False block with "Include “Not given”" on, as the builder draws it (DG-113). The builder's 2026-10-04 drawing gives the authoring side of that switch and of Matching. "Choose N", the word limit, the essay and its rubric are still undrawn (DG-60), so they are extrapolated from deck primitives and each new panel is listed for sign-off. The blocks extend R4's answer areas (74-r4.md T-R4.66), which the builder and the bank's Question editor share.
  • Multiple choice has "Students choose exactly [N]", off by default. When on, N is 2–7 and the hint reads "tick N correct answers".
  • True / False gains the switch "Include “Not given”", with the hint "· for IELTS-style True / False / Not given". On, the question is stored as tfng and a third card, "Not given", joins "True" and "False"; off, it is true_false. Turning it off while "Not given" is the answer sets the answer to False, as drawn. The cards are labelled through t().
  • Short answer has an optional word limit of 1–100.
  • Essay has: - default points of 9 - the note "Graded by you on a 3–9 band" - a sample answer - rubric criteria: add, rename, reorder and remove, up to six, with the "IELTS Writing" preset
  • Matching follows the builder's drawing, two columns from 1100px of page width and one below: - "Items · what students match, with the correct option": rows with a letter, the text, a select of the options ("Choose…", then i, ii, iii …) and "Remove item"; "Add item"; 2–20 items. An item with no option chosen has the danger border - "Options · the list students choose from": rows with a roman numeral (i to xii; the deck's list stops at x), the text, the tag "Extra" on an option no item uses (its title is the deck's), and "Remove option"; "Add option"; 2–12 options. Removing an option unsets the items that used it - the toasts name this file's bounds, "Up to 20 items" and "Up to 12 options", not the deck's ten and ten (DG-113) - the switch "Each choice can be used more than once", which the deck does not draw, listed for sign-off - the grading note "Marked automatically when the student submits. Each item scores its share of the points."
  • Validation messages follow the deck's order and style. The zod schema keeps its Expect<Equal<…>> assertion against the generated request type.
  • Switching type keeps what still applies and drops the rest. Loading, error and dirty states are unchanged. The PR lists the vi and en keys.

T-R6.9b — Authoring: builder, groups and the bank list

Depends on: T-R6.9a Touches: web/src/features/tests/pages/teacher/TestBuilderPage.tsx, web/src/features/tests/components/{OutlineTree,OutlineGroupRow,StudentPreview,QuestionPickerDialog}.tsx, web/src/features/tests/publishProblem.ts, web/src/features/question-groups/{model.ts,components/GroupComposer.tsx}, web/src/features/question-bank/pages/teacher/QuestionBankPage.tsx, locales, web/tests/units/tests/outline.test.ts, web/tests/integration/builder-question-switch.test.tsx, web/tests/units/builder/autosave-unmount.test.tsx Size: M Done when:

  • The builder's type menu and outline icons cover the whole set. The menu gains "Matching" (arrow-left-right) and "Essay" beside R4's five entries, whose icons are the deck's (74-r4.md T-R4.31b); an icon the deck does not give comes from lib/icons.ts. The outline warns "No correct answer set" for an unkeyed matching question or "Choose N".
  • publishProblem.ts has builder messages for T-R6.4's publish refusals.
  • The group composer offers the new types as members.
  • The bank's type filter and counts include the new types.
  • The preview-mode engine (T-D4.16) renders the new types.
  • autosave-unmount.test.tsx passes. Autosave still flushes on unmount and before publish.

T-R6.10 — Engine: the new types

Depends on: T-R6.5, R3's engine Touches: web/src/features/take-test/components/QuestionBody.tsx, web/src/features/take-test/components/{MatchingBody,EssayBody}.tsx (new), web/src/features/take-test/{answered,draft}.ts, web/src/lib/words.ts (new), locales, web/tests/units/take-test/{renderers,answered,draft,draft-compat,keyboard}.test.ts(x), web/tests/units/lib/words.test.ts (new) Size: L Done when:

  • The five canaries and the take-test, integrity and media suites pass before and after, with both runs in the PR.
  • TFNG shows its three options in fixed order, labelled from t() by position, with the type label "True / False / Not given".
  • "Choose N": - the type label stays "Multiple choice"; the hint line reads "Choose two answers.", with the number in words in both locales; one pick of two is "Partly" in the rail and the sheet (progress, T-D4.4) - square markers - the (N+1)th pick replaces the oldest; optionIds keeps pick order, so the oldest is still known after a reload
  • Word limit: - "2 of 3 words" under the input, then "· too many" in danger past the limit - typing is never blocked - lib/words.ts counts whitespace-separated tokens, counting a hyphenated word or a number once, and the grading screen uses the same function
  • Essay: a textarea that starts at twelve lines and grows, a live word count, paste blocked when the policy blocks it, and the same autosave as a short answer.
  • Matching: - one card per item (1.5px border, primary once matched, radius 11) with its key, its text and a radiogroup of chips, one per option key (38 by 38px from 1024, 44px below it, radius 9); a chip used by another item is at 50% opacity, choosing it moves it, and tapping the chosen chip clears it; above the cards "{k} of {n} matched"; when the options have text, the question has no shared content and the engine is at least 1000 wide, a 220px card lists the options once, each with the item that uses it; keys are shown as authored (DG-194); a matching is one question, one number and one square, "Partly" until every item is matched - without reuse, a choice used on another item shows where, and choosing it moves it - keyboard-operable, with 44px targets on phones
  • answered.ts follows T-R6.6's rules.
  • The draft schema: - gains matching - parses entry by entry, so one unreadable entry is dropped rather than clearing the whole draft - still restores a v0.9.0-format draft (draft-compat.test.ts)
  • Larger text applies to the new types. The reload block now covers only types this build does not know.
  • TFNG, the two-pick multiple choice, the word counter and matching are compared with the deck's papers s1, s2 and s9. The essay frame is listed for DG-80 and DG-60.
  • progress() (T-D4.4) follows T-R6.6's rules for the new types; the Submit dialog's sentence becomes the deck's "Unanswered gaps, matches and questions score zero." once matching exists.
  • The AI's Generate offers "True / False / Not given" and its Check applies "Change to Not given" and "Add word limit" once the types exist (DG-202).

T-R6.11 — Grading: score sets, bands and rubrics

Depends on: T-R6.7 Touches: web/src/features/attempts/pages/teacher/{GradingPage,AttemptReviewPage}.tsx (R4), web/src/features/attempts/components/{GradingCard,AnswerReview,AttemptSheet}.tsx, web/src/features/attempts/components/BandScale.tsx (new), the Students tab of web/src/features/assignments/pages/teacher/AssignmentDetailPage.tsx (R4), locales, web/tests/units/attempts/{grading,papers}.test.tsx, web/tests/units/assignments/detail.test.tsx Size: M Done when:

  • Points questions keep R4's score buttons (74-r4.md): 0 to the maximum in 0.5 steps with position keys when that is nine values or fewer, a number field otherwise. Nothing changes for them.
  • Essays replace those buttons with a 3–9 half-step scale. With criteria there is one scale per criterion, and the derived band updates live.
  • Keys apply to the focused scale: a digit 3–9 sets that band, and Shift+digit sets the half above. Each button shows its key hint.
  • A short answer over its limit shows the badge "Over the word limit (5 of 3)", from lib/words.ts, next to the deck's "Word limit exceeded" snippet.
  • Review shows: - TFNG with its labels - "Choose N" with the picks against the key - matching with the pairs against the key - essays with the text, word count, band and criteria
  • Rosters and the attempt sheet show "6.0 band" where score.band is present.
  • 422 grade errors render as field messages. "Save & next" stays disabled until every criterion is set.
  • D4's "Suggest a score" takes the band form: the four criteria as reasons and the band as the score (DG-202).

T-R6.12 — Result: band, rubric, part and skill tiles

Depends on: T-R6.8, T-R6.10 Touches: web/src/features/results/pages/ResultPage.tsx, web/src/features/results/components/{ScoreRing,ResultTiles,ReviewItem}.tsx, web/src/features/assignments/components/RecentResults.tsx, locales, web/tests/units/results/result.test.tsx, web/tests/units/student/home.test.tsx Size: M Done when:

  • With score.band, the ring reads "6.0 · band" and the sentence names the grader. Recent results on Home show the band.
  • Tiles follow the rule in the decisions section. A single essay with criteria shows its criteria bands, as the deck's Task achievement, Coherence, Vocabulary and Grammar tiles do. Pending answers keep R3's summary tiles.
  • The deck's Result variant 2 topic tiles are not built: a one-part, one-skill paper shows no tiles, and the PR lists the variant as a known difference from the deck.
  • When the paper's only hand-graded answer has a comment, the comment callout shows it. Otherwise comments stay on their items.
  • Each new type has a review item, and correctness shows only with showCorrectAnswers.
  • Compared with the deck's "Hand-graded band" variant at the five widths, light and dark.

T-R6.13 — Word import: type mapping

Depends on: T-R6.3 Touches: server/internal/modules/imports/domain/recognition/{tasks,build,exam,keys}.go, server/internal/modules/imports/domain/{draft,drafts}.go, server/internal/modules/imports/application/command/commit.go, server/internal/modules/imports/domain/recognition/tests/, web/src/features/imports/components/review/QuestionEditor.tsx, web/src/features/imports/draft.ts, locales, web/tests/units/imports/{draft,review-workspace}.test.ts(x) Size: L Done when:

  • A section whose instruction names TRUE / FALSE / NOT GIVEN produces tfng questions. The instruction may also read T / F / NG, or the Vietnamese "đúng / sai / không có thông tin". - The key comes from the answer key's TRUE / FALSE / NOT GIVEN or T / F / NG. - A key outside those three is a finding. - Pasted text maps the same way (R4's text source, 74-r4.md T-R4.55 and T-R4.56): a part whose instruction reads "True, False or Not given", with "Answer: Not given" under a question, gives a tfng question and its key, where R4 leaves a short answer for the teacher to fix. If the mapping moves a count for any pasted text, R4's shared fixture api/fixtures/pasted-text-counts.json gains the case and the browser's quick count changes in the same PR.
  • "Choose TWO letters" on a multiple choice produces selectCount, with the number as a word (ONE–FIVE) or a digit. A key with a different number of letters is a finding.
  • "NO MORE THAN n WORDS" and "ONE WORD ONLY" on a short answer produce maxWords. On a fill-in, they stay instruction text.
  • Matching stays "Matching (not supported)" with the deck's exclude finding. Essays are not recognised (open item).
  • The review editor's type select gains "True / False / Not given", with "Choose N" and word-limit fields. The draft JSON gains them additively.
  • Commit writes the new fields. Recognition tests cover each form of instruction, using fixtures written for the test; the real corpus stays out of the repo.

T-R6.14 — Seed and live E2E

Depends on: T-R6.10 … T-R6.13 Touches: seed/NN-dev-question-types.sql (new, development only, numbered at merge), seed/99-assert.sql, seed/README.md, web/tests/e2e/{author-and-publish,payload-leak}.live.spec.ts, web/tests/e2e/question-types.live.spec.ts (new), web/tests/e2e/support/live.ts Size: M Done when:

  • The dev seed gains a published paper in two parts with one question of every type, a reading-passage group, and an essay with four criteria. Seed data never goes in a migration. - Seeds load in lexical order, so the file takes the next free number at merge, one number per file in merge order, as every release from R2 does. It follows the earlier seeds whose teachers, classes and students it uses (at least R2's access and classroom seeds), and its header names them. - 99-assert.sql also refuses a frozen row that breaks a T-R6.4 publish rule, since a seed that writes version rows directly skips publish validation. README.md lists the file.

  • E2E 1 authors one question of every type through the editor and publishes the test.

  • question-types.live.spec.ts runs a paper from start to result: - a student answers every type, with "Choose two" replacing its oldest pick and a word limit exceeded, then submits - the auto-marks match T-R6.6 - a teacher grades the essay by criteria - the result shows the band and the tiles

    It is a live spec because marking and the projection are server behaviour meeting the
    engine.
    
  • E2E 9 runs against the every-type paper: - the session payload carries none of the four banned keys, nor correctOptionIds, correctAnswers, correctMatches, correctOptionId, band or criteria - a second case reads the result with showCorrectAnswers and showScore off and finds none of them

  • pnpm e2e:live passes E2E 1, 2 and 5–9 and the new spec.


T-R6.15 — Docs

Depends on: T-R6.14 Touches: docs/quizzivy-spec-v0.3.md (§7, §13, §15; version bumped per §18), docs/plan/20-data-model.md, AGENTS.md, docs/design/gaps.md Size: S Done when:

  • Spec §7 defines each type with its answer shape, marking rule and the band rule. §13 lists the new tables and columns, and §15 the contract. The "Availability: R6" markers go, and the version is bumped.
  • 20-data-model.md is complete for R6, with its deviations.
  • AGENTS.md's student-payload rule adds that the session payload never carries the result-only keys (correct*, band, criteria). No other rule changes.
  • gaps.md notes, under DG-60 and DG-61, what was built in the meantime.
  • gaps.md holds a DG entry for each design request this release makes, added here unless it is already there: - the essay grading scale: the deck draws 3–8 in nine values, and R6 builds 3–9 in half steps - "Topic tiles have no model": Result variant 2's topic tiles, with the proposed interim rule from the open items

Release checklist

  • Every task is ticked on work/redesign-r6. develop is merged into it, and verification is re-run after that merge.
  • Every CI step runs locally, in order, judged by exit code (70 §8.1): - make lint, make gen-check - make test-api (after docker compose up -d db minio), and goose up/down/up - pnpm lint, pnpm typecheck, pnpm format:check - pnpm test:unit, pnpm test:integration, pnpm build - pnpm e2e, pnpm e2e:content - pnpm e2e:live, required because take-test and imports changed - node scripts/check-design-deck.mjs
  • The five canaries pass, and their files are untouched against develop.
  • Security: the isolation suite passes, E2E 9 passes as extended, and no public operation was added.
  • All eight migrations are rehearsed on a Neon branch of production, including the enum additions, the two CONCURRENTLY indexes and each VALIDATE. Timings go in the release PR.
  • The deploy window is outside exam windows (query production assignments) and outside import hours. The worker and the API run the same binary, so an import in review during the switch is read by one version or the other, never both.
  • Deploy and smoke: - Thuong's go, then release/0.13.0 → main - deploy the API (the release command migrates), then Pages - smoke /healthz, /livez, and sign-in as each role - in the owner's test class, a teacher publishes a paper with one question of every type, a student takes it, and the teacher grades the essay - back-merge to develop, and tag v0.13.0
  • Release notes, vi first: - the new types - how essay bands turn into points - rubric criteria - "Choose N" marking per correct pick - word limits advise and never cut text - old tabs are asked to reload
  • T-R6.15's docs are in the release.

Open items

  • DG-60, DG-113. Since the 2026-10-04 export the builder draws the authoring side of Matching and of "Not given". "Choose N", the word limit, the essay and its rubric are still undrawn; their panels ship extrapolated and need the design team's sign-off. The drawing and this file disagree on two points, and the defaults are this file's: tfng stays its own stored type behind the drawn switch, and matching keeps 12 options and 20 items where the deck stops at ten of each. Thuong or the design team confirms.
  • Images and audio inside a prompt or an explanation (DG-109). The 2026-10-04 editor draws them, the contract has no place for them, and R4 leaves them out (74-r4.md, open items). Default: R6 builds them on this file's track (contract, bank, snapshot, projection, engine, result), and this file gains the tasks when Thuong confirms.
  • Matching on the Student page (DG-194). Three key styles and the nouns "pick the {pick} for each {what}" have no model. Default: keys as authored and no nouns.
  • A per-question hint line and an image caption (DG-189) have no field. Default: not built.
  • DG-61. The rubric model (criteria per question, band = mean rounded to 0.5) is interim.
  • The deck's essay score set is 3–8 in nine values; R6 builds 3–9 in half steps. A DG entry in gaps.md (T-R6.15) asks the design team to redraw the grading scale.
  • Topic tiles have no model. The deck's Result variant 2 draws topic tiles on a one-part, one-skill grammar paper, and nothing in the data model defines a topic. The DG entry (T-R6.15) proposes an interim rule: tiles from question tags when a paper has one part and one skill. Thuong or the design team decides. Default: not built in R6; such a paper shows no tiles.
  • YES / NO / NOT GIVEN, IELTS's opinion variant of TFNG, is not built. It would need a label set on tfng. Default: teachers use TFNG.
  • Essays in the Word import. Writing prompts ("Write at least 150 words") are not recognised as essays. Default: the teacher changes the type in review.

Generated from docs/plan/76-r6.md. Edit that file in the repository; changes made on this page are overwritten.

Home

Start here

Product

Implementation plan

Redesign (Phase R)

Setup and operations

Design

Team

Code READMEs

Clone this wiki locally