Apply ZIP update: 170-didactopus-auth-db-async-evaluator-prototype.zip [2026-03-14T13:20:10]
This commit is contained in:
parent
8939908293
commit
d851515201
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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 = ""
|
||||||
|
|
|
||||||
|
|
@ -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="{}")
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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(); }
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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 />);
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
.muted { color:var(--muted); }
|
.narrow { max-width: 420px; margin: 60px auto; }
|
||||||
.error { color:#b42318; margin-top:10px; }
|
label { display: block; font-weight: 600; margin-bottom: 12px; }
|
||||||
@media (max-width:1100px) {
|
input { width: 100%; margin-top: 6px; border: 1px solid var(--border); border-radius: 10px; padding: 10px; font: inherit; }
|
||||||
.hero { flex-direction:column; }
|
.muted { color: var(--muted); }
|
||||||
.twocol { grid-template-columns:1fr; }
|
.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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue