Plan 74d D4 Deck Update Part 4 - SekiroKenjii/Quizzivy GitHub Wiki

Part 4 of 5 of D4 — Deck update: the fourth export and v2 (v0.11.0) · Previous · Next

G. The AI assistant

Built under Thuong's decisions of 2026-10-10 (D19 to D22 in 70-redesign-overview.md §1): Anthropic's API with cloud processing on; data minimisation per feature; a $200 monthly pool that is never overspent without an Admin's approval; built in D4. Everything below rests on them.

Shape. A new bounded context server/internal/modules/ai/ (domain, application/{command, query,ports,model}, repositories, http), wired in core/wiring/ai.go. It reads through ports typed as other modules' handlers or adapters (a test's draft and default version, a question, a grading item, item analysis and the new wrong-answer aggregate, the caller's own bank rows), and writes nothing a teacher sees: every acceptance in the deck ("Use this", "Use score and comment", "Add {n} to {section}", "Save {n} to the bank", "Save as practice test", "Add tags", "{fix}") is the browser calling an existing write (updateQuestion, createQuestion, the section unit writes, duplicateTest, the bulk tag write, gradeAttempt) with the content the teacher accepted. The module's own writes are its ledger, reservations, requests and settings. So the integrity rule holds by construction: the AI never writes a grade, a question or a comment, and never concludes anything about a student (no feature reads integrity events, focus losses or flags; the insights prompt asks for patterns in answers).

The provider behind a port (D19). platform/anthropic holds the client for the Messages API. The module's port is ports.Completer (Complete(ctx, Request) (Response, error) with the tier, a system prompt, user content blocks of text, images or a PDF document, a JSON schema for the answer, and a max_tokens; the response carries the parsed object and the reported usage). core/adapters/ai.go adapts the platform client and is nil when ANTHROPIC_API_KEY is unset, as adapters/media.go is nil without storage: every AI operation then answers 501 NOT_IMPLEMENTED, GET /public/status says ai: false, the web draws no AI entry point, and the import's "Read with AI" card is absent. The deck's "AI didn’t respond, so this is sample output." is the transient-failure state (a timeout, a 429 after retries, a 5xx, a refusal, a response that fails the schema): the panel shows the warning callout and "Try again", never sample content. The key is read once by platform/config, is never logged, and the client redacts the header from every error it returns.

Dependency. The official Go SDK for the API, with the reason the PR states: typed errors with retry on 429 and 5xx and none on 4xx, streaming for the long generations, the image and document content blocks the import reader needs, and the usage object the metering reads. The alternative is net/http against one endpoint, as platform/google does, which saves the dependency and costs a hand-written retry, error and streaming layer. Either way the client is one file behind the port, and nothing outside platform/anthropic imports it.

Tiers as configuration. AI_TIER_FAST_MODEL and AI_TIER_CAPABLE_MODEL (the capable tier must accept images and PDF pages), and AI_TIER_{FAST,CAPABLE}_PRICE_{INPUT,OUTPUT,CACHE_READ} in USD per million tokens. The server refuses to start when the key is set and a tier or a price is missing. No model identifier is written in any document or committed file; .env.example lists the names with no values.

What each feature sends (D20), its tier, and its cost. One credit is $0.001 of provider list price, so the $200 pool is 200,000 credits, the deck's AI_POOL. A call's credits are ceil((input × p_in + output × p_out + cache_read × p_cache) / $0.001) from the response's usage and the tier's configured prices. Estimates use indicative list prices of about $2 / $10 per million input / output tokens for the capable tier and $0.10 / $0.50 for the fast tier; the product meters the real numbers, and the deck's costs are placeholders.

Feature Sent to the provider Never sent Tier Tokens in / out Credits Ceiling reserved
Generate questions The source text the teacher pastes or picks (a shared-content passage of the test, up to 8,000 characters), types, count, level, the explanation switch, the UI language Anything about students capable 3k / 3.5k ≈ 40 200
Check this test The draft's prompts, options, keys, explanations and shared passages; the chosen checks Student data; assignments capable 12k / 2.5k ≈ 50 300
Make a variant The default version's questions, options and keys; what to change; difficulty Student data capable 10k / 4k ≈ 60 300
Write an explanation Prompt, options, key, type, the language and length asked — capable 0.6k / 0.2k ≈ 3 20
Suggest wrong options Prompt, key, existing options — capable 0.6k / 0.3k ≈ 3 20
Suggest a score The question's prompt, type and max points, the accepted answers or the sample answer, the allowed score set, the student's answer text The student's name, email, id, class; the attempt id; other answers capable 0.8k / 0.3k ≈ 5 30
Explain results Per question: number, type, prompt excerpt, key, the correct rate (T-R4.13), and the distinct wrong answer texts with their counts (a new aggregate; the eight most frequent per question); the handed-in count Any name, id or attempt; who wrote what capable 3k / 1k ≈ 16 60
Clean up the bank The caller's own questions' prompts, types and tags, in chunks (Own() rows only, up to 2,000) Shared questions; usage counts fast 90k / 8k ≈ 13 200
Read with AI The uploaded exam's pages as images (rendered by go-pdfium, up to 40 pages) or the PDF document, and the recogniser's schema Nothing else; the import workspace's Sources pane says so before upload (18-word-import-ux.md §4) capable (vision) 2k / 1.2k a page ≈ 16 a page 60 a page
Generate words (R10) The text, count, level, meaning language — fast 3k / 1.5k ≈ 2 20

No feature sends a personal identifier; none needs one. The two that carry student-written text are gated by the teacher's "Let AI read student answers" switch (Q33). A month in which ten teachers each make 20 generations, 5 checks, 2 variants, 100 score suggestions, 10 insights, 50 explanations and import two 10-page scans costs about 23,000 credits, a tenth of the pool: the pool guards against runaway use (scans, loops, a bulk import), not ordinary use. The default allowance is 20,000 credits ($20) a month (Q30): ten teachers fill the pool exactly.

Rules for every task in this section. The module is a high-risk area (it reads student answers and holds a paid key); every task runs the five canaries and make test-api-integration on its branch point and its last commit, both runs in the PR. No task needs the key to pass CI: every suite covers the nil adapter's 501 and the web's hidden entry points. A task that calls the provider for real does so in a live smoke on the builder's machine and pastes the metered credits into the PR, never the key or a response that holds student text. Prompts live in modules/ai/domain/prompts/ as Go constants with their JSON schemas beside them; a test asserts every prompt's schema parses the deck's demo arrays (the shapes the UI draws).

T-D4.30 — The provider adapter, its configuration and the tiers

