-
Notifications
You must be signed in to change notification settings - Fork 0
Feature Outline
Summary: You upload a course outline schema — a flat list of named nodes with hierarchical paths — and the server materializes it into a recursive node tree. Every course is described by its own uploaded schema; the engine has no built-in taxonomy. Once an outline exists, questions get tagged to its nodes (by the Intelligence-Layer grounded categorizer) and you can read subtree-rolled mastery at any node or for the whole course. This is the structural backbone the rest of the app keys off: tags, mastery, knowledge-base facts, and tutor search all resolve against outline nodes.
The core is domain-blind (SPEC §A): there is no hard-coded MCAT/AAMC structure anywhere in the schema. AAMC is just one course whose outline happens to be 4+ levels deep, expressed entirely through generic kind labels (section → foundational_concept → content_category → topic). Any course — anatomy, organic chemistry, a bar-exam outline — is the same shape: a courses row plus an outline_nodes tree. The reference seed lives at app/seeds/aamc_outline.schema.json (1554 nodes, depth 0–7) and is uploaded through the same public import endpoint as anything else — it is not privileged.
Note: Two AAMC artifacts exist and are easy to confuse.
app/seeds/aamc_outline.jsonis the raw scraped source (keysversion/sections).app/seeds/aamc_outline.schema.jsonis the import-shaped payload ({course, nodes:[{path,kind,name,position}]}) that the:importendpoint accepts. Only the.schema.jsonform is a valid upload body.
A Course owns a tree of OutlineNode rows. Each node is one row carrying:
| Column | Meaning |
|---|---|
parent_id |
self-FK into outline_nodes (NULL for a root). ondelete=CASCADE. |
kind |
per-course label for what this level means (section, unit, lecture, concept, …). Free text. |
name |
the node's display name; equals the last segment of its path. |
depth |
0-based; equals len(path) - 1 (root is depth 0). |
position |
sibling ordering hint within a parent. |
The tree is arbitrary depth (V-O1) — there are no fixed levels. Uniqueness is enforced on (course_id, parent_id, name): no two siblings under the same parent may share a name.
Nodes are addressed by a >>-delimited path — e.g. "CP >> FC4 >> 4A >> Translational Motion". The delimiter constant is OUTLINE_PATH_DELIMITER = " >> " (app/models/outline.py), chosen over / because node names contain ÷-like slashes in physics formulas. OutlineLookup (app/services/outline/lookup.py) builds an in-memory index for one course and walks roots → children by sibling name:
-
node_id_by_path(path)resolves a path to anode_id, returningNone(with a warning) on any missing or ambiguous segment — resolution requires exactly one match at each level. -
path_of(node_id)is the inverse renderer. - Both sides normalize typographic punctuation (curly quotes/apostrophes → ASCII) so an LLM echoing back
"Piaget's stages"with a straight apostrophe still matches the AAMC’source. Scope is exactly four codepoints.
OutlineLookup.load() raises OutlineNotSeededError if the course is missing or has no nodes — you must import a schema first.
A node's subtree is itself plus all descendants, computed by a recursive CTE over parent_id in app/services/outline_subtree.py:
async def subtree_node_ids(session, root_id) -> set[int]: # {root} ∪ {descendants}
async def subtree_node_ids_many(session, root_ids) -> set[int]: # union of each subtreeThis matters for rollups: each question lives once at its single most-specific node (via QuestionTag.node_id). A parent's stats are the union of the distinct questions across its subtree — never a sum of child counts (which would double-count anything tagged at multiple levels). Reads key on node_id only; the legacy topic_id / content_category_id / cc_code joins are gone (V-O5).
sequenceDiagram
participant SPA as Client (SPA / CLI)
participant API as outline.py router
participant V as validate_outline_schema
participant M as materialize_outline
participant DB as Postgres
SPA->>API: POST /courses {slug,name}
API->>DB: insert Course (409 on dup slug)
SPA->>API: POST /courses/{id}/outline:import {course, nodes}
API->>API: assert body course.slug == course_id slug (409 on mismatch)
API->>V: validate whole upload
alt any error
V-->>API: OutlineSchemaValidationError(errors[])
API-->>SPA: 422 {errors:[...]}
else valid
V-->>API: ValidatedOutline (nodes in depth order)
API->>M: materialize (one txn)
M->>DB: wipe existing outline_nodes for course
M->>DB: insert tree (parents before children)
API-->>SPA: 200 {course, nodes_imported}
end
SPA->>API: GET /courses/{id}/outline
API-->>SPA: 200 {course, nodes:[...]}
SPA->>API: GET /outline/nodes/{id}/mastery
API-->>SPA: 200 {node, rollup, children:[...]}
The whole router is X-Coach-Token gated (dependencies=[verify_coach_token], same seam as the tutor reads). No route declares a response_model; shapes below are the literal returned dicts.
| Method | Path | Auth | Body / Response | Purpose |
|---|---|---|---|---|
| GET | /api/v1/courses |
token | → list[{id,slug,name,description}]
|
List courses, slug-ordered, for the course picker. |
| POST | /api/v1/courses |
token |
CreateCourseBody{slug,name,description?} → {id,slug,name,description} (201) |
Create a course row. |
| POST | /api/v1/courses/{course_id}/outline:import |
token | raw {course,nodes} dict → {course, nodes_imported}
|
Validate-then-materialize a schema atomically (V-O2/V-O3). |
| GET | /api/v1/courses/{course_id}/outline |
token | → {course, nodes:[{id,parent_id,kind,name,depth,position}]}
|
Return the materialized node tree, ordered by depth, position, id. |
| GET | /api/v1/outline/nodes/{node_id}/mastery |
token | → compute_node_mastery dict |
Per-node subtree set-rollup + per-direct-child breakdown (T44). |
| GET | /api/v1/outline/courses/{course_id}/mastery |
token | → compute_course_mastery dict |
Course total set-rollup + per-root-node breakdown (T44). |
Note:
outline:importuses an HTTPPOSTwith a literal colon in the path segment ({course_id}/outline:import), not a query param. The request body is a rawdict[str,Any]validated byvalidate_outline_schema, not a Pydantic body model — so a malformed schema returns a domain422 {errors:[...]}, not FastAPI's field-level validation envelope.
compute_node_mastery(session, node_id) →
compute_course_mastery(session, course_id) → {course:{id,slug,name}, total:{rollup}, nodes:[{root rollups}]}. Each rollup is a Rollup dataclass: attempts, correct, accuracy (correct/attempts, 0 when no attempts), and a 95% Wilson lower bound on the accuracy proportion (wilson_lower, pure math, 0 at zero attempts). The rollup counts distinct non-overridden tagged questions in the subtree, joined against Attempt (each question once).
Controller → service → data-layer chains (see Function-Index):
-
Create / list:
create_course,list_courses(app/api/v1/outline.py) →Coursedirectly. -
Import:
import_outline→validate_outline_schema→materialize_outline(app/services/outline/importer.py). -
Read tree:
read_outline→OutlineNodeselect. -
Mastery:
node_mastery/course_mastery→compute_node_mastery/compute_course_mastery(app/services/analytics.py) →subtree_node_ids(app/services/outline_subtree.py) →wilson_lower. -
Path resolution (used by ingest/categorizer/tutor, not the routes above):
OutlineLookup(app/services/outline/lookup.py).
-
Course— study domain;slugunique; cascades to its nodes. -
OutlineNode— the recursive tree (parent_idself-FK,kind/name/depth/position). -
QuestionTag— the only mastery input; reads filternode_id IN subtree AND is_overridden = false.
Validation (V-O2, whole-upload-or-reject). validate_outline_schema is a pure function that collects every problem and raises OutlineSchemaValidationError(errors[]) so the API surfaces them all in one 422 rather than one at a time. Checks:
-
course.slugandcourse.namepresent and non-empty;descriptiona string when present. -
nodesnon-empty; eachpatha non-empty list of non-empty strings. -
V-O4: no path segment may contain the reserved
>>delimiter. -
kindandnamenon-empty;namemust equal the last path segment. - No duplicate paths (canonicalized to stripped tuples).
-
Parent-chain closure: every non-root's
path[:-1]must itself appear as some node's path. -
depth, when supplied, must be a non-negative int equal tolen(path) - 1;positiona non-negative int (defaults to 0). -
Kind/depth agreement: all nodes at the same depth must share one
kind, else a "depth/kind contradiction" error. (The AAMC seed satisfies this — every depth maps to a single kind, withtopicused uniformly at depths 3–7.)
Atomic materialize + re-upload restores (V-O3). On success, materialize_outline runs in one transaction: it upserts the Course header, deletes all existing outline_nodes for that course, then inserts the validated tree in depth order so each child's parent_id resolves against an already-inserted parent. There is no idempotent merge — re-uploading the AAMC seed rebuilds the MCAT outline from scratch. (Caller commits; the route relies on get_session committing on clean exit.) A DB-side failure raises OutlineImportError → 500.
Slug mismatch is rejected twice (409). import_outline pins the course by URL course_id, but the body still carries course.slug. It checks the body slug against the URL course's slug before validating, then checks the validated slug again after — either mismatch is a 409 CONFLICT.
Errors:
| Code | When |
|---|---|
| 409 (create) | duplicate course slug |
| 409 (import) | body/validated course.slug ≠ the course_id's slug |
| 404 |
course id={…} not found / node id={…} not found (mastery) |
| 422 |
OutlineSchemaValidationError → {errors:[...]} (whole-upload rejection, V-O2) |
| 500 |
OutlineImportError during materialize |
Mastery is read-only and tag-driven. Mastery numbers are derived purely from non-overridden QuestionTag.node_id rows joined to Attempt; an empty subtree yields a zero rollup. Tags themselves are produced by the grounded categorizer — see Intelligence-Layer — and tagging resolves question content to nodes via OutlineLookup. The mastery surface was unfenced and re-exposed on the public API in T44 (no longer dashboard-private; V-D1/V-RB1).
See also: Home · Architecture · Data-Models · API-Reference · Function-Index · Glossary · Intelligence-Layer · Core-Library · Features · sibling features Feature-Capture · Feature-Knowledge-Base · Feature-Tutor-MCP · Feature-Anki · Feature-Platform-Admin
Reference
Features
Design Notes
{ "node": {"id", "name", "kind", "depth", "parent_id", "path"}, "rollup": {"attempts", "correct", "accuracy", "wilson_lower"}, "children": [ {"node_id", "name", "kind", "path", "attempts", "correct", "accuracy", "wilson_lower"}, ... ] }