Apply ZIP update: 235-didactopus-productionization-scaffold.zip [2026-03-14T13:20:53]

This commit is contained in:
welsberr 2026-03-14 13:29:56 -04:00
parent b722c379dd
commit 664f959f34
15 changed files with 277 additions and 506 deletions

View File

@ -6,10 +6,20 @@ build-backend = "setuptools.build_meta"
name = "didactopus" name = "didactopus"
version = "0.1.0" version = "0.1.0"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = ["pydantic>=2.7", "pyyaml>=6.0"] dependencies = [
"pydantic>=2.7",
"pyyaml>=6.0",
"fastapi>=0.115",
"uvicorn>=0.30",
"sqlalchemy>=2.0",
"psycopg[binary]>=3.1",
"passlib[bcrypt]>=1.7",
"python-jose[cryptography]>=3.3"
]
[project.scripts] [project.scripts]
didactopus-pack-to-frontend = "didactopus.pack_to_frontend:main" didactopus-api = "didactopus.api:main"
didactopus-worker = "didactopus.worker:main"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]

View File

@ -1,19 +1,30 @@
from __future__ import annotations from __future__ import annotations
from fastapi import FastAPI, HTTPException, Header, Depends from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
import uvicorn, json, tempfile import uvicorn
from pathlib import Path from .config import load_settings
from .db import Base, engine from .db import Base, engine
from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, MediaRenderRequest from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest
from .repository import authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token, list_packs_for_user, get_pack, get_pack_row, create_learner, learner_owned_by_user, load_learner_state, save_learner_state from .repository import (
authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token,
list_packs, get_pack, upsert_pack, create_learner, learner_owned_by_user, load_learner_state,
save_learner_state, create_evaluator_job, get_evaluator_job, list_evaluator_jobs_for_learner
)
from .engine import apply_evidence, recommend_next
from .auth import issue_access_token, issue_refresh_token, decode_token, new_token_id from .auth import issue_access_token, issue_refresh_token, decode_token, new_token_id
from .engine import build_graph_frames, stable_layout from .worker import process_job
from .render_bundle import make_render_bundle
settings = load_settings()
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
app = FastAPI(title="Didactopus API Prototype") app = FastAPI(title="Didactopus API Prototype")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
def current_user(authorization: str = Header(default="")): def current_user(authorization: str = Header(default="")):
token = authorization.removeprefix("Bearer ").strip() token = authorization.removeprefix("Bearer ").strip()
@ -25,24 +36,17 @@ def current_user(authorization: str = Header(default="")):
raise HTTPException(status_code=401, detail="Unauthorized") raise HTTPException(status_code=401, detail="Unauthorized")
return user return user
def require_admin(user = Depends(current_user)):
if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin role required")
return user
def ensure_learner_access(user, learner_id: str): def ensure_learner_access(user, learner_id: str):
if user.role == "admin": if user.role == "admin":
return return
if not learner_owned_by_user(user.id, learner_id): if not learner_owned_by_user(user.id, learner_id):
raise HTTPException(status_code=403, detail="Learner not accessible by this user") raise HTTPException(status_code=403, detail="Learner not accessible by this user")
def ensure_pack_access(user, pack_id: str):
row = get_pack_row(pack_id)
if row is None:
raise HTTPException(status_code=404, detail="Pack not found")
if user.role == "admin":
return row
if row.policy_lane == "community":
return row
if row.owner_user_id == user.id:
return row
raise HTTPException(status_code=403, detail="Pack not accessible by this user")
@app.post("/api/login", response_model=TokenPair) @app.post("/api/login", response_model=TokenPair)
def login(payload: LoginRequest): def login(payload: LoginRequest):
user = authenticate_user(payload.username, payload.password) user = authenticate_user(payload.username, payload.password)
@ -50,7 +54,12 @@ def login(payload: LoginRequest):
raise HTTPException(status_code=401, detail="Invalid credentials") raise HTTPException(status_code=401, detail="Invalid credentials")
token_id = new_token_id() token_id = new_token_id()
store_refresh_token(user.id, token_id) store_refresh_token(user.id, token_id)
return TokenPair(access_token=issue_access_token(user.id, user.username, user.role), refresh_token=issue_refresh_token(user.id, user.username, user.role, token_id), username=user.username, role=user.role) return TokenPair(
access_token=issue_access_token(user.id, user.username, user.role),
refresh_token=issue_refresh_token(user.id, user.username, user.role, token_id),
username=user.username,
role=user.role,
)
@app.post("/api/refresh", response_model=TokenPair) @app.post("/api/refresh", response_model=TokenPair)
def refresh(payload: RefreshRequest): def refresh(payload: RefreshRequest):
@ -66,11 +75,29 @@ def refresh(payload: RefreshRequest):
revoke_refresh_token(token_id) revoke_refresh_token(token_id)
new_jti = new_token_id() new_jti = new_token_id()
store_refresh_token(user.id, new_jti) store_refresh_token(user.id, new_jti)
return TokenPair(access_token=issue_access_token(user.id, user.username, user.role), refresh_token=issue_refresh_token(user.id, user.username, user.role, new_jti), username=user.username, role=user.role) return TokenPair(
access_token=issue_access_token(user.id, user.username, user.role),
refresh_token=issue_refresh_token(user.id, user.username, user.role, new_jti),
username=user.username,
role=user.role,
)
@app.get("/api/packs") @app.get("/api/packs")
def api_list_packs(user = Depends(current_user)): def api_list_packs(user = Depends(current_user)):
return [p.model_dump() for p in list_packs_for_user(user.id, include_unpublished=(user.role == "admin"))] include_unpublished = user.role == "admin"
return [p.model_dump() for p in list_packs(include_unpublished=include_unpublished)]
@app.get("/api/packs/{pack_id}")
def api_get_pack(pack_id: str, user = Depends(current_user)):
pack = get_pack(pack_id)
if pack is None:
raise HTTPException(status_code=404, detail="Pack not found")
return pack.model_dump()
@app.post("/api/admin/packs")
def api_upsert_pack(payload: CreatePackRequest, user = Depends(require_admin)):
upsert_pack(payload.pack, is_published=payload.is_published)
return {"ok": True, "pack_id": payload.pack.id}
@app.post("/api/learners") @app.post("/api/learners")
def api_create_learner(payload: CreateLearnerRequest, user = Depends(current_user)): def api_create_learner(payload: CreateLearnerRequest, user = Depends(current_user)):
@ -89,52 +116,45 @@ def api_put_learner_state(learner_id: str, state: LearnerState, user = Depends(c
raise HTTPException(status_code=400, detail="Learner ID mismatch") raise HTTPException(status_code=400, detail="Learner ID mismatch")
return save_learner_state(state).model_dump() return save_learner_state(state).model_dump()
@app.get("/api/packs/{pack_id}/layout") @app.post("/api/learners/{learner_id}/evidence")
def api_pack_layout(pack_id: str, user = Depends(current_user)): def api_post_evidence(learner_id: str, event: EvidenceEvent, user = Depends(current_user)):
ensure_pack_access(user, pack_id)
pack = get_pack(pack_id)
return {"pack_id": pack_id, "layout": stable_layout(pack)} if pack else {"pack_id": pack_id, "layout": {}}
@app.get("/api/learners/{learner_id}/graph-animation/{pack_id}")
def api_graph_animation(learner_id: str, pack_id: str, user = Depends(current_user)):
ensure_learner_access(user, learner_id) ensure_learner_access(user, learner_id)
ensure_pack_access(user, pack_id)
pack = get_pack(pack_id)
state = load_learner_state(learner_id) state = load_learner_state(learner_id)
frames = build_graph_frames(state, pack) state = apply_evidence(state, event)
return { save_learner_state(state)
"learner_id": learner_id, return state.model_dump()
"pack_id": pack_id,
"pack_title": pack.title if pack else "",
"frames": frames,
"concepts": [{"id": c.id, "title": c.title, "prerequisites": c.prerequisites, "cross_pack_links": [l.model_dump() for l in c.cross_pack_links]} for c in pack.concepts] if pack else [],
}
@app.post("/api/learners/{learner_id}/render-bundle/{pack_id}") @app.get("/api/learners/{learner_id}/recommendations/{pack_id}")
def api_render_bundle(learner_id: str, pack_id: str, payload: MediaRenderRequest, user = Depends(current_user)): def api_get_recommendations(learner_id: str, pack_id: str, user = Depends(current_user)):
ensure_learner_access(user, learner_id) ensure_learner_access(user, learner_id)
ensure_pack_access(user, pack_id)
pack = get_pack(pack_id)
state = load_learner_state(learner_id) state = load_learner_state(learner_id)
animation = { pack = get_pack(pack_id)
"learner_id": learner_id, if pack is None:
"pack_id": pack_id, raise HTTPException(status_code=404, detail="Pack not found")
"pack_title": pack.title if pack else "", return {"cards": recommend_next(state, pack)}
"frames": build_graph_frames(state, pack),
} @app.post("/api/learners/{learner_id}/evaluator-jobs", response_model=EvaluatorJobStatus)
base = Path(tempfile.mkdtemp(prefix="didactopus_render_")) def api_submit_evaluator_job(learner_id: str, payload: EvaluatorSubmission, background_tasks: BackgroundTasks, user = Depends(current_user)):
payload_json = base / "animation_payload.json" ensure_learner_access(user, learner_id)
payload_json.write_text(json.dumps(animation, indent=2), encoding="utf-8") job_id = create_evaluator_job(learner_id, payload.pack_id, payload.concept_id, payload.submitted_text)
out_dir = base / "bundle" background_tasks.add_task(process_job, job_id)
make_render_bundle(str(payload_json), str(out_dir), fps=payload.fps, fmt=payload.format) return EvaluatorJobStatus(job_id=job_id, status="queued")
return {
"bundle_dir": str(out_dir), @app.get("/api/evaluator-jobs/{job_id}", response_model=EvaluatorJobStatus)
"payload_json": str(payload_json), def api_get_evaluator_job(job_id: int, user = Depends(current_user)):
"manifest": str(out_dir / "render_manifest.json"), job = get_evaluator_job(job_id)
"script": str(out_dir / "render.sh"), if job is None:
"format": payload.format, raise HTTPException(status_code=404, detail="Job not found")
"fps": payload.fps, return EvaluatorJobStatus(job_id=job.id, status=job.status, result_score=job.result_score, result_confidence_hint=job.result_confidence_hint, result_notes=job.result_notes)
}
@app.get("/api/learners/{learner_id}/evaluator-history")
def api_get_evaluator_history(learner_id: str, user = Depends(current_user)):
ensure_learner_access(user, learner_id)
jobs = list_evaluator_jobs_for_learner(learner_id)
return [{"job_id": j.id, "status": j.status, "concept_id": j.concept_id, "result_score": j.result_score, "result_confidence_hint": j.result_confidence_hint, "result_notes": j.result_notes} for j in jobs]
def main(): def main():
uvicorn.run(app, host="127.0.0.1", port=8011) uvicorn.run(app, host=settings.host, port=settings.port)
if __name__ == "__main__":
main()

View File

@ -20,10 +20,10 @@ def _encode_token(payload: dict, expires_delta: timedelta) -> str:
return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm) return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def issue_access_token(user_id: int, username: str, role: str) -> str: def issue_access_token(user_id: int, username: str, role: str) -> str:
return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "access"}, timedelta(minutes=30)) return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "access"}, timedelta(minutes=settings.access_token_minutes))
def issue_refresh_token(user_id: int, username: str, role: str, token_id: str) -> str: def issue_refresh_token(user_id: int, username: str, role: str, token_id: str) -> str:
return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "refresh", "jti": token_id}, timedelta(days=14)) return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "refresh", "jti": token_id}, timedelta(days=settings.refresh_token_days))
def decode_token(token: str) -> dict | None: def decode_token(token: str) -> dict | None:
try: try:

