Apply ZIP update: 200-didactopus-layout-aware-graph-engine-layer.zip [2026-03-14T13:20:30]

This commit is contained in:
welsberr 2026-03-14 13:29:56 -04:00
parent ebd754c6ba
commit adac1e1637
15 changed files with 284 additions and 849 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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'<line x1="{src["x"]}" y1="{src["y"]}" x2="{dst["x"]}" y2="{dst["y"]}" stroke="#b8c2cf" stroke-width="3"/>')
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'<line x1="{src["x"]}" y1="{src["y"]}" x2="{src["x"]+120}" y2="{src["y"]-60}" stroke="#cc4bc2" stroke-width="2" stroke-dasharray="8 6"/>')
for node in frame.get("nodes", []):
fill = color_for_status(node["status"])
parts.append(f'<circle cx="{node["x"]}" cy="{node["y"]}" r="{node["size"]}" fill="{fill}" />')
@ -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}")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Didactopus Dual-Lane Policy Layer</title>
<title>Didactopus Layout-Aware Graph</title>
<script type="module" src="/src/main.jsx"></script>
</head>
<body><div id="root"></div></body>

View File

@ -1,5 +1,5 @@
{
"name": "didactopus-dual-lane-ui",
"name": "didactopus-layout-aware-graph-ui",
"private": true,
"version": "0.1.0",
"type": "module",

View File

@ -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 (
<div className="tab-row">
<button className={tab==="personal" ? "active-tab" : ""} onClick={() => setTab("personal")}>Personal packs</button>
<button className={tab==="contribute" ? "active-tab" : ""} onClick={() => setTab("contribute")}>Community contribution</button>
{role === "admin" ? <>
<button className={tab==="submissions" ? "active-tab" : ""} onClick={() => setTab("submissions")}>Submissions</button>
<button className={tab==="review" ? "active-tab" : ""} onClick={() => setTab("review")}>Governance</button>
</> : null}
</div>
<svg viewBox="0 0 960 560" className="graph">
{frame.edges.map((edge, idx) => {
const s = frame.nodes.find((n) => n.id === edge.source);
const t = frame.nodes.find((n) => n.id === edge.target);
if (!s || !t) return null;
return <line key={idx} x1={s.x} y1={s.y} x2={t.x} y2={t.y} stroke="#b8c2cf" strokeWidth="3" markerEnd="url(#arrow)" />;
})}
{frame.cross_pack_links.map((edge, idx) => {
const s = frame.nodes.find((n) => n.id === edge.source);
if (!s) return null;
return <line key={`c${idx}`} x1={s.x} y1={s.y} x2={s.x + 120} y2={s.y - 60} stroke="#cc4bc2" strokeWidth="2" strokeDasharray="8 6" />;
})}
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,6 L9,3 z" fill="#b8c2cf" />
</marker>
</defs>
{frame.nodes.map((node) => (
<g key={node.id}>
<circle cx={node.x} cy={node.y} r={node.size} fill={nodeColor(node.status)} opacity="0.92" />
<text x={node.x} y={node.y - 4} textAnchor="middle" className="svg-label">{node.title}</text>
<text x={node.x} y={node.y + 14} textAnchor="middle" className="svg-small">{node.score.toFixed(2)} · {node.status}</text>
</g>
))}
</svg>
);
}
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 <LoginView onAuth={setAuth} />;
const frame = graphData?.frames?.[frameIndex];
return (
<div className="page">
<header className="hero">
<div>
<h1>Didactopus dual-lane policy layer</h1>
<p>Personal packs stay low-friction. Community packs keep gates, review, and approval workflows.</p>
<div className="muted">Signed in as {auth.username} ({auth.role})</div>
{message ? <div className="message">{message}</div> : null}
<h1>Didactopus layout-aware graph engine</h1>
<p>Stable node positions, cross-pack links, and export-ready graph frames.</p>
<div className="muted">{message}</div>
</div>
<div className="hero-controls">
{auth.role === "admin" ? (
<label>Pack<select value={selectedPackId} onChange={(e) => setSelectedPackId(e.target.value)}>{adminPacks.map((p) => <option key={p.id} value={p.id}>{p.title} [{p.policy_lane}]</option>)}</select></label>
) : null}
<div className="controls">
<label>Pack
<select value={packId} onChange={async (e) => { setPackId(e.target.value); await reload(e.target.value); }}>
{packs.map((p) => <option key={p.id} value={p.id}>{p.title}</option>)}
</select>
</label>
<button onClick={() => setPlaying((x) => !x)}>{playing ? "Pause" : "Play"}</button>
<button onClick={generateDemo}>Generate demo</button>
<button onClick={() => { clearAuth(); setAuth(null); }}>Logout</button>
</div>
</header>
<NavTabs tab={tab} setTab={setTab} role={auth.role} />
{tab === "personal" && (
<main className="layout onecol">
<section className="card">
<h2>Personal lane authoring</h2>
<p className="muted">This lane is intended not to hamper an individual building packs for private use.</p>
<label>Pack ID<input value={personalPack.id} onChange={(e) => setPersonalPack({ ...personalPack, id: e.target.value })} /></label>
<label>Title<input value={personalPack.title} onChange={(e) => setPersonalPack({ ...personalPack, title: e.target.value })} /></label>
<label>Subtitle<input value={personalPack.subtitle} onChange={(e) => setPersonalPack({ ...personalPack, subtitle: e.target.value })} /></label>
<button className="primary" onClick={savePersonalPack}>Save personal pack directly</button>
<pre className="prebox">{JSON.stringify(personalPack, null, 2)}</pre>
</section>
</main>
)}
{tab === "contribute" && (
<main className="layout onecol">
<section className="card">
<h2>Community contribution lane</h2>
<p className="muted">Use this lane for packs intended to enter shared review and publication workflows.</p>
<label>Pack ID<input value={contribPack.id} onChange={(e) => setContribPack({ ...contribPack, id: e.target.value })} /></label>
<label>Title<input value={contribPack.title} onChange={(e) => setContribPack({ ...contribPack, title: e.target.value })} /></label>
<label>Subtitle<input value={contribPack.subtitle} onChange={(e) => setContribPack({ ...contribPack, subtitle: e.target.value })} /></label>
<button className="primary" onClick={submitContribution}>Submit for community review</button>
<pre className="prebox">{JSON.stringify(contribPack, null, 2)}</pre>
</section>
</main>
)}
{tab === "submissions" && auth.role === "admin" && (
<main className="layout twocol">
<section className="card">
<h2>Submission queue</h2>
<table className="table">
<thead><tr><th>ID</th><th>Pack</th><th>Lane</th><th>Version</th><th>Status</th><th>Select</th></tr></thead>
<tbody>
{submissions.map((s) => (
<tr key={s.submission_id}>
<td>{s.submission_id}</td>
<td>{s.pack_id}</td>
<td>{s.policy_lane}</td>
<td>{s.proposed_version_number}</td>
<td>{s.status}</td>
<td><button onClick={() => setSelectedSubmissionId(s.submission_id)}>Inspect</button></td>
</tr>
))}
</tbody>
</table>
<h3>Review tasks</h3>
<pre className="prebox">{JSON.stringify(reviewTasks, null, 2)}</pre>
</section>
<section className="card">
<h2>Submission diff and gates</h2>
<h3>Diff summary</h3>
<pre className="prebox">{JSON.stringify(submissionDiff, null, 2)}</pre>
<h3>Gate summary</h3>
<pre className="prebox">{JSON.stringify(submissionGates, null, 2)}</pre>
</section>
</main>
)}
{tab === "review" && auth.role === "admin" && (
<main className="layout twocol">
<section className="card">
<h2>Governance and publishability</h2>
<div className="button-row">
<button onClick={() => doGovernance("in_review")}>Move to in_review</button>
<button onClick={() => doGovernance("approved")}>Approve</button>
<button onClick={() => doGovernance("rejected")}>Reject</button>
<button onClick={publishSelected}>Publish</button>
</div>
<label>Review summary<textarea value={reviewSummary} onChange={(e) => setReviewSummary(e.target.value)} /></label>
<h3>Publishability</h3>
<pre className="prebox">{JSON.stringify(publishability, null, 2)}</pre>
<h3>Validation</h3>
<pre className="prebox">{JSON.stringify(validation, null, 2)}</pre>
<h3>Provenance</h3>
<pre className="prebox">{JSON.stringify(provenance, null, 2)}</pre>
</section>
<section className="card">
<h2>Versions and comments</h2>
<h3>Versions</h3>
<pre className="prebox">{JSON.stringify(versions, null, 2)}</pre>
<label>Reviewer comment<textarea value={commentText} onChange={(e) => setCommentText(e.target.value)} /></label>
<button className="primary" onClick={addCommentNow}>Add comment</button>
<h3>Comments</h3>
<pre className="prebox">{JSON.stringify(comments, null, 2)}</pre>
</section>
</main>
)}
<main className="layout twocol">
<section className="card">
<h2>Graph animation</h2>
<div className="frame-meta">
<div><strong>Frame:</strong> {frameIndex + 1} / {graphData?.frames?.length || 0}</div>
<div><strong>Event:</strong> {frame?.event_kind || "-"}</div>
<div><strong>Focus:</strong> {frame?.focus_concept_id || "-"}</div>
<div><strong>Timestamp:</strong> {frame?.timestamp || "-"}</div>
</div>
<GraphView frame={frame} />
</section>
<section className="card">
<h2>Frame payload</h2>
<pre className="prebox">{JSON.stringify(graphData, null, 2)}</pre>
</section>
</main>
</div>
);
}

View File

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

View File

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