Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 52 additions & 3 deletions backend/api/curriculum.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Curriculum API - read-only track and module retrieval."""
"""Curriculum API - track, module, and resource retrieval."""

import logging

Expand All @@ -11,13 +11,16 @@
CurriculumModuleSummary,
CurriculumTrackDetail,
CurriculumTrackSummary,
TrackResource,
)
from backend.services.learning.curriculum_store import (
DEFAULT_USER_ID,
get_concept_progress,
get_module,
get_module_progress,
get_track,
list_module_resources,
list_track_resources,
list_tracks,
update_concept_progress,
)
Expand Down Expand Up @@ -54,6 +57,19 @@ class ModuleProgressResponse(BaseModel):
not_started: int


class ModuleResourcesResponse(BaseModel):
track_id: str
module_id: str
resources: list[TrackResource]
total: int


class TrackResourcesResponse(BaseModel):
track_id: str
resources: list[TrackResource]
total: int


@router.get("/tracks", response_model=TrackListResponse)
def list_curriculum_tracks(published_only: bool = Query(True)):
"""List all curriculum tracks."""
Expand Down Expand Up @@ -81,12 +97,16 @@ def get_curriculum_track(track_id: str):

@router.get("/tracks/{track_id}/modules/{module_id}", response_model=CurriculumModuleDetail)
def get_curriculum_module(track_id: str, module_id: str):
"""Get a module with ordered concept refs and readiness state."""
"""Get a module with ordered concept refs, readiness state, and resources."""
module = get_module(track_id, module_id)
if not module:
raise HTTPException(status_code=404, detail="Module not found")
return CurriculumModuleDetail(
**{**module, "concepts": [ConceptRef(**c) for c in module["concepts"]]}
**{
**module,
"concepts": [ConceptRef(**c) for c in module["concepts"]],
"resources": [TrackResource(**r) for r in module["resources"]],
}
)


Expand All @@ -106,6 +126,35 @@ def get_curriculum_module_progress(
return ModuleProgressResponse(**{k: v for k, v in progress.items() if k != "concepts"})


@router.get(
"/tracks/{track_id}/modules/{module_id}/resources",
response_model=ModuleResourcesResponse,
)
def get_module_resources_endpoint(track_id: str, module_id: str):
"""List all resources for a specific module."""
resources = list_module_resources(track_id, module_id)
return ModuleResourcesResponse(
track_id=track_id,
module_id=module_id,
resources=[TrackResource(**r) for r in resources],
total=len(resources),
)


@router.get(
"/tracks/{track_id}/resources",
response_model=TrackResourcesResponse,
)
def get_track_resources_endpoint(track_id: str):
"""List all resources for a track."""
resources = list_track_resources(track_id)
return TrackResourcesResponse(
track_id=track_id,
resources=[TrackResource(**r) for r in resources],
total=len(resources),
)


@router.put("/progress/{concept_id}", response_model=ConceptProgressResponse)
def update_progress(concept_id: str, body: ProgressUpdateRequest):
"""Update learning progress for a concept."""
Expand Down
65 changes: 45 additions & 20 deletions backend/jobs/seed_curriculum.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@
Loads all YAML files from curriculum/tracks/, validates each against the
canonical concept registry and dossier layer, and upserts into DB.

Supports two track types:
- concept: validated against canonical concepts (existing behavior)
- resource: curated link/reference collections (no concept validation)

Usage:
python -m backend.jobs.seed_curriculum
python -m backend.jobs.seed_curriculum --dry-run
python -m backend.jobs.seed_curriculum --type resource
"""

import argparse
Expand All @@ -16,7 +21,7 @@
logger = logging.getLogger(__name__)


def seed(dry_run: bool = False) -> bool:
def seed(dry_run: bool = False, track_type_filter: str | None = None) -> bool:
"""Load and seed all curriculum track files. Returns True if all succeeded."""
from backend.services.chat.history import init_db

Expand All @@ -34,7 +39,11 @@ def seed(dry_run: bool = False) -> bool:
logger.warning("No curriculum track files found in curriculum/tracks/")
return False