View File

@ -3,11 +3,13 @@ import os
from pydantic import BaseModel from pydantic import BaseModel
class Settings(BaseModel): class Settings(BaseModel):
database_url: str = os.getenv("DIDACTOPUS_DATABASE_URL", "sqlite+pysqlite:///:memory:") database_url: str = os.getenv("DIDACTOPUS_DATABASE_URL", "postgresql+psycopg://didactopus:didactopus-dev-password@localhost:5432/didactopus")
host: str = os.getenv("DIDACTOPUS_HOST", "127.0.0.1") host: str = os.getenv("DIDACTOPUS_HOST", "127.0.0.1")
port: int = int(os.getenv("DIDACTOPUS_PORT", "8011")) port: int = int(os.getenv("DIDACTOPUS_PORT", "8011"))
jwt_secret: str = os.getenv("DIDACTOPUS_JWT_SECRET", "change-me") jwt_secret: str = os.getenv("DIDACTOPUS_JWT_SECRET", "change-me")
jwt_algorithm: str = "HS256" jwt_algorithm: str = "HS256"
access_token_minutes: int = 30
refresh_token_days: int = 14
def load_settings() -> Settings: def load_settings() -> Settings:
return Settings() return Settings()

View File

@ -1,110 +1,59 @@
from __future__ import annotations from __future__ import annotations
from collections import defaultdict from .models import LearnerState, EvidenceEvent, MasteryRecord, PackData
from .models import LearnerState, PackData
def concept_depths(pack: PackData) -> dict[str, int]: def get_record(state: LearnerState, concept_id: str, dimension: str = "mastery") -> MasteryRecord | None:
concept_map = {c.id: c for c in pack.concepts} for rec in state.records:
memo = {} if rec.concept_id == concept_id and rec.dimension == dimension:
def depth(cid: str) -> int: return rec
if cid in memo: return None
return memo[cid]
c = concept_map[cid]
if not c.prerequisites:
memo[cid] = 0
else:
memo[cid] = 1 + max(depth(pid) for pid in c.prerequisites if pid in concept_map)
return memo[cid]
for cid in concept_map:
depth(cid)
return memo
def stable_layout(pack: PackData, width: int = 900, height: int = 520): def apply_evidence(state: LearnerState, event: EvidenceEvent, decay: float = 0.05, reinforcement: float = 0.25) -> LearnerState:
depths = concept_depths(pack) rec = get_record(state, event.concept_id, event.dimension)
layers = defaultdict(list) if rec is None:
for c in pack.concepts: rec = MasteryRecord(concept_id=event.concept_id, dimension=event.dimension, score=0.0, confidence=0.0, evidence_count=0, last_updated=event.timestamp)
layers[depths.get(c.id, 0)].append(c) state.records.append(rec)
positions = {} weight = max(0.05, min(1.0, event.confidence_hint))
max_depth = max(layers.keys()) if layers else 0 rec.score = ((rec.score * rec.evidence_count) + (event.score * weight)) / max(1, rec.evidence_count + 1)
for d in sorted(layers): rec.confidence = min(1.0, max(0.0, rec.confidence * (1.0 - decay) + reinforcement * weight + 0.10 * max(0.0, min(1.0, event.score))))
nodes = sorted(layers[d], key=lambda c: c.id) rec.evidence_count += 1
y = 90 + d * ((height - 160) / max(1, max_depth)) rec.last_updated = event.timestamp
for idx, node in enumerate(nodes): state.history.append(event)
if node.position is not None: return state
positions[node.id] = {"x": node.position.x, "y": node.position.y, "source": "pack_authored"}
else:
spacing = width / (len(nodes) + 1)
x = spacing * (idx + 1)
positions[node.id] = {"x": x, "y": y, "source": "auto_layered"}
return positions
def prereqs_satisfied(scores: dict[str, float], concept, min_score: float = 0.65) -> bool: def prereqs_satisfied(state: LearnerState, concept, min_score: float = 0.65, min_confidence: float = 0.45) -> bool:
for pid in concept.prerequisites: for pid in concept.prerequisites:
if scores.get(pid, 0.0) < min_score: rec = get_record(state, pid, concept.masteryDimension)
if rec is None or rec.score < min_score or rec.confidence < min_confidence:
return False return False
return True return True
def concept_status(scores: dict[str, float], concept, min_score: float = 0.65) -> str: def concept_status(state: LearnerState, concept, min_score: float = 0.65, min_confidence: float = 0.45) -> str:
score = scores.get(concept.id, 0.0) rec = get_record(state, concept.id, concept.masteryDimension)
if score >= min_score: if rec and rec.score >= min_score and rec.confidence >= min_confidence:
return "mastered" return "mastered"
if prereqs_satisfied(scores, concept, min_score): if prereqs_satisfied(state, concept, min_score, min_confidence):
return "active" if score > 0 else "available" return "active" if rec else "available"
return "locked" return "locked"
def build_graph_frames(state: LearnerState, pack: PackData): def recommend_next(state: LearnerState, pack: PackData) -> list[dict]:
concepts = {c.id: c for c in pack.concepts} cards = []
layout = stable_layout(pack) for concept in pack.concepts:
scores = {c.id: 0.0 for c in pack.concepts} status = concept_status(state, concept)
frames = [] rec = get_record(state, concept.id, concept.masteryDimension)
history = sorted(state.history, key=lambda x: x.timestamp) if status in {"available", "active"}:
static_edges = [{"source": pre, "target": c.id, "kind": "prerequisite"} for c in pack.concepts for pre in c.prerequisites] cards.append({
static_cross = [{ "id": concept.id,
"source": c.id, "title": f"Work on {concept.title}",
"target_pack_id": link.target_pack_id, "minutes": 15 if status == "available" else 10,
"target_concept_id": link.target_concept_id, "reason": "Prerequisites are satisfied, so this is the best next unlock." if status == "available" else "You have started this concept, but mastery is not yet secure.",
"relationship": link.relationship, "why": [
"kind": "cross_pack" "Prerequisite check passed",
} for c in pack.concepts for link in c.cross_pack_links] f"Current score: {rec.score:.2f}" if rec else "No evidence recorded yet",
for idx, ev in enumerate(history): f"Current confidence: {rec.confidence:.2f}" if rec else "Confidence starts after your first exercise",
if ev.concept_id in scores: ],
scores[ev.concept_id] = ev.score "reward": concept.exerciseReward or f"{concept.title} progress recorded",
nodes = [] "conceptId": concept.id,
for cid, concept in concepts.items(): "scoreHint": 0.82 if status == "available" else 0.76,
score = scores.get(cid, 0.0) "confidenceHint": 0.72 if status == "available" else 0.55,
status = concept_status(scores, concept)
pos = layout[cid]
nodes.append({
"id": cid,
"title": concept.title,
"score": score,
"status": status,
"size": 20 + int(score * 30),
"x": pos["x"],
"y": pos["y"],
"layout_source": pos["source"],
}) })
frames.append({ return cards[:4]
"index": idx,
"timestamp": ev.timestamp,
"event_kind": ev.kind,
"focus_concept_id": ev.concept_id,
"nodes": nodes,
"edges": static_edges,
"cross_pack_links": static_cross,
})
if not frames:
nodes = []
for c in pack.concepts:
pos = layout[c.id]
nodes.append({
"id": c.id,
"title": c.title,
"score": 0.0,
"status": "available" if not c.prerequisites else "locked",
"size": 20,
"x": pos["x"],
"y": pos["y"],
"layout_source": pos["source"],
})
frames.append({"index": 0, "timestamp": "", "event_kind": "empty", "focus_concept_id": "", "nodes": nodes, "edges": static_edges, "cross_pack_links": static_cross})
return frames

