Apply ZIP update: 140-didactopus-admin-learner-ui-workflows.zip [2026-03-14T13:19:20]

This commit is contained in:
welsberr 2026-03-14 13:29:55 -04:00
parent 5b64420315
commit 5b4221d430
15 changed files with 367 additions and 252 deletions

View File

@ -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()

View File

@ -20,10 +20,10 @@ def _encode_token(payload: dict, expires_delta: timedelta) -> str:
return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
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:

View File

@ -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()

View File

@ -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

View File

@ -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="{}")

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Didactopus Admin Curation Layer</title>
<title>Didactopus UI Workflows</title>
<script type="module" src="/src/main.jsx"></script>
</head>
<body><div id="root"></div></body>

View File

@ -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"
}
}

View File

@ -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 (
<div className="page narrow-page">
<section className="card narrow">
@ -32,32 +40,7 @@ function NavTabs({ tab, setTab, role }) {
<button className={tab==="learner" ? "active-tab" : ""} onClick={() => setTab("learner")}>Learner</button>
<button className={tab==="history" ? "active-tab" : ""} onClick={() => setTab("history")}>Evaluator history</button>
<button className={tab==="manage" ? "active-tab" : ""} onClick={() => setTab("manage")}>Learners</button>
{role === "admin" ? <>
<button className={tab==="admin" ? "active-tab" : ""} onClick={() => setTab("admin")}>Pack admin</button>
<button className={tab==="review" ? "active-tab" : ""} onClick={() => setTab("review")}>Curation review</button>
</> : null}
</div>
);
}
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 (
<div className="form-grid">
<label>Pack ID<input value={value.id} onChange={(e) => setField("id", e.target.value)} /></label>
<label>Title<input value={value.title} onChange={(e) => setField("title", e.target.value)} /></label>
<label className="full">Subtitle<input value={value.subtitle} onChange={(e) => setField("subtitle", e.target.value)} /></label>
<label>Level<input value={value.level} onChange={(e) => setField("level", e.target.value)} /></label>
<label>Source count<input type="number" value={value.compliance.sources} onChange={(e) => setCompliance("sources", Number(e.target.value))} /></label>
<label className="full">Onboarding headline<input value={value.onboarding.headline} onChange={(e) => onChange({ ...value, onboarding: { ...value.onboarding, headline: e.target.value } })} /></label>
<label className="full">Onboarding body<textarea value={value.onboarding.body} onChange={(e) => onChange({ ...value, onboarding: { ...value.onboarding, body: e.target.value } })} /></label>
<div className="checkrow full">
<label><input type="checkbox" checked={value.compliance.attributionRequired} onChange={(e) => setCompliance("attributionRequired", e.target.checked)} /> Attribution required</label>
<label><input type="checkbox" checked={value.compliance.shareAlikeRequired} onChange={(e) => setCompliance("shareAlikeRequired", e.target.checked)} /> Share-alike</label>
<label><input type="checkbox" checked={value.compliance.noncommercialOnly} onChange={(e) => setCompliance("noncommercialOnly", e.target.checked)} /> Noncommercial only</label>
</div>
<div className="full"><button className="primary" onClick={onSave}>Save pack</button></div>
{role === "admin" ? <button className={tab==="admin" ? "active-tab" : ""} onClick={() => setTab("admin")}>Pack admin</button> : null}
</div>
);
}
@ -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 <LoginView onAuth={setAuth} />;
if (!auth) return <LoginView onAuth={setAuth} />
return (
<div className="page">
<header className="hero">
<div>
<h1>Didactopus admin curation layer</h1>
<p>Pack validation review, provenance inspection, evaluator traces, and form-driven pack authoring.</p>
<h1>Didactopus workflow scaffold</h1>
<p>Login, refresh, learner management, evaluator history, and admin pack publication workflows.</p>
<div className="muted">Signed in as {auth.username} ({auth.role})</div>
{message ? <div className="message">{message}</div> : null}
</div>
<div className="hero-controls">
<label>Learner<select value={selectedLearnerId} onChange={(e) => setSelectedLearnerId(e.target.value)}>{learners.map((l) => <option key={l.learner_id} value={l.learner_id}>{l.display_name || l.learner_id}</option>)}</select></label>
<label>Pack<select value={selectedPackId} onChange={(e) => setSelectedPackId(e.target.value)}>{packs.map((p) => <option key={p.id} value={p.id}>{p.title}</option>)}</select></label>
<label>Learner<select value={selectedLearnerId} onChange={(e) => setSelectedLearnerId(e.target.value)}>
{learners.map((l) => <option key={l.learner_id} value={l.learner_id}>{l.display_name || l.learner_id}</option>)}
</select></label>
<label>Pack<select value={selectedPackId} onChange={(e) => setSelectedPackId(e.target.value)}>
{packs.map((p) => <option key={p.id} value={p.id}>{p.title}</option>)}
</select></label>
<button onClick={() => { clearAuth(); setAuth(null); }}>Logout</button>
</div>
</header>
@ -202,19 +215,28 @@ export default function App() {
<NavTabs tab={tab} setTab={setTab} role={auth.role} />
{tab === "learner" && (
<main className="layout onecol">
<main className="layout">
<section className="card">
<h2>Learner dashboard</h2>
<p><strong>Pack:</strong> {pack?.title || "-"}</p>
<p>{pack?.subtitle || ""}</p>
<button onClick={runEvaluator}>Submit demo evaluator job</button>
<h3>Next actions</h3>
<div className="steps-stack">
{cards.length ? cards.map((card) => (
<div key={card.id} className="step-card">
<div className="step-header">
<div><h4>{card.title}</h4><div className="muted">{card.minutes} minutes</div></div>
<div>
<h4>{card.title}</h4>
<div className="muted">{card.minutes} minutes</div>
</div>
<div className="reward-pill">{card.reward}</div>
</div>
<p>{card.reason}</p>
<details><summary>Why this is recommended</summary><ul>{card.why.map((w, idx) => <li key={idx}>{w}</li>)}</ul></details>
<details>
<summary>Why this is recommended</summary>
<ul>{card.why.map((w, idx) => <li key={idx}>{w}</li>)}</ul>
</details>
<button className="primary" onClick={() => simulateCard(card)}>Simulate step</button>
</div>
)) : <div className="muted">No recommendations available.</div>}
@ -226,12 +248,12 @@ export default function App() {
)}
{tab === "history" && (
<main className="layout twocol">
<main className="layout onecol">
<section className="card">
<h2>Evaluator history</h2>
{history.length ? (
<table className="table">
<thead><tr><th>Job</th><th>Status</th><th>Concept</th><th>Score</th><th>Trace</th></tr></thead>
<thead><tr><th>Job</th><th>Status</th><th>Concept</th><th>Score</th><th>Confidence</th><th>Notes</th></tr></thead>
<tbody>
{history.map((row) => (
<tr key={row.job_id}>
@ -239,17 +261,14 @@ export default function App() {
<td>{row.status}</td>
<td>{row.concept_id}</td>
<td>{row.result_score ?? "-"}</td>
<td><button onClick={() => loadTrace(row.job_id)}>Inspect trace</button></td>
<td>{row.result_confidence_hint ?? "-"}</td>
<td>{row.result_notes || ""}</td>
</tr>
))}
</tbody>
</table>
) : <div className="muted">No evaluator jobs yet.</div>}
</section>
<section className="card">
<h2>Evaluator trace</h2>
<pre className="prebox">{JSON.stringify(selectedTrace, null, 2)}</pre>
</section>
</main>
)}
@ -281,8 +300,9 @@ export default function App() {
{tab === "admin" && auth.role === "admin" && (
<main className="layout twocol">
<section className="card">
<h2>Richer pack authoring</h2>
<PackAuthorForm value={formPack} onChange={setFormPack} onSave={savePack} />
<h2>Pack editor</h2>
<textarea className="bigtext" value={newPackJson} onChange={(e) => setNewPackJson(e.target.value)} />
<button className="primary" onClick={savePack}>Save pack</button>
</section>
<section className="card">
<h2>Pack administration</h2>
@ -302,19 +322,6 @@ export default function App() {
</section>
</main>
)}
{tab === "review" && auth.role === "admin" && (
<main className="layout twocol">
<section className="card">
<h2>Pack validation review</h2>
<pre className="prebox">{JSON.stringify(validation, null, 2)}</pre>
</section>
<section className="card">
<h2>Attribution / provenance inspection</h2>
<pre className="prebox">{JSON.stringify(provenance, null, 2)}</pre>
</section>
</main>
)}
</div>
);
)
}

View File

@ -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();
}

View File

@ -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);
}

View File

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

View File

@ -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; }
}