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 from __future__ import annotations
import json
from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
import uvicorn import uvicorn
from .config import load_settings from .config import load_settings
from .db import Base, engine from .db import Base, engine
from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest 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 .engine import apply_evidence, recommend_next
from .auth import issue_access_token, issue_refresh_token, decode_token, new_token_id from .auth import issue_access_token, issue_refresh_token, decode_token, new_token_id
from .worker import process_job from .worker import process_job
@ -15,7 +19,13 @@ settings = load_settings()
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
app = FastAPI(title="Didactopus API Prototype") app = FastAPI(title="Didactopus API Prototype")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
def current_user(authorization: str = Header(default="")): def current_user(authorization: str = Header(default="")):
token = authorization.removeprefix("Bearer ").strip() token = authorization.removeprefix("Bearer ").strip()
@ -45,7 +55,12 @@ def login(payload: LoginRequest):
raise HTTPException(status_code=401, detail="Invalid credentials") raise HTTPException(status_code=401, detail="Invalid credentials")
token_id = new_token_id() token_id = new_token_id()
store_refresh_token(user.id, 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) @app.post("/api/refresh", response_model=TokenPair)
def refresh(payload: RefreshRequest): def refresh(payload: RefreshRequest):
@ -61,24 +76,22 @@ def refresh(payload: RefreshRequest):
revoke_refresh_token(token_id) revoke_refresh_token(token_id)
new_jti = new_token_id() new_jti = new_token_id()
store_refresh_token(user.id, new_jti) 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") @app.get("/api/packs")
def api_list_packs(user = Depends(current_user)): def api_list_packs(user = Depends(current_user)):
return [p.model_dump() for p in list_packs(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") @app.get("/api/admin/packs")
def api_admin_list_packs(user = Depends(require_admin)): def api_admin_list_packs(user = Depends(require_admin)):
return list_pack_admin_rows() 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") @app.post("/api/admin/packs")
def api_upsert_pack(payload: CreatePackRequest, user = Depends(require_admin)): def api_upsert_pack(payload: CreatePackRequest, user = Depends(require_admin)):
upsert_pack(payload.pack, is_published=payload.is_published) 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") 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) 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") @app.get("/api/learners/{learner_id}/evaluator-history")
def api_get_evaluator_history(learner_id: str, user = Depends(current_user)): def api_get_evaluator_history(learner_id: str, user = Depends(current_user)):
ensure_learner_access(user, learner_id) ensure_learner_access(user, learner_id)
@ -158,3 +164,6 @@ def api_get_evaluator_history(learner_id: str, user = Depends(current_user)):
def main(): def main():
uvicorn.run(app, host=settings.host, port=settings.port) 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) return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def issue_access_token(user_id: int, username: str, role: str) -> str: 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: 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: def decode_token(token: str) -> dict | None:
try: try:

View File

@ -8,6 +8,8 @@ class Settings(BaseModel):
port: int = int(os.getenv("DIDACTOPUS_PORT", "8011")) port: int = int(os.getenv("DIDACTOPUS_PORT", "8011"))
jwt_secret: str = os.getenv("DIDACTOPUS_JWT_SECRET", "change-me") jwt_secret: str = os.getenv("DIDACTOPUS_JWT_SECRET", "change-me")
jwt_algorithm: str = "HS256" jwt_algorithm: str = "HS256"
access_token_minutes: int = 30
refresh_token_days: int = 14
def load_settings() -> Settings: def load_settings() -> Settings:
return Settings() return Settings()

View File

