diff --git a/src/didactopus/api.py b/src/didactopus/api.py
index 332873c..489d2af 100644
--- a/src/didactopus/api.py
+++ b/src/didactopus/api.py
@@ -1,12 +1,16 @@
from __future__ import annotations
-import json
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, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest
-from .repository import authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token, list_packs, list_pack_admin_rows, get_pack, get_pack_validation, get_pack_provenance, upsert_pack, set_pack_publication, create_learner, list_learners_for_user, learner_owned_by_user, load_learner_state, save_learner_state, create_evaluator_job, get_evaluator_job, list_evaluator_jobs_for_learner
+from .repository import (
+ authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token,
+ list_packs, list_pack_admin_rows, get_pack, upsert_pack, set_pack_publication,
+ create_learner, list_learners_for_user, learner_owned_by_user, load_learner_state,
+ save_learner_state, create_evaluator_job, get_evaluator_job, list_evaluator_jobs_for_learner
+)
from .engine import apply_evidence, recommend_next
from .auth import issue_access_token, issue_refresh_token, decode_token, new_token_id
from .worker import process_job
@@ -15,7 +19,13 @@ 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()
@@ -45,7 +55,12 @@ def login(payload: LoginRequest):
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)
+ 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):
@@ -61,24 +76,22 @@ def refresh(payload: RefreshRequest):
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 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")
def api_list_packs(user = Depends(current_user)):
- return [p.model_dump() for p in list_packs(include_unpublished=(user.role == "admin"))]
+ include_unpublished = user.role == "admin"
+ return [p.model_dump() for p in list_packs(include_unpublished=include_unpublished)]
@app.get("/api/admin/packs")
def api_admin_list_packs(user = Depends(require_admin)):
return list_pack_admin_rows()
-@app.get("/api/admin/packs/{pack_id}/validation")
-def api_admin_pack_validation(pack_id: str, user = Depends(require_admin)):
- return get_pack_validation(pack_id)
-
-@app.get("/api/admin/packs/{pack_id}/provenance")
-def api_admin_pack_provenance(pack_id: str, user = Depends(require_admin)):
- return get_pack_provenance(pack_id)
-
@app.post("/api/admin/packs")
def api_upsert_pack(payload: CreatePackRequest, user = Depends(require_admin)):
upsert_pack(payload.pack, is_published=payload.is_published)
@@ -143,13 +156,6 @@ def api_get_evaluator_job(job_id: int, user = Depends(current_user)):
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)
-@app.get("/api/evaluator-jobs/{job_id}/trace")
-def api_get_evaluator_job_trace(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 json.loads(job.trace_json or "{}")
-
@app.get("/api/learners/{learner_id}/evaluator-history")
def api_get_evaluator_history(learner_id: str, user = Depends(current_user)):
ensure_learner_access(user, learner_id)
@@ -158,3 +164,6 @@ def api_get_evaluator_history(learner_id: str, user = Depends(current_user)):
def main():
uvicorn.run(app, host=settings.host, port=settings.port)
+
+if __name__ == "__main__":
+ main()
diff --git a/src/didactopus/auth.py b/src/didactopus/auth.py
index 54745ac..a2d088d 100644
--- a/src/didactopus/auth.py
+++ b/src/didactopus/auth.py
@@ -20,10 +20,10 @@ def _encode_token(payload: dict, expires_delta: timedelta) -> str:
return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def issue_access_token(user_id: int, username: str, role: str) -> str:
- return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "access"}, timedelta(minutes=30))
+ return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "access"}, timedelta(minutes=settings.access_token_minutes))
def issue_refresh_token(user_id: int, username: str, role: str, token_id: str) -> str:
- return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "refresh", "jti": token_id}, timedelta(days=14))
+ return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "refresh", "jti": token_id}, timedelta(days=settings.refresh_token_days))
def decode_token(token: str) -> dict | None:
try:
diff --git a/src/didactopus/config.py b/src/didactopus/config.py
index 6db28e1..9890f28 100644
--- a/src/didactopus/config.py
+++ b/src/didactopus/config.py
@@ -8,6 +8,8 @@ class Settings(BaseModel):
port: int = int(os.getenv("DIDACTOPUS_PORT", "8011"))
jwt_secret: str = os.getenv("DIDACTOPUS_JWT_SECRET", "change-me")
jwt_algorithm: str = "HS256"
+ access_token_minutes: int = 30
+ refresh_token_days: int = 14
def load_settings() -> Settings:
return Settings()
diff --git a/src/didactopus/models.py b/src/didactopus/models.py
index 9456ad7..e2a0b8b 100644
--- a/src/didactopus/models.py
+++ b/src/didactopus/models.py
@@ -41,14 +41,6 @@ class PackData(BaseModel):
onboarding: dict = Field(default_factory=dict)
compliance: PackCompliance = Field(default_factory=PackCompliance)
-class CreatePackRequest(BaseModel):
- pack: PackData
- is_published: bool = True
-
-class CreateLearnerRequest(BaseModel):
- learner_id: str
- display_name: str = ""
-
class MasteryRecord(BaseModel):
concept_id: str
dimension: str
@@ -71,6 +63,10 @@ class LearnerState(BaseModel):
records: list[MasteryRecord] = Field(default_factory=list)
history: list[EvidenceEvent] = Field(default_factory=list)
+class CreateLearnerRequest(BaseModel):
+ learner_id: str
+ display_name: str = ""
+
class EvaluatorSubmission(BaseModel):
pack_id: str
concept_id: str
@@ -83,3 +79,7 @@ class EvaluatorJobStatus(BaseModel):
result_score: float | None = None
result_confidence_hint: float | None = None
result_notes: str = ""
+
+class CreatePackRequest(BaseModel):
+ pack: PackData
+ is_published: bool = True
diff --git a/src/didactopus/orm.py b/src/didactopus/orm.py
index 1382d61..d4a16f7 100644
--- a/src/didactopus/orm.py
+++ b/src/didactopus/orm.py
@@ -24,8 +24,6 @@ class PackORM(Base):
subtitle: Mapped[str] = mapped_column(Text, default="")
level: Mapped[str] = mapped_column(String(100), default="novice-friendly")
data_json: Mapped[str] = mapped_column(Text)
- validation_json: Mapped[str] = mapped_column(Text, default="{}")
- provenance_json: Mapped[str] = mapped_column(Text, default="{}")
is_published: Mapped[bool] = mapped_column(Boolean, default=True)
class LearnerORM(Base):
@@ -68,4 +66,3 @@ class EvaluatorJobORM(Base):
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="")
- trace_json: Mapped[str] = mapped_column(Text, default="{}")
diff --git a/src/didactopus/repository.py b/src/didactopus/repository.py
index 0c7ce34..23b55a6 100644
--- a/src/didactopus/repository.py
+++ b/src/didactopus/repository.py
@@ -37,13 +37,12 @@ def revoke_refresh_token(token_id: str):
row.is_revoked = True
db.commit()
-def list_packs(include_unpublished: bool = False):
+def list_packs(include_unpublished: bool = False) -> list[PackData]:
with SessionLocal() as db:
stmt = select(PackORM)
if not include_unpublished:
stmt = stmt.where(PackORM.is_published == True)
- rows = db.execute(stmt).scalars().all()
- return [PackData.model_validate(json.loads(r.data_json)) for r in rows]
+ return [PackData.model_validate(json.loads(r.data_json)) for r in db.execute(stmt).scalars().all()]
def list_pack_admin_rows():
with SessionLocal() as db:
@@ -55,43 +54,17 @@ def get_pack(pack_id: str):
row = db.get(PackORM, pack_id)
return None if row is None else PackData.model_validate(json.loads(row.data_json))
-def get_pack_validation(pack_id: str):
- with SessionLocal() as db:
- row = db.get(PackORM, pack_id)
- return {} if row is None else json.loads(row.validation_json or "{}")
-
-def get_pack_provenance(pack_id: str):
- with SessionLocal() as db:
- row = db.get(PackORM, pack_id)
- return {} if row is None else json.loads(row.provenance_json or "{}")
-
def upsert_pack(pack: PackData, is_published: bool = True):
- validation = {
- "ok": len(pack.concepts) > 0,
- "warnings": [] if len(pack.concepts) > 0 else ["Pack has no concepts."],
- "errors": [],
- "summary": {"concept_count": len(pack.concepts), "has_onboarding": bool(pack.onboarding)}
- }
- provenance = {
- "source_count": pack.compliance.sources,
- "licenses_present": ["CC BY-NC-SA 4.0"] if pack.compliance.shareAlikeRequired or pack.compliance.noncommercialOnly else [],
- "restrictive_flags": list(pack.compliance.flags),
- "sources": [
- {"source_id": "sample-source-1", "title": f"Provenance placeholder for {pack.title}", "license_id": "CC BY-NC-SA 4.0" if pack.compliance.shareAlikeRequired or pack.compliance.noncommercialOnly else "unspecified", "attribution_text": "Sample attribution text placeholder"}
- ] if pack.compliance.sources else []
- }
with SessionLocal() as db:
row = db.get(PackORM, pack.id)
payload = json.dumps(pack.model_dump())
if row is None:
- db.add(PackORM(id=pack.id, title=pack.title, subtitle=pack.subtitle, level=pack.level, data_json=payload, validation_json=json.dumps(validation), provenance_json=json.dumps(provenance), is_published=is_published))
+ db.add(PackORM(id=pack.id, title=pack.title, subtitle=pack.subtitle, level=pack.level, data_json=payload, is_published=is_published))
else:
row.title = pack.title
row.subtitle = pack.subtitle
row.level = pack.level
row.data_json = payload
- row.validation_json = json.dumps(validation)
- row.provenance_json = json.dumps(provenance)
row.is_published = is_published
db.commit()
@@ -123,7 +96,7 @@ 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()
@@ -146,8 +119,7 @@ def save_learner_state(state: LearnerState):
def create_evaluator_job(learner_id: str, pack_id: str, concept_id: str, submitted_text: str) -> int:
with SessionLocal() as db:
- trace = {"rubric_dimension_scores": [{"dimension": "mastery", "score": 0.0}], "notes": ["Job queued", "Awaiting evaluator"], "token_count_estimate": len(submitted_text.split())}
- job = EvaluatorJobORM(learner_id=learner_id, pack_id=pack_id, concept_id=concept_id, submitted_text=submitted_text, status="queued", trace_json=json.dumps(trace))
+ 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)
@@ -155,13 +127,14 @@ def create_evaluator_job(learner_id: str, pack_id: str, concept_id: str, submitt
def list_evaluator_jobs_for_learner(learner_id: str):
with SessionLocal() as db:
- return db.execute(select(EvaluatorJobORM).where(EvaluatorJobORM.learner_id == learner_id).order_by(EvaluatorJobORM.id.desc())).scalars().all()
+ rows = db.execute(select(EvaluatorJobORM).where(EvaluatorJobORM.learner_id == learner_id).order_by(EvaluatorJobORM.id.desc())).scalars().all()
+ return rows
def get_evaluator_job(job_id: int):
with SessionLocal() as db:
return db.get(EvaluatorJobORM, job_id)
-def update_evaluator_job(job_id: int, status: str, score: float | None = None, confidence_hint: float | None = None, notes: str = "", trace: dict | None = None):
+def update_evaluator_job(job_id: int, status: str, score: float | None = None, confidence_hint: float | None = None, notes: str = ""):
with SessionLocal() as db:
job = db.get(EvaluatorJobORM, job_id)
if job is None:
@@ -170,6 +143,4 @@ def update_evaluator_job(job_id: int, status: str, score: float | None = None, c
job.result_score = score
job.result_confidence_hint = confidence_hint
job.result_notes = notes
- if trace is not None:
- job.trace_json = json.dumps(trace)
db.commit()
diff --git a/src/didactopus/seed.py b/src/didactopus/seed.py
index abffe81..03bbc74 100644
--- a/src/didactopus/seed.py
+++ b/src/didactopus/seed.py
@@ -1,27 +1,48 @@
from __future__ import annotations
+import json
from sqlalchemy import select
from .db import Base, engine, SessionLocal
-from .orm import UserORM
+from .orm import UserORM, PackORM
from .auth import hash_password
-from .repository import upsert_pack
-from .models import PackData, PackConcept, PackCompliance
+
+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"}
+ ],
+ "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))
+ for pack in PACKS:
+ if db.get(PackORM, pack["id"]) is None:
+ db.add(PackORM(id=pack["id"], title=pack["title"], subtitle=pack["subtitle"], level=pack["level"], data_json=json.dumps(pack), is_published=True))
db.commit()
- upsert_pack(PackData(
- id="bayes-pack",
- title="Bayesian Reasoning",
- subtitle="Probability, evidence, updating, and model criticism.",
- level="novice-friendly",
- concepts=[
- PackConcept(id="prior", title="Prior", prerequisites=[], masteryDimension="mastery", exerciseReward="Prior badge earned"),
- PackConcept(id="posterior", title="Posterior", prerequisites=["prior"], masteryDimension="mastery", exerciseReward="Posterior path opened"),
- ],
- 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=PackCompliance(sources=2, attributionRequired=True, shareAlikeRequired=True, noncommercialOnly=True, flags=["share-alike","noncommercial","excluded-third-party-content"])
- ), is_published=True)
print("Seeded database. Demo user: wesley / demo-pass")
+
+if __name__ == "__main__":
+ main()
diff --git a/src/didactopus/worker.py b/src/didactopus/worker.py
index 6d28da5..3cea356 100644
--- a/src/didactopus/worker.py
+++ b/src/didactopus/worker.py
@@ -8,17 +8,27 @@ def process_job(job_id: int):
job = get_evaluator_job(job_id)
if job is None:
return
- update_evaluator_job(job_id, "running", trace={"rubric_dimension_scores": [{"dimension": "mastery", "score": 0.35}], "notes": ["Job running", "Prototype trace generated"], "token_count_estimate": len(job.submitted_text.split())})
+ 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."
- trace = {"rubric_dimension_scores": [{"dimension": "mastery", "score": score}], "notes": ["Prototype evaluator completed", notes], "token_count_estimate": len(job.submitted_text.split()), "decision_basis": ["response length heuristic", "single-dimension mastery proxy"]}
- update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes, trace=trace)
+ 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}"))
+ 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)
def main():
print("Didactopus worker scaffold running. Replace this with a real queue worker.")
while True:
time.sleep(60)
+
+if __name__ == "__main__":
+ main()
diff --git a/webui/index.html b/webui/index.html
index 5a90d2b..9cbd33a 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -3,7 +3,7 @@
- Didactopus Admin Curation Layer
+ Didactopus UI Workflows
diff --git a/webui/package.json b/webui/package.json
index fc49c73..28ef3ef 100644
--- a/webui/package.json
+++ b/webui/package.json
@@ -1,9 +1,17 @@
{
- "name": "didactopus-admin-curation-ui",
+ "name": "didactopus-ui-workflows",
"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"
+ }
}
diff --git a/webui/src/App.jsx b/webui/src/App.jsx
index 93a331a..8d8462b 100644
--- a/webui/src/App.jsx
+++ b/webui/src/App.jsx
@@ -1,18 +1,26 @@
-import React, { useEffect, useState } from "react";
-import { login, refresh, fetchPacks, fetchAdminPacks, fetchPackValidation, fetchPackProvenance, upsertPack, publishPack, listLearners, createLearner, fetchLearnerState, fetchRecommendations, postEvidence, submitEvaluatorJob, fetchEvaluatorHistory, fetchEvaluatorTrace } from "./api";
+import React, { useEffect, useMemo, useState } from "react";
+import {
+ login, refresh, fetchPacks, fetchAdminPacks, upsertPack, publishPack,
+ createLearner, listLearners, fetchLearnerState, fetchRecommendations, postEvidence,
+ submitEvaluatorJob, fetchEvaluatorHistory
+} from "./api";
import { loadAuth, saveAuth, clearAuth } from "./authStore";
function LoginView({ onAuth }) {
const [username, setUsername] = useState("wesley");
const [password, setPassword] = useState("demo-pass");
const [error, setError] = useState("");
+
async function doLogin() {
try {
const result = await login(username, password);
saveAuth(result);
onAuth(result);
- } catch { setError("Login failed"); }
+ } catch {
+ setError("Login failed");
+ }
}
+
return (
@@ -32,32 +40,7 @@ function NavTabs({ tab, setTab, role }) {
- {role === "admin" ? <>
-
-
- > : null}
-
- );
-}
-
-function PackAuthorForm({ value, onChange, onSave }) {
- function setField(field, val) { onChange({ ...value, [field]: val }); }
- function setCompliance(field, val) { onChange({ ...value, compliance: { ...value.compliance, [field]: val } }); }
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ {role === "admin" ?
: null}
);
}
@@ -73,128 +56,158 @@ export default function App() {
const [learnerState, setLearnerState] = useState(null);
const [cards, setCards] = useState([]);
const [history, setHistory] = useState([]);
- const [selectedTrace, setSelectedTrace] = useState(null);
- const [validation, setValidation] = useState(null);
- const [provenance, setProvenance] = useState(null);
const [newLearnerId, setNewLearnerId] = useState("wesley-learner");
- const [formPack, setFormPack] = useState({ id: "new-pack", title: "New Pack", subtitle: "Editable admin pack scaffold", level: "novice-friendly", concepts: [], onboarding: { headline: "Start here", body: "Begin", checklist: [] }, compliance: { sources: 0, attributionRequired: false, shareAlikeRequired: false, noncommercialOnly: false, flags: [] } });
+ const [newPackJson, setNewPackJson] = useState(JSON.stringify({
+ pack: {
+ id: "new-pack",
+ title: "New Pack",
+ subtitle: "Editable admin pack scaffold",
+ level: "novice-friendly",
+ concepts: [],
+ onboarding: { headline: "Start here", body: "Begin", checklist: [] },
+ compliance: { sources: 0, attributionRequired: false, shareAlikeRequired: false, noncommercialOnly: false, flags: [] }
+ },
+ is_published: false
+ }, null, 2));
const [message, setMessage] = useState("");
async function refreshAuthToken() {
- if (!auth?.refresh_token) return null;
+ if (!auth?.refresh_token) return null
try {
- const result = await refresh(auth.refresh_token);
- saveAuth(result);
- setAuth(result);
- return result;
+ const result = await refresh(auth.refresh_token)
+ saveAuth(result)
+ setAuth(result)
+ return result
} catch {
- clearAuth();
- setAuth(null);
- return null;
+ 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);
+ 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)
}
}
useEffect(() => {
- if (!auth) return;
+ if (!auth) return
async function load() {
- const p = await guarded((token) => fetchPacks(token));
- setPacks(p);
- setSelectedPackId((prev) => prev || p[0]?.id || "");
- let ls = await guarded((token) => listLearners(token));
+ const p = await guarded((token) => fetchPacks(token))
+ setPacks(p)
+ setSelectedPackId((prev) => prev || p[0]?.id || "")
+ const ls = await guarded((token) => listLearners(token))
+ setLearners(ls)
if (ls.length === 0) {
- await guarded((token) => createLearner(token, selectedLearnerId, selectedLearnerId));
- ls = await guarded((token) => listLearners(token));
+ await guarded((token) => createLearner(token, selectedLearnerId, selectedLearnerId))
+ const ls2 = await guarded((token) => listLearners(token))
+ setLearners(ls2)
}
- setLearners(ls);
if (auth.role === "admin") {
- const ap = await guarded((token) => fetchAdminPacks(token));
- setAdminPacks(ap);
+ const ap = await guarded((token) => fetchAdminPacks(token))
+ setAdminPacks(ap)
}
}
- load();
- }, [auth]);
+ load()
+ }, [auth])
useEffect(() => {
- if (!auth || !selectedLearnerId || !selectedPackId) return;
+ if (!auth || !selectedLearnerId || !selectedPackId) return
async function loadLearnerStuff() {
- setLearnerState(await guarded((token) => fetchLearnerState(token, selectedLearnerId)));
- const recs = await guarded((token) => fetchRecommendations(token, selectedLearnerId, selectedPackId));
- setCards(recs.cards || []);
- setHistory(await guarded((token) => fetchEvaluatorHistory(token, selectedLearnerId)));
- if (auth.role === "admin") {
- setValidation(await guarded((token) => fetchPackValidation(token, selectedPackId)));
- setProvenance(await guarded((token) => fetchPackProvenance(token, selectedPackId)));
- }
+ const state = await guarded((token) => fetchLearnerState(token, selectedLearnerId))
+ setLearnerState(state)
+ const recs = await guarded((token) => fetchRecommendations(token, selectedLearnerId, selectedPackId))
+ setCards(recs.cards || [])
+ const hist = await guarded((token) => fetchEvaluatorHistory(token, selectedLearnerId))
+ setHistory(hist)
}
- loadLearnerStuff();
- }, [auth, selectedLearnerId, selectedPackId]);
+ loadLearnerStuff()
+ }, [auth, selectedLearnerId, selectedPackId])
+
+ const pack = useMemo(() => packs.find((p) => p.id === selectedPackId) || null, [packs, selectedPackId])
async function simulateCard(card) {
- await guarded((token) => postEvidence(token, selectedLearnerId, { concept_id: card.conceptId, dimension: "mastery", score: card.scoreHint, confidence_hint: card.confidenceHint, timestamp: new Date().toISOString(), kind: "checkpoint", source_id: `ui-${card.id}` }));
- setLearnerState(await guarded((token) => fetchLearnerState(token, selectedLearnerId)));
- const recs = await guarded((token) => fetchRecommendations(token, selectedLearnerId, selectedPackId));
- setCards(recs.cards || []);
- setMessage(card.reward);
+ await guarded((token) => postEvidence(token, selectedLearnerId, {
+ concept_id: card.conceptId,
+ dimension: "mastery",
+ score: card.scoreHint,
+ confidence_hint: card.confidenceHint,
+ timestamp: new Date().toISOString(),
+ kind: "checkpoint",
+ source_id: `ui-${card.id}`
+ }))
+ const state = await guarded((token) => fetchLearnerState(token, selectedLearnerId))
+ setLearnerState(state)
+ const recs = await guarded((token) => fetchRecommendations(token, selectedLearnerId, selectedPackId))
+ setCards(recs.cards || [])
+ setMessage(card.reward)
}
async function runEvaluator() {
- const conceptId = packs.find((p) => p.id === selectedPackId)?.concepts?.[0]?.id || "prior";
- await guarded((token) => submitEvaluatorJob(token, selectedLearnerId, { pack_id: selectedPackId, concept_id: conceptId, submitted_text: "This is a moderately detailed learner response intended to trigger a somewhat better prototype evaluator score.", kind: "checkpoint" }));
+ if (!pack?.concepts?.length) return
+ await guarded((token) => submitEvaluatorJob(token, selectedLearnerId, {
+ pack_id: selectedPackId,
+ concept_id: pack.concepts[0].id,
+ submitted_text: "This is a moderately detailed learner response intended to trigger a somewhat better prototype evaluator score.",
+ kind: "checkpoint"
+ }))
setTimeout(async () => {
- setHistory(await guarded((token) => fetchEvaluatorHistory(token, selectedLearnerId)));
- setLearnerState(await guarded((token) => fetchLearnerState(token, selectedLearnerId)));
- const recs = await guarded((token) => fetchRecommendations(token, selectedLearnerId, selectedPackId));
- setCards(recs.cards || []);
- }, 1200);
+ const hist = await guarded((token) => fetchEvaluatorHistory(token, selectedLearnerId))
+ setHistory(hist)
+ const state = await guarded((token) => fetchLearnerState(token, selectedLearnerId))
+ setLearnerState(state)
+ const recs = await guarded((token) => fetchRecommendations(token, selectedLearnerId, selectedPackId))
+ setCards(recs.cards || [])
+ }, 1200)
}
async function createLearnerNow() {
- await guarded((token) => createLearner(token, newLearnerId, newLearnerId));
- const ls = await guarded((token) => listLearners(token));
- setLearners(ls);
- setSelectedLearnerId(newLearnerId);
+ await guarded((token) => createLearner(token, newLearnerId, newLearnerId))
+ const ls = await guarded((token) => listLearners(token))
+ setLearners(ls)
+ setSelectedLearnerId(newLearnerId)
}
async function savePack() {
- await guarded((token) => upsertPack(token, { pack: formPack, is_published: false }));
- setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
- setPacks(await guarded((token) => fetchPacks(token)));
- setMessage("Pack saved");
+ const payload = JSON.parse(newPackJson)
+ await guarded((token) => upsertPack(token, payload))
+ const ap = await guarded((token) => fetchAdminPacks(token))
+ const p = await guarded((token) => fetchPacks(token))
+ setAdminPacks(ap)
+ setPacks(p)
}
async function togglePublish(packId, isPublished) {
- await guarded((token) => publishPack(token, packId, isPublished));
- setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
- setPacks(await guarded((token) => fetchPacks(token)));
+ await guarded((token) => publishPack(token, packId, isPublished))
+ const ap = await guarded((token) => fetchAdminPacks(token))
+ const p = await guarded((token) => fetchPacks(token))
+ setAdminPacks(ap)
+ setPacks(p)
}
- async function loadTrace(jobId) {
- setSelectedTrace(await guarded((token) => fetchEvaluatorTrace(token, jobId)));
- }
-
- if (!auth) return ;
+ if (!auth) return
return (
-
Didactopus admin curation layer
-
Pack validation review, provenance inspection, evaluator traces, and form-driven pack authoring.
+
Didactopus workflow scaffold
+
Login, refresh, learner management, evaluator history, and admin pack publication workflows.
Signed in as {auth.username} ({auth.role})
{message ?
{message}
: null}
-
-
+
+
@@ -202,19 +215,28 @@ export default function App() {
{tab === "learner" && (
-
+
Learner dashboard
+ Pack: {pack?.title || "-"}
+ {pack?.subtitle || ""}
+ Next actions
{cards.length ? cards.map((card) => (
-
{card.title}
{card.minutes} minutes
+
+
{card.title}
+
{card.minutes} minutes
+
{card.reward}
{card.reason}
-
Why this is recommended
{card.why.map((w, idx) => - {w}
)}
+
+ Why this is recommended
+ {card.why.map((w, idx) => - {w}
)}
+
)) :
No recommendations available.
}
@@ -226,12 +248,12 @@ export default function App() {
)}
{tab === "history" && (
-
+
Evaluator history
{history.length ? (
- | Job | Status | Concept | Score | Trace |
+ | Job | Status | Concept | Score | Confidence | Notes |
{history.map((row) => (
@@ -239,17 +261,14 @@ export default function App() {
| {row.status} |
{row.concept_id} |
{row.result_score ?? "-"} |
- |
+ {row.result_confidence_hint ?? "-"} |
+ {row.result_notes || ""} |
))}
) : No evaluator jobs yet.
}
-
- Evaluator trace
- {JSON.stringify(selectedTrace, null, 2)}
-
)}
@@ -281,8 +300,9 @@ export default function App() {
{tab === "admin" && auth.role === "admin" && (
- Richer pack authoring
-
+ Pack editor
+
Pack administration
@@ -302,19 +322,6 @@ export default function App() {
)}
-
- {tab === "review" && auth.role === "admin" && (
-
-
- Pack validation review
- {JSON.stringify(validation, null, 2)}
-
-
- Attribution / provenance inspection
- {JSON.stringify(provenance, null, 2)}
-
-
- )}
- );
+ )
}
diff --git a/webui/src/api.js b/webui/src/api.js
index 30ae221..71b5070 100644
--- a/webui/src/api.js
+++ b/webui/src/api.js
@@ -1,34 +1,112 @@
const API = "http://127.0.0.1:8011/api";
function authHeaders(token, json=true) {
- const h = { Authorization: `Bearer ${token}` };
+ 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");
+ 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 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");
+ 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");
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 fetchAdminPacks(token) { const res = await fetch(`${API}/admin/packs`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchAdminPacks failed"); return await res.json(); }
-export async function fetchPackValidation(token, packId) { const res = await fetch(`${API}/admin/packs/${packId}/validation`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPackValidation failed"); return await res.json(); }
-export async function fetchPackProvenance(token, packId) { const res = await fetch(`${API}/admin/packs/${packId}/provenance`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPackProvenance failed"); return await res.json(); }
-export async function upsertPack(token, payload) { const res = await fetch(`${API}/admin/packs`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("upsertPack failed"); return await res.json(); }
-export async function publishPack(token, packId, isPublished) { const res = await fetch(`${API}/admin/packs/${packId}/publish?is_published=${isPublished}`, { method: "POST", headers: authHeaders(token, false) }); if (!res.ok) throw new Error("publishPack failed"); return await res.json(); }
-export async function listLearners(token) { const res = await fetch(`${API}/learners`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listLearners failed"); return await res.json(); }
-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 }) }); if (!res.ok) throw new Error("createLearner 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 fetchRecommendations(token, learnerId, packId) { const res = await fetch(`${API}/learners/${learnerId}/recommendations/${packId}`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchRecommendations failed"); 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) }); if (!res.ok) throw new Error("postEvidence failed"); 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) }); if (!res.ok) throw new Error("submitEvaluatorJob failed"); return await res.json(); }
-export async function fetchEvaluatorHistory(token, learnerId) { const res = await fetch(`${API}/learners/${learnerId}/evaluator-history`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchEvaluatorHistory failed"); return await res.json(); }
-export async function fetchEvaluatorTrace(token, jobId) { const res = await fetch(`${API}/evaluator-jobs/${jobId}/trace`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchEvaluatorTrace failed"); 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 fetchAdminPacks(token) {
+ const res = await fetch(`${API}/admin/packs`, { headers: authHeaders(token, false) });
+ if (!res.ok) throw new Error("fetchAdminPacks failed");
+ return await res.json();
+}
+
+export async function upsertPack(token, payload) {
+ const res = await fetch(`${API}/admin/packs`, {
+ method: "POST",
+ headers: authHeaders(token),
+ body: JSON.stringify(payload)
+ });
+ if (!res.ok) throw new Error("upsertPack failed");
+ return await res.json();
+}
+
+export async function publishPack(token, packId, isPublished) {
+ const res = await fetch(`${API}/admin/packs/${packId}/publish?is_published=${isPublished}`, {
+ method: "POST",
+ headers: authHeaders(token, false)
+ });
+ if (!res.ok) throw new Error("publishPack failed");
+ return await res.json();
+}
+
+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 })
+ });
+ if (!res.ok) throw new Error("createLearner failed");
+ return await res.json();
+}
+
+export async function listLearners(token) {
+ const res = await fetch(`${API}/learners`, { headers: authHeaders(token, false) });
+ if (!res.ok) throw new Error("listLearners 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 fetchRecommendations(token, learnerId, packId) {
+ const res = await fetch(`${API}/learners/${learnerId}/recommendations/${packId}`, { headers: authHeaders(token, false) });
+ if (!res.ok) throw new Error("fetchRecommendations failed");
+ 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)
+ });
+ if (!res.ok) throw new Error("postEvidence failed");
+ 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)
+ });
+ if (!res.ok) throw new Error("submitEvaluatorJob failed");
+ return await res.json();
+}
+
+export async function fetchEvaluatorHistory(token, learnerId) {
+ const res = await fetch(`${API}/learners/${learnerId}/evaluator-history`, { headers: authHeaders(token, false) });
+ if (!res.ok) throw new Error("fetchEvaluatorHistory failed");
+ return await res.json();
+}
diff --git a/webui/src/authStore.js b/webui/src/authStore.js
index c208022..7e1f4d4 100644
--- a/webui/src/authStore.js
+++ b/webui/src/authStore.js
@@ -1,4 +1,17 @@
const KEY = "didactopus-auth";
-export function loadAuth() { try { return JSON.parse(localStorage.getItem(KEY) || "null"); } catch { return null; } }
-export function saveAuth(data) { localStorage.setItem(KEY, JSON.stringify(data)); }
-export function clearAuth() { localStorage.removeItem(KEY); }
+
+export function loadAuth() {
+ try {
+ return JSON.parse(localStorage.getItem(KEY) || "null");
+ } catch {
+ return null;
+ }
+}
+
+export function saveAuth(data) {
+ localStorage.setItem(KEY, JSON.stringify(data));
+}
+
+export function clearAuth() {
+ localStorage.removeItem(KEY);
+}
diff --git a/webui/src/main.jsx b/webui/src/main.jsx
index 7352818..8ad26cf 100644
--- a/webui/src/main.jsx
+++ b/webui/src/main.jsx
@@ -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();
diff --git a/webui/src/styles.css b/webui/src/styles.css
index 334c6b7..56b99f2 100644
--- a/webui/src/styles.css
+++ b/webui/src/styles.css
@@ -3,7 +3,7 @@
}
* { 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; }
+.page { max-width:1400px; 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; }
.hero-controls { min-width:280px; display:flex; flex-direction:column; gap:12px; }
@@ -11,10 +11,10 @@ label { display:block; font-weight:600; }
input, select, textarea { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; }
.card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; }
.narrow { margin-top:60px; }
-.tab-row { display:flex; gap:10px; margin:16px 0; flex-wrap:wrap; }
+.tab-row { display:flex; gap:10px; margin:16px 0; }
.tab-row button, button { border:1px solid var(--border); background:white; border-radius:12px; padding:10px 14px; cursor:pointer; }
.active-tab, .primary { background:var(--accent); color:white; border-color:var(--accent); }
-.layout { display:grid; gap:16px; }
+.layout { display:grid; grid-template-columns:1fr; gap:16px; }
.twocol { grid-template-columns:1fr 1fr; }
.onecol { grid-template-columns:1fr; }
.steps-stack { display:grid; gap:14px; }
@@ -26,12 +26,10 @@ input, select, textarea { width:100%; margin-top:6px; border:1px solid var(--bor
.message { margin-top:10px; padding:10px 12px; border:1px solid var(--border); background:#fff7dd; border-radius:12px; }
.table { width:100%; border-collapse:collapse; }
.table th, .table td { border-bottom:1px solid var(--border); text-align:left; padding:8px; vertical-align:top; }
-.prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:420px; }
-.form-grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
-.full { grid-column:1 / -1; }
-.checkrow { display:flex; gap:16px; flex-wrap:wrap; align-items:center; }
+.prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:360px; }
+.bigtext { min-height:340px; font-family:monospace; }
details summary { cursor:pointer; color:var(--accent); }
@media (max-width:1100px) {
.hero { flex-direction:column; }
- .twocol, .form-grid { grid-template-columns:1fr; }
+ .twocol { grid-template-columns:1fr; }
}