diff --git a/src/didactopus/api.py b/src/didactopus/api.py index 332873c..489d2af 100644 --- a/src/didactopus/api.py +++ b/src/didactopus/api.py @@ -1,12 +1,16 @@ from __future__ import annotations -import json from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks from fastapi.middleware.cors import CORSMiddleware import uvicorn from .config import load_settings from .db import Base, engine from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest -from .repository import authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token, list_packs, list_pack_admin_rows, get_pack, get_pack_validation, get_pack_provenance, upsert_pack, set_pack_publication, create_learner, list_learners_for_user, learner_owned_by_user, load_learner_state, save_learner_state, create_evaluator_job, get_evaluator_job, list_evaluator_jobs_for_learner +from .repository import ( + authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token, + list_packs, list_pack_admin_rows, get_pack, upsert_pack, set_pack_publication, + create_learner, list_learners_for_user, learner_owned_by_user, load_learner_state, + save_learner_state, create_evaluator_job, get_evaluator_job, list_evaluator_jobs_for_learner +) from .engine import apply_evidence, recommend_next from .auth import issue_access_token, issue_refresh_token, decode_token, new_token_id from .worker import process_job @@ -15,7 +19,13 @@ settings = load_settings() Base.metadata.create_all(bind=engine) app = FastAPI(title="Didactopus API Prototype") -app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) def current_user(authorization: str = Header(default="")): token = authorization.removeprefix("Bearer ").strip() @@ -45,7 +55,12 @@ def login(payload: LoginRequest): raise HTTPException(status_code=401, detail="Invalid credentials") token_id = new_token_id() store_refresh_token(user.id, token_id) - return TokenPair(access_token=issue_access_token(user.id, user.username, user.role), refresh_token=issue_refresh_token(user.id, user.username, user.role, token_id), username=user.username, role=user.role) + return TokenPair( + access_token=issue_access_token(user.id, user.username, user.role), + refresh_token=issue_refresh_token(user.id, user.username, user.role, token_id), + username=user.username, + role=user.role, + ) @app.post("/api/refresh", response_model=TokenPair) def refresh(payload: RefreshRequest): @@ -61,24 +76,22 @@ def refresh(payload: RefreshRequest): revoke_refresh_token(token_id) new_jti = new_token_id() store_refresh_token(user.id, new_jti) - return TokenPair(access_token=issue_access_token(user.id, user.username, user.role), refresh_token=issue_refresh_token(user.id, user.username, user.role, new_jti), username=user.username, role=user.role) + return TokenPair( + access_token=issue_access_token(user.id, user.username, user.role), + refresh_token=issue_refresh_token(user.id, user.username, user.role, new_jti), + username=user.username, + role=user.role, + ) @app.get("/api/packs") def api_list_packs(user = Depends(current_user)): - return [p.model_dump() for p in list_packs(include_unpublished=(user.role == "admin"))] + include_unpublished = user.role == "admin" + return [p.model_dump() for p in list_packs(include_unpublished=include_unpublished)] @app.get("/api/admin/packs") def api_admin_list_packs(user = Depends(require_admin)): return list_pack_admin_rows() -@app.get("/api/admin/packs/{pack_id}/validation") -def api_admin_pack_validation(pack_id: str, user = Depends(require_admin)): - return get_pack_validation(pack_id) - -@app.get("/api/admin/packs/{pack_id}/provenance") -def api_admin_pack_provenance(pack_id: str, user = Depends(require_admin)): - return get_pack_provenance(pack_id) - @app.post("/api/admin/packs") def api_upsert_pack(payload: CreatePackRequest, user = Depends(require_admin)): upsert_pack(payload.pack, is_published=payload.is_published) @@ -143,13 +156,6 @@ def api_get_evaluator_job(job_id: int, user = Depends(current_user)): raise HTTPException(status_code=404, detail="Job not found") return EvaluatorJobStatus(job_id=job.id, status=job.status, result_score=job.result_score, result_confidence_hint=job.result_confidence_hint, result_notes=job.result_notes) -@app.get("/api/evaluator-jobs/{job_id}/trace") -def api_get_evaluator_job_trace(job_id: int, user = Depends(current_user)): - job = get_evaluator_job(job_id) - if job is None: - raise HTTPException(status_code=404, detail="Job not found") - return json.loads(job.trace_json or "{}") - @app.get("/api/learners/{learner_id}/evaluator-history") def api_get_evaluator_history(learner_id: str, user = Depends(current_user)): ensure_learner_access(user, learner_id) @@ -158,3 +164,6 @@ def api_get_evaluator_history(learner_id: str, user = Depends(current_user)): def main(): uvicorn.run(app, host=settings.host, port=settings.port) + +if __name__ == "__main__": + main() diff --git a/src/didactopus/auth.py b/src/didactopus/auth.py index 54745ac..a2d088d 100644 --- a/src/didactopus/auth.py +++ b/src/didactopus/auth.py @@ -20,10 +20,10 @@ def _encode_token(payload: dict, expires_delta: timedelta) -> str: return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm) def issue_access_token(user_id: int, username: str, role: str) -> str: - return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "access"}, timedelta(minutes=30)) + return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "access"}, timedelta(minutes=settings.access_token_minutes)) def issue_refresh_token(user_id: int, username: str, role: str, token_id: str) -> str: - return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "refresh", "jti": token_id}, timedelta(days=14)) + return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "refresh", "jti": token_id}, timedelta(days=settings.refresh_token_days)) def decode_token(token: str) -> dict | None: try: diff --git a/src/didactopus/config.py b/src/didactopus/config.py index 6db28e1..9890f28 100644 --- a/src/didactopus/config.py +++ b/src/didactopus/config.py @@ -8,6 +8,8 @@ class Settings(BaseModel): port: int = int(os.getenv("DIDACTOPUS_PORT", "8011")) jwt_secret: str = os.getenv("DIDACTOPUS_JWT_SECRET", "change-me") jwt_algorithm: str = "HS256" + access_token_minutes: int = 30 + refresh_token_days: int = 14 def load_settings() -> Settings: return Settings() diff --git a/src/didactopus/models.py b/src/didactopus/models.py index 9456ad7..e2a0b8b 100644 --- a/src/didactopus/models.py +++ b/src/didactopus/models.py @@ -41,14 +41,6 @@ class PackData(BaseModel): onboarding: dict = Field(default_factory=dict) compliance: PackCompliance = Field(default_factory=PackCompliance) -class CreatePackRequest(BaseModel): - pack: PackData - is_published: bool = True - -class CreateLearnerRequest(BaseModel): - learner_id: str - display_name: str = "" - class MasteryRecord(BaseModel): concept_id: str dimension: str @@ -71,6 +63,10 @@ class LearnerState(BaseModel): records: list[MasteryRecord] = Field(default_factory=list) history: list[EvidenceEvent] = Field(default_factory=list) +class CreateLearnerRequest(BaseModel): + learner_id: str + display_name: str = "" + class EvaluatorSubmission(BaseModel): pack_id: str concept_id: str @@ -83,3 +79,7 @@ class EvaluatorJobStatus(BaseModel): result_score: float | None = None result_confidence_hint: float | None = None result_notes: str = "" + +class CreatePackRequest(BaseModel): + pack: PackData + is_published: bool = True diff --git a/src/didactopus/orm.py b/src/didactopus/orm.py index 1382d61..d4a16f7 100644 --- a/src/didactopus/orm.py +++ b/src/didactopus/orm.py @@ -24,8 +24,6 @@ class PackORM(Base): subtitle: Mapped[str] = mapped_column(Text, default="") level: Mapped[str] = mapped_column(String(100), default="novice-friendly") data_json: Mapped[str] = mapped_column(Text) - validation_json: Mapped[str] = mapped_column(Text, default="{}") - provenance_json: Mapped[str] = mapped_column(Text, default="{}") is_published: Mapped[bool] = mapped_column(Boolean, default=True) class LearnerORM(Base): @@ -68,4 +66,3 @@ class EvaluatorJobORM(Base): result_score: Mapped[float | None] = mapped_column(Float, nullable=True) result_confidence_hint: Mapped[float | None] = mapped_column(Float, nullable=True) result_notes: Mapped[str] = mapped_column(Text, default="") - trace_json: Mapped[str] = mapped_column(Text, default="{}") diff --git a/src/didactopus/repository.py b/src/didactopus/repository.py index 0c7ce34..23b55a6 100644 --- a/src/didactopus/repository.py +++ b/src/didactopus/repository.py @@ -37,13 +37,12 @@ def revoke_refresh_token(token_id: str): row.is_revoked = True db.commit() -def list_packs(include_unpublished: bool = False): +def list_packs(include_unpublished: bool = False) -> list[PackData]: with SessionLocal() as db: stmt = select(PackORM) if not include_unpublished: stmt = stmt.where(PackORM.is_published == True) - rows = db.execute(stmt).scalars().all() - return [PackData.model_validate(json.loads(r.data_json)) for r in rows] + return [PackData.model_validate(json.loads(r.data_json)) for r in db.execute(stmt).scalars().all()] def list_pack_admin_rows(): with SessionLocal() as db: @@ -55,43 +54,17 @@ def get_pack(pack_id: str): row = db.get(PackORM, pack_id) return None if row is None else PackData.model_validate(json.loads(row.data_json)) -def get_pack_validation(pack_id: str): - with SessionLocal() as db: - row = db.get(PackORM, pack_id) - return {} if row is None else json.loads(row.validation_json or "{}") - -def get_pack_provenance(pack_id: str): - with SessionLocal() as db: - row = db.get(PackORM, pack_id) - return {} if row is None else json.loads(row.provenance_json or "{}") - def upsert_pack(pack: PackData, is_published: bool = True): - validation = { - "ok": len(pack.concepts) > 0, - "warnings": [] if len(pack.concepts) > 0 else ["Pack has no concepts."], - "errors": [], - "summary": {"concept_count": len(pack.concepts), "has_onboarding": bool(pack.onboarding)} - } - provenance = { - "source_count": pack.compliance.sources, - "licenses_present": ["CC BY-NC-SA 4.0"] if pack.compliance.shareAlikeRequired or pack.compliance.noncommercialOnly else [], - "restrictive_flags": list(pack.compliance.flags), - "sources": [ - {"source_id": "sample-source-1", "title": f"Provenance placeholder for {pack.title}", "license_id": "CC BY-NC-SA 4.0" if pack.compliance.shareAlikeRequired or pack.compliance.noncommercialOnly else "unspecified", "attribution_text": "Sample attribution text placeholder"} - ] if pack.compliance.sources else [] - } with SessionLocal() as db: row = db.get(PackORM, pack.id) payload = json.dumps(pack.model_dump()) if row is None: - db.add(PackORM(id=pack.id, title=pack.title, subtitle=pack.subtitle, level=pack.level, data_json=payload, validation_json=json.dumps(validation), provenance_json=json.dumps(provenance), is_published=is_published)) + db.add(PackORM(id=pack.id, title=pack.title, subtitle=pack.subtitle, level=pack.level, data_json=payload, is_published=is_published)) else: row.title = pack.title row.subtitle = pack.subtitle row.level = pack.level row.data_json = payload - row.validation_json = json.dumps(validation) - row.provenance_json = json.dumps(provenance) row.is_published = is_published db.commit() @@ -123,7 +96,7 @@ def learner_owned_by_user(user_id: int, learner_id: str) -> bool: learner = db.get(LearnerORM, learner_id) return learner is not None and learner.owner_user_id == user_id -def load_learner_state(learner_id: str): +def load_learner_state(learner_id: str) -> LearnerState: with SessionLocal() as db: records = db.execute(select(MasteryRecordORM).where(MasteryRecordORM.learner_id == learner_id)).scalars().all() history = db.execute(select(EvidenceEventORM).where(EvidenceEventORM.learner_id == learner_id)).scalars().all() @@ -146,8 +119,7 @@ def save_learner_state(state: LearnerState): def create_evaluator_job(learner_id: str, pack_id: str, concept_id: str, submitted_text: str) -> int: with SessionLocal() as db: - trace = {"rubric_dimension_scores": [{"dimension": "mastery", "score": 0.0}], "notes": ["Job queued", "Awaiting evaluator"], "token_count_estimate": len(submitted_text.split())} - job = EvaluatorJobORM(learner_id=learner_id, pack_id=pack_id, concept_id=concept_id, submitted_text=submitted_text, status="queued", trace_json=json.dumps(trace)) + job = EvaluatorJobORM(learner_id=learner_id, pack_id=pack_id, concept_id=concept_id, submitted_text=submitted_text, status="queued") db.add(job) db.commit() db.refresh(job) @@ -155,13 +127,14 @@ def create_evaluator_job(learner_id: str, pack_id: str, concept_id: str, submitt def list_evaluator_jobs_for_learner(learner_id: str): with SessionLocal() as db: - return db.execute(select(EvaluatorJobORM).where(EvaluatorJobORM.learner_id == learner_id).order_by(EvaluatorJobORM.id.desc())).scalars().all() + rows = db.execute(select(EvaluatorJobORM).where(EvaluatorJobORM.learner_id == learner_id).order_by(EvaluatorJobORM.id.desc())).scalars().all() + return rows def get_evaluator_job(job_id: int): with SessionLocal() as db: return db.get(EvaluatorJobORM, job_id) -def update_evaluator_job(job_id: int, status: str, score: float | None = None, confidence_hint: float | None = None, notes: str = "", trace: dict | None = None): +def update_evaluator_job(job_id: int, status: str, score: float | None = None, confidence_hint: float | None = None, notes: str = ""): with SessionLocal() as db: job = db.get(EvaluatorJobORM, job_id) if job is None: @@ -170,6 +143,4 @@ def update_evaluator_job(job_id: int, status: str, score: float | None = None, c job.result_score = score job.result_confidence_hint = confidence_hint job.result_notes = notes - if trace is not None: - job.trace_json = json.dumps(trace) db.commit() diff --git a/src/didactopus/seed.py b/src/didactopus/seed.py index abffe81..03bbc74 100644 --- a/src/didactopus/seed.py +++ b/src/didactopus/seed.py @@ -1,27 +1,48 @@ from __future__ import annotations +import json from sqlalchemy import select from .db import Base, engine, SessionLocal -from .orm import UserORM +from .orm import UserORM, PackORM from .auth import hash_password -from .repository import upsert_pack -from .models import PackData, PackConcept, PackCompliance + +PACKS = [ + { + "id": "bayes-pack", + "title": "Bayesian Reasoning", + "subtitle": "Probability, evidence, updating, and model criticism.", + "level": "novice-friendly", + "concepts": [ + {"id": "prior", "title": "Prior", "prerequisites": [], "masteryDimension": "mastery", "exerciseReward": "Prior badge earned"}, + {"id": "posterior", "title": "Posterior", "prerequisites": ["prior"], "masteryDimension": "mastery", "exerciseReward": "Posterior path opened"}, + {"id": "model-checking", "title": "Model Checking", "prerequisites": ["posterior"], "masteryDimension": "mastery", "exerciseReward": "Model-checking unlocked"} + ], + "onboarding": {"headline": "Start with a fast visible win", "body": "Read one short orientation, answer one guided question, and leave with your first mastery marker.", "checklist": ["Read the one-screen topic orientation", "Answer one guided exercise", "Write one explanation in your own words"]}, + "compliance": {"sources": 2, "attributionRequired": True, "shareAlikeRequired": True, "noncommercialOnly": True, "flags": ["share-alike", "noncommercial", "excluded-third-party-content"]} + }, + { + "id": "stats-pack", + "title": "Introductory Statistics", + "subtitle": "Descriptive statistics, sampling, and inference.", + "level": "novice-friendly", + "concepts": [ + {"id": "descriptive", "title": "Descriptive Statistics", "prerequisites": [], "masteryDimension": "mastery", "exerciseReward": "Descriptive tools unlocked"}, + {"id": "sampling", "title": "Sampling", "prerequisites": ["descriptive"], "masteryDimension": "mastery", "exerciseReward": "Sampling pathway opened"} + ], + "onboarding": {"headline": "Build your first useful data skill", "body": "You will learn one concept that immediately helps you summarize real data.", "checklist": ["See one worked example", "Compute one short example yourself", "Explain what the result means"]}, + "compliance": {"sources": 1, "attributionRequired": True, "shareAlikeRequired": False, "noncommercialOnly": False, "flags": []} + } +] def main(): Base.metadata.create_all(bind=engine) with SessionLocal() as db: if db.execute(select(UserORM).where(UserORM.username == "wesley")).scalar_one_or_none() is None: db.add(UserORM(username="wesley", password_hash=hash_password("demo-pass"), role="admin", is_active=True)) + for pack in PACKS: + if db.get(PackORM, pack["id"]) is None: + db.add(PackORM(id=pack["id"], title=pack["title"], subtitle=pack["subtitle"], level=pack["level"], data_json=json.dumps(pack), is_published=True)) db.commit() - upsert_pack(PackData( - id="bayes-pack", - title="Bayesian Reasoning", - subtitle="Probability, evidence, updating, and model criticism.", - level="novice-friendly", - concepts=[ - PackConcept(id="prior", title="Prior", prerequisites=[], masteryDimension="mastery", exerciseReward="Prior badge earned"), - PackConcept(id="posterior", title="Posterior", prerequisites=["prior"], masteryDimension="mastery", exerciseReward="Posterior path opened"), - ], - onboarding={"headline":"Start with a fast visible win","body":"Read one short orientation, answer one guided question, and leave with your first mastery marker.","checklist":["Read the one-screen topic orientation","Answer one guided exercise","Write one explanation in your own words"]}, - compliance=PackCompliance(sources=2, attributionRequired=True, shareAlikeRequired=True, noncommercialOnly=True, flags=["share-alike","noncommercial","excluded-third-party-content"]) - ), is_published=True) print("Seeded database. Demo user: wesley / demo-pass") + +if __name__ == "__main__": + main() diff --git a/src/didactopus/worker.py b/src/didactopus/worker.py index 6d28da5..3cea356 100644 --- a/src/didactopus/worker.py +++ b/src/didactopus/worker.py @@ -8,17 +8,27 @@ def process_job(job_id: int): job = get_evaluator_job(job_id) if job is None: return - update_evaluator_job(job_id, "running", trace={"rubric_dimension_scores": [{"dimension": "mastery", "score": 0.35}], "notes": ["Job running", "Prototype trace generated"], "token_count_estimate": len(job.submitted_text.split())}) + update_evaluator_job(job_id, "running") score = 0.78 if len(job.submitted_text.strip()) > 20 else 0.62 confidence_hint = 0.72 if len(job.submitted_text.strip()) > 20 else 0.45 notes = "Prototype evaluator: longer responses scored somewhat higher." - trace = {"rubric_dimension_scores": [{"dimension": "mastery", "score": score}], "notes": ["Prototype evaluator completed", notes], "token_count_estimate": len(job.submitted_text.split()), "decision_basis": ["response length heuristic", "single-dimension mastery proxy"]} - update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes, trace=trace) + update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes) state = load_learner_state(job.learner_id) - state = apply_evidence(state, EvidenceEvent(concept_id=job.concept_id, dimension="mastery", score=score, confidence_hint=confidence_hint, timestamp="2026-03-13T12:00:00+00:00", kind="review", source_id=f"evaluator-job-{job_id}")) + state = apply_evidence(state, EvidenceEvent( + concept_id=job.concept_id, + dimension="mastery", + score=score, + confidence_hint=confidence_hint, + timestamp="2026-03-13T12:00:00+00:00", + kind="review", + source_id=f"evaluator-job-{job_id}", + )) save_learner_state(state) def main(): print("Didactopus worker scaffold running. Replace this with a real queue worker.") while True: time.sleep(60) + +if __name__ == "__main__": + main() diff --git a/webui/index.html b/webui/index.html index 5a90d2b..9cbd33a 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3,7 +3,7 @@ - Didactopus Admin Curation Layer + Didactopus UI Workflows
diff --git a/webui/package.json b/webui/package.json index fc49c73..28ef3ef 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,9 +1,17 @@ { - "name": "didactopus-admin-curation-ui", + "name": "didactopus-ui-workflows", "private": true, "version": "0.1.0", "type": "module", - "scripts": { "dev": "vite", "build": "vite build" }, - "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" }, - "devDependencies": { "vite": "^5.4.0" } + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "vite": "^5.4.0" + } } diff --git a/webui/src/App.jsx b/webui/src/App.jsx index 93a331a..8d8462b 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -1,18 +1,26 @@ -import React, { useEffect, useState } from "react"; -import { login, refresh, fetchPacks, fetchAdminPacks, fetchPackValidation, fetchPackProvenance, upsertPack, publishPack, listLearners, createLearner, fetchLearnerState, fetchRecommendations, postEvidence, submitEvaluatorJob, fetchEvaluatorHistory, fetchEvaluatorTrace } from "./api"; +import React, { useEffect, useMemo, useState } from "react"; +import { + login, refresh, fetchPacks, fetchAdminPacks, upsertPack, publishPack, + createLearner, listLearners, fetchLearnerState, fetchRecommendations, postEvidence, + submitEvaluatorJob, fetchEvaluatorHistory +} from "./api"; import { loadAuth, saveAuth, clearAuth } from "./authStore"; function LoginView({ onAuth }) { const [username, setUsername] = useState("wesley"); const [password, setPassword] = useState("demo-pass"); const [error, setError] = useState(""); + async function doLogin() { try { const result = await login(username, password); saveAuth(result); onAuth(result); - } catch { setError("Login failed"); } + } catch { + setError("Login failed"); + } } + return (
@@ -32,32 +40,7 @@ function NavTabs({ tab, setTab, role }) { - {role === "admin" ? <> - - - : null} -
- ); -} - -function PackAuthorForm({ value, onChange, onSave }) { - function setField(field, val) { onChange({ ...value, [field]: val }); } - function setCompliance(field, val) { onChange({ ...value, compliance: { ...value.compliance, [field]: val } }); } - return ( -
- - - - - - -