# Skip temporary/reference files (prefixed with _)
track_files = [p for p in track_files if not p.name.startswith("_")]

all_valid = True
seeded_count = 0

for path in track_files:
logger.info("Loading %s", path.name)
Expand All @@ -45,6 +54,11 @@ def seed(dry_run: bool = False) -> bool:
all_valid = False
continue

# Filter by track type if specified
if track_type_filter and track.track_type != track_type_filter:
logger.info("Skipping %s (type=%s, filter=%s)", track.id, track.track_type, track_type_filter)
continue

result = validate_track(track)

# Print errors
Expand All @@ -63,39 +77,50 @@ def seed(dry_run: bool = False) -> bool:
if not result.valid:
continue

# Readiness summary
# Summary
total_concepts = sum(len(m.concepts) for m in track.modules)
print(f"\nValidated track '{track.id}'")
total_resources = sum(len(m.resources) for m in track.modules)
print(f"\nValidated track '{track.id}' (type={track.track_type})")
print(f" Modules: {len(track.modules)}")
print(f" Concept links: {total_concepts}")
print(f" Warnings: {len(result.warnings)}")

# Per-module readiness breakdown
from backend.services.learning.curriculum_loader import _get_conn
from backend.services.learning.curriculum_store import derive_readiness

conn = _get_conn()
for module in track.modules:
counts: dict[str, int] = {"rich": 0, "grounded": 0, "scaffolded": 0}
for c in module.concepts:
r = derive_readiness(conn, c.concept_id)
counts[r] = counts.get(r, 0) + 1
parts = [f"{v} {k}" for k, v in counts.items() if v > 0]
print(f" [{module.sort_order}] {module.title}: {len(module.concepts)} concepts ({', '.join(parts)})")

if track.track_type == "concept":
print(f" Concept links: {total_concepts}")

# Per-module readiness breakdown
from backend.services.learning.curriculum_loader import _get_conn
from backend.services.learning.curriculum_store import derive_readiness

conn = _get_conn()
for module in track.modules:
counts: dict[str, int] = {"rich": 0, "grounded": 0, "scaffolded": 0}
for c in module.concepts:
r = derive_readiness(conn, c.concept_id)
counts[r] = counts.get(r, 0) + 1
parts = [f"{v} {k}" for k, v in counts.items() if v > 0]
print(f" [{module.sort_order}] {module.title}: {len(module.concepts)} concepts ({', '.join(parts)})")
else:
print(f" Resources: {total_resources}")
for module in track.modules:
print(f" [{module.sort_order}] {module.title}: {len(module.resources)} resources")

if dry_run:
print("\n (dry run - not seeding)")
else:
from backend.services.learning.curriculum_loader import _get_conn
conn = _get_conn()
seed_track(track, conn)
print("\nSeeded curriculum successfully.")
seeded_count += 1
print(" Seeded.")

print(f"\nDone. Seeded {seeded_count} tracks.")
return all_valid


if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Seed curriculum tracks from YAML files")
parser.add_argument("--dry-run", action="store_true", help="Validate only, do not write to DB")
parser.add_argument("--type", choices=["concept", "resource"], default=None, help="Only seed tracks of this type")
args = parser.parse_args()

success = seed(dry_run=args.dry_run)
success = seed(dry_run=args.dry_run, track_type_filter=args.type)
sys.exit(0 if success else 1)
43 changes: 39 additions & 4 deletions backend/models/curriculum.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,55 @@ class CurriculumModuleConceptFile(BaseModel):
sort_order: int


class ResourceFile(BaseModel):
"""A curated resource within a module (link, reference, or knowledge item)."""

name: str
url: str | None = None
description: str = ""
detail: str | None = None
resource_type: str = "link"
sort_order: int = 0
metadata_json: str | None = None


class CurriculumModuleFile(BaseModel):
id: str
title: str
objective: str
estimated_time_minutes: int
estimated_time_minutes: int = 30
sort_order: int
concepts: list[CurriculumModuleConceptFile]
color: str | None = None
concepts: list[CurriculumModuleConceptFile] = []
resources: list[ResourceFile] = []


