diff --git a/pyproject.toml b/pyproject.toml
index b6064b1..35368af 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,10 +6,20 @@ build-backend = "setuptools.build_meta"
name = "didactopus"
version = "0.1.0"
requires-python = ">=3.10"
-dependencies = ["pydantic>=2.7", "pyyaml>=6.0"]
+dependencies = [
+ "pydantic>=2.7",
+ "pyyaml>=6.0",
+ "fastapi>=0.115",
+ "uvicorn>=0.30",
+ "sqlalchemy>=2.0",
+ "psycopg[binary]>=3.1",
+ "passlib[bcrypt]>=1.7",
+ "python-jose[cryptography]>=3.3"
+]
[project.scripts]
-didactopus-pack-to-frontend = "didactopus.pack_to_frontend:main"
+didactopus-api = "didactopus.api:main"
+didactopus-worker = "didactopus.worker:main"
[tool.setuptools.packages.find]
where = ["src"]
diff --git a/src/didactopus/api.py b/src/didactopus/api.py
index 8f42cf9..e494573 100644
--- a/src/didactopus/api.py
+++ b/src/didactopus/api.py
@@ -1,19 +1,30 @@
from __future__ import annotations
-from fastapi import FastAPI, HTTPException, Header, Depends
+from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
-import uvicorn, json, tempfile
-from pathlib import Path
+import uvicorn
+from .config import load_settings
from .db import Base, engine
-from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, MediaRenderRequest
-from .repository import authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token, list_packs_for_user, get_pack, get_pack_row, create_learner, learner_owned_by_user, load_learner_state, save_learner_state
+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, get_pack, upsert_pack, create_learner, 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 .engine import build_graph_frames, stable_layout
-from .render_bundle import make_render_bundle
+from .worker import process_job
+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()
@@ -25,24 +36,17 @@ def current_user(authorization: str = Header(default="")):
raise HTTPException(status_code=401, detail="Unauthorized")
return user
+def require_admin(user = Depends(current_user)):
+ if user.role != "admin":
+ raise HTTPException(status_code=403, detail="Admin role required")
+ return user
+
def ensure_learner_access(user, learner_id: str):
if user.role == "admin":
return
if not learner_owned_by_user(user.id, learner_id):
raise HTTPException(status_code=403, detail="Learner not accessible by this user")
-def ensure_pack_access(user, pack_id: str):
- row = get_pack_row(pack_id)
- if row is None:
- raise HTTPException(status_code=404, detail="Pack not found")
- if user.role == "admin":
- return row
- if row.policy_lane == "community":
- return row
- if row.owner_user_id == user.id:
- return row
- raise HTTPException(status_code=403, detail="Pack not accessible by this user")
-
@app.post("/api/login", response_model=TokenPair)
def login(payload: LoginRequest):
user = authenticate_user(payload.username, payload.password)
@@ -50,7 +54,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):
@@ -66,11 +75,29 @@ 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_for_user(user.id, 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/packs/{pack_id}")
+def api_get_pack(pack_id: str, user = Depends(current_user)):
+ pack = get_pack(pack_id)
+ if pack is None:
+ raise HTTPException(status_code=404, detail="Pack not found")
+ return pack.model_dump()
+
+@app.post("/api/admin/packs")
+def api_upsert_pack(payload: CreatePackRequest, user = Depends(require_admin)):
+ upsert_pack(payload.pack, is_published=payload.is_published)
+ return {"ok": True, "pack_id": payload.pack.id}
@app.post("/api/learners")
def api_create_learner(payload: CreateLearnerRequest, user = Depends(current_user)):
@@ -89,52 +116,45 @@ def api_put_learner_state(learner_id: str, state: LearnerState, user = Depends(c
raise HTTPException(status_code=400, detail="Learner ID mismatch")
return save_learner_state(state).model_dump()
-@app.get("/api/packs/{pack_id}/layout")
-def api_pack_layout(pack_id: str, user = Depends(current_user)):
- ensure_pack_access(user, pack_id)
- pack = get_pack(pack_id)
- return {"pack_id": pack_id, "layout": stable_layout(pack)} if pack else {"pack_id": pack_id, "layout": {}}
-
-@app.get("/api/learners/{learner_id}/graph-animation/{pack_id}")
-def api_graph_animation(learner_id: str, pack_id: str, user = Depends(current_user)):
+@app.post("/api/learners/{learner_id}/evidence")
+def api_post_evidence(learner_id: str, event: EvidenceEvent, user = Depends(current_user)):
ensure_learner_access(user, learner_id)
- ensure_pack_access(user, pack_id)
- pack = get_pack(pack_id)
state = load_learner_state(learner_id)
- frames = build_graph_frames(state, pack)
- return {
- "learner_id": learner_id,
- "pack_id": pack_id,
- "pack_title": pack.title if pack else "",
- "frames": frames,
- "concepts": [{"id": c.id, "title": c.title, "prerequisites": c.prerequisites, "cross_pack_links": [l.model_dump() for l in c.cross_pack_links]} for c in pack.concepts] if pack else [],
- }
+ state = apply_evidence(state, event)
+ save_learner_state(state)
+ return state.model_dump()
-@app.post("/api/learners/{learner_id}/render-bundle/{pack_id}")
-def api_render_bundle(learner_id: str, pack_id: str, payload: MediaRenderRequest, user = Depends(current_user)):
+@app.get("/api/learners/{learner_id}/recommendations/{pack_id}")
+def api_get_recommendations(learner_id: str, pack_id: str, user = Depends(current_user)):
ensure_learner_access(user, learner_id)
- ensure_pack_access(user, pack_id)
- pack = get_pack(pack_id)
state = load_learner_state(learner_id)
- animation = {
- "learner_id": learner_id,
- "pack_id": pack_id,
- "pack_title": pack.title if pack else "",
- "frames": build_graph_frames(state, pack),
- }
- base = Path(tempfile.mkdtemp(prefix="didactopus_render_"))
- payload_json = base / "animation_payload.json"
- payload_json.write_text(json.dumps(animation, indent=2), encoding="utf-8")
- out_dir = base / "bundle"
- make_render_bundle(str(payload_json), str(out_dir), fps=payload.fps, fmt=payload.format)
- return {
- "bundle_dir": str(out_dir),
- "payload_json": str(payload_json),
- "manifest": str(out_dir / "render_manifest.json"),
- "script": str(out_dir / "render.sh"),
- "format": payload.format,
- "fps": payload.fps,
- }
+ pack = get_pack(pack_id)
+ if pack is None:
+ raise HTTPException(status_code=404, detail="Pack not found")
+ return {"cards": recommend_next(state, pack)}
+
+@app.post("/api/learners/{learner_id}/evaluator-jobs", response_model=EvaluatorJobStatus)
+def api_submit_evaluator_job(learner_id: str, payload: EvaluatorSubmission, background_tasks: BackgroundTasks, user = Depends(current_user)):
+ ensure_learner_access(user, learner_id)
+ job_id = create_evaluator_job(learner_id, payload.pack_id, payload.concept_id, payload.submitted_text)
+ background_tasks.add_task(process_job, job_id)
+ return EvaluatorJobStatus(job_id=job_id, status="queued")
+
+@app.get("/api/evaluator-jobs/{job_id}", response_model=EvaluatorJobStatus)
+def api_get_evaluator_job(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 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/learners/{learner_id}/evaluator-history")
+def api_get_evaluator_history(learner_id: str, user = Depends(current_user)):
+ ensure_learner_access(user, learner_id)
+ jobs = list_evaluator_jobs_for_learner(learner_id)
+ return [{"job_id": j.id, "status": j.status, "concept_id": j.concept_id, "result_score": j.result_score, "result_confidence_hint": j.result_confidence_hint, "result_notes": j.result_notes} for j in jobs]
def main():
- uvicorn.run(app, host="127.0.0.1", port=8011)
+ uvicorn.run(app, host=settings.host, port=settings.port)
+
+if __name__ == "__main__":
+ main()
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 1f71733..9890f28 100644
--- a/src/didactopus/config.py
+++ b/src/didactopus/config.py
@@ -3,11 +3,13 @@ import os
from pydantic import BaseModel
class Settings(BaseModel):
- database_url: str = os.getenv("DIDACTOPUS_DATABASE_URL", "sqlite+pysqlite:///:memory:")
+ database_url: str = os.getenv("DIDACTOPUS_DATABASE_URL", "postgresql+psycopg://didactopus:didactopus-dev-password@localhost:5432/didactopus")
host: str = os.getenv("DIDACTOPUS_HOST", "127.0.0.1")
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/engine.py b/src/didactopus/engine.py
index 6b7c236..c1e52bf 100644
--- a/src/didactopus/engine.py
+++ b/src/didactopus/engine.py
@@ -1,110 +1,59 @@
from __future__ import annotations
-from collections import defaultdict
-from .models import LearnerState, PackData
+from .models import LearnerState, EvidenceEvent, MasteryRecord, PackData
-def concept_depths(pack: PackData) -> dict[str, int]:
- concept_map = {c.id: c for c in pack.concepts}
- memo = {}
- def depth(cid: str) -> int:
- if cid in memo:
- return memo[cid]
- c = concept_map[cid]
- if not c.prerequisites:
- memo[cid] = 0
- else:
- memo[cid] = 1 + max(depth(pid) for pid in c.prerequisites if pid in concept_map)
- return memo[cid]
- for cid in concept_map:
- depth(cid)
- return memo
+def get_record(state: LearnerState, concept_id: str, dimension: str = "mastery") -> MasteryRecord | None:
+ for rec in state.records:
+ if rec.concept_id == concept_id and rec.dimension == dimension:
+ return rec
+ return None
-def stable_layout(pack: PackData, width: int = 900, height: int = 520):
- depths = concept_depths(pack)
- layers = defaultdict(list)
- for c in pack.concepts:
- layers[depths.get(c.id, 0)].append(c)
- positions = {}
- max_depth = max(layers.keys()) if layers else 0
- for d in sorted(layers):
- nodes = sorted(layers[d], key=lambda c: c.id)
- y = 90 + d * ((height - 160) / max(1, max_depth))
- for idx, node in enumerate(nodes):
- if node.position is not None:
- positions[node.id] = {"x": node.position.x, "y": node.position.y, "source": "pack_authored"}
- else:
- spacing = width / (len(nodes) + 1)
- x = spacing * (idx + 1)
- positions[node.id] = {"x": x, "y": y, "source": "auto_layered"}
- return positions
+def apply_evidence(state: LearnerState, event: EvidenceEvent, decay: float = 0.05, reinforcement: float = 0.25) -> LearnerState:
+ rec = get_record(state, event.concept_id, event.dimension)
+ if rec is None:
+ rec = MasteryRecord(concept_id=event.concept_id, dimension=event.dimension, score=0.0, confidence=0.0, evidence_count=0, last_updated=event.timestamp)
+ state.records.append(rec)
+ weight = max(0.05, min(1.0, event.confidence_hint))
+ rec.score = ((rec.score * rec.evidence_count) + (event.score * weight)) / max(1, rec.evidence_count + 1)
+ rec.confidence = min(1.0, max(0.0, rec.confidence * (1.0 - decay) + reinforcement * weight + 0.10 * max(0.0, min(1.0, event.score))))
+ rec.evidence_count += 1
+ rec.last_updated = event.timestamp
+ state.history.append(event)
+ return state
-def prereqs_satisfied(scores: dict[str, float], concept, min_score: float = 0.65) -> bool:
+def prereqs_satisfied(state: LearnerState, concept, min_score: float = 0.65, min_confidence: float = 0.45) -> bool:
for pid in concept.prerequisites:
- if scores.get(pid, 0.0) < min_score:
+ rec = get_record(state, pid, concept.masteryDimension)
+ if rec is None or rec.score < min_score or rec.confidence < min_confidence:
return False
return True
-def concept_status(scores: dict[str, float], concept, min_score: float = 0.65) -> str:
- score = scores.get(concept.id, 0.0)
- if score >= min_score:
+def concept_status(state: LearnerState, concept, min_score: float = 0.65, min_confidence: float = 0.45) -> str:
+ rec = get_record(state, concept.id, concept.masteryDimension)
+ if rec and rec.score >= min_score and rec.confidence >= min_confidence:
return "mastered"
- if prereqs_satisfied(scores, concept, min_score):
- return "active" if score > 0 else "available"
+ if prereqs_satisfied(state, concept, min_score, min_confidence):
+ return "active" if rec else "available"
return "locked"
-def build_graph_frames(state: LearnerState, pack: PackData):
- concepts = {c.id: c for c in pack.concepts}
- layout = stable_layout(pack)
- scores = {c.id: 0.0 for c in pack.concepts}
- frames = []
- history = sorted(state.history, key=lambda x: x.timestamp)
- static_edges = [{"source": pre, "target": c.id, "kind": "prerequisite"} for c in pack.concepts for pre in c.prerequisites]
- static_cross = [{
- "source": c.id,
- "target_pack_id": link.target_pack_id,
- "target_concept_id": link.target_concept_id,
- "relationship": link.relationship,
- "kind": "cross_pack"
- } for c in pack.concepts for link in c.cross_pack_links]
- for idx, ev in enumerate(history):
- if ev.concept_id in scores:
- scores[ev.concept_id] = ev.score
- nodes = []
- for cid, concept in concepts.items():
- score = scores.get(cid, 0.0)
- status = concept_status(scores, concept)
- pos = layout[cid]
- nodes.append({
- "id": cid,
- "title": concept.title,
- "score": score,
- "status": status,
- "size": 20 + int(score * 30),
- "x": pos["x"],
- "y": pos["y"],
- "layout_source": pos["source"],
+def recommend_next(state: LearnerState, pack: PackData) -> list[dict]:
+ cards = []
+ for concept in pack.concepts:
+ status = concept_status(state, concept)
+ rec = get_record(state, concept.id, concept.masteryDimension)
+ if status in {"available", "active"}:
+ cards.append({
+ "id": concept.id,
+ "title": f"Work on {concept.title}",
+ "minutes": 15 if status == "available" else 10,
+ "reason": "Prerequisites are satisfied, so this is the best next unlock." if status == "available" else "You have started this concept, but mastery is not yet secure.",
+ "why": [
+ "Prerequisite check passed",
+ f"Current score: {rec.score:.2f}" if rec else "No evidence recorded yet",
+ f"Current confidence: {rec.confidence:.2f}" if rec else "Confidence starts after your first exercise",
+ ],
+ "reward": concept.exerciseReward or f"{concept.title} progress recorded",
+ "conceptId": concept.id,
+ "scoreHint": 0.82 if status == "available" else 0.76,
+ "confidenceHint": 0.72 if status == "available" else 0.55,
})
- frames.append({
- "index": idx,
- "timestamp": ev.timestamp,
- "event_kind": ev.kind,
- "focus_concept_id": ev.concept_id,
- "nodes": nodes,
- "edges": static_edges,
- "cross_pack_links": static_cross,
- })
- if not frames:
- nodes = []
- for c in pack.concepts:
- pos = layout[c.id]
- nodes.append({
- "id": c.id,
- "title": c.title,
- "score": 0.0,
- "status": "available" if not c.prerequisites else "locked",
- "size": 20,
- "x": pos["x"],
- "y": pos["y"],
- "layout_source": pos["source"],
- })
- frames.append({"index": 0, "timestamp": "", "event_kind": "empty", "focus_concept_id": "", "nodes": nodes, "edges": static_edges, "cross_pack_links": static_cross})
- return frames
+ return cards[:4]
diff --git a/src/didactopus/models.py b/src/didactopus/models.py
index fb9adf3..e2a0b8b 100644
--- a/src/didactopus/models.py
+++ b/src/didactopus/models.py
@@ -1,5 +1,8 @@
from __future__ import annotations
from pydantic import BaseModel, Field
+from typing import Literal
+
+EvidenceKind = Literal["checkpoint", "project", "exercise", "review"]
class TokenPair(BaseModel):
access_token: str
@@ -15,24 +18,19 @@ class LoginRequest(BaseModel):
class RefreshRequest(BaseModel):
refresh_token: str
-class GraphPosition(BaseModel):
- x: float
- y: float
-
-class CrossPackLink(BaseModel):
- source_concept_id: str
- target_pack_id: str
- target_concept_id: str
- relationship: str = "related"
-
class PackConcept(BaseModel):
id: str
title: str
prerequisites: list[str] = Field(default_factory=list)
masteryDimension: str = "mastery"
exerciseReward: str = ""
- position: GraphPosition | None = None
- cross_pack_links: list[CrossPackLink] = Field(default_factory=list)
+
+class PackCompliance(BaseModel):
+ sources: int = 0
+ attributionRequired: bool = False
+ shareAlikeRequired: bool = False
+ noncommercialOnly: bool = False
+ flags: list[str] = Field(default_factory=list)
class PackData(BaseModel):
id: str
@@ -41,11 +39,7 @@ class PackData(BaseModel):
level: str = "novice-friendly"
concepts: list[PackConcept] = Field(default_factory=list)
onboarding: dict = Field(default_factory=dict)
- compliance: dict = Field(default_factory=dict)
-
-class CreateLearnerRequest(BaseModel):
- learner_id: str
- display_name: str = ""
+ compliance: PackCompliance = Field(default_factory=PackCompliance)
class MasteryRecord(BaseModel):
concept_id: str
@@ -61,7 +55,7 @@ class EvidenceEvent(BaseModel):
score: float
confidence_hint: float = 0.5
timestamp: str
- kind: str = "exercise"
+ kind: EvidenceKind = "exercise"
source_id: str = ""
class LearnerState(BaseModel):
@@ -69,9 +63,23 @@ class LearnerState(BaseModel):
records: list[MasteryRecord] = Field(default_factory=list)
history: list[EvidenceEvent] = Field(default_factory=list)
-class MediaRenderRequest(BaseModel):
+class CreateLearnerRequest(BaseModel):
learner_id: str
+ display_name: str = ""
+
+class EvaluatorSubmission(BaseModel):
pack_id: str
- format: str = "gif"
- fps: int = 2
- theme: str = "default"
+ concept_id: str
+ submitted_text: str
+ kind: str = "checkpoint"
+
+class EvaluatorJobStatus(BaseModel):
+ job_id: int
+ status: str
+ result_score: float | None = None
+ result_confidence_hint: float | None = None
+ result_notes: str = ""
+
+class CreatePackRequest(BaseModel):
+ pack: PackData
+ is_published: bool = True
diff --git a/src/didactopus/orm.py b/src/didactopus/orm.py
index 49c1d57..fb79b36 100644
--- a/src/didactopus/orm.py
+++ b/src/didactopus/orm.py
@@ -1,5 +1,5 @@
from sqlalchemy import String, Integer, Float, ForeignKey, Text, Boolean
-from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.orm import Mapped, mapped_column, relationship
from .db import Base
class UserORM(Base):
@@ -20,13 +20,11 @@ class RefreshTokenORM(Base):
class PackORM(Base):
__tablename__ = "packs"
id: Mapped[str] = mapped_column(String(100), primary_key=True)
- owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
- policy_lane: Mapped[str] = mapped_column(String(50), default="personal")
title: Mapped[str] = mapped_column(String(255))
subtitle: Mapped[str] = mapped_column(Text, default="")
level: Mapped[str] = mapped_column(String(100), default="novice-friendly")
data_json: Mapped[str] = mapped_column(Text)
- is_published: Mapped[bool] = mapped_column(Boolean, default=False)
+ is_published: Mapped[bool] = mapped_column(Boolean, default=True)
class LearnerORM(Base):
__tablename__ = "learners"
@@ -56,3 +54,15 @@ class EvidenceEventORM(Base):
timestamp: Mapped[str] = mapped_column(String(100), default="")
kind: Mapped[str] = mapped_column(String(50), default="exercise")
source_id: Mapped[str] = mapped_column(String(255), default="")
+
+class EvaluatorJobORM(Base):
+ __tablename__ = "evaluator_jobs"
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ learner_id: Mapped[str] = mapped_column(ForeignKey("learners.id"), index=True)
+ pack_id: Mapped[str] = mapped_column(ForeignKey("packs.id"), index=True)
+ concept_id: Mapped[str] = mapped_column(String(100), index=True)
+ submitted_text: Mapped[str] = mapped_column(Text, default="")
+ status: Mapped[str] = mapped_column(String(50), default="queued")
+ 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="")
diff --git a/src/didactopus/repository.py b/src/didactopus/repository.py
index bc8e397..90ad6a9 100644
--- a/src/didactopus/repository.py
+++ b/src/didactopus/repository.py
@@ -2,7 +2,7 @@ from __future__ import annotations
import json
from sqlalchemy import select
from .db import SessionLocal
-from .orm import UserORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM
+from .orm import UserORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM
from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent
from .auth import verify_password
@@ -37,54 +37,31 @@ def revoke_refresh_token(token_id: str):
row.is_revoked = True
db.commit()
-def list_packs_for_user(user_id: int | None = None, 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()
- out = []
- for r in rows:
- if r.policy_lane == "community":
- out.append(PackData.model_validate(json.loads(r.data_json)))
- elif user_id is not None and r.owner_user_id == user_id:
- out.append(PackData.model_validate(json.loads(r.data_json)))
- return out
+ return [PackData.model_validate(json.loads(r.data_json)) for r in rows]
def get_pack(pack_id: str):
with SessionLocal() as db:
row = db.get(PackORM, pack_id)
return None if row is None else PackData.model_validate(json.loads(row.data_json))
-def get_pack_row(pack_id: str):
- with SessionLocal() as db:
- return db.get(PackORM, pack_id)
-
-def upsert_pack(pack: PackData, submitted_by_user_id: int, policy_lane: str = "personal", is_published: bool = False):
+def upsert_pack(pack: PackData, is_published: bool = True):
with SessionLocal() as db:
row = db.get(PackORM, pack.id)
payload = json.dumps(pack.model_dump())
if row is None:
- row = PackORM(
- id=pack.id,
- owner_user_id=submitted_by_user_id if policy_lane == "personal" else None,
- policy_lane=policy_lane,
- title=pack.title,
- subtitle=pack.subtitle,
- level=pack.level,
- data_json=payload,
- is_published=is_published if policy_lane == "personal" else False,
- )
- db.add(row)
+ db.add(PackORM(id=pack.id, title=pack.title, subtitle=pack.subtitle, level=pack.level, data_json=payload, is_published=is_published))
else:
- row.owner_user_id = submitted_by_user_id if policy_lane == "personal" else row.owner_user_id
- row.policy_lane = policy_lane
row.title = pack.title
row.subtitle = pack.subtitle
row.level = pack.level
row.data_json = payload
- if policy_lane == "personal":
- row.is_published = is_published
+ row.is_published = is_published
db.commit()
def create_learner(owner_user_id: int, learner_id: str, display_name: str = ""):
@@ -98,7 +75,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()
@@ -118,3 +95,30 @@ def save_learner_state(state: LearnerState):
db.add(EvidenceEventORM(learner_id=state.learner_id, concept_id=h.concept_id, dimension=h.dimension, score=h.score, confidence_hint=h.confidence_hint, timestamp=h.timestamp, kind=h.kind, source_id=h.source_id))
db.commit()
return state
+
+def create_evaluator_job(learner_id: str, pack_id: str, concept_id: str, submitted_text: str) -> int:
+ with SessionLocal() as db:
+ job = EvaluatorJobORM(learner_id=learner_id, pack_id=pack_id, concept_id=concept_id, submitted_text=submitted_text, status="queued")
+ db.add(job)
+ db.commit()
+ db.refresh(job)
+ return job.id
+
+def list_evaluator_jobs_for_learner(learner_id: str) -> list[EvaluatorJobORM]:
+ with SessionLocal() as db:
+ return db.execute(select(EvaluatorJobORM).where(EvaluatorJobORM.learner_id == learner_id).order_by(EvaluatorJobORM.id.desc())).scalars().all()
+
+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 = ""):
+ with SessionLocal() as db:
+ job = db.get(EvaluatorJobORM, job_id)
+ if job is None:
+ return
+ job.status = status
+ job.result_score = score
+ job.result_confidence_hint = confidence_hint
+ job.result_notes = notes
+ db.commit()
diff --git a/src/didactopus/seed.py b/src/didactopus/seed.py
index bdc7b86..f77244d 100644
--- a/src/didactopus/seed.py
+++ b/src/didactopus/seed.py
@@ -1,34 +1,36 @@
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, create_learner
-from .models import PackData, PackConcept, GraphPosition, CrossPackLink
+
+PACKS = [
+ {
+ "id": "bayes-pack",
+ "title": "Bayesian Reasoning",
+ "subtitle": "Probability, evidence, updating, and model criticism.",
+ "level": "novice-friendly",
+ "concepts": [
+ {"id": "prior", "title": "Prior", "prerequisites": [], "masteryDimension": "mastery", "exerciseReward": "Prior badge earned"},
+ {"id": "posterior", "title": "Posterior", "prerequisites": ["prior"], "masteryDimension": "mastery", "exerciseReward": "Posterior path opened"},
+ {"id": "model-checking", "title": "Model Checking", "prerequisites": ["posterior"], "masteryDimension": "mastery", "exerciseReward": "Model-checking unlocked"}
+ ],
+ "onboarding": {"headline": "Start with a fast visible win", "body": "Read one short orientation, answer one guided question, and leave with your first mastery marker.", "checklist": ["Read the one-screen topic orientation", "Answer one guided exercise", "Write one explanation in your own words"]},
+ "compliance": {"sources": 2, "attributionRequired": True, "shareAlikeRequired": True, "noncommercialOnly": True, "flags": ["share-alike", "noncommercial", "excluded-third-party-content"]}
+ }
+]
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()
- create_learner(1, "wesley-learner", "Wesley learner")
- upsert_pack(
- PackData(
- id="wesley-private-pack",
- title="Wesley Private Pack",
- subtitle="Personal pack example.",
- level="novice-friendly",
- concepts=[
- PackConcept(id="intro", title="Intro", prerequisites=[], position=GraphPosition(x=150, y=120)),
- PackConcept(id="second", title="Second concept", prerequisites=["intro"], position=GraphPosition(x=420, y=120)),
- PackConcept(id="third", title="Third concept", prerequisites=["second"], position=GraphPosition(x=700, y=120), cross_pack_links=[CrossPackLink(source_concept_id="third", target_pack_id="advanced-pack", target_concept_id="adv-1", relationship="next_pack")]),
- PackConcept(id="branch", title="Branch concept", prerequisites=["intro"], position=GraphPosition(x=420, y=320)),
- ],
- onboarding={"headline":"Start privately"},
- compliance={}
- ),
- submitted_by_user_id=1,
- policy_lane="personal",
- is_published=True,
- )
+ 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 ad99705..aa3f154 100644
--- a/src/didactopus/worker.py
+++ b/src/didactopus/worker.py
@@ -8,14 +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")
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."
- update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes, trace={"notes": ["completed"]})
+ 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. In a real deployment this would poll a queue.")
while True:
time.sleep(60)
+
+if __name__ == "__main__":
+ main()
diff --git a/webui/index.html b/webui/index.html
index 45716ea..be6856a 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -3,7 +3,7 @@
- Didactopus Pack UI
+ Didactopus Production UI Scaffold
diff --git a/webui/package.json b/webui/package.json
index 5d364c7..369022a 100644
--- a/webui/package.json
+++ b/webui/package.json
@@ -1,5 +1,5 @@
{
- "name": "didactopus-live-pack-ui",
+ "name": "didactopus-production-ui",
"private": true,
"version": "0.1.0",
"type": "module",
diff --git a/webui/src/App.jsx b/webui/src/App.jsx
index 6c3533b..5256ec2 100644
--- a/webui/src/App.jsx
+++ b/webui/src/App.jsx
@@ -1,207 +1,11 @@
-import React, { useEffect, useMemo, useState } from "react";
-import { applyEvidence, buildMasteryMap, claimReadiness, milestoneMessages, progressPercent, recommendNext } from "./engine";
-import { loadLearnerState, saveLearnerState, resetLearnerState } from "./storage";
-
-const PACKS = ["/packs/bayes-pack.json", "/packs/stats-pack.json"];
-
-function DomainCard({ domain, selected, onSelect }) {
- return (
-
- );
-}
-
-function NextStepCard({ step, onSimulate }) {
- return (
-
-
-
-
{step.title}
-
{step.minutes} minutes
-
-
{step.reward}
-
-
{step.reason}
-
- Why this is recommended
-
- {step.why.map((item, idx) => - {item}
)}
-
-
-
-
- );
-}
-
+import React from "react";
export default function App() {
- const [packs, setPacks] = useState([]);
- const [selectedDomainId, setSelectedDomainId] = useState("");
- const [learnerName, setLearnerName] = useState("Wesley");
- const [domainStates, setDomainStates] = useState({});
- const [lastReward, setLastReward] = useState("");
-
- useEffect(() => {
- Promise.all(PACKS.map((u) => fetch(u).then((r) => r.json()))).then((loaded) => {
- setPacks(loaded);
- setSelectedDomainId(loaded[0]?.id || "");
- const states = {};
- for (const pack of loaded) {
- states[pack.id] = loadLearnerState(pack.id);
- }
- setDomainStates(states);
- });
- }, []);
-
- const domain = useMemo(() => packs.find((d) => d.id === selectedDomainId) || null, [packs, selectedDomainId]);
- const learnerState = domain ? (domainStates[domain.id] || loadLearnerState(domain.id)) : null;
-
- const masteryMap = useMemo(() => domain && learnerState ? buildMasteryMap(learnerState, domain) : [], [learnerState, domain]);
- const progress = useMemo(() => domain && learnerState ? progressPercent(learnerState, domain) : 0, [learnerState, domain]);
- const recs = useMemo(() => domain && learnerState ? recommendNext(learnerState, domain) : [], [learnerState, domain]);
- const milestones = useMemo(() => domain && learnerState ? milestoneMessages(learnerState, domain) : [], [learnerState, domain]);
- const readiness = useMemo(() => domain && learnerState ? claimReadiness(learnerState, domain) : {ready:false, mastered:0, avgScore:0, avgConfidence:0}, [learnerState, domain]);
-
- function updateState(domainId, nextState) {
- saveLearnerState(domainId, nextState);
- setDomainStates((prev) => ({ ...prev, [domainId]: nextState }));
- }
-
- function simulateStep(step) {
- if (!domain || !learnerState) return;
- const timestamp = new Date().toISOString();
- const updated = applyEvidence(learnerState, {
- concept_id: step.conceptId,
- dimension: "mastery",
- score: step.scoreHint,
- confidence_hint: step.confidenceHint,
- timestamp,
- kind: "checkpoint",
- source_id: `ui-${step.id}`
- });
- updateState(domain.id, updated);
- setLastReward(step.reward);
- }
-
- function resetSelectedDomain() {
- if (!domain) return;
- resetLearnerState(domain.id);
- updateState(domain.id, loadLearnerState(domain.id));
- setLastReward("");
- }
-
- if (!domain || !learnerState) {
- return ;
- }
-
return (
-
-
-
Didactopus learner prototype
-
Real pack files, persistent learner state, and live recommendation updates.
-
-
-
-
-
-
-
-
-
-
-
-
- First-session onboarding
- {domain.onboarding.headline}
- {domain.onboarding.body}
- Learner: {learnerName || "Unnamed learner"}
- {domain.onboarding.checklist.map((item, idx) => - {item}
)}
-
-
-
- Visible mastery map
-
- {masteryMap.map((node) => (
-
-
{node.label}
-
{node.status}
-
- ))}
-
-
-
-
- Evidence log
- {learnerState.history.length === 0 ? No evidence recorded yet.
: (
-
- {learnerState.history.slice().reverse().map((item, idx) => (
- - {item.concept_id} · score {item.score.toFixed(2)} · confidence hint {item.confidence_hint.toFixed(2)}
- ))}
-
- )}
-
-
-
-
-
- What should I do next?
- {recs.length === 0 ? (
- No immediate recommendation available.
- ) : (
-
- {recs.map((step) => )}
-
- )}
-
-
-
-
-
- Progress
-
-
Mastery progress
-
-
{progress}%
-
-
-
{readiness.ready ? "Usable expertise threshold met" : "Still building toward usable expertise"}
-
Mastered concepts: {readiness.mastered}
-
Average score: {readiness.avgScore.toFixed(2)}
-
Average confidence: {readiness.avgConfidence.toFixed(2)}
-
-
-
-
- Milestones and rewards
- {lastReward ? {lastReward}
: null}
- {milestones.map((m, idx) => - {m}
)}
-
-
-
- Source attribution and compliance
-
-
Sources
{domain.compliance.sources}
-
Attribution
{domain.compliance.attributionRequired ? "required" : "not required"}
-
Share-alike
{domain.compliance.shareAlikeRequired ? "yes" : "no"}
-
Noncommercial
{domain.compliance.noncommercialOnly ? "yes" : "no"}
-
-
- {domain.compliance.flags.length ? domain.compliance.flags.map((f) => {f}) : No extra flags}
-
-
-
-
+
+
Didactopus productionization scaffold
+
This UI scaffold is intended for later connection to evaluator history, learner management, and admin pack management endpoints.
+
);
}
diff --git a/webui/src/main.jsx b/webui/src/main.jsx
index 8ad26cf..7352818 100644
--- a/webui/src/main.jsx
+++ b/webui/src/main.jsx
@@ -2,5 +2,4 @@ import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles.css";
-
createRoot(document.getElementById("root")).render();
diff --git a/webui/src/styles.css b/webui/src/styles.css
index 0de0492..3c9f1b9 100644
--- a/webui/src/styles.css
+++ b/webui/src/styles.css
@@ -1,53 +1,3 @@
-:root {
- --bg: #f6f8fb;
- --card: #ffffff;
- --text: #1f2430;
- --muted: #60697a;
- --border: #dbe1ea;
- --accent: #2d6cdf;
- --soft: #eef4ff;
-}
-* { 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: 20px; }
-.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: 260px; }
-.hero-controls input { width: 100%; margin-top: 6px; border: 1px solid var(--border); border-radius: 10px; padding: 10px; font: inherit; }
-.hero-controls button { margin-top: 12px; }
-.domain-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-top: 16px; }
-.domain-card { border: 1px solid var(--border); background: var(--card); border-radius: 18px; padding: 16px; text-align: left; cursor: pointer; }
-.domain-card.selected { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(45,108,223,0.12); }
-.domain-title { font-size: 20px; font-weight: 700; }
-.domain-subtitle { margin-top: 6px; color: var(--muted); }
-.domain-meta { margin-top: 10px; display: flex; gap: 12px; color: var(--muted); font-size: 14px; }
-.layout { display: grid; grid-template-columns: 1fr 1.25fr 0.95fr; gap: 16px; margin-top: 16px; }
-.card { background: var(--card); border: 1px solid var(--border); border-radius: 20px; padding: 18px; }
-.muted { color: var(--muted); }
-.steps-stack { display: grid; gap: 14px; }
-.step-card { border: 1px solid var(--border); border-radius: 16px; padding: 14px; background: #fcfdff; }
-.step-header { display: flex; justify-content: space-between; gap: 12px; align-items: start; }
-.reward-pill { background: var(--soft); border: 1px solid var(--border); border-radius: 999px; padding: 8px 10px; font-size: 12px; }
-button { border: 1px solid var(--border); background: white; border-radius: 12px; padding: 10px 14px; cursor: pointer; }
-.primary { margin-top: 10px; background: var(--accent); color: white; border: none; }
-.map-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
-.map-node { border: 1px solid var(--border); border-radius: 16px; padding: 14px; }
-.map-node.mastered { background: #eef9f0; }
-.map-node.active, .map-node.available { background: #eef4ff; }
-.map-node.locked { background: #f6f7fa; }
-.node-label { font-weight: 700; }
-.node-status { margin-top: 6px; color: var(--muted); text-transform: capitalize; }
-.progress-wrap { margin-bottom: 14px; }
-.progress-bar { width: 100%; height: 12px; border-radius: 999px; background: #e9edf4; overflow: hidden; margin: 8px 0; }
-.progress-fill { height: 100%; background: var(--accent); }
-.reward-banner { background: #fff7dd; border: 1px solid #ecdca2; border-radius: 14px; padding: 12px; margin-bottom: 12px; font-weight: 700; }
-.readiness-box { border: 1px solid var(--border); background: #fbfcfe; border-radius: 14px; padding: 12px; }
-.readiness-box.ready { background: #eef9f0; }
-.compliance-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
-.flag-row { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 8px; }
-.flag { border: 1px solid var(--border); background: #f4f7fc; border-radius: 999px; padding: 6px 10px; font-size: 12px; }
-details summary { cursor: pointer; color: var(--accent); }
-@media (max-width: 1100px) {
- .layout { grid-template-columns: 1fr; }
- .domain-grid { grid-template-columns: 1fr; }
- .hero { flex-direction: column; }
-}
+body { margin:0; font-family:Arial, Helvetica, sans-serif; background:#f6f8fb; color:#1f2430; }
+.page { max-width: 1100px; margin: 0 auto; padding: 24px; }
+.card { background:white; border:1px solid #dbe1ea; border-radius:18px; padding:20px; }