Apply ZIP update: 170-didactopus-auth-db-async-evaluator-prototype.zip [2026-03-14T13:20:10]

This commit is contained in:
welsberr 2026-03-14 13:29:55 -04:00
parent 8939908293
commit d851515201
17 changed files with 713 additions and 580 deletions

View File

@ -6,10 +6,18 @@ 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",
"passlib[bcrypt]>=1.7"
]
[project.scripts] [project.scripts]
didactopus-build-attribution = "didactopus.attribution_builder:main" didactopus-api = "didactopus.api:main"
didactopus-seed-db = "didactopus.seed:main"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]

View File

@ -2,78 +2,77 @@ from __future__ import annotations
from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
import uvicorn import uvicorn
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, LoginResponse, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus
from .repository import ( from .repository import (
authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token, authenticate_user, get_user_by_token, list_packs, get_pack, create_learner,
list_packs_for_user, get_pack, get_pack_row, create_learner, learner_owned_by_user, load_learner_state, save_learner_state, learner_owned_by_user, load_learner_state, save_learner_state,
create_render_job, update_render_job, list_render_jobs, list_artifacts create_evaluator_job, get_evaluator_job, update_evaluator_job
) )
from .auth import issue_access_token, issue_refresh_token, decode_token, new_token_id from .engine import apply_evidence, recommend_next
from .engine import build_graph_frames, stable_layout
from .worker import process_render_job
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()
payload = decode_token(token) if token else None user = get_user_by_token(token) if token else None
if not payload or payload.get("kind") != "access": if user is None:
raise HTTPException(status_code=401, detail="Unauthorized")
user = get_user_by_id(int(payload["sub"]))
if user is None or not user.is_active:
raise HTTPException(status_code=401, detail="Unauthorized") raise HTTPException(status_code=401, detail="Unauthorized")
return user return user
def ensure_learner_access(user, learner_id: str): def ensure_learner_access(user, learner_id: str):
if user.role == "admin":
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): def simulate_evaluator_job(job_id: int):
row = get_pack_row(pack_id) job = get_evaluator_job(job_id)
if row is None: if job is None:
raise HTTPException(status_code=404, detail="Pack not found") return
if user.role == "admin": update_evaluator_job(job_id, "running")
return row score = 0.78 if len(job.submitted_text.strip()) > 20 else 0.62
if row.policy_lane == "community": confidence_hint = 0.72 if len(job.submitted_text.strip()) > 20 else 0.45
return row notes = "Prototype evaluator: longer responses scored somewhat higher."
if row.owner_user_id == user.id: update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes)
return row state = load_learner_state(job.learner_id)
raise HTTPException(status_code=403, detail="Pack not accessible by this user") 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)
@app.post("/api/login", response_model=TokenPair) @app.post("/api/login", response_model=LoginResponse)
def login(payload: LoginRequest): def login(payload: LoginRequest):
user = authenticate_user(payload.username, payload.password) user = authenticate_user(payload.username, payload.password)
if user is None: if user is None:
raise HTTPException(status_code=401, detail="Invalid credentials") raise HTTPException(status_code=401, detail="Invalid credentials")
token_id = new_token_id() return LoginResponse(token=user.token, username=user.username)
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)
@app.post("/api/refresh", response_model=TokenPair)
def refresh(payload: RefreshRequest):
data = decode_token(payload.refresh_token)
if not data or data.get("kind") != "refresh":
raise HTTPException(status_code=401, detail="Invalid refresh token")
token_id = data.get("jti")
if not token_id or not refresh_token_active(token_id):
raise HTTPException(status_code=401, detail="Refresh token inactive")
user = get_user_by_id(int(data["sub"]))
if user is None:
raise HTTPException(status_code=401, detail="User not found")
revoke_refresh_token(token_id)
new_jti = new_token_id()
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)
@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"))] return [p.model_dump() for p in list_packs()]
@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/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)):
@ -92,54 +91,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-jobs/{pack_id}") @app.get("/api/learners/{learner_id}/recommendations/{pack_id}")
def api_render_job(learner_id: str, pack_id: str, payload: MediaRenderRequest, background_tasks: BackgroundTasks, 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),
}
job_id = create_render_job(learner_id, pack_id, payload.format, payload.fps, payload.theme)
background_tasks.add_task(process_render_job, job_id, learner_id, pack_id, payload.format, payload.fps, payload.theme, animation)
return {"job_id": job_id, "status": "queued"}
@app.get("/api/render-jobs") @app.post("/api/learners/{learner_id}/evaluator-jobs", response_model=EvaluatorJobStatus)
def api_list_render_jobs(learner_id: str | None = None, user = Depends(current_user)): def api_submit_evaluator_job(learner_id: str, payload: EvaluatorSubmission, background_tasks: BackgroundTasks, user = Depends(current_user)):
if learner_id:
ensure_learner_access(user, learner_id) ensure_learner_access(user, learner_id)
return list_render_jobs(learner_id) job_id = create_evaluator_job(learner_id, payload.pack_id, payload.concept_id, payload.submitted_text)
background_tasks.add_task(simulate_evaluator_job, job_id)
return EvaluatorJobStatus(job_id=job_id, status="queued")
@app.get("/api/artifacts") @app.get("/api/evaluator-jobs/{job_id}", response_model=EvaluatorJobStatus)
def api_list_artifacts(learner_id: str | None = None, user = Depends(current_user)): def api_get_evaluator_job(job_id: int, user = Depends(current_user)):
if learner_id: job = get_evaluator_job(job_id)
ensure_learner_access(user, learner_id) if job is None:
return list_artifacts(learner_id) raise HTTPException(status_code=404, detail="Job not found")
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,
)
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