@ -41,14 +41,6 @@ class PackData(BaseModel):
onboarding: dict = Field(default_factory=dict) onboarding: dict = Field(default_factory=dict)
compliance: PackCompliance = Field(default_factory=PackCompliance) 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): class MasteryRecord(BaseModel):
concept_id: str concept_id: str
dimension: str dimension: str
@ -71,6 +63,10 @@ class LearnerState(BaseModel):
records: list[MasteryRecord] = Field(default_factory=list) records: list[MasteryRecord] = Field(default_factory=list)
history: list[EvidenceEvent] = Field(default_factory=list) history: list[EvidenceEvent] = Field(default_factory=list)
class CreateLearnerRequest(BaseModel):
learner_id: str
display_name: str = ""
class EvaluatorSubmission(BaseModel): class EvaluatorSubmission(BaseModel):
pack_id: str pack_id: str
concept_id: str concept_id: str
@ -83,3 +79,7 @@ class EvaluatorJobStatus(BaseModel):
result_score: float | None = None result_score: float | None = None
result_confidence_hint: float | None = None result_confidence_hint: float | None = None
result_notes: str = "" 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="") subtitle: Mapped[str] = mapped_column(Text, default="")
level: Mapped[str] = mapped_column(String(100), default="novice-friendly") level: Mapped[str] = mapped_column(String(100), default="novice-friendly")
data_json: Mapped[str] = mapped_column(Text) data_json: Mapped[str] = mapped_column(Text)
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) is_published: Mapped[bool] = mapped_column(Boolean, default=True)
class LearnerORM(Base): class LearnerORM(Base):
@ -68,4 +66,3 @@ class EvaluatorJobORM(Base):
result_score: Mapped[float | None] = mapped_column(Float, nullable=True) result_score: Mapped[float | None] = mapped_column(Float, nullable=True)
result_confidence_hint: 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="") 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 row.is_revoked = True
db.commit() db.commit()
def list_packs(include_unpublished: bool = False): def list_packs(include_unpublished: bool = False) -> list[PackData]:
with SessionLocal() as db: with SessionLocal() as db:
stmt = select(PackORM) stmt = select(PackORM)
if not include_unpublished: if not include_unpublished:
stmt = stmt.where(PackORM.is_published == True) 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 db.execute(stmt).scalars().all()]
return [PackData.model_validate(json.loads(r.data_json)) for r in rows]
def list_pack_admin_rows(): def list_pack_admin_rows():
with SessionLocal() as db: with SessionLocal() as db:
@ -55,43 +54,17 @@ def get_pack(pack_id: str):
row = db.get(PackORM, pack_id) row = db.get(PackORM, pack_id)
return None if row is None else PackData.model_validate(json.loads(row.data_json)) 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): 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: with SessionLocal() as db:
row = db.get(PackORM, pack.id) row = db.get(PackORM, pack.id)
payload = json.dumps(pack.model_dump()) payload = json.dumps(pack.model_dump())
if row is None: 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: else:
row.title = pack.title row.title = pack.title
row.subtitle = pack.subtitle row.subtitle = pack.subtitle
row.level = pack.level row.level = pack.level
row.data_json = payload row.data_json = payload
row.validation_json = json.dumps(validation)
row.provenance_json = json.dumps(provenance)
row.is_published = is_published row.is_published = is_published
db.commit() db.commit()
@ -123,7 +96,7 @@ def learner_owned_by_user(user_id: int, learner_id: str) -> bool:
learner = db.get(LearnerORM, learner_id) learner = db.get(LearnerORM, learner_id)
return learner is not None and learner.owner_user_id == user_id return learner is not None and learner.owner_user_id == user_id
def load_learner_state(learner_id: str): def load_learner_state(learner_id: str) -> LearnerState:
with SessionLocal() as db: with SessionLocal() as db:
records = db.execute(select(MasteryRecordORM).where(MasteryRecordORM.learner_id == learner_id)).scalars().all() records = db.execute(select(MasteryRecordORM).where(MasteryRecordORM.learner_id == learner_id)).scalars().all()
history = db.execute(select(EvidenceEventORM).where(EvidenceEventORM.learner_id == learner_id)).scalars().all() history = db.execute(select(EvidenceEventORM).where(EvidenceEventORM.learner_id == learner_id)).scalars().all()
@ -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: def create_evaluator_job(learner_id: str, pack_id: str, concept_id: str, submitted_text: str) -> int:
with SessionLocal() as db: 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")
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))
db.add(job) db.add(job)
db.commit() db.commit()
db.refresh(job) 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): def list_evaluator_jobs_for_learner(learner_id: str):
with SessionLocal() as db: 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): def get_evaluator_job(job_id: int):
with SessionLocal() as db: with SessionLocal() as db:
return db.get(EvaluatorJobORM, job_id) 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: with SessionLocal() as db:
job = db.get(EvaluatorJobORM, job_id) job = db.get(EvaluatorJobORM, job_id)
if job is None: 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_score = score
job.result_confidence_hint = confidence_hint job.result_confidence_hint = confidence_hint
job.result_notes = notes job.result_notes = notes
if trace is not None:
job.trace_json = json.dumps(trace)
db.commit() db.commit()

