Skip to content

Feature Outline

Ramiro Cantu edited this page May 30, 2026 · 1 revision

Feature: Outline & Mastery

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.

Domain-blind principle (V-O3)

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 (sectionfoundational_conceptcontent_categorytopic). 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.json is the raw scraped source (keys version/sections). app/seeds/aamc_outline.schema.json is the import-shaped payload ({course, nodes:[{path,kind,name,position}]}) that the :import endpoint accepts. Only the .schema.json form is a valid upload body.

The recursive node tree

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.

>>-path resolution (V-O4)

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 a node_id, returning None (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.

Subtree membership is a SET, not a sum (V-O1 / V-O5)

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 subtree

This 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).

User flow

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:[...]}
Loading

Endpoints

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:import uses an HTTP POST with a literal colon in the path segment ({course_id}/outline:import), not a query param. The request body is a raw dict[str,Any] validated by validate_outline_schema, not a Pydantic body model — so a malformed schema returns a domain 422 {errors:[...]}, not FastAPI's field-level validation envelope.

Mastery response shapes

compute_node_mastery(session, node_id)

{
  "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"}, ... ]
}

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).

Functions involved

Controller → service → data-layer chains (see Function-Index):

Data models touched

  • Course — study domain; slug unique; cascades to its nodes.
  • OutlineNode — the recursive tree (parent_id self-FK, kind/name/depth/position).
  • QuestionTag — the only mastery input; reads filter node_id IN subtree AND is_overridden = false.

Edge cases & business rules

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.slug and course.name present and non-empty; description a string when present.
  • nodes non-empty; each path a non-empty list of non-empty strings.
  • V-O4: no path segment may contain the reserved >> delimiter.
  • kind and name non-empty; name must 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 to len(path) - 1; position a 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, with topic used 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 OutlineImportError500.

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

Clone this wiki locally