class CurriculumTrackFile(BaseModel):
id: str
title: str
description: str
difficulty: str
track_type: str = "concept"
is_published: bool = True
modules: list[CurriculumModuleFile]


# --- Runtime response models ---


class TrackResource(BaseModel):
"""A curated resource attached to a track module."""

id: int
name: str
url: str | None = None
description: str = ""
detail: str | None = None
resource_type: str = "link"
sort_order: int = 0
metadata_json: str | None = None


class ConceptRef(BaseModel):
"""A concept referenced by a curriculum module, joined with concept metadata."""

Expand All @@ -52,7 +80,9 @@ class CurriculumModuleDetail(BaseModel):
objective: str
estimated_time_minutes: int
sort_order: int
concepts: list[ConceptRef]
color: str | None = None
concepts: list[ConceptRef] = []
resources: list[TrackResource] = []


class CurriculumModuleSummary(BaseModel):
Expand All @@ -61,14 +91,17 @@ class CurriculumModuleSummary(BaseModel):
objective: str
estimated_time_minutes: int
sort_order: int
concept_count: int
color: str | None = None
concept_count: int = 0
resource_count: int = 0


class CurriculumTrackDetail(BaseModel):
id: str
title: str
description: str
difficulty: str
track_type: str = "concept"
is_published: bool
modules: list[CurriculumModuleSummary]

Expand All @@ -78,9 +111,11 @@ class CurriculumTrackSummary(BaseModel):
title: str
description: str
difficulty: str
track_type: str = "concept"
is_published: bool
module_count: int
concept_count: int
resource_count: int = 0


# --- Validation results ---
Expand Down
45 changes: 45 additions & 0 deletions backend/services/infrastructure/db_migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -2961,6 +2961,50 @@ def _migration_083_concepts_lens_level(conn: sqlite3.Connection) -> None:
)


def _migration_084_track_resources(conn: sqlite3.Connection) -> None:
"""Add track_type to tracks, color to modules, and track_resources table.

Supports resource-oriented tracks (curated link collections) alongside
concept-oriented tracks (the existing curriculum model).
"""
# track_type on curriculum_tracks (concept vs resource)
try:
conn.execute(
"ALTER TABLE curriculum_tracks ADD COLUMN track_type TEXT NOT NULL DEFAULT 'concept'"
)
except sqlite3.OperationalError:
pass # Column already exists

# color on curriculum_modules (for tab coloring in resource tracks)
try:
conn.execute("ALTER TABLE curriculum_modules ADD COLUMN color TEXT")
except sqlite3.OperationalError:
pass

# track_resources table
conn.executescript("""
CREATE TABLE IF NOT EXISTS track_resources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
track_id TEXT NOT NULL,
module_id TEXT NOT NULL,
name TEXT NOT NULL,
url TEXT,
description TEXT NOT NULL DEFAULT '',
detail TEXT,
resource_type TEXT NOT NULL DEFAULT 'link'
CHECK(resource_type IN ('link', 'reference', 'knowledge')),
sort_order INTEGER NOT NULL DEFAULT 0,
metadata_json TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (track_id) REFERENCES curriculum_tracks(id) ON DELETE CASCADE,
FOREIGN KEY (module_id, track_id) REFERENCES curriculum_modules(id, track_id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS idx_track_resources_module
ON track_resources(track_id, module_id);
""")


MIGRATIONS: tuple[MigrationStep, ...] = (
MigrationStep("0001", "chat_core_tables", _migration_001_chat_core),
MigrationStep("0002", "messages_max_rerank_score", _migration_002_messages_max_rerank_score),
Expand Down Expand Up @@ -3045,6 +3089,7 @@ def _migration_083_concepts_lens_level(conn: sqlite3.Connection) -> None:
MigrationStep("0081", "curriculum_tables", _migration_081_curriculum_tables),
MigrationStep("0082", "concept_progress_per_user", _migration_082_concept_progress_per_user),
MigrationStep("0083", "concepts_lens_level", _migration_083_concepts_lens_level),
MigrationStep("0084", "track_resources", _migration_084_track_resources),
)


Expand Down
Loading
Loading