View File

@ -1,5 +1,8 @@
from __future__ import annotations from __future__ import annotations
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Literal
EvidenceKind = Literal["checkpoint", "project", "exercise", "review"]
class TokenPair(BaseModel): class TokenPair(BaseModel):
access_token: str access_token: str
@ -15,24 +18,19 @@ class LoginRequest(BaseModel):
class RefreshRequest(BaseModel): class RefreshRequest(BaseModel):
refresh_token: str refresh_token: str
class GraphPosition(BaseModel):
x: float
y: float
class CrossPackLink(BaseModel):
source_concept_id: str
target_pack_id: str
target_concept_id: str
relationship: str = "related"
class PackConcept(BaseModel): class PackConcept(BaseModel):
id: str id: str
title: str title: str
prerequisites: list[str] = Field(default_factory=list) prerequisites: list[str] = Field(default_factory=list)
masteryDimension: str = "mastery" masteryDimension: str = "mastery"
exerciseReward: str = "" exerciseReward: str = ""
position: GraphPosition | None = None
cross_pack_links: list[CrossPackLink] = Field(default_factory=list) class PackCompliance(BaseModel):
sources: int = 0
attributionRequired: bool = False
shareAlikeRequired: bool = False
noncommercialOnly: bool = False
flags: list[str] = Field(default_factory=list)
class PackData(BaseModel): class PackData(BaseModel):
id: str id: str
@ -41,11 +39,7 @@ class PackData(BaseModel):
level: str = "novice-friendly" level: str = "novice-friendly"
concepts: list[PackConcept] = Field(default_factory=list) concepts: list[PackConcept] = Field(default_factory=list)
onboarding: dict = Field(default_factory=dict) onboarding: dict = Field(default_factory=dict)
compliance: dict = Field(default_factory=dict) compliance: PackCompliance = Field(default_factory=PackCompliance)
class CreateLearnerRequest(BaseModel):
learner_id: str
display_name: str = ""
class MasteryRecord(BaseModel): class MasteryRecord(BaseModel):
concept_id: str concept_id: str
@ -61,7 +55,7 @@ class EvidenceEvent(BaseModel):
score: float score: float
confidence_hint: float = 0.5 confidence_hint: float = 0.5
timestamp: str timestamp: str
kind: str = "exercise" kind: EvidenceKind = "exercise"
source_id: str = "" source_id: str = ""
class LearnerState(BaseModel): class LearnerState(BaseModel):
@ -69,9 +63,23 @@ class LearnerState(BaseModel):
records: list[MasteryRecord] = Field(default_factory=list) records: list[MasteryRecord] = Field(default_factory=list)
history: list[EvidenceEvent] = Field(default_factory=list) history: list[EvidenceEvent] = Field(default_factory=list)
class MediaRenderRequest(BaseModel): class CreateLearnerRequest(BaseModel):
learner_id: str learner_id: str
display_name: str = ""
class EvaluatorSubmission(BaseModel):
pack_id: str pack_id: str
format: str = "gif" concept_id: str
fps: int = 2 submitted_text: str
theme: str = "default" kind: str = "checkpoint"
class EvaluatorJobStatus(BaseModel):
job_id: int
status: str
result_score: float | None = None
result_confidence_hint: float | None = None
result_notes: str = ""
class CreatePackRequest(BaseModel):
pack: PackData
is_published: bool = True

