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"
version = "0.1.0"
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]
didactopus-build-attribution = "didactopus.attribution_builder:main"
didactopus-api = "didactopus.api:main"
didactopus-seed-db = "didactopus.seed:main"
[tool.setuptools.packages.find]
where = ["src"]

View File

@ -2,78 +2,77 @@ from __future__ import annotations
from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
from .config import load_settings
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 (
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,
create_render_job, update_render_job, list_render_jobs, list_artifacts
authenticate_user, get_user_by_token, list_packs, get_pack, create_learner,
learner_owned_by_user, load_learner_state, save_learner_state,
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 build_graph_frames, stable_layout
from .worker import process_render_job
from .engine import apply_evidence, recommend_next
settings = load_settings()
Base.metadata.create_all(bind=engine)
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="")):
token = authorization.removeprefix("Bearer ").strip()
payload = decode_token(token) if token else None
if not payload or payload.get("kind") != "access":
raise HTTPException(status_code=401, detail="Unauthorized")
user = get_user_by_id(int(payload["sub"]))
if user is None or not user.is_active:
user = get_user_by_token(token) if token else None
if user is None:
raise HTTPException(status_code=401, detail="Unauthorized")
return user
def ensure_learner_access(user, learner_id: str):
if user.role == "admin":
return
if not learner_owned_by_user(user.id, learner_id):
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")
def simulate_evaluator_job(job_id: int):
job = get_evaluator_job(job_id)
if job is None:
return
update_evaluator_job(job_id, "running")
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
notes = "Prototype evaluator: longer responses scored somewhat higher."
update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes)
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}",
))
save_learner_state(state)
@app.post("/api/login", response_model=TokenPair)
@app.post("/api/login", response_model=LoginResponse)
def login(payload: LoginRequest):
user = authenticate_user(payload.username, payload.password)
if user is None:
raise HTTPException(status_code=401, detail="Invalid credentials")
token_id = new_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)
@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)
return LoginResponse(token=user.token, username=user.username)
@app.get("/api/packs")
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")
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")
return save_learner_state(state).model_dump()
@app.get("/api/packs/{pack_id}/layout")
def api_pack_layout(pack_id: str, 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)):
@app.post("/api/learners/{learner_id}/evidence")
def api_post_evidence(learner_id: str, event: EvidenceEvent, user = Depends(current_user)):
ensure_learner_access(user, learner_id)
ensure_pack_access(user, pack_id)
pack = get_pack(pack_id)
state = load_learner_state(learner_id)
frames = build_graph_frames(state, pack)
return {
"learner_id": learner_id,
"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 [],
}
state = apply_evidence(state, event)
save_learner_state(state)
return state.model_dump()
@app.post("/api/learners/{learner_id}/render-jobs/{pack_id}")
def api_render_job(learner_id: str, pack_id: str, payload: MediaRenderRequest, background_tasks: BackgroundTasks, user = Depends(current_user)):
@app.get("/api/learners/{learner_id}/recommendations/{pack_id}")
def api_get_recommendations(learner_id: str, pack_id: str, user = Depends(current_user)):
ensure_learner_access(user, learner_id)
ensure_pack_access(user, pack_id)
pack = get_pack(pack_id)
state = load_learner_state(learner_id)
animation = {
"learner_id": learner_id,
"pack_id": pack_id,
"pack_title": pack.title if pack else "",
"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"}
pack = get_pack(pack_id)
if pack is None:
raise HTTPException(status_code=404, detail="Pack not found")
return {"cards": recommend_next(state, pack)}
@app.get("/api/render-jobs")
def api_list_render_jobs(learner_id: str | None = None, user = Depends(current_user)):
if learner_id:
@app.post("/api/learners/{learner_id}/evaluator-jobs", response_model=EvaluatorJobStatus)
def api_submit_evaluator_job(learner_id: str, payload: EvaluatorSubmission, background_tasks: BackgroundTasks, user = Depends(current_user)):
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")
def api_list_artifacts(learner_id: str | None = None, user = Depends(current_user)):
if learner_id:
ensure_learner_access(user, learner_id)
return list_artifacts(learner_id)
@app.get("/api/evaluator-jobs/{job_id}", response_model=EvaluatorJobStatus)
def api_get_evaluator_job(job_id: int, user = Depends(current_user)):
job = get_evaluator_job(job_id)
if job is None:
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():
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 datetime import datetime, timedelta, timezone
from jose import jwt, JWTError
from passlib.context import CryptContext
import secrets
from .config import load_settings
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
settings = load_settings()
def hash_password(password: str) -> str:
return pwd_context.hash(password)
@ -14,22 +10,5 @@ def hash_password(password: str) -> str:
def verify_password(password: str, password_hash: str) -> bool:
return pwd_context.verify(password, password_hash)
def _encode_token(payload: dict, expires_delta: timedelta) -> 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:
def issue_token() -> str:
return secrets.token_urlsafe(24)

