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} -
+ + {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 ; + })} + {frame.cross_pack_links.map((edge, idx) => { + const s = frame.nodes.find((n) => n.id === edge.source); + if (!s) return null; + return ; + })} + + + + + + {frame.nodes.map((node) => ( + + + {node.title} + {node.score.toFixed(2)} ยท {node.status} + + ))} + ); } 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" && ( -
-
-

Personal lane authoring

-

This lane is intended not to hamper an individual building packs for private use.

- - - - -
{JSON.stringify(personalPack, null, 2)}
-
-
- )} - - {tab === "contribute" && ( -
-
-

Community contribution lane

-

Use this lane for packs intended to enter shared review and publication workflows.

- - - - -
{JSON.stringify(contribPack, null, 2)}
-
-
- )} - - {tab === "submissions" && auth.role === "admin" && ( -
-
-

Submission queue

- - - - {submissions.map((s) => ( - - - - - - - - - ))} - -
IDPackLaneVersionStatusSelect
{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

-
- - - - -
-