@ -1,12 +1,8 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone
from jose import jwt, JWTError
from passlib.context import CryptContext
import secrets import secrets
from .config import load_settings from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
settings = load_settings()
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
return pwd_context.hash(password) return pwd_context.hash(password)
@ -14,22 +10,5 @@ def hash_password(password: str) -> str:
def verify_password(password: str, password_hash: str) -> bool: def verify_password(password: str, password_hash: str) -> bool:
return pwd_context.verify(password, password_hash) return pwd_context.verify(password, password_hash)
def _encode_token(payload: dict, expires_delta: timedelta) -> str: def issue_token() -> str:
to_encode = dict(payload)
to_encode["exp"] = datetime.now(timezone.utc) + expires_delta
return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
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))
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))
def decode_token(token: str) -> dict | None:
try:
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
except JWTError:
return None
def new_token_id() -> str:
return secrets.token_urlsafe(24) return secrets.token_urlsafe(24)

View File

@ -1,13 +1,10 @@
from __future__ import annotations from pathlib import Path
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 = "sqlite:///./didactopus.db"
host: str = os.getenv("DIDACTOPUS_HOST", "127.0.0.1") host: str = "127.0.0.1"
port: int = int(os.getenv("DIDACTOPUS_PORT", "8011")) port: int = 8011
jwt_secret: str = os.getenv("DIDACTOPUS_JWT_SECRET", "change-me")
jwt_algorithm: str = "HS256"
def load_settings() -> Settings: def load_settings() -> Settings:
return Settings() return Settings()

View File

@ -3,6 +3,7 @@ from sqlalchemy.orm import declarative_base, sessionmaker
from .config import load_settings from .config import load_settings
settings = load_settings() settings = load_settings()
engine = create_engine(settings.database_url, future=True) connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
engine = create_engine(settings.database_url, future=True, connect_args=connect_args)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
Base = declarative_base() Base = declarative_base()

View File

@ -1,110 +1,97 @@
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(
depths = concept_depths(pack) state: LearnerState,
layers = defaultdict(list) event: EvidenceEvent,
for c in pack.concepts: decay: float = 0.05,
layers[depths.get(c.id, 0)].append(c) reinforcement: float = 0.25,
positions = {} ) -> LearnerState:
max_depth = max(layers.keys()) if layers else 0 rec = get_record(state, event.concept_id, event.dimension)
for d in sorted(layers): if rec is None:
nodes = sorted(layers[d], key=lambda c: c.id) rec = MasteryRecord(
y = 90 + d * ((height - 160) / max(1, max_depth)) concept_id=event.concept_id,
for idx, node in enumerate(nodes): dimension=event.dimension,
if node.position is not None: score=0.0,
positions[node.id] = {"x": node.position.x, "y": node.position.y, "source": "pack_authored"} confidence=0.0,
else: evidence_count=0,
spacing = width / (len(nodes) + 1) last_updated=event.timestamp,
x = spacing * (idx + 1) )
positions[node.id] = {"x": x, "y": y, "source": "auto_layered"} state.records.append(rec)
return positions
def prereqs_satisfied(scores: dict[str, float], concept, min_score: float = 0.65) -> bool: weight = max(0.05, min(1.0, event.confidence_hint))
rec.score = ((rec.score * rec.evidence_count) + (event.score * weight)) / max(1, rec.evidence_count + 1)
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))),
)
rec.evidence_count += 1
rec.last_updated = event.timestamp
state.history.append(event)
return state
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": (
"relationship": link.relationship, "Prerequisites are satisfied, so this is the best next unlock."
"kind": "cross_pack" if status == "available"
} for c in pack.concepts for link in c.cross_pack_links] else "You have started this concept, but mastery is not yet secure."
for idx, ev in enumerate(history): ),
if ev.concept_id in scores: "why": [
scores[ev.concept_id] = ev.score "Prerequisite check passed",
nodes = [] f"Current score: {rec.score:.2f}" if rec else "No evidence recorded yet",
for cid, concept in concepts.items(): f"Current confidence: {rec.confidence:.2f}" if rec else "Confidence starts after your first exercise",
score = scores.get(cid, 0.0) ],
status = concept_status(scores, concept) "reward": concept.exerciseReward or f"{concept.title} progress recorded",
pos = layout[cid] "conceptId": concept.id,
nodes.append({ "scoreHint": 0.82 if status == "available" else 0.76,
"id": cid, "confidenceHint": 0.72 if status == "available" else 0.55,
"title": concept.title,
"score": score,
"status": status,
"size": 20 + int(score * 30),
"x": pos["x"],
"y": pos["y"],
"layout_source": pos["source"],
}) })
frames.append({ for rec in state.records:
"index": idx, if rec.dimension == "mastery" and rec.confidence < 0.40:
"timestamp": ev.timestamp, concept = next((c for c in pack.concepts if c.id == rec.concept_id), None)
"event_kind": ev.kind, if concept:
"focus_concept_id": ev.concept_id, cards.append({
"nodes": nodes, "id": f"{concept.id}-reinforce",
"edges": static_edges, "title": f"Reinforce {concept.title}",
"cross_pack_links": static_cross, "minutes": 8,
"reason": "Your score is promising, but confidence is still thin.",
"why": [
f"Confidence {rec.confidence:.2f} is below reinforcement threshold",
"A small fresh exercise can stabilize recall",
],
"reward": "Confidence ring grows",
"conceptId": concept.id,
"scoreHint": max(0.60, rec.score),
"confidenceHint": 0.30,
}) })
if not frames: return cards[:4]
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,29 +1,8 @@
from __future__ import annotations from __future__ import annotations
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Literal
class TokenPair(BaseModel): EvidenceKind = Literal["checkpoint", "project", "exercise", "review"]
access_token: str
refresh_token: str
token_type: str = "bearer"
username: str
role: str
class LoginRequest(BaseModel):
username: str
password: str
class RefreshRequest(BaseModel):
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
@ -31,8 +10,13 @@ class PackConcept(BaseModel):
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 +25,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 +41,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 +49,27 @@ 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 LoginRequest(BaseModel):
username: str
password: str
class LoginResponse(BaseModel):
token: str
username: str
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 = ""