View File

@ -1,13 +1,10 @@
from __future__ import annotations
import os
from pathlib import Path
from pydantic import BaseModel
class Settings(BaseModel):
database_url: str = os.getenv("DIDACTOPUS_DATABASE_URL", "sqlite+pysqlite:///:memory:")
host: str = os.getenv("DIDACTOPUS_HOST", "127.0.0.1")
port: int = int(os.getenv("DIDACTOPUS_PORT", "8011"))
jwt_secret: str = os.getenv("DIDACTOPUS_JWT_SECRET", "change-me")
jwt_algorithm: str = "HS256"
database_url: str = "sqlite:///./didactopus.db"
host: str = "127.0.0.1"
port: int = 8011
def load_settings() -> Settings:
return Settings()

View File

@ -3,6 +3,7 @@ from sqlalchemy.orm import declarative_base, sessionmaker
from .config import 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)
Base = declarative_base()

View File

@ -1,110 +1,97 @@
from __future__ import annotations
from collections import defaultdict
from .models import LearnerState, PackData
from .models import LearnerState, EvidenceEvent, MasteryRecord, PackData
def concept_depths(pack: PackData) -> dict[str, int]:
concept_map = {c.id: c for c in pack.concepts}
memo = {}
def depth(cid: str) -> int:
if cid in memo:
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 get_record(state: LearnerState, concept_id: str, dimension: str = "mastery") -> MasteryRecord | None:
for rec in state.records:
if rec.concept_id == concept_id and rec.dimension == dimension:
return rec
return None
def stable_layout(pack: PackData, width: int = 900, height: int = 520):
depths = concept_depths(pack)
layers = defaultdict(list)
for c in pack.concepts:
layers[depths.get(c.id, 0)].append(c)
positions = {}
max_depth = max(layers.keys()) if layers else 0
for d in sorted(layers):
nodes = sorted(layers[d], key=lambda c: c.id)
y = 90 + d * ((height - 160) / max(1, max_depth))
for idx, node in enumerate(nodes):
if node.position is not None:
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 apply_evidence(
state: LearnerState,
event: EvidenceEvent,
decay: float = 0.05,
reinforcement: float = 0.25,
) -> LearnerState:
rec = get_record(state, event.concept_id, event.dimension)
if rec is None:
rec = MasteryRecord(
concept_id=event.concept_id,
dimension=event.dimension,
score=0.0,
confidence=0.0,
evidence_count=0,
last_updated=event.timestamp,
)
state.records.append(rec)
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:
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 True
def concept_status(scores: dict[str, float], concept, min_score: float = 0.65) -> str:
score = scores.get(concept.id, 0.0)
if score >= min_score:
def concept_status(state: LearnerState, concept, min_score: float = 0.65, min_confidence: float = 0.45) -> str:
rec = get_record(state, concept.id, concept.masteryDimension)
if rec and rec.score >= min_score and rec.confidence >= min_confidence:
return "mastered"
if prereqs_satisfied(scores, concept, min_score):
return "active" if score > 0 else "available"
if prereqs_satisfied(state, concept, min_score, min_confidence):
return "active" if rec else "available"
return "locked"
def build_graph_frames(state: LearnerState, pack: PackData):
concepts = {c.id: c for c in pack.concepts}
layout = stable_layout(pack)
scores = {c.id: 0.0 for c in pack.concepts}
frames = []
history = sorted(state.history, key=lambda x: x.timestamp)
static_edges = [{"source": pre, "target": c.id, "kind": "prerequisite"} for c in pack.concepts for pre in c.prerequisites]
static_cross = [{
"source": c.id,
"target_pack_id": link.target_pack_id,
"target_concept_id": link.target_concept_id,
"relationship": link.relationship,
"kind": "cross_pack"
} for c in pack.concepts for link in c.cross_pack_links]
for idx, ev in enumerate(history):
if ev.concept_id in scores:
scores[ev.concept_id] = ev.score
nodes = []
for cid, concept in concepts.items():
score = scores.get(cid, 0.0)
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"],
def recommend_next(state: LearnerState, pack: PackData) -> list[dict]:
cards = []
for concept in pack.concepts:
status = concept_status(state, concept)
rec = get_record(state, concept.id, concept.masteryDimension)
if status in {"available", "active"}:
cards.append({
"id": concept.id,
"title": f"Work on {concept.title}",
"minutes": 15 if status == "available" else 10,
"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."
),
"why": [
"Prerequisite check passed",
f"Current score: {rec.score:.2f}" if rec else "No evidence recorded yet",
f"Current confidence: {rec.confidence:.2f}" if rec else "Confidence starts after your first exercise",
],
"reward": concept.exerciseReward or f"{concept.title} progress recorded",
"conceptId": concept.id,
"scoreHint": 0.82 if status == "available" else 0.76,
"confidenceHint": 0.72 if status == "available" else 0.55,
})
frames.append({
"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,
for rec in state.records:
if rec.dimension == "mastery" and rec.confidence < 0.40:
concept = next((c for c in pack.concepts if c.id == rec.concept_id), None)
if concept:
cards.append({
"id": f"{concept.id}-reinforce",
"title": f"Reinforce {concept.title}",
"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:
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
return cards[:4]

View File

@ -1,29 +1,8 @@
from __future__ import annotations
from pydantic import BaseModel, Field
from typing import Literal
class TokenPair(BaseModel):
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"
EvidenceKind = Literal["checkpoint", "project", "exercise", "review"]
class PackConcept(BaseModel):
id: str
@ -31,8 +10,13 @@ class PackConcept(BaseModel):
prerequisites: list[str] = Field(default_factory=list)
masteryDimension: str = "mastery"
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):
id: str
@ -41,11 +25,7 @@ class PackData(BaseModel):
level: str = "novice-friendly"
concepts: list[PackConcept] = Field(default_factory=list)
onboarding: dict = Field(default_factory=dict)
compliance: dict = Field(default_factory=dict)
class CreateLearnerRequest(BaseModel):
learner_id: str
display_name: str = ""
compliance: PackCompliance = Field(default_factory=PackCompliance)
class MasteryRecord(BaseModel):
concept_id: str
@ -61,7 +41,7 @@ class EvidenceEvent(BaseModel):
score: float
confidence_hint: float = 0.5
timestamp: str
kind: str = "exercise"
kind: EvidenceKind = "exercise"
source_id: str = ""
class LearnerState(BaseModel):
@ -69,9 +49,27 @@ class LearnerState(BaseModel):
records: list[MasteryRecord] = 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
display_name: str = ""
class EvaluatorSubmission(BaseModel):
pack_id: str
format: str = "gif"
fps: int = 2
theme: str = "default"
concept_id: str
submitted_text: str
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.orm import Mapped, mapped_column
from sqlalchemy import String, Integer, Float, Boolean, ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .db import Base
class UserORM(Base):
@ -7,32 +7,22 @@ class UserORM(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
role: Mapped[str] = mapped_column(String(50), default="learner")
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)
token: Mapped[str] = mapped_column(String(255), unique=True, index=True)
class PackORM(Base):
__tablename__ = "packs"
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))
subtitle: Mapped[str] = mapped_column(Text, default="")
level: Mapped[str] = mapped_column(String(100), default="novice-friendly")
data_json: Mapped[str] = mapped_column(Text)
is_published: Mapped[bool] = mapped_column(Boolean, default=False)
class LearnerORM(Base):
__tablename__ = "learners"
id: Mapped[str] = mapped_column(String(100), primary_key=True)
owner_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
display_name: Mapped[str] = mapped_column(String(255), default="")
owner = relationship("UserORM")
class MasteryRecordORM(Base):
__tablename__ = "mastery_records"
@ -44,6 +34,7 @@ class MasteryRecordORM(Base):
confidence: Mapped[float] = mapped_column(Float, default=0.0)
evidence_count: Mapped[int] = mapped_column(Integer, default=0)
last_updated: Mapped[str] = mapped_column(String(100), default="")
learner = relationship("LearnerORM")
class EvidenceEventORM(Base):
__tablename__ = "evidence_events"
@ -56,30 +47,18 @@ class EvidenceEventORM(Base):
timestamp: Mapped[str] = mapped_column(String(100), default="")
kind: Mapped[str] = mapped_column(String(50), default="exercise")
source_id: Mapped[str] = mapped_column(String(255), default="")
learner = relationship("LearnerORM")
class RenderJobORM(Base):
__tablename__ = "render_jobs"
class EvaluatorJobORM(Base):
__tablename__ = "evaluator_jobs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
learner_id: Mapped[str] = mapped_column(String(100), index=True)
pack_id: Mapped[str] = mapped_column(String(100), index=True)
requested_format: Mapped[str] = mapped_column(String(20), default="gif")
fps: Mapped[int] = mapped_column(Integer, default=2)
theme: Mapped[str] = mapped_column(String(100), default="default")
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")
bundle_dir: Mapped[str] = mapped_column(Text, default="")
payload_json: Mapped[str] = mapped_column(Text, default="")
manifest_path: Mapped[str] = mapped_column(Text, default="")
script_path: Mapped[str] = mapped_column(Text, default="")
error_text: Mapped[str] = mapped_column(Text, default="")
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="{}")
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="")
learner = relationship("LearnerORM")
pack = relationship("PackORM")

