diff --git a/pyproject.toml b/pyproject.toml
index 17c47da..083045d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,17 +8,16 @@ version = "0.1.0"
requires-python = ">=3.10"
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-api = "didactopus.api:main"
+didactopus-export-svg = "didactopus.export_svg:main"
[tool.setuptools.packages.find]
where = ["src"]
diff --git a/src/didactopus/api.py b/src/didactopus/api.py
index 0942b91..3d03db4 100644
--- a/src/didactopus/api.py
+++ b/src/didactopus/api.py
@@ -1,24 +1,13 @@
from __future__ import annotations
-import json
-from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks
+from fastapi import FastAPI, HTTPException, Header, Depends
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, GovernanceAction, ReviewCommentCreate, ContributionSubmissionCreate
-from .repository import (
- authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token,
- list_packs_for_user, list_pack_admin_rows, get_pack, get_pack_row, get_pack_validation, get_pack_provenance,
- upsert_pack, create_submission, list_submissions, get_submission_diff, get_submission_gates, list_review_tasks,
- set_pack_publication, can_publish_pack, set_governance_state, list_pack_versions, add_review_comment, list_review_comments,
- 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 .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState
+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 .auth import issue_access_token, issue_refresh_token, decode_token, new_token_id
-from .worker import process_job
+from .engine import build_graph_frames, stable_layout
-settings = load_settings()
Base.metadata.create_all(bind=engine)
app = FastAPI(title="Didactopus API Prototype")
@@ -34,11 +23,6 @@ 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
@@ -86,93 +70,11 @@ def refresh(payload: RefreshRequest):
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"))]
-@app.post("/api/packs")
-def api_upsert_personal_pack(payload: CreatePackRequest, user = Depends(current_user)):
- lane = payload.policy_lane
- if lane == "community" and user.role != "admin":
- raise HTTPException(status_code=403, detail="Community lane direct upsert is admin-only; use contribution submission")
- upsert_pack(payload.pack, submitted_by_user_id=user.id, policy_lane=lane, is_published=payload.is_published if lane == "personal" else False, change_summary=payload.change_summary)
- return {"ok": True, "pack_id": payload.pack.id, "policy_lane": lane}
-
-@app.post("/api/contributions")
-def api_create_contribution(payload: ContributionSubmissionCreate, user = Depends(current_user)):
- submission_id = create_submission(payload.pack, user.id, payload.submission_summary)
- return {"ok": True, "submission_id": submission_id}
-
-@app.get("/api/admin/submissions")
-def api_admin_submissions(user = Depends(require_admin)):
- return list_submissions()
-
-@app.get("/api/admin/submissions/{submission_id}/diff")
-def api_admin_submission_diff(submission_id: int, user = Depends(require_admin)):
- return get_submission_diff(submission_id)
-
-@app.get("/api/admin/submissions/{submission_id}/gates")
-def api_admin_submission_gates(submission_id: int, user = Depends(require_admin)):
- return get_submission_gates(submission_id)
-
-@app.get("/api/admin/review-tasks")
-def api_admin_review_tasks(user = Depends(require_admin)):
- return list_review_tasks()
-
-@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.get("/api/admin/packs/{pack_id}/versions")
-def api_admin_pack_versions(pack_id: str, user = Depends(require_admin)):
- return list_pack_versions(pack_id)
-
-@app.get("/api/admin/packs/{pack_id}/comments")
-def api_admin_pack_comments(pack_id: str, user = Depends(require_admin)):
- return list_review_comments(pack_id)
-
-@app.get("/api/admin/packs/{pack_id}/publishability")
-def api_pack_publishability(pack_id: str, user = Depends(require_admin)):
- ok, reason = can_publish_pack(pack_id)
- return {"ok": ok, "reason": reason}
-
-@app.post("/api/admin/packs")
-def api_upsert_pack(payload: CreatePackRequest, user = Depends(require_admin)):
- upsert_pack(payload.pack, submitted_by_user_id=user.id, policy_lane=payload.policy_lane, is_published=payload.is_published if payload.policy_lane == "personal" else False, change_summary=payload.change_summary)
- return {"ok": True, "pack_id": payload.pack.id}
-
-@app.post("/api/admin/packs/{pack_id}/publish")
-def api_publish_pack(pack_id: str, is_published: bool, user = Depends(require_admin)):
- ok, reason = set_pack_publication(pack_id, is_published)
- if not ok:
- raise HTTPException(status_code=400, detail=reason)
- return {"ok": True, "pack_id": pack_id, "is_published": is_published, "reason": reason}
-
-@app.post("/api/admin/packs/{pack_id}/governance")
-def api_governance_action(pack_id: str, payload: GovernanceAction, user = Depends(require_admin)):
- ok = set_governance_state(pack_id, payload.status, payload.review_summary)
- if not ok:
- raise HTTPException(status_code=400, detail="Governance transition blocked")
- return {"ok": True, "pack_id": pack_id, "status": payload.status}
-
-@app.post("/api/admin/packs/{pack_id}/comments")
-def api_add_review_comment(pack_id: str, version_number: int, payload: ReviewCommentCreate, user = Depends(require_admin)):
- add_review_comment(pack_id, version_number, user.id, payload.comment_text, payload.disposition)
- return {"ok": True}
-
@app.post("/api/learners")
def api_create_learner(payload: CreateLearnerRequest, user = Depends(current_user)):
create_learner(user.id, payload.learner_id, payload.display_name)
return {"ok": True, "learner_id": payload.learner_id}
-@app.get("/api/learners")
-def api_list_learners(user = Depends(current_user)):
- return list_learners_for_user(user.id, is_admin=(user.role == "admin"))
-
@app.get("/api/learners/{learner_id}/state")
def api_get_learner_state(learner_id: str, user = Depends(current_user)):
ensure_learner_access(user, learner_id)
@@ -185,51 +87,26 @@ 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.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)
- state = load_learner_state(learner_id)
- state = apply_evidence(state, event)
- save_learner_state(state)
- return 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}/recommendations/{pack_id}")
-def api_get_recommendations(learner_id: str, pack_id: str, user = Depends(current_user)):
+@app.get("/api/learners/{learner_id}/graph-animation/{pack_id}")
+def api_graph_animation(learner_id: str, pack_id: str, user = Depends(current_user)):
ensure_learner_access(user, learner_id)
ensure_pack_access(user, pack_id)
- state = load_learner_state(learner_id)
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)
- ensure_pack_access(user, payload.pack_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/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)
- 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]
+ 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 [],
+ }
def main():
- uvicorn.run(app, host=settings.host, port=settings.port)
+ uvicorn.run(app, host="127.0.0.1", port=8011)
diff --git a/src/didactopus/config.py b/src/didactopus/config.py
index 6db28e1..1f71733 100644
--- a/src/didactopus/config.py
+++ b/src/didactopus/config.py
@@ -3,7 +3,7 @@ import os
from pydantic import BaseModel
class Settings(BaseModel):
- database_url: str = os.getenv("DIDACTOPUS_DATABASE_URL", "postgresql+psycopg://didactopus:didactopus-dev-password@localhost:5432/didactopus")
+ database_url: str = os.getenv("DIDACTOPUS_DATABASE_URL", "sqlite+pysqlite:///:memory:")
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")
diff --git a/src/didactopus/engine.py b/src/didactopus/engine.py
index c1e52bf..6b7c236 100644
--- a/src/didactopus/engine.py
+++ b/src/didactopus/engine.py
@@ -1,59 +1,110 @@
from __future__ import annotations
-from .models import LearnerState, EvidenceEvent, MasteryRecord, PackData
+from collections import defaultdict
+from .models import LearnerState, PackData
-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 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 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 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 prereqs_satisfied(state: LearnerState, concept, min_score: float = 0.65, min_confidence: float = 0.45) -> bool:
+def prereqs_satisfied(scores: dict[str, float], concept, min_score: float = 0.65) -> bool:
for pid in concept.prerequisites:
- rec = get_record(state, pid, concept.masteryDimension)
- if rec is None or rec.score < min_score or rec.confidence < min_confidence:
+ if scores.get(pid, 0.0) < min_score:
return False
return True
-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:
+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:
return "mastered"
- if prereqs_satisfied(state, concept, min_score, min_confidence):
- return "active" if rec else "available"
+ if prereqs_satisfied(scores, concept, min_score):
+ return "active" if score > 0 else "available"
return "locked"
-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,
+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"],
})
- return cards[:4]
+ 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
diff --git a/src/didactopus/export_svg.py b/src/didactopus/export_svg.py
index 1bb04a0..e3e4a68 100644
--- a/src/didactopus/export_svg.py
+++ b/src/didactopus/export_svg.py
@@ -18,10 +18,6 @@ def frame_to_svg(frame: dict, width: int = 960, height: int = 560) -> str:
dst = next((n for n in frame["nodes"] if n["id"] == edge["target"]), None)
if src and dst:
parts.append(f'')
- for edge in frame.get("cross_pack_links", []):
- src = next((n for n in frame["nodes"] if n["id"] == edge["source"]), None)
- if src:
- parts.append(f'')
for node in frame.get("nodes", []):
fill = color_for_status(node["status"])
parts.append(f'')
@@ -37,3 +33,12 @@ def export_svg_frames(payload_path: str, out_dir: str):
for frame in payload.get("frames", []):
svg = frame_to_svg(frame)
(out / f'frame_{frame["index"]:04d}.svg').write_text(svg, encoding="utf-8")
+
+def main():
+ import argparse
+ parser = argparse.ArgumentParser()
+ parser.add_argument("payload_json")
+ parser.add_argument("out_dir")
+ args = parser.parse_args()
+ export_svg_frames(args.payload_json, args.out_dir)
+ print(f"Exported SVG frames to {args.out_dir}")
diff --git a/src/didactopus/models.py b/src/didactopus/models.py
index 5cd5a5f..42e0f8a 100644
--- a/src/didactopus/models.py
+++ b/src/didactopus/models.py
@@ -1,9 +1,5 @@
from __future__ import annotations
from pydantic import BaseModel, Field
-from typing import Literal
-
-EvidenceKind = Literal["checkpoint", "project", "exercise", "review"]
-PolicyLane = Literal["personal", "community"]
class TokenPair(BaseModel):
access_token: str
@@ -19,19 +15,24 @@ 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 = ""
-
-class PackCompliance(BaseModel):
- sources: int = 0
- attributionRequired: bool = False
- shareAlikeRequired: bool = False
- noncommercialOnly: bool = False
- flags: list[str] = Field(default_factory=list)
+ position: GraphPosition | None = None
+ cross_pack_links: list[CrossPackLink] = Field(default_factory=list)
class PackData(BaseModel):
id: str
@@ -40,25 +41,7 @@ class PackData(BaseModel):
level: str = "novice-friendly"
concepts: list[PackConcept] = Field(default_factory=list)
onboarding: dict = Field(default_factory=dict)
- compliance: PackCompliance = Field(default_factory=PackCompliance)
-
-class CreatePackRequest(BaseModel):
- pack: PackData
- policy_lane: PolicyLane = "personal"
- is_published: bool = False
- change_summary: str = ""
-
-class GovernanceAction(BaseModel):
- status: str
- review_summary: str = ""
-
-class ReviewCommentCreate(BaseModel):
- comment_text: str
- disposition: str = "comment"
-
-class ContributionSubmissionCreate(BaseModel):
- pack: PackData
- submission_summary: str = ""
+ compliance: dict = Field(default_factory=dict)
class CreateLearnerRequest(BaseModel):
learner_id: str
@@ -78,23 +61,10 @@ class EvidenceEvent(BaseModel):
score: float
confidence_hint: float = 0.5
timestamp: str
- kind: EvidenceKind = "exercise"
+ kind: str = "exercise"
source_id: str = ""
class LearnerState(BaseModel):
learner_id: str
records: list[MasteryRecord] = Field(default_factory=list)
history: list[EvidenceEvent] = Field(default_factory=list)
-
-class EvaluatorSubmission(BaseModel):
- pack_id: str
- 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 = ""
diff --git a/src/didactopus/orm.py b/src/didactopus/orm.py
index 1a49273..49c1d57 100644
--- a/src/didactopus/orm.py
+++ b/src/didactopus/orm.py
@@ -21,63 +21,13 @@ 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") # personal | community
+ 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)
- validation_json: Mapped[str] = mapped_column(Text, default="{}")
- provenance_json: Mapped[str] = mapped_column(Text, default="{}")
- governance_state: Mapped[str] = mapped_column(String(50), default="draft")
- current_version: Mapped[int] = mapped_column(Integer, default=1)
is_published: Mapped[bool] = mapped_column(Boolean, default=False)
-class PackVersionORM(Base):
- __tablename__ = "pack_versions"
- id: Mapped[int] = mapped_column(Integer, primary_key=True)
- pack_id: Mapped[str] = mapped_column(ForeignKey("packs.id"), index=True)
- version_number: Mapped[int] = mapped_column(Integer)
- submitted_by_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
- policy_lane: Mapped[str] = mapped_column(String(50), default="personal")
- status: Mapped[str] = mapped_column(String(50), default="draft")
- data_json: Mapped[str] = mapped_column(Text)
- change_summary: Mapped[str] = mapped_column(Text, default="")
- created_at: Mapped[str] = mapped_column(String(100), default="")
- review_summary: Mapped[str] = mapped_column(Text, default="")
-
-class ReviewCommentORM(Base):
- __tablename__ = "review_comments"
- id: Mapped[int] = mapped_column(Integer, primary_key=True)
- pack_id: Mapped[str] = mapped_column(ForeignKey("packs.id"), index=True)
- version_number: Mapped[int] = mapped_column(Integer)
- reviewer_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
- comment_text: Mapped[str] = mapped_column(Text, default="")
- disposition: Mapped[str] = mapped_column(String(50), default="comment")
- created_at: Mapped[str] = mapped_column(String(100), default="")
-
-class ContributionSubmissionORM(Base):
- __tablename__ = "contribution_submissions"
- id: Mapped[int] = mapped_column(Integer, primary_key=True)
- pack_id: Mapped[str] = mapped_column(String(100), index=True)
- policy_lane: Mapped[str] = mapped_column(String(50), default="community")
- proposed_version_number: Mapped[int] = mapped_column(Integer, default=1)
- contributor_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
- status: Mapped[str] = mapped_column(String(50), default="submitted")
- submission_summary: Mapped[str] = mapped_column(Text, default="")
- proposed_data_json: Mapped[str] = mapped_column(Text, default="{}")
- diff_json: Mapped[str] = mapped_column(Text, default="{}")
- gate_json: Mapped[str] = mapped_column(Text, default="{}")
- created_at: Mapped[str] = mapped_column(String(100), default="")
-
-class ReviewTaskORM(Base):
- __tablename__ = "review_tasks"
- id: Mapped[int] = mapped_column(Integer, primary_key=True)
- submission_id: Mapped[int] = mapped_column(ForeignKey("contribution_submissions.id"), index=True)
- reviewer_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
- task_status: Mapped[str] = mapped_column(String(50), default="open")
- task_note: Mapped[str] = mapped_column(Text, default="")
- created_at: Mapped[str] = mapped_column(String(100), default="")
-
class LearnerORM(Base):
__tablename__ = "learners"
id: Mapped[str] = mapped_column(String(100), primary_key=True)
@@ -106,16 +56,3 @@ 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="")
- trace_json: Mapped[str] = mapped_column(Text, default="{}")
diff --git a/src/didactopus/repository.py b/src/didactopus/repository.py
index 576479f..bc8e397 100644
--- a/src/didactopus/repository.py
+++ b/src/didactopus/repository.py
@@ -1,48 +1,11 @@
from __future__ import annotations
import json
-from datetime import datetime, timezone
from sqlalchemy import select
from .db import SessionLocal
-from .orm import UserORM, RefreshTokenORM, PackORM, PackVersionORM, ReviewCommentORM, ContributionSubmissionORM, ReviewTaskORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM
+from .orm import UserORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM
from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent
from .auth import verify_password
-def now_iso() -> str:
- return datetime.now(timezone.utc).isoformat()
-
-def pack_diff(old_pack: dict | None, new_pack: dict) -> dict:
- old_pack = old_pack or {}
- old_concepts = {c.get("id"): c for c in old_pack.get("concepts", [])}
- new_concepts = {c.get("id"): c for c in new_pack.get("concepts", [])}
- added = sorted([cid for cid in new_concepts if cid not in old_concepts])
- removed = sorted([cid for cid in old_concepts if cid not in new_concepts])
- changed = sorted([cid for cid in new_concepts if cid in old_concepts and new_concepts[cid] != old_concepts[cid]])
- return {
- "title_changed": old_pack.get("title") != new_pack.get("title"),
- "subtitle_changed": old_pack.get("subtitle") != new_pack.get("subtitle"),
- "concepts_added": added,
- "concepts_removed": removed,
- "concepts_changed": changed,
- "onboarding_changed": old_pack.get("onboarding") != new_pack.get("onboarding"),
- "compliance_changed": old_pack.get("compliance") != new_pack.get("compliance"),
- }
-
-def gate_summary(validation: dict, provenance: dict) -> dict:
- warnings = list(validation.get("warnings", []) or [])
- errors = list(validation.get("errors", []) or [])
- restrictive_flags = list(provenance.get("restrictive_flags", []) or [])
- qa_ok = validation.get("ok", False) and len(errors) == 0
- provenance_ok = provenance.get("source_count", 0) >= 0
- ready_for_review = qa_ok and provenance_ok
- return {
- "qa_ok": qa_ok,
- "provenance_ok": provenance_ok,
- "ready_for_review": ready_for_review,
- "warnings": warnings,
- "errors": errors,
- "restrictive_flags": restrictive_flags,
- }
-
def get_user_by_username(username: str):
with SessionLocal() as db:
return db.execute(select(UserORM).where(UserORM.username == username)).scalar_one_or_none()
@@ -88,11 +51,6 @@ def list_packs_for_user(user_id: int | None = None, include_unpublished: bool =
out.append(PackData.model_validate(json.loads(r.data_json)))
return out
-def list_pack_admin_rows():
- with SessionLocal() as db:
- rows = db.execute(select(PackORM).order_by(PackORM.id)).scalars().all()
- return [{"id": r.id, "title": r.title, "policy_lane": r.policy_lane, "is_published": r.is_published, "subtitle": r.subtitle, "governance_state": r.governance_state, "current_version": r.current_version} for r in rows]
-
def get_pack(pack_id: str):
with SessionLocal() as db:
row = db.get(PackORM, pack_id)
@@ -102,35 +60,7 @@ def get_pack_row(pack_id: str):
with SessionLocal() as db:
return db.get(PackORM, pack_id)
-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 validation_and_provenance_for_pack(pack: PackData):
- 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 []
- }
- return validation, provenance
-
-def upsert_pack(pack: PackData, submitted_by_user_id: int, policy_lane: str = "personal", is_published: bool = False, change_summary: str = ""):
- validation, provenance = validation_and_provenance_for_pack(pack)
+def upsert_pack(pack: PackData, submitted_by_user_id: int, policy_lane: str = "personal", is_published: bool = False):
with SessionLocal() as db:
row = db.get(PackORM, pack.id)
payload = json.dumps(pack.model_dump())
@@ -139,13 +69,13 @@ def upsert_pack(pack: PackData, submitted_by_user_id: int, policy_lane: str = "p
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, validation_json=json.dumps(validation), provenance_json=json.dumps(provenance),
- governance_state="draft" if policy_lane == "community" else "personal_ready",
- current_version=1, is_published=is_published if policy_lane == "personal" else False
+ 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)
- version_number = 1
else:
row.owner_user_id = submitted_by_user_id if policy_lane == "personal" else row.owner_user_id
row.policy_lane = policy_lane
@@ -153,195 +83,16 @@ def upsert_pack(pack: PackData, submitted_by_user_id: int, policy_lane: str = "p
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.current_version += 1
- row.governance_state = "draft" if policy_lane == "community" else "personal_ready"
if policy_lane == "personal":
row.is_published = is_published
- version_number = row.current_version
- db.flush()
- db.add(PackVersionORM(
- pack_id=pack.id,
- version_number=version_number,
- submitted_by_user_id=submitted_by_user_id,
- policy_lane=policy_lane,
- status="draft" if policy_lane == "community" else "personal_ready",
- data_json=payload,
- change_summary=change_summary,
- created_at=now_iso(),
- review_summary=""
- ))
db.commit()
-def create_submission(pack: PackData, contributor_user_id: int, submission_summary: str):
- validation, provenance = validation_and_provenance_for_pack(pack)
- with SessionLocal() as db:
- current_pack = db.get(PackORM, pack.id)
- current_payload = json.loads(current_pack.data_json) if current_pack is not None else None
- current_version = current_pack.current_version if current_pack is not None else 0
- proposed_version = current_version + 1
- proposed_payload = pack.model_dump()
- diff = pack_diff(current_payload, proposed_payload)
- gates = gate_summary(validation, provenance)
- sub = ContributionSubmissionORM(
- pack_id=pack.id,
- policy_lane="community",
- proposed_version_number=proposed_version,
- contributor_user_id=contributor_user_id,
- status="submitted",
- submission_summary=submission_summary,
- proposed_data_json=json.dumps(proposed_payload),
- diff_json=json.dumps(diff),
- gate_json=json.dumps(gates),
- created_at=now_iso(),
- )
- db.add(sub)
- db.flush()
- db.add(ReviewTaskORM(
- submission_id=sub.id,
- reviewer_user_id=None,
- task_status="open",
- task_note="Community submission awaiting reviewer attention",
- created_at=now_iso(),
- ))
- db.commit()
- return sub.id
-
-def list_submissions():
- with SessionLocal() as db:
- rows = db.execute(select(ContributionSubmissionORM).order_by(ContributionSubmissionORM.id.desc())).scalars().all()
- return [{
- "submission_id": r.id,
- "pack_id": r.pack_id,
- "policy_lane": r.policy_lane,
- "proposed_version_number": r.proposed_version_number,
- "contributor_user_id": r.contributor_user_id,
- "status": r.status,
- "submission_summary": r.submission_summary,
- "created_at": r.created_at,
- } for r in rows]
-
-def get_submission_diff(submission_id: int):
- with SessionLocal() as db:
- row = db.get(ContributionSubmissionORM, submission_id)
- return {} if row is None else json.loads(row.diff_json or "{}")
-
-def get_submission_gates(submission_id: int):
- with SessionLocal() as db:
- row = db.get(ContributionSubmissionORM, submission_id)
- return {} if row is None else json.loads(row.gate_json or "{}")
-
-def list_review_tasks():
- with SessionLocal() as db:
- rows = db.execute(select(ReviewTaskORM).order_by(ReviewTaskORM.id.desc())).scalars().all()
- return [{
- "task_id": r.id,
- "submission_id": r.submission_id,
- "reviewer_user_id": r.reviewer_user_id,
- "task_status": r.task_status,
- "task_note": r.task_note,
- "created_at": r.created_at,
- } for r in rows]
-
-def can_publish_pack(pack_id: str) -> tuple[bool, str]:
- with SessionLocal() as db:
- row = db.get(PackORM, pack_id)
- if row is None:
- return False, "Pack not found"
- if row.policy_lane == "personal":
- return True, "Personal lane pack may publish directly"
- if row.governance_state != "approved":
- return False, "Community lane pack must be approved before publication"
- validation = json.loads(row.validation_json or "{}")
- provenance = json.loads(row.provenance_json or "{}")
- gates = gate_summary(validation, provenance)
- if not gates.get("ready_for_review", False):
- return False, "Community lane gates not satisfied"
- return True, "Community lane pack passed publish gates"
-
-def set_pack_publication(pack_id: str, is_published: bool):
- with SessionLocal() as db:
- row = db.get(PackORM, pack_id)
- if row is None:
- return False, "Pack not found"
- if is_published:
- validation = json.loads(row.validation_json or "{}")
- provenance = json.loads(row.provenance_json or "{}")
- gates = gate_summary(validation, provenance)
- if row.policy_lane == "community":
- if row.governance_state != "approved":
- return False, "Community lane pack must be approved before publication"
- if not gates.get("ready_for_review", False):
- return False, "Community lane gates not satisfied"
- row.is_published = is_published
- db.commit()
- return True, "Updated"
-
-def set_governance_state(pack_id: str, status: str, review_summary: str):
- with SessionLocal() as db:
- row = db.get(PackORM, pack_id)
- if row is None:
- return False
- if row.policy_lane == "personal":
- row.governance_state = status
- else:
- validation = json.loads(row.validation_json or "{}")
- provenance = json.loads(row.provenance_json or "{}")
- gates = gate_summary(validation, provenance)
- if status == "approved" and not gates.get("ready_for_review", False):
- return False
- row.governance_state = status
- version = db.execute(select(PackVersionORM).where(PackVersionORM.pack_id == pack_id, PackVersionORM.version_number == row.current_version)).scalar_one_or_none()
- if version is not None:
- version.status = status
- version.review_summary = review_summary
- db.commit()
- return True
-
-def list_pack_versions(pack_id: str):
- with SessionLocal() as db:
- rows = db.execute(select(PackVersionORM).where(PackVersionORM.pack_id == pack_id).order_by(PackVersionORM.version_number.desc())).scalars().all()
- return [{
- "version_number": r.version_number,
- "policy_lane": r.policy_lane,
- "status": r.status,
- "change_summary": r.change_summary,
- "created_at": r.created_at,
- "review_summary": r.review_summary,
- "submitted_by_user_id": r.submitted_by_user_id
- } for r in rows]
-
-def add_review_comment(pack_id: str, version_number: int, reviewer_user_id: int, comment_text: str, disposition: str):
- with SessionLocal() as db:
- db.add(ReviewCommentORM(pack_id=pack_id, version_number=version_number, reviewer_user_id=reviewer_user_id, comment_text=comment_text, disposition=disposition, created_at=now_iso()))
- db.commit()
-
-def list_review_comments(pack_id: str):
- with SessionLocal() as db:
- rows = db.execute(select(ReviewCommentORM).where(ReviewCommentORM.pack_id == pack_id).order_by(ReviewCommentORM.id.desc())).scalars().all()
- return [{
- "version_number": r.version_number,
- "reviewer_user_id": r.reviewer_user_id,
- "comment_text": r.comment_text,
- "disposition": r.disposition,
- "created_at": r.created_at
- } for r in rows]
-
def create_learner(owner_user_id: int, learner_id: str, display_name: str = ""):
with SessionLocal() as db:
if db.get(LearnerORM, learner_id) is None:
db.add(LearnerORM(id=learner_id, owner_user_id=owner_user_id, display_name=display_name))
db.commit()
-def list_learners_for_user(user_id: int, is_admin: bool = False):
- with SessionLocal() as db:
- stmt = select(LearnerORM).order_by(LearnerORM.id)
- if not is_admin:
- stmt = stmt.where(LearnerORM.owner_user_id == user_id)
- rows = db.execute(stmt).scalars().all()
- return [{"learner_id": r.id, "display_name": r.display_name, "owner_user_id": r.owner_user_id} for r in rows]
-
def learner_owned_by_user(user_id: int, learner_id: str) -> bool:
with SessionLocal() as db:
learner = db.get(LearnerORM, learner_id)
@@ -367,33 +118,3 @@ 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):
- 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))
- db.add(job)
- db.commit()
- db.refresh(job)
- return job.id
-
-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()
-
-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):
- 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
- 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 14b09e5..bdc7b86 100644
--- a/src/didactopus/seed.py
+++ b/src/didactopus/seed.py
@@ -3,50 +3,32 @@ from sqlalchemy import select
from .db import Base, engine, SessionLocal
from .orm import UserORM
from .auth import hash_password
-from .repository import upsert_pack
-from .models import PackData, PackConcept, PackCompliance
+from .repository import upsert_pack, create_learner
+from .models import PackData, PackConcept, GraphPosition, CrossPackLink
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))
- if db.execute(select(UserORM).where(UserORM.username == "contrib")).scalar_one_or_none() is None:
- db.add(UserORM(username="contrib", password_hash=hash_password("demo-pass"), role="learner", is_active=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"])
- ),
- submitted_by_user_id=1,
- policy_lane="community",
- is_published=True,
- change_summary="Initial shared seed version"
- )
+ create_learner(1, "wesley-learner", "Wesley learner")
upsert_pack(
PackData(
id="wesley-private-pack",
title="Wesley Private Pack",
- subtitle="Personal pack example without community friction.",
+ subtitle="Personal pack example.",
level="novice-friendly",
concepts=[
- PackConcept(id="intro", title="Intro", prerequisites=[], masteryDimension="mastery", exerciseReward="Intro marker"),
+ 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","body":"Personal pack lane.","checklist":["Create pack","Use pack"]},
- compliance=PackCompliance(sources=0, attributionRequired=False, shareAlikeRequired=False, noncommercialOnly=False, flags=[])
+ onboarding={"headline":"Start privately"},
+ compliance={}
),
submitted_by_user_id=1,
policy_lane="personal",
is_published=True,
- change_summary="Initial personal pack"
)
- print("Seeded database. Demo users: wesley/demo-pass and contrib/demo-pass")
diff --git a/tests/test_scaffold_files.py b/tests/test_scaffold_files.py
index 2e9b522..c028d17 100644
--- a/tests/test_scaffold_files.py
+++ b/tests/test_scaffold_files.py
@@ -3,5 +3,5 @@ from pathlib import Path
def test_scaffold_files_exist():
assert Path("src/didactopus/api.py").exists()
assert Path("src/didactopus/repository.py").exists()
+ assert Path("src/didactopus/export_svg.py").exists()
assert Path("webui/src/App.jsx").exists()
- assert Path("webui/src/api.js").exists()
diff --git a/webui/index.html b/webui/index.html
index 83e5a74..8220d96 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -3,7 +3,7 @@
- Didactopus Dual-Lane Policy Layer
+ Didactopus Layout-Aware Graph
diff --git a/webui/package.json b/webui/package.json
index 45e8f5a..467065d 100644
--- a/webui/package.json
+++ b/webui/package.json
@@ -1,5 +1,5 @@
{
- "name": "didactopus-dual-lane-ui",
+ "name": "didactopus-layout-aware-graph-ui",
"private": true,
"version": "0.1.0",
"type": "module",
diff --git a/webui/src/App.jsx b/webui/src/App.jsx
index 48c490a..df96ee9 100644
--- a/webui/src/App.jsx
+++ b/webui/src/App.jsx
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
-import { login, refresh, fetchPacks, upsertPack, createContribution, fetchAdminPacks, fetchPackValidation, fetchPackProvenance, fetchPackVersions, fetchPackComments, fetchSubmissions, fetchSubmissionDiff, fetchSubmissionGates, fetchReviewTasks, publishPack, fetchPublishability, governanceAction, addReviewComment } from "./api";
+import { login, refresh, fetchPacks, fetchLearnerState, putLearnerState, fetchGraphAnimation } from "./api";
import { loadAuth, saveAuth, clearAuth } from "./authStore";
function LoginView({ onAuth }) {
@@ -26,56 +26,53 @@ function LoginView({ onAuth }) {
);
}
-function NavTabs({ tab, setTab, role }) {
+function nodeColor(status) {
+ if (status === "mastered") return "#1f7a1f";
+ if (status === "active") return "#2d6cdf";
+ if (status === "available") return "#c48a00";
+ return "#9aa4b2";
+}
+
+function GraphView({ frame }) {
+ if (!frame) return null;
return (
-
-
-
- {role === "admin" ? <>
-
-
- > : null}
-
+
);
}
export default function App() {
const [auth, setAuth] = useState(loadAuth());
- const [tab, setTab] = useState("personal");
const [packs, setPacks] = useState([]);
- const [adminPacks, setAdminPacks] = useState([]);
- const [selectedPackId, setSelectedPackId] = useState("");
- const [validation, setValidation] = useState(null);
- const [provenance, setProvenance] = useState(null);
- const [versions, setVersions] = useState([]);
- const [comments, setComments] = useState([]);
- const [submissions, setSubmissions] = useState([]);
- const [selectedSubmissionId, setSelectedSubmissionId] = useState(null);
- const [submissionDiff, setSubmissionDiff] = useState(null);
- const [submissionGates, setSubmissionGates] = useState(null);
- const [publishability, setPublishability] = useState(null);
- const [reviewTasks, setReviewTasks] = useState([]);
- const [commentText, setCommentText] = useState("Looks structurally plausible.");
- const [reviewSummary, setReviewSummary] = useState("Reviewed and ready for next stage.");
+ const [learnerId] = useState("wesley-learner");
+ const [packId, setPackId] = useState("");
+ const [graphData, setGraphData] = useState(null);
+ const [frameIndex, setFrameIndex] = useState(0);
+ const [playing, setPlaying] = useState(false);
const [message, setMessage] = useState("");
- const [personalPack, setPersonalPack] = useState({
- id: "my-private-pack",
- title: "My Private Pack",
- subtitle: "Personal lane scaffold",
- level: "novice-friendly",
- concepts: [{ id: "intro", title: "Intro", prerequisites: [], masteryDimension: "mastery", exerciseReward: "Intro" }],
- onboarding: { headline: "Start privately", body: "Personal pack lane", checklist: [] },
- compliance: { sources: 0, attributionRequired: false, shareAlikeRequired: false, noncommercialOnly: false, flags: [] }
- });
- const [contribPack, setContribPack] = useState({
- id: "bayes-pack",
- title: "Bayesian Reasoning",
- subtitle: "Contributor revision scaffold",
- level: "novice-friendly",
- concepts: [{ id: "prior", title: "Prior", prerequisites: [], masteryDimension: "mastery", exerciseReward: "Prior badge earned" }],
- onboarding: { headline: "Start here", body: "Begin", checklist: [] },
- compliance: { sources: 1, attributionRequired: true, shareAlikeRequired: false, noncommercialOnly: false, flags: [] }
- });
async function refreshAuthToken() {
if (!auth?.refresh_token) return null;
@@ -100,185 +97,95 @@ export default function App() {
}
}
+ async function reload(pid) {
+ const data = await guarded((token) => fetchGraphAnimation(token, learnerId, pid));
+ setGraphData(data);
+ setFrameIndex(0);
+ }
+
useEffect(() => {
if (!auth) return;
async function load() {
const p = await guarded((token) => fetchPacks(token));
setPacks(p);
- setSelectedPackId((prev) => prev || p[0]?.id || "");
- if (auth.role === "admin") {
- setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
- setSubmissions(await guarded((token) => fetchSubmissions(token)));
- setReviewTasks(await guarded((token) => fetchReviewTasks(token)));
- }
+ const pid = p[0]?.id || "";
+ setPackId(pid);
+ if (pid) await reload(pid);
}
load();
}, [auth]);
useEffect(() => {
- if (!auth?.role || auth.role !== "admin" || !selectedPackId) return;
- async function loadReview() {
- setValidation(await guarded((token) => fetchPackValidation(token, selectedPackId)));
- setProvenance(await guarded((token) => fetchPackProvenance(token, selectedPackId)));
- setVersions(await guarded((token) => fetchPackVersions(token, selectedPackId)));
- setComments(await guarded((token) => fetchPackComments(token, selectedPackId)));
- setPublishability(await guarded((token) => fetchPublishability(token, selectedPackId)));
+ if (!playing || !graphData?.frames?.length) return;
+ const t = setInterval(() => {
+ setFrameIndex((idx) => idx >= graphData.frames.length - 1 ? 0 : idx + 1);
+ }, 900);
+ return () => clearInterval(t);
+ }, [playing, graphData]);
+
+ async function generateDemo() {
+ let state = await guarded((token) => fetchLearnerState(token, learnerId));
+ const base = Date.now();
+ const events = [
+ ["intro", 0.30, "exercise", 0],
+ ["intro", 0.78, "review", 1000],
+ ["second", 0.42, "exercise", 2000],
+ ["second", 0.72, "review", 3000],
+ ["third", 0.25, "exercise", 4000],
+ ["branch", 0.60, "exercise", 5000],
+ ];
+ const latest = {}
+ for (const [cid, score, kind, offset] of events) {
+ const ts = new Date(base + offset).toISOString();
+ state.history.push({ concept_id: cid, dimension: "mastery", score, confidence_hint: 0.6, timestamp: ts, kind, source_id: `demo-${cid}-${offset}` });
+ latest[cid] = { concept_id: cid, dimension: "mastery", score, confidence: Math.min(0.9, score), evidence_count: (latest[cid]?.evidence_count || 0) + 1, last_updated: ts };
}
- loadReview();
- }, [auth, selectedPackId]);
-
- useEffect(() => {
- if (!auth?.role || auth.role !== "admin" || !selectedSubmissionId) return;
- async function loadSubmission() {
- setSubmissionDiff(await guarded((token) => fetchSubmissionDiff(token, selectedSubmissionId)));
- setSubmissionGates(await guarded((token) => fetchSubmissionGates(token, selectedSubmissionId)));
- }
- loadSubmission();
- }, [auth, selectedSubmissionId]);
-
- async function savePersonalPack() {
- const result = await guarded((token) => upsertPack(token, { pack: personalPack, policy_lane: "personal", is_published: true, change_summary: "Saved through personal lane UI" }));
- setMessage(`Personal pack saved: ${result.pack_id}`);
- setPacks(await guarded((token) => fetchPacks(token)));
- }
-
- async function submitContribution() {
- const result = await guarded((token) => createContribution(token, { pack: contribPack, submission_summary: "Contributor-submitted revision from UI scaffold" }));
- setMessage(`Community submission created: ${result.submission_id}`);
- }
-
- async function doGovernance(status) {
- await guarded((token) => governanceAction(token, selectedPackId, { status, review_summary: reviewSummary }));
- setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
- setVersions(await guarded((token) => fetchPackVersions(token, selectedPackId)));
- setPublishability(await guarded((token) => fetchPublishability(token, selectedPackId)));
- setMessage(`Pack moved to ${status}`);
- }
-
- async function addCommentNow() {
- const versionNumber = versions[0]?.version_number || 1;
- await guarded((token) => addReviewComment(token, selectedPackId, versionNumber, { comment_text: commentText, disposition: "comment" }));
- setComments(await guarded((token) => fetchPackComments(token, selectedPackId)));
- setMessage("Review comment added");
- }
-
- async function publishSelected() {
- const result = await guarded((token) => publishPack(token, selectedPackId, true));
- setMessage(result.reason || "Publish updated");
- setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
- setPublishability(await guarded((token) => fetchPublishability(token, selectedPackId)));
+ state.records = Object.values(latest);
+ await guarded((token) => putLearnerState(token, learnerId, state));
+ await reload(packId);
+ setMessage("Stable-layout graph demo generated.");
}
if (!auth) return ;
+ const frame = graphData?.frames?.[frameIndex];
+
return (
-
Didactopus dual-lane policy layer
-
Personal packs stay low-friction. Community packs keep gates, review, and approval workflows.
-
Signed in as {auth.username} ({auth.role})
- {message ?
{message}
: null}
+
Didactopus layout-aware graph engine
+
Stable node positions, cross-pack links, and export-ready graph frames.
+
{message}
-
- {auth.role === "admin" ? (
-
- ) : null}
+
+
+
+
-
-
- {tab === "personal" && (
-
-
-
- )}
-
- {tab === "contribute" && (
-
-
-
- )}
-
- {tab === "submissions" && auth.role === "admin" && (
-
-
- Submission queue
-
- | ID | Pack | Lane | Version | Status | Select |
-
- {submissions.map((s) => (
-
- | {s.submission_id} |
- {s.pack_id} |
- {s.policy_lane} |
- {s.proposed_version_number} |
- {s.status} |
- |
-
- ))}
-
-
- Review tasks
- {JSON.stringify(reviewTasks, null, 2)}
-
-
- Submission diff and gates
- Diff summary
- {JSON.stringify(submissionDiff, null, 2)}
- Gate summary
- {JSON.stringify(submissionGates, null, 2)}
-
-
- )}
-
- {tab === "review" && auth.role === "admin" && (
-
-
- Governance and publishability
-
-
-
-
-
-
-
- Publishability
- {JSON.stringify(publishability, null, 2)}
- Validation
- {JSON.stringify(validation, null, 2)}
- Provenance
- {JSON.stringify(provenance, null, 2)}
-
-
- Versions and comments
- Versions
- {JSON.stringify(versions, null, 2)}
-
-
- Comments
- {JSON.stringify(comments, null, 2)}
-
-
- )}
+
+
+ Graph animation
+
+
Frame: {frameIndex + 1} / {graphData?.frames?.length || 0}
+
Event: {frame?.event_kind || "-"}
+
Focus: {frame?.focus_concept_id || "-"}
+
Timestamp: {frame?.timestamp || "-"}
+
+
+
+
+ Frame payload
+ {JSON.stringify(graphData, null, 2)}
+
+
);
}
diff --git a/webui/src/api.js b/webui/src/api.js
index ae687ea..975a975 100644
--- a/webui/src/api.js
+++ b/webui/src/api.js
@@ -17,18 +17,6 @@ export async function refresh(refreshToken) {
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 upsertPack(token, payload) { const res = await fetch(`${API}/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 createContribution(token, payload) { const res = await fetch(`${API}/contributions`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("createContribution failed"); return await res.json(); }
-export async function fetchAdminPacks(token) { const res = await fetch(`${API}/admin/packs`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchAdminPacks failed"); return await res.json(); }
-export async function fetchPackValidation(token, packId) { const res = await fetch(`${API}/admin/packs/${packId}/validation`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPackValidation failed"); return await res.json(); }
-export async function fetchPackProvenance(token, packId) { const res = await fetch(`${API}/admin/packs/${packId}/provenance`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPackProvenance failed"); return await res.json(); }
-export async function fetchPackVersions(token, packId) { const res = await fetch(`${API}/admin/packs/${packId}/versions`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPackVersions failed"); return await res.json(); }
-export async function fetchPackComments(token, packId) { const res = await fetch(`${API}/admin/packs/${packId}/comments`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPackComments failed"); return await res.json(); }
-export async function fetchSubmissions(token) { const res = await fetch(`${API}/admin/submissions`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchSubmissions failed"); return await res.json(); }
-export async function fetchSubmissionDiff(token, submissionId) { const res = await fetch(`${API}/admin/submissions/${submissionId}/diff`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchSubmissionDiff failed"); return await res.json(); }
-export async function fetchSubmissionGates(token, submissionId) { const res = await fetch(`${API}/admin/submissions/${submissionId}/gates`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchSubmissionGates failed"); return await res.json(); }
-export async function fetchReviewTasks(token) { const res = await fetch(`${API}/admin/review-tasks`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchReviewTasks 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 fetchPublishability(token, packId) { const res = await fetch(`${API}/admin/packs/${packId}/publishability`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPublishability failed"); return await res.json(); }
-export async function governanceAction(token, packId, payload) { const res = await fetch(`${API}/admin/packs/${packId}/governance`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("governanceAction failed"); return await res.json(); }
-export async function addReviewComment(token, packId, versionNumber, payload) { const res = await fetch(`${API}/admin/packs/${packId}/comments?version_number=${versionNumber}`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("addReviewComment 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 putLearnerState(token, learnerId, state) { const res = await fetch(`${API}/learners/${learnerId}/state`, { method: "PUT", headers: authHeaders(token), body: JSON.stringify(state) }); if (!res.ok) throw new Error("putLearnerState failed"); return await res.json(); }
+export async function fetchGraphAnimation(token, learnerId, packId) { const res = await fetch(`${API}/learners/${learnerId}/graph-animation/${packId}`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchGraphAnimation failed"); return await res.json(); }
diff --git a/webui/src/styles.css b/webui/src/styles.css
index c284650..10bbea0 100644
--- a/webui/src/styles.css
+++ b/webui/src/styles.css
@@ -1,30 +1,28 @@
:root {
- --bg:#f6f8fb; --card:#ffffff; --text:#1f2430; --muted:#60697a; --border:#dbe1ea; --accent:#2d6cdf; --soft:#eef4ff;
+ --bg:#f6f8fb; --card:#ffffff; --text:#1f2430; --muted:#60697a; --border:#dbe1ea; --accent:#2d6cdf;
}
* { box-sizing:border-box; }
body { margin:0; font-family:Arial, Helvetica, sans-serif; background:var(--bg); color:var(--text); }
.page { max-width:1500px; margin:0 auto; padding:24px; }
.narrow-page { max-width:520px; }
-.hero { background:var(--card); border:1px solid var(--border); border-radius:22px; padding:24px; display:flex; justify-content:space-between; gap:16px; }
-.hero-controls { min-width:280px; display:flex; flex-direction:column; gap:12px; }
+.hero { background:var(--card); border:1px solid var(--border); border-radius:22px; padding:24px; display:flex; justify-content:space-between; gap:16px; align-items:flex-start; }
+.controls { display:flex; gap:10px; align-items:flex-end; flex-wrap:wrap; }
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 { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; }
+button { border:1px solid var(--border); background:white; border-radius:12px; padding:10px 14px; cursor:pointer; }
.card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; }
.narrow { margin-top:60px; }
-.tab-row { display:flex; gap:10px; margin:16px 0; flex-wrap:wrap; }
-.tab-row button, button { border:1px solid var(--border); background:white; border-radius:12px; padding:10px 14px; cursor:pointer; }
-.active-tab, .primary { background:var(--accent); color:white; border-color:var(--accent); }
.layout { display:grid; gap:16px; }
.twocol { grid-template-columns:1fr 1fr; }
-.onecol { grid-template-columns:1fr; }
+.prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:460px; }
.muted { color:var(--muted); }
.error { color:#b42318; margin-top:10px; }
-.message { margin-top:10px; padding:10px 12px; border:1px solid var(--border); background:#fff7dd; border-radius:12px; }
-.table { width:100%; border-collapse:collapse; }
-.table th, .table td { border-bottom:1px solid var(--border); text-align:left; padding:8px; vertical-align:top; }
-.prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:320px; }
-.button-row { display:flex; gap:10px; flex-wrap:wrap; margin-bottom:12px; }
+.frame-meta { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:16px; }
+.graph { width:100%; height:auto; border:1px solid var(--border); border-radius:16px; background:#fbfcfe; }
+.svg-label { font-size:12px; fill:#fff; font-weight:bold; }
+.svg-small { font-size:10px; fill:#fff; }
@media (max-width:1100px) {
.hero { flex-direction:column; }
.twocol { grid-template-columns:1fr; }
+ .frame-meta { grid-template-columns:1fr; }
}