View File

@ -1,5 +1,5 @@
from sqlalchemy import String, Integer, Float, ForeignKey, Text, Boolean from sqlalchemy import String, Integer, Float, Boolean, ForeignKey, Text
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):
@ -7,32 +7,22 @@ class UserORM(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(100), unique=True, index=True) username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255)) password_hash: Mapped[str] = mapped_column(String(255))
role: Mapped[str] = mapped_column(String(50), default="learner") token: Mapped[str] = mapped_column(String(255), unique=True, index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
class RefreshTokenORM(Base):
__tablename__ = "refresh_tokens"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
token_id: Mapped[str] = mapped_column(String(255), unique=True, index=True)
is_revoked: Mapped[bool] = mapped_column(Boolean, default=False)
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)
class LearnerORM(Base): class LearnerORM(Base):
__tablename__ = "learners" __tablename__ = "learners"
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] = mapped_column(ForeignKey("users.id"), index=True) owner_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
display_name: Mapped[str] = mapped_column(String(255), default="") display_name: Mapped[str] = mapped_column(String(255), default="")
owner = relationship("UserORM")
class MasteryRecordORM(Base): class MasteryRecordORM(Base):
__tablename__ = "mastery_records" __tablename__ = "mastery_records"
@ -44,6 +34,7 @@ class MasteryRecordORM(Base):
confidence: Mapped[float] = mapped_column(Float, default=0.0) confidence: Mapped[float] = mapped_column(Float, default=0.0)
evidence_count: Mapped[int] = mapped_column(Integer, default=0) evidence_count: Mapped[int] = mapped_column(Integer, default=0)
last_updated: Mapped[str] = mapped_column(String(100), default="") last_updated: Mapped[str] = mapped_column(String(100), default="")
learner = relationship("LearnerORM")
class EvidenceEventORM(Base): class EvidenceEventORM(Base):
__tablename__ = "evidence_events" __tablename__ = "evidence_events"
@ -56,30 +47,18 @@ 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="")
learner = relationship("LearnerORM")
class RenderJobORM(Base): class EvaluatorJobORM(Base):
__tablename__ = "render_jobs" __tablename__ = "evaluator_jobs"
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
learner_id: Mapped[str] = mapped_column(String(100), index=True) learner_id: Mapped[str] = mapped_column(ForeignKey("learners.id"), index=True)
pack_id: Mapped[str] = mapped_column(String(100), index=True) pack_id: Mapped[str] = mapped_column(ForeignKey("packs.id"), index=True)
requested_format: Mapped[str] = mapped_column(String(20), default="gif") concept_id: Mapped[str] = mapped_column(String(100), index=True)
fps: Mapped[int] = mapped_column(Integer, default=2) submitted_text: Mapped[str] = mapped_column(Text, default="")
theme: Mapped[str] = mapped_column(String(100), default="default")
status: Mapped[str] = mapped_column(String(50), default="queued") status: Mapped[str] = mapped_column(String(50), default="queued")
bundle_dir: Mapped[str] = mapped_column(Text, default="") result_score: Mapped[float | None] = mapped_column(Float, nullable=True)
payload_json: Mapped[str] = mapped_column(Text, default="") result_confidence_hint: Mapped[float | None] = mapped_column(Float, nullable=True)
manifest_path: Mapped[str] = mapped_column(Text, default="") result_notes: Mapped[str] = mapped_column(Text, default="")
script_path: Mapped[str] = mapped_column(Text, default="") learner = relationship("LearnerORM")
error_text: Mapped[str] = mapped_column(Text, default="") pack = relationship("PackORM")
class ArtifactORM(Base):
__tablename__ = "artifacts"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
render_job_id: Mapped[int] = mapped_column(ForeignKey("render_jobs.id"), index=True)
learner_id: Mapped[str] = mapped_column(String(100), index=True)
pack_id: Mapped[str] = mapped_column(String(100), index=True)
artifact_type: Mapped[str] = mapped_column(String(50), default="render_bundle")
format: Mapped[str] = mapped_column(String(20), default="gif")
title: Mapped[str] = mapped_column(String(255), default="")
path: Mapped[str] = mapped_column(Text, default="")
metadata_json: Mapped[str] = mapped_column(Text, default="{}")