View File

@ -2,92 +2,36 @@ from __future__ import annotations
import json
from sqlalchemy import select
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 .auth import verify_password
def get_user_by_username(username: str):
def get_user_by_token(token: str) -> UserORM | None:
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:
return db.get(UserORM, user_id)
def authenticate_user(username: str, password: str):
user = get_user_by_username(username)
if user is None or not verify_password(password, user.password_hash) or not user.is_active:
user = db.execute(select(UserORM).where(UserORM.username == username)).scalar_one_or_none()
if user is None:
return None
if not verify_password(password, user.password_hash):
return None
return user
def store_refresh_token(user_id: int, token_id: str):
def list_packs() -> list[PackData]:
with SessionLocal() as db:
db.add(RefreshTokenORM(user_id=user_id, token_id=token_id, is_revoked=False))
db.commit()
rows = db.execute(select(PackORM)).scalars().all()
return [PackData.model_validate(json.loads(r.data_json)) for r in rows]
def refresh_token_active(token_id: str) -> bool:
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):
def get_pack(pack_id: str) -> PackData | None:
with SessionLocal() as db:
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:
row = PackORM(
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:
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()
return None
return PackData.model_validate(json.loads(row.data_json))
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:
if db.get(LearnerORM, learner_id) is None:
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)
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:
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()
return LearnerState(
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],
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],
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
],
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:
db.execute(select(LearnerORM).where(LearnerORM.id == state.learner_id))
db.query(MasteryRecordORM).filter(MasteryRecordORM.learner_id == state.learner_id).delete()
db.query(EvidenceEventORM).filter(EvidenceEventORM.learner_id == state.learner_id).delete()
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:
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()
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 sqlalchemy import select
import json
from .db import Base, engine, SessionLocal
from .orm import UserORM
from .auth import hash_password
from .repository import upsert_pack, create_learner
from .models import PackData, PackConcept, GraphPosition, CrossPackLink
from .orm import UserORM, PackORM
from .auth import hash_password, issue_token
from sqlalchemy import select
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():
Base.metadata.create_all(bind=engine)
with SessionLocal() as db:
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))
existing = db.execute(select(UserORM).where(UserORM.username == "wesley")).scalar_one_or_none()
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()
create_learner(1, "wesley-learner", "Wesley learner")
upsert_pack(
PackData(
id="wesley-private-pack",
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,
)
print("Seeded database. Demo user: wesley / demo-pass")
if __name__ == "__main__":
main()

View File

@ -3,8 +3,10 @@
<head>
<meta charset="UTF-8" />
<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>
</head>
<body><div id="root"></div></body>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -1,9 +1,17 @@
{
"name": "didactopus-artifact-registry-ui",
"name": "didactopus-auth-api-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": { "dev": "vite", "build": "vite build" },
"dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" },
"devDependencies": { "vite": "^5.4.0" }
"scripts": {
"dev": "vite",
"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 { login, refresh, fetchPacks, fetchLearnerState, putLearnerState, createRenderJob, listRenderJobs, listArtifacts } from "./api";
import { loadAuth, saveAuth, clearAuth } from "./authStore";
import React, { useEffect, useMemo, useState } from "react";
import { login, fetchPacks, createLearner, fetchLearnerState, fetchRecommendations, postEvidence, submitEvaluatorJob, fetchEvaluatorJob } from "./api";
import { buildMasteryMap, progressPercent } from "./localEngine";
function LoginView({ onAuth }) {
function LoginPanel({ onLogin }) {
const [username, setUsername] = useState("wesley");
const [password, setPassword] = useState("demo-pass");
const [error, setError] = useState("");
async function doLogin() {
async function handleLogin() {
try {
const result = await login(username, password);
saveAuth(result);
onAuth(result);
} catch { setError("Login failed"); }
setError("");
const result = await onLogin(username, password);
return result;
} catch (e) {
setError("Login failed");
}
}
return (
<div className="page narrow-page">
<section className="card narrow">
<h1>Didactopus login</h1>
<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>
<button className="primary" onClick={doLogin}>Login</button>
<button className="primary" onClick={handleLogin}>Login</button>
{error ? <div className="error">{error}</div> : null}
</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>
);
}
export default function App() {
const [auth, setAuth] = useState(loadAuth());
const [token, setToken] = useState("");
const [username, setUsername] = useState("");
const [packs, setPacks] = useState([]);
const [learnerId] = useState("wesley-learner");
const [packId, setPackId] = useState("");
const [jobs, setJobs] = useState([]);
const [artifacts, setArtifacts] = useState([]);
const [format, setFormat] = useState("gif");
const [fps, setFps] = useState(2);
const [message, setMessage] = useState("");
const [selectedDomainId, setSelectedDomainId] = useState("");
const [learnerId, setLearnerId] = useState("wesley-learner");
const [learnerState, setLearnerState] = useState(null);
const [cards, setCards] = useState([]);
const [jobStatus, setJobStatus] = useState(null);
async function refreshAuthToken() {
if (!auth?.refresh_token) return null;
try {
const result = await refresh(auth.refresh_token);
saveAuth(result);
setAuth(result);
return result;
} catch {
clearAuth();
setAuth(null);
return null;
}
}
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)));
async function handleLogin(user, pass) {
const auth = await login(user, pass);
setToken(auth.token);
setUsername(auth.username);
await createLearner(auth.token, learnerId, learnerId);
const loadedPacks = await fetchPacks(auth.token);
setPacks(loadedPacks);
setSelectedDomainId(loadedPacks[0]?.id || "");
const state = await fetchLearnerState(auth.token, learnerId);
setLearnerState(state);
return auth;
}
useEffect(() => {
if (!auth) return;
async function load() {
const p = await guarded((token) => fetchPacks(token));
setPacks(p);
setPackId(p[0]?.id || "");
await reloadLists();
async function loadCards() {
if (!token || !selectedDomainId) return;
const data = await fetchRecommendations(token, learnerId, selectedDomainId);
setCards(data.cards || []);
}
load();
}, [auth]);
loadCards();
}, [token, learnerId, selectedDomainId, learnerState]);
async function generateDemo() {
let state = await guarded((token) => fetchLearnerState(token, learnerId));
const base = Date.now()
const events = [
["intro", 0.30, "exercise", 0],
["intro", 0.78, "review", 1000],
["second", 0.42, "exercise", 2000],
["second", 0.72, "review", 3000],
["third", 0.25, "exercise", 4000],
["branch", 0.60, "exercise", 5000],
];
const latest = {}
for (const [cid, score, kind, offset] of events) {
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}` });
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.");
const domain = useMemo(() => packs.find((d) => d.id === selectedDomainId) || null, [packs, selectedDomainId]);
const masteryMap = useMemo(() => domain && learnerState ? buildMasteryMap(learnerState, domain) : [], [learnerState, domain]);
const progress = useMemo(() => domain && learnerState ? progressPercent(learnerState, domain) : 0, [learnerState, domain]);
async function simulateStep(step) {
const nextState = await postEvidence(token, learnerId, {
concept_id: step.conceptId,
dimension: "mastery",
score: step.scoreHint,
confidence_hint: step.confidenceHint,
timestamp: new Date().toISOString(),
kind: "checkpoint",
source_id: `ui-${step.id}`
});
setLearnerState(nextState);
}
async function createJob() {
const result = await guarded((token) => createRenderJob(token, learnerId, packId, { learner_id: learnerId, pack_id: packId, format, fps, theme: "default" }));
setMessage(`Render job ${result.job_id} queued.`);
setTimeout(() => reloadLists(), 500);
async function submitDemoEvaluator() {
if (!domain) return;
const firstConcept = domain.concepts[0]?.id;
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 (
<div className="page">
<header className="hero">
<div>
<h1>Didactopus artifact registry</h1>
<p>Track render jobs and produced media artifacts as first-class Didactopus objects.</p>
<div className="muted">{message}</div>
<h1>Didactopus learner prototype</h1>
<p>Authenticated multi-user scaffold with DB-backed state and async evaluator jobs.</p>
<div className="muted">Signed in as {username}</div>
</div>
<div className="controls">
<label>Pack
<select value={packId} onChange={(e) => setPackId(e.target.value)}>
{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 className="hero-controls">
<label>Learner ID<input value={learnerId} onChange={(e) => setLearnerId(e.target.value)} /></label>
<button onClick={submitDemoEvaluator}>Submit demo evaluator job</button>
</div>
</header>
<main className="layout twocol">
<section className="card">
<h2>Render jobs</h2>
<pre className="prebox">{JSON.stringify(jobs, null, 2)}</pre>
<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>Artifacts</h2>
<pre className="prebox">{JSON.stringify(artifacts, null, 2)}</pre>
<h2>Onboarding</h2>
<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 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>
</div>
);

View File

@ -1,24 +1,62 @@
const API = "http://127.0.0.1:8011/api";
function authHeaders(token, json=true) {
const h = { Authorization: `Bearer ${token}` };
if (json) h["Content-Type"] = "application/json";
return h;
export async function login(username, password) {
const res = await fetch(`${API}/login`, {
method: "POST",
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) {
const res = await fetch(`${API}/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }) });
if (!res.ok) throw new Error("login failed");
function authHeaders(token) {
return { "Content-Type": "application/json", "Authorization": `Bearer ${token}` };
}
export async function fetchPacks(token) {
const res = await fetch(`${API}/packs`, { headers: authHeaders(token) });
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 }) });
if (!res.ok) throw new Error("refresh failed");
export async function createLearner(token, learnerId, displayName) {
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();
}
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;
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 App from "./App";
import "./styles.css";
createRoot(document.getElementById("root")).render(<App />);

View File

@ -1,23 +1,52 @@
: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; }
body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: var(--bg); color: var(--text); }
.page { max-width:1500px; margin:0 auto; padding:24px; }
.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; align-items:flex-start; }
.controls { display:flex; gap:10px; align-items:flex-end; flex-wrap:wrap; }
label { display:block; font-weight:600; }
input, select { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; }
button { border:1px solid var(--border); background:white; border-radius:12px; padding:10px 14px; cursor:pointer; }
.card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; }
.narrow { margin-top:60px; }
.layout { display:grid; gap:16px; }
.twocol { grid-template-columns:1fr 1fr; }
.prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:460px; }
.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; }
.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; }
.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); }
.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) {
.layout { grid-template-columns: 1fr; }
.domain-grid { grid-template-columns: 1fr; }
.hero { flex-direction: column; }
.twocol { grid-template-columns:1fr; }
}