feat(assessment): add exam and capstone definition/session engine
New tables: assessment_definitions (reusable template — pool_spec, duration, hint_policy, completion/unlock rules, a plain incrementing version bumped only on semantic edits), assessment_cohort_assignments, assessment_sessions (one per student attempt, full snapshot of what was drawn and under what rules — never affected by a later definition edit), assessment_session_scenarios (the drawn set, ordered, with a real FK to scenario_attempts). Deliberately does not touch/reuse exam_sessions (migration 006) — that was already scoped in Phase 2B as historical-import-only, never the live mechanism; these tables are the real thing. drawScenarios: every 'required' scenario id included, plus N random picks per pool group, shuffled together (tiny seeded PRNG, mulberry32 — no new dependency; the seed is recorded for audit, never shown to the student before the draw). One assessment at a time per student. Timer is server-authoritative (expires_at fixed at draw time) and lazily expired — checked whenever a session is touched, no background cron. Ships two seeded defaults: rootnode-capstone (Real Ops scenarios required, weighted toward operational ability) and rootnode-quick-assessment (a general example exam matching the spec's category-draw example).
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
-- Assessment engine: definitions (exam/capstone templates), cohort assignment, live sessions,
|
||||
-- and the per-session scenario draw. exam_sessions (migration 006) is left untouched — it was
|
||||
-- already scoped in Phase 2B as historical-import-only from the old Lab-local SQLite exam
|
||||
-- bookkeeping, never the live mechanism. These tables ARE the live mechanism; duplicating
|
||||
-- exam_sessions' purpose would mean building the real thing twice, so this doesn't reuse it.
|
||||
--
|
||||
-- Definitions are lightly versioned (a plain incrementing integer, not full history) so a
|
||||
-- later edit to pool_spec/duration/hint_policy never changes what an already-started session is
|
||||
-- graded against — every session snapshots the fields that matter at draw time.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS assessment_definitions (
|
||||
assessment_definition_id TEXT PRIMARY KEY, -- slug, e.g. 'rootnode-capstone'
|
||||
type TEXT NOT NULL CHECK (type IN ('exam', 'capstone')),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
duration_minutes INTEGER NOT NULL,
|
||||
hint_policy TEXT NOT NULL DEFAULT 'disabled' CHECK (hint_policy IN ('free', 'disabled', 'flagged')),
|
||||
pool_spec JSONB NOT NULL, -- {"required": [scenario_id,...], "random": [{"count":n,"from":[scenario_id,...]},...]}
|
||||
completion_rule JSONB NOT NULL DEFAULT '{"type":"all_scenarios_passed"}',
|
||||
unlock_rule JSONB, -- capstone only: {"type":"stages_complete","stage_ids":[...]}
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
|
||||
created_by UUID REFERENCES instructors(instructor_id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS assessment_cohort_assignments (
|
||||
assessment_definition_id TEXT NOT NULL REFERENCES assessment_definitions(assessment_definition_id),
|
||||
cohort_id TEXT NOT NULL REFERENCES cohorts(cohort_id),
|
||||
assigned_by UUID REFERENCES instructors(instructor_id),
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (assessment_definition_id, cohort_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS assessment_sessions (
|
||||
assessment_session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
assessment_definition_id TEXT NOT NULL REFERENCES assessment_definitions(assessment_definition_id),
|
||||
definition_version INTEGER NOT NULL, -- snapshot: which version of the definition was drawn against
|
||||
student_id TEXT NOT NULL REFERENCES students(student_id),
|
||||
cohort_id TEXT,
|
||||
type TEXT NOT NULL, -- snapshot
|
||||
title TEXT NOT NULL, -- snapshot
|
||||
duration_minutes INTEGER NOT NULL, -- snapshot
|
||||
hint_policy TEXT NOT NULL, -- snapshot
|
||||
status TEXT NOT NULL DEFAULT 'not_started'
|
||||
CHECK (status IN ('not_started', 'in_progress', 'completed', 'expired', 'cancelled')),
|
||||
started_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ, -- server-computed = started_at + duration_minutes; authoritative, never client-set
|
||||
submitted_at TIMESTAMPTZ,
|
||||
random_seed TEXT, -- recorded for audit/reproducibility, not exposed to the student before the draw
|
||||
result JSONB, -- {"scenarios_total":n,"scenarios_passed":n,"passed":bool}
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS assessment_session_scenarios (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
assessment_session_id UUID NOT NULL REFERENCES assessment_sessions(assessment_session_id),
|
||||
scenario_id TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'in_progress', 'completed', 'failed')),
|
||||
attempt_id BIGINT REFERENCES scenario_attempts(attempt_id),
|
||||
UNIQUE (assessment_session_id, scenario_id)
|
||||
);
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE scenario_attempts ADD CONSTRAINT scenario_attempts_assessment_session_fk
|
||||
FOREIGN KEY (assessment_session_id) REFERENCES assessment_sessions(assessment_session_id);
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_assessment_sessions_student ON assessment_sessions(student_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_assessment_sessions_definition ON assessment_sessions(assessment_definition_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_assessment_session_scenarios_session ON assessment_session_scenarios(assessment_session_id);
|
||||
|
||||
-- Seed defaults — a working RootNode Capstone and one example exam ship out of the box, per the
|
||||
-- Phase 3B spec ("ship with a sensible RootNode default"). Scenario ids are a snapshot of
|
||||
-- scenarios/rootnode-path.json's stage lists as of this migration; edit the definition via the
|
||||
-- admin API if curriculum content changes later, same as any other admin-owned config.
|
||||
INSERT INTO assessment_definitions (assessment_definition_id, type, title, description, duration_minutes, hint_policy, pool_spec, completion_rule, unlock_rule)
|
||||
VALUES (
|
||||
'rootnode-capstone', 'capstone', 'RootNode Capstone',
|
||||
'Combines skills from every curriculum stage into one operational assessment, weighted toward RootNode Real Ops incident scenarios. Evaluates operational ability, not memorized kubectl syntax.',
|
||||
90, 'flagged',
|
||||
'{"required": ["rootnode-runaway-monitor","broken-deployment","rootnode-probe-port-mismatch","rootnode-enforce-limits"], "random": [{"count":1,"from":["deploy-nginx","scale-deployment","rolling-update-task","liveness-probe-task","edit-deployment-basics"]},{"count":1,"from":["resource-limits-task","env-vars-configmap","secrets-basics"]},{"count":1,"from":["extract-logs-basics","kubectl-cp-basics","exec-command-basics","node-cordon-basics"]}]}',
|
||||
'{"type":"all_scenarios_passed"}',
|
||||
'{"type":"stages_complete","stage_ids":["foundation","workloads","networking","configuration","troubleshooting","real-ops"]}'
|
||||
) ON CONFLICT (assessment_definition_id) DO NOTHING;
|
||||
|
||||
INSERT INTO assessment_definitions (assessment_definition_id, type, title, description, duration_minutes, hint_policy, pool_spec, completion_rule)
|
||||
VALUES (
|
||||
'rootnode-quick-assessment', 'exam', 'RootNode Kubernetes Assessment',
|
||||
'A timed, randomized skills check drawn from across the curriculum. Example exam definition — instructors can create their own from the admin portal.',
|
||||
60, 'disabled',
|
||||
'{"required": [], "random": [{"count":2,"from":["deploy-nginx","scale-deployment","rolling-update-task","liveness-probe-task","daemonset-mcq"]},{"count":1,"from":["services-mcq","expose-service-basics"]},{"count":1,"from":["extract-logs-basics","kubectl-cp-basics","exec-command-basics","node-cordon-basics"]},{"count":1,"from":["rootnode-runaway-monitor","broken-deployment","rootnode-probe-port-mismatch","rootnode-enforce-limits"]}]}',
|
||||
'{"type":"all_scenarios_passed"}'
|
||||
) ON CONFLICT (assessment_definition_id) DO NOTHING;
|
||||
Reference in New Issue
Block a user