View File

@ -2,92 +2,36 @@ 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, 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
def get_user_by_username(username: str): def get_user_by_token(token: str) -> UserORM | None:
with SessionLocal() as db: with SessionLocal() as db:
return db.execute(select(UserORM).where(UserORM.username == username)).scalar_one_or_none() return db.execute(select(UserORM).where(UserORM.token == token)).scalar_one_or_none()
def get_user_by_id(user_id: int): def authenticate_user(username: str, password: str) -> UserORM | None:
with SessionLocal() as db: with SessionLocal() as db:
return db.get(UserORM, user_id) user = db.execute(select(UserORM).where(UserORM.username == username)).scalar_one_or_none()
if user is None:
def authenticate_user(username: str, password: str): return None
user = get_user_by_username(username) if not verify_password(password, user.password_hash):
if user is None or not verify_password(password, user.password_hash) or not user.is_active:
return None return None
return user return user
def store_refresh_token(user_id: int, token_id: str): def list_packs() -> list[PackData]:
with SessionLocal() as db: with SessionLocal() as db:
db.add(RefreshTokenORM(user_id=user_id, token_id=token_id, is_revoked=False)) rows = db.execute(select(PackORM)).scalars().all()
db.commit() return [PackData.model_validate(json.loads(r.data_json)) for r in rows]
def refresh_token_active(token_id: str) -> bool: def get_pack(pack_id: str) -> PackData | None:
with SessionLocal() as db:
row = db.execute(select(RefreshTokenORM).where(RefreshTokenORM.token_id == token_id)).scalar_one_or_none()
return row is not None and not row.is_revoked
def revoke_refresh_token(token_id: str):
with SessionLocal() as db:
row = db.execute(select(RefreshTokenORM).where(RefreshTokenORM.token_id == token_id)).scalar_one_or_none()
if row:
row.is_revoked = True
db.commit()
def list_packs_for_user(user_id: int | None = None, include_unpublished: bool = False):
with SessionLocal() as db:
stmt = select(PackORM)
if not include_unpublished:
stmt = stmt.where(PackORM.is_published == True)
rows = db.execute(stmt).scalars().all()
out = []
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):
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))
def get_pack_row(pack_id: str):
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:
row = db.get(PackORM, pack.id)
payload = json.dumps(pack.model_dump())
if row is None: if row is None:
row = PackORM( return None
id=pack.id, return PackData.model_validate(json.loads(row.data_json))
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:
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.subtitle = pack.subtitle
row.level = pack.level
row.data_json = payload
if policy_lane == "personal":
row.is_published = is_published
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 = "") -> None:
with SessionLocal() as db: with SessionLocal() as db:
if db.get(LearnerORM, learner_id) is None: if db.get(LearnerORM, learner_id) is None:
db.add(LearnerORM(id=learner_id, owner_user_id=owner_user_id, display_name=display_name)) db.add(LearnerORM(id=learner_id, owner_user_id=owner_user_id, display_name=display_name))
@ -98,23 +42,89 @@ 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()
return LearnerState( return LearnerState(
learner_id=learner_id, learner_id=learner_id,
records=[MasteryRecord(concept_id=r.concept_id, dimension=r.dimension, score=r.score, confidence=r.confidence, evidence_count=r.evidence_count, last_updated=r.last_updated) for r in records], records=[
history=[EvidenceEvent(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) for h in history], MasteryRecord(
concept_id=r.concept_id,
dimension=r.dimension,
score=r.score,
confidence=r.confidence,
evidence_count=r.evidence_count,
last_updated=r.last_updated,
) for r in records
],
history=[
EvidenceEvent(
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,
) for h in history
]
) )
def save_learner_state(state: LearnerState): def save_learner_state(state: LearnerState) -> LearnerState:
with SessionLocal() as db: with SessionLocal() as db:
db.execute(select(LearnerORM).where(LearnerORM.id == state.learner_id))
db.query(MasteryRecordORM).filter(MasteryRecordORM.learner_id == state.learner_id).delete() db.query(MasteryRecordORM).filter(MasteryRecordORM.learner_id == state.learner_id).delete()
db.query(EvidenceEventORM).filter(EvidenceEventORM.learner_id == state.learner_id).delete() db.query(EvidenceEventORM).filter(EvidenceEventORM.learner_id == state.learner_id).delete()
for r in state.records: for r in state.records:
db.add(MasteryRecordORM(learner_id=state.learner_id, concept_id=r.concept_id, dimension=r.dimension, score=r.score, confidence=r.confidence, evidence_count=r.evidence_count, last_updated=r.last_updated)) db.add(MasteryRecordORM(
learner_id=state.learner_id,
concept_id=r.concept_id,
dimension=r.dimension,
score=r.score,
confidence=r.confidence,
evidence_count=r.evidence_count,
last_updated=r.last_updated,
))
for h in state.history: for h in state.history:
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 get_evaluator_job(job_id: int) -> EvaluatorJobORM | None:
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 = "") -> None:
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,79 @@
from __future__ import annotations from __future__ import annotations
from sqlalchemy import select import json
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, issue_token
from .repository import upsert_pack, create_learner from sqlalchemy import select
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"]
}
},
{
"id": "stats-pack",
"title": "Introductory Statistics",
"subtitle": "Descriptive statistics, sampling, and inference.",
"level": "novice-friendly",
"concepts": [
{"id": "descriptive", "title": "Descriptive Statistics", "prerequisites": [], "masteryDimension": "mastery", "exerciseReward": "Descriptive tools unlocked"},
{"id": "sampling", "title": "Sampling", "prerequisites": ["descriptive"], "masteryDimension": "mastery", "exerciseReward": "Sampling pathway opened"},
{"id": "inference", "title": "Inference", "prerequisites": ["sampling"], "masteryDimension": "mastery", "exerciseReward": "Inference challenge unlocked"}
],
"onboarding": {
"headline": "Build your first useful data skill",
"body": "You will learn one concept that immediately helps you summarize real data.",
"checklist": [
"See one worked example",
"Compute one short example yourself",
"Explain what the result means"
]
},
"compliance": {
"sources": 1,
"attributionRequired": True,
"shareAlikeRequired": False,
"noncommercialOnly": False,
"flags": []
}
}
]
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: existing = db.execute(select(UserORM).where(UserORM.username == "wesley")).scalar_one_or_none()
db.add(UserORM(username="wesley", password_hash=hash_password("demo-pass"), role="admin", is_active=True)) if existing is None:
db.add(UserORM(username="wesley", password_hash=hash_password("demo-pass"), token=issue_token()))
for pack in PACKS:
row = db.get(PackORM, pack["id"])
if row is None:
db.add(PackORM(id=pack["id"], title=pack["title"], subtitle=pack["subtitle"], level=pack["level"], data_json=json.dumps(pack)))
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