Depends on: T-D4.1 (for nothing but the order); v0.10.0 released Touches: server/internal/platform/anthropic/{client.go,usage.go,errors.go} (new), server/internal/platform/config/ai.go (new), server/internal/core/adapters/ai.go (new), server/internal/core/adapters/tests/ai_test.go (new), server/internal/platform/config/tests/ai_test.go (new), server/internal/modules/ai/application/ports/completer.go (new), server/internal/core/wiring/{ai.go (new),wiring.go}, server/go.mod, .env.example, docs/setup/operations.md (the key, its rotation, the tiers), server/internal/platform/anthropic/tests/client_test.go (new, against an httptest server), server/internal/core/tests/architecture_test.go (the new packages' allowed imports) Size: M Done when:

  • platform/config reads ANTHROPIC_API_KEY, AI_TIER_FAST_MODEL, AI_TIER_CAPABLE_MODEL and the six prices; with the key unset the whole block is off and nothing else is read; with the key set a missing tier or price refuses start-up with a sentence that names the variable. The key never appears in a log line, an error or a panic: errors.go wraps every SDK error and redacts the header, and a test asserts the redaction on a 401 body.
  • ports.Completer carries a tier, a system prompt, content blocks (text, image bytes with a media type, a PDF document), a JSON schema and max_tokens, and returns the parsed object with the reported usage (input, output, cache read, cache write) and the provider's request id. The client streams when max_tokens exceeds 4,096, retries 429 and 5xx with back-off three times, never retries a 4xx, treats a refusal stop as a typed ErrRefused, and times out at 60s (120s for a document).
  • core/adapters/ai.go returns nil with the key unset, and Assembly.AI carries it. The module's 501 NOT_IMPLEMENTED with the envelope's code AI_OFF and ai: boolean on GET /public/status (a read of configuration, never of the provider) move to T-D4.32: they need the AI_OFF error code and PublicStatus.ai in the contract, there is no AI operation to answer 501 before that task, and D4 changes the contract exactly twice ("Lanes"). Config.AIEnabled() is the read they will use.
  • architecture_test.go allows platform/anthropic to import the SDK and nothing else to; modules/ai imports no platform package but through its ports.
  • .env.example gains the nine names with no values and a two-line comment; operations.md says how the key is set on Fly, rotated (set the new key, deploy), and that the prices are re-read at start-up when the provider's list price changes.
  • Unit tests against httptest: a happy completion with usage, a 429 then a 200, a 5xx three times then the typed error, a refusal, invalid JSON, a timeout; the nil adapter (its 501 is T-D4.32's, with the first operation).

As built: platform/config/ai.go reads the nine names into Config.AI. The key is a config.Secret, which prints as "[redacted]" through fmt, slog and JSON, so a configuration in a log line, an error or a panic cannot carry it (Reveal() is read once, by the adapter). A price is a plain decimal of at most six decimals between 0.001 and 1000 dollars per million tokens, held as config.MicroUSD (millionths of a dollar), so T-D4.31's credits are integer arithmetic, ceil(sum(tokens x price) / 1e9) with no float to round across a credit; a price per token fails the bounds instead of silently undercounting the pool. A key with whitespace is refused without being echoed. platform/anthropic is client.go, usage.go and errors.go over the official SDK (anthropic-sdk-go v1.75.0, chosen for its typed errors, the SSE stream and accumulator, and the image and document blocks; v1.80.0 was three days old). The client builds the SDK client with WithoutEnvironmentDefaults, so no ANTHROPIC_* variable, profile or federation source can add a credential or redirect the key, and with WithMaxRetries(0): the retry loop is the client's own, because the SDK also retries 408 and 409 and the plan says never a 4xx. It retries 429 and 5xx (and a stream that ends in an overloaded_error event) three times, 1s doubling, honouring Retry-After up to 30s and stopping at once when the wait would pass the budget. The 60s (120s with a PDF) is one budget for the whole call, retries included, inside the server's 120s write timeout. The answer is requested with output_config.format (a JSON schema), because forced tool choice is a 400 on the current models; thinking and effort are left to the model's defaults, since the tiers are configuration, and no server-side refusal fallback is set (the plan makes a refusal a typed ErrRefused, and the smallest tier rejects the field). Every error is an *anthropic.Error built from the provider's message with the key scrubbed, never the SDK's error (which holds the request and its headers); a cancelled caller gets context.Canceled. With ErrRefused and ErrInvalidResponse the response keeps the usage and the request id, because the provider billed those tokens and T-D4.32 should settle them rather than release. ports holds the Completer, its request and response types and four errors (ErrUnavailable for a timeout, a 429 or a 5xx that outlived the retries, ErrRefused, ErrInvalidResponse, ErrRejected for a 4xx or a bad key); adapters.NewAI returns a nil Completer without the key, maps a tier to its model and the client's kinds to the port's. architecture_test.go gains two tests: only platform/anthropic imports the SDK, and only core imports platform/anthropic (so the ai module reaches the provider through its port alone). core/router/{modules.go,server.go} are untouched: the AI transport is T-D4.32's file. No model identifier is written anywhere; the tests use placeholders. .env.example and operations.md ("AI provider key and tiers") are current. Not done here: the live smoke (it needs the key and belongs to T-D4.32's smoke), and the 501 and status clauses above.

T-D4.31 — Credits: the pool, allowances, reservations, the ledger and the maintenance command

Depends on: T-D4.30 Touches: migrations/NNNNN_create_ai_settings.sql, NNNNN_create_ai_credit_periods.sql, NNNNN_create_ai_teacher_credits.sql, NNNNN_create_ai_reservations.sql, NNNNN_create_ai_usage_ledger.sql, NNNNN_create_ai_credit_requests.sql (new; numbered at merge), server/internal/modules/ai/{domain/{credits.go,feature.go},application/command/{reserve.go,settle.go,release.go,request_credits.go,decide_request.go,set_allowance.go,add_credits.go,set_rules.go},application/query/{usage.go,admin_credits.go},repositories/{credits.go,ledger.go,requests.go}} (new), server/internal/core/jobs/ai_reservations.go (new), server/cmd/maintenance/main.go (ai-credits), api/openapi.yaml (/admin/ai/*, GET /teacher/ai/usage, POST /teacher/ai/credit-requests; the shapes ride in T-D4.32's contract PR), server/internal/modules/notifications/ (three kinds), server/internal/modules/ai/application/tests/credits_test.go (new, integration), docs/plan/20-data-model.md (§12, the six tables), docs/setup/operations.md (the command) Size: L Done when:

  • Six migrations, one concern each, Down working, all on new empty tables (none is -- +goose NO TRANSACTION): - app.ai_settings (one row, seeded by the migration: pool_credits 200,000 (D21), default_allowance_credits 20,000, warn_percent 80, on_teacher_limit request); - app.ai_credit_periods (month date PK, pool_credits, added_credits, used_credits, reserved_credits) with CHECK (used_credits + reserved_credits <= pool_credits + added_credits): the hard stop is a constraint, so no code path can overspend the pool. month is the first day of the month in APP_TIMEZONE; the row is created on first use from ai_settings.pool_credits; - app.ai_teacher_credits (month, user_id, allowance_credits NOT NULL, custom, paused, used_credits, reserved_credits, PK (month, user_id)) with the same CHECK against allowance_credits; the allowance is materialised from the default when the row is created and rewritten for every non-custom row of the month when the Admin changes the rule; - app.ai_reservations (id uuidv7, month, user_id, feature, credits, created_at); - app.ai_usage_ledger (append-only: the app role has no UPDATE or DELETE, as on audit_log): one row per settled call with the actor, feature, tier, the four token counts, credits, the provider's request id, created_at; - app.ai_credit_requests (id, user_id, month, amount_credits, reason text ≤ 500, status pending | approved | declined, decided_by, decided_at, created_at). Every insert names its owner (user_id); 20-data-model.md §12 records the six with the reasoning (D-nn), and why the ledger is append-only.
  • Reserve(ctx, actor, feature, ceiling): one transaction locks the period row and the teacher row (FOR UPDATE, creating either if absent), adds the ceiling to both reserved_credits, and writes the reservation; a constraint violation (pgerrcode check violation) answers 402 AI_CREDITS_EXHAUSTED with detail.scope teacher or pool, and a paused teacher 403 AI_PAUSED. Settle(ctx, reservation, usage) moves the metered credits from reserved to used on both rows, writes the ledger row, deletes the reservation, and raises ai.allowance_warning once a month when the teacher crosses warn_percent. Release deletes the reservation and subtracts it. The three are one package the operations call; nothing else touches the balances.
  • core/jobs.ReleaseStaleAiReservations releases reservations older than 10 minutes; cmd/maintenance ai-reservations-sweep runs it, under the maintenance role's grant in the same migration set.
  • Operations (the contract rides in T-D4.32's PR; the code is here): GET /teacher/ai/usage (ai.use: the month's used, allowance, left, paused, and the five category sums the Settings card draws) and POST /teacher/ai/credit-requests (ai.use; amount 1,000 to 100,000 and a reason; one pending request per teacher per month; ai.credit_request to every ai.credits.manage holder); GET /admin/ai/credits (ai.credits.manage: the pool, the facts, the teachers with their month, the pending requests, the rules), PATCH /admin/ai/rules, POST /admin/ai/credits/additions (an amount; records the approver), PUT /admin/ai/teachers/{userId}/allowance (custom or default, paused), POST /admin/ai/credit-requests/{id}/approve and /decline (approve raises the allowance by the amount; both notify the teacher with ai.credit_request.decided). Every admin write is one audit_log row through the data-modifying CTE pattern. The "On pace for" projection is used / days elapsed × days in month, computed in the query.
  • cmd/maintenance ai-credits {status,set-allowance,add-credits,approve,decline,pause, resume,set-rules} calls the same application commands as the operations, audited as System with the operator named in details; it is Thuong's console until R5's T-R5.35 (Q28). operations.md documents it.
  • credits_test.go (integration): a reservation that would cross the pool is refused by the constraint and nothing is written; two concurrent reservations against a pool with room for one leave exactly one; settle moves exactly the metered amount; release returns it; a paused teacher is refused before any lock; the month rolls over in APP_TIMEZONE; the ledger refuses an UPDATE as the app role; a rule change rewrites non-custom allowances only; approve raises the allowance and writes the audit row.
  • Rehearsed on a Neon branch of production; the timings go in the release PR.

As built: Six migrations, 00118 to 00123, one table each, none NO TRANSACTION, each Down a DROP TABLE; 20-data-model.md §46 and D-44 to D-49 record them, with the reasoning. The two constraints are named ai_credit_periods_within_pool and ai_teacher_credits_within_allowance, and the repository tells the teacher's refusal from the pool's by the constraint's name, trying the teacher's first. Three columns go beyond the list above, each for a reason the data model gives: granted_credits (what approved requests added this month, part of the allowance and not carried over, so a change of the default rule and a new month treat a grant right), warned_at (the warning once a month is a flag written in the same statement as the crossing) and custom and paused copied from the teacher's latest earlier row when a month's first row is created (ai_teacher_credits_user_idx), because both belong to the person and the plan's per-month row would otherwise lose an Admin's pause on the 1st. The app role has no UPDATE or DELETE on the ledger, and privileges_test.go lists it beside audit_log.

modules/ai gains domain (the credit arithmetic, the months, the features with their category and the plan's ceiling, the repository port), application (Reserve, Settle, Release, Finish, ReleaseStale, RequestCredits, DecideRequest, SetAllowance, AddCredits, SetRules; the Usage and AdminCredits queries) and repositories. Every statement that moves a credit takes the month's row and then the teacher's (FOR NO KEY UPDATE, which does not block the foreign keys that point at them), and the sweep and Settle take both before they delete the reservation, so the order is one everywhere. Settle charges min(metered, reserved), so it cannot fail on the pool's constraint after the provider has been paid; the ledger keeps the real token counts and the log notes an overrun. Credits are ceil(sum(tokens x price) / 1e9) over config.MicroUSD prices, with cache writes charged at the input price because the configuration prices none. The tier prices reach the application through domain.Prices, built in core/wiring/ai.go; modules/ai imports no platform package but db in repositories.

Finish is the one place that decides settle or release, from T-D4.30's note that the Completer returns the usage with ErrRefused and ErrInvalidResponse: those two settle what the provider billed, and ErrUnavailable, ErrRejected, a cancelled caller and any other error release; a response that reports no tokens releases whatever the error. T-D4.32's operations call it and not Settle and Release separately. T-D4.32's "releases on failure" below is amended to say so.

The operations' application code is here; their transport (modules/ai/http/ai.go), contract and rate limits are T-D4.32's, as its Touches says, and so are four things that need the contract: the NotificationKind enum and table for ai.allowance_warning, ai.credit_request and ai.credit_request.decided, the new NotificationParams properties (used, allowance, credits, teacherName, decision), the three kinds' place in kindsByAudience (the teacher's bell), and their words in web/src/features/notifications/kinds.ts (vi, en). The notifications domain already knows the three kinds, their params types and dedupe keys, writes them without a switch, and no console lists them until the audience table does, so the web never sees an unknown kind. They are moved to T-D4.32's Done-when below.

ReleaseStaleAiReservations runs once at start-up and hourly, and only when the provider is configured: every poll wakes a scale-to-zero database, and a leftover of a dead process costs a teacher a few hundred credits for at most an hour and ten minutes. cmd/maintenance gains ai-credits {status,set-allowance,add-credits,approve,decline,pause,resume,set-rules} and ai-reservations-sweep, in core/maintenance/ai.go beside the other commands (cmd/maintenance/ main.go only parses). They run the Admin operations' handlers as System with a required -operator, and a dry run is the same handler inside a transaction that is rolled back with no notifier. The maintenance role's grant is not in a migration: quizzivy_maintenance does not exist until T-R5.17a, so the commands run as MAINTENANCE_DATABASE_URL's credential, and §46 lists what grant_maintenance_role must add. APP_TIMEZONE was documented and never read; config.AppLocation() reads it (Asia/Ho_Chi_Minh, with a fixed +07:00 fallback on a host without zone data). operations.md ("AI credits") documents the console. An allowance edit is not subject to the subset rule (access.CanActOn) in this task, which T-D4.32 applies to the application commands; the console, as System, is not asked.

Not done here: the Neon rehearsal (T-D4.40's migration rehearsal runs the six files on a branch of production and puts the timings in the release pull request).

T-D4.32 — The teacher operations and the contract

Depends on: T-D4.30, T-D4.31. D4's second contract pull request; T-D4.31's and T-D4.33's shapes ride in it Touches: api/openapi.yaml (PermissionKey gains ai.use and ai.credits.manage; the /teacher/ai/* and /admin/ai/* operations; WordImport.readWithAi and createImport's field; PublicStatus.ai; UserPreferences.ai), server/gen/openapi/, web/src/lib/api/schema.d.ts, migrations/NNNNN_add_ai_permissions.sql (new), server/internal/platform/httpx/permissions.go (the two keys' trees), server/internal/core/router/{ratelimits.go,tests/testdata/permissions.golden}, server/tests/isolation_cases_test.go, server/internal/modules/ai/{domain/prompts/*.go,application/{query/{generate,check,variant,explanation,distractors,score,insights,cleanup}.go,ports/{tests,questions,grading,analysis}.go}},http/ai.go} (new), server/internal/core/adapters/ai_reads.go (new: the ports over the tests, questions, attempts and assignments modules), server/internal/modules/attempts/{application/query/wrong_answers.go,repositories/analysis.go} (the aggregate), server/internal/modules/ai/application/tests/{prompts_test.go,operations_test.go} (new), docs/plan/70-redesign-overview.md §4.1 (two rows; on the branch already) Size: L Done when:

  • The contract. PermissionKey gains ai.use ("Use AI features", Content, Admin and Teacher) and ai.credits.manage ("Manage AI credits", System, Admin); the migration inserts both into app.permissions (in_matrix true, DG-199) and grants ai.use to the built-in Teacher role; the Admin wildcard holds both. httpx.PermissionRequirements lets the /teacher tree take ai.use and the /admin tree ai.credits.manage. permissions.golden is regenerated once, in this PR. Operations, all ai.use, all POST, all answering a suggestion object and never writing content: - /teacher/ai/questions/generate {source | testId + stimulusId, types ⊆ the five stored types, count 1..8, level, explanations, language} → {items: [{type, prompt, options?, answerKey, explanation}]} (x-resource: body testId kind test); - /teacher/ai/tests/{id}/check {checks} → {findings: [{severity, questionId, number, type, title, issue, fix: {kind: option_text | accepted_answer | explanation | none, payload}}]} (path id kind test); - /teacher/ai/tests/{id}/variant {changes, difficulty} → {changes: [{questionId, kind, before, after, payload}]} (kind test); - /teacher/ai/questions/{id}/explanation {language, length} → {text}; /teacher/ai/questions/{id}/distractors → {items: [{text, why}]} (kind question); - /teacher/ai/attempts/{attemptId}/answers/{questionId}/score-suggestion → {score, confidence, reasons, comment} with score snapped to the card's set (attempt, question; the attempt must be in the caller's scope as the grading reads are); refused with 403 AI_READ_OFF while the teacher's readStudentAnswers is off; - /teacher/ai/assignments/{id}/insights → {basis, items: [{kind, title, body, action}]} (kind assignment; the same preference gate); - /teacher/ai/bank/cleanup {checks} → {duplicates: [{a, b, why, usage}], tags: [{questionId, prompt, level, tags}]} over the caller's own rows, in chunks, capped at 2,000 questions and a 60s budget (x-resource-list). As built, one call bounded as below; a bounded pass of several calls since follow-up 1. Every operation declares its x-resource kinds and has its isolation-suite entry; another teacher's id answers as a missing one. make gen is its own commit; MSW fixtures validate with ajv; pnpm typecheck passes.
  • The subset rule (the Tech Lead's decision of 2026-10-13). Every /admin/ai/* write that targets one teacher (setAiAllowance, which also pauses and resumes, and the two credit request decisions) calls access.CanActOn(actor, target) in the application command, where Author carries the caller's effective permissions; a target outside the subset answers 403 FORBIDDEN with the words of the user-admin operations. The maintenance console (System) is not asked: CanActOn compares two permission sets and System has none. One end-to-end case: a custom role holding only ai.credits.manage cannot pause an Admin.
  • What T-D4.30 left for the contract: ErrorCode gains AI_OFF, and with the adapter nil (Assembly.AI == nil) every /teacher/ai/* and /admin/ai/* operation answers 501 NOT_IMPLEMENTED with that code; PublicStatus gains ai: boolean, read from Config.AIEnabled() and never from the provider; core/router/{modules.go,server.go} gain the AI transport.
  • What T-D4.31 left for the contract: NotificationKind gains ai.allowance_warning, ai.credit_request and ai.credit_request.decided, with their rows in its table; NotificationParams gains used, allowance, credits (integers, 0 or more), teacherName (1 to 200 characters) and decision (approved or declined), and the three Params types in notifications/domain/params.go (AllowanceWarning, CreditRequest, CreditRequestDecided) go into params_test.go's everyKind; kindsByAudience lists the three for the teacher's bell; kinds.ts words them in vi and en.
  • PrincipalRateLimits() gains every AI operation at 10 a minute and 100 an hour per actor, the clean-up at 3 an hour, the credit request at 5 an hour; credential_limits_test.go's form of assertion lists them.
  • Each operation reserves its ceiling (the table above) before the call and ends the reservation with Commands.Finish, which settles the metered usage, including what a refusal or an unparseable answer was billed (T-D4.30), and releases when nothing was billed; the response carries usage: {credits, left} for the panel's footer. With the adapter nil: 501. With the budget gone: 402 as T-D4.31 says. A provider failure after retries: 503 AI_UNAVAILABLE with Retry-After, and the reservation released.
  • Data minimisation is tested, not described: operations_test.go drives each operation against a fake Completer that records the request, and asserts that no student name, email, user id, attempt id or class name appears in any content block; the score and insights cases run with a fixture whose student is named in every other field.
  • The wrong-answer aggregate (attempts/application/query/wrong_answers.go): per question of an assignment's default version, the distinct normalised answer texts that did not earn full points with their counts, the eight most frequent, over handed-in attempts in scope; choice answers as option letters; x-permission through the insights operation only.
  • Prompts: one Go constant per feature with its schema; every prompt names the UI language for any student-facing text; the insights prompt asks for patterns and actions, never a judgement of a student; prompts_test.go parses the deck's demo arrays against each schema and asserts the snapping of a suggested score to the allowed set.
  • Leak review in the PR: no AI response reaches a student; the four forbidden keys are not involved; payload_test.go and openapi-contract.test.ts pass with their lists untouched.
  • Live smoke on the builder's machine (the key in .env): one generation from the deck's passage, one check of a seeded test, one score suggestion on a seeded short answer; the metered credits and the ledger rows pasted in the PR, the responses not. Needs the owner's key; run at release.

As built: One migration, 00124_add_ai_permissions.sql: the two rows in app.permissions (ai.use in Content at ordinal 6, ai.credits.manage in System at 4, both in_matrix), and ai.use granted to the built-in Teacher role, written only where that role exists. The System group already numbered its four hidden keys 4 to 7 and the catalogue lists hidden keys last, so they move up by one; (group_key, ordinal) is unique and not deferrable, so the Up moves them out of the way (+100) and back (-99) as two statements and the Down reverses it. Because the Teacher now holds ai.use, T-D4.31's integration case that expected no Teacher in the Admin's list was changed: an idle Teacher is listed, an Assistant is not. The web's Admin and Teacher permission fixtures carry the keys as the migration grants them.

The contract adds sixteen operations: ten under /teacher/ai/* (ai.use) and the six of T-D4.31's console under /admin/ai/* (ai.credits.manage); PublicStatus.ai (required, so the status fixtures changed); UserPreferences.ai (language, tagDrafts, readStudentAnswers, readImports, stored in the preferences document, reading answers on unless it is false); readWithAi on WordImport, WordImportHistoryItem and CreateWordImport, optional and absent for false (a property with a default is required in the generated request type), which the imports transport ignores until T-D4.33; the three notification kinds; and the error codes AI_OFF, AI_PAUSED, AI_READ_OFF, AI_CREDITS_EXHAUSTED (402, details.scope is teacher or pool), AI_UNAVAILABLE (503 with Retry-After: 30), AI_REQUESTS_OFF, AI_REQUEST_PENDING, AI_REQUEST_DECIDED, AI_REQUEST_EXPIRED and AI_ALLOWANCE_BELOW_USE. A provider that refused, answered something that is not an object, answered something no decoder can use, or rejected the request (ErrRejected, logged at error for an operator to read) is the same 503: what the provider billed is settled and the rest released (Finish, run from a defer, so a panic in the provider client also returns the held credits), and nothing is written. makeTestVariant and checkTestWithAi read the test's draft, one question read per question, bounded by 120,000 characters of paper; a question with formatted text is read for its words and never rewritten (a reword of one is dropped). cleanUpQuestionBank was built as one call on the fast tier, not the chunks and 60 seconds the sketch above says, and bounded so its answer fits the tokens it is given: at most 2,000 of the caller's own questions and 300,000 characters of them (prompts.FitCleanup), named q1 to qN so no id is sent, at most 20 pairs of duplicates and tags for the first 60 questions that have none, with MaxTokens 3,000. The decoder dropped a tag suggestion for any other question, and truncated was true when the bank held more than the call read or more untagged questions than it tagged, and the panel says to apply the suggestions and run it again. The chunked pass, with duplicates across chunks, inside the 60-second budget, is follow-up 1 of "Follow-ups from the built tasks", built after this task; its "As built" says what replaced this paragraph's bounds (the 300,000 characters, FitCleanup and the first 60 are gone). suggestAiScore and explainAssignmentResults also need teaching.grading, because they read what a grading read reads, and a score is suggested only for a short answer that waits for a mark by hand (ReviewAnswer.RequiresManual); an answer the grader marked itself is ErrNotManual.

The ports are typed as other modules' handlers and wired in core/adapters/ai_reads.go, so modules/ai imports none of them; a nil Completer is 501 AI_OFF before a read, and wiring.WithCompleter (and core.New's options) is the seam by which the end-to-end world runs the whole application over a provider a test controls. The wrong-answer aggregate is one statement in attempts/repositories/wrong_answers.go, not analysis.go: the latest handed-in paper of each student, answers that did not earn full points and were marked, lower-cased with spaces collapsed, choice answers as option letters, blanks joined with " | ", the eight most frequent by count and then text.

Data minimisation is tested at three levels: the prompts' own tests, operations_test.go (no id, actor or option id in any prompt, and the content is), and ai_flow_test.go over the real adapters (a student named, e-mailed and classed in every other field; none of it, nor the teacher's own identity, reaches a prompt, while the answer and its key do). The isolation suite holds an entry for each of the ten operations. Its world has a passage of more than forty characters in the listening group and a handed-in short answer, so the generation and the score suggestion reach the provider for the caller's own id too; the suite asserts the provider was asked eight times, once for each id slot that names the caller's own work (the score suggestion has two), and never for another teacher's or a missing id.

Per-user limits: the eight suggestions and the console writes at 10 a minute and 100 an hour, the clean-up at 3 an hour, the credit request at 5 an hour, and the two reads (getAiUsage, getAiCredits) at 60 a minute and 600 an hour, since a page draws them on every visit.

For the release: the live smoke is not done here, as it needs the owner's key. 20-data-model.md §46 lists what grant_maintenance_role must add for ai-credits and ai-reservations-sweep, and 75-r5.md T-R5.17a now says so.

T-D4.33 — "Read with AI" in the import worker

Depends on: T-D4.30, T-D4.31; T-D4.32 merged (its contract PR carries readWithAi) Touches: migrations/NNNNN_add_word_imports_read_with_ai.sql (new), server/internal/modules/imports/{domain/{source.go,pdf.go},application/command/{create.go,process.go},repositories/imports.go,http/imports.go}, server/cmd/import-worker/, server/internal/core/adapters/{import_pdf.go,import_ai_reader.go (new)}, server/internal/platform/pdftext/ (page rendering through go-pdfium), server/internal/modules/imports/application/tests/ai_reader_test.go (new), docs/plan/17-word-import.md (§1, a checkpoint; §8's "not enabled by default" superseded by D19), docs/plan/18-word-import-ux.md §4, docs/setup/word-import-worker.md Size: L Done when:

  • createImport takes readWithAi: boolean (default false; refused with 422 when the adapter is nil or the caller lacks ai.use); WordImport carries it; the column has a constant default, so the v0.10.0 binary keeps inserting.
  • With it on, a PDF that fails the text-layer check (PDF_NO_TEXT) is not refused: the worker renders its pages (up to 40; a longer file is refused with PDF_TOO_LONG_FOR_AI naming the limit), reserves 60 credits a page before the first call (a refusal is the import's failed state "Not enough AI credits for {n} pages", never a half-read file), sends each page to the capable tier with the recogniser's schema, and feeds the returned candidates into the existing pipeline as untrusted candidates (17 §8): every block still passes validation, review and the transactional commit; a page the model cannot read is a finding on that page, not a silent gap. A .docx or a PDF with a text layer is read as today whether or not the switch is on.
  • The processing log names the reader ("Read 12 pages with AI · 190 credits"); the history row's meta shows it; the privacy note in the Sources pane of the import workspace (18-word-import-ux.md §4) says what left the system ("The pages of this file were read by an AI service outside Quizzivy.").
  • Nothing of a student leaves: the import holds exam documents; the task adds a check that refuses a file whose metadata or first page names it as a student's submission is not attempted (a false sense of safety); the page's sentence carries the responsibility.
  • ai_reader_test.go: a scanned fixture (the corpus of 17 §1.1 has 49 two-page PDFs; one is rendered to images in the test) goes through a fake Completer and yields candidates the recogniser accepts; the reservation precedes the first call; a mid-file failure releases the remainder and fails the import; the 40-page refusal; the switch off keeps today's PDF_NO_TEXT.
  • Live smoke: one two-page scan from the corpus, read for real; the credits in the PR. Needs the owner's key; run at release.

As built: Migration 00125_add_word_imports_read_with_ai.sql (boolean NOT NULL DEFAULT false; 20-data-model.md §47). No dependency is added: pages are drawn by the go-pdfium WebAssembly runtime that already reads the text layer (pdftext.Reader.Render, in the same wazero sandbox: no filesystem, no network, 256 MiB, now a three-minute deadline for a rendering), and the JPEG is encoded by it. The production image is the same distroless static image with the same two binaries. createWordImport answers 422 VALIDATION_FAILED with details.readWithAi when the server has no provider or the caller lacks ai.use; the 422 is documented on the operation (a response only, no schema changed). WordImport and the history item carry readWithAi when true.

The reading lives in imports/application/reader, which holds the credits through the ai module's Reserve and Finish handlers and the Completer (all typed as the other module's ports, wired in core/wiring/import_worker.go; core/adapters/import_ai_reader.go draws the pages). What the model returns is the page's printed lines, not questions: they become pdf-lines-v1 evidence, so the deterministic recognizer, its source links, review and commit are unchanged, and the model has no field of a draft to write. The prompt and decoder are ai/domain/prompts/read_page.go (readable and lines only; a line is cut to 2,000 characters, a page to 300 lines). A scan is read only when the text-layer path would have said PDF_NO_TEXT; a longer file than 40 pages is refused as PDF_TOO_LONG_FOR_AI before anything is held, and a scan over the 60-page general limit stays PDF_TOO_LARGE, as it is without the switch. The reservation is 60 x pages before the first call (AI_CREDITS_EXHAUSTED / AI_PAUSED, nothing sent), pages go four at a time, and one Finish at the end settles the summed usage in one ledger row and releases the rest, also for a failure, a cancelled context (on a context that is not cancelled) and a panic. A provider failure fails the import PROVIDER_UNAVAILABLE (terminal: a retry would pay the pages again); a read that finds no readable page fails AI_UNREADABLE. An unreadable page keeps a hidden marker block and a review-required AI_PAGE_UNREADABLE finding on it; every page read adds to one informational AI_PAGE_READ finding, which is the "log line" a teacher sees (the contract carries no credits, so "Read 12 pages with AI · 190 credits" is the worker's log, import read with ai and ai import settled, and the Settings usage card's import row, not a web line). A reprocess of a read scan reuses its extraction and spends nothing. The worker loads the provider settings as the API does, and its per-run timeout is 15 minutes while the assistant is on.

The web's change is the Sources pane's note (imports.upload.privacyAi) for an import with readWithAi, in the awaiting-files and started states, the two findings' titles and help, and the messages for the four failures. The note says that scanned pages are read by an AI service outside Quizzivy and pages with selectable text are not sent, rather than the past-tense sentence of the Done when above, which would be false for a text-layer PDF; the finding's title, "{n} pages were read by an AI service outside Quizzivy", is that sentence, counted. The toggle card is T-D4.39's, and the failure "Not enough AI credits for {n} pages" has no {n}: a run's error is a code, and the contract is closed.

Measured on this container (4 cores): forty 300 dpi A4 scans took 30.6 s and 322 MiB resident, forty 12-megapixel photographs 32.6 s and 478 MiB (528 MiB on a second rendering in the same process), with the 256 MiB sandbox included; BenchmarkRenderForty* in pdftext/tests repeats it. The live smoke (one two-page scan, read for real) needs the owner's key and is run at release.

T-D4.34 — The AI panel and its entry points: the builder, the bank, Test detail

Depends on: T-D4.32; T-D4.2a and T-D4.14 merged (the builder's title bar is shared); T-D4.20 Touches: web/src/features/ai/ (new: components/{AiPanel,AiPanelField,AiItemCard,AiRunSteps,AiCreditNote}.tsx, kinds/{generate,check,variant,cleanup}.ts, api.ts, useAiAvailable.ts), web/src/features/tests/pages/teacher/{TestBuilderPage,TestDetailPage}.tsx, web/src/features/question-bank/pages/teacher/QuestionBankPage.tsx, web/src/app/router.tsx (nothing routes: the panel is a dialog), locales, web/tests/units/ai/ (new), web/tests/integration/router-chunks.test.ts (run, not edited: features/ai/ must stay out of the entry chunk) Reuses: Sheet (side, 560px), Segmented, Switch, Callout, the deck's chip and pill primitives, BulkTagDialog's write, duplicateTest, the section unit writes Size: L Done when:

  • AiPanel is the deck's drawer (V-14) on the Sheet primitive from the right, min(560px, 100%), with the three stages, the field types, the run steps and skeleton, the item cards and the footer's credit note from the operation's usage; and the states the deck lacks (DG-201): Esc, a focus trap and return (DG-36); "Try again" on 503; the 402 sentence "Your allowance is used up. Ask for more in Settings." (or "The school's AI pool is used up this month."); 403 AI_PAUSED as "AI is paused for your account."; a response that fails the schema as the warning callout. No demo content ever renders.
  • Entry points. The builder's title bar gains "AI check" and "Generate" before Preview (V-09) and is re-measured for one row at 768 collapsed, 1024, 1280 and 1440 in both languages (T-R4.31a's rule); Preview still flushes pending edits first, and so do both AI buttons. The bank's header gains "Clean up" and "Generate" (V-08). Test detail gains "Make a variant" (V-12), disabled without a published version. Every entry point is drawn only when useAiAvailable() (the status read and ai.use) is true, and disabled with a tooltip when the usage read says paused or exhausted (Q34).
  • Generate: the source chips offer the test's shared-content passages by title ("Passage 1 · …") and "Start empty"; types are the five stored types (TFNG joins in R6, DG-202); "Add {n} to {section}" creates the accepted items as questions in the open question's section through the existing writes, with the "AI draft" tag when the preference is on, and opens the first; in the bank "Save {n} to the bank"; the toast is the deck's. Corrected in v0.11.3 (hotfix/ai-generate-into-group): a run from a passage that a shared-context group holds reads "Add {n} to {group}" and appends the picks to that group as new members after its existing ones, through updateQuestionGroup under the group's revision (a stale revision is the server's STALE_WRITE, shown in the panel, which stays open); more than the group's 200 members is refused before the write. It opens the first new member. Pasted text, a topic or "Start empty" still goes to the section.
  • Check: findings as drawn; "{fix}" is one click only for a fix whose write exists (option text, accepted answer, explanation) and calls updateQuestion with the payload; otherwise "Open question" and "Dismiss" only (DG-202). Corrected in v0.11.4 (hotfix/ai-check-group-members): the check reads the members of the test's shared-context groups as well as its standalone questions. AITests.Draft walks each section's units in the builder's order (a section with no units, from before groups, reads its questionIds as question units), reads each group once, and emits its members in the group's order with the group's passage and PaperQuestion.GroupID. The number of a finding is the builder's positionOf count: one per question across the sections, a question the caller cannot read keeping its number, and a group the caller cannot read contributing none. It used to read the standalone questions only, so a test whose questions were all members answered "There is nothing for the assistant to read yet."
  • Variant: the before / after cards; "Save {n} changes as a draft" calls duplicateTest into "{title} · Variant" and applies the accepted changes to the copy's draft questions through updateQuestion; the toast reads "Variant saved as a draft. Open the variant." with a link (DG-197). Limit, stated in v0.11.4: a variant covers the standalone questions only. A variant duplicates bank questions and writes each change to the duplicate, which a group's members cannot be, so VariantHandler drops GroupID != "" questions before it builds the prompt and a test whose questions are all members answers ErrNothingToRead, as before. Corrected in v0.11.9: a variant is a bounded pass. One call for the whole draft, with 6,000 tokens of answer, could not come back inside the 60 seconds a call and the proxy allow on a test of about thirty questions or more, so it answered 503. VariantHandler now asks about prompts.VariantChunkSize (8) questions a call, at most VariantCallsInFlight (5) at a time within the clean-up's budget, under one reservation and one Finish, and returns the usable calls' changes in paper order. Lifted in v0.11.9: a variant covers group members. The owner's report was a test made only of group members, which answered ErrNothingToRead under the limit above. The draft read gives every question, a member marked Gapped when a gap of its group's material is bound to it; prompts.Variant neither reorders an OrderFixed member nor rewords a Gapped one. saveVariant writes a standalone question's changes to a duplicate, as before, saves the outline, then writes each changed member to the copy's group at the same group-unit index, the member at the same position, with updateGroup (the copy's groups are section-owned copies, copyDraftGraph).
  • Clean up: a duplicate card offers "Open" for each question and "Keep both"; no merge (DG-197); "Add tags to {n} questions" is the bulk tag write.
  • Corrected in the next patch release (v0.11.8): a panel's session outlives its drawer. The panel had unmounted whenever the drawer closed, which dropped its fields, the suggestions of a run and, through the unmount cleanup, aborted a run in flight. A teacher who pressed "Open question" on a finding, or closed the drawer by mistake, got a blank setup and paid for another run. AiPanelStack now keeps each panel mounted from its first opening, drawn closed (AiPanel's open), for as long as its host page is; the four hosts (the builder, the bank, Test detail and the insights' practice panel) use it and own the lazy import. Done, a successful primary and Start over end the session; the unmount cleanup still aborts a run when the page goes. Two things followed from keeping results across edits: a kind may declare an item stale (a check's finding whose question has left the test is hidden and not counted), and Generate reads a section, bank or practice destination from the page when the questions are kept, since the builder's open section can change between the run and the write. A run that settles while its drawer is closed, answered or failed, raises one toast naming the panel with an Open action (AiPanelStack's onOpen, which each host points at its own open state), so a teacher who closed the drawer mid-run is not left waiting on a run that is already done; Stop and leaving the page raise none, opening the drawer by any route or leaving the page takes the toast away, and while another drawer is showing (a modal keeps a toast from being clicked) it waits for that one to close. Its action takes focus out of the toaster first (leaveToast), because Sonner returns focus to where it was before the toasts took it and would do so after the drawer has opened; the Sheet falls back to the page's <main> when nothing had focus as it opened. A check's fix names an option or a blank by id and the server re-ids options on every save, so a held finding stops fitting after any save of its question; fixFits makes "Apply fix" fail with a sentence and write nothing (matching by text is not possible: the finding does not carry the option's old text; stable option ids would end the problem). The bank clean-up's tag write reports the questions it changed (updated), not the ones kept. Stop and leaving the page abort the request, the server cancels the provider call and Finish releases the reservation, so a single-call operation charges nothing and the bank clean-up settles only the calls already billed (Tally). That a cancelled call bills nothing is Billed's assumption ("as far as anyone can tell"): Anthropic's documentation does not say what a request abandoned midway costs. The bank clean-up's "Open" is a link and still leaves the page, ending its session; keeping it needs state above the router or a new tab, and a second tab races the refresh cookie's rotation, so it is not done here.
  • Every write a primary makes goes through the existing mutation hooks and their autosave flushes; the panel itself calls only /teacher/ai/*. A unit test spies on the API module and finds no write from features/ai/.
  • Strings vi first, en from the deck, listed in the PR; features/ai/ is a lazy chunk; router-chunks.test.ts passes untouched.
  • Compared with the deck at 360, 768, 1024, 1280 and 1440, light and dark: the panel in its three stages for each kind, and the three headers. Known differences: DG-197, DG-201, DG-202.

As built (T-D4.34a, the panel and the builder): The task ran past 800 lines, so it is two pull requests under one id: T-D4.34a (the panel, Generate and Check, the builder's title bar) and T-D4.34b (the bank, Test detail, Variant and Clean up). The boxes above that 34a closes are ticked; the entry-point, Generate, strings and comparison boxes close with 34b.

The panel is the Sheet at a new 560 frame (min(560px, 100%), the header aligned to the top for the two-line subtitle, a 1.35 title with the subtitle 2px under it, a muted close button, a footer that wraps). A kind is a config object (features/ai/kind.ts, kinds/{generate,check}.ts): its fields, the check that stops a run, the run, how an item is drawn and what its primary saves. The host page hands in a context that holds every write, so features/ai/ imports the API client in api.ts alone and names only /teacher/ai/* and /public/status there; tests/units/ai/no-writes.test.tsx spies on the client through both kinds' runs, a primary and a fix and asserts it, and checks the sources. AiPanel is loaded with lazy() by its host (its own 20 kB chunk in the build); router-chunks.test.ts passes untouched.

useAiAvailable() is ai.use and PublicStatus.ai; without ai.use the status is not read. The usage read (getAiUsage) decides the disabled state: paused, or nothing left, which is the teacher's allowance when used has reached it and the pool otherwise. A blocked entry point is aria-disabled, focusable, with the reason as its tooltip. The footer shows only metered figures: "{left} credits left this month" before a run and "Used {credits} credits · {left} left" after it, from the answer's usage, which also updates the cached month; the deck's "About {cost}" is a placeholder and is not drawn. The deck's 3.2 s minimum run is demo pacing and is not built: the steps advance every 1.3 s while the call runs. Failures: a 5xx, a dropped connection or an answer that fails the schema is the warning callout "AI didn't respond. Try again in a minute." with "Try again", over the fields as they were; 402 (by details.scope) and 403 AI_PAUSED are the footer's sentence with Run disabled, and the usage is read again; 501 AI_OFF reads the status again, so the entry points go; anything else shows the server's message. Stop aborts the request.

Generate offers the open test's shared-content materials that have text, in outline order, as "Passage {n} · {title}"; choosing one copies its text into the field, and while the text is unchanged the request names testId and stimulusId, otherwise it sends the text as source (at most 20,000 characters). A kept question becomes bank values in question-bank/aiDrafts.ts (draftQuestion: one point, the level asked for, the canonical True/False options, a blank's first ___ as its one gap, a short answer's key as its sample answer), is checked against questionSchema before anything is sent, and is created with createQuestion; the builder appends the ids to the open question's section through the outline's autosave and opens the first. The tag is "AI draft" in the teacher's interface language (ai.draftTag, "Bản nháp AI" in Vietnamese) while UserPreferences.ai.tagDrafts is not false. A check's one-click fix flushes the open editor, reads the question again, applies the payload (withFix) and saves it with updateQuestion; an open editor of that question is mounted again on the saved copy. "Q{n}" is the builder's own number for the question when it is in the outline. The check's first step reads "Reading the questions and passages" (the deck's "Reading both passages" names its demo's two).

The title bar keeps R4's Versions icon before the two buttons, and its actions row now wraps, as the deck's does (with two more buttons it overflowed at 360). Measured in Chromium (Be Vietnam Pro loaded), Publish's top against the title's, in both languages and themes: one row at 1024 collapsed, 1280 and 1440 in both sidebar states, where the deck has one; the actions on a second row at 768 in both sidebar states and at 1024 expanded, as the deck's; at 360 the deck's three rows. At 768 expanded Publish goes to a third row (the deck keeps the actions on one): the Versions icon the deck no longer draws needs 42px, and the row is 3px short in English.

As built (T-D4.34b, the bank and Test detail): The bank's header gains "Clean up" and "Generate" before New question (question-bank/components/BankAiActions.tsx), and Test detail "Make a variant" before Open builder (tests/components/VariantAction.tsx); both load the panel lazily and keep the rules of 34a. "Make a variant" is drawn disabled, with "Publish the test before making a variant." as its tooltip, while the test has no published version; the builder's Generate uses the same form ("Add a section first.") in place of a bare disabled button.

A variant never writes to the test it copies. duplicateTest copies a draft's outline, and a section's standalone questions are copied by reference: the copy names the same bank questions. So updateQuestion on "the copy's questions", as the box above says, would change the original's draft too. tests/aiVariant.ts checks every changed question against questionSchema first (a change that cannot be saved stops the whole save before a write), then calls duplicateTest, duplicates each changed question with duplicateQuestion, writes the changes to the duplicate with updateQuestion (its option ids mapped from the original's by position, withVariant in question-bank/aiDrafts.ts), and saves the copy's outline with each duplicate in place of its original and the title "{title} · Variant" (cut to 200 characters by the test's title). Questions in a shared-context group are never named by the operation (it reads questionIds), so a variant's groups are the copy's own. The toast is "Variant saved as a draft." with "Open the variant", which opens the new test's detail.

The clean-up's duplicate card offers "Open one", "Open other" (links to the bank editor) and "Keep both", which only drops the card; nothing merges. Kept tag suggestions go through tagQuestions, one call for each distinct set of tags; with no tag suggestion left the primary is "Done". truncated is the info line "This scan read {n} questions, not the whole bank. Apply these and run it again." The usage line names each count in its own plural ("Used in 4 tests and 1 test"). Generate in the bank has no source chips (the bank has no passage) and saves with "Save {n} to the bank" and the deck's toast.

Compared in Chromium against the deck at the five widths, light and dark: the three kinds' panels within 1px of the deck's geometry in every stage, the differences being DG-197 (no merge; "Open" for each), DG-201 (no sample callout; metered credits) and DG-202 (five types). The headers keep the deck's rows where the product draws what the deck draws; the bank's header has no Share or Import (not in the product) and so wraps later than the deck's, and Test detail has no Share. No header scrolls sideways at any cell, in either language. Focus returns to the opening button in every cell.

T-D4.35 — Question editor: "Write with AI" and "Suggest wrong options"

Depends on: T-D4.32, T-D4.34 (shares features/ai/api.ts and the credit note) Touches: web/src/features/question-bank/components/{QuestionEditor,ExplanationField,OptionsEditor,AiExplanationPanel (new),AiDistractorsPanel (new)}.tsx, locales, web/tests/units/question-bank/editor-ai.test.tsx (new) Size: M Done when:

  • On both pages that mount QuestionEditor (the bank and the builder, DG-108): the Explanation label row gains "Write with AI" (28px; "Writing…"; "Write again" after), the field keeps its <label for> (DG-138: the deck's aria-label is not taken); the panel "Suggested explanation" with "Use this" (writes the text into the field through the editor's own change handler, so autosave sees it), "Shorter", "In Vietnamese" / "In English", and "Discard" (V-10). The explanation is written in the preference's language.
  • For single and multiple choice: "Suggest wrong options" beside Add option; the panel "Suggested wrong options" with up to four rows, "Add" fills the first empty option or appends while under the cap of 8 (DG-63; the deck's 6 is not taken), "Added" after; the error line on 503.
  • Both guarded by "Write the question first" / "Mark the correct answer first" as toasts; both hidden without useAiAvailable(); both disabled with the reason when paused or exhausted.
  • Unit tests on both hosts; compared with the deck at 360 and 1280, light and dark.

As built: the editor the two pages share is not QuestionEditor itself but its parts: the bank's QuestionEditorPage and the builder's BuilderQuestionEditor both compose QuestionPromptAnswers (the answer area, so OptionsEditor) and ExplanationField, which moved to its own file. Each takes an optional QuestionAi (question-bank/questionAi.ts), and only those two pages pass one, so the group composer and the import review, which mount QuestionEditor on questions the operations cannot read yet, draw neither suggestion. useAiSuggestion runs one suggestion (the guards, the page's prepare, the call, features/ai/outcome.ts for the usage and the failure); AiExplanationPanel and AiDistractorsPanel draw the two panels. features/ai/api.ts gains writeAiExplanation and suggestAiDistractors, and AiEntryButton a ghost variant for "Suggest wrong options".

  • The stored question is what the assistant reads. Both operations read the question by id, so the editor makes the stored copy match first: the builder's prepare flushes the question's autosave, and the bank's, whose Save is explicit, answers "Save the question first" (a toast) while the fields the assistant reads differ from the last save (aiReadsOf: type, prompt, written options, accepted answers, sample answer). Editing only the explanation, the points or the tags does not stop a call. An empty option is not read: it cannot be stored and is what "Add" fills, so in the builder a flush the autosave refuses only because of an empty option still asks. A new question in the bank has no id and gets the same toast.
  • The guards are the deck's toasts, checked on the editor's own values before prepare: no prompt, or no correct option with text (a blank with no accepted answer). A short answer passes, as in the deck; the server's 422 for a missing sample answer is the panel's line.
  • "Use this" replaces the explanation in the form the field holds (withExplanation): a rich document of paragraphs while the field is rich, the plain text while it is Markdown, then remounts the field on the new value and returns focus to the button. "Shorter" keeps the language of the suggestion shown; "In Vietnamese" / "In English" asks for the other language than the preference. The panel closes while a new one is written, as the deck's does.
  • "Add" (withDistractor) fills the first empty option that is not marked correct, else adds one while there are fewer than 8, else the editor's own "Up to 8 options" toast; "Added" stays focusable (aria-disabled). An answer with no suggestion is the error line, as the deck's.
  • Failures are a line in the panel: 503 and a dropped connection the deck's sentence, 402 and 403 AI_PAUSED the block sentence (and the buttons then hold, with it as their tooltip), anything else the server's message. The button is the retry. The credit note of the call ends each panel that answered (spec §12), which the deck does not draw.
  • The label row keeps R4's "Rich text | Markdown" after "Write with AI". With the product's longer hint the two wrap under the label below 1440 (at 1440 they sit on the label's row, as the deck's button does).

Measured in Chromium at 360, 768, 1280 and 1440, light and dark (English against the deck's question route with its AI state set; Vietnamese for wrapping), on the bank's editor and the builder's pane: the buttons, the panels, their titles, text, rows and actions match the deck's sizes, type, radii, padding and colours in both themes; the panels are taller by the credit note; no cell scrolls sideways. Focus rings are --focus, and closing a panel returns focus to its button.

T-D4.36 — Grading: "Suggest a score"

Depends on: T-D4.32, T-D4.34 Touches: web/src/features/attempts/components/{GradingCard,AiScoreBox (new)}.tsx, locales, web/tests/units/attempts/grading-ai.test.tsx (new) Size: S Done when:

  • Between the student answer and Score, while the teacher's readStudentAnswers is on and AI is available: the box of V-07 with its idle, running and done states; the confidence pill; "{score} / {max}"; the reasons; the comment; "Use score and comment" sets the score button and the comment field through the card's own handlers (nothing is saved until the teacher's Save & next, as today) and toasts the deck's sentence; "Try again". The idle sentence is the short-answer one; the band sentence waits for R6 (DG-202).
  • With the preference off the box is absent and a line under the answer reads "AI suggestions are off in Settings › AI assistant." (one sentence, from deck parts).
  • Unit tests: the gate, the fill, that no request carries the student's name (the fixture names one); compared with the deck at 360 and 1280, light and dark.

As built: The grading page's card is pages/teacher/GradingAnswerCard.tsx; components/GradingCard.tsx is the attempt review's marking card, which V-07 does not draw and which gains nothing. The box is attempts/components/AiScoreBox.tsx, handed to the card by GradingPage as its suggestion slot for a short_answer item, so the card itself reads no AI state and its unit tests mount it as before. It stands directly above Score, after the accepted answers and the sample answer, which is where the deck puts it relative to Score.

A score button saves at once; the suggestion does not. The card's score buttons call pickScore, which writes the grade, so "Use score and comment" cannot go through onScore without saving. useGradingQueue gains fill(item, points, comment), which lays both over the answer's draft and writes nothing (refused while a save is in flight); the score button shows pressed and the comment field holds the text. Save & next writes them through flush, and so does j/k or a click on another answer, which already flush a pending comment. A route change does not: the leave dialog appeared only for an unsaved comment, so a filled score with an empty or unchanged comment was lost; the D4 follow-ups (item 9 of "Follow-ups from the built tasks") make the dialog ask for it too. grading-ai.test.tsx spies on the API client and finds no POST /teacher/attempts/{id}/grade after "Use score and comment", then one with the suggested points and comment after Save & next; it also reads the box's source for any import of the attempts API.

suggestAiScore is in features/ai/api.ts. The panel's failure reading and usage settling moved from AiPanel.tsx to features/ai/outcome.ts (aiFailureOf, refreshAfterAiFailure, settleAiUsage) so the box and the panel share them. States: idle (the short-answer sentence, DG-202), running ("Reading the answer…" with the spinner, still under reduced motion), done (the confidence pill in the success, warning or danger tones, "{score} / {max}" in the locale's figures, the reasons, "Comment for the student:" when the comment is not empty, "Use score and comment", "Try again" and the credit note "Used {credits} credits · {left} left" where the deck draws its demo line). 503 and a dropped connection are the warning callout with "Try again"; 402 and 403 AI_PAUSED, or a usage read that says paused or exhausted, show the block sentence with the button disabled; 403 AI_READ_OFF turns the box into the off line; 501 AI_OFF reads the status again; 422 and anything else show the server's message. Leaving the answer aborts a run, and a suggestion is not kept for the answer when the card moves on. With readStudentAnswers false the box is the line "AI suggestions are off in Settings › AI assistant." whose last words link to /teacher/settings/ai; the Trans tag is <settings>, since i18next parses <link> as a void HTML element.

The request is a body-less POST naming the attempt and the question; the unit case whose fixture names the student in the queue row and the assignment title asserts that neither the name nor the student id is in the URL, the body or the client call. teacher-grading.spec.ts adds ten cases (360, 768, 1024, 1280 and 1440, light and dark): the box above Score, the 30px button, the 17px score, the fill, no write until Save & next, no sideways scroll. Compared in Chromium against the deck's grading screen (first item, the deck's demo suggestion served as the operation's answer) at the same widths and both themes, sidebar expanded: every measured box (the frame, title, idle sentence, buttons, pill, score, reasons, comment card) within 0.6px and the same computed colours, except the done frame, 0.8px taller at 360 to 1024 where the credit note wraps in place of the deck's 12px "Sample output" line (DG-201).

T-D4.37 — Assignment detail: "Explain results with AI"

Depends on: T-D4.32, T-D4.34, T-D4.22 Touches: web/src/features/assignments/pages/teacher/AssignmentDetailPage.tsx (the Questions tab), web/src/features/assignments/components/AiInsightsPanel.tsx (new), locales, web/tests/units/assignments/insights.test.tsx (new) Size: S Done when:

  • The Questions tab's header row gains the button and the panel of V-06; "Review answers" opens Grading narrowed to the assignment; "Make a practice set" opens the Generate panel with the insight's types preselected and the primary "Save as practice test", which calls duplicateTest's create path for a new draft test "{title} · Practice" with the accepted questions. "Based on {n} attempts" is the operation's basis; "Hide insights" closes the panel for the visit.
  • Hidden without readStudentAnswers; disabled with the reason when exhausted; "Try again" on 503.
  • Unit tests; compared with the deck at 360, 1024 and 1280, light and dark.

As built: features/assignments/components/AiInsightsPanel.tsx draws the button and the panel, features/assignments/assignmentInsights.ts holds the call for the visit. The call lives in the page (useAssignmentInsights), so a tab change keeps what was read; "Hide insights" resets it and returns focus to the button, and another assignment's id never shows the last one's answer. ItemAnalysis gains a header slot (action, given the number handed in) and a slot under it. The button is AiEntryButton, which gains busy (the spinner in the icon's place, aria-busy, a press ignored): "Explain results with AI", "Reading results…" while it reads, "Refresh" after. It is drawn only with useAiAvailable() and inside the card teaching.grading already gates; it is disabled with the reason while paused or out of credits (useAiBlock), and with "No papers handed in yet." while the analysis counts none. readStudentAnswers off (the Tech Lead's brief, which replaces "hidden"): the button's place holds one line, "To explain results with AI, turn on “Let AI read student answers” in Settings › AI assistant.", the last words a link to /teacher/settings/ai. Failures sit under the header as a callout: 503, a dropped connection or a 5xx is "AI didn't respond. Try again in a minute." with "Try again"; 402 (by scope) and AI_PAUSED the block sentence, and the month is read again; AI_READ_OFF the line above; 422 "No papers handed in yet."; AI_OFF reads the status again so the button goes; anything else the server's message. The panel is a section named by its "What AI noticed" heading; the cards are a list; an answer with no items says "AI found nothing to point out in these results.". The answer's usage updates the cached month. Read-only: the call sends no body, and nothing in the panel writes; insights.test.tsx spies on the API client through a run, a refresh and Hide and finds only GETs and the insights POST, and no student name in the panel (the fixture names one). The practice set is the only write, made by the Generate panel's primary: generateKind gains a practice destination ("Save as practice test", the deck's toast "Practice test with {n} questions saved as a draft in Tests") and types, the insight's types preselected (the default pair when it names none). Its passages are the assigned version's shared-content stimuli from the preview, sent as text (no testId, since the draft's stimuli may differ from the version's). tests/aiPractice.ts creates each kept question with createQuestion (tagged "AI draft" while the preference is on), then createTest with "{title} · Practice" (cut to 200 characters) and saves its outline with one section, "Section 1", holding them; no other test is touched. duplicateTest is not called: it copies a whole draft, and a practice set holds only the new questions. Compared with the deck side by side with the deck's open assignment's Questions tab and its four sample insights (served with window.claude answering them, so the "sample" note does not show), the app on a production build with the same four, English, sidebar expanded, at 360, 768, 1024, 1280 and 1440, light and dark: every box, font, weight, line height, colour, border, radius, padding and gap of the header row, the button (idle and done), the panel, its head, the basis, the close button and the four cards with their tiles, titles, bodies and actions equals the deck's within 0.5px. Not visible: Tailwind's transparent ring layers before the same --shadow, and border-color on elements with no border. The intro line now uses the deck's line height of 1.5 (it was 1.45), which moved the header by 0.7px at one line. teacher-shell.spec.ts gains the panel's geometry, focus ring and focus return at 360, 768, 1280 and 1440 in both themes.

T-D4.38 — Settings › AI assistant, and the credit request

Depends on: T-D4.32 (the usage read, the request operation, UserPreferences.ai) Touches: web/src/features/settings/sections/AiAssistant.tsx (new), web/src/features/settings/pages/teacher/TeacherSettingsPage.tsx (/teacher/settings/ai), web/src/features/auth/preferences.ts (ai defaults), locales, web/tests/units/settings/ai-assistant.test.tsx (new), web/tests/e2e/settings-navigation.spec.ts Size: M Done when:

  • The sixth section (V-13): the usage card from GET /teacher/ai/usage (used of allowance, left, the bar, the five category rows) with "Usage resets on {1 Month}" computed in APP_TIMEZONE; the Preferences card (UserPreferences.ai {language, tagDrafts, readStudentAnswers, readImports}, saved through PATCH /auth/me as the other preferences are; defaults en-or-vi from the locale, on, on, off) with the deck's three switches and the language segment.
  • "Ask for more credits" under the usage card (DG-200): amount and reason, POST /teacher/ai/credit-requests; "Request sent. Your admin will be told."; the pending request shown until decided; hidden while the rule is "Pause until next month".
  • With AI off (501): the section shows one sentence, "AI is not turned on for this school." (DG-201), and the nav keeps the item so the teacher knows where it would be.
  • The handle.permission of the section is ai.use; a teacher without it gets the 403 page as the other sections do (T-R5.20's guard pattern, built here for the teacher tree if it is not there yet).
  • Unit tests and the settings E2E spec's navigation case; compared with the deck at 360, 768 and 1280, light and dark.

As built: one pull request, three commits (the section, this note, and the section in settings.spec.ts's list of the teacher's sections, which the first CI run caught). The section is sections/AiAssistant.tsx, which composes AiUsageCard, AiCreditRequest and AiPreferences; the reads, the mutation and the status query are features/settings/aiApi.ts, the form's schema aiCreditRequestSchema.ts (with its Expect<Equal<…>> against requestAiCredits' body), the figures aiCredits.ts, and the defaults features/auth/preferences.ts (aiPreferencesOf: the interface's language, tags on, answers on, imports off), which T-D4.39 reads.

  • Not offered without ai.use, rather than the 403 page. Every section of Settings renders inside one route (settings/:section?) and stays mounted, so a route guard cannot gate one section without remounting the others and losing their unsaved edits. The section is built as Assignment defaults and API reference are: no nav item, and /teacher/settings/ai is replaced by /teacher/settings. The Tech Lead's brief allowed "not offered, or shown off"; the Done-when's 403 is amended to this.
  • Off. With PublicStatus.ai false, or getAiUsage answering 501 AI_OFF, the nav keeps the item and the section says one sentence, "AI is not turned on for this school." (DG-201). The section reads nothing until it is first shown: GET /public/status (kept 30 s, as the route lets a browser keep it), then GET /teacher/ai/usage, so a visit to another section costs no AI read.
  • The usage card is the deck's at every width: the reset date is the first of the month after month, which the server counts in APP_TIMEZONE ("1 November", "1 tháng 11"); the bar is ProgressBar (8px, accent, round); a warning callout says when the teacher is paused, when the allowance is used up, or when the centre's pool is (left 0 with the allowance not used up), in the spec's words (§12).
  • The request (DG-200) is a row under the card's rows, "Need more credits this month?" and an outline "Ask for more credits", which opens the form in place: amount (digits, 1,000 to 100,000) and reason (at most 500), Cancel and "Send request", the fields in the Profile section's grid (at most three columns, each at most --field-max, DG-205). Sending goes through useRequestAiCredits; a ref taken before mutate makes a double press send once (the unit case fails without it). The answer becomes the cached usage's pending, the toast says "Request sent. Your admin will be told.", and focus moves to the waiting request, which shows the amount, the day it was sent and the reason until an admin decides. A 409 shows the server's message and rereads the usage, which then shows the waiting request or hides the control. Nothing is drawn while requests is false (the rule is pause). Typing in the form counts as an unsaved change for Settings' leave guard.
  • The preferences save at once, as Appearance's do, the whole ai object through PATCH /me/preferences (the object is replaced whole by the merge); while one saves the controls answer aria-disabled, and a failed save toasts the server's message and shows the stored value again.
  • Shared helpers. T-D4.34 (features/ai/) was in flight, so this task keeps its own small useAiConfigured (the status query, key ["public-status"]), aiUsageQuery (key ["ai-usage"]) and credit formatting in features/settings/, and its own strings under settings.ai.*, including the three "paused / allowance / pool" sentences. T-D4.34's merge reconciles them with useAiAvailable and the credit note.
  • Compared with the deck (served with React, react-dom, Babel and lucide from their npm tarballs through route interception, verification-r4-51.md) at 360, 768, 1024, 1280 and 1440, light and dark: both cards' heights, every usage row, the bar, the segment and the switches equal the deck's at every width; up to 1280 their x and width too. At 1440 the cards are 56px wider and start 28px left of the deck's, because the page fills its frame (DG-205) where the deck centres 1080px. At 360 everything sits 12px higher, as on every Settings section (the page head, DG-122). The request row, its form and the waiting request are not drawn by the deck (DG-200); they were checked at 360, 768 and 1280 in Vietnamese, with no sideways scroll and a 2px --focus ring on every control.

T-D4.39 — Imports: the "Read with AI" card

Depends on: T-D4.33, T-D4.38 (the preference's default) Touches: web/src/features/imports/components/{SourceIntake,ProcessingPanel}.tsx, web/src/features/imports/pages/teacher/{ImportWorkspacePage,ImportsListPage}.tsx, locales, web/tests/units/imports/{upload-flow,workspace,processing-states,history}.test.tsx Size: S Done when:

  • The toggle card of V-11 sits in the Sources pane of ImportWorkspacePage (DG-207) between the sources and the privacy note, in the draft state only, prefilled from preferences.ai.readImports, switching the accepts line; the privacy note gains the sentence of T-D4.33 while it is on; createImport carries readWithAi. Absent without useAiAvailable(); disabled with the reason when exhausted (the 402 would otherwise arrive after upload). The card lives in SourceIntake, so the page frame (DG-205) and the pane split are not touched.
  • The status pane (ProcessingPanel) and the history (ImportsListPage) show the reader's log line and meta (T-D4.33).
  • Unit tests in upload-flow.test.tsx and workspace.test.tsx; compared with the approved page-width design's import workspace (deck/page-width-proposal/) and the v2 deck's V-11 at 360 and 1280, light and dark.

As built: One pull request; no contract, migration or server change. The card is components/ReadWithAiCard.tsx and the choice useReadWithAi.ts, both in features/imports/, on features/ai/'s useAiAvailable and useAiBlock and features/auth/'s aiPreferencesOf.

  • Where and when. SourceIntake draws the card after the exam and key slots, inside the file mode's block, so the page frame and the pane split are untouched and the card is hidden, with its state kept, in paste mode. It is offered for a new import (/teacher/imports/new) only, while useAiAvailable() is true and the server's limits list pdf: with no PDF accepted the switch would change nothing. An import awaiting files or failed keeps T-D4.33's note and draws no card, because readWithAi is fixed at create.
  • What it sends. It starts from preferences.ai.readImports (off by default); a change applies to this import and writes no preference. Start sends readWithAi: true on createWordImport only while it is on; off, the body is today's. Pasted text never sends it. A change of the choice, like a change of the title, takes a new requestId. Once the import exists (an upload failed after the create) the card is locked to the import's own value with "The import was created with this choice.". A 422 naming readWithAi shows the server's message and reads the status and the usage again, so the card goes when the assistant has.
  • Blocked. Paused, or out of allowance or pool (useAiBlock): the switch shows off, stays focusable with aria-disabled, ignores a press, and the reason (ai.blocked.*, the panels' sentences) is a line under the hint and its description. Start then sends no readWithAi.
  • What changes while on. The accepts line reads imports.upload.limitsAi (formats and size from the server, as before), the privacy note appends imports.upload.privacyAi, and the "Read the content" stage of the status pane says "Scans read with AI"; the same stage line shows for an import with readWithAi in every later state. The history row's meta ends "· Read with AI". The contract carries no credits, so neither the deck's "About 400 credits per page" nor the proposal's "Uses 1 AI credit · 18 left" is drawn (§12: no figure the product has not metered). Settings' "Read imports with AI" hint (T-D4.38) carried the deck's figure until the D4 follow-ups dropped it there too.
  • Compared with the deck (served with React, react-dom, Babel and lucide from their npm tarballs through route interception) at 360, 768, 1280 and 1440, light and dark: the card's border, radius, padding, gap, the 32px tile and its tones, the title and hint type and colour, the 38 x 22 switch and its 18px thumb equal the deck's at every width; the card's width is the Sources pane's (DG-205, DG-207), so its height follows the hint's wrap, which is one line shorter without the credit figure. The order (files, card, privacy note) is the page-width design's. No sideways scroll; the switch's ring is 2px --focus in both themes. Vietnamese, off and blocked, was checked at 360 and 1280.

T-D4.40 — Documents for the AI assistant

Depends on: T-D4.32 merged (the behaviour exists); runs before T-D4.12 Touches: docs/quizzivy-spec-v0.3.md (§1.3, §2, §8, §12, §13, §15, §16, §17, the "Changes since" head; version per §18), AGENTS.md ("Non-negotiable", "High-risk areas", "Repository map", "The composition root"), docs/plan/70-redesign-overview.md (§4.1 is on the branch; §10), docs/plan/17-word-import.md §8, docs/plan/18-word-import-ux.md §4, docs/design/gaps.md (DG-196 to DG-202 "what was built"), docs/setup/operations.md Size: S Done when:

  • The spec: §2 names the provider behind platform/anthropic and the tiers as configuration; §8 gains the teacher AI surfaces and Settings › AI assistant, and the Admin "AI credits" page with "Availability: R5"; §12 records the panel's rules (the acceptance rule, the disabled-with-reason rule, the credit note); §13 the six tables and the append-only ledger; §15 the operations; §16 D4's scope; §17 a decision "cloud AI processing is on for the assistant's features (D19), with per-feature minimisation (D20) and a pool-constrained budget (D21)"; the "Changes since v0.7" sentence is marked superseded by D19 for these features. Version bumped per §18, after #429's v0.64.
  • AGENTS.md: "Non-negotiable" gains "The AI assistant never writes a grade, a question or a comment: a suggestion is stored only when a teacher accepts it through an existing write; no AI operation sends a student's name, email or id; ANTHROPIC_API_KEY is never logged"; "High-risk areas" gains server/internal/modules/ai/ and web/src/features/ai/; the composition-root table gains adapters/ai.go's nil rule and wiring/ai.go; the repository map gains the module and platform/anthropic.
  • 17-word-import.md §8 and 18-word-import-ux.md §4 record D19 (cloud approved; the admin-managed configuration is the environment and ai_settings; the page says what is sent); 70 §10 keeps the open item on Q35's two remaining pieces.
  • gaps.md: DG-196 to DG-202 say what was built.

As built: Documents only; no code, contract or migration changed. The head on work/deck-d4 was v0.98 (T-D4.9), so the spec is v0.99, and its "Changes since v0.98" lists every section.

  • Spec. §1.3 gains two non-goals (credits are a budget and not a payment; an AI that decides). §2 gains the provider's row. §8 gains the AI surfaces as a table of entry points and the operation behind each, Settings › AI assistant at /teacher/settings/ai, and the Admin "AI credits" page. §12 gains the panels' four rules (acceptance, disabled-with-reason, the credit note, failure). §13 gains §13.10, beside the maintenance windows of §13.9, because §13.3 is a sketch that lists no AI table: the six tables, the two constraints, the lock order, the month, the append-only ledger and the audit rows. §15 gains the sixteen operations in the endpoint listing, PublicStatus.ai, UserPreferences.ai, readWithAi and its 422, a paragraph on the assistant (the reserve, call and Finish order, the refusals and codes, what each feature sends, the subset rule, the limits), and the count of notification kinds (twelve). §16 gains D4's scope and a "Read with AI" paragraph, and the "text layer only" and "no OCR" sentences say "unless the import asked for it". §17 gains decision 4. The "Changes since v0.7" and "v0.41" sentences are marked superseded by D19 for these features and left in place.
  • What §8 and §12 say about the web. T-D4.34 to T-D4.39 are not built, so both sections give the surfaces as decided and mark them "Availability: D4"; the Admin page is "Availability: R5". Each web task removes its own line when it ships, and T-D4.12 checks that none is left.
  • Where the spec follows what was built and not the sketch. The clean-up is a bounded pass of at most five calls (T-D4.32's follow-up 1: tags for the first 200 untagged questions, duplicates chosen by the words two prompts share); a provider 4xx is 503 AI_UNAVAILABLE; the import's "Read N pages with AI · M credits" is the worker's log and the Sources pane says what leaves instead; "Not enough AI credits for {n} pages" is a run's failure code, not a sentence with a number; the contract changed a third time with a documented 422 (see "Lanes").
  • AGENTS.md. "Non-negotiable" gains one bullet (the assistant writes no grade, question or comment; nothing identifying a student leaves; the key is a config.Secret and is never logged; no model identifier is committed; the ledger is append-only). "High-risk areas" gains web/src/features/ai/ (from T-D4.34) and server/internal/modules/ai/ and a paragraph on the reserve and Finish rule. The composition-root table gains adapters/ai.go's nil rule, wiring/ai.go, the import worker's scanReader (T-D4.33), ai_reads.go, PDFPages and ReleaseStaleAiReservations; the repository map gains modules/ai and platform/anthropic.
  • Other documents. 70-redesign-overview.md §1 records what D19 became (501 AI_OFF, the environment and ai_settings as the admin-managed configuration) and §10 keeps the open item on Q35's two pieces. 17-word-import.md §8 and 18-word-import-ux.md §4 record D19. gaps.md DG-196, DG-197 and DG-199 to DG-202 each gain a "Built in D4" note for what the server built; the web tasks add theirs, and DG-198 is R8's. operations.md gains the assistant's log lines and the live-smoke procedure.
  • Checks. node scripts/check-design-deck.mjs ("Deck OK: 20 files, 8 pages") because gaps.md is under docs/design/; the CI planner's tests do not read these files.
  • Not done here. The migration rehearsal that step 6 of the order of work pairs with this task needs a Neon branch of production. It stays in the release checklist, with T-D4.31's last box.

E. Close-out

T-D4.12 — Suites, browser verification and documents

Depends on: T-D4.2a to T-D4.2c, T-D4.3 to T-D4.11, T-D4.13 to T-D4.16, T-D4.20 to T-D4.23, T-D4.30 to T-D4.40, as far as each is in the release Done when (v2):

  • The four redrawn R4 screens (section F) and every AI surface (section G) are compared with the v2 deck at 360, 768, 1024, 1280 and 1440, light and dark, with the sidebar collapsed where the deck draws a separator; the differences listed are DG-196 to DG-202's and no other.
  • The spec's "Availability: D4" lines (§8 and §12, placed by T-D4.40) are gone: each of T-D4.34 to T-D4.39 removes its own when it ships, and this task checks that none is left and that §8's AI block and §12's panel rules say what was built. The follow-ups under "Open items" are read, and each is built or left with a reason.
  • 74-r4.md: T-R4.4, T-R4.25, T-R4.26, T-R4.27a, T-R4.28, T-R4.30, T-R4.32, T-R4.33, T-R4.36, T-R4.38, T-R4.42 and T-R4.43 each gain one closing line naming the T-D4 task that reopened them (T-R4.31a's line names T-D4.2a, T-D4.20 and T-D4.34).
  • With the key unset on a clean checkout, pnpm e2e and make test-api are green and no AI entry point is drawn.
  • With the key set on the builder's machine, the live smoke of the exit criteria runs once and its metered credits go in the PR. Done when (fourth export): Touches: web/tests/e2e/{student-interactions,student-engine.phone,group-attempt}.spec.ts, AGENTS.md ("Design"), docs/quizzivy-spec-v0.3.md (§9 S-05, S-06, S-08; §11; §12; version per §18), docs/plan/73-r3.md (T-R3.9b, T-R3.9c), docs/plan/74-r4.md (the six reopened tasks), docs/design/gaps.md, docs/design/README.md Size: L Done when:
  • The fixture papers are T-D4.3's (s1, s9 with 34 questions, s2 with both parts); this task adds none and checks that none gained an answer key on the way.
  • Every engine frame is compared with the deck at 360, 768, 1024, 1280 and 1440 and at 419 / 420, 899 / 900 and 1179 / 1180, light and dark, sizes measured with getBoundingClientRect, the dev server confirmed fresh. Every frame of DG-80's list is looked at in Vietnamese at 320, 360, 768 and 1280: nothing scrolls sideways, overlaps or is clipped.
  • In mobile-chromium: a recording plays from inside the drawer, keeps playing with the drawer closed, and the handle's dot shows it.
  • On a real phone, the same; and, with seeking off, leave the group mid-play, come back and resume: the recording goes on from where it stopped and the count has not moved (T-D4.8's rule, on iOS Safari, where metadata may not load before the first play).
  • pnpm e2e:live passes E2E 2 and 5 to 9, E2E 9's key list unchanged.
  • The teacher side is compared with the deck at 360, 768, 1024, 1280 and 1440, light and dark: the builder with a shared-context group holding a passage with two gaps and one holding a recording; the Shared content dialog in its three kinds; the question media block and the group's recording with "Plays" and "Students can pause"; the three previews as Computer and as Phone, with the engine in the frame. The differences listed are DG-186, DG-187 and DG-188's, and no other.
  • Grading's Ready to finish states are compared with page-width-proposal/Grading Finish States.dc.html; the differences are DG-208's.
  • The open gap's options are one Tab stop, the chosen option or else the first, with Up and Down between them, as the radio-group pattern has it (#596).
  • shared-content-dialog.test.tsx runs well inside its timeout: fake timers for the autosave waits, the lazy outline warmed (#596).
  • AGENTS.md's engine paragraph is rewritten: the rail at 900 and 1180, the grid button and sheet elsewhere, the drawer below 768, shared content in the stimulus pane, the cloze stop, no strip. Its sentence "so the strip can leave a gap between parts" becomes "so the rail and the sheet can head each part".
  • Spec S-05 says a section's title and instructions show above each of its questions; S-06 and S-08 describe the rail and the sheet; §12 records the thresholds.
  • 73-r3.md: T-R3.9b's and T-R3.9c's "As built" each gain one closing line naming the T-D4 tasks that replaced the switcher, the strip, the first-question instructions and the shared recordings' place. Their text is not rewritten.
  • 74-r4.md: T-R4.31a, T-R4.31b, T-R4.30, T-R4.34, T-R4.39 and T-R4.65 each gain one closing line naming the T-D4 task that reopened it and what it replaced. Their text is not rewritten.
  • AGENTS.md, "Redesign in progress": T-D4.1's bullet leaves; the engine paragraph under "Design" carries the rules.
  • gaps.md: DG-80's list is trimmed as T-D4.1 says; the rows DG-182 to DG-194 say what was built.
  • Thuong reviews the Vietnamese type names the engine shares with the builder's type menu since T-D4.5: "Tự luận" (essay) names the short answer.

As built (documents, T-D4.12a): one pull request, one commit per document. AGENTS.md's engine paragraph, spec v0.104 (§9, §12, §10.4 and §8), the closing lines in 73-r3.md and 74-r4.md, and gaps.md's DG-80 and DG-182 to DG-194 were each checked against web/src/features/take-test/ (the thresholds in Paper.tsx, panes.ts, SectionRail.tsx and Navigator.tsx), web/src/features/ai/ and its hosts, and this file's As built paragraphs. 74-r4.md takes seventeen lines, the two boxes' lists together. Each was checked against the Reopens lines of T-D4.2a to T-D4.2c, T-D4.15 and T-D4.20 to T-D4.23; T-R4.26 and T-R4.36 have no Reopens line, and theirs name what T-D4.37 and T-D4.39 added (their Touches). T-R4.38's line says T-D4.20 named it and changed nothing there, since its As built found no side column on the review workspace and made Preview and create's summary sticky (T-R4.39's line). The fixture papers are T-D4.3's (deckSession.ts: s1 with 8, s9 with 34 in four sections, s2 with both parts and 10, as deck-fixtures.test.ts asserts), and none carries isCorrect, acceptedAnswers, sampleAnswer or a transcript. gaps.md takes DG-209 for the roster's select and search, which T-D4.22 kept and no row recorded. No deck file changed, so docs/design/README.md is untouched. The boxes left open are the other half's:

  • The v2 comparison, the engine and teacher comparisons and the DG-80 frames in Vietnamese: the tester's browser verification, on its own branch.
  • The real phone and mobile-chromium: the tester's, and it also carries follow-up 6 (on a real Android phone, swipe the drawer open and closed, then tap an option at once: the option is chosen and the drawer does not move).
  • pnpm e2e:live, and pnpm e2e and make test-api with the key unset: a run against the real API, the tester's or the Tech Lead's.
  • The live smoke with the key set: the owner's key, once, at the release (follow-up 2).
  • Thuong's review of "Tự luận": the owner's.

As built (verification, T-D4.12b and T-D4.12c): the tester's sweep ran on 9b3eae47, on production builds (vite build served by vite preview), with the API stubbed through web/tests/e2e/support/api.ts and, for the live checks, a scratch stack (Postgres, MinIO and the API) with ANTHROPIC_API_KEY unset; sizes were measured with getBoundingClientRect beside the deck served on 5175. It covered the engine matrix at the five widths and the rail's and the sheet's boundaries, light and dark, on T-D4.3's fixtures, with DG-80's frames in Vietnamese at 320, 360, 768 and 1280; the teacher side (the builder's groups, the Shared content dialog in its three kinds, the media block and the three previews); the v2 screens and every AI surface; Grading's Ready to finish states (DG-208); mobile-chromium, where a recording plays from inside the drawer, keeps playing with it closed and the handle's dot shows it; and, with the key unset, pnpm e2e and make test-api green and no AI entry point drawn. Its findings were closed in two pull requests, except QA-12b-4, which is follow-up 11. #596: the cloze's options take one Tab stop with Up and Down between them, the phone spec reads the drawer handle's place from its layout, and shared-content-dialog.test.tsx waits with fake timers. T-D4.12c: the phone strip's failed-save line wraps instead of ending in an ellipsis at 320px (QA-12b-1, T-D4.9 / DG-80); a shared group's question rows are 33px apart, as ungrouped rows and the deck are (QA-12b-5, T-D4.14); the engine's title is the page's <h1> (an <h2> in a preview, whose host has its own <h1>) and the question panel a named group (axe); Class detail's New code and Stop take the deck's type and padding (U4); the grading card's block sentence is one line, not a 180px band (U5); and gaps.md records what was kept: U1 in DG-135, U2, U4's 1.3px and U7 in DG-205, U3 in DG-183, U6 in DG-200, U8 in DG-201 and U9 in DG-01. Those rows join DG-186 to DG-188 and DG-196 to DG-202 as the differences the two comparison boxes allow. The boxes left open:

  • The real phone, iOS Safari included, and follow-up 6's Android tap: the owner's, on devices neither the tester nor CI has.
  • The live smoke with the key set (follow-up 2) and Thuong's review of "Tự luận", as above.

As built (the second verification, #600): one pull request after #594 to #596, merged with develop after #597, #601 and #602. Three side-by-side comparisons on production builds, each against the pinned deck (Deck OK: 22 files, 10 pages) with its CDN files served from npm and the app's own Be Vietnam Pro, sizes from getBoundingClientRect:

  • Engine. Every frame of s1, s9 (34 questions) and s2 at 360, 768, 1024, 1280 and 1440 and at 419 / 420, 899 / 900 and 1179 / 1180, light and dark. Four differences were fixed and measured again equal to the deck: the drawer's tip hung 25.5px above the handle while it animated (the qz-tip keyframes carried the -50% the tip's class already sets), the grid button's 8px padding cut its short label with an ellipsis from 768 to about 772px, a cloze stop's line was 6px taller when it wrapped, and the footer's key caps were the mono face DG-141 rules out. The rest is DG-60, DG-80, DG-184, DG-185 and DG-189 to DG-194, and DG-212. DG-80's list in Vietnamese at 320, 360, 768 and 1280, light and dark, on a paper built for it: 472 cells, 468 with nothing that scrolls sideways, overlaps, is clipped or covered; the four others are the "Saving…" line, which below 768 shows only for a failed or offline save (DG-80). DG-80's timer widths depend on the font files the deck loads; with ours the structure holds and the figures differ.
  • Teacher, fourth export. The builder with a passage group and a recording group, the Shared content dialog in its three kinds, the media block and the group's recording with "Plays" and "Students can pause", and the three previews as Computer and Phone, at the five widths, light and dark: no sideways scroll. The group's "…" menu took the deck's 200px. The rest is DG-186 to DG-188, and DG-210 and DG-211.
  • Teacher, v2. Section F's four screens, the eight sticky columns and the collapsed sidebar's separator, and every AI surface, at the five widths, light and dark, with the sidebar collapsed from 768. The blocked and failure states the deck does not draw (DG-201) were checked for behaviour and strings. The Generate panel's apply button, "Thêm 5 câu vào Passage 1 · Urban green space", was nowrap and could not shrink, so at 360 it ran 18px out of the sheet in Vietnamese; it now truncates inside the footer, its whole label kept as its name and title, measured inside the sheet in both languages at 360, 768 and 1280 with an 80-character section title. The rest is DG-196 to DG-208's, and DG-210 and DG-211.

Suites on the merged head: make test-api green (end-to-end with no skip; integration skips only the Word converter's twelve, which need its image and a non-root worker, on the branch point too); pnpm lint, typecheck, format:check, the unit and integration suites, build, pnpm e2e and pnpm e2e:content green, with the key unset, and no AI entry point drawn while the server has no assistant (builder/ai-entry, question-bank/bank-ai and settings/ai-assistant assert it). On a machine shared with the comparisons, about forty unit and integration tests timed out at the 5s limit and passed when run alone; the builder's question-switch test, which mounts the whole builder and takes 3.3s alone, has its own 15s timeout, its assertions unchanged. The AI clean-up race test (TestTwoCleanupsRacingForOnePoolWithRoomForOneLeaveExactlyOne) relied on a 20ms sleep to hold the first reservation and failed when the second start was 300ms late; it now holds the reservation until a clean-up returns. pnpm e2e:live passes all fourteen, E2E 2 and 5 to 9 among them, with E2E 9 unchanged since v0.10.0; E2E 10 needs Word imports configured (IMPORT_S3_BUCKET, IMPORT_WORK_DIR, the worker), as .env.example says. A new phone spec plays a recording inside the drawer, closes it, and sees the recording go on and the handle's dot.

Left open, with reasons: the real-phone checks, follow-up 6's swipe-then-tap among them (the owner's phone); the live smoke with the key, follow-up 2 (the owner's key); Thuong's review of "Tự luận". Follow-up 3 is a question for the design team, also asked in DG-212.

T-D4.18 — Release v0.11.0

Depends on: T-D4.1 to T-D4.16, T-D4.19 to T-D4.23, T-D4.30 to T-D4.40 Touches: web/package.json, docs/plan/74d-d4-deck-update.md (the checklist), docs/plan/70-redesign-overview.md (§2: D4 released), docs/plan/75-r5.md to docs/plan/80-r10.md (their titles take the versions 70 §2 names, Q29), release notes Size: S Done when:

  • The release checklist below is complete, with Thuong's go before main
  • 75-r5.md names the release R5 follows: under "Schema changes", "the v0.10.0 binary keeps inserting" reads "the D4 binary (v0.11.0)", and T-R5.1's "Depends on" reads "D4 (v0.11.0) in production". The steps are still the ones R4 owes; D4 adds none.
  • ANTHROPIC_API_KEY and the tier variables are set as Fly secrets before the API deploys (Thuong, docs/setup/operations.md); without them the release is still complete and the AI is off.

Release checklist

v0.11.0, the deck update and the AI assistant (Q29, decided 2026-10-10)

  • Every task is ticked on work/deck-d4. develop is merged into it, and verification is re-run after that merge. Then work/deck-d4 → develop (--no-ff), and release/0.11.0 is cut from develop at a named commit.
  • Every CI step runs on the release pull request, in order, judged by exit code (70 §8.1): - make lint, make gen-check - make test-api, and goose up/down/up - pnpm lint, pnpm typecheck, pnpm format:check - pnpm test:unit, pnpm test:integration, pnpm build - pnpm e2e (chromium and mobile-chromium), pnpm e2e:content - pnpm e2e:live, required because take-test, integrity and media changed - node scripts/check-design-deck.mjs, which prints "Deck OK: 22 files, 10 pages"
  • The five canaries pass, and their files are as they were at v0.10.0: git diff --stat v0.10.0 release/0.11.0 -- <the five> is empty.
  • Each task that touched a high-risk area has its before-and-after suite runs and its deck comparison in its PR: T-D4.3 to T-D4.11, T-D4.13, T-D4.2a, T-D4.2b, T-D4.2c, T-D4.14, T-D4.15, T-D4.16. T-D4.12's matrix is complete.
  • Security: the AI operations are the only operations added and the open list is the same; permissions.golden changed once (T-D4.32) and holds the two AI keys; R2's isolation suite (every /teacher/ai/* entry included) and escalation tests pass; E2E 9 passes with its key list unchanged (allowPause is a rule a student reads, not a key); payload_test.go walks StudentAudioPolicy; no AI response reaches a student payload; operations_test.go's minimisation cases pass; the key appears in no log of the rehearsal.
  • Migrations: T-D4.13's four, T-D4.31's six, T-D4.32's one and T-D4.33's one are rehearsed on a Neon branch of production. The timings and the up/down/up run go in the release PR.
  • Credits: on the Neon branch, a deliberate reservation past the pool is refused by the constraint; ai_settings reads 200,000 and 20,000; the maintenance command's status prints the month.
  • Rolling deploy: the API deploys first, then Pages. A tab of v0.10.0 keeps saving a question's and a group's audio policy, and a "cannot pause" set from a new tab survives that save: an absent field keeps the stored value (allow_pause_test.go, case c).
  • Attempts in progress at the deploy keep their answers, flags and play counts. The draft and flag formats and the open-question key did not change (draft-compat.test.ts, flags.test.ts, open-question.test.tsx). A play in progress at the reload costs one play (T-D4.8), and the release notes say so.
  • A read-only review of everything since v0.10.0, by three reviewers (a student in the middle of a test and the answer key; listening and its counts; access and the contract), finds no blocker.
  • Exam window: a query of production assignments open during the planned window returns none, or Thuong picks another window.
  • web/package.json reads 0.11.0, set on the release branch.
  • Release notes, vi first: - the test screen shows the parts and their questions at the side on a computer, and from one button elsewhere - on a phone the passage or the recording opens from the left edge - a recording has its own card, and pausing it no longer costs a play - a fill-in-the-gaps passage is answered on one screen - tests in progress keep their answers; a recording that was playing at the reload counts one more play - for teachers: a group's passage or recording is edited in "Shared content"; the preview is the student's own screen, on a computer or a phone; a teacher can stop students pausing a recording - for teachers: the AI assistant: questions from a passage, a check of a test, a variant, explanations and wrong options, a score suggestion, an explanation of a class's results, scanned tests read on import; every suggestion is yours to accept or edit; your usage and preferences in Settings › AI assistant; the school's monthly pool and your allowance - for the owner: cmd/maintenance ai-credits until the Admin page (R5) - the assignment's progress card filters the roster; the join-code card shows the QR for a projector
  • Deploy and smoke: - Thuong's go, then release/0.11.0 → main - ANTHROPIC_API_KEY and the tier variables set as Fly secrets (or deliberately left unset: the AI is then off and the release is still whole) - deploy the API (the release command migrates), then Pages - smoke /healthz, /livez; /version.json reads 0.11.0; a sign-in as an admin and as a student - a student takes a short test with a passage, a cloze and a recording in the owner's test class, once on a phone and once on a laptop; the recording is paused and resumed and its count moves once - the owner opens a group's Shared content, saves a passage with one gap, and previews the test as Computer and as Phone - with the key set: the owner generates three questions from the test's passage, asks for a score suggestion on one short answer, and reads the ledger with ai-credits status; the three calls are metered and the pool moved by their sum - tag v0.11.0, and back-merge main to develop
  • The documents changed with the code (70 §3): AGENTS.md's engine paragraph, its "Redesign in progress" bullet and T-D4.40's three additions, the spec's S-05, S-06, S-08, §11, §12, §13 and §15 and T-D4.40's sections, 20-data-model.md, gaps.md, docs/design/README.md, docs/setup/operations.md, .env.example, and the closing lines in 73-r3.md and 74-r4.md (T-D4.12).

Release preparation (2026-10-13), the evidence behind the ticks:

  • Branch. develop was merged into work/deck-d4 (#598) and CI ran in full on that head (8c005f0a). work/deck-d4 went to develop by #599 (30c85029) after the review's fixes. release/0.11.0 is cut from develop at 30c85029.
  • Tasks. Every build task is ticked. The boxes left open are the owner's:
    • the real-phone and iOS checks;
    • the live AI smokes with the key;
    • Thuong's review of "Tự luận";
    • follow-ups 2, 3 and 6, each with its reason.
  • Canaries. git diff --stat 4098f784..HEAD over the five canaries is empty: v0.10.1 holds v0.10.0's canaries unchanged, and the tag v0.10.0 is not in the clone. They pass (the review of "a student in the middle of a test").
  • Review. Three read-only reviewers read 4098f784..d0c7bf1a.
    • Access and the contract: no blocker. There are 16 AI operations, theOpenSeven is unchanged, permissions.golden changed once, and the generated code shows no drift.
    • Listening: one blocker and one major (an uncounted play when two audio questions follow each other; a remembered place outliving the page). Both were fixed in #602 and verified by the same reviewer.
    • A student mid-test: one major (a double click answered two cloze gaps), fixed in #601.
    • A minor from listening is left as a rollback note in docs/setup/deploy.md, "Rolling v0.11.0 back": 00114's CHECK against v0.10.1's update.
  • Migrations and credits. The Neon branch of production was rehearsed 113 → 125 → 113 → 125, as "Rolling out v0.11.0" records, and the branch was deleted. cmd/maintenance ai-credits status was not run there: the sandbox reaches Postgres only over Neon's HTTPS endpoint. It runs after the deploy, in the smoke.
  • Exam window. On the branch (production at 19:17 UTC), no assignment is open or opens in the next seven days.

Part 4 of 5 of D4 — Deck update: the fourth export and v2 (v0.11.0) · Previous · Next


Generated from docs/plan/74d-d4-deck-update.md. Edit that file in the repository; changes made on this page are overwritten.

⚠️ **GitHub.com Fallback** ⚠️