Assessment Architecture¶
Status: Design - umbrella roadmap for reconciling CMS-authored practice widgets with LMS graded quiz entries.
This document defines the target assessment mechanic for the Open Learning Hub LMS. It connects the existing Quiz System, Analytics, Entry Pagination System, and CMS widget model into one course-creator and learner experience.
The decision is intentional:
- Canonical graded assessment: first-class quiz entries, server-graded through the LMS.
- Formative practice: existing assessment widgets stay available inside lessons for client-side practice and immediate feedback, but they are not persisted, scored, or used for entry gating.
Current State¶
The codebase currently has two assessment models that do not line up.
| Model | What exists | Gap |
|---|---|---|
| Structured quiz entry | QuizSchema and QuizPublicSchema in src/schemas/cms/quiz.ts; quiz_attempts; rate limit policy; entry_progress integration spec |
No CMS authoring vehicle emits the shape; QuizPlayer is a stub and submits a hard-coded pass |
| Assessment widgets | Eight CMS widgets under the assessment category; shared widget renderers self-grade in the browser | Answer keys are shipped to the client; scores are ephemeral or local-only; widget submits do not reach LMS persistence |
The LMS also infers an entry as a quiz when a CMS page contains one of several legacy assessment widget types in src/lib/course/entry-classifier.ts. That inference made early prototypes easy, but it now creates ambiguity: a page with a practice widget becomes a graded entry even though no server-graded assessment exists.
Target Model¶
Graded quizzes are explicit. A CMS page becomes a graded LMS quiz entry only when it contains one dedicated structured quiz widget. Legacy assessment widgets no longer classify a page as a quiz entry.
flowchart TB
subgraph cms [CMS Authoring]
Page["Leaf page in course outline"]
QuizWidget["structured-quiz widget"]
PracticeWidgets["legacy assessment widgets"]
end
subgraph wire [Shared Wire Contract]
QuizContent["quizWidgetContentSchema"]
WidgetContent["legacy widget content schemas"]
end
subgraph lms [LMS Runtime]
Adapter["getEntry quiz adapter"]
FullQuiz["Quiz server-only"]
PublicQuiz["QuizPublic client-safe"]
QuizPlayer["QuizPlayer"]
SubmitAction["submitQuizAttemptAction"]
Attempts["quiz_attempts"]
Progress["entry_progress"]
Reports["learner and admin reports"]
LessonPlayer["LessonPlayer"]
end
Page --> QuizWidget --> QuizContent --> Adapter
Adapter --> FullQuiz
FullQuiz --> PublicQuiz --> QuizPlayer
QuizPlayer -->|"answers only"| SubmitAction
SubmitAction --> Attempts
SubmitAction --> Progress
Attempts --> Reports
Progress --> Reports
PracticeWidgets --> WidgetContent --> LessonPlayer
The hard boundary is that answer keys never reach the browser for graded quizzes. The server holds the full quiz, strips it to QuizPublic for rendering, and re-fetches or re-validates the authoritative CMS quiz on submission before grading.
Authoring Vehicle¶
Recommendation¶
Add a new dedicated structured-quiz widget in the CMS and shared widget wire schemas. One structured-quiz widget on one leaf CMS page represents one graded LMS quiz entry.
This is the recommended V1 because it matches the current CMS model:
- Courses are CMS projects.
- Course structure is a nested page tree.
- Entry bodies are authored as blocks and widgets.
- There is no page type, page template, or separate quiz document model today.
Adding a widget gives course creators a clear "graded quiz" affordance without requiring a broader CMS page-type migration.
Author Workflow¶
- Create or select a leaf page in a course project.
- Add one
structured-quizwidget from the assessment category. - Configure quiz settings:
passThreshold, stored as a fraction from0to1.maxAttempts, integer>= 1.shuffleQuestions, defaultfalse.showPerQuestionFeedback, defaulttrue.
- Add questions:
multiple_choicetrue_falseshort_answer
- Add optional explanation copy for review mode.
- Publish the project. The LMS treats the page as a
quizentry.
The editor must surface validation inline:
| Rule | Editor behavior |
|---|---|
| Multiple choice needs at least two options | Block save or show inline validation until fixed |
| Multiple choice needs at least one correct option | Show a question-level error |
| Single-select multiple choice should have exactly one correct option | Prefer a radio-style authoring control when multiSelect is false |
| Short answer needs at least one accepted answer | Show a question-level error |
passThreshold must be 0..1 |
Use percent UI if helpful, but persist the fraction |
maxAttempts must be at least 1 |
Reject 0, null, and negative values |
One Quiz Per Page¶
V1 enforces one structured-quiz widget per page. A page may contain explanatory text, images, or other non-assessment content around the quiz, but it must not contain multiple graded quiz widgets.
If an author needs multiple graded checkpoints, they should create multiple quiz pages in the course outline. This keeps attempts, reporting, gating, and analytics scoped to one (courseSlug, entrySlug) pair.
Forward Path¶
If the CMS later needs many explicit entry types, promote quiz intent and quiz settings to page-level metadata:
pages.properties.entryType = "lesson" | "quiz" | "assignment"pages.properties.quizSettings = { passThreshold, maxAttempts, ... }
That larger page metadata model is not required for V1 and should not block the structured quiz widget.
Wire Contract¶
The new widget content schema should align with the existing LMS quiz schemas rather than invent a parallel shape.
type StructuredQuizContent = {
instructions?: PortableText;
passThreshold: number;
maxAttempts: number;
shuffleQuestions?: boolean;
showPerQuestionFeedback?: boolean;
questions: QuizQuestion[];
};
QuizQuestion should remain the shape already documented in quiz-system.md:
multiple_choicetrue_falseshort_answer
The shared package should expose a schema such as structuredQuizContentSchema or quizWidgetContentSchema from @open-learning-hub/widget-wire-schemas. The LMS adapter then maps one widget instance plus page metadata into the existing Quiz domain shape:
type Quiz = {
_type: "quiz";
_order: number;
slug: string;
title: string;
instructions?: PortableText;
passThreshold: number;
maxAttempts: number;
shuffleQuestions?: boolean;
showPerQuestionFeedback?: boolean;
questions: QuizQuestion[];
};
The server-only Quiz includes correct flags and acceptedAnswers. The client receives only QuizPublic from toQuizPublic(quiz).
LMS Entry Classification¶
The target classifier rule is:
structured-quizpresent: entry_typeisquiz.- Otherwise: entry remains
lesson, unless a future explicitassignmentmarker exists.
Legacy widgets should be removed from QUIZ_WIDGET_TYPES during the implementation. That behavior change must be called out in release notes because a page with only form, trueorfalse, quick-questions, quick-questions-multiple-choice, or rubric will stop being gated as a graded quiz and will render as a lesson with formative practice.
The implementation should keep the classifier narrow and explicit. Do not classify by broad widget category, because the assessment category contains formative and self-reflection widgets such as checklist and rubric.
Grading and Submission Flow¶
The grading flow follows quiz-system.md, with the authoring vehicle clarified here.
sequenceDiagram
participant Student
participant QuizPlayer
participant Action as submitQuizAttemptAction
participant CMS
participant DB as Kysely DB
Student->>QuizPlayer: Complete QuizPublic form
QuizPlayer->>Action: Submit courseSlug, entrySlug, answers, startedAt
Action->>Action: Auth, access, rate limit, preview check
Action->>CMS: Re-fetch structured quiz content
Action->>Action: Validate answers against server-only Quiz
Action->>Action: Grade score and pass/fail
Action->>DB: Insert quiz_attempts
alt passed or attempts exhausted
Action->>DB: Upsert entry_progress.completed_at
end
Action->>QuizPlayer: Return score, attempt, feedback, remaining attempts
Submission requirements:
- Resolve the authenticated user.
- Reject preview mode without writes.
- Enforce enrollment, course access, and gating.
- Rate-limit by authenticated user using the existing
quizSubmitpolicy. - Re-fetch the CMS quiz and grade against the server-only payload.
- Validate every answer shape by question type.
- Refuse attempts when the learner has already passed or exhausted attempts.
- Insert
quiz_attemptsfor every valid submission. - Set
entry_progress.completed_atwhen the learner passes or exhaustsmaxAttempts. - Revalidate course and progress state.
The action must never trust score, passed, correct, acceptedAnswers, or passThreshold from the client.
Learner Experience¶
The learner experience should be a single-page quiz form with one submission per attempt.
Core states:
| State | UI |
|---|---|
| Not started | Quiz form, attempts remaining, submit disabled until all questions are answered |
| Submitting | Submit button disabled, no automatic retry |
| Failed with attempts remaining | Results view, score, pass/fail banner, per-question correctness if enabled, Retry button |
| Passed | Results or review view, score, next-entry affordance, no retry |
| Attempts exhausted | Review-only results, no retry, next-entry affordance |
| Preview mode | Render quiz read-only or allow local interaction only; server submission writes nothing |
The results view must provide:
- Score line (
correctCount / totalCountplus percent). - Pass/fail banner with focus management.
- Per-question feedback rows when enabled.
- Learner answer echo.
- Explanation copy only in review mode.
- Retry when failed and attempts remain.
- Next-entry affordance only when the quiz entry is complete.
Accessibility follows quiz-system.md: fieldsets and legends for question groups, form error summary with role="alert", focus moved to the outcome banner after submit, and translation-keyed UI chrome.
Scoring Rules¶
V1 scoring stays intentionally simple:
- Equal-weight questions.
- All-or-nothing correctness per question.
- No partial credit.
- No negative marking.
- No timers.
- No essays or file-upload questions.
Question-specific grading:
| Type | Rule |
|---|---|
multiple_choice single-select |
Selected option id equals the only correct option id |
multiple_choice multi-select |
Selected option id set exactly equals the correct option id set |
true_false |
Boolean equality |
short_answer |
Normalized string membership in acceptedAnswers using trim and caseSensitive |
The persisted score is correctCount / totalCount. The attempt passes when score >= passThreshold.
Formative Widgets¶
Existing assessment widgets continue to serve a useful role as low-friction practice:
trueorfalsequick-questionsquick-questions-multiple-choiceformguessrubricchecklistselect-media-files
Their target role is formative, not graded:
- They render in lessons through
LessonPlayer. - They may self-grade or provide local feedback.
- They do not write
quiz_attempts. - They do not write
entry_progress. - They do not appear in learner or admin assessment reports.
- Their answer keys may remain in client payloads because they are not trusted assessment records.
If a course creator wants a gradebook-visible result, they must use structured-quiz.
Reporting¶
Assessment reporting reads from LMS-owned persistence, not from widget-local state.
Learner Reports¶
Learner reports use quiz_attempts, entry_progress, and the CMS hierarchy for labels and ordering.
V1 learner surfaces:
| Surface | Data |
|---|---|
/learn/dashboard |
Recent quiz scores, course progress, continue target |
| Course landing | Progress summary and next incomplete quiz/entry |
| Entry review mode | Latest or best relevant attempt for the current quiz |
| Optional score history section | All attempts for a learner scoped to their own user id |
Learner queries must always scope by tenantId and authenticated userId. They must not use shared cache across users.
Admin Reports¶
Admin reports follow analytics.md.
| Surface | Data |
|---|---|
/admin/courses/\[slug\]/dashboard |
Completion rate, entry funnel, quiz pass rates |
/admin/courses/\[slug\]/dashboard/quizzes |
Per-question averages, attempts-to-pass distribution, quiz-level drilldown |
/admin/page |
Tenant-level enrollment and progress overview |
Access rules:
- Course admins see assigned courses only.
- Tenant admins see their tenant only.
- Super admins must use the existing tenant override pattern for cross-tenant views.
- Aggregate reports should avoid exposing learner names or emails unless the route is explicitly a roster or student-detail surface.
Initial query helpers should read OLTP tables directly. Materialized views are a future optimization and must preserve the helper return types.
Data Sources¶
The source of truth is split by concern:
| Concern | Source |
|---|---|
| Course/module/entry order and labels | CMS project page hierarchy |
| Quiz questions and answer keys | CMS structured-quiz widget content, server-only |
| Learner answers and scores | quiz_attempts |
| Entry completion and gating | entry_progress |
| Enrollment and course access | enrollments plus role/assignment checks |
| Admin audit trail | audit_log |
Do not add an analytics event table in V1. The existing domain tables are enough for launch volume.
Migration Notes¶
The move from widget-sniff quizzes to explicit structured quizzes is a behavior change.
Existing content with only legacy assessment widgets will become lesson content with formative practice. This is the desired target, but teams should audit existing courses before shipping the classifier change.
Recommended migration path:
- Add the
structured-quizwidget and leave the old classifier behavior temporarily available behind an implementation flag or branch-only compatibility note. - Audit CMS pages that currently classify as quiz because they contain legacy assessment widgets.
- For pages that need gradebook-visible results, add a
structured-quizwidget or split the content into a dedicated quiz page. - Remove legacy widget types from
QUIZ_WIDGET_TYPES. - Update author docs to explain graded quiz vs formative practice.
Do not attempt to auto-convert every legacy widget. The semantics do not map cleanly:
form.passRateuses percent whileQuiz.passThresholduses a fraction.quick-questionsuses index-based answers, not stable option ids.rubricis self-assessment, not an answer-key quiz.checklistcan be progress tracking, not correctness.
Implementation Roadmap¶
Phase A - CMS Authoring and Wire Contract¶
- Add
structured-quizschema to@open-learning-hub/widget-wire-schemas. - Add CMS registry entry and editor component.
- Add authoring validation and one-widget-per-page guidance.
- Add defaults that match
QuizSchema. - Document author-facing graded vs formative distinction.
Phase B - LMS Quiz Extraction and Secure Grading¶
- Add an LMS adapter that extracts
structured-quizcontent into server-onlyQuiz. - Pass only
QuizPublictoQuizPlayer. - Change entry classification to
structured-quizonly. - Rebuild
submitQuizAttemptActionto accept answers only and grade server-side. - Re-fetch the authoritative CMS quiz during submission.
- Persist real scores and feedback.
Phase C - Attempts and Results UX¶
- Replace the stub
QuizPlayersubmit flow. - Add
QuizResults, pass/fail banner, score line, feedback rows, retry, review mode, and next-entry affordance. - Enforce
maxAttemptsand passed-terminal behavior. - Complete entries on pass or attempts exhausted.
- Add preview-mode read-only behavior.
Phase D - Learner Reports¶
- Add quiz-attempt query helpers scoped by user and tenant.
- Add recent scores and progress to
/learn/dashboard. - Add current-entry review/history surfaces.
- Keep per-user data uncached across users.
Phase E - Admin Analytics¶
- Add analytics query helpers from analytics.md.
- Wire
/admin/courses/\[slug\]/dashboardto completion, funnel, and pass-rate cards. - Add
/admin/courses/\[slug\]/dashboard/quizzesfor question and attempts-to-pass drilldowns. - Component-test empty, zero-attempt, and populated states.
- E2E-test analytics after enrollment, progress completion, and quiz submission.
CSV export is intentionally out of scope for this roadmap. Add it after the reporting query contracts settle.
Documentation Corrections¶
The implementation should update adjacent docs when code lands:
- quiz-system.md should clarify that CMS authoring is via
structured-quizwidget in V1. - analytics.md remains the query contract but should link back to this architecture doc.
planning_milestone_3.mdcurrently overstates completion of Phases 8 and 9 relative to the shipped code; update it or add an audit note when implementation starts.- hierarchy-structure.md says ordering is CMS-authored and 1-indexed, while current mapping renumbers entries 0-based; avoid relying on raw
_orderfor assessment persistence.
Decisions¶
These were previously open; they are now resolved.
- Page composition:
structured-quizmay share a page with non-assessment widgets (explanatory text, images) placed above or below it. Only the quiz widget is graded, and the one-graded-quiz-widget-per-page constraint still holds. - Review source attempt: review mode shows the learner's latest attempt. A best/highest-scoring view is deferred until a future policy allows retaking already-passed quizzes.
- Admin report granularity: admin reports aggregate first and avoid named learner rows outside explicit roster or student-detail contexts. Dedicated student-detail surfaces can be added later.
- Legacy migration: no migration tooling is provided and no automatic or manual data migration of legacy widget content is performed. The only required change is that the database schema supports structured quiz persistence (the existing
quiz_attemptstable, extended only if grading needs a new column). Authors recreate any graded checks as newstructured-quizcontent where desired; existing legacy widgets simply become formative practice.
Related Documents¶
- Quiz System - server grading, retry policy, results UI,
QuizandQuizPublic. - Analytics - course-admin and tenant-admin query contracts.
- Entry Pagination System - completion and gating handoff.
- Hierarchy Structure - CMS course outline projection.
- Content Rendering - shared widget rendering in LMS pages.