View File

@ -1,5 +1,5 @@
from sqlalchemy import String, Integer, Float, ForeignKey, Text, Boolean from sqlalchemy import String, Integer, Float, ForeignKey, Text, Boolean
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column, relationship
from .db import Base from .db import Base
class UserORM(Base): class UserORM(Base):
@ -20,13 +20,11 @@ class RefreshTokenORM(Base):
class PackORM(Base): class PackORM(Base):
__tablename__ = "packs" __tablename__ = "packs"
id: Mapped[str] = mapped_column(String(100), primary_key=True) id: Mapped[str] = mapped_column(String(100), primary_key=True)
owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
policy_lane: Mapped[str] = mapped_column(String(50), default="personal")
title: Mapped[str] = mapped_column(String(255)) title: Mapped[str] = mapped_column(String(255))
subtitle: Mapped[str] = mapped_column(Text, default="") subtitle: Mapped[str] = mapped_column(Text, default="")
level: Mapped[str] = mapped_column(String(100), default="novice-friendly") level: Mapped[str] = mapped_column(String(100), default="novice-friendly")
data_json: Mapped[str] = mapped_column(Text) data_json: Mapped[str] = mapped_column(Text)
is_published: Mapped[bool] = mapped_column(Boolean, default=False) is_published: Mapped[bool] = mapped_column(Boolean, default=True)
class LearnerORM(Base): class LearnerORM(Base):
__tablename__ = "learners" __tablename__ = "learners"
@ -56,3 +54,15 @@ class EvidenceEventORM(Base):
timestamp: Mapped[str] = mapped_column(String(100), default="") timestamp: Mapped[str] = mapped_column(String(100), default="")
kind: Mapped[str] = mapped_column(String(50), default="exercise") kind: Mapped[str] = mapped_column(String(50), default="exercise")
source_id: Mapped[str] = mapped_column(String(255), default="") source_id: Mapped[str] = mapped_column(String(255), default="")
class EvaluatorJobORM(Base):
__tablename__ = "evaluator_jobs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
learner_id: Mapped[str] = mapped_column(ForeignKey("learners.id"), index=True)
pack_id: Mapped[str] = mapped_column(ForeignKey("packs.id"), index=True)
concept_id: Mapped[str] = mapped_column(String(100), index=True)
submitted_text: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(String(50), default="queued")
result_score: Mapped[float | None] = mapped_column(Float, nullable=True)
result_confidence_hint: Mapped[float | None] = mapped_column(Float, nullable=True)
result_notes: Mapped[str] = mapped_column(Text, default="")