@ -3,8 +3,10 @@
<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 Artifact Registry</title> <title>Didactopus Auth API UI</title>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</head> </head>
<body><div id="root"></div></body> <body>
<div id="root"></div>
</body>
</html> </html>

View File

@ -1,9 +1,17 @@
{ {
"name": "didactopus-artifact-registry-ui", "name": "didactopus-auth-api-ui",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "dev": "vite", "build": "vite build" }, "scripts": {
"dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" }, "dev": "vite",
"devDependencies": { "vite": "^5.4.0" } "build": "vite build"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"vite": "^5.4.0"
}
} }

View File

@ -1,150 +1,235 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { login, refresh, fetchPacks, fetchLearnerState, putLearnerState, createRenderJob, listRenderJobs, listArtifacts } from "./api"; import { login, fetchPacks, createLearner, fetchLearnerState, fetchRecommendations, postEvidence, submitEvaluatorJob, fetchEvaluatorJob } from "./api";
import { loadAuth, saveAuth, clearAuth } from "./authStore"; import { buildMasteryMap, progressPercent } from "./localEngine";
function LoginView({ onAuth }) { function LoginPanel({ onLogin }) {
const [username, setUsername] = useState("wesley"); const [username, setUsername] = useState("wesley");
const [password, setPassword] = useState("demo-pass"); const [password, setPassword] = useState("demo-pass");
const [error, setError] = useState(""); const [error, setError] = useState("");
async function doLogin() {
async function handleLogin() {
try { try {
const result = await login(username, password); setError("");
saveAuth(result); const result = await onLogin(username, password);
onAuth(result); return result;
} catch { setError("Login failed"); } } catch (e) {
setError("Login failed");
} }
}
return ( return (
<div className="page narrow-page">
<section className="card narrow"> <section className="card narrow">
<h1>Didactopus login</h1> <h1>Didactopus login</h1>
<label>Username<input value={username} onChange={(e) => setUsername(e.target.value)} /></label> <label>Username<input value={username} onChange={(e) => setUsername(e.target.value)} /></label>
<label>Password<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} /></label> <label>Password<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} /></label>
<button className="primary" onClick={doLogin}>Login</button> <button className="primary" onClick={handleLogin}>Login</button>
{error ? <div className="error">{error}</div> : null} {error ? <div className="error">{error}</div> : null}
</section> </section>
);
}
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>
</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>
<div className="button-row">
<button className="primary" onClick={() => onSimulate(step)}>Simulate step</button>
</div>
</div> </div>
); );
} }
export default function App() { export default function App() {
const [auth, setAuth] = useState(loadAuth()); const [token, setToken] = useState("");
const [username, setUsername] = useState("");
const [packs, setPacks] = useState([]); const [packs, setPacks] = useState([]);
const [learnerId] = useState("wesley-learner"); const [selectedDomainId, setSelectedDomainId] = useState("");
const [packId, setPackId] = useState(""); const [learnerId, setLearnerId] = useState("wesley-learner");
const [jobs, setJobs] = useState([]); const [learnerState, setLearnerState] = useState(null);
const [artifacts, setArtifacts] = useState([]); const [cards, setCards] = useState([]);
const [format, setFormat] = useState("gif"); const [jobStatus, setJobStatus] = useState(null);
const [fps, setFps] = useState(2);
const [message, setMessage] = useState("");
async function refreshAuthToken() { async function handleLogin(user, pass) {
if (!auth?.refresh_token) return null; const auth = await login(user, pass);
try { setToken(auth.token);
const result = await refresh(auth.refresh_token); setUsername(auth.username);
saveAuth(result); await createLearner(auth.token, learnerId, learnerId);
setAuth(result); const loadedPacks = await fetchPacks(auth.token);
return result; setPacks(loadedPacks);
} catch { setSelectedDomainId(loadedPacks[0]?.id || "");
clearAuth(); const state = await fetchLearnerState(auth.token, learnerId);
setAuth(null); setLearnerState(state);
return null; return auth;
}
}
async function guarded(fn) {
try { return await fn(auth.access_token); }
catch {
const next = await refreshAuthToken();
if (!next) throw new Error("auth failed");
return await fn(next.access_token);
}
}
async function reloadLists() {
setJobs(await guarded((token) => listRenderJobs(token, learnerId)));
setArtifacts(await guarded((token) => listArtifacts(token, learnerId)));
} }
useEffect(() => { useEffect(() => {
if (!auth) return; async function loadCards() {
async function load() { if (!token || !selectedDomainId) return;
const p = await guarded((token) => fetchPacks(token)); const data = await fetchRecommendations(token, learnerId, selectedDomainId);
setPacks(p); setCards(data.cards || []);
setPackId(p[0]?.id || "");
await reloadLists();
} }
load(); loadCards();
}, [auth]); }, [token, learnerId, selectedDomainId, learnerState]);
async function generateDemo() { const domain = useMemo(() => packs.find((d) => d.id === selectedDomainId) || null, [packs, selectedDomainId]);
let state = await guarded((token) => fetchLearnerState(token, learnerId)); const masteryMap = useMemo(() => domain && learnerState ? buildMasteryMap(learnerState, domain) : [], [learnerState, domain]);
const base = Date.now() const progress = useMemo(() => domain && learnerState ? progressPercent(learnerState, domain) : 0, [learnerState, domain]);
const events = [
["intro", 0.30, "exercise", 0], async function simulateStep(step) {
["intro", 0.78, "review", 1000], const nextState = await postEvidence(token, learnerId, {
["second", 0.42, "exercise", 2000], concept_id: step.conceptId,
["second", 0.72, "review", 3000], dimension: "mastery",
["third", 0.25, "exercise", 4000], score: step.scoreHint,
["branch", 0.60, "exercise", 5000], confidence_hint: step.confidenceHint,
]; timestamp: new Date().toISOString(),
const latest = {} kind: "checkpoint",
for (const [cid, score, kind, offset] of events) { source_id: `ui-${step.id}`
const ts = new Date(base + offset).toISOString(); });
state.history.push({ concept_id: cid, dimension: "mastery", score, confidence_hint: 0.6, timestamp: ts, kind, source_id: `demo-${cid}-${offset}` }); setLearnerState(nextState);
latest[cid] = { concept_id: cid, dimension: "mastery", score, confidence: Math.min(0.9, score), evidence_count: (latest[cid]?.evidence_count || 0) + 1, last_updated: ts };
}
state.records = Object.values(latest);
await guarded((token) => putLearnerState(token, learnerId, state));
setMessage("Demo state generated.");
} }
async function createJob() { async function submitDemoEvaluator() {
const result = await guarded((token) => createRenderJob(token, learnerId, packId, { learner_id: learnerId, pack_id: packId, format, fps, theme: "default" })); if (!domain) return;
setMessage(`Render job ${result.job_id} queued.`); const firstConcept = domain.concepts[0]?.id;
setTimeout(() => reloadLists(), 500); const job = await submitEvaluatorJob(token, learnerId, {
pack_id: domain.id,
concept_id: firstConcept,
submitted_text: "This is a moderately detailed learner response intended to trigger a somewhat better prototype evaluator score.",
kind: "checkpoint"
});
setJobStatus(job);
setTimeout(async () => {
const refreshed = await fetchEvaluatorJob(token, job.job_id);
setJobStatus(refreshed);
const state = await fetchLearnerState(token, learnerId);
setLearnerState(state);
}, 1200);
} }
if (!auth) return <LoginView onAuth={setAuth} />; if (!token) {
return <div className="page"><LoginPanel onLogin={handleLogin} /></div>;
}
if (!domain || !learnerState) {
return <div className="page"><div className="card">Loading authenticated learner view...</div></div>;
}
return ( return (
<div className="page"> <div className="page">
<header className="hero"> <header className="hero">
<div> <div>
<h1>Didactopus artifact registry</h1> <h1>Didactopus learner prototype</h1>
<p>Track render jobs and produced media artifacts as first-class Didactopus objects.</p> <p>Authenticated multi-user scaffold with DB-backed state and async evaluator jobs.</p>
<div className="muted">{message}</div> <div className="muted">Signed in as {username}</div>
</div> </div>
<div className="controls"> <div className="hero-controls">
<label>Pack <label>Learner ID<input value={learnerId} onChange={(e) => setLearnerId(e.target.value)} /></label>
<select value={packId} onChange={(e) => setPackId(e.target.value)}> <button onClick={submitDemoEvaluator}>Submit demo evaluator job</button>
{packs.map((p) => <option key={p.id} value={p.id}>{p.title}</option>)}
</select>
</label>
<label>Format
<select value={format} onChange={(e) => setFormat(e.target.value)}>
<option value="gif">GIF</option>
<option value="mp4">MP4</option>
</select>
</label>
<label>FPS
<input type="number" value={fps} onChange={(e) => setFps(Number(e.target.value || 2))} />
</label>
<button onClick={generateDemo}>Generate demo state</button>
<button onClick={createJob}>Create render job</button>
<button onClick={reloadLists}>Refresh lists</button>
<button onClick={() => { clearAuth(); setAuth(null); }}>Logout</button>
</div> </div>
</header> </header>
<main className="layout twocol"> <section className="domain-grid">
<section className="card"> {packs.map((d) => <DomainCard key={d.id} domain={d} selected={d.id === domain.id} onSelect={setSelectedDomainId} />)}
<h2>Render jobs</h2>
<pre className="prebox">{JSON.stringify(jobs, null, 2)}</pre>
</section> </section>
<main className="layout">
<div className="left-col">
<section className="card"> <section className="card">
<h2>Artifacts</h2> <h2>Onboarding</h2>
<pre className="prebox">{JSON.stringify(artifacts, null, 2)}</pre> <h3>{domain.onboarding.headline}</h3>
<p>{domain.onboarding.body}</p>
<ul>{(domain.onboarding.checklist || []).map((item, idx) => <li key={idx}>{item}</li>)}</ul>
</section> </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>Async evaluator job</h2>
{jobStatus ? (
<div>
<div><strong>Status:</strong> {jobStatus.status}</div>
<div><strong>Score:</strong> {jobStatus.result_score ?? "-"}</div>
<div><strong>Confidence hint:</strong> {jobStatus.result_confidence_hint ?? "-"}</div>
<div className="muted">{jobStatus.result_notes || ""}</div>
</div>
) : (
<div className="muted">No evaluator job submitted yet.</div>
)}
</section>
</div>
<div className="center-col">
<section className="card">
<h2>What should I do next?</h2>
{cards.length === 0 ? <div className="muted">No immediate recommendation available.</div> : (
<div className="steps-stack">
{cards.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>
</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)} · hint {item.confidence_hint.toFixed(2)} · {item.kind}</li>
))}
</ul>
)}
</section>
<section className="card">
<h2>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>
</section>
</div>
</main> </main>
</div> </div>
); );