View File

@ -1,27 +1,48 @@
from __future__ import annotations from __future__ import annotations
import json
from sqlalchemy import select from sqlalchemy import select
from .db import Base, engine, SessionLocal from .db import Base, engine, SessionLocal
from .orm import UserORM from .orm import UserORM, PackORM
from .auth import hash_password from .auth import hash_password
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(): def main():
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
with SessionLocal() as db: with SessionLocal() as db:
if db.execute(select(UserORM).where(UserORM.username == "wesley")).scalar_one_or_none() is None: 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)) 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() 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") 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) job = get_evaluator_job(job_id)
if job is None: if job is None:
return 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 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 confidence_hint = 0.72 if len(job.submitted_text.strip()) > 20 else 0.45
notes = "Prototype evaluator: longer responses scored somewhat higher." 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)
update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes, trace=trace)
state = load_learner_state(job.learner_id) 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) save_learner_state(state)
def main(): def main():
print("Didactopus worker scaffold running. Replace this with a real queue worker.") print("Didactopus worker scaffold running. Replace this with a real queue worker.")
while True: while True:
time.sleep(60) time.sleep(60)
if __name__ == "__main__":
main()

View File

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

View File

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

View File

@ -1,18 +1,26 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { login, refresh, fetchPacks, fetchAdminPacks, fetchPackValidation, fetchPackProvenance, upsertPack, publishPack, listLearners, createLearner, fetchLearnerState, fetchRecommendations, postEvidence, submitEvaluatorJob, fetchEvaluatorHistory, fetchEvaluatorTrace } from "./api"; import {
login, refresh, fetchPacks, fetchAdminPacks, upsertPack, publishPack,
createLearner, listLearners, fetchLearnerState, fetchRecommendations, postEvidence,
submitEvaluatorJob, fetchEvaluatorHistory
} from "./api";
import { loadAuth, saveAuth, clearAuth } from "./authStore"; import { loadAuth, saveAuth, clearAuth } from "./authStore";
function LoginView({ onAuth }) { function LoginView({ onAuth }) {
const [username, setUsername] = useState("wesley"); const [username, setUsername] = useState("wesley");
const [password, setPassword] = useState("demo-pass"); const [password, setPassword] = useState("demo-pass");
const [error, setError] = useState(""); const [error, setError] = useState("");
async function doLogin() { async function doLogin() {
try { try {
const result = await login(username, password); const result = await login(username, password);
saveAuth(result); saveAuth(result);
onAuth(result); onAuth(result);
} catch { setError("Login failed"); } } catch {
setError("Login failed");
} }
}
return ( return (
<div className="page narrow-page"> <div className="page narrow-page">
<section className="card narrow"> <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==="learner" ? "active-tab" : ""} onClick={() => setTab("learner")}>Learner</button>
<button className={tab==="history" ? "active-tab" : ""} onClick={() => setTab("history")}>Evaluator history</button> <button className={tab==="history" ? "active-tab" : ""} onClick={() => setTab("history")}>Evaluator history</button>
<button className={tab==="manage" ? "active-tab" : ""} onClick={() => setTab("manage")}>Learners</button> <button className={tab==="manage" ? "active-tab" : ""} onClick={() => setTab("manage")}>Learners</button>
{role === "admin" ? <> {role === "admin" ? <button className={tab==="admin" ? "active-tab" : ""} onClick={() => setTab("admin")}>Pack admin</button> : null}
<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>
</div> </div>
); );
} }
@ -73,128 +56,158 @@ export default function App() {
const [learnerState, setLearnerState] = useState(null); const [learnerState, setLearnerState] = useState(null);
const [cards, setCards] = useState([]); const [cards, setCards] = useState([]);
const [history, setHistory] = 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 [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(""); const [message, setMessage] = useState("");
async function refreshAuthToken() { async function refreshAuthToken() {
if (!auth?.refresh_token) return null; if (!auth?.refresh_token) return null
try { try {
const result = await refresh(auth.refresh_token); const result = await refresh(auth.refresh_token)
saveAuth(result); saveAuth(result)
setAuth(result); setAuth(result)
return result; return result
} catch { } catch {
clearAuth(); clearAuth()
setAuth(null); setAuth(null)
return null; return null
} }
} }
async function guarded(fn) { async function guarded(fn) {
try { return await fn(auth.access_token); } try {
catch { return await fn(auth.access_token)
const next = await refreshAuthToken(); } catch {
if (!next) throw new Error("auth failed"); const next = await refreshAuthToken()
return await fn(next.access_token); if (!next) throw new Error("auth failed")
return await fn(next.access_token)
} }
} }
useEffect(() => { useEffect(() => {
if (!auth) return; if (!auth) return
async function load() { async function load() {
const p = await guarded((token) => fetchPacks(token)); const p = await guarded((token) => fetchPacks(token))
setPacks(p); setPacks(p)
setSelectedPackId((prev) => prev || p[0]?.id || ""); setSelectedPackId((prev) => prev || p[0]?.id || "")
let ls = await guarded((token) => listLearners(token)); const ls = await guarded((token) => listLearners(token))
setLearners(ls)
if (ls.length === 0) { if (ls.length === 0) {
await guarded((token) => createLearner(token, selectedLearnerId, selectedLearnerId)); await guarded((token) => createLearner(token, selectedLearnerId, selectedLearnerId))
ls = await guarded((token) => listLearners(token)); const ls2 = await guarded((token) => listLearners(token))
setLearners(ls2)
} }
setLearners(ls);
if (auth.role === "admin") { if (auth.role === "admin") {
const ap = await guarded((token) => fetchAdminPacks(token)); const ap = await guarded((token) => fetchAdminPacks(token))
setAdminPacks(ap); setAdminPacks(ap)
} }
} }
load(); load()
}, [auth]); }, [auth])
useEffect(() => { useEffect(() => {
if (!auth || !selectedLearnerId || !selectedPackId) return; if (!auth || !selectedLearnerId || !selectedPackId) return
async function loadLearnerStuff() { async function loadLearnerStuff() {
setLearnerState(await guarded((token) => fetchLearnerState(token, selectedLearnerId))); const state = await guarded((token) => fetchLearnerState(token, selectedLearnerId))
const recs = await guarded((token) => fetchRecommendations(token, selectedLearnerId, selectedPackId)); setLearnerState(state)
setCards(recs.cards || []); const recs = await guarded((token) => fetchRecommendations(token, selectedLearnerId, selectedPackId))
setHistory(await guarded((token) => fetchEvaluatorHistory(token, selectedLearnerId))); setCards(recs.cards || [])
if (auth.role === "admin") { const hist = await guarded((token) => fetchEvaluatorHistory(token, selectedLearnerId))
setValidation(await guarded((token) => fetchPackValidation(token, selectedPackId))); setHistory(hist)
setProvenance(await guarded((token) => fetchPackProvenance(token, selectedPackId)));
} }
} loadLearnerStuff()
loadLearnerStuff(); }, [auth, selectedLearnerId, selectedPackId])
}, [auth, selectedLearnerId, selectedPackId]);
const pack = useMemo(() => packs.find((p) => p.id === selectedPackId) || null, [packs, selectedPackId])
async function simulateCard(card) { 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}` })); await guarded((token) => postEvidence(token, selectedLearnerId, {
setLearnerState(await guarded((token) => fetchLearnerState(token, selectedLearnerId))); concept_id: card.conceptId,
const recs = await guarded((token) => fetchRecommendations(token, selectedLearnerId, selectedPackId)); dimension: "mastery",
setCards(recs.cards || []); score: card.scoreHint,
setMessage(card.reward); 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() { async function runEvaluator() {
const conceptId = packs.find((p) => p.id === selectedPackId)?.concepts?.[0]?.id || "prior"; if (!pack?.concepts?.length) return
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" })); 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 () => { setTimeout(async () => {
setHistory(await guarded((token) => fetchEvaluatorHistory(token, selectedLearnerId))); const hist = await guarded((token) => fetchEvaluatorHistory(token, selectedLearnerId))
setLearnerState(await guarded((token) => fetchLearnerState(token, selectedLearnerId))); setHistory(hist)
const recs = await guarded((token) => fetchRecommendations(token, selectedLearnerId, selectedPackId)); const state = await guarded((token) => fetchLearnerState(token, selectedLearnerId))
setCards(recs.cards || []); setLearnerState(state)
}, 1200); const recs = await guarded((token) => fetchRecommendations(token, selectedLearnerId, selectedPackId))
setCards(recs.cards || [])
}, 1200)
} }
async function createLearnerNow() { async function createLearnerNow() {
await guarded((token) => createLearner(token, newLearnerId, newLearnerId)); await guarded((token) => createLearner(token, newLearnerId, newLearnerId))
const ls = await guarded((token) => listLearners(token)); const ls = await guarded((token) => listLearners(token))
setLearners(ls); setLearners(ls)
setSelectedLearnerId(newLearnerId); setSelectedLearnerId(newLearnerId)
} }
async function savePack() { async function savePack() {
await guarded((token) => upsertPack(token, { pack: formPack, is_published: false })); const payload = JSON.parse(newPackJson)
setAdminPacks(await guarded((token) => fetchAdminPacks(token))); await guarded((token) => upsertPack(token, payload))
setPacks(await guarded((token) => fetchPacks(token))); const ap = await guarded((token) => fetchAdminPacks(token))
setMessage("Pack saved"); const p = await guarded((token) => fetchPacks(token))
setAdminPacks(ap)
setPacks(p)
} }
async function togglePublish(packId, isPublished) { async function togglePublish(packId, isPublished) {
await guarded((token) => publishPack(token, packId, isPublished)); await guarded((token) => publishPack(token, packId, isPublished))
setAdminPacks(await guarded((token) => fetchAdminPacks(token))); const ap = await guarded((token) => fetchAdminPacks(token))
setPacks(await guarded((token) => fetchPacks(token))); const p = await guarded((token) => fetchPacks(token))
setAdminPacks(ap)
setPacks(p)
} }
async function loadTrace(jobId) { if (!auth) return <LoginView onAuth={setAuth} />
setSelectedTrace(await guarded((token) => fetchEvaluatorTrace(token, jobId)));
}
if (!auth) return <LoginView onAuth={setAuth} />;
return ( return (
<div className="page"> <div className="page">
<header className="hero"> <header className="hero">
<div> <div>
<h1>Didactopus admin curation layer</h1> <h1>Didactopus workflow scaffold</h1>
<p>Pack validation review, provenance inspection, evaluator traces, and form-driven pack authoring.</p> <p>Login, refresh, learner management, evaluator history, and admin pack publication workflows.</p>
<div className="muted">Signed in as {auth.username} ({auth.role})</div> <div className="muted">Signed in as {auth.username} ({auth.role})</div>
{message ? <div className="message">{message}</div> : null} {message ? <div className="message">{message}</div> : null}
</div> </div>
<div className="hero-controls"> <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>Learner<select value={selectedLearnerId} onChange={(e) => setSelectedLearnerId(e.target.value)}>
<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> {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> <button onClick={() => { clearAuth(); setAuth(null); }}>Logout</button>
</div> </div>
</header> </header>
@ -202,19 +215,28 @@ export default function App() {
<NavTabs tab={tab} setTab={setTab} role={auth.role} /> <NavTabs tab={tab} setTab={setTab} role={auth.role} />
{tab === "learner" && ( {tab === "learner" && (
<main className="layout onecol"> <main className="layout">
<section className="card"> <section className="card">
<h2>Learner dashboard</h2> <h2>Learner dashboard</h2>
<p><strong>Pack:</strong> {pack?.title || "-"}</p>
<p>{pack?.subtitle || ""}</p>
<button onClick={runEvaluator}>Submit demo evaluator job</button> <button onClick={runEvaluator}>Submit demo evaluator job</button>
<h3>Next actions</h3>
<div className="steps-stack"> <div className="steps-stack">
{cards.length ? cards.map((card) => ( {cards.length ? cards.map((card) => (
<div key={card.id} className="step-card"> <div key={card.id} className="step-card">
<div className="step-header"> <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 className="reward-pill">{card.reward}</div>
</div> </div>
<p>{card.reason}</p> <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> <button className="primary" onClick={() => simulateCard(card)}>Simulate step</button>
</div> </div>
)) : <div className="muted">No recommendations available.</div>} )) : <div className="muted">No recommendations available.</div>}
@ -226,12 +248,12 @@ export default function App() {
)} )}
{tab === "history" && ( {tab === "history" && (
<main className="layout twocol"> <main className="layout onecol">
<section className="card"> <section className="card">
<h2>Evaluator history</h2> <h2>Evaluator history</h2>
{history.length ? ( {history.length ? (
<table className="table"> <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> <tbody>
{history.map((row) => ( {history.map((row) => (
<tr key={row.job_id}> <tr key={row.job_id}>
@ -239,17 +261,14 @@ export default function App() {
<td>{row.status}</td> <td>{row.status}</td>
<td>{row.concept_id}</td> <td>{row.concept_id}</td>
<td>{row.result_score ?? "-"}</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> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
) : <div className="muted">No evaluator jobs yet.</div>} ) : <div className="muted">No evaluator jobs yet.</div>}
</section> </section>
<section className="card">
<h2>Evaluator trace</h2>
<pre className="prebox">{JSON.stringify(selectedTrace, null, 2)}</pre>
</section>
</main> </main>
)} )}
@ -281,8 +300,9 @@ export default function App() {
{tab === "admin" && auth.role === "admin" && ( {tab === "admin" && auth.role === "admin" && (
<main className="layout twocol"> <main className="layout twocol">
<section className="card"> <section className="card">
<h2>Richer pack authoring</h2> <h2>Pack editor</h2>
<PackAuthorForm value={formPack} onChange={setFormPack} onSave={savePack} /> <textarea className="bigtext" value={newPackJson} onChange={(e) => setNewPackJson(e.target.value)} />
<button className="primary" onClick={savePack}>Save pack</button>
</section> </section>
<section className="card"> <section className="card">
<h2>Pack administration</h2> <h2>Pack administration</h2>
@ -302,19 +322,6 @@ export default function App() {
</section> </section>
</main> </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> </div>
); )
} }

View File

@ -1,34 +1,112 @@
const API = "http://127.0.0.1:8011/api"; const API = "http://127.0.0.1:8011/api";
function authHeaders(token, json=true) { function authHeaders(token, json=true) {
const h = { Authorization: `Bearer ${token}` }; const h = { "Authorization": `Bearer ${token}` };
if (json) h["Content-Type"] = "application/json"; if (json) h["Content-Type"] = "application/json";
return h; return h;
} }
export async function login(username, password) { export async function login(username, password) {
const res = await fetch(`${API}/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }) }); const res = await fetch(`${API}/login`, {
if (!res.ok) throw new Error("login failed"); method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
if (!res.ok) throw new Error("Login failed");
return await res.json(); return await res.json();
} }
export async function refresh(refreshToken) { export async function refresh(refreshToken) {
const res = await fetch(`${API}/refresh`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: refreshToken }) }); const res = await fetch(`${API}/refresh`, {
if (!res.ok) throw new Error("refresh failed"); 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(); 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 fetchPacks(token) {
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(); } const res = await fetch(`${API}/packs`, { headers: authHeaders(token, false) });
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(); } if (!res.ok) throw new Error("fetchPacks failed");
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(); } 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 fetchAdminPacks(token) {
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(); } const res = await fetch(`${API}/admin/packs`, { headers: authHeaders(token, false) });
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(); } if (!res.ok) throw new Error("fetchAdminPacks failed");
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(); } 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 upsertPack(token, payload) {
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(); } 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"; 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 loadAuth() {
export function clearAuth() { localStorage.removeItem(KEY); } 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 { createRoot } from "react-dom/client";
import App from "./App"; import App from "./App";
import "./styles.css"; import "./styles.css";
createRoot(document.getElementById("root")).render(<App />); createRoot(document.getElementById("root")).render(<App />);