View File

@ -2,7 +2,7 @@ from __future__ import annotations
import json import json
from sqlalchemy import select from sqlalchemy import select
from .db import SessionLocal from .db import SessionLocal
from .orm import UserORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM from .orm import UserORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM
from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent
from .auth import verify_password from .auth import verify_password
@ -37,54 +37,31 @@ def revoke_refresh_token(token_id: str):
row.is_revoked = True row.is_revoked = True
db.commit() db.commit()
def list_packs_for_user(user_id: int | None = None, include_unpublished: bool = False): def list_packs(include_unpublished: bool = False) -> list[PackData]:
with SessionLocal() as db: with SessionLocal() as db:
stmt = select(PackORM) stmt = select(PackORM)
if not include_unpublished: if not include_unpublished:
stmt = stmt.where(PackORM.is_published == True) stmt = stmt.where(PackORM.is_published == True)
rows = db.execute(stmt).scalars().all() rows = db.execute(stmt).scalars().all()
out = [] return [PackData.model_validate(json.loads(r.data_json)) for r in rows]
for r in rows:
if r.policy_lane == "community":
out.append(PackData.model_validate(json.loads(r.data_json)))
elif user_id is not None and r.owner_user_id == user_id:
out.append(PackData.model_validate(json.loads(r.data_json)))
return out
def get_pack(pack_id: str): def get_pack(pack_id: str):
with SessionLocal() as db: with SessionLocal() as db:
row = db.get(PackORM, pack_id) row = db.get(PackORM, pack_id)
return None if row is None else PackData.model_validate(json.loads(row.data_json)) return None if row is None else PackData.model_validate(json.loads(row.data_json))
def get_pack_row(pack_id: str): def upsert_pack(pack: PackData, is_published: bool = True):
with SessionLocal() as db:
return db.get(PackORM, pack_id)
def upsert_pack(pack: PackData, submitted_by_user_id: int, policy_lane: str = "personal", is_published: bool = False):
with SessionLocal() as db: with SessionLocal() as db:
row = db.get(PackORM, pack.id) row = db.get(PackORM, pack.id)
payload = json.dumps(pack.model_dump()) payload = json.dumps(pack.model_dump())
if row is None: if row is None:
row = PackORM( db.add(PackORM(id=pack.id, title=pack.title, subtitle=pack.subtitle, level=pack.level, data_json=payload, is_published=is_published))
id=pack.id,
owner_user_id=submitted_by_user_id if policy_lane == "personal" else None,
policy_lane=policy_lane,
title=pack.title,
subtitle=pack.subtitle,
level=pack.level,
data_json=payload,
is_published=is_published if policy_lane == "personal" else False,
)
db.add(row)
else: else:
row.owner_user_id = submitted_by_user_id if policy_lane == "personal" else row.owner_user_id
row.policy_lane = policy_lane
row.title = pack.title row.title = pack.title
row.subtitle = pack.subtitle row.subtitle = pack.subtitle
row.level = pack.level row.level = pack.level
row.data_json = payload row.data_json = payload
if policy_lane == "personal": row.is_published = is_published
row.is_published = is_published
db.commit() db.commit()
def create_learner(owner_user_id: int, learner_id: str, display_name: str = ""): def create_learner(owner_user_id: int, learner_id: str, display_name: str = ""):
@ -98,7 +75,7 @@ def learner_owned_by_user(user_id: int, learner_id: str) -> bool:
learner = db.get(LearnerORM, learner_id) learner = db.get(LearnerORM, learner_id)
return learner is not None and learner.owner_user_id == user_id return learner is not None and learner.owner_user_id == user_id
def load_learner_state(learner_id: str): def load_learner_state(learner_id: str) -> LearnerState:
with SessionLocal() as db: with SessionLocal() as db:
records = db.execute(select(MasteryRecordORM).where(MasteryRecordORM.learner_id == learner_id)).scalars().all() records = db.execute(select(MasteryRecordORM).where(MasteryRecordORM.learner_id == learner_id)).scalars().all()
history = db.execute(select(EvidenceEventORM).where(EvidenceEventORM.learner_id == learner_id)).scalars().all() history = db.execute(select(EvidenceEventORM).where(EvidenceEventORM.learner_id == learner_id)).scalars().all()
@ -118,3 +95,30 @@ def save_learner_state(state: LearnerState):
db.add(EvidenceEventORM(learner_id=state.learner_id, concept_id=h.concept_id, dimension=h.dimension, score=h.score, confidence_hint=h.confidence_hint, timestamp=h.timestamp, kind=h.kind, source_id=h.source_id)) db.add(EvidenceEventORM(learner_id=state.learner_id, concept_id=h.concept_id, dimension=h.dimension, score=h.score, confidence_hint=h.confidence_hint, timestamp=h.timestamp, kind=h.kind, source_id=h.source_id))
db.commit() db.commit()
return state return state
def create_evaluator_job(learner_id: str, pack_id: str, concept_id: str, submitted_text: str) -> int:
with SessionLocal() as db:
job = EvaluatorJobORM(learner_id=learner_id, pack_id=pack_id, concept_id=concept_id, submitted_text=submitted_text, status="queued")
db.add(job)
db.commit()
db.refresh(job)
return job.id
def list_evaluator_jobs_for_learner(learner_id: str) -> list[EvaluatorJobORM]:
with SessionLocal() as db:
return db.execute(select(EvaluatorJobORM).where(EvaluatorJobORM.learner_id == learner_id).order_by(EvaluatorJobORM.id.desc())).scalars().all()
def get_evaluator_job(job_id: int):
with SessionLocal() as db:
return db.get(EvaluatorJobORM, job_id)
def update_evaluator_job(job_id: int, status: str, score: float | None = None, confidence_hint: float | None = None, notes: str = ""):
with SessionLocal() as db:
job = db.get(EvaluatorJobORM, job_id)
if job is None:
return
job.status = status
job.result_score = score
job.result_confidence_hint = confidence_hint
job.result_notes = notes
db.commit()

