feat(content): add course/module/activity content model
New tables (courses, course_modules, module_activities, activity_completions) layered over the existing scenario/assessment/ achievement systems — a lab activity references an existing scenario_id, an assessment/capstone activity references an existing assessment_definition_id, neither duplicated. Long-form lesson prose lives in git-tracked Markdown (content/root/), split into shared vs. instructor-only sections by a '## Instructor Notes' heading convention (content.js) rather than a second file or a CMS. course-progress.js derives completion from EXISTING sources only: auto_scenario reads the progress table, auto_assessment reads achievements by assessment_definition_id, manual_marker is the one genuinely new piece of state (for lesson-only activities with nothing else to derive completion from). Prerequisite locking treats a zero-content (PLANNED) prerequisite as vacuously satisfied, so an honestly-unbuilt course never permanently locks everything after it. Found by testing: computing a level's prerequisite lock and stripping its working totals in the same map iteration made the result depend on iteration order. Fixed with two explicit passes. Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
# Course Content Model
|
||||
|
||||
Phase 6A §3 — how RootNode represents actual teaching content (lessons, demonstrations,
|
||||
knowledge checks, guided exercises, reflections) around the existing scenario/assessment
|
||||
systems, without duplicating either. See `docs/ROOT_PROGRAM_ARCHITECTURE.md` for what this
|
||||
model is populated with, and `docs/COURSE_AUTHORING_STANDARD.md` for how to author new content
|
||||
against it.
|
||||
|
||||
## 1. Shape
|
||||
|
||||
```
|
||||
program (existing, 019_programs.sql — unchanged)
|
||||
→ course (new: courses)
|
||||
→ module (new: course_modules)
|
||||
→ activity (new: module_activities)
|
||||
```
|
||||
|
||||
`platform/api/migrations/022_course_content.sql` is the schema. Every table follows the same
|
||||
conventions already established by `programs`/`assessment_definitions`: a TEXT slug primary key,
|
||||
a plain incrementing `version` integer bumped by hand on a semantic edit, `status`/`visibility`
|
||||
CHECK constraints, `created_at`/`updated_at`.
|
||||
|
||||
## 2. Activity types and what they reference
|
||||
|
||||
| Type | References | Completion behavior |
|
||||
|---|---|---|
|
||||
| `lesson`, `demonstration`, `knowledge_check`, `guided_exercise`, `reflection` | a Markdown file (`content_path`) | `manual_marker` |
|
||||
| `lab` | an EXISTING `scenario_id` (scenarios/data/\*.json) | `auto_scenario` |
|
||||
| `assessment`, `capstone` | an EXISTING `assessment_definition_id` (`assessment_definitions`) | `auto_assessment` |
|
||||
|
||||
A `lab` activity never duplicates scenario content — it stores a bare `scenario_id` string, the
|
||||
same id the existing curriculum/attempt/progress system already uses. An `assessment`/`capstone`
|
||||
activity never duplicates an assessment definition — it stores the existing
|
||||
`assessment_definition_id` and a foreign key enforces it actually exists. Neither activity type
|
||||
is prevented from also having its own `content_path` (e.g. the gold module's lab activity has a
|
||||
short Markdown intro at `05-lab-intro.md`) — that's framing prose around the reference, not a
|
||||
copy of the referenced thing.
|
||||
|
||||
## 3. Where the prose lives
|
||||
|
||||
Long-form lesson content is git-tracked Markdown under `content/root/<course>/<module>/`, not
|
||||
Postgres. Reasoning, weighed explicitly per the authorization's instruction:
|
||||
|
||||
- **Reviewable like code.** A lesson edit is a normal diff/PR, not an opaque row update.
|
||||
- **No CMS needed.** The authorization explicitly ruled one out ("Do not build a complex CMS").
|
||||
A Postgres TEXT column holding Markdown would technically work but loses git history/diffing
|
||||
for the one thing (prose) that benefits from it most.
|
||||
- **Structural metadata still belongs in Postgres** (ordering, type, references, status,
|
||||
objectives, prerequisites) because it's queried, joined, and mutated (locking/completion) —
|
||||
exactly the kind of relational state Postgres is for, and exactly what the authorization asked
|
||||
for ("Postgres preferred for metadata/state").
|
||||
|
||||
### The Instructor Notes convention
|
||||
|
||||
One Markdown file per activity, not two. Everything before a `## Instructor Notes` heading is
|
||||
shared (self-paced-visible) content; that heading and everything after it is instructor-only.
|
||||
`platform/api/src/content.js` does the split (`splitContent()`) — a plain string search on that
|
||||
exact heading, no frontmatter parser, no template engine. The API layer (`course-progress.js`'s
|
||||
`shapeActivity()`) only ever attaches `instructor_notes` to the response when the caller is
|
||||
resolved (server-side, from session identity) to be an instructor — never from a request
|
||||
parameter. A self-paced student and an instructor read the identical file; they just see
|
||||
different slices of it.
|
||||
|
||||
## 4. Completion rules — one source of truth per behavior
|
||||
|
||||
`platform/api/src/course-progress.js`'s `isActivityComplete()` is the only place completion is
|
||||
decided, and it never invents new state for anything that's independently verifiable:
|
||||
|
||||
- **`auto_scenario`** — reads `progress[scenario_id].status === 'completed'`, the exact same
|
||||
`progress` table `curriculum-rules.js` already reads for stage completion (`platform/api/src/
|
||||
progress.js`'s `loadProgress`). A lab activity's completion is a real fact about whether the
|
||||
student actually solved the scenario, not a flag a course layer could get out of sync with.
|
||||
- **`auto_assessment`** — reads `achievements` (`achievement_type IN ('assessment_completed',
|
||||
'capstone_completed')` with `metadata->>'assessment_definition_id'` matching), the exact
|
||||
mechanism `achievements.js`'s `recordAssessmentAchievement` already writes on a real pass. No
|
||||
new achievement type, no new table.
|
||||
- **`manual_marker`** — the ONE new piece of state this phase adds: `activity_completions`
|
||||
(student_id, activity_id). Used ONLY for activity types with nothing else to derive completion
|
||||
from (a lesson has no "pass/fail," a reflection has no validator). `POST /api/student/
|
||||
activities/:id/complete` refuses (409 `not_manually_completable`) for any activity whose
|
||||
`completion_behavior` isn't `manual_marker` — a student can never fake a lab or assessment
|
||||
completion through this route.
|
||||
|
||||
Course and module completion are pure aggregates over their activities' completion (`total`/
|
||||
`completed`/`pct`/`complete` — see `totals()`), never separately stored.
|
||||
|
||||
## 5. Prerequisite/locking rule
|
||||
|
||||
A course or module lists `prerequisites` (an array of other course/module ids). It's locked if
|
||||
ANY prerequisite is not satisfied, where **satisfied** means: the prerequisite has zero total
|
||||
activities (a PLANNED entry — vacuously satisfied, since there's nothing to have completed) OR
|
||||
the prerequisite is fully complete. This is deliberate: without the "zero activities is vacuously
|
||||
satisfied" clause, an honestly-PLANNED course with no content yet would permanently lock every
|
||||
real course that lists it as a prerequisite, which would perversely punish honesty about what
|
||||
isn't built yet. See `course-progress.js`'s `prerequisitesSatisfied()`.
|
||||
|
||||
Locking is computed in two passes over each level (compute every entry's totals/prerequisite-
|
||||
check, THEN strip working state) rather than one — mutating and reading totals in the same pass
|
||||
made the result depend on Map iteration order, a real bug caught by `e2e-phase6a.js` during
|
||||
development (see that file's "found by testing" comment in `course-progress.js`).
|
||||
|
||||
## 6. Draft vs. published — separate from READY/PARTIAL/PLANNED
|
||||
|
||||
`status` (`ready`/`partial`/`planned`) describes authoring completeness and is shown to
|
||||
everyone, including students, honestly ("PLANNED — coming soon" is useful information, not a
|
||||
leak). `visibility` (`published`/`draft`) is a hard gate: a `draft` course/module/activity is
|
||||
excluded from every query a student-facing route runs (`buildCourseTree`'s `visClause`), full
|
||||
stop, regardless of its `status`. Use `draft` for genuinely unfinished authoring work you don't
|
||||
want a student to see at all yet; use `status = planned` for an honest "not built yet" that's
|
||||
fine to be visible.
|
||||
|
||||
## 7. Why this doesn't touch Phase 1-5 systems
|
||||
|
||||
No column was added to `scenarios/*.json`, `assessment_definitions`, `achievements`,
|
||||
`progress`, or `scenario_attempts`. No existing API route's behavior changed (the `/api/
|
||||
progress/record`, `/api/assessments/*`, `/api/capstone` routes are byte-for-byte unchanged).
|
||||
The course layer is purely additive: new tables, new routes, new frontend views, reading
|
||||
existing tables through their existing shapes.
|
||||
Reference in New Issue
Block a user