View File

@ -3,7 +3,7 @@
} }
* { box-sizing:border-box; } * { box-sizing:border-box; }
body { margin:0; font-family:Arial, Helvetica, sans-serif; background:var(--bg); color:var(--text); } body { margin:0; font-family:Arial, Helvetica, sans-serif; background:var(--bg); color:var(--text); }
.page { max-width:1500px; margin:0 auto; padding:24px; } .page { max-width:1400px; margin:0 auto; padding:24px; }
.narrow-page { max-width:520px; } .narrow-page { max-width:520px; }
.hero { background:var(--card); border:1px solid var(--border); border-radius:22px; padding:24px; display:flex; justify-content:space-between; gap:16px; } .hero { background:var(--card); border:1px solid var(--border); border-radius:22px; padding:24px; display:flex; justify-content:space-between; gap:16px; }
.hero-controls { min-width:280px; display:flex; flex-direction:column; gap:12px; } .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; } 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; } .card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; }
.narrow { margin-top:60px; } .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; } .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); } .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; } .twocol { grid-template-columns:1fr 1fr; }
.onecol { grid-template-columns:1fr; } .onecol { grid-template-columns:1fr; }
.steps-stack { display:grid; gap:14px; } .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; } .message { margin-top:10px; padding:10px 12px; border:1px solid var(--border); background:#fff7dd; border-radius:12px; }
.table { width:100%; border-collapse:collapse; } .table { width:100%; border-collapse:collapse; }
.table th, .table td { border-bottom:1px solid var(--border); text-align:left; padding:8px; vertical-align:top; } .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; } .prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:360px; }
.form-grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; } .bigtext { min-height:340px; font-family:monospace; }
.full { grid-column:1 / -1; }
.checkrow { display:flex; gap:16px; flex-wrap:wrap; align-items:center; }
details summary { cursor:pointer; color:var(--accent); } details summary { cursor:pointer; color:var(--accent); }
@media (max-width:1100px) { @media (max-width:1100px) {
.hero { flex-direction:column; } .hero { flex-direction:column; }
.twocol, .form-grid { grid-template-columns:1fr; } .twocol { grid-template-columns:1fr; }
} }