View File

@ -1,34 +1,36 @@
from __future__ import annotations from __future__ import annotations
import json
from sqlalchemy import select from sqlalchemy import select
from .db import Base, engine, SessionLocal from .db import Base, engine, SessionLocal
from .orm import UserORM from .orm import UserORM, PackORM
from .auth import hash_password from .auth import hash_password
from .repository import upsert_pack, create_learner
from .models import PackData, PackConcept, GraphPosition, CrossPackLink PACKS = [
{
"id": "bayes-pack",
"title": "Bayesian Reasoning",
"subtitle": "Probability, evidence, updating, and model criticism.",
"level": "novice-friendly",
"concepts": [
{"id": "prior", "title": "Prior", "prerequisites": [], "masteryDimension": "mastery", "exerciseReward": "Prior badge earned"},
{"id": "posterior", "title": "Posterior", "prerequisites": ["prior"], "masteryDimension": "mastery", "exerciseReward": "Posterior path opened"},
{"id": "model-checking", "title": "Model Checking", "prerequisites": ["posterior"], "masteryDimension": "mastery", "exerciseReward": "Model-checking unlocked"}
],
"onboarding": {"headline": "Start with a fast visible win", "body": "Read one short orientation, answer one guided question, and leave with your first mastery marker.", "checklist": ["Read the one-screen topic orientation", "Answer one guided exercise", "Write one explanation in your own words"]},
"compliance": {"sources": 2, "attributionRequired": True, "shareAlikeRequired": True, "noncommercialOnly": True, "flags": ["share-alike", "noncommercial", "excluded-third-party-content"]}
}
]
def main(): def main():
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
with SessionLocal() as db: with SessionLocal() as db:
if db.execute(select(UserORM).where(UserORM.username == "wesley")).scalar_one_or_none() is None: if db.execute(select(UserORM).where(UserORM.username == "wesley")).scalar_one_or_none() is None:
db.add(UserORM(username="wesley", password_hash=hash_password("demo-pass"), role="admin", is_active=True)) db.add(UserORM(username="wesley", password_hash=hash_password("demo-pass"), role="admin", is_active=True))
for pack in PACKS:
if db.get(PackORM, pack["id"]) is None:
db.add(PackORM(id=pack["id"], title=pack["title"], subtitle=pack["subtitle"], level=pack["level"], data_json=json.dumps(pack), is_published=True))
db.commit() db.commit()
create_learner(1, "wesley-learner", "Wesley learner") print("Seeded database. Demo user: wesley / demo-pass")
upsert_pack(
PackData( if __name__ == "__main__":
id="wesley-private-pack", main()
title="Wesley Private Pack",
subtitle="Personal pack example.",
level="novice-friendly",
concepts=[
PackConcept(id="intro", title="Intro", prerequisites=[], position=GraphPosition(x=150, y=120)),
PackConcept(id="second", title="Second concept", prerequisites=["intro"], position=GraphPosition(x=420, y=120)),
PackConcept(id="third", title="Third concept", prerequisites=["second"], position=GraphPosition(x=700, y=120), cross_pack_links=[CrossPackLink(source_concept_id="third", target_pack_id="advanced-pack", target_concept_id="adv-1", relationship="next_pack")]),
PackConcept(id="branch", title="Branch concept", prerequisites=["intro"], position=GraphPosition(x=420, y=320)),
],
onboarding={"headline":"Start privately"},
compliance={}
),
submitted_by_user_id=1,
policy_lane="personal",
is_published=True,
)

View File

@ -8,14 +8,27 @@ def process_job(job_id: int):
job = get_evaluator_job(job_id) job = get_evaluator_job(job_id)
if job is None: if job is None:
return return
update_evaluator_job(job_id, "running")
score = 0.78 if len(job.submitted_text.strip()) > 20 else 0.62 score = 0.78 if len(job.submitted_text.strip()) > 20 else 0.62
confidence_hint = 0.72 if len(job.submitted_text.strip()) > 20 else 0.45 confidence_hint = 0.72 if len(job.submitted_text.strip()) > 20 else 0.45
notes = "Prototype evaluator: longer responses scored somewhat higher." notes = "Prototype evaluator: longer responses scored somewhat higher."
update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes, trace={"notes": ["completed"]}) update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes)
state = load_learner_state(job.learner_id) state = load_learner_state(job.learner_id)
state = apply_evidence(state, EvidenceEvent(concept_id=job.concept_id, dimension="mastery", score=score, confidence_hint=confidence_hint, timestamp="2026-03-13T12:00:00+00:00", kind="review", source_id=f"evaluator-job-{job_id}")) state = apply_evidence(state, EvidenceEvent(
concept_id=job.concept_id,
dimension="mastery",
score=score,
confidence_hint=confidence_hint,
timestamp="2026-03-13T12:00:00+00:00",
kind="review",
source_id=f"evaluator-job-{job_id}",
))
save_learner_state(state) save_learner_state(state)
def main(): def main():
print("Didactopus worker scaffold running. In a real deployment this would poll a queue.")
while True: while True:
time.sleep(60) time.sleep(60)
if __name__ == "__main__":
main()

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Didactopus Pack UI</title> <title>Didactopus Production UI Scaffold</title>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</head> </head>
<body> <body>

