v2 — Dynamic courses
Status: implementation spec. Branch
feat/dynamic-courses.Document priority:
v1-scope.mdremains the source of truth for the v1 path. This document defines the v2 path and only applies when the feature flag is active. Where this document contradictsarchitecture.md,content-generation.md, orscreens.md, this document wins for the v2 path; the v1 path is untouched.Hard requirement: v1’s static path must keep working for any course that has not opted into v2. No change in this document may alter the behavior of
GET /api/v1/courses/{id}, of thebuild_content_graph()pipeline, or of the existing markdown render for a course whosedelivery_modeis notdynamic(see §10).Current episodic architecture: the separation between the course’s persisted constitution, the on-the-fly generated
EpisodeBrief, neutral capability selection,LearningExperience, and server-owned evidence is specified inlearning-experience-architecture.md. This document still describes v2 runtime persistence, delivery, and compatibility. Sections that prescribeScreenScheme, a fixed screen formula, or step grouping apply only to thelegacy_stepperfallback; they do not constrain theepisodepath.shell_modeis decided by the server. The migration keepssrc.services.course_delivery.resolve_deliveryas the single v1/v2 selector.
1. Goal and scope
1.1 The change
Today a course is generated “in one shot”: the admin uploads a document, a 7-node LangGraph graph writes the entire course in Markdown, and every employee reads exactly the same text.
v2 splits generation into two stages:
| Stage | Who | What it produces | When |
|---|---|---|---|
| Design-time | Admin/creator | A schema: title, outcome, and a list of nodes (competencies) with criticality, prerequisites, and an associated source. Zero content. | When the course is created |
| Runtime | System, per employee | For each NOT-mastered node: a UI spec generated on the fly from the node + the learner’s profile + the source | When the employee opens the node |
Between the two stages there is a blocking human gate: the creator validates the schema.
Without schema_status = 'validated', nothing is generated for any employee.
1.2 In scope for this PR (complete vertical slice)
- Course schema — a node table with criticality and prerequisites, LLM-proposed schema generation, creator editing, blocking validation.
- Onboarding — 4 questions + 1 optional, ≤90 s, that seed the learner’s profile.
- Learner profile — declared profile (role, goal, experience, preset), implicit format vector, per-node state (mastery, streaks, type of last error).
- Per-node pre-assessment — 2 items + tiebreaker, with a computable mastery rule and a threshold per criticality.
- On-the-fly per-node generation — a new LangGraph graph (
decide_formato→genera_ui→validate_ui→persist_render), with a two-tier model router. - Adapter render layer — a canonical IR (
ui_specjsonb) + one dialect backend, OpenUI Lang, behind aProtocolwith a registry. The adapter seam goes in; the second dialect does not (see §1.3 and §5.4). - Profile adjustment — a deterministic service that updates mastery, vector, and tutor notes after every answer.
- Click-to-explain (Curio) — any word or selection inside a node opens an inline contextual explanation, with server-side caching.
- Latency — skeleton, productive wait overlapped with the pre-assessment, SSE block streaming, cache per profile bucket.
- Alembic migrations (
0005), tests with recorded fixtures, and this documentation.
1.3 NOT in scope (explicit backlog, not “TBD”)
| Out | Reason |
|---|---|
SandboxHTML / free-form LLM-generated HTML |
A direct XSS vector, and 12-65% of generated code has vulnerabilities. The pattern is prompt → typed IR → native render. To be reevaluated once an audited iframe sandbox exists |
Simulation component (adjustable parameters) |
Requires data-binding and a lifecycle the IR doesn’t have. The simulation value exists in the reserved ui_format enum, but decide_formato does not emit it (the ALLOWED_UI_FORMATS constant) |
| Spaced repetition (HLR or FSRS) | Premise correction: it does not exist today. spaced_repetition/HLR appears in data-model.md, product.md, and background-processing.md, but there is no table or module in the repo (verified: 20 tables, no half_life/next_review in src/). This PR does not introduce it. Direct consequence: the needs_review state is not produced by any scheduler — its only producer in this PR is the hint cap (§7.4) |
Second dialect backend (a2tl) |
The UIDL/1 format of packages/a2tl-web is a flat list of sections with no ids or children (parser.ts:11-21) and has no primitive for QuizItem, Stack, Card, or Callout. It cannot represent the UISpec from §5.2, so round-tripping and cross-retry are impossible for any spec with an exercise or nested containers. The Protocol + registry (the seam) goes in, not the second dialect. Backlog: if needed, it will be a dialect native to SkillNet, not that package’s |
background_jobs table / purge worker |
Does not exist (verified). Purging learning_events and term_explanations is a CLI script (python -m src.scripts.purge_learning_data), documented and runnable by hand or via a host cron job. Backlog: turn it into a real job |
| QLoRA fine-tuning of the DSL | Backlog |
| Parallel router decomposition (8B skeleton + concurrent 120B fills) | The router is implemented at the full-UI level. Per-component decomposition only makes sense with SandboxHTML, which is out of scope |
Destructive migration of modules/lessons/exercises |
They coexist. v1 lessons are the seed (course_nodes.seed_lesson_id) and the degraded mode |
| Click-to-locate editing (diff-based editing over the DSL) | Backlog |
| Multi-turn chat/tutor inside the node | v1 chat stays as is. The node only has click-to-explain |
1.4 Decisions this document closes
These contradictions were left open during research. They are closed as follows, and are not reopened within this PR:
| Question | Decision |
|---|---|
| LLM output format | Typed IR (ui_spec jsonb, flat list of components). The LLM emits the active backend’s dialect; the adapter parses it into the IR. Never HTML |
| Nodes vs. modules/lessons | They coexist. course_nodes is the v2 unit; modules/lessons are the v1 path and the seed |
| Where generated content lives | New node_renders table, cached by cache_key. It is persisted (cost). The audit trail does not live there: it lives in node_render_views (§3.4) |
| Render stability within a node | View A: the render is pinned. learner_node_states.active_render_id anchors the spec from when the node is opened until the user explicitly requests a regeneration. A browser refresh or a TanStack Query refetch returns the same spec. Adaptation happens between nodes and sessions, never within an open screen. View B (continuous regeneration) is out of scope: it requires layout locking, a change explanation, and “view the previous version,” none of which fit in this PR |
| Declared experience: per course or per person? | Per person and about their own job, not about a specific course (the question doesn’t name any course). Per-competency adjustment is contributed by user_skills via course_nodes.skill_id as a prior for the probe (§7.1), not the declaration |
| Raw event log | learning_events is persisted, reversing the earlier idea of storing only the aggregated vector. Concrete reason: the decay in §3.3 needs created_at per event, and the aggregated vector cannot be recomputed if weights change. Cost: a 90-day retention window and a purge script |
format_vector dimensions |
Only the ones the kit can produce: text, exercise, code, data. diagram, audio, and resource are removed — no component emits them, so they would be structurally dead dimensions |
Neurotypes in screens.md |
screens.md §Employee Settings (“optional: TEA, TDAH, dyslexia flags”, line 213) is deprecated by the decision not to store neurotype. Fixed in the same routes chore (§14.2 #8), along with design-system.md §Skeleton, which documents animate-pulse while motion-system.md:437,636 prohibits it |
| Primary mastery scale | Real mastery 0..1 per (user, node), plus a derived node_state enum. Shu-Ha-Ri and Bloom are derivations, not primary state |
| Rating scale | The existing one: real score 0..1 (same as exercise_attempts). No 1-4 Rating |
| Modality preference | The user can explicitly request image, audio, video, or text when the kit supports them. The declared preference wins; format_vector remains a secondary inferred signal. See adaptive-learning.md |
| Neurodivergence | No neurotype label is stored (health data, GDPR art. 9). Only neutral, opt-in reading adjustments in users.accessibility |
| Nature of creator validation | Blocking gate via DB state (schema_status), not a LangGraph interrupt() — survives process restarts |
| LLM provider | litellm, provider-agnostic. The router’s two tiers are purposes (runtime_fast, runtime_heavy), not providers. Groq is a possible env var value, not a dependency |
| Frontend routes | Follows the convention already implemented in App.tsx (Spanish). Fixing screens.md is a separate chore |
| Presentation adaptation | Does not adapt until 3 nodes are completed (calibration period). It adapts what appears, not where |
2. v2 architecture — full flow
══════════ DESIGN-TIME (admin) ══════════
┌───────────┐ POST /documents ┌──────────────────┐
│ document │ ─────────────────────────► │ v1 ingestion │ (unchanged)
│ PDF/DOCX │ POST /documents/{id}/ │ parse → chunk │
└───────────┘ process │ → embeddings │
└────────┬─────────┘
│ documents.status='ready'
▼
POST /courses/{id}/schema/propose (202 → job_id)
│
┌───────────────────────────┴───────────────────────────┐
│ build_schema_graph() [src/agents/schema/graph.py] │
│ │
│ load_source ─► extract_themes_schema ─► design_schema │
│ (NEW, uses (NEW, uses helpers (NEW, LLM) │
│ helpers) + v1 prompt) │ │
│ ▼ │
│ persist_schema │
│ (NEW) │
└───────────────────────────┬───────────────────────────┘
│ SSE: schema_step / schema_ready
│ channel generation:{job_id}
▼
courses.schema_status = 'proposed'
course_nodes + course_node_prerequisites
⚠ ZERO content generated
│
GET /courses/{id}/schema │ PUT /courses/{id}/schema
(the creator reads it) ◄─────┼───────► (the creator edits it)
│
▼
╔═══════════════════════════════════════════════════════╗
║ BLOCKING GATE ║
║ POST /courses/{id}/schema/validate ║
║ validates: acyclic DAG · ≥1 critical node · ║
║ every node has a summary and a source · ║
║ prereqs with no orphans ║
║ ⇒ schema_status='validated', delivery_mode='dynamic' ║
╚═══════════════════════════════╤═══════════════════════╝
│
POST /enrollments (assignment, unchanged)
│
══════════ RUNTIME (employee) ══════════
│
▼
first login ──► ┌────────────────────────────────────────────┐
│ ONBOARDING (5 screens, ≤90 s) │
│ GET /onboarding · POST /onboarding │
│ role · goal · experience · preset · │
│ reading settings (optional) │
└────────────────────┬───────────────────────┘
│ seeds
▼
learner_profiles (role_title, goal,
experience_level, preset, format_vector=0)
│
▼
GET /courses/{id}/nodes (list + state + prereq lock)
│
▼
┌──────────────────── for each unlocked node ─────────────────────────┐
│ │
│ POST /nodes/{node_id}/probe ┌─────────────────────────┐ │
│ ──────────────────────────────────►│ PRE-ASSESSMENT │ │
│ │ item A (apply) │ │
│ POST /nodes/{node_id}/probe/answer │ item B (understand) │ │
│ ──────────────────────────────────►│ [+ tiebreaker if unsure]│ │
│ └───────────┬─────────────┘ │
│ │ │
│ ┌───────────────────────────────────────────┴───────┐ │
│ │ mastery ≥ threshold(criticality)? │ │
│ └────────┬──────────────────────────────┬───────────┘ │
│ YES │ │ NO │
│ ▼ ▼ │
│ node_state='mastered' ╔═══════════════════════════════════╗│
│ (skipped, 0 tokens) ║ build_node_graph() ║│
│ │ ║ [src/agents/runtime/graph.py] ║│
│ │ ║ ║│
│ │ ║ load_context ║│
│ │ ║ (node + profile + state + ║│
│ │ ║ source via RAG/seed) ║│
│ │ ║ │ ║│
│ │ ║ ▼ ║│
│ │ ║ probe_gate ──(mastered)──► skip ║│
│ │ ║ │ needs_content ║│
│ │ ║ ▼ ║│
│ │ ║ decide_formato ◄── ROUTER ║│
│ │ ║ (LLM tier=fast) fast│heavy ║│
│ │ ║ → explanation|exercise| ║│
│ │ ║ chart|mixed ║│
│ │ ║ │ ║│
│ │ ║ ▼ ║│
│ │ ║ genera_ui (LLM tier from router)║│
│ │ ║ → active backend's dialect ║│
│ │ ║ │ ║│
│ │ ║ ▼ ║│
│ │ ║ validate_ui ║│
│ │ ║ adapter.parse() → UISpec ║│
│ │ ║ ├ ok ──────────► persist_render ║│
│ │ ║ ├ invalid & retry<1 ─► genera_ui║│
│ │ ║ └ fails ──────► fallback_seed ║│
│ │ ║ (v1 markdown) ║│
│ │ ╚═══════════════╤═══════════════════╝│
│ │ │ node_renders │
│ │ ▼ (ui_spec+answer_key)│
│ │ GET /nodes/{id}/render (cache hit)│
│ │ GET /nodes/{id}/render/stream (SSE) │
│ │ │ │
│ │ ▼ │
│ │ ┌───────────────────────────────────┐ │
│ │ │ RENDER (frontend) │ │
│ │ │ UiSpecRenderer → blocks/* │ │
│ │ │ wrapped in ClickableSurface │ │
│ │ │ │ │
│ │ │ click on word/selection ────────►│ │
│ │ │ POST /explain (SSE) ─► popover │ │
│ │ └───────────────┬───────────────────┘ │
│ │ │ │
│ │ POST /nodes/{id}/answer │
│ │ POST /nodes/{id}/feedback │
│ │ │ │
│ │ ▼ │
│ │ ┌───────────────────────────────────┐ │
│ │ │ PROFILE ADJUSTMENT (deterministic)│ │
│ │ │ [src/services/learner_profile_ │ │
│ │ │ service.py] │ │
│ │ │ · mastery ← EWMA(score) │ │
│ │ │ · consecutive_correct/failed │ │
│ │ │ · last_error_kind │ │
│ │ │ · format_vector ← learning_events│ │
│ │ │ · tutor_notes (controlled vocab) │ │
│ │ └───────────────┬───────────────────┘ │
│ │ │ │
│ └─────────────────────────────────┤ │
│ ▼ │
│ next unlocked node │
└──────────────────────────────────────────────────────────────────────┘
│
▼
enrollments.status='completed' when all
critical nodes are 'mastered'
2.1 What is persisted vs. what is generated
The asynchronous pedagogical preparation stage sitting between the index and OpenUI is specified in
node-knowledge-packs.md. After the index commit it generates a
structured contract and a derived Markdown per node. Only ready packs feed runtime knowledge
selection; their hash and the selection’s hash are part of the cache key. The review_required,
failed, stale states, and the absence of a pack keep the previous raw flow as fallback. Creating
or editing the schema automatically enqueues preparation; opening the screen does not start work.
Each node exposes its state inside its own disclosure on the schema screen; the technical details
do not occupy a global section.
| Persisted (design-time, stable) | Generated on the fly (runtime, per user) |
|---|---|
| Course title and outcome | Each node’s UI (ui_spec) |
| Nodes, summaries, criticality, prerequisites | The adapted explanatory text |
| Source document + associated headings | Examples contextualized to the role |
| Constitution: competency, grounding, evidence gates, and critical errors | EpisodeBrief: mission, dominant action, support, boundaries, and continuation |
| Learner profile | Exercise and pre-assessment items |
| Per-node state (mastery, streaks) | Click-to-explain explanations |
The already generated ui_spec (content, shared per bucket) |
— |
Who saw which render and when (node_render_views) |
— |
The constitution and the packs are not a pre-built presentation. They do not fix the course’s sequence, modality, component, or speculative artifacts; they bound episodic generation and make it auditable. Audio and video, when eligible, are embedded in the mission: they do not create modality tabs.
Cache and audit are two tables, not one. node_renders is org-scoped content: it stores
the canonical dialect that was served (re-serialized from the UISpec, never the model’s raw
text), the validated ui_spec, the model used, and provenance (catalog_version,
library_version), and the same row is shared by every employee in the same profile bucket. So it
cannot say who saw what: on a cache hit (the ~80% that makes the cost sustainable), the row has
nothing to do with the employee currently reading it.
What does say that is node_render_views(user_id, render_id, first_seen_at): a thin row written on
each user’s first GET /nodes/{id}/render. A certificate is justified by joining
node_attempts → node_render_views → node_renders, and it survives the deletion of any other
user because node_renders.generated_by is NULL-able with ON DELETE SET NULL (§3.4).
3. Data model
Everything new goes into one migration: alembic/versions/0005_dynamic_courses.py, with
revision = "0005" and down_revision = "0004" (current head verified: linear chain
0001→0002→0003→0004, no branches).
What “downgrade tested” exactly means — and what it doesn’t: PostgreSQL cannot remove a value
from an enum. downgrade() drops the 13 new tables, the 6 courses columns, and the 8 new
enums, but leaves schema_proposing and schema_proposed orphaned in the generation_step
type. This is harmless (no row references them after the downgrade, because schema jobs are
deleted along with the new tables… and if one remained, generation_service already falls back to
a known value) and is exactly what tests/integration/test_migration_0005.py asserts: not a
byte-for-byte identical schema, but “all new tables and columns gone, the two enum values still
there.” A truly clean downgrade would require recreating the type and rewriting
generation_jobs.status; that is not done, and this line is the documented reason.
Conventions honored from data-model.md: PK uuid DEFAULT gen_random_uuid(), timestamptz,
jsonb for flexible data, org_id on top-level tables (children inherit the parent’s scoping;
see §15.4), enums named in snake_case.
Enrollment tenant isolation. An enrollment has two ends — the learner and the course — and both
must live in the same organization. Creation (EnrollmentService.assign /
assign_courses) now validates that all user_ids belong to the admin’s org, not just the
course; enrolling a learner from another org returns 403. The “My courses” listing
(EnrollmentRepository.list_enrollments) filters by User.org_id and Course.org_id, so a
preexisting cross-org row no longer appears — previously its title showed up and then the detail
view responded 404 (“Course not found”). The defense applies on both read and write without
deleting rows: an inconsistent enrollment is hidden, not removed. The course detail view already
filtered by the caller’s org and that is kept.
Creation order in
0005— the SQL blocks below are grouped by topic, not in executable order. There are two forward references:course_nodes.default_ui_formatneeds theui_formatenum, andlearner_node_states.active_render_idneeds thenode_renderstable. Actual order: allCREATE TYPEfirst, thencourse_nodes→course_node_prerequisites→node_renders→learner_node_states→ the rest →node_render_views(which referencesnode_rendersandcourse_nodes).
3.1 Changes to existing tables
All with DEFAULT, all additive. No existing column is renamed or dropped.
CREATE TYPE course_delivery_mode AS ENUM ('static', 'dynamic');
CREATE TYPE course_schema_status AS ENUM ('draft', 'proposed', 'validated', 'archived');
ALTER TABLE courses
ADD COLUMN delivery_mode course_delivery_mode NOT NULL DEFAULT 'static',
ADD COLUMN schema_status course_schema_status NOT NULL DEFAULT 'draft',
ADD COLUMN schema_validated_by uuid REFERENCES users(id) ON DELETE SET NULL,
ADD COLUMN schema_validated_at timestamptz,
ADD COLUMN schema_version int NOT NULL DEFAULT 1,
ADD COLUMN intent_density smallint NOT NULL DEFAULT 3
CHECK (intent_density BETWEEN 1 AND 5);
delivery_mode:'static'= v1 path intact.'dynamic'= v2 path. OnlyPOST /courses/{id}/schema/validatesets it.schema_version: incremented on everyPUT /courses/{id}/schemathat changes nodes. It goes into thecache_key, so editing the schema invalidates derived renders without deleting rows.intent_density: the “intent slider” condensed(1) ↔ expanded(5). It goes into thegenera_uiprompt as a length budget, not as a format decision.
-- The 'schema' step of the design-time pipeline
ALTER TYPE generation_step ADD VALUE IF NOT EXISTS 'schema_proposing' BEFORE 'extracting';
ALTER TYPE generation_step ADD VALUE IF NOT EXISTS 'schema_proposed' AFTER 'reviewing';
Implementation note, corrected twice. The deployment is pg16 (
docker-compose.yml:pgvector/pgvector:pg16), whereALTER TYPE … ADD VALUEdoes work inside a transaction since pg12. The only thing forbidden is using the new value in the same transaction — and the earlier version of this note claimed0005didn’t. That was false: step 16 usesschema_proposing/schema_proposedin the predicate of the partial indexuq_generation_jobs_schema_in_flight, so the run dies withUnsafeNewEnumValueUsageError. This was discovered by the first real run of the integration suites; until then nobody had done analembic upgrade headfrom scratch.Fix applied in the file: the two
ALTER TYPE … ADD VALUEstatements run insideop.get_context().autocommit_block(), and it is the only one in the migration. The cost of that block is exactly what the earlier note wanted to avoid, and it must be accepted with eyes open:alembic/env.pywraps the entire run in acontext.begin_transaction()withouttransaction_per_migration, andsrc/main.pycallsrun_migrations()in the lifespan, so the block commits0001..0004early. If a later step of0005fails, the database is left at0004without0005stamped, and the half-created v2 objects must be dropped by hand before retrying. This is documented in the migration’s docstring and asserted bytests/integration/test_migration_0005.py.Second lesson from the same run, applicable to any future migration in this repo:
sa.Enum(..., create_type=False)doesn’t work.sa.Enumloses that flag when adapting to the postgres dialect, soCREATE TYPEis emitted anyway and blows up the second time. You must usepostgresql.ENUM(..., create_type=False). This was the real reasonalembic upgrade headfrom scratch had never worked (it was already broken in0003).Third: a JSONB default value built with
sa.text()interprets:as bind parameters. Left unescaped, the DDL in0005and insrc/models/learner_profile.pycame out as{"texto"NULL,...}. You have to write\:.Also add the members to the Python
GenerationStepenum insrc/models/generation_job.py(SCHEMA_PROPOSING = "schema_proposing",SCHEMA_PROPOSED = "schema_proposed").
generation_jobs.output_type: it isNOT NULLon an enum with onlycourse_and_manual|manual_only(src/models/generation_job.py:15-17), and a schema-proposal job is neither. Decision: the schema job writesoutput_type='course_and_manual'as a meaningless placeholder and no consumer interprets it (schema clients go bystatus). A third enum value is not added, to avoid growing the downgrade’s orphan-enum problem for a field nobody reads.
generation_jobs.progress (jsonb) and langgraph_thread_id, never written today, start being
written in the schema graph: progress receives {"step": ..., "nodes_proposed": N} at each
node, so a client that subscribes late to the SSE can reconstruct state via REST.
users.accessibility (jsonb, already exists) now gets a defined shape — neutral settings,
never diagnoses:
{"reduce_motion": false, "short_blocks": true,
"high_contrast": false, "extra_time": false}
audio_first is removed from the shape and from question 5: there is no TTS anywhere in this
PR, nor an audio component in the frozen kit (§5.3). Offering an accommodation the pipeline cannot
deliver is worse than not offering it.
How short_blocks is honored without accessibility reaching the LLM (the rule that it never
does is kept): the frontend cannot shorten prose written by the model, so the signal is
translated on the server into a dimension that already travels to the prompt. In
load_context:
effective_density = min(course.intent_density, 2) if user.accessibility.get("short_blocks") else course.intent_density
effective_density is what goes into the prompt and into the cache_key. The LLM receives a
length-budget number, never the flag or its origin.
3.2 The course schema
CREATE TYPE node_criticality AS ENUM ('critical', 'recommended', 'contextual');
CREATE TABLE course_nodes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
course_id uuid NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
skill_id uuid REFERENCES skills(id) ON DELETE SET NULL,
seed_lesson_id uuid REFERENCES lessons(id) ON DELETE SET NULL,
title text NOT NULL,
summary text NOT NULL,
outcome text,
criticality node_criticality NOT NULL DEFAULT 'recommended',
position int NOT NULL,
source_document_id uuid REFERENCES documents(id) ON DELETE SET NULL,
source_headings text[] NOT NULL DEFAULT '{}',
mastery_threshold real NOT NULL DEFAULT 0.80
CHECK (mastery_threshold > 0 AND mastery_threshold <= 1),
default_ui_format ui_format NOT NULL DEFAULT 'explanation',
probe_items jsonb NOT NULL DEFAULT '[]',
probe_answer_key jsonb NOT NULL DEFAULT '{}',
estimated_minutes int,
reviewed_at timestamptz,
reviewed_by uuid REFERENCES users(id) ON DELETE SET NULL,
archived boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_course_nodes_position UNIQUE (course_id, position)
DEFERRABLE INITIALLY IMMEDIATE
);
CREATE INDEX idx_course_nodes_course ON course_nodes(course_id, position);
CREATE INDEX idx_course_nodes_skill ON course_nodes(skill_id);
CREATE TABLE course_node_prerequisites (
node_id uuid NOT NULL REFERENCES course_nodes(id) ON DELETE CASCADE,
prerequisite_node_id uuid NOT NULL REFERENCES course_nodes(id) ON DELETE CASCADE,
PRIMARY KEY (node_id, prerequisite_node_id),
CHECK (node_id <> prerequisite_node_id)
);
CREATE INDEX idx_node_prereq_node ON course_node_prerequisites(node_id);
Design notes, each with its reason:
summaryisNOT NULL. Without a summary, the tutor’s PageIndex pattern (read the summary tree → decide which node is relevant → load only that one) does not work. It’s a validation requirement, not decoration.source_headings text[], notchunk_id. Chunks are destroyed when the document is re-ingested; headings survive. The node references(source_document_id, source_headings).seed_lesson_idpoints to the equivalent v1 lesson if the course comes from v1. This is the degraded mode: ifgenera_uifails twice or no LLM is available,lessons.contentis served rendered as markdown. This resolves v2’s incompatibility with offline/catalog mode.mastery_thresholdper node, with a default derived from criticality when the schema is created:critical → 0.90,recommended → 0.80,contextual → 0.70. The creator can override it.default_ui_formatexists because §6.4 mandates falling back to “the node’s canonical default format” during calibration, and without this column that instruction had nowhere to read from. It’s proposed bydesign_schemaand edited by the creator in B10. Defaultexplanation.probe_items/probe_answer_keystore the pre-assessment pre-generated at validation time, not per user. Items depend only on(node, source), so they are generated once per node and served instantly to everyone: this solves the probe’s cold start (§9.1), where otherwise the “productive wait” would face its own wait on an LLM call against a blank screen.node_probesbecomes just the per-user record of an attempt.reviewed_at/reviewed_byper node. The validation in §11.1 proves the graph is well-formed, not that a human read the pedagogy. A node withoutreviewed_atcannot be served:resolve_deliverystill looks at the course, butGET /nodes/{id}/renderreturns409 node_not_reviewedif the node hasn’t been reviewed. That closes the §11.1 bypass (adding new nodes to an already-validated course) by construction, not by trust.archivedinstead of deletion: a node withlearner_node_states.attempts_count > 0cannot be deleted (422 node_has_progress), it gets archived. Deleting it would cascade intolearner_node_statesandnode_renders, discarding mastery and the audit trail of people who had already worked on it.UNIQUE (course_id, position)isDEFERRABLE INITIALLY IMMEDIATE, and thePUTin §11.1 defers it (SET CONSTRAINTS uq_course_nodes_position DEFERRED) within its transaction. Without that, any reordering violates the constraint mid-statement. Mandatory test: swap positions 1 and 2 in a singlePUT.- Acyclicity: cannot be expressed in a CHECK. It’s validated in
CourseSchemaService.validate()with a topological order (Kahn) before moving to'validated', and there is a unit test for the cycle detector. A cycle returns422.
3.3 The learner profile
Three independent sources, three distinct places: declared (learner_profiles), inferred
(learning_events → format_vector), and per-competency (learner_node_states).
CREATE TYPE learner_experience AS ENUM ('unknown', 'none', 'some', 'experienced');
CREATE TABLE learner_profiles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id uuid NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
role_title text,
sector text,
goal text,
experience_level learner_experience NOT NULL DEFAULT 'unknown',
preset learning_profile NOT NULL DEFAULT 'standard',
format_vector jsonb NOT NULL DEFAULT
'{"texto":0,"ejercicio":0,"codigo":0,"dato":0}',
format_vector_updated_at timestamptz,
nodes_completed int NOT NULL DEFAULT 0,
tutor_notes jsonb NOT NULL DEFAULT '{}',
onboarding_completed_at timestamptz,
onboarding_skipped boolean NOT NULL DEFAULT false,
onboarding_version smallint NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
presetreuses the existinglearning_profileenum (standard|focus|fast). No new enum is created.users.learning_profileremains the source of truth for the v1 frontend;learner_profiles.presetis kept in sync in the same onboarding transaction.experience_levelstarts at'unknown', not'none'.'none'means “declares no experience” and triggers novice scaffolding (worked examples), which is exactly what hurts the expert. Using'none'as “I don’t know” would make everyone who skips onboarding silently receive novice scaffolding.'unknown'maps to neutral scaffolding: neither extra worked examples nor scaffolding suppression; the first node’s probe corrects this in 2 items.format_vectoris jsonb, not 4 columns. Adding a dimension should not be a migration. The dimensions are exactly the ones the frozen kit in §5.3 can produce:texto(TextContent,Callout,StepSequence,Card),ejercicio(QuizItem),codigo(CodeBlock),dato(Chart,Table).diagrama,audio, andrecursoare removed: no component emits them, so they would be dimensions that can never receive signal and would bias the dominant bucket towardtextoby construction. Sums to ~1.0 after L1 normalization; for a new user they’re all 0 and it isn’t used for anything (see calibration period, §6.4).nodes_completedis the counter that governs the calibration period. Denormalized on purpose: it’s read on everydecide_formatoand we don’t want aCOUNT(*)per render. Increment rule, fixed:+1only on thelearning → masteredtransition, i.e. only when the node has actually been worked on. A node skipped by the probe (probing → mastered) does not incrementnodes_completed, precisely because it generated no interaction event: counting it would take the user out of calibration with an emptyformat_vector.tutor_notesis the “tutor’s notebook” (Notarius). Controlled vocabulary, not free prose, so it is auditable and erasable:
{
"version": 1,
"context": {"sector": "retail", "role": "dependiente", "prior": ["caja", "inventario"]},
"signals": [
{"node_id": "…", "action": "reforzar_con_ejemplo", "at": "2026-07-25T10:00:00Z"},
{"node_id": "…", "action": "reducir_longitud_modulo", "at": "…"}
]
}
Allowed actions (validated via Pydantic, Literal) and the exact condition that writes them.
Without this table tutor_notes was a free, unspecified entry into a prompt, i.e. neither
implementable nor testable; and sugerir_formato_audio is removed because there is neither an
audio component (§5.3) nor a signal that could produce it. All writes happen in
LearnerProfileService.apply_signals(), called after every answer/feedback, never from an
LLM:
| Action | Exact condition that emits it | Test |
|---|---|---|
reforzar_con_ejemplo (reinforce with example) |
consecutive_failed >= 2 on the node |
test_profile_service.py::test_signal_reinforce |
bajar_dificultad (lower difficulty) |
node_feedback.difficulty == 'hard' on the node |
…::test_signal_lower |
subir_dificultad (raise difficulty) |
node_feedback.difficulty == 'easy' and consecutive_correct >= 3 |
…::test_signal_raise |
reducir_longitud_modulo (shorten module) |
3 consecutive scroll_fast events on the same node |
…::test_signal_shorten |
revisar_prerrequisito (review prerequisite) |
last_error_kind == 'conceptual' and the node has ≥1 prerequisite with state != 'mastered' |
…::test_signal_prereq |
signals is capped at the 20 most recent (pruned in the service), and the same (node_id, action) is never duplicated: the at is updated instead. This is a deliberate
simplification of the earlier Notarius exploration, which proposed deriving signals from
conversation with the tutor and from audio/dwell behavior: since §1.3 removes chat inside the
node and there is no audio, those sources don’t exist here, and the closed vocabulary is the only
thing that can be fed.
CREATE TYPE node_state AS ENUM
('not_started', 'probing', 'learning', 'mastered', 'needs_review');
CREATE TYPE error_kind AS ENUM ('detail', 'procedural', 'conceptual');
CREATE TABLE learner_node_states (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
node_id uuid NOT NULL REFERENCES course_nodes(id) ON DELETE CASCADE,
state node_state NOT NULL DEFAULT 'not_started',
mastery real NOT NULL DEFAULT 0 CHECK (mastery >= 0 AND mastery <= 1),
probe_score real CHECK (probe_score IS NULL OR (probe_score >= 0 AND probe_score <= 1)),
consecutive_correct smallint NOT NULL DEFAULT 0,
consecutive_failed smallint NOT NULL DEFAULT 0,
hints_used smallint NOT NULL DEFAULT 0,
attempts_count int NOT NULL DEFAULT 0,
last_error_kind error_kind,
active_render_id uuid REFERENCES node_renders(id) ON DELETE SET NULL,
render_pinned boolean NOT NULL DEFAULT true,
scaffold_band text NOT NULL DEFAULT 'neutral'
CHECK (scaffold_band IN ('novice','neutral','advanced')),
waived_by uuid REFERENCES users(id) ON DELETE SET NULL,
waived_at timestamptz,
first_seen_at timestamptz,
mastered_at timestamptz,
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, node_id)
);
CREATE INDEX idx_lns_user ON learner_node_states(user_id);
CREATE INDEX idx_lns_state ON learner_node_states(user_id, state);
active_render_id+render_pinnedimplement View A from §1.4: the render is pinned when the node is opened.GET /nodes/{id}/renderreturns theactive_render_idspec whilerender_pinnedistrue, without recomputing thecache_key. Without this, content would mutate mid-node: with perfect answers mastery travels 0 → 0.40 → 0.64 → 0.784 → 0.87, and withmastery_bandin the key that’s four distinct keys within a singlecriticalnode, so a simple browser refresh would return different blocks in a different order — breaking §5.5’s own spatial stability zones.scaffold_bandreplacesmastery_bandin thecache_key(§3.4). It’s computed once, when the probe closes:noviceifexperience_level='none'or the probe came outlearningwithscore_a == 0;advancedif the probe came outtiebreakorexperience_level='experienced';neutralotherwise. It’s stable for the whole node by construction, not by convention.waived_by/waived_at: the human escape hatch from §7.4 (POST /nodes/{id}/waive).
mastery is the only primary mastery scale. The others are derived views, computed in code,
never persisted redundantly:
| Derivation | Rule |
|---|---|
| Shu-Ha-Ri phase (scaffolding) | mastery < 0.5 → shu; 0.5 ≤ mastery < threshold → ha; ≥ threshold → ri |
skill_level (low/medium/high from user_skills) |
< 0.5 → low; < 0.85 → medium; ≥ 0.85 → high. Applied only upward, as _assign_course_skills already does |
| Target Bloom level for the next item | shu → understand; ha → apply; ri → analyze |
CREATE TABLE learning_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
node_id uuid REFERENCES course_nodes(id) ON DELETE SET NULL,
type text NOT NULL,
element text,
weight real NOT NULL DEFAULT 0,
metadata jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_learning_events_user ON learning_events(user_id, created_at DESC);
Append-only. Fixed weights, defined as a constant in
src/services/learner_profile_service.py::EVENT_WEIGHTS:
type |
weight | type |
weight | |
|---|---|---|---|---|
explain_click |
+0.30 | quiz_correct |
+0.20 | |
expand |
+0.15 | quiz_wrong |
+0.10 | |
scroll_slow (>3 s) |
+0.10 | view |
+0.05 | |
scroll_fast (<1 s) |
−0.05 |
element ∈ {texto, ejercicio, codigo, dato} — the four dimensions of format_vector.
resource_opened is removed (it was the second-highest weight): the frozen kit in §5.3 has no
link or resource component, so no render can emit an element on which to open a resource. An event
that no render can fire isn’t a signal, it’s dead weight that distorts L1 normalization.
Privacy, decided and corrected: learning_events.metadata never stores user text or
copied content. Only {"element_id": "...", "ms": 1234}. Text derived from the user lands in
two places, not one — an earlier version of this document said “a single place” and
contradicted §3.4:
node_feedback.unclear— free text the user writes.term_explanations.term/term_normalized— the selection the user clicked. It’s text chosen by the user, and that’s why §8.4 caps what’s cacheable at ≤60 characters and ≤4 tokens; above that the explanation is served but not persisted.
Retention and deletion, all via the same script (python -m src.scripts.purge_learning_data, see
§1.3 — there is no background_jobs table): learning_events at 90 days; term_explanations
at 180 days from last_used_at. Deletion on data-subject request:
DELETE /users/me/learner-profile (§11.2) deletes the user’s seven personal tables —
node_render_views, node_feedback, node_attempts, node_probes, learner_node_states,
learning_events, and learner_profiles — and anonymizes node_renders.generated_by to NULL.
node_attempts and node_probes hold the answers the employee wrote, so a deletion that left them
behind would return 204 for a promise it hadn’t kept; the order follows the FKs from 0005
(node_attempts before node_probes, because node_attempts.probe_id is ON DELETE SET NULL).
The retention script does not cover this, nor should it: its job is the two windows above, not
art. 17 erasure.
The vector is computed over a 30-day window with decay:
weight_effective = weight * GREATEST(0.2, 1.0 - (age_seconds / (30*86400)) * 0.8)
format_vector[e] = SUM(weight_effective) / SUM(all) -- L1 normalized
Visibility: learner_profiles and learner_node_states are private to the employee. The admin
sees only aggregates with k ≥ 5 (if the group has fewer than 5 people, the metric isn’t shown).
role_title and sector do travel to the LLM. goal no longer travels to the LLM (see §3.4
and §6.2): it’s consumed deterministically in the frontend for the opening line “this helps you
with X.” That reduces the personal data sent to a third party and makes question 2’s promise
real instead of depending on the model remembering to write it. users.accessibility never
goes to the LLM (§3.1 explains how short_blocks is honored without sending it).
Notice at the point of collection (GDPR, art. 13): the onboarding’s question-1 screen shows, with
the same visual weight as the question, a fixed line:
“Your job title and sector are sent to the AI provider to adapt the examples. You can delete them
anytime from Settings.” It isn’t optional copy: it’s a requirement and lives in OnboardingRead
(notice), not hardcoded on the client.
3.4 Content generated at runtime
CREATE TYPE ui_format AS ENUM ('explanation', 'simulation', 'exercise', 'chart', 'mixed');
CREATE TYPE node_render_status AS ENUM ('pending', 'generating', 'ready', 'failed', 'fallback');
CREATE TABLE node_renders (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
generated_by uuid REFERENCES users(id) ON DELETE SET NULL,
is_preview boolean NOT NULL DEFAULT false,
node_id uuid NOT NULL REFERENCES course_nodes(id) ON DELETE CASCADE,
cache_key text NOT NULL,
ui_format ui_format NOT NULL,
ui_spec jsonb NOT NULL DEFAULT '{}', -- validated IR; audit only, NOT served
answer_key jsonb NOT NULL DEFAULT '{}', -- never serialized to the client
dialect text, -- the canonical program the browser painted
catalog_version text, -- "skillnet-ui/1+<digest12>"
library_version text, -- "@openuidev/lang-core@0.2.10; ..."
backend text NOT NULL,
model text NOT NULL,
tier text NOT NULL CHECK (tier IN ('fast', 'heavy')),
status node_render_status NOT NULL DEFAULT 'pending',
tokens_in int,
tokens_out int,
duration_ms int,
error_message text,
created_at timestamptz NOT NULL DEFAULT now(),
-- Compliance traceability: a row someone viewed says WHAT they saw and against which
-- catalog. 'pending'/'generating'/'failed' have nothing to show.
CONSTRAINT ck_node_renders_served_provenance CHECK (
status NOT IN ('ready', 'fallback')
OR (dialect IS NOT NULL AND catalog_version IS NOT NULL AND library_version IS NOT NULL)
),
UNIQUE (cache_key)
);
CREATE INDEX idx_node_renders_node ON node_renders(node_id, created_at DESC);
CREATE INDEX idx_node_renders_status ON node_renders(status);
CREATE TABLE node_render_views (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
render_id uuid NOT NULL REFERENCES node_renders(id) ON DELETE CASCADE,
node_id uuid NOT NULL REFERENCES course_nodes(id) ON DELETE CASCADE,
first_seen_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, render_id)
);
CREATE INDEX idx_node_render_views_user ON node_render_views(user_id, node_id, first_seen_at DESC);
answer_keyis a column separate fromui_specand is never serialized to the API. It is the structural equivalent of v1’sstrip_answers(), but by construction rather than by filtering: you can’t badly filter what isn’t in the same field.node_rendershas nouser_id. It used to have one,NOT NULL … ON DELETE CASCADE, alongside a globalUNIQUE (cache_key), and the two together were incoherent in three ways: a per-user lookup never found the shared row (0 hit rate, which is the pillar of the cost model); the row only recorded whoever generated it first, so §2.1’s audit promise was false for every cache hit; and offboarding that first employee destroyed the render everyone else was seeing, and with it the evidence for their certificates. Now:org_idfor scoping,generated_bynullable withSET NULLfor generation traceability, andnode_render_viewsfor the read audit trail (§2.1).- The cache lookup is strictly
WHERE cache_key = :key AND status='ready' AND NOT is_preview. Nouser_idin theWHERE, ever. is_preview: renders from?preview=1inshadowmode are persisted so they can be reviewed, but stay out of the cache. Without this, a preview generated by an admin before validating the schema could literally be served to an employee in the same bucket — unapproved content reaching a learner through the back door.cache_keyis globallyUNIQUE: two users with the same profile bucket share a render. That’s deliberate, and it’s what makes the cost sustainable.
cache_key = sha256(
f"{node_id}|{course.schema_version}|{preset}|{experience_level}|{role_bucket}"
f"|{scaffold_band}|{vector_bucket}|{effective_density}|{backend}|{model}|{PROMPT_VERSION}"
)
role_bucket = slug(role_title or sector or "")[:24] # "" if there was no onboarding
scaffold_band = learner_node_states.scaffold_band # novice|neutral|advanced, fixed per node
vector_bucket = f"{dominant}:{round(p_dominant,1)}" # "" during calibration
Two corrections relative to the previous version of this formula, both mandatory:
role_bucketcomes in.role_titleis the only thing §6.2 declares travels literally to thegenera_uiprompt, and it was the only adaptation with strong evidence — yet it wasn’t in the key. Result: a sales associate and a shift supervisor with the same preset shared a row, and the second one got examples framed for the first one’s role, silently erasing the personalization onboarding promises.slug(role_title)lowers the hit rate; that’s accepted, because a cache that serves content with the wrong role framing isn’t a hit, it’s a cheap failure.mastery_bandis out,scaffold_bandis in.floor(mastery*5)/5changes with every answer: four different keys within a singlecriticalnode.scaffold_bandfreezes when the probe closes (§3.3) and doesn’t move until the node closes.
PROMPT_VERSION is a constant in src/llm/prompts/runtime.py; bumping it invalidates every
render without touching the DB.
On the cache experiment citation, honestly: the internal measurement of ~80% hits with 0 stale
deliveries corresponds to a per-user key (user+course+module+bucket), and the 0% staleness is
a property of that key, not a transferable result. This design’s cross-user key is an
unmeasured regime. That’s why the first number measured in §14.2 #3 isn’t just the hit rate:
it’s the pair (hit rate, staleness rate) of the shared key.
CREATE TABLE node_probes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
node_id uuid NOT NULL REFERENCES course_nodes(id) ON DELETE CASCADE,
schema_version int NOT NULL,
attempt_no smallint NOT NULL DEFAULT 1,
items jsonb NOT NULL,
answer_key jsonb NOT NULL DEFAULT '{}',
answers jsonb NOT NULL DEFAULT '[]',
score real CHECK (score IS NULL OR (score >= 0 AND score <= 1)),
mastered boolean,
tiebreak_used boolean NOT NULL DEFAULT false,
scored boolean NOT NULL DEFAULT true,
model text,
created_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz
);
CREATE UNIQUE INDEX uq_node_probes_user_node_version
ON node_probes(user_id, node_id, schema_version) WHERE scored;
CREATE INDEX idx_node_probes_user_node ON node_probes(user_id, node_id, created_at DESC);
CREATE TABLE node_attempts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
node_id uuid NOT NULL REFERENCES course_nodes(id) ON DELETE CASCADE,
render_id uuid REFERENCES node_renders(id) ON DELETE SET NULL,
probe_id uuid REFERENCES node_probes(id) ON DELETE SET NULL,
item_id text NOT NULL,
item_type exercise_type NOT NULL,
bloom_level text CHECK (bloom_level IN
('remember','understand','apply','analyze','evaluate','create')),
answer jsonb NOT NULL,
score real NOT NULL CHECK (score >= 0 AND score <= 1),
passed boolean NOT NULL,
hints_used smallint NOT NULL DEFAULT 0,
feedback text,
latency_ms int,
attempted_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_node_attempts_user_node ON node_attempts(user_id, node_id, attempted_at DESC);
The partial UNIQUE on node_probes is the anti-retry rule, and it’s what kept mastery from
being gameable: a perfect probe (2/2) returns mastered, skips the node, and counts toward
enrollments.status='completed' and toward user_skills; with two 4-option items, chance succeeds
1 time in 16, so with no attempt limit it took only ~16 re-entries to skip any node — including a
safety-critical one — without having seen a single line of content. Concrete rules:
- One probe scored per
(user_id, node_id, schema_version). Re-entering the node serves the stored verdict; no new one is generated. - Re-probe only from
needs_review, and at least 7 days aftercompleted_at. It’s inserted withattempt_no + 1(the previous row becomesscored = false, which is what the partial index allows). - In a
criticalnode themasteredverdict can never come purely from selected-response items: the constructed-response tiebreak is mandatory (§7.2). scored = falseis also used for the declared novice’s diagnostic probe (§7.1): it’s shown, doesn’t score, doesn’t persist failures, and doesn’t consume the single attempt.
node_attempts exists instead of reusing exercise_attempts because the latter has
exercise_id uuid NOT NULL REFERENCES exercises(id), and items generated on the fly aren’t rows in
exercises. It does reuse the exercise_type enum and reuse the existing deterministic
grading, but with an exact name and shape that the previous version of this document got wrong:
- The function is
grade(exercise_type, content, answer), module-level and already pure, insrc/services/exercise_service.py:73(“Pure and importable without any DB or LLM dependency”). It is notExerciseService.grade()(ExerciseServiceis the class at line 100), and there’s no need to extract it anywhere: it’s imported as-is. - An adapter is actually needed, so it’s not “zero new logic”:
grade()reads the correct answer from acontentdict shaped like v1 (correct,blanks,correct_order,explanation), while v2 has the prompts inQuizItem.propsand the solutions inanswer_key. The adapter issrc/services/node_grading.py::content_for(item_props, answer_key_entry) -> dict, with its own test for each of the 4 deterministic types. - The four deterministic types score 0.0 or 1.0, no partial credit — including
fill_blank, which returns 0.0 if even one blank fails (_grade_fill_blank, lines 34-43). This is load-bearing for the arithmetic in §7.2 and must be kept in mind before “making continuous” any item. - For
practical_case/dialogue,grade_open_answer()is used (purposeeval), built withget_optional_llm_service. That factory also has to go through_maybe_fixture(§12.1) or the fixtures flow attempts a real network call.
CREATE TABLE node_feedback (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
node_id uuid NOT NULL REFERENCES course_nodes(id) ON DELETE CASCADE,
difficulty text NOT NULL CHECK (difficulty IN ('easy', 'ok', 'hard')),
unclear text,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (user_id, node_id)
);
CREATE TABLE term_explanations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
node_id uuid REFERENCES course_nodes(id) ON DELETE SET NULL,
term text NOT NULL,
term_normalized text NOT NULL,
context_hash text NOT NULL,
language text NOT NULL DEFAULT 'es',
explanation text NOT NULL,
model text NOT NULL,
hit_count int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
last_used_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (org_id, term_normalized, context_hash, language)
);
CREATE INDEX idx_term_expl_lookup ON term_explanations(org_id, term_normalized, context_hash);
CREATE INDEX idx_term_expl_purge ON term_explanations(last_used_at);
It is only persisted if the term is ≤60 characters and ≤4 tokens (§8.4). A 140-character
selection is a phrase the user chose, not a term, and storing it indefinitely in a row without
user_id would also make it impossible to honor an erasure request. Above 60 characters (up to
the hard limit of 140) the explanation is generated and served, but not written.
idx_term_expl_purge backs the 180-day-from-last_used_at deletion.
context_hash = sha256(normalized_block_text)[:16]. Including the context in the key is not
optional: it’s the central argument of the function. “Mercury” in a chemistry node and “Mercury”
next to “planet” must give different explanations. Curio’s reference implementation omits the
context from the key, and that’s its design bug; it isn’t replicated here.
3.5 The two instrumentation tables the design takes for granted
These two don’t exist in the repo (verified: 20 tables, no llm_usage_log or audit_log in
src/ or in 0001..0004), and yet three mitigations in §14.1 and open decision #1 in
§14.2 depend on them. They are created in 0005 instead of continuing to just cite them:
CREATE TABLE llm_usage_log (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id uuid REFERENCES users(id) ON DELETE SET NULL,
use_case text NOT NULL, -- decide_formato|runtime_activity_authoring|genera_ui|explain|probe_generate|schema_design
purpose text NOT NULL, -- runtime_fast|runtime_heavy|generation|eval|tutor
model text NOT NULL,
tier text CHECK (tier IS NULL OR tier IN ('fast','heavy')),
tokens_in int,
tokens_out int,
duration_ms int,
ok boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_llm_usage_case ON llm_usage_log(org_id, use_case, created_at DESC);
CREATE TABLE audit_log (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
org_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
actor_id uuid REFERENCES users(id) ON DELETE SET NULL,
action text NOT NULL, -- course_schema_validated|course_schema_unvalidated|node_waived
subject text NOT NULL, -- "course:{uuid}" | "node:{uuid}"
detail jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_audit_log_subject ON audit_log(org_id, subject, created_at DESC);
llm_usage_log is small and load-bearing: it is the only way to decide §14.2 #1 (real
fast/heavy ratio) with data instead of the 90/10 hypothesis. It is written from a single
place, a log_usage() wrapper around the calls made by the new nodes; v1 nodes are not
instrumented in this PR. audit_log.detail on course_schema_validated stores the
proposed→validated diff (nodes added, deleted, fields edited): this makes it possible to
measure whether creators actually edit what the LLM proposes, which is the “High” risk from
§14.1 that no structural validation covers.
3.6 Delta summary
13 tablas nuevas: course_nodes, course_node_prerequisites, learner_profiles,
learner_node_states, learning_events, node_renders, node_render_views, node_probes,
node_attempts, node_feedback, term_explanations, llm_usage_log, audit_log.
8 enums nuevos: course_delivery_mode, course_schema_status, node_criticality,
learner_experience, node_state, error_kind, ui_format, node_render_status.
1 tabla alterada: courses (+6 columnas). 1 enum extendido: generation_step (+2 valores).
node_statekeeps theneeds_reviewmember, but its only producer in this PR is the hint cap in §7.4. There is no spaced-repetition scheduler (§1.3), so themastered → needs_reviewtransition does not occur and does not appear in the §7.3 table.
4. LangGraph pipeline
Two new graphs. The v1 graph (src/agents/content/) does not change behavior, but it does
get an extraction refactor with no functional change (see below). The previous version of
this document claimed that prepare_context and extract_themes “do not write to the DB” and
could be imported as-is — that was false, and two pieces of the design fell with it:
nodes.py:177-178doesawait _set_job(job_id, status=GenerationStep.EXTRACTING)andawait _publish_step(job_id, "extracting", …);nodes.py:215-216does the same withSTRUCTURING. Importing them intobuild_schema_graph()would put the schema job in'extracting'/'structuring'— never in the'schema_proposing'/'schema_proposed'states added by the migration — and would emit genericstepevents.- The channel is hardcoded in
src/agents/content/errors.py:26-27(return f"generation:{job_id}"), so those events would go togeneration:{job_id}, which no schema client would be listening to.
One single decision: the pure parts are extracted to src/agents/content/helpers.py
(estimate_pages, assemble_chunk_text, themes_list) and src/agents/content/nodes.py
imports them from there. It is a pure code move with no behavior change, covered by
tests/test_generation_pipeline.py, which already exists. The schema graph’s nodes are new
and live in src/agents/schema/nodes.py: they reuse those helpers, THEME_EXTRACTOR_SYSTEM and
build_extraction_prompt, and write their own states and events. No importing of v1 nodes.
src/agents/content/helpers.py and nodes.py are on the B2 file list.
4.1 Design-time: build_schema_graph()
src/agents/schema/{state.py,nodes.py,graph.py,runner.py,errors.py}
load_source ──► extract_themes_schema ──► design_schema ──► persist_schema ──► END
(NUEVO, usa (NUEVO, usa (NUEVO, LLM) (NUEVO, DB)
helpers puros) helpers + prompt v1)
└──────────────── on error ────────────────► handle_error ──► END
class SchemaState(TypedDict, total=False):
# Identity
job_id: str
org_id: str
triggered_by: str
# Inputs
source_document_ids: list[str]
course_id: str
intent_density: int
# Derived from the source (same shape as v1, computed by the new nodes)
rag_mode: Literal["full_text", "chunked"]
full_texts: dict
extracted_themes: list[dict]
source_metadata: dict
available_headings: list[str] # closed list of real headings from the document
# New
proposed_nodes: list[dict] # title, summary, outcome, criticality,
# prerequisites (indices), source_headings
schema_warnings: list[str]
# Control
error: str | None
current_step: str
design_schema makes one LLM call (SCHEMA_DESIGNER_SYSTEM, json_mode=True,
temperature=0.2, purpose generation) and returns nodes with prerequisites expressed as
indices into the list itself, not as uuids — the LLM cannot invent uuids. persist_schema
translates them to FKs, runs a topological sort, prunes edges that would create cycles (adding
a warning to schema_warnings instead of failing) and writes with schema_status = 'proposed'.
source_headings is chosen from a closed list, not invented. load_source collects the
real headings (distinct chunk_metadata->>'heading' values from the document, which
src/services/chunker.py stores as a string per chunk) into available_headings, and the
design_schema prompt forces choosing only from that list; persist_schema discards any
heading outside it and notes it in schema_warnings. Without this, a heading invented by the
LLM would not match any chunk and load_context (§4.2) would hand an empty source to
genera_ui — a silent failure that produces plausible content with no documentary basis.
Checkpointer: MemorySaver, same as v1. The job is short and its real state lives in
generation_jobs + course_nodes. langgraph-checkpoint-postgres is not introduced in
this PR (a new dependency, and the human gate is already a DB state, not an interrupt).
SSE channel: f"generation:{job_id}", the same one v1 uses — not schema:{job_id}.
Concrete reason: the endpoint being reused has the channel hardcoded
(src/routes/generation_jobs.py:42, async for event in subscribe(f"generation:{job_id}")),
so a dedicated channel would not reach any client without rewriting the endpoint to subscribe
to two channels. Events are already namespaced by type, so sharing the channel does not
collide: schema_step, schema_progress, schema_ready, error. The only thing that changes
in the routes file is _TERMINAL_EVENTS = {"completed", "error", "schema_ready"} (line 18).
4.2 Runtime: build_node_graph()
src/agents/runtime/{state.py,nodes.py,graph.py,router.py,runner.py}
load_context
│
▼
probe_gate ──(mastered)──────────────────────────────────► skip_node ──► END
│ needs_content
▼
decide_formato ──► genera_ui ──► validate_ui ──┬─(ok)─────► persist_render ──► END
▲ │
└──(invalid, retry<1)───┤
└─(fail)──► fallback_seed ──► END
class NodeRuntimeState(TypedDict, total=False):
# Identity
request_id: str
org_id: str
user_id: str
course_id: str
node_id: str
# Loaded context
node: dict # title, summary, outcome, criticality, source_headings
profile: dict # role_title, sector, goal, experience_level, preset,
# format_vector, nodes_completed, tutor_notes
node_state: dict # mastery, state, consecutive_*, last_error_kind
source_context: str # source text (RAG or full_text), already trimmed
# Gate
mastered: bool
# Router
ui_format: Literal["explanation", "simulation", "exercise", "chart", "mixed"]
tier: Literal["fast", "heavy"]
format_rationale: str
# Generation
backend: str # "openui" (only dialect in this PR)
effective_density: int # course intent_density, bounded by short_blocks (§3.1)
scaffold_band: str # novice|neutral|advanced, fixed when the probe closes
raw_dsl: str
ui_spec: dict | None
answer_key: dict
validation_errors: list[str]
retry_count: int
# Output
cache_key: str
render_id: str | None
tokens_in: int
tokens_out: int
# Control
error: str | None
current_step: str
Each node is wrapped by its own, new wrapper, src/agents/runtime/errors.py:: runtime_node_error_wrapper. The v1 one is not adapted: src/agents/content/errors.py:47-66
is hard-coupled to state["job_id"] and to marking a generation_jobs row as failed, and
NodeRuntimeState has no job_id or job row. With an empty job_id the v1 wrapper skips
both the bookkeeping and the sse.publish, so the error {fallback: true} contract the
frontend expects in §9.2 would never be emitted on a node failure. The new wrapper:
# src/agents/runtime/errors.py
def runtime_node_error_wrapper(name: str): # keyed on request_id, not job_id
# on exception: node_renders.status = 'failed' + error_message
# sse.publish(f"node:{request_id}", "error",
# {"step": name, "message": …, "fallback": True})
# return {"error": …, "current_step": "failed"}
Each node opens its own session with async_session_factory, same as v1.
| Node | What it does | LLM |
|---|---|---|
load_context |
Loads node, profile, state, and the source (see note below). Computes effective_density and cache_key. On a hit in node_renders with status='ready' and NOT is_preview, it short-circuits before entering the graph (checked by the service, not the graph) |
— |
probe_gate |
Reads learner_node_states.state. If 'mastered' → skip |
— |
decide_formato |
Decides ui_format and calls the router for the tier |
Yes, tier fast |
genera_ui |
Asks the model for the active backend’s dialect, using src.render.prompt.render_prompt() (the artifact generated by library.prompt()) as part of the system prompt |
Yes, router’s tier |
validate_ui |
gate.canonicalize(raw_dsl): size caps and reactivity rejection → backend.parse() → UISpec (the 7 rules) → serialize() → the canonical dialect. Splits off answer_key |
— |
persist_render |
Writes node_renders (status='ready') with dialect, catalog_version and library_version, publishes ui_done |
— |
fallback_seed |
Builds a single-block Markdown ui_spec from lessons.content of the seed_lesson_id (or the trimmed source_context). status='fallback' |
— |
skip_node |
Marks the node as skipped and publishes node_skipped |
— |
The source in load_context, with a real implementation path. The previous version said
“similarity_search(top_k=8) … filtering by source_headings”, and that filter does not
exist: src/repositories/document_chunk_repo.py:73-97 only accepts
org_id / query_embedding / top_k / document_ids. B1 adds a new method to the repository
(the existing one, used by v1 routes, is left untouched):
async def similarity_search_by_headings(
self, *, org_id, query_embedding, top_k=8,
document_ids=None, headings: Sequence[str] | None = None,
) -> list[dict]:
"""Same as similarity_search, with
AND (chunk_metadata->>'heading') = ANY(:headings) when headings is not empty."""
Branches, explicit:
- Document with ≤5 pages →
full_text. This branch needs no embeddings, and is the one covered by the fixture tests (§12.1). - Larger document →
similarity_search_by_headings(headings=node.source_headings). It needs a real query embedding and chunks with a non-nullembedding(DocumentChunk.embeddingisVector(...)nullable=False), so it only works with an embedder available. Ifheadingsreturns nothing, it retries without the headings filter and logs a warning in the structured log.
Budgets: decide_formato max_tokens=256, temperature=0.0, json_mode=True.
genera_ui max_tokens=1200 (tier fast) / 2400 (tier heavy), temperature=0.4. MAX_UI_RETRIES = 1.
Global runtime generation concurrency: asyncio.Semaphore(6) in the runner, so that an employee
spike doesn’t take down the process.
4.3 El router de dos niveles
src/agents/runtime/router.py
HEAVY_FORMATS = frozenset({"chart", "mixed", "simulation"})
ALLOWED_UI_FORMATS = frozenset({"explanation", "exercise", "chart", "mixed"}) # simulation OFF
# There is no FAST_FORMATS: select_tier only consults HEAVY_FORMATS, so a second
# constant would be dead code that could go out of sync.
def select_tier(ui_format: str) -> Literal["fast", "heavy"]:
return "heavy" if ui_format in HEAVY_FORMATS else "fast"
def purpose_for(tier: str) -> str:
return "runtime_heavy" if tier == "heavy" else "runtime_fast"
Routing at the level of the whole UI, not per component. It plugs into the purposes
mechanism that already exists in resolve_llm_config(org_settings, purpose=...), so only two
env vars and two org settings keys need to be added:
# src/config.py — Settings
LLM_RUNTIME_FAST_MODEL: str | None = None # e.g. "groq/llama-3.1-8b-instant"
LLM_RUNTIME_HEAVY_MODEL: str | None = None # e.g. "groq/openai/gpt-oss-120b"
Precedence (the one resolve_llm_config already implements, untouched):
org_settings["llm_runtime_fast_model"] → org_settings["llm_model"] →
LLM_RUNTIME_FAST_MODEL → LLM_MODEL. If nothing is configured, both tiers fall back to
LLM_MODEL and everything keeps working with a single model. No specific provider is required.
Every call is logged in llm_usage_log (table created in 0005, §3.5) with use_case ∈
{decide_formato, runtime_activity_authoring, genera_ui, explain, probe_generate, schema_design} in order to measure the real
fast/heavy ratio (the 90/10 estimate is a hypothesis, not a data point).
Single-tenancy: documented here, not fixed here. src/deps/llm.py:22-25::_org_settings and
SettingsService._get_org do select(Organization).limit(1). Fixing it properly means
threading a CurrentUser into LLMDep/TutorLLMDep/EmbeddingDep/OptionalLLMDep, which are
consumed by v1 routes (src/routes/chat.py:30,47, src/routes/exercises.py:61) with not a
single test covering them today (tests/ has test_chunking, test_generation_pipeline,
test_grading, test_retrieval_assembly, test_skill_service). Changing v1 route dependency
signatures with no safety net, inside a batch presented as “parallel and safe”, is exactly the
kind of silent regression this PR promises not to introduce.
Decision: it moves out of B1 and becomes its own chore PR (with route tests for chat and
exercise correction as part of it). It is safe to defer because the bootstrap keeps the
single organization invariant (ensure_organization in the lifespan), so today
limit(1) resolves the correct org by construction. The two new purposes do not depend on that
fix: resolve_llm_config resolves them via getattr(settings, f"LLM_{purpose.upper()}_MODEL")
(src/llm/client.py:61-67) regardless of how the org settings were loaded. This is noted in
§14.2 #11, dated.
5. Render layer
5.1 Principle
CORRECTED on 2026-07-26 (product decision: full adoption of OpenUI —
docs/design/openui-adoption.md). The sentence that used to be here, “the browser never receives generated markup”, is no longer true in the part that matters: the browser receives dialect and interprets it with<Renderer>from@openuidev/react-langover the components we register. What remains literally true is the rest: the LLM never produces HTML and the browser never receives the text the model wrote.
node + profile + source ──► LLM ──► DIALECT (raw text, NEVER SERVED)
│
├─► gate.check_program() size caps; no $state, Query, Mutation, @builtins
├─► backend.parse() frozen grammar + the 7 rules from §5.2 (Pydantic)
├─► UISpec (jsonb) audit record, server-only
└─► backend.serialize() ──► node_renders.dialect ──► <Renderer> in the browser
(+ catalog_version, library_version)
The three properties that hold this up, none of which is a style promise:
- The browser only ever sees the canonical re-serialization of an already-validated
UISpec. Neverraw_dsl. The column is no longer called that (node_renders.dialect), precisely so no one serves it by accident. AUISpeccannot represent state or a tool call, so the property is structural, not a check. - The server is still the one that validates. OpenUI’s client-side parser paints; ours decides.
Its parser silently accepts made-up enums, wrong types, duplicate ids,
<script>andMutation("delete_all_users", {...})withmeta.errors=[]; ours cannot even represent them. answer_keyis never serialized. Same as before: rule 5 of §5.2, a separate column, and no response schema mentions it.
New surface this opens, said out loud: mutations. The real language has state ($var), queries
(Query), mutations (Mutation), actions (Action, @OpenUrl, @ToAssistant, @Set) and 13
builtins. A poisoned PDF can try to emit them. Mitigation, in four stacked, measured controls
(SEGURIDAD-MUTACIONES.md):
| Control | How | Measured effect |
|---|---|---|
| Never serve raw text | serialize() from the validated UISpec |
The 10 re-serialized fixtures parse with 0 violations |
toolProvider absent from <Renderer> |
by omitting the prop | createQueryManager(null): zero network, both queries and mutations cut off |
onAction and onStateUpdate absent |
by omitting the props | @OpenUrl/@ToAssistant are no-ops; @Set is not persisted. And with no component calling useTriggerAction(), an ActionPlan isn’t even reachable |
| Gate on both sides | src/render/gate.py + parse() on the server; assertStaticOnly(parseResult) in onParseResult on the client |
15/15 payloads rejected, 0 false positives across the 10 valid fixtures |
And the cheapest one of all: the prompt does not teach reactivity. Without tools and without
markReactive(), library.prompt() never mentions $var, Query(, Mutation(, @Run or @Set.
It’s defense in depth, not a wall: if the model emits it from memory, the gate is what rejects it.
RENDER_ALLOW_REACTIVE=false is the switch, and the conditions for touching it are in
docs/design/openui-adoption.md §6.
Switching dialects by flipping an env var still works; the pipeline, the IR and the DB don’t notice. The frontend now would notice: it receives dialect, not IR.
5.2 The canonical IR: UISpec
Flat list of components with references by id (LLMs generate flat lists better than nested trees, and this makes incremental parsing trivial).
{
"version": "skillnet-ui/1",
"format": "explanation",
"root": "b0",
"components": [
{"id": "b0", "type": "Stack", "props": {"gap": "md"},
"children": ["b1", "b2", "b3"]},
{"id": "b1", "type": "TextContent",
"props": {"text": "Returns are accepted within 30 calendar days.",
"variant": "body"}},
{"id": "b2", "type": "StepSequence",
"props": {"title": "Return process",
"steps": ["Check the product", "Scan the receipt",
"Log it in the system", "Issue the refund"]}},
{"id": "b3", "type": "QuizItem",
"props": {"item_id": "q1", "item_type": "test", "bloom_level": "apply",
"question": "A customer returns an item on day 32. What do you do?",
"options": ["Accept the return", "Offer the manufacturer's warranty",
"Reject it outright", "Call the manager"]}}
]
}
Contract rules, validated by Pydantic in src/render/spec.py:
rootmust exist incomponentsand be a container type (StackorCard).- Every reference in
childrenmust exist. Forward references allowed. - No cycles in the
childrentree. - Maximum 12 components per spec and 5 elements at the root level of the
Stack. This isn’t aesthetics: working memory processes 4-7 elements, and a “cognitive screen” is 3-5 related elements. A spec with 30 blocks is a generation failure, not rich content. QuizItemdoes not carry the correct answer or an explanation. That goes inanswer_key. Corollary, demonstrated (2026-07-26): 100% client-side correction is incompatible with this rule by construction — writing the verdict as$chosen == 1requires serializing the answer to the browser. This isn’t a flaw in OpenUI Lang, it’s arithmetic. The path that does respect it isMutation("grade_answer", {item_id, choice})with a round trip to the server, and it’s turned off (§5.1).props.textis plain text or inline markdown (**,*,`, links). Never HTML.- The first child of
rootin theexplanationandmixedformats must be aTextContent(variant: "lead") or aCallout. It’s a validation error, not a warning, and it’s the slot where the frontend injects the “this helps you with X” line derived fromgoal(§6.2 Q2). Without this rule, question 2’s promise had nowhere to materialize.
Quality heuristic (not a contract rule): two sibling components with token similarity > 0.8 say
the same thing twice (redundancy effect). validate_ui logs it in the structured log and in
node_renders.error_message as a warning, and does not reject the spec. It’s deliberately left
out of the contract because measuring “saying the same thing in two formats” with token similarity
has obvious false positives (a table summarizing the steps the text just listed is good
redundancy). If the §14.2 data shows this happening often with real harm, it gets promoted to an
error; until then it isn’t presented as a fulfilled contract.
answer_key, stored separately and never serialized to the client:
{"q1": {"item_type": "test", "correct": 1,
"explanation": "Manual p.3: after 30 days the manufacturer's warranty applies",
"bloom_level": "apply"}}
5.3 The SkillNet UI Kit — frozen list
Updated on 2026-07-26. Where each thing lives since the adoption:
apps/skillnet-web/src/components/courses/kit/— the catalog, in zod +defineComponent. This is where the prompt comes from:scripts/generate-openui-prompt.mjscallslibrary.prompt()and writesapps/skillnet-api/src/render/openui_prompt.txt+openui_catalog.json. A single place where the list is declared.src/render/kit.py— source of truth for validation (types, enums, positional order, the 7 rules viasrc/render/spec.py). It no longer generates the prompt.tests/test_render_prompt_artifact.pyrecomputes the catalog digest from here and fails if the artifact doesn’t match: it’s the drift alarm between the two sides, the one that will warn the day its API changes.apps/skillnet-web/src/components/courses/blocks/— the React implementation, now registered in OpenUI’s library instead of dispatched viaswitch.
The browser library registers ten components and the prompt catalog advertises nine:
Markdown is written by the server for fallback_seed and the model cannot emit it. Since the
browser now receives dialect, the fallback also needs a dialect form, so serialize() covers all
ten and parse() still rejects Markdown. The asymmetry didn’t disappear: it moved.
| Component | Props (positional order for the OpenUI dialect) | What it’s for |
|---|---|---|
Stack |
children: string[], gap: "sm"|"md"|"lg" |
Vertical container |
TextContent |
text: string, variant: "body"|"lead"|"caption" |
Prose |
Card |
title: string, children: string[] |
Grouping |
Callout |
tone: "info"|"warn"|"success", text: string |
Critical rule, exception |
StepSequence |
title: string, steps: string[] |
Procedure (2-7 steps) |
Table |
headers: string[], rows: string[][] |
Compare concepts |
CodeBlock |
language: string, code: string |
Code example |
Chart |
kind: "bar"|"line", title: string, labels: string[], values: number[] |
Quantitative data |
QuizItem |
item_id: string, item_type: exercise_type, bloom_level: string, question: string, options: string[] |
Exercise |
Markdown |
content: string |
Only for fallback_seed. The LLM cannot emit it |
Naming decisions, closed: StepSequence (not StepList); Chart unified with kind (not
BarChart/LineChart); Callout is included because procedural exceptions are 80% of compliance
content; Timeline, ImageCard, DragDrop, Simulation and SandboxHTML are not included.
The 6 values of item_type are exactly those of the existing exercise_type enum.
QuizItemBlock is self-contained; it is not a bridge to v1’s ExerciseRenderer. The earlier
claim (“reused as-is, only swapping the submission hook”) was false: each of the six exercise
components builds its own mutations and relies on the id of a real row in exercises — e.g.
TestExercise.tsx:3 imports useSubmitAttempt/useCorrectExercise, calls them on lines 10-11, does
correctMut.mutate(exercise.id) and uses name={exercise.id} for the radio group. Turning them into
controlled components would require refactoring all six to accept injected handlers: v1 surface no
batch budgets for. Decision: B6 writes QuizItemBlock.tsx with its own state and its own
submission against POST /nodes/{id}/answer (2 internal subcomponents for single-choice and text),
and does not touch src/components/exercises/. The duplication of ~120 lines of UI is accepted
in exchange for not touching v1.
5.4 The adapter interface
src/render/backends/base.py
class RenderBackend(Protocol):
name: str # "openui" (the registry allows more)
# prompt_fragment() WAS REMOVED on 2026-07-26: the prompt is now generated by
# library.prompt() in the build step and read by src/render/prompt.py. A backend
# validates a dialect and re-writes it; it no longer teaches it.
def parse(self, raw: str, *, ui_format: str | None = None) -> UISpec:
"""Parses the complete dialect. Raises RenderParseError."""
def parse_partial(self, raw: str, *, ui_format: str | None = None) -> UISpec:
"""Tolerant parsing of incomplete output (streaming). Discards the last
line if it's mid-way through. Never raises. Still needed on the server
even though the browser also parses: what gets sent during streaming is
the canonical re-serialization of the prefix, never the model's bytes."""
def serialize(self, spec: UISpec) -> str:
"""Spec -> canonical text. The ONLY thing the client can receive."""
# src/render/backends/__init__.py
_BACKENDS = {"openui": OpenUiLangBackend()} # a single dialect in this PR
def get_render_backend(name: str | None = None) -> RenderBackend:
return _BACKENDS[(name or settings.RENDER_BACKEND)]
Validation parsing is Python, in the backend; painting parsing is JavaScript, in the browser.
Of the three consequences this paragraph used to claim, (b) still holds — the fixtures cover the
parser without a browser — and the other two changed on 2026-07-26: (a) npm dependencies do
enter now (@openuidev/react-lang@0.2.9, @openuidev/lang-core@0.2.10, zod@4.4.3, exact
versions), and (c) the browser no longer receives JSON, it receives dialect. What takes the place
of (c) are the three properties and four controls from §5.1.
Backend 1 — openui (DEFAULT). src/render/backends/openui.py. Line-by-line dialect, one
declaration per line, positional arguments in the kit table’s order, references by array:
root = Stack([intro, steps, quiz], "md")
intro = TextContent("Returns are accepted within 30 calendar days.", "body")
steps = StepSequence("Return process", ["Check the product", "Scan the receipt", "Log it in the system", "Issue the refund"])
quiz = QuizItem("q1", "test", "apply", "A customer returns an item on day 32. What do you do?", ["Accept the return", "Offer the manufacturer's warranty", "Reject it outright", "Call the manager"])
Chosen as default for token density (≈50% less than equivalent JSON) and because the line-by-line
format makes parse_partial trivial: every \n completes a component. The variable name is the
component’s id in the IR.
Frozen grammar. Lives in the docstring of src/render/backends/openui.py and is no longer a
GRAMMAR constant pasted into the prompt: library.prompt() puts the syntax block in. It’s still
the gate’s specification, and it’s what makes reactivity inexpressible rather than being on a
blocklist. An “obvious” dialect with one example and no rules is what an 8B model breaks on day one;
these three are exactly the ones it breaks, and they go into the prompt via additionalRules:
program = { line } ;
line = ident "=" call newline ;
ident = ("a".."z" | "A".."Z" | "_") { "a".."z" | "A".."Z" | "0".."9" | "_" } ;
call = comp_name "(" [ arg { "," arg } ] ")" ;
comp_name = "Stack" | "TextContent" | "Card" | "Callout" | "StepSequence"
| "Table" | "CodeBlock" | "Chart" | "QuizItem" ;
arg = string | number | array | ident | call ;
array = "[" [ arg { "," arg } ] "]" ;
string = '"' { char | escape } '"' ;
escape = "\" ( '"' | "\" | "n" ) ;
char = <any character except '"', '\' and newline> ;
number = [ "-" ] digit { digit } [ "." digit { digit } ] ;
arg = … | call is from 2026-07-27 (docs/design/openui-adoption.md §4 bis). An inline nested
call, root = Stack([TextContent("Hi.", "lead")], "md"), is valid OpenUI Lang and the signature
block that library.prompt() generates offers it; rejecting it was a subset of our own making, not
a rule of the standard, and it cost a 7B model the entire repair loop. parse flattens it into
the UISpec’s flat list with deterministic synthetic ids (root_1, root_1_1, …), so rule 4 of
§5.2 is counted after flattening; it only makes sense where the kit declares a ref[]
(Stack.children, Card.children) and in any other position it’s a named error; and per-line depth
is capped (16) so recursion isn’t a crash vector. serialize still emits only the referenced
form: it’s the canonical form, the one its prompt recommends for streaming, and the only one that
gives each block a real statementId in the browser.
The three rules the prompt repeats imperatively and that the malformed fixtures cover one by one:
- Double quote inside a string:
\". Never unescaped. - Nested arrays allowed and required in
Table.rows(string[][]):Table("t", ["A","B"], [["1","2"],["3","4"]]). - No literal newline inside a string — write
\ninstead. This is a hard restriction, not stylistic:parse_partial’s whole premise is that every\ncloses a component, so a literal break inside a quote breaks incremental parsing, not just the complete one.parse()rejects an unclosed string at end of line;parse_partial()discards that line.
There is no second backend in this PR. The seam does go in (the Protocol and the registry), the
second dialect does not — see §1.3 for the reason (UIDL/1 cannot represent the UISpec). Concrete
consequences, all applied further below: RENDER_BACKEND_FALLBACK disappears from §10.2; the
“cross retry” disappears from §14.1; and tests/test_render_a2tl.py doesn’t exist.
What takes its place as a second attempt (which was the real value of the cross retry): in the
single retry (MAX_UI_RETRIES = 1) the prompt is not the same one. UI_REPAIR_SYSTEM is sent,
which includes the failed raw_dsl, the parser’s exact validation_errors, and the instruction to
return the corrected program and nothing else, with temperature=0.0. Repairing with the error in
hand is more effective than retrying blind or switching dialects. If that also fails:
fallback_seed. The “never a red screen” safety net is provided by fallback_seed, not by a second
dialect.
5.5 Frontend side
CORRECTED on 2026-07-26. The
switch-based dispatch over aUiSpecis replaced by<Renderer>from@openuidev/react-langover the library atsrc/components/courses/kit/: the same ten block components, now registered instead of dispatched, with streaming carried byisStreaming. The props that are not passed are part of the security contract (§5.1): notoolProvider, noonAction, noonStateUpdate. The block below describes the earlier shape and is kept because the spatial stability zones, the pinning ofactive_render_id, and the two control affordances do not change.
apps/skillnet-web/src/components/courses/UiSpecRenderer.tsx — same dispatch pattern as
ExerciseRenderer:
export function UiSpecRenderer({ spec, nodeId }: { spec: UiSpec; nodeId: string }) { … }
// switch (component.type) → blocks/StackBlock, TextContentBlock, CardBlock,
// CalloutBlock, StepSequenceBlock, TableBlock, CodeBlockBlock, ChartBlock,
// QuizItemBlock, MarkdownBlock
ChartBlock is drawn with inline SVG (no Chart.js or Recharts added). MarkdownBlock reuses
LessonContent, which is kept intact and also becomes the fallback. A component of unknown type
renders null and logs a warning — it never breaks the page.
Spatial stability zones, mandatory in NodeView.tsx:
| Zone | Content | Can it change |
|---|---|---|
| Frozen | Header, node title, ProgressBar, previous/next buttons, chat access |
Never |
| Stable | Order and content of the blocks while the node is open | Only if the user asks for it |
| Adaptive | Which examples, which format, which depth, which difficulty, between nodes and sessions | Freely |
How the “Stable” row is guaranteed (previously a promise with no mechanism):
GET /nodes/{id}/render serves learner_node_states.active_render_id while render_pinned is
true. It doesn’t recompute the key, so answering an item cannot change the screen, and a TanStack
Query refetch on window refocus returns byte-for-byte the same thing.
User control, two minimal affordances (not optional; they’re the counterpart to adapting nothing):
- “Update this lesson” — a button in the node footer that does
POST /nodes/{id}/render {"force": true}and repaintsactive_render_id. It’s the only way an open node’s content changes. - “See the previous version” — when regenerating, a line reading “this lesson has been adapted
to your latest answers” is shown with a link to the previous render. The rows already exist in
node_rendersandnode_render_views(ordered byfirst_seen_at DESC), so it’s a query, not a new table.
On revisiting an already-seen node, the latest render for that (user, node) is served, not a
new one, even if the profile has changed. Regenerating requires the button.
6. Onboarding
6.1 Shape
5 screens, one question per screen, at most 3 visible elements per screen, target
≤90 seconds. Skippable at any point with “I’ll do it later”. Asked once: skipping writes
onboarding_completed_at and onboarding_skipped = true, and it isn’t asked again (it can be redone
from settings). The 5-screen and 3-element limits come from the documented attention adaptations
(5-question-max tests, 3 bullets per screen).
Skipping writes experience_level = 'unknown', not 'none' (§3.3): 'none' means “declares
being a beginner” and forces beginner scaffolding, which is the case that hurts the expert. Someone
who skips hasn’t declared anything.
Gate in ProtectedRoute, with the details that matter. ProtectedRoute today only has
useAuth() (user, isLoading), and it also wraps every admin page, so:
redirects to /onboarding ⇔ features.dynamic_courses === 'on'
∧ user.role === 'employee'
∧ profile loaded with onboarding_completed_at == null
- The flag is read from
GET /healthonce at startup (see §10.1 — not from/auth/me). - The profile query is conditioned on
role === 'employee' && flag === 'on', so an admin never triggers it. - A 404 from the profile endpoint means “don’t redirect”, not “not onboarded”. If it meant the latter, turning the flag off mid-session (routes become 404) would drop the user into a redirect loop toward a route that no longer exists.
- While the query is in flight,
AppSkeletonis painted, never a redirect.
6.2 The questions
| # | Question (en) | Type | Field | Why this one and not another |
|---|---|---|---|---|
| 1 | “What’s your role?” | free text + 6 org-sector suggestions | learner_profiles.role_title, sector |
The role goes literally into genera_ui’s system prompt to contextualize the examples. It’s content adaptation, the one with strong evidence |
| 2 | “What do you want to use SkillNet for right now?” | 3 options + “other” | goal |
Andragogy principle: adults need to know the WHY before investing time. It never travels to the LLM. It’s rendered as a deterministic opening line in the lead block that rule 7 of §5.2 requires to exist (templated by goal value, on the client). This way the promise is always fulfilled, not only when the model remembers |
| 3 | “How much experience do you have in your current role?” | None / Some / Plenty | experience_level |
Prior knowledge is the only dimension with a large effect: it reverses instructional design (worked examples help the novice and hurt the expert). The question does not name any course: the field is one per person (UNIQUE (user_id)) and enters the cache_key of all their courses, so asking about “Customer service” and then applying it to “Fire prevention” would be incoherent. Per-competency granularity is provided by user_skills via course_nodes.skill_id (§7.1), not by the declaration |
| 4 | “How do you prefer to study?” | Standard / Focus / Fast pace, each with a one-line description | preset (+ mirrored in users.learning_profile) |
It’s presentation, not modality. Gives real autonomy and is reversible without restrictions |
| 5 | “Want to turn on any reading settings? (optional)” | checkboxes: short blocks · fewer animations · more contrast · no time limit | users.accessibility |
No diagnosis, no label. “Read aloud” is removed: there’s no TTS in this PR nor an audio component in the kit, and offering a nonexistent accommodation is worse than not offering it. “Shorter blocks” is real: it translates to effective_density ≤ 2 on the server (§3.1) |
6.3 What is not forced during this onboarding
- Initial level via test. The system adjusts by performance; the per-node pre-assessment already does that job, and better, because it’s per competency, not global.
- Preferred format (video, text, audio?). It isn’t forced as an initial question nor turned into
a “learning style” label. However, any later explicit choice is respected and overrides the
format_vector. The requested modality can combine different pedagogical strategies — retrieval, self-explanation, contrast, or scenario — without replacing the user’s choice. Seeadaptive-learning.md. - Neurodivergence diagnoses. A diagnosis is health data (special category, GDPR art. 9) and isn’t needed: the concrete settings from question 5 produce the same functional result without the legal risk. Question 5 asks about needs, not conditions.
6.4 Calibration period
For a new user the format_vector is all zeros: there’s no signal. Hard rule, implemented in
decide_formato:
if profile["nodes_completed"] < 3:
vector_bucket = "" # doesn't enter the cache_key
ui_format = node.default_ui_format # real column, §3.2 — decide_formato isn't called
# the prompt receives ONLY: role_title, sector, experience_level, preset,
# effective_density and scaffold_band
What exactly “doesn’t adapt” means, with the boundary drawn — the earlier version said “the
presentation doesn’t adapt” while §4.2 fed the full node_state into the prompt from node 1, which
contradicted it:
| Dimension | Does it act during calibration? |
|---|---|
Format (ui_format: explanation vs exercise vs table) |
No. node.default_ui_format is used |
Implicit vector bucket (vector_bucket in the key and the prompt) |
No. Events accumulate and are not used |
| Content (role, sector, source) | Yes, from the first node |
Scaffolding (scaffold_band, last_error_kind, consecutive_failed) |
Yes. It’s difficulty and support, not spatial layout: responding to the learner’s error doesn’t move the interface around |
The reason for the first row is the lesson from the failure of Office 2000-2003’s adaptive menus: the user must form their mental map before the interface starts moving.
Expected node distribution and its uncomfortable consequence. A typical compliance course has
3-6 nodes; process courses, 6-12. With a 3-node course and a single enrollment, the
format_vector subsystem (events endpoint, decay, L1 normalization, vector_bucket, 90-day purge)
does not influence a single render: the user finishes the course still in calibration. This is
accepted knowingly — the vector is infrastructure for the second and third course, not the first —
and that’s why nodes skipped by the probe do not increment nodes_completed (§3.3): if they did,
someone could leave calibration with zero interaction events and the vector would be applied over
noise.
7. Pre-assessment and mastery rule
7.1 The items
When a node is opened, POST /nodes/{node_id}/probe returns 2 items (3 for critical nodes):
- Item A —
bloom_level = "apply", of typetestwith exactly 4 options. A case, not a definition. It’s the deciding one. - Item B —
bloom_level = "understand", of typetestwith exactly 4 options.true_falseis no longer allowed: with true/false the chance floor rises from 6.25% to 12.5%, and the number §7.2 uses to justify the doubt band would stop being true. - Item C (tiebreak) — constructed response:
fill_blankor a shortpractical_case. In acriticalnode it’s always mandatory; in others only if the verdict falls in the doubt band.
Item source, in order of preference:
- Pre-generated at schema validation and stored in
course_nodes.probe_items/probe_answer_key(§3.2). This is the normal case: zero tokens and zero wait. Items depend only on(node, source), so one generation per node serves the whole organization. - If the node has a
seed_lesson_idand there are no pre-generated items, existing exercises from that lesson are sampled at the requested Bloom levels. Zero tokens. - Last resort: an LLM call (purpose
runtime_fast,json_mode,max_tokens=500) fromnode.summary+source_context, written intocourse_nodes.probe_itemsso the next employee doesn’t pay for it.
The attempt is recorded in node_probes with a separate answer_key, a single scored row per
(user_id, node_id, schema_version) — see §3.4 for the full anti-retry rule, which is what
prevents skipping a node by re-entering until you get it right by chance.
Prior from user_skills, instead of starting everyone at 0. course_nodes.skill_id exists and
user_skills.skill_level already carries a verified level (including peer/manager verification).
When learner_node_states is created, it is seeded with:
mastery_prior = {"high": 0.85, "medium": 0.55, "low": 0.25}.get(user_skill_level, 0.0)
It’s only the EWMA’s and scaffold_band’s starting point; it does not skip the node by itself
(that’s decided by the probe). Until now §7 only wrote to user_skills and never read it, wasting
the only prior-mastery signal the product already had.
Diagnostic probe for the declared beginner. If experience_level == 'none' and
nodes_completed == 0, the first node’s probe is served with scored = false: it’s presented as
“let’s see what you already know”, it does not persist failures, doesn’t score mastery, and
doesn’t consume the single attempt. Without this, the first product experience for someone who has
just declared themselves a beginner is N×2 guaranteed failures before seeing a single line of
content.
7.2 The rule, made computable
Honest starting point: the four deterministic types score 0.0 or 1.0, with no partial credit
(verified in exercise_service.py:25-57, fill_blank included). With two binary items,
0.6a + 0.4b can only take the values {0.0, 0.4, 0.6, 1.0}. Consequence of the previous version of
this rule: the three thresholds (0.90 / 0.80 / 0.70) behaved identically — 1.0 mastered in all
three, 0.6 fell into tiebreak in all three, 0.4 and 0.0 stayed in learning in all three — and the
0.5a+0.2b+0.3c tiebreak capped out at 0.80, below critical’s 0.90, so on a critical node it
was dead code: an LLM call and an extra question that couldn’t change the verdict.
# src/services/mastery_service.py
THRESHOLDS = {"critical": 0.90, "recommended": 0.80, "contextual": 0.70}
DOUBT_BAND_FLOOR = 0.55
W_APPLY, W_UNDERSTAND = 0.6, 0.4
# Renormalized tiebreak: a perfect third item reaches 1.0, so
# ALL thresholds are reachable and none is unreachable.
W3_APPLY, W3_UNDERSTAND, W3_CONSTRUCTED = 0.45, 0.15, 0.40
FADING_STREAK = 3 # N
REGRESS_STREAK = 2
def probe_estimate(score_a: float, score_b: float) -> float:
return W_APPLY * score_a + W_UNDERSTAND * score_b
def probe_verdict(score_a, score_b, criticality, threshold=None):
"""Verdict with ONLY the two selected-response items."""
est = probe_estimate(score_a, score_b)
if score_a < 0.5: # fails apply → never masters
return "learning", est
if est >= 1.0:
# Everything correct. On a critical node this is NOT enough: chance here is 1/16.
if criticality == "critical":
return "tiebreak", est
return "mastered", est
if est >= DOUBT_BAND_FLOOR: # 0.6 → doubt
return "tiebreak", est
return "learning", est
def tiebreak_mastery(score_a, score_b, score_c) -> float:
return W3_APPLY * score_a + W3_UNDERSTAND * score_b + W3_CONSTRUCTED * score_c
def tiebreak_verdict(score_a, score_b, score_c, criticality, threshold=None):
m = tiebreak_mastery(score_a, score_b, score_c)
thr = threshold if threshold is not None else THRESHOLDS[criticality]
return ("mastered" if m >= thr else "learning"), m
Resulting arithmetic, verified (tests/test_mastery.py asserts it case by case):
| a | b | c | tiebreak_mastery |
critical 0.90 | recommended 0.80 | contextual 0.70 |
|---|---|---|---|---|---|---|
| 1 | 1 | 1 | 1.00 | masters | masters | masters |
| 1 | 0 | 1 | 0.85 | learns | masters | masters |
| 1 | 1 | 0 | 0.60 | learns | learns | learns |
| 1 | 0 | 0 | 0.45 | learns | learns | learns |
The thresholds now actually discriminate and none is unreachable. Four rules that make this defensible:
- You cannot master while failing the apply item (
score_a < 0.5→learning, no exceptions). - In a two-selected-item probe, the honest verdict is “all correct = candidate”. It’s phrased
this way instead of pretending a continuous threshold discriminates over four possible values.
The per-criticality threshold does its real work in two places: deciding whether constructed
confirmation is needed, and in the
learning → masteredtransition in §7.3. - On a
criticalnode,masterednever comes purely from selected response. The constructed tiebreak is mandatory. Combined chance: 1/16 × ~0 ≈ 0. Together with the single probe per schema version (§3.4), skipping a safety node by luck stops being a strategy. - The threshold depends on criticality, not on the person.
7.3 Mastery during the node
After every POST /nodes/{node_id}/answer:
ALPHA = 0.4 # weight of new evidence (EWMA)
mastery_new = (1 - ALPHA) * mastery_old + ALPHA * score
if passed:
consecutive_correct += 1; consecutive_failed = 0
# MASTERY CEILING: without this the EWMA converges to the average of the
# scores, so someone sustaining 0.85 asymptotes at 0.85 and NEVER reaches
# a critical node's 0.90 -> course impossible to complete.
if consecutive_correct >= FADING_STREAK:
mastery_new = max(mastery_new, node.mastery_threshold)
else:
consecutive_failed += 1; consecutive_correct = 0
mastery_new = min(mastery_new, mastery_old) # a failure never raises mastery
The ceiling corrects a real arithmetic flaw: mastery_new = 0.6·old + 0.4·score has a fixed point at
score, so the competent-but-imperfect learner (sustained 0.85) would stay 0.05 short of the
threshold forever, and since enrollments.status='completed' requires all critical nodes to be
mastered, the course would remain permanently incomplete with no way out. With the ceiling, three
consecutive correct answers are sufficient evidence: it’s the same streak already required, now
also applied to the magnitude and not just the counter.
node_state transitions, deterministic and complete (the 8 covered by
tests/test_mastery.py):
| # | From | Condition | To | Effects |
|---|---|---|---|---|
| 1 | not_started |
probe is requested | probing |
first_seen_at, mastery = prior from user_skills (§7.1) |
| 2 | probing |
probe_verdict == "mastered" |
mastered |
probe_score = est; mastery = max(prior, est); mastered_at; nodes_completed is NOT incremented |
| 3 | probing |
probe_verdict == "tiebreak" |
probing |
tiebreak_used = true; item C is served; probe_score not yet written |
| 4 | probing |
tiebreak_verdict == "mastered" |
mastered |
probe_score = m; mastery = max(prior, m); mastered_at |
| 5 | probing |
probe_verdict/tiebreak_verdict == "learning" |
learning |
probe_score written; mastery is NOT touched (prior remains); scaffold_band frozen |
| 6 | learning |
mastery >= threshold and consecutive_correct >= 3 |
mastered |
mastered_at; nodes_completed += 1 |
| 7 | learning |
consecutive_failed >= 2 |
learning |
lowers difficulty, no state change; reforzar_con_ejemplo signal |
| 8 | learning |
4th failure on the same item after 3 hints (§7.4) | needs_review |
worked solution shown; the node enters the practice queue |
Two ambiguities that used to be open and affect certificates, closed above: mastery after a
probe is written only if the verdict masters (max(prior, estimate)), and if the verdict is
learning the prior is kept — so probe_score and mastery don’t step on each other; and
nodes_completed is incremented only in transition 6. Without pinning both down,
enrollments.score (average of mastery over critical nodes) varied by implementation detail,
and that ends up printed on a certificate.
mastered → needs_review does not exist in this PR: it would require the spaced-repetition
scheduler, which isn’t in the repo (§1.3). The only producer of needs_review is transition 8.
FADING_STREAK = 3 and REGRESS_STREAK = 2, fixed, the same for every criticality. The value
that already appears in the research is chosen (3 correct answers raise, 2 failures lower) and it
isn’t parameterized per skill until there’s real data.
Requiring both mastery >= threshold and a streak of 3 avoids the central problem of
cognitive offloading: with AI-generated content the learner reports less cognitive load but
produces weaker answers — the “illusion of mastery”. A streak requires repeated production, not a
lucky spike.
7.4 Scaffolding escalation
Hard rules in genera_ui’s prompt and in the service, not suggestions:
attempt-before-hint: no hint is offered until at least one attempt is recorded innode_attemptsfor thatitem_id. A click-to-explain inside an unansweredQuizItemcounts as a hint and consumes the quota — see §8.5, where it used to be an escape hatch that didn’t touchhints_used.- Hint cap: 3, with a defined exit. On the fourth failure the full worked solution is shown and
the node moves to
state = 'needs_review'(not'learning'), which gives it three things it didn’t have before — the earlier version said “the node is passed over” without defining any way back:- Visibility:
NodeListReadexposesneeds_practice: trueand the node appears in a “to practice” section, instead of disappearing. - Re-entry: it can be retried at any time (
POST /nodes/{id}/render {force:true}regenerates withlast_error_kindin the prompt) and re-probed after 7 days (§3.4). - Human path:
POST /nodes/{node_id}/waive(admin or manager role) setsmasteredwithwaived_by/waived_atand a row inaudit_log(action='node_waived'). It’s consistent with the product’s “if you know it, you know it” principle: a human who has seen the person work can certify them, and who did it is recorded. While acriticalnode is inneeds_review,enrollments.statusstays atactiveandNodeListRead.can_completeisfalsewith the node listed inblocked_by. The course neither completes silently nor blocks silently: you can see why.
- Visibility:
- Error classification →
last_error_kind, which enters the nextgenera_uicall:detail(typo/format) → correct and continue;procedural→ point at the exact step and repeat;conceptual→ a single Socratic question about the mistaken part. - Don’t intervene by default. If
consecutive_correct >= 1and there’s no overload signal, the next render doesn’t add scaffolding or extra explanations. Silence is the default. - No time limit on any item. Time pressure increases extraneous cognitive load.
7.5 Course closure
enrollments.status = 'completed' when all non-archived critical nodes of the course are
mastered (via mastery, probe, or waive). recommended and contextual nodes don’t block it.
enrollments.score = average of learner_node_states.mastery over those critical nodes.
_assign_course_skills still grants user_skills with the mastery → skill_level translation from
§3.3, and never downgrades.
Mandatory recalculation on schema change. PUT /courses/{id}/schema changes the set of
critical nodes, which is precisely what governs the closure condition. In the same transaction as
the PUT (and the validate), the condition is recalculated for all active enrollments of the
course: an already-completed course can go back to active if the creator adds a new critical
node, and a blocked one can complete if the missing node is archived. It’s recorded in audit_log.
Without this, enrollment state would be a function of a schema that no longer exists.
8. Click-to-explain (Curio)
8.1 What is ported and what is not
| Ported | Destination | Note |
|---|---|---|
tokenize() + TOKEN_RE + Token |
apps/skillnet-web/src/lib/tokenize.ts |
Pure function, no dependencies. Copied with its regex `/[\p{L}\p{N}]+(?:[’’-][\p{L}\p{N}]+)* |
toClickable() |
src/components/courses/ClickableText.tsx |
Wraps each clickable token in a <span className="entity"> |
clickify() pattern over already-rendered text nodes |
ClickableText + LessonContent |
The key to the port: raw markdown is never tokenized, only the typeof child === 'string' children of the already-built tree. That’s why the structure doesn’t break |
ClickableSurface (a single listener with onClick + onMouseUp) |
src/components/courses/ClickableSurface.tsx |
With the justDragged ref and its setTimeout(…, 0) |
expandRangeToWords() |
src/components/courses/ClickableSurface.tsx |
Snap to full word on selections |
cleanDescription() |
src/llm/prompts/explain.py (Python) |
Cleanup happens on the server, before caching |
| Description prompt | src/llm/prompts/explain.py |
No uppercase tags (small models tend to repeat them) |
useGenerative is NOT ported |
— | Triggers 4 LLM generations per click. Only the quick-glance behavior is wanted |
@floating-ui/react is NOT ported |
— | New dependency. Manual positioning in the style of the extension’s Overlay.tsx, with framer-motion, which is already present |
| Hover is NOT ported | — | Click and selection only. Debounced hover is promised in Curio’s docs but never implemented, and it would trigger cost without user intent |
8.2 Mandatory corrections relative to the original
These three are acknowledged bugs in the original and are not replicated:
STOPWORDSin Spanish and English. The original list is English-only, so in a Spanish textde,la,quewould be clickable andthe,ofwould not. It definesSTOPWORDS_ES(~120 function words) ∪STOPWORDS_EN.- The cache key includes the context.
(org_id, term_normalized, context_hash, language)— see §3.4. Withoutcontext_hashthe function would contradict its own premise. - Keyboard accessibility. Words don’t carry
tabindex(it would flood the tab order of a long node). Roving tabindex over the block is implemented instead: each text block istabindex="0"withrole="group", and inside it the ←/→ arrows move a logical cursor between clickable words, Enter/Space opens the explanation. The active<span>getsaria-expanded="true"and there’s a real:focus-visiblerule. A@media (prefers-reduced-motion: reduce)rule is added to the popover’s animations, which is also missing in the original.
8.3 The context that gets sent
- Block context: it climbs to the closest block using
BLOCK_SELECTOR = 'p,li,h1,h2,h3,h4,h5,h6,blockquote,td,th,dd,dt', normalizes whitespace (.replace(/\s+/g,' ').trim()) and trims to 600 characters centered on the term, not the first 600. Fixed relative to the original: in a long block, the clicked term could fall outside the context sent to the model, which is exactly the worst possible failure. - Node context:
node_id(the server addsnode.titleandnode.summary). Replaces Curio’smessageId/last user turn, which doesn’t apply here.
8.4 How it is served
POST /api/v1/explain with Accept: text/event-stream.
- The term is normalized (
trim().toLowerCase()) andcontext_hashis computed. - Hit in
term_explanations→ a singletokenevent is emitted with the full text anddone;hit_countandlast_used_atare incremented. Latency ~10 ms, cost 0. - Miss →
LLMService.stream()with purposeruntime_fast,temperature=0.2,max_tokens=80. It accumulates, passes throughclean_explanation()on each delta and emitstoken. It’s persisted once finished. - Rate limit: 30 explanations per user per minute, in process memory. Above that,
429and the popover shows “Too many requests in a row.” - Two limits, not one: more than 140 characters →
422(an accidental selection of half a paragraph is not a term). Between 61 and 140 characters → it’s explained but not persisted (term_explanationsonly caches ≤60 characters and ≤4 tokens, §3.4). Storing phrases chosen by the user in a row with nouser_id, no retention window, and no delete endpoint would contradict §3.3’s own privacy promise.
Prompt (EXPLAIN_SYSTEM, in src/llm/prompts/explain.py): exactly one short sentence, no
markdown, no preamble, no repeating the instruction, in the language of the text, explaining
the term in its concrete usage — not translating it. No uppercase tags like TERM:.
The popover is not recursively clickable (its content is rendered as plain text). This avoids
generation loops and adds nothing. It does carry one action: “I don’t understand”, which
opens the existing v1 chat seeded with the term, the block text, and the node_id. It’s the exit
for someone who doesn’t understand the single sentence, who otherwise had no next step: the chat
lives on another route and doesn’t know the node’s context.
8.5 Where it mounts and which clicks do NOT count
NodeView.tsx wraps <UiSpecRenderer> in <ClickableSurface nodeId={nodeId}>.
ClickableText is applied inside TextContentBlock, CalloutBlock, StepSequenceBlock,
TableBlock, CardBlock (titles included).
Explicit hit-test rule. ClickableSurface is a single listener over the whole subtree, and
that subtree is no longer chat prose: it contains buttons, radios and inputs. Curio’s original
pattern never had to distinguish “click on a word” from “click on a control,” and justDragged
only separates drag from click. Without a rule, answering a quiz option would also trigger the
explanation. First line of the handler:
if ((e.target as HTMLElement).closest(
'button, a, input, textarea, select, label, [role="radio"], [role="button"], [data-no-explain]'
)) return;
- The entire
QuizItemBlockcarriesdata-no-explain: statement and options. The previous version only excluded the statement, and the options are exactly the part that leaks the answer — clicking a word inside the correct option returns a contextual explanation of it. CodeBlockBlockand links remain excluded.- Inside an unanswered
QuizItem, if explain were ever enabled there, each explain would count as a hint (hints_used += 1, subject to the cap of 3 and toattempt-before-hint). Today it’s simply disabled, which is the safe version of the same rule: a free explain was an uncounted hint that also fed the vector’s highest weight (+0.30). - Mandatory test in
ClickableSurface.test.tsx: clicking an option’s button produces no request to/explain.
9. Latency strategy
Three layers, in this order.
9.1 The pre-assessment IS the productive wait
The contradiction “the pre-assessment and the waiting screen occupy the same first seconds” is resolved by merging them:
Prerequisite, without which this doesn’t work: the probe items are pre-generated in
course_nodes.probe_items at schema validation time (§3.2, §7.1). If the probe had to be generated
with an LLM call when the node is opened, the “productive wait” would have its own wait in front of
it against a blank screen — a wait can’t cover for a wait. In the residual case where they have to
be generated (a node without pre-generation), node.summary plus the opening line derived from
goal are shown while they generate.
t=0 POST /nodes/{id}/probe → 2 items (pre-generated: instant)
t≈0 the user reads and answers item A
t=A POST /nodes/{id}/probe/answer (A) ─┬─► if the verdict can no longer be "mastered",
│ POST /nodes/{id}/render fires IN BACKGROUND
└─► the user answers item B
t=B POST /nodes/{id}/probe/answer (B) → final verdict
t=B+ε GET /nodes/{id}/render/stream → normalmente ya hay bloques listos
Answering item B costs 10-20 s of human attention, which is the same order of magnitude as
decide_formato + genera_ui. The wait disappears because it overlaps with pedagogically useful
work (the pre-question effect has a notable effect size on its own). If the final verdict comes
out mastered, the in-flight render is cancelled (asyncio.Task.cancel()) and discarded — cost
accepted in exchange for zero latency in the frequent case.
9.2 Skeleton and streaming
- Skeleton:
NodeSkeleton.tsxpaints the canonical shape (title + 3 text bars + 1 block) with a shimmer based ontransform/opacity, notanimate-pulse(motion-system.md:437,636forbids it; ashimmerpreset is added tosrc/lib/motion.ts). It’s implemented as a new componentShimmerSkeleton.tsx, andsrc/components/ui/Skeleton.tsxis NOT touched: that file usesanimate-pulseand is re-exported asSkeletonText/SkeletonCard/SkeletonRowin v1 pages (plus its story), so changing it would be a visible v1 change with the flag off, contradicting §10.1.design-system.md§Skeleton, which documentsanimate-pulseas the canonical pattern, is the stale doc and gets fixed in thechoreof §14.2 #8. - Streaming:
GET /nodes/{node_id}/render/streamis SSE with these events:
| Event | Payload | When |
|---|---|---|
render_step |
{step, message} |
On entering each graph node |
ui_format |
{format, tier} |
After decide_formato — allows swapping the skeleton for one of the correct shape |
ui_block |
{component} |
Each time parse_partial completes a new component |
ui_done |
{render_id, format} |
On persisting |
node_skipped |
{reason: "mastered"} |
If the gate skips the node |
error |
{step, message, fallback: bool} |
Failure; if fallback is true, the client requests the render again and gets the seed |
The channel is f"node:{request_id}". src/core/sse.py is kept with its known limitation
(in-memory, single worker, loses events prior to subscription) and two functions are added to
it, because “reused as-is” was incompatible with the mitigation: subscribers live in the private
dict _registry (lines 11-37) and there’s no accessor, so waiting “until there’s a subscriber”
couldn’t be scheduled. In B5, and src/core/sse.py goes in its file list:
def subscriber_count(channel: str) -> int:
return len(_registry.get(channel, ()))
async def wait_for_subscriber(channel: str, timeout: float = 0.5) -> bool:
"""True if a subscriber appears before the timeout. Polls every 25 ms."""
With that: POST /nodes/{id}/render returns 202 {request_id}, the client subscribes, and the
runner does await wait_for_subscriber(f"node:{request_id}", 0.5) before starting the real work.
Migrating to LISTEN/NOTIFY is backlog, and is only needed with more than one uvicorn worker
(today docker/api.Dockerfile starts with --workers 1, which is consistent).
9.3 Cache
Four levels:
node_rendersbycache_key(§3.4). A hit is a SQL query: ~5 ms, 0 tokens. This is the level that makes it so the second employee with the same profile doesn’t pay for generation. The lookup is bycache_keyalone, never byuser_id, or the hit rate would be 0.active_render_idby(user, node)(§5.5). Inside an open node and on a revisit, not even the cache is queried: the already-pinned render is served. This is the cheapest level of all and also the one that guarantees spatial stability.course_nodes.probe_items(§7.1). Pre-assessment items are generated once per node at validation time and serve the whole organization. It’s the only pre-generation in this PR, and it’s justified because it doesn’t depend on the user: N employees, one generation.term_explanationsfor click-to-explain (§8.4), and the v1 seed as the final safety net:fallback_seedserveslessons.content. This also answers offline-catalog compatibility: with no LLM available, the course keeps working in degraded v1 mode instead of breaking.
9.4 Anticipated render window
Generation remains on-the-fly: the representation is not folded into the course when it’s validated, nor is the full path produced. What changes is when the runtime work starts, so the model’s latency doesn’t turn into visible latency:
- opening the course requests the first two available lessons;
- once the current lesson is served,
NodeViewrequests the next three; - as the learner advances, that window of three shifts along;
POST /nodes/{id}/render {force:false}is idempotent and reuses a ready render or an in-flight task;- each render keeps the same pins, policy keys, versions, and invalidation rules as if it had been requested by entering the node directly.
The anticipation follows the likely path and is bounded; it does not generate full branches or
every possible component. So “pre-generated” here means runtime render started ahead of time
during the session, not a pedagogical artifact persisted inside the course definition. Authority
and implications for future branching episodes are in
learning-experience-architecture.md §2.1.
10. Path selection: no global flag
This section originally described a three-value
DYNAMIC_COURSES_MODEflag (off/shadow/on) as a progressive rollout mechanism. That flag never made it to production: the mechanism that actually got implemented, and the one that exists today inmain, is simpler — the choice is per course, with no environment variable involved.
src/services/course_delivery.py::resolve_delivery(course) is the single decision point:
def resolve_delivery(course) -> Literal["static", "dynamic"]:
if course.delivery_mode != CourseDeliveryMode.DYNAMIC:
return "static"
if course.schema_status != CourseSchemaStatus.VALIDATED:
return "static"
return "dynamic"
A course goes through v2 only if it has delivery_mode='dynamic' and
schema_status='validated'. Any other course — including any created before v2 existed — keeps
going through v1 on the same instance, with no gate or special environment. GET /api/v1/health
does not expose feature flags; it returns DB and embedding status (src/routes/health.py).
Querying course.delivery_mode/schema_status for this decision anywhere other than this function
is forbidden.
10.1 Secondary flags
| Env var | Values | Default | What it does |
|---|---|---|---|
RENDER_BACKEND |
openui |
openui |
Dialect requested from the LLM and the parser used. A single valid value in this PR; the env var exists so that adding a dialect isn’t a call-site code change |
LLM_RUNTIME_FAST_MODEL |
litellm model id | empty → LLM_MODEL |
Fast tier of the router |
LLM_RUNTIME_HEAVY_MODEL |
litellm model id | empty → LLM_MODEL |
Heavy tier of the router |
LLM_FIXTURE_DIR |
path | src/llm/fixture_data |
Where it looks up/records fixtures (§12) |
LLM_FIXTURE_MODE |
replay | record |
replay |
record records (prompt, response) pairs with a real key |
RENDER_BACKEND_FALLBACK does not exist — it’s removed along with the second dialect (§1.3,
§5.4). The single retry uses UI_REPAIR_SYSTEM with the same dialect.
LLM_FIXTURE_DIR points inside the package, not at tests/. The previous default
(./tests/fixtures/llm) made the fixtures profile of docker-compose.yml impossible: the
runtime image copies only .venv, src, alembic, alembic.ini and pyproject.toml
(docker/api.Dockerfile:34-38), so tests/ isn’t inside the container and every lookup would
fail — exactly the “the whole flow can be demoed locally with no key” promise. Fixtures live in
src/llm/fixture_data/, ship in the image with src, and there’s no need to touch the
Dockerfile. Tests point at the same directory.
All of these are documented in .env.example and in docker-compose.yml.
11. API
Prefix /api/v1. Auth via session cookie, like everything else. Role and flag guards are
implemented as dependencies: require_dynamic_courses(mode_min="shadow").
11.1 Course schema (admin)
| Method | Route | Request | Response |
|---|---|---|---|
POST |
/courses/{course_id}/schema/propose |
{"source_document_id": uuid | null, "intent_density": 1..5} |
202 {"job_id": str} — idempotent while the job is in flight: if the course already has a job in a non-terminal state (pending/schema_proposing, no cancelled_at), that same job_id is returned instead of launching another designer. Two clicks don’t buy two runs or leave two runners writing the same set of nodes. The read is done by CourseSchemaService.propose; the actual race is closed by the partial unique index uq_generation_jobs_schema_in_flight from 0005. intent_density is not rewritten on reuse (the in-flight job already read it) |
GET |
/courses/{course_id}/schema |
— | 200 CourseSchemaRead |
PUT |
/courses/{course_id}/schema |
CourseSchemaUpdate |
200 CourseSchemaRead |
POST |
/courses/{course_id}/schema/validate |
— | 200 CourseSchemaRead · 422 SchemaValidationError |
POST |
/courses/{course_id}/schema/unvalidate |
— | 200 CourseSchemaRead |
// CourseSchemaRead
{
"course_id": "…", "schema_status": "proposed", "schema_version": 3,
"delivery_mode": "static", "intent_density": 3,
"validated_by": null, "validated_at": null,
"warnings": ["Removed a cyclic prerequisite between 'Exceptions' and 'Deadlines'"],
"nodes": [{
"id": "…", "title": "Return deadline", "summary": "…", "outcome": "…",
"criticality": "critical", "position": 1,
"mastery_threshold": 0.90, "estimated_minutes": 6,
"skill_id": "…", "seed_lesson_id": null,
"source_document_id": "…", "source_headings": ["Returns", "Deadline"],
"prerequisite_node_ids": []
}]
}
// CourseSchemaUpdate — full replacement (not a partial PATCH: order and graph
// must be validated as a whole). Nodes without "id" are created; nodes that are
// absent are ARCHIVED if they have progress, deleted if they don't.
{ "intent_density": 4, "nodes": [ { /* same fields, "id" optional */ } ] }
// 422 SchemaValidationError
{ "detail": { "code": "schema_invalid",
"errors": [{"code": "cycle", "node_ids": ["…","…"]},
{"code": "missing_summary", "node_ids": ["…"]},
{"code": "no_critical_node"}] } }
// 422 when editing an already-validated schema
{ "detail": { "code": "schema_locked",
"message": "This schema is validated. Use /schema/unvalidate before editing it." } }
The gate can’t be skipped by editing after validation. Previously, PUT …/schema was a full
replacement that bumped schema_version without touching schema_status or delivery_mode:
on a live, validated course, a creator could add new, never-reviewed nodes and employees would
immediately get content generated for them — exactly what §1.1 promises can’t happen. Three rules,
all blocking:
PUT …/schemaonschema_status='validated'returns422 schema_locked. You have to callPOST …/unvalidate, which setsschema_status='proposed'anddelivery_mode='static'in the same transaction and writes toaudit_log(course_schema_unvalidated). In other words: editing a live course pulls it out of v2 until it’s validated again. Explicit and visible, not implicit.reviewed_atper node (§3.2).POST …/validateonly reviews the graph; a node withoutreviewed_atis never served (409 node_not_reviewed). B10’s panel marks each node as reviewed when it’s opened and edited, andPUTclearsreviewed_aton any node whosetitle,summary,criticality, orsource_headingschanged.- A node with
attempts_count > 0can’t be deleted:422 node_has_progress, or it’s archived (archived = true). Deleting it would cascade tolearner_node_statesandnode_renders, destroying mastery and the audit trail of people who already worked on it, and also changing the set ofcriticalnodes that governs enrollment closure.
POST …/validate validation rules, all blocking: acyclic DAG · at least one critical node ·
every node with a non-empty summary · every node with a source_document_id or
seed_lesson_id (inherited rule: no source, no course) · no orphan prerequisites ·
position contiguous starting from 1 · every node with reviewed_at. On validation:
schema_status='validated', delivery_mode='dynamic', schema_validated_by/at,
pre-generation of probes for all nodes (§7.1), recalculation of closure for active enrollments
(§7.5), and a row in audit_log with action='course_schema_validated' and the
proposed→validated diff in detail.
The PUT runs SET CONSTRAINTS uq_course_nodes_position DEFERRED at the start of its transaction
(§3.2), without which any reordering would violate the UNIQUE (course_id, position) constraint
mid-statement.
11.2 Onboarding and profile (employee)
| Method | Route | Request | Response |
|---|---|---|---|
GET |
/onboarding |
— | 200 OnboardingRead |
POST |
/onboarding |
OnboardingSubmit |
200 LearnerProfileRead |
POST |
/onboarding/skip |
— | 200 LearnerProfileRead |
GET |
/users/me/learner-profile |
— | 200 LearnerProfileRead · 404 if it doesn’t exist |
PATCH |
/users/me/learner-profile |
{"preset"?, "role_title"?, "sector"?, "goal"?} |
200 LearnerProfileRead |
DELETE |
/users/me/learner-profile |
— | 204 — deletes the user’s seven personal tables in this order: node_render_views, node_feedback, node_attempts, node_probes, learner_node_states, learning_events, learner_profiles; and sets node_renders.generated_by = NULL. node_attempts before node_probes because node_attempts.probe_id is ON DELETE SET NULL (§3.3). It’s the GDPR art. 17 erasure path that §3.3 promised and had no endpoint for |
// OnboardingRead — the server sends the questions so the copy lives in one place
{ "version": 1, "completed": false,
"notice": "Your job title and sector are sent to the AI provider to tailor examples. You can delete them anytime from Settings.",
"questions": [
{"id": "role_title", "kind": "text_suggest", "prompt": "What is your job title?",
"suggestions": ["Sales assistant", "Cashier", "Shift supervisor", "…"]},
{"id": "goal", "kind": "single_choice", "prompt": "What do you want to use SkillNet for right now?",
"options": [{"value":"onboarding","label":"I just joined and want to get up to speed"},
{"value":"specific_gap","label":"There's something specific I need to master"},
{"value":"assigned","label":"I've been assigned training"}], "allow_other": true},
{"id": "experience_level", "kind": "single_choice",
"prompt": "How much experience do you have in your current role?",
"options": [{"value":"none","label":"None"},{"value":"some","label":"Some"},
{"value":"experienced","label":"A lot"}]},
{"id": "preset", "kind": "single_choice", "prompt": "How do you prefer to study?",
"options": [{"value":"standard","label":"Standard","hint":"10-15 min blocks"},
{"value":"focus","label":"Focus","hint":"Step by step, no distractions"},
{"value":"fast","label":"Fast pace","hint":"3-5 min micro-blocks"}]},
{"id": "accessibility", "kind": "multi_choice", "optional": true,
"prompt": "Do you want to turn on any reading settings?",
"options": [{"value":"short_blocks","label":"Shorter blocks"},
{"value":"reduce_motion","label":"Less animation"},
{"value":"high_contrast","label":"More contrast"},
{"value":"extra_time","label":"No time limit"}]}
]}
// OnboardingSubmit
{ "role_title": "Sales assistant", "sector": "retail", "goal": "onboarding",
"experience_level": "some", "preset": "focus",
"accessibility": {"short_blocks": true, "reduce_motion": false,
"high_contrast": false, "extra_time": false} }
// LearnerProfileRead — format_vector and tutor_notes are NOT exposed to the client
{ "role_title": "Sales assistant", "sector": "retail", "goal": "onboarding",
"experience_level": "some", "preset": "focus", "nodes_completed": 0,
"onboarding_completed_at": "2026-07-25T09:12:00Z", "onboarding_skipped": false,
"calibrating": true }
POST /onboarding writes learner_profiles and users.learning_profile and
users.accessibility in a single transaction.
11.3 Runtime (employee)
| Method | Route | Request | Response |
|---|---|---|---|
GET |
/courses/{course_id}/nodes |
— | 200 NodeListRead |
POST |
/nodes/{node_id}/probe |
— | 200 ProbeRead |
POST |
/nodes/{node_id}/probe/answer |
{"probe_id", "item_id", "answer"} |
200 ProbeAnswerResult |
POST |
/nodes/{node_id}/render |
{"force": false, "preview": false} |
202 {"request_id", "cached": bool} · 409 node_not_reviewed |
GET |
/nodes/{node_id}/render |
— | 200 NodeRenderRead (the pinned render, see below) · 202 {"status":"generating","request_id"} · 409 node_not_reviewed |
GET |
/nodes/{node_id}/renders |
— | 200 {"renders": [{render_id, created_at, ui_format}]} — history for “view the previous version” (§5.5) |
POST |
/nodes/{node_id}/waive |
{"reason"?} |
200 NodeStateRead — admin/manager only; §7.4 |
GET |
/nodes/{node_id}/render/stream?request_id=… |
— | 200 text/event-stream |
POST |
/nodes/{node_id}/answer |
{"render_id", "item_id", "answer", "hints_used", "latency_ms"} |
200 NodeAttemptResult — the body’s hints_used is informational and the server must NOT trust it (B5): it’s the value that decides whether NodeAttemptResult.correct_answer is revealed, and a field the client fills in can’t govern that reveal (hints_used: 3 would be a free answer key). The valid count is derived server-side from node_attempts.hints_used for (user_id, node_id, item_id), which only POST /nodes/{id}/hint increments. QuizItemBlock (B6) grants no hints and always sends 0 |
POST |
/nodes/{node_id}/hint |
{"render_id", "item_id"} |
200 {"hint", "hints_used"} · 409 if there’s no prior attempt |
POST |
/nodes/{node_id}/feedback |
{"difficulty", "unclear"?} |
204 |
POST |
/nodes/{node_id}/events |
{"events": [{"type","element","node_id"?,"ms"?}]} |
204 |
POST |
/explain |
{"term", "context", "node_id"?, "language"?} |
200 text/event-stream |
GET |
/render-kit |
— | 200 UIKitRead |
// NodeListRead
{ "course_id": "…", "delivery_mode": "dynamic", "schema_version": 3,
"nodes": [{ "id": "…", "title": "Return deadline", "summary": "…",
"criticality": "critical", "position": 1,
"state": "not_started", "mastery": 0.0,
"locked": false, "locked_by": [],
"needs_practice": false, // state == 'needs_review' (§7.4)
"estimated_minutes": 6 }],
"can_complete": false, "blocked_by": ["…"], "progress_percent": 0 }
// ProbeRead — no correct answers
{ "probe_id": "…", "node_id": "…",
"items": [{ "item_id": "a", "item_type": "test", "bloom_level": "apply",
"question": "…", "options": ["…","…","…","…"] },
{ "item_id": "b", "item_type": "true_false", "bloom_level": "understand",
"question": "…" }] }
// ProbeAnswerResult
{ "item_id": "a", "score": 1.0, "passed": true,
"verdict": null, // null until all items are answered
"estimate": 0.6, "next_item_id": "b",
"render_hint": "prefetch" } // "prefetch" | "skip" | null → the client
// triggers POST /render in the background
// NodeRenderRead — answer_key NEVER appears here
{ "render_id": "…", "node_id": "…", "ui_format": "explanation",
"status": "ready", "backend": "openui", "cached": true,
"spec": { "version": "skillnet-ui/1", "root": "b0", "format": "explanation",
"components": [ /* … */ ] } }
// NodeAttemptResult
{ "score": 0.0, "passed": false, "feedback": "…",
"correct_answer": null, // only when hints_used >= 3 or passed
"mastery": 0.34, "state": "learning",
"consecutive_correct": 0, "consecutive_failed": 1,
"next": "retry" } // "retry" | "next_item" | "next_node"
GET /courses/{course_id} does not change shape. When the course is dynamic it adds
"delivery_mode": "dynamic" and returns modules: []; the frontend uses that to decide which view
to go to. No existing field changes type — v1’s CourseView keeps compiling and working.
GET /nodes/{node_id}/render recalculates nothing. It returns the ui_spec of
learner_node_states.active_render_id while render_pinned is true (§5.5), and writes a row to
node_render_views the first time that user sees that render (§2.1). Only
POST …/render {"force": true} recalculates the cache_key and repins. Without this separation, a
simple refetch would change the screen mid-node.
POST /nodes/{node_id}/render with "preview": true can only be called by an admin, generates
with the admin’s profile, doesn’t write learner_node_states, and persists with
is_preview = true, which excludes it from the cache (§3.4). Without that flag, a preview
generated before validation could literally be served to an employee in the same bucket. This
is what makes shadow mode possible without leaking unapproved content.
12. Test strategy and fixtures
Central constraint: there are no API keys. Everything must be verifiable without network
access. The solution is not to mock every test, but an alternative LLMService implementation
selected by configuration.
12.1 FixtureLLMService
src/llm/fixtures.py
FIXTURE_PREFIX = "fixture/"
class FixtureLLMService(LLMService):
"""Serves recorded responses. Activates when the resolved model starts with
'fixture/'. No network calls."""
def _key(self, system_prompt: str, user_prompt: str) -> str:
return sha256(f"{system_prompt}\x00{user_prompt}".encode()).hexdigest()[:16]
class FixtureEmbeddingService(EmbeddingService):
"""Deterministic vectors hashed from the text, dimension = config.dimensions.
No network calls. Not semantic: they let the pipeline run and support shape
assertions, not relevance measurement."""
It branches at ALL construction points, with a single helper. The earlier version said “a single branch, in the factory” and patched two sites out of five, under the wrong name. The actual points are:
| File | Line | What it builds |
|---|---|---|
src/deps/llm.py |
29 | get_llm_service |
src/deps/llm.py |
33 | get_tutor_llm_service |
src/deps/llm.py |
37 | get_embedding_service |
src/deps/llm.py |
43 | get_optional_llm_service (purpose eval — the one grade_open_answer uses) |
src/agents/content/nodes.py |
82 | _make_llm (not _build_llm, as this document used to say) |
src/agents/content/nodes.py |
88 | _make_embedder |
src/services/settings_service.py |
72 | settings connection test |
src/services/ingestion.py |
53 | ingestion embedder |
# src/llm/fixtures.py
def maybe_fixture_llm(config) -> LLMService:
return FixtureLLMService(config) if config.model.startswith(FIXTURE_PREFIX) else LLMService(config)
def maybe_fixture_embedder(config) -> EmbeddingService:
return FixtureEmbeddingService(config) if config.model.startswith(FIXTURE_PREFIX) else EmbeddingService(config)
The eight sites call one of the two. Without get_optional_llm_service patched, correcting a
practical_case/dialogue from a probe or a tiebreak would attempt a real network call (§3.4).
Without FixtureEmbeddingService, load_context in its chunked branch has no query embedding and,
moreover, there would be no chunk to search at all: DocumentChunk.embedding is Vector(...)
nullable=False (src/models/document_chunk.py:35-37), so without an embedder not a single row gets
created (src/services/ingestion.py:76-81 swallows the failure and saves only full_text).
Honest scope of the flow without keys: with FixtureEmbeddingService the fixture tests cover
both branches of load_context. The chunked branch is exercised with deterministic vectors, so
it tests the wiring (that the query arrives, that heading filtering applies, that the context gets
trimmed), not semantic relevance. Retrieval quality can only be judged with
@pytest.mark.integration and real keys, and it is labeled as such.
Fixture layout (inside the package, see §10.2 — if they lived under tests/ the Docker
fixtures profile would not find them):
src/llm/fixture_data/
├── index.json # {sha16: file, prompt_preview, use_case}
├── schema_design/returns_policy.json
├── decide_formato/{explanation,exercise,chart}.json
├── genera_ui/openui_explanation.txt # raw dialect, exactly as the model would emit it
├── genera_ui/openui_exercise.txt
├── genera_ui/openui_table_nested.txt # Table.rows as string[][] (rule 2 of §5.4)
├── genera_ui/malformed_unclosed_array.txt # retry path
├── genera_ui/malformed_unescaped_quote.txt # rule 1 of §5.4
├── genera_ui/malformed_literal_newline.txt # rule 3 of §5.4 — breaks parse_partial
├── genera_ui/invalid_unknown_component.txt # fallback path
├── genera_ui/repaired_after_retry.txt # response to UI_REPAIR_SYSTEM
├── probe_generate/plazo_devolucion.json
└── explain/{mercurio_quimica,mercurio_planeta}.json # tests context_hash
The three malformed_* fixtures correspond one to one to the three rules of the frozen grammar
in §5.4: they are the failures an 8B model makes on day one, not invented malformations.
Recording mode, for whenever someone has a key: LLM_FIXTURE_MODE=record makes the real
LLMService write each (prompt, response) pair to LLM_FIXTURE_DIR and update index.json. That
way fixtures are real, not hand-invented. If a fixture is missing in replay mode, the test fails
with the sha and a preview of the prompt, not an opaque KeyError.
docker-compose.yml has a fixtures profile with LLM_MODEL=fixture/local and
EMBEDDING_MODEL=fixture/local, so the whole flow can be demoed locally with no keys at all (the
v2 path is enabled per course, not by env var — see §10). It works in the production compose file
because fixtures travel inside src/ (§10.2); docker-compose.dev.yml, which bind-mounts
./apps/skillnet-api:/app, works the same way.
12.2 What is tested and how
| Level | File | What it checks | Needs |
|---|---|---|---|
| Unit | tests/test_render_openui.py |
parse() of 8 valid dialects → golden JSON; 6 malformed → RenderParseError, including the 3 grammar rules (§5.4); parse_partial over truncations at every position via a @pytest.mark.parametrize over range(len(raw)) — not with hypothesis, which would be a new dev dependency, and the dependency limit in AGENTS.md requires justifying it for anything a loop already covers |
nothing |
| Unit | tests/test_render_roundtrip.py |
parse(serialize(spec)) == spec for the openui backend over 11 golden specs (the 10 here plus inline_nested, which pins the synthetic ids of inline nesting), including specs with QuizItem, nested Stack, and Table with nested rows |
nothing |
| Unit | tests/test_render_kit.py |
The frozen catalog (10 names, positional prop-by-prop order, the 6 item_type values of the existing enum) and the 7 rules: UISpec rejects >12 components, cycles, dangling refs, QuizItem with correct, and explanation/mixed without an initial lead block |
nothing |
| Unit | tests/test_render_prompt_artifact.py |
The drift alarm between src/render/kit.py and the artifact library.prompt() generates: normalized catalog digest, prompt_sha256, that the prompt announces the 9 signatures and no more, that it does not teach reactive syntax, and that the @openuidev versions are the audited ones |
nothing |
| Unit | tests/test_render_gate.py |
15 reactive payloads (loose Mutation, self-triggering Query, refreshInterval, @OpenUrl with javascript:, @ToAssistant, $state, ternary, builtins…) rejected, and 6 legitimate contents accepted — including prose that mentions Query() and $300, which is the measured false positive of a keyword grep; size caps; canonicalize() returns the re-serialization, not the input |
nothing |
| Unit | tests/test_mastery.py |
Truth table of probe_verdict (25 cases), including “B perfect and A at zero” → no mastery; critical with 2/2 selected gives tiebreak, not mastered; tiebreak_mastery reaches each of the 3 thresholds (the §7.2 table case by case); the mastery ceiling (a sustained 0.85 does reach mastered on a critical node); EWMA; the 8 node_state transitions of §7.3 |
nothing |
| Unit | tests/test_probe_reuse.py |
The second POST /probe for the same (user, node, schema_version) returns the stored verdict and does not generate items; re-probe rejected if state != 'needs_review' or fewer than 7 days have passed; diagnostic probe (scored=false) does not consume the attempt |
nothing |
| Unit | tests/test_node_grading.py |
content_for() for the 4 deterministic types: recombines answer_key + props and grade() scores the same as v1 given the same input |
nothing |
| Unit | tests/test_schema_validation.py |
Cycle detection (self-edge, 2-cycle, 5-cycle, large valid DAG), orphans, no_critical_node, cycle pruning in persist_schema |
nothing |
| Unit | tests/test_runtime_router.py |
select_tier for the 5 formats; purpose_for; resolve_llm_config precedence with runtime_fast/runtime_heavy and fallback to LLM_MODEL |
nothing |
| Unit | tests/test_profile_service.py |
Vector with decay (event fixture with fixed created_at), L1 normalization, vector_bucket, calibration with nodes_completed < 3, pruning tutor_notes to 20 |
nothing |
| Unit | tests/test_cache_key.py |
The key changes with schema_version, preset, role_bucket, scaffold_band, effective_density, PROMPT_VERSION; it does not change with user_id or with mastery within the same scaffold_band; two profiles with different role_title do not share a key |
nothing |
| Unit | tests/test_schema_gate.py |
PUT on validated → 422 schema_locked; unvalidate sets proposed + static; deleting a node with attempts_count > 0 → 422; editing summary clears reviewed_at; a node without reviewed_at → 409 when requesting a render |
nothing |
| Unit | tests/test_delivery_resolution.py |
The 12 cases of resolve_delivery |
nothing |
| Graph | tests/test_runtime_graph.py |
build_node_graph() compiles; full run with FixtureLLMService and fake repos: happy path, mastered→skip, malformed→retry, invalid→fallback_seed |
fixtures |
| Graph | tests/test_schema_graph.py |
Full propose run with fixtures → N nodes with expected criticality and prereqs |
fixtures |
| Integration | tests/integration/test_dynamic_flow.py (@pytest.mark.integration) |
e2e over real Postgres: propose → validate → onboarding → probe → render → answer → mastery → completed | docker db + fixtures |
| Integration | tests/integration/test_v1_regression.py (@pytest.mark.integration) |
The full v1 flow stays identical for a static course, and a course with the new columns set to dynamic but without a validated schema still gets served by v1 (resolve_delivery) |
docker db |
| Migration | tests/integration/test_migration_0005.py |
upgrade from 0004 and downgrade back, with v1 data present. Exact asserts: the 13 tables and the 6 courses columns disappear; the 8 new enums disappear; generation_step retains schema_proposing and schema_proposed (orphans by design, §3); no v1 row altered; reordering positions 1↔2 in a PUT does not violate the deferred UNIQUE constraint |
docker db |
On aiosqlite: DB tests cannot run on SQLite (jsonb, text[], native enums,
pgvector). Decision: service tests use fake in-memory repositories (protocols, since services
receive repos by injection) and anything that touches real SQL is marked @pytest.mark.integration
and runs against the docker-compose Postgres. pytest -m "not integration" stays green with no
Docker and no network — that is what runs in CI by default.
12.3 Frontend
| File | What |
|---|---|
src/lib/tokenize.test.ts |
Spanish tokenization with accents, apostrophes, hyphens, punctuation, emoji; ES and EN stopwords; verbatim re-render (tokens.join('') === input) |
src/components/courses/UiSpecRenderer.test.tsx |
Renders the 10 golden specs (shared with the backend via src/test/fixtures/ui-specs/*.json); unknown type → null with no crash; dangling refs → no crash |
src/components/courses/ClickableSurface.test.tsx |
Click on a word → correct term; partial selection → whole word; justDragged; click on CodeBlock → nothing; click on a QuizItemBlock option button → no request to /explain; click on the quiz statement → nothing |
src/api/nodes.test.ts |
SSE parsing with fetch stubbed (same pattern as client.test.ts), including incremental ui_block and error with fallback |
| Stories | UiSpecRenderer, each block, NodeSkeleton, explanation popover, onboarding wizard — with the addon’s a11y set to error (not todo) for the new ones |
The golden specs are the same JSON file on backend and frontend (copied by a script in
pretest, not hand-duplicated). If the contract breaks, both sides break at once.
13. Batch work plan
Rule: each batch is a commit (or a few), compiles, passes pytest -m "not integration" and pnpm lint, and leaves the flag at off until batch B12. No intermediate batch may break v1.
The six and only v1 surfaces touched, declared (everything else is a new file). None changes behavior with the flag off, and all six are covered by existing or new v1 tests:
| v1 file | Batch | Change | Why it’s safe |
|---|---|---|---|
src/agents/content/{nodes,helpers}.py |
B2 | Move 3 pure functions to helpers.py and import them |
No behavior change; already covered by tests/test_generation_pipeline.py |
src/routes/generation_jobs.py |
B2 | _TERMINAL_EVENTS += schema_ready (1 line) |
An event type v1 never emits |
src/core/sse.py |
B5 | +subscriber_count, +wait_for_subscriber |
Additive only; publish/subscribe untouched |
src/repositories/document_chunk_repo.py |
B1 | +similarity_search_by_headings |
New method; the existing one untouched |
apps/skillnet-web/src/pages/employee/CourseView.tsx |
B9 | if (delivery_mode === 'dynamic') |
With the flag off, the API never returns dynamic |
apps/skillnet-web/src/pages/admin/CreateCourse.tsx |
B10 | Extract StepIndicator + optional step |
Pure refactor + branch behind the flag |
src/components/ui/Skeleton.tsx is no longer on the list: B6 creates a separate
ShimmerSkeleton.tsx (§9.2).
B0 ──┬── B1 ──┬── B5 ──┬── B9 ── B12
├── B2 ──┴─ B10 ──┤
├── B3 ──── B8 ───┤
├── B4 ───────────┤
├── B6 ───────────┤
└── B7 ───────────┘
B0 — Base: migration, models, config (blocking for everything)
alembic/versions/0005_dynamic_courses.py(new,down_revision="0004",downgradewith the exact scope of §3: noop.execute("COMMIT"), leaving the 2 orphan enum values)src/models/{course_node.py, course_node_prerequisite.py, learner_profile.py, learner_node_state.py, learning_event.py, node_render.py, node_render_view.py, node_probe.py, node_attempt.py, node_feedback.py, term_explanation.py, llm_usage_log.py, audit_log.py}(new — 13)src/models/course.py(+6 columns, 2 enums),src/models/generation_job.py(+2 members),src/models/__init__.pysrc/config.py(RENDER_BACKEND,LLM_RUNTIME_FAST_MODEL,LLM_RUNTIME_HEAVY_MODEL,LLM_FIXTURE_DIR,LLM_FIXTURE_MODE)src/llm/fixtures.py(new:FixtureLLMService,FixtureEmbeddingService,maybe_fixture_llm,maybe_fixture_embedder),src/llm/fixture_data/(fixture directory, inside the package)- The 8 construction points from §12.1 go through the helpers:
src/deps/llm.py(4),src/agents/content/nodes.py(2),src/services/settings_service.py(1),src/services/ingestion.py(1) src/services/course_delivery.py(new,resolve_delivery)src/deps/features.py(new,require_dynamic_courses)src/routes/health.py(+features),src/scripts/purge_learning_data.py(new)tests/test_delivery_resolution.py,tests/integration/test_migration_0005.py.env.example,docker-compose.yml(fixturesprofile with fixtureLLM_MODELandEMBEDDING_MODEL)
B1 — Render adapter and UI Kit (Python) (parallel with B2, B3, B4, B6, B7)
src/render/{__init__.py, spec.py, kit.py, errors.py}(new)src/render/backends/{__init__.py, base.py, openui.py}(new — withouta2tl.py, §1.3)src/repositories/document_chunk_repo.py(+similarity_search_by_headings, new method; the existing one untouched)tests/test_render_openui.py,test_render_roundtrip.py,test_render_kit.pytests/fixtures/dsl/*.openui,tests/fixtures/ui-specs/*.json- Does NOT touch the
org_idscoping ofsrc/deps/llm.pyorsettings_service.py: it goes out as its ownchorePR with route tests for chat and exercises (§4.3, §14.2 #11)
B2 — Design-time: schema graph + endpoints (parallel with B1, B3, B4, B6, B7)
src/agents/schema/{__init__.py, state.py, nodes.py, graph.py, runner.py, errors.py}(new — new nodes, not imported from v1, §4)src/agents/content/helpers.py(new:estimate_pages,assemble_chunk_text,themes_listmoved fromnodes.py) +src/agents/content/nodes.py(imports from there; no behavior change)src/llm/prompts/schema.py(new:SCHEMA_DESIGNER_SYSTEM,build_schema_promptwithavailable_headingsas a closed list)src/repositories/course_node_repo.py(new)src/services/course_schema_service.py(new: propose, update, validate, unvalidate, cycles, versioning,schema_lockedgate,reviewed_at, archiving, probe pre-generation, enrollment recalculation)src/repositories/audit_log_repo.py(new)src/schemas/course_schema.py(new)src/routes/course_schema.py(new),src/main.py(registration),src/routes/generation_jobs.py(_TERMINAL_EVENTS+=schema_ready, 1 line — the channel is stillgeneration:{job_id})tests/test_schema_validation.py,tests/test_schema_gate.py,tests/test_schema_graph.py,src/llm/fixture_data/schema_design/*.json
B3 — Learner profile and onboarding (API) (parallel with B1, B2, B4, B6, B7)
src/repositories/{learner_profile_repo.py, learning_event_repo.py}(new)src/services/learner_profile_service.py(new:EVENT_WEIGHTSfor 7 types, 4-dimension vector with decay,vector_bucket, calibration,apply_signals()with the 5 trigger rules of §3.3)src/schemas/{onboarding.py, learner_profile.py}(new;OnboardingRead.noticeincluded)src/routes/{onboarding.py, learner_profile.py}(new, includingDELETE /users/me/learner-profile),src/main.pytests/test_profile_service.py(one test pertutor_notesrule),tests/test_cache_key.py
B4 — Pre-assessment and mastery (parallel with B1, B2, B3, B6, B7)
src/services/mastery_service.py(new:probe_verdict,tiebreak_mastery/tiebreak_verdict, EWMA with ceiling, the 8 transitions,THRESHOLDS, prior fromuser_skills)src/services/probe_service.py(new: readingcourse_nodes.probe_items, sampling from the seed, LLM generation as last resort, grading, single attempt per version, diagnostic probe)src/services/node_grading.py(new:content_for()—answer_key+ props → v1 dict adapter; importsgrade()fromexercise_service, does not move it)src/repositories/{node_probe_repo.py, node_attempt_repo.py, learner_node_state_repo.py}(new)src/llm/prompts/probe.py(new)tests/test_mastery.py,tests/test_probe_reuse.py,tests/test_node_grading.py,src/llm/fixture_data/probe_generate/*.json
B5 — Runtime: per-node graph + endpoints (depends on B1 and B4)
src/agents/runtime/{__init__.py, state.py, nodes.py, graph.py, router.py, runner.py, errors.py}(new;errors.pycontainsruntime_node_error_wrapper, independent from v1’s — §4.2)src/llm/prompts/runtime.py(new:FORMAT_DECIDER_SYSTEM,UI_GENERATOR_SYSTEM,UI_REPAIR_SYSTEM,PROMPT_VERSION,build_*)src/services/node_render_service.py(new:cache_keywithrole_bucket/scaffold_band, hit/miss bycache_keywithoutuser_id, pinningactive_render_id, writingnode_render_views, cancellation, fallback)src/repositories/{node_render_repo.py, node_render_view_repo.py, llm_usage_repo.py}(new)src/core/sse.py(+subscriber_count, +wait_for_subscriber— §9.2; additive only)src/schemas/node.py(new),src/routes/nodes.py(new, including/waiveand/renders),src/main.pysrc/services/exercise_service.py: untouched.grade()is already a pure module-level function (line 73) and is imported as-is; the adapter lives innode_grading.py(B4)tests/test_runtime_router.py,tests/test_runtime_graph.py,src/llm/fixture_data/{decide_formato,genera_ui}/*
B6 — Frontend: spec renderer and blocks (parallel; only needs B1’s contract JSON)
src/types/ui-spec.ts(new),src/types/index.ts(LearningNode,NodeRender,LearnerProfile,ProbeItem, without touching v1 types)src/components/courses/UiSpecRenderer.tsx(new)src/components/courses/blocks/{StackBlock,TextContentBlock,CardBlock,CalloutBlock,StepSequenceBlock,TableBlock,CodeBlockBlock,ChartBlock,QuizItemBlock,MarkdownBlock}.tsx(new).QuizItemBlockis self-contained (§5.3): its own state and its own submission toPOST /nodes/{id}/answer, andsrc/components/exercises/is not touchedsrc/lib/motion.ts(+shimmerpreset),src/components/ui/ShimmerSkeleton.tsx(new; v1’sSkeleton.tsxuntouched — §9.2)- Stories +
UiSpecRenderer.test.tsx,src/test/fixtures/ui-specs/(copy from B1)
B7 — Curio: click-to-explain (parallel; needs term_explanations from B0)
- Backend:
src/llm/prompts/explain.py,src/services/explain_service.py,src/schemas/explain.py,src/routes/explain.py,src/repositories/term_explanation_repo.py(new) +src/main.py - Frontend:
src/lib/tokenize.ts,src/components/courses/{ClickableText,ClickableSurface,ExplainPopover}.tsx,src/api/explain.ts(new).ClickableSurfaceimplements the hit-test from §8.5 (closest(...)) as the first line of the handler;ExplainPopovercarries the “I don’t understand” action → seeded v1 chat src/index.css(.entity,.entity-open,.phrase-rect,:focus-visible,prefers-reduced-motion)tests:tokenize.test.ts,ClickableSurface.test.tsx(including the “click on option → no explain” case),src/llm/fixture_data/explain/*.json
B8 — Frontend: onboarding wizard (depends on B3)
src/components/ui/StepIndicator.tsx(extracted fromCreateCourse.tsx;CreateCourse.tsxnow imports it — refactor with no functional change)src/pages/onboarding/Onboarding.tsx,src/components/onboarding/{RoleStep,GoalStep,ExperienceStep,PresetStep,AccessibilityStep}.tsx(new)src/api/onboarding.ts(new),src/api/users.ts(useUpdateProfileacceptsaccessibility)src/types/index.ts: fixlearning_profile?: Record<string, unknown> | null→learning_profile?: 'standard' | 'focus' | 'fast'. The backend column is thelearning_profileenum (src/models/user.py), so the wizard has to send a plain string and the current type blocks thatsrc/App.tsx(/onboardingroute, outsideAppLayout),src/components/layout/ProtectedRoute.tsx(gate with the 4 rules from §6.1: flag from/health, query conditioned onrole === 'employee', 404 ⇒ do not redirect, skeleton while loading)
B9 — Frontend: node view (depends on B5 and B6)
src/pages/employee/NodeView.tsx,src/components/courses/{NodeList,NodeSkeleton,ProbeRunner,NodeFeedback,RenderControls}.tsx(new).RenderControlsis the node footer with “Update this lesson” and “View previous version” (§5.5) — it is not optionalsrc/api/nodes.ts(new:useCourseNodes,useProbe,useSubmitProbeAnswer,useNodeRender,useNodeRenderStream,useSubmitNodeAnswer,useNodeEvents,useNodeRenderHistory).useNodeRenderruns withrefetchOnWindowFocus: false— belt-and-suspenders, since the backend already serves the pinned rendersrc/pages/employee/CourseView.tsx(only modification: ifdelivery_mode === 'dynamic'rendersNodeList; otherwise the v1 tree stays intact)src/App.tsx(/empleado/curso/:id/nodo/:nodeId)
B10 — Admin frontend: schema (depends on B2)
src/pages/admin/CourseSchema.tsx,src/components/schema/{NodeEditor,PrerequisitePicker,CriticalityBadge,IntentDensitySlider,SchemaValidationPanel,ReviewChecklist}.tsx(new).NodeEditorexposesdefault_ui_format;ReviewChecklistmarksreviewed_atper node and blocks the validate button until all are reviewedsrc/api/schema.ts(new, includingunvalidateand theschema_lockedwarning),src/pages/admin/CreateCourse.tsx(step 1 → “define schema” when the flag allows it; the v1 path stays available),src/App.tsx(/admin/curso/:id/esquema)
B11 — Integration and regression (depends on B5, B8, B9, B10)
tests/integration/test_dynamic_flow.py,tests/integration/test_v1_regression.pysrc/services/enrollment_service.py(completion via unarchivedcriticalnodes, only on the dynamic branch; recalculation when the schema changes, §7.5)src/services/skill_service.py(mastery → skill_leveltranslation, and readinguser_skillsfor the probe prior, §7.1)
B12 — Docs, flag, and PR
docs/design/v2-dynamic-courses.md(this file, updated with lessons learned)AGENTS.md(§“Current phase” → v2 with the flag; and fix the package list: it mentionspackages/mcp-ui-renderer, which does not exist —packages/containsa2tl-video,a2tl-web,mcp-md-reader),CLAUDE.md(fixturesprofile commands)chorefor obsolete docs (§14.2 #8), in this same batch:screens.md(routes in Spanish; §Employee Settings line 213, remove “TEA, TDAH, dislexia flags” and describeusers.accessibilityas neutral settings),design-system.md(§Skeleton:animate-pulse→ShimmerSkeleton)docs/design/data-model.md(v2 appendix pointing here; the v1 body is not rewritten)README.md(flag section), final.env.example- Flag set to
shadowin the developmentdocker-compose.yml,offin production - PR to
mainwithresolve_delivery’s truth table and the v1 regression test evidence
Critical path: B0 → B1 → B5 → B9 → B11 → B12. Everything else hangs in parallel. With two people, the split is: one does B0/B1/B5 (adapter + runtime), the other B2/B3/B4 (schema + profile + mastery), and then B6-B10 can overlap.
14. Risks and open decisions
14.1 Risks with a decided mitigation
| Risk | Probability | Mitigation in this design |
|---|---|---|
| A small model (8B) frequently generates a malformed dialect | High | Frozen EBNF grammar + 3 escape rules in the prompt (§5.4) · tolerant parse_partial · 1 retry with UI_REPAIR_SYSTEM (the exact error in the prompt) · fallback_seed to v1 markdown. The user never sees a red screen — and that is guaranteed by fallback_seed, not a second dialect |
| The generated content is pedagogically worse than the static one and no one reviews it | High | shadow mode (previews with is_preview, kept out of the cache) · reviewed_at mandatory per node: a node no one has opened does not get served · proposed→validated diff in audit_log, to measure whether creators edit · node_feedback with a trigger: 3+ users mark hard on the same node → notice to the creator. The creator decides, the system never rewrites on its own |
| LLM cost spikes | Medium | Cache shared per bucket (not per user) · probes pre-generated per node, not per user · pre-assessment avoids generating what’s already known · llm_usage_log with use_case, table created in 0005 (§3.5) · explicit max_tokens budgets |
| The cross-user cache hit rate is much worse than measured | High | The cross-user regime is not measured (§3.4) and role_bucket deliberately makes it worse. It’s the first thing measured (§14.2 #3), and the rollback lever is one line: drop role_bucket or vector_bucket from the key and bump PROMPT_VERSION |
| The in-memory SSE loses events or breaks with >1 worker | Medium | 202 {request_id} + subscription before the work + 500 ms wait · fallback to polling GET /nodes/{id}/render (a pattern the frontend already uses for generation) · documented: a single uvicorn worker until migrating to LISTEN/NOTIFY |
| The correct answer leaks to the client | Medium | answer_key in a separate column that no response Pydantic schema includes · ProbeSession.probe typed as ProbeRow (a protocol without answer_key) and projected by ProbeSessionRead.from_session (extra="forbid", fields enumerated by hand) — the service no longer returns the whole ORM row to its caller · a test that dumps the response model and asserts that neither the key nor its values appear (tests/test_probe_answer_key_privacy.py) · NodeRenderRead already exists (src/schemas/node.py:115, arrived with B5) with extra="forbid" and the field list enumerated by hand in NodeRenderRead.of, which is the whole contract; still pending is the equivalent test that dumps that model and asserts the absence of the key, like the one for ProbeSessionRead · the client’s hints_used is informational and cannot govern the reveal (§11.3) |
| Prompt injection from client-supplied text | Medium | POST /explain interpolates two client values (term, context, and the context is not checked against the node’s real text). Neither goes between quotes: they’re sanitized (control characters, </>, quote runs, length caps of 140/600) and fenced in <<<name:token>>> markers whose token no sanitized payload can contain — closing the fence would require characters already stripped. The system prompt declares that whatever sits between markers is data, never instruction. Token derived from content (not random) so fixtures stay reproducible. Hijack tests in tests/test_explain_service.py |
| The mastery rule lets someone who doesn’t know through | Medium | score_a >= 0.5 clause · a constructed-response tiebreak mandatory on every critical node · a single scored probe per schema version (unique index), which is what prevents re-entering until getting it right by chance · a streak of 3 in addition to the threshold |
| The mastery rule locks out someone who does know | Medium | Mastery ceiling: 3 consecutive hits raise mastery to the threshold (§7.3), because the EWMA converges to the mean and left a sustained 0.85 at 0.05 short of 0.90 forever · human override POST /nodes/{id}/waive logged to audit_log · a node in needs_review stays visible and retriable, never disappeared |
| Content shifts under the user’s feet | Medium | active_render_id pins the render while the node is open · scaffold_band (stable) replaces mastery_band (changed with every answer) in the key · regeneration only via an explicit button, with “view previous version” |
| Silent regression in v1 | Medium | test_v1_regression.py with the flag at off · resolve_delivery as the single decision point · default off |
| Contract drift between backend and frontend | Medium | Shared golden specs, copied by a script in pretest. Both sides break at once |
| XSS via generated content | Low (by design) | Never HTML: typed IR + native render. SandboxHTML out of scope. props.text is inline text/markdown, rendered by react-markdown without rehype-raw |
| GDPR with sensitive data | Medium | No neurotype labels · learning_events carries no user text, purged at 90 days by src/scripts/purge_learning_data.py (there is no background_jobs table, §1.3) · term_explanations only caches ≤60 characters and purges at 180 days · goal no longer travels to the LLM · notice at the collection point (OnboardingRead.notice) · DELETE /users/me/learner-profile for Art. 17, which deletes the seven personal tables (including node_attempts and node_probes, the ones holding what the employee wrote) and proves it table by table in tests/test_gdpr_erasure.py · accessibility never goes to the LLM · admin aggregates with k≥5 · audit_log (table created in 0005) on schema validation |
| Audit evidence gets destroyed when an employee is offboarded | Medium | node_renders has no user_id, and generated_by uses ON DELETE SET NULL; the read trail lives in node_render_views, per user · a node with progress gets archived, not deleted |
14.2 Open decisions (with a decision date, not “TBD”)
-
Real fast/heavy ratio. The 90/10 estimate is a hypothesis. It’s decided with
llm_usage_logdata after 2 weeks inshadowmode. If heavy exceeds 25%, thedecide_formatoprompt needs review — it’s probably choosingchartwhenexplanationwould do. -
Real
genera_uilatency.OpenCLOSED (2026-07-27, measured). Internal sources gave incompatible figures (1-2 s vs 22.9 s vs 60-120 s), all for full HTML generation and not for the IR. Measured withscripts/quality_bench.pyagainst real Groq (groq/llama-3.1-8b-instantas the fast tier,groq/openai/gpt-oss-120bas heavy): under one second to ~3 s per render, at ~0.0008 USD per render, with token accounting already populated. The “20-30 seconds” problem the research assumed does not exist in this stack: the 60-150 s figures were from a 7B model on local CPU.Consequences, and these are the ones that matter: the productive wait is more than enough, no pre-generation is needed, and no extra layer of waiting gets added. The latency budget stops being a design constraint, so the dials get spent on correctness, not speed (
docs/design/tuning.md). The only real operational constraint is that Groq’s free tier returns 429 easily: any measurement run needs exponential backoff, and the bench already has it built in and accounts for the wait separately so a 429 can never score as a quality failure. -
Cross-user cache hit rate and staleness rate. Both get measured, because the ~80%/0% figure quoted was measured with a per-user key, and this key is shared: it’s a regime with no data (§3.4). Query: hits per
cache_keyovernode_renders+node_render_views, and “stale” = a served render whoserole_bucket/scaffold_bandno longer matches the reader’s profile. If hits fall below 50%,vector_bucketgets dropped; if there is also perceptible staleness,role_bucketgets dropped too and less personalized content is accepted. A one-line change plus aPROMPT_VERSIONbump. -
FSRS vs HLR. HLR is kept (it’s already in the data model). The decision to migrate to FSRS-6 is made in the spaced-repetition PR, not here, and requires first pinning down: number of weights (sources say 17, 19, and 21),
difficultyrange, and thepy-fsrsversion with its API (Scheduler.review_cardvsFSRS.repeat). -
Retention alert thresholds. There are three incompatible sets in the research (0.85/0.70/0.50 vs 0.50/0.70 vs 85/70/50 + critical). A single canonical table gets defined in the spaced-repetition PR.
-
Interleaving between nodes. Mixing nodes from different courses in a session improves transfer but overloads beginners. Decided once real
masterydata exists: the candidate rule is to enable it only for nodes withmastery >= 0.5(phasehaor above). -
Stateful
Simulation? Requires the IR to grow with data-binding, a structural change to the contract. Decided only ifnode_feedbackshows thatexplanation+exerciseisn’t enough for procedural nodes. -
Spanish vs English routes. The code uses Spanish, the docs English. The code is followed and
screens.mdis corrected in a separatechore. If the opposite is decided, it’s a mechanical rename ofApp.tsxand theLinks, with no effect on the API. -
Per-employee weight personalization threshold. Not applicable until FSRS exists. Once it applies, choose between >50 and >200 reviews (sources give both) and document it.
-
Multi-worker. The design assumes a single uvicorn worker because of the in-memory SSE (consistent with
docker/api.Dockerfile, which starts with--workers 1). If deployment needs more, the decision is PostgresLISTEN/NOTIFY(no Redis, consistent with “no Redis, no Celery”), and onlysrc/core/sse.pyneeds touching. -
org_idscoping of org settings.select(Organization).limit(1)insrc/deps/llm.py:22-25andSettingsService._get_org. It leaves this PR for its ownchore(§4.3) because fixing it properly requires threadingCurrentUserthrough four dependencies that untested v1 routes consume. Decided before admitting a second organization on an instance; today the single-org invariant makes it harmless. Thechoreincludes route tests forchat.pyandexercises.py. -
Second render dialect. Out of scope for this PR (§1.3). Decided if the retry with
UI_REPAIR_SYSTEMleaves a parse failure rate >5% measured overnode_renders.status. If needed, it will be a SkillNet-owned dialect capable of expressing the fullUISpec, notUIDL/1. -
Course navigation: flat list vs semantic-zoom canvas.
NodeListReadis an ordered list, which is a deliberate downgrade from the canvas exploration with a persistent status rail that the Keyhole research recommends. Reason: the canvas is its own frontend project and this PR already ships a new renderer. Re-evaluated once real courses with >10 nodes exist; noted here rather than omitted. -
Promoting the redundancy rule (§5.2) from warning to error. Decided from the warnings accumulated in
node_renders.error_messageafter 2 weeks inshadow: if it appears in <5% of renders, it’s promoted to a validation error; if it appears often due to table-summary-after-text (good redundancy), the heuristic gets retired.
14.3 What the first real run of the integration suites found (2026-07-27)
The tests/integration/ suites were written in B11 but had never been run against a live
PostgreSQL. The first time they ran, they found seven things. They’re noted here because five of
them contradict something this document or the code claimed, and because two are v1 bugs that
had been there since before v2.
Migrations (none of this was theoretical: alembic upgrade head from scratch had never worked).
0003usedsa.Enum(..., create_type=False).sa.Enumloses that flag when adapting to the postgres dialect, soCREATE TYPE skill_levelwas emitted twice and the run died. Fixed topostgresql.ENUM. This was the underlying reason no one had ever been able to bring up the database from scratch.0005andsrc/models/learner_profile.pybuilt a JSONB default withsa.text()carrying an unescaped:. SQLAlchemy read them as bind parameters and the DDL came out as{"text"NULL,...}. Fixed by escaping\:.0005used a newgeneration_stepvalue in the same transaction that added it (theuq_generation_jobs_schema_in_flightpartial index) →UnsafeNewEnumValueUsageError. The twoALTER TYPE … ADD VALUEstatements now sit insideop.get_context().autocommit_block(). The opposite claim in0005’s docstring and in the §3 note was false and is corrected in both places.- New migration
0006:user_skills.last_assessed_atwastimestamp without time zonewhile its two writers (SkillService.record_masteryandEnrollmentService._grant_course_skills) pass it aware values. asyncpg rejects the mix, so leveling up a skill that already existed blew up the request. It survived until now because it only affects the UPDATE branch.
v1 product bugs, discovered by the regression suite and not by v2.
- Four sites auto-complete an enrollment on reaching progress 1.0, and none of them granted the
course’s skills.
POST /enrollments/{id}/completefound the enrollment already completed, took its early return, anduser_skillsstayed empty: an employee would finish a course and get no credit at all. Fixed inEnrollmentService.complete(). PUT /lessons/{id}was a guaranteed 500 (MissingGreenlet): it readlesson.exercisesfrom a loader that never eagerly loaded them. No test covered that route.
Environment.
docker-compose.dev.ymlmounted the host repo over/app, souv runinside the container saw a virtualenv with binaries from another OS, decided it was broken, and deleted the host’s.venvto rebuild it. Fixed with an anonymous volume over/app/.venv.
The cross-cutting lesson, and the one that applies to the rest of the project: a suite that has never run is not coverage. The two broken v1 routes (5 and 6) had sat in the repo for months with green unit tests around them.
15. Review: objections dismissed
Two adversarial audits reviewed this document. All blockers from both were verified against the code and all of them turned out to be correct, and are fixed above. What follows are the parts of objections that were not applied, with the evidence for why.
15.1 “Force fill_blank on item A so score_a is continuous”
Dismissed: the proposed fix doesn’t work. The diagnosis was indeed correct (with two binary
items, all three thresholds behave the same, and the tiebreak capped at 0.80 < 0.90), and it’s fixed
in §7.2. But the suggested route — making score_a continuous via fill_blank — doesn’t exist:
_grade_fill_blank (src/services/exercise_service.py:34-43) returns 0.0 as soon as a single
blank fails, same as _grade_test, _grade_true_false, and _grade_order_steps. All four
deterministic types are all-or-nothing. Making it continuous would require changing v1’s grading,
which is used in production for real exercises, and that is out of scope.
What was applied instead: renormalizing the tiebreak to 0.45a + 0.15b + 0.40c (it reaches 1.0, so
every threshold is reachable), requiring item B to be a 4-option test (so the cited 6.25% is
accurate), and making the constructed tiebreak mandatory on every critical node. The
thresholds discriminate without needing partial credit (table in §7.2).
15.2 “Add a schema_only value to generation_output”
Dismissed on cost/benefit, not on correctness. The observation is valid:
generation_jobs.output_type is NOT NULL over a two-value enum, and a schema job is neither. But
adding a third value widens the same problem §3 documents — a Postgres enum can’t lose values, so
the downgrade would leave three orphans instead of two — for a field that no schema consumer
reads: clients go by status, and generation_service already falls back to
COURSE_AND_MANUAL on any unknown value. It’s documented as an explicit placeholder in §3.1 instead
of creating migration debt.
15.3 “Generate the role block with a fast call per user (~40 tokens)”
Dismissed in favor of the same reviewer’s other option. The problem — the role wasn’t in the
cache_key and per-role personalization got lost on every cache hit — is real and is fixed. Of the
two proposed routes, the first is taken (role_bucket inside the key), not the separately generated
block, for two concrete reasons: an extra LLM call per user and per node is exactly the cost the
cache exists to avoid (and it would be 100% of users, not the 20% of cache misses); and a block
generated outside the ui_spec would live outside node_renders, i.e. outside the audit trail and
outside the built-in answer_key. The “this helps you with X” line does get implemented, but
deterministic and on the client from goal (§6.2 Q2), with no tokens.
15.4 “Add org_id to the ten new tables”
Applied partially, by design. The top-level tables carry org_id, which is what
data-model.md’s convention requires: course_nodes, learner_profiles, node_renders,
term_explanations, llm_usage_log, audit_log. The child tables whose scoping derives
unambiguously from their parent and which are only queried by user_id or node_id do not
carry it: course_node_prerequisites, learner_node_states, learning_events, node_probes,
node_attempts, node_feedback, node_render_views. Adding org_id there would be one more
denormalized column to keep consistent on every write with no query that ever uses it. The
inconsistency the objection flagged — claiming multi-tenancy with a single org-scoped table — is
resolved by the six above.
15.5 Verifications that confirmed the document (no change required)
TWO OF THESE VERIFICATIONS ARE VOIDED on 2026-07-26 (product decision: full OpenUI adoption —
docs/design/openui-adoption.md). This section reads as “this is already checked, don’t audit it again”, so the two expired statements are struck through below instead of deleted, and what remains true is kept separate from what no longer is.
- The “no new npm dependency” promise no longer holds. Three come in, with exact versions and no
^:@openuidev/react-lang@0.2.9,@openuidev/lang-core@0.2.10, andzod@4.4.3(apps/skillnet-web/package.json, resolved inpnpm-lock.yaml). The versions are pinned because none of the security properties the adoption relies on is a public contract of the package;tests/test_render_prompt_artifact.py::test_the_pinned_openui_versions_are_the_audited_onesis the alarm that fires when they get bumped.- The browser parser is NO longer the Python one. The Python one is kept, but as a validator on the server (
src/render/gate.py+src/render/spec.py): it validates before persisting and re-serializes theUISpecto the canonical dialect. The rendering is done by<Renderer>from@openuidev/react-langover the components we register (§5.1, §5.3, §5.4).What still holds from the original statement, and is verified: the XSS mitigation.
react-markdown10 is still present withoutrehype-raw, there is nodangerouslySetInnerHTMLanywhere inapps/skillnet-web/src/, andframer-motionis still present. That half doesn’t depend on the adoption: the OpenUI packages don’t interpret HTML, they map the language to our own React components.
Listed so no one audits them again: the Alembic chain is linear 0001→0004, so
down_revision="0004" is correct and there are no conflicting heads · no new route from §11
collides with the ~40 existing ones · resolve_llm_config’s precedence works exactly as §4.3
describes for runtime_fast/runtime_heavy (src/llm/client.py:61-67) · GenerationJobRead.status
is str, so the two new enum members don’t break it · the integration marker is already in the
pytest configuration · react-markdown 10 is present without rehype-raw and framer-motion
is present, so both the XSS mitigation and the “no new npm dependency” promise hold the XSS
mitigation holds (the “no new npm dependency” promise does not: see the notice above) ·
docker/api.Dockerfile starts with --workers 1, consistent with the SSE assumption.
On the npm package openui: whether it exists or not is irrelevant, because §5.4 implements the
parser in Python and adds no frontend dependency. Corrected on 2026-07-26: the real
package is @openuidev/*, it exists, and it does come in as a frontend dependency; the Python
parser is kept as a server-side validator, not as the browser parser. What was a real risk —
presenting “OpenUI Lang” as an external dialect with one example and no grammar — is closed twice:
with the frozen EBNF and the three escape rules of §5.4 (plus one malformed fixture per rule), and,
since the adoption, with the dialect’s own reference implementation.