View File

@ -1,24 +1,62 @@
const API = "http://127.0.0.1:8011/api"; const API = "http://127.0.0.1:8011/api";
function authHeaders(token, json=true) { export async function login(username, password) {
const h = { Authorization: `Bearer ${token}` }; const res = await fetch(`${API}/login`, {
if (json) h["Content-Type"] = "application/json"; method: "POST",
return h; headers: {"Content-Type": "application/json"},
body: JSON.stringify({ username, password })
});
if (!res.ok) throw new Error("Login failed");
return await res.json();
} }
export async function login(username, password) { function authHeaders(token) {
const res = await fetch(`${API}/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }) }); return { "Content-Type": "application/json", "Authorization": `Bearer ${token}` };
if (!res.ok) throw new Error("login failed"); }
export async function fetchPacks(token) {
const res = await fetch(`${API}/packs`, { headers: authHeaders(token) });
return await res.json(); return await res.json();
} }
export async function refresh(refreshToken) {
const res = await fetch(`${API}/refresh`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: refreshToken }) }); export async function createLearner(token, learnerId, displayName) {
if (!res.ok) throw new Error("refresh failed"); const res = await fetch(`${API}/learners`, {
method: "POST",
headers: authHeaders(token),
body: JSON.stringify({ learner_id: learnerId, display_name: displayName })
});
return await res.json();
}
export async function fetchLearnerState(token, learnerId) {
const res = await fetch(`${API}/learners/${learnerId}/state`, { headers: authHeaders(token) });
return await res.json();
}
export async function postEvidence(token, learnerId, event) {
const res = await fetch(`${API}/learners/${learnerId}/evidence`, {
method: "POST",
headers: authHeaders(token),
body: JSON.stringify(event)
});
return await res.json();
}
export async function fetchRecommendations(token, learnerId, packId) {
const res = await fetch(`${API}/learners/${learnerId}/recommendations/${packId}`, { headers: authHeaders(token) });
return await res.json();
}
export async function submitEvaluatorJob(token, learnerId, payload) {
const res = await fetch(`${API}/learners/${learnerId}/evaluator-jobs`, {
method: "POST",
headers: authHeaders(token),
body: JSON.stringify(payload)
});
return await res.json();
}
export async function fetchEvaluatorJob(token, jobId) {
const res = await fetch(`${API}/evaluator-jobs/${jobId}`, { headers: authHeaders(token) });
return await res.json(); return await res.json();
} }
export async function fetchPacks(token) { const res = await fetch(`${API}/packs`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPacks failed"); return await res.json(); }
export async function fetchLearnerState(token, learnerId) { const res = await fetch(`${API}/learners/${learnerId}/state`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchLearnerState failed"); return await res.json(); }
export async function putLearnerState(token, learnerId, state) { const res = await fetch(`${API}/learners/${learnerId}/state`, { method: "PUT", headers: authHeaders(token), body: JSON.stringify(state) }); if (!res.ok) throw new Error("putLearnerState failed"); return await res.json(); }
export async function createRenderJob(token, learnerId, packId, payload) { const res = await fetch(`${API}/learners/${learnerId}/render-jobs/${packId}`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("createRenderJob failed"); return await res.json(); }
export async function listRenderJobs(token, learnerId) { const res = await fetch(`${API}/render-jobs?learner_id=${encodeURIComponent(learnerId)}`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listRenderJobs failed"); return await res.json(); }
export async function listArtifacts(token, learnerId) { const res = await fetch(`${API}/artifacts?learner_id=${encodeURIComponent(learnerId)}`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listArtifacts failed"); return await res.json(); }

View File

@ -22,27 +22,3 @@ export function progressPercent(state, domain) {
const mastered = domain.concepts.filter((c) => conceptStatus(state, c) === "mastered").length; const mastered = domain.concepts.filter((c) => conceptStatus(state, c) === "mastered").length;
return Math.round((mastered / total) * 100); return Math.round((mastered / total) * 100);
} }
export function milestoneMessages(state, domain) {
const msgs = [];
for (const concept of domain.concepts) {
if (conceptStatus(state, concept) === "mastered") msgs.push(`${concept.title} mastered`);
}
if (msgs.length === 0) msgs.push("Complete your first guided exercise to earn a visible mastery marker");
return msgs;
}
export function claimReadiness(state, domain, minScore = 0.75, minConfidence = 0.60) {
const records = domain.concepts
.map((c) => getRecord(state, c.id, c.masteryDimension || "mastery"))
.filter(Boolean);
const mastered = records.filter((r) => r.score >= minScore && r.confidence >= minConfidence).length;
const avgScore = records.length ? records.reduce((a, r) => a + r.score, 0) / records.length : 0;
const avgConfidence = records.length ? records.reduce((a, r) => a + r.confidence, 0) / records.length : 0;
return {
ready: mastered >= Math.max(1, domain.concepts.length - 1) && avgScore >= minScore && avgConfidence >= minConfidence,
mastered,
avgScore,
avgConfidence
};
}

View File

@ -2,4 +2,5 @@ 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,23 +1,52 @@
:root { :root {
--bg:#f6f8fb; --card:#ffffff; --text:#1f2430; --muted:#60697a; --border:#dbe1ea; --accent:#2d6cdf; --bg: #f6f8fb;
--card: #ffffff;
--text: #1f2430;
--muted: #60697a;
--border: #dbe1ea;
--accent: #2d6cdf;
--soft: #eef4ff;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: var(--bg); color: var(--text); } body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: var(--bg); color: var(--text); }
.page { max-width:1500px; margin:0 auto; padding:24px; } .page { max-width: 1500px; margin: 0 auto; padding: 20px; }
.narrow-page { max-width:520px; } .hero { background: var(--card); border: 1px solid var(--border); border-radius: 22px; padding: 24px; display: flex; justify-content: space-between; gap: 16px; }
.hero { background:var(--card); border:1px solid var(--border); border-radius:22px; padding:24px; display:flex; justify-content:space-between; gap:16px; align-items:flex-start; } .hero-controls { min-width: 260px; }
.controls { display:flex; gap:10px; align-items:flex-end; flex-wrap:wrap; } .hero-controls input { width: 100%; margin-top: 6px; border: 1px solid var(--border); border-radius: 10px; padding: 10px; font: inherit; }
label { display:block; font-weight:600; } .domain-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-top: 16px; }
input, select { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; } .domain-card { border: 1px solid var(--border); background: var(--card); border-radius: 18px; padding: 16px; text-align: left; cursor: pointer; }
button { border:1px solid var(--border); background:white; border-radius:12px; padding:10px 14px; cursor:pointer; } .domain-card.selected { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(45,108,223,0.12); }
.card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; } .domain-title { font-size: 20px; font-weight: 700; }
.narrow { margin-top:60px; } .domain-subtitle { margin-top: 6px; color: var(--muted); }
.layout { display:grid; gap:16px; } .domain-meta { margin-top: 10px; display: flex; gap: 12px; color: var(--muted); font-size: 14px; }
.twocol { grid-template-columns:1fr 1fr; } .layout { display: grid; grid-template-columns: 1fr 1.25fr 0.95fr; gap: 16px; margin-top: 16px; }
.prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:460px; } .card { background: var(--card); border: 1px solid var(--border); border-radius: 20px; padding: 18px; }
.narrow { max-width: 420px; margin: 60px auto; }
label { display: block; font-weight: 600; margin-bottom: 12px; }
input { width: 100%; margin-top: 6px; border: 1px solid var(--border); border-radius: 10px; padding: 10px; font: inherit; }
.muted { color: var(--muted); } .muted { color: var(--muted); }
.error { color: #b42318; margin-top: 10px; } .error { color: #b42318; margin-top: 10px; }
.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 { background: var(--accent); color: white; border: none; }
.button-row { display: flex; gap: 10px; }
.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); }
.compliance-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
details summary { cursor: pointer; color: var(--accent); }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.layout { grid-template-columns: 1fr; }
.domain-grid { grid-template-columns: 1fr; }
.hero { flex-direction: column; } .hero { flex-direction: column; }
.twocol { grid-template-columns:1fr; }
} }