View File

@ -1,5 +1,5 @@
{ {
"name": "didactopus-live-pack-ui", "name": "didactopus-production-ui",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",

View File

@ -1,207 +1,11 @@
import React, { useEffect, useMemo, useState } from "react"; import React from "react";
import { applyEvidence, buildMasteryMap, claimReadiness, milestoneMessages, progressPercent, recommendNext } from "./engine";
import { loadLearnerState, saveLearnerState, resetLearnerState } from "./storage";
const PACKS = ["/packs/bayes-pack.json", "/packs/stats-pack.json"];
function DomainCard({ domain, selected, onSelect }) {
return (
<button className={`domain-card ${selected ? "selected" : ""}`} onClick={() => onSelect(domain.id)}>
<div className="domain-title">{domain.title}</div>
<div className="domain-subtitle">{domain.subtitle}</div>
<div className="domain-meta">
<span>{domain.level}</span>
<span>{domain.concepts.length} concepts</span>
</div>
</button>
);
}
function NextStepCard({ step, onSimulate }) {
return (
<div className="step-card">
<div className="step-header">
<div>
<h3>{step.title}</h3>
<div className="muted">{step.minutes} minutes</div>
</div>
<div className="reward-pill">{step.reward}</div>
</div>
<p>{step.reason}</p>
<details>
<summary>Why this is recommended</summary>
<ul>
{step.why.map((item, idx) => <li key={idx}>{item}</li>)}
</ul>
</details>
<button className="primary" onClick={() => onSimulate(step)}>Simulate completing this step</button>
</div>
);
}
export default function App() { export default function App() {
const [packs, setPacks] = useState([]);
const [selectedDomainId, setSelectedDomainId] = useState("");
const [learnerName, setLearnerName] = useState("Wesley");
const [domainStates, setDomainStates] = useState({});
const [lastReward, setLastReward] = useState("");
useEffect(() => {
Promise.all(PACKS.map((u) => fetch(u).then((r) => r.json()))).then((loaded) => {
setPacks(loaded);
setSelectedDomainId(loaded[0]?.id || "");
const states = {};
for (const pack of loaded) {
states[pack.id] = loadLearnerState(pack.id);
}
setDomainStates(states);
});
}, []);
const domain = useMemo(() => packs.find((d) => d.id === selectedDomainId) || null, [packs, selectedDomainId]);
const learnerState = domain ? (domainStates[domain.id] || loadLearnerState(domain.id)) : null;
const masteryMap = useMemo(() => domain && learnerState ? buildMasteryMap(learnerState, domain) : [], [learnerState, domain]);
const progress = useMemo(() => domain && learnerState ? progressPercent(learnerState, domain) : 0, [learnerState, domain]);
const recs = useMemo(() => domain && learnerState ? recommendNext(learnerState, domain) : [], [learnerState, domain]);
const milestones = useMemo(() => domain && learnerState ? milestoneMessages(learnerState, domain) : [], [learnerState, domain]);
const readiness = useMemo(() => domain && learnerState ? claimReadiness(learnerState, domain) : {ready:false, mastered:0, avgScore:0, avgConfidence:0}, [learnerState, domain]);
function updateState(domainId, nextState) {
saveLearnerState(domainId, nextState);
setDomainStates((prev) => ({ ...prev, [domainId]: nextState }));
}
function simulateStep(step) {
if (!domain || !learnerState) return;
const timestamp = new Date().toISOString();
const updated = applyEvidence(learnerState, {
concept_id: step.conceptId,
dimension: "mastery",
score: step.scoreHint,
confidence_hint: step.confidenceHint,
timestamp,
kind: "checkpoint",
source_id: `ui-${step.id}`
});
updateState(domain.id, updated);
setLastReward(step.reward);
}
function resetSelectedDomain() {
if (!domain) return;
resetLearnerState(domain.id);
updateState(domain.id, loadLearnerState(domain.id));
setLastReward("");
}
if (!domain || !learnerState) {
return <div className="page"><div className="card">Loading packs...</div></div>;
}
return ( return (
<div className="page"> <div className="page">
<header className="hero"> <div className="card">
<div> <h1>Didactopus productionization scaffold</h1>
<h1>Didactopus learner prototype</h1> <p>This UI scaffold is intended for later connection to evaluator history, learner management, and admin pack management endpoints.</p>
<p>Real pack files, persistent learner state, and live recommendation updates.</p> </div>
</div>
<div className="hero-controls">
<label>
Learner name
<input value={learnerName} onChange={(e) => setLearnerName(e.target.value)} />
</label>
<button onClick={resetSelectedDomain}>Reset selected domain</button>
</div>
</header>
<section className="domain-grid">
{packs.map((d) => <DomainCard key={d.id} domain={d} selected={d.id === domain.id} onSelect={setSelectedDomainId} />)}
</section>
<main className="layout">
<div className="left-col">
<section className="card">
<h2>First-session onboarding</h2>
<h3>{domain.onboarding.headline}</h3>
<p>{domain.onboarding.body}</p>
<p className="muted">Learner: {learnerName || "Unnamed learner"}</p>
<ul>{domain.onboarding.checklist.map((item, idx) => <li key={idx}>{item}</li>)}</ul>
</section>
<section className="card">
<h2>Visible mastery map</h2>
<div className="map-grid">
{masteryMap.map((node) => (
<div key={node.id} className={`map-node ${node.status}`}>
<div className="node-label">{node.label}</div>
<div className="node-status">{node.status}</div>
</div>
))}
</div>
</section>
<section className="card">
<h2>Evidence log</h2>
{learnerState.history.length === 0 ? <div className="muted">No evidence recorded yet.</div> : (
<ul>
{learnerState.history.slice().reverse().map((item, idx) => (
<li key={idx}>{item.concept_id} · score {item.score.toFixed(2)} · confidence hint {item.confidence_hint.toFixed(2)}</li>
))}
</ul>
)}
</section>
</div>
<div className="center-col">
<section className="card">
<h2>What should I do next?</h2>
{recs.length === 0 ? (
<div className="muted">No immediate recommendation available.</div>
) : (
<div className="steps-stack">
{recs.map((step) => <NextStepCard key={step.id} step={step} onSimulate={simulateStep} />)}
</div>
)}
</section>
</div>
<div className="right-col">
<section className="card">
<h2>Progress</h2>
<div className="progress-wrap">
<div className="progress-label">Mastery progress</div>
<div className="progress-bar"><div className="progress-fill" style={{ width: `${progress}%` }} /></div>
<div className="muted">{progress}%</div>
</div>
<div className={`readiness-box ${readiness.ready ? "ready" : ""}`}>
<strong>{readiness.ready ? "Usable expertise threshold met" : "Still building toward usable expertise"}</strong>
<div className="muted">Mastered concepts: {readiness.mastered}</div>
<div className="muted">Average score: {readiness.avgScore.toFixed(2)}</div>
<div className="muted">Average confidence: {readiness.avgConfidence.toFixed(2)}</div>
</div>
</section>
<section className="card">
<h2>Milestones and rewards</h2>
{lastReward ? <div className="reward-banner">{lastReward}</div> : null}
<ul>{milestones.map((m, idx) => <li key={idx}>{m}</li>)}</ul>
</section>
<section className="card">
<h2>Source attribution and compliance</h2>
<div className="compliance-grid">
<div><strong>Sources</strong><br />{domain.compliance.sources}</div>
<div><strong>Attribution</strong><br />{domain.compliance.attributionRequired ? "required" : "not required"}</div>
<div><strong>Share-alike</strong><br />{domain.compliance.shareAlikeRequired ? "yes" : "no"}</div>
<div><strong>Noncommercial</strong><br />{domain.compliance.noncommercialOnly ? "yes" : "no"}</div>
</div>
<div className="flag-row">
{domain.compliance.flags.length ? domain.compliance.flags.map((f) => <span className="flag" key={f}>{f}</span>) : <span className="muted">No extra flags</span>}
</div>
</section>
</div>
</main>
</div> </div>
); );
} }

View File

@ -2,5 +2,4 @@ import React from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import App from "./App"; import App from "./App";
import "./styles.css"; import "./styles.css";
createRoot(document.getElementById("root")).render(<App />); createRoot(document.getElementById("root")).render(<App />);

View File

@ -1,53 +1,3 @@
:root { body { margin:0; font-family:Arial, Helvetica, sans-serif; background:#f6f8fb; color:#1f2430; }
--bg: #f6f8fb; .page { max-width: 1100px; margin: 0 auto; padding: 24px; }
--card: #ffffff; .card { background:white; border:1px solid #dbe1ea; border-radius:18px; padding:20px; }
--text: #1f2430;
--muted: #60697a;
--border: #dbe1ea;
--accent: #2d6cdf;
--soft: #eef4ff;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: var(--bg); color: var(--text); }
.page { max-width: 1500px; margin: 0 auto; padding: 20px; }
.hero { background: var(--card); border: 1px solid var(--border); border-radius: 22px; padding: 24px; display: flex; justify-content: space-between; gap: 16px; }
.hero-controls { min-width: 260px; }
.hero-controls input { width: 100%; margin-top: 6px; border: 1px solid var(--border); border-radius: 10px; padding: 10px; font: inherit; }
.hero-controls button { margin-top: 12px; }
.domain-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-top: 16px; }
.domain-card { border: 1px solid var(--border); background: var(--card); border-radius: 18px; padding: 16px; text-align: left; cursor: pointer; }
.domain-card.selected { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(45,108,223,0.12); }
.domain-title { font-size: 20px; font-weight: 700; }
.domain-subtitle { margin-top: 6px; color: var(--muted); }
.domain-meta { margin-top: 10px; display: flex; gap: 12px; color: var(--muted); font-size: 14px; }
.layout { display: grid; grid-template-columns: 1fr 1.25fr 0.95fr; gap: 16px; margin-top: 16px; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 20px; padding: 18px; }
.muted { color: var(--muted); }
.steps-stack { display: grid; gap: 14px; }
.step-card { border: 1px solid var(--border); border-radius: 16px; padding: 14px; background: #fcfdff; }
.step-header { display: flex; justify-content: space-between; gap: 12px; align-items: start; }
.reward-pill { background: var(--soft); border: 1px solid var(--border); border-radius: 999px; padding: 8px 10px; font-size: 12px; }
button { border: 1px solid var(--border); background: white; border-radius: 12px; padding: 10px 14px; cursor: pointer; }
.primary { margin-top: 10px; background: var(--accent); color: white; border: none; }
.map-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
.map-node { border: 1px solid var(--border); border-radius: 16px; padding: 14px; }
.map-node.mastered { background: #eef9f0; }
.map-node.active, .map-node.available { background: #eef4ff; }
.map-node.locked { background: #f6f7fa; }
.node-label { font-weight: 700; }
.node-status { margin-top: 6px; color: var(--muted); text-transform: capitalize; }
.progress-wrap { margin-bottom: 14px; }
.progress-bar { width: 100%; height: 12px; border-radius: 999px; background: #e9edf4; overflow: hidden; margin: 8px 0; }
.progress-fill { height: 100%; background: var(--accent); }
.reward-banner { background: #fff7dd; border: 1px solid #ecdca2; border-radius: 14px; padding: 12px; margin-bottom: 12px; font-weight: 700; }
.readiness-box { border: 1px solid var(--border); background: #fbfcfe; border-radius: 14px; padding: 12px; }
.readiness-box.ready { background: #eef9f0; }
.compliance-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.flag-row { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 8px; }
.flag { border: 1px solid var(--border); background: #f4f7fc; border-radius: 999px; padding: 6px 10px; font-size: 12px; }
details summary { cursor: pointer; color: var(--accent); }
@media (max-width: 1100px) {
.layout { grid-template-columns: 1fr; }
.domain-grid { grid-template-columns: 1fr; }
.hero { flex-direction: column; }
}