Graph animation
-Render jobs
+{JSON.stringify(jobs, null, 2)}
Graph payload
-{JSON.stringify(graphData, null, 2)}
+ Artifacts
+{JSON.stringify(artifacts, null, 2)}
diff --git a/pyproject.toml b/pyproject.toml index 53c7bda..b6b46ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,8 @@ dependencies = [ [project.scripts] didactopus-api = "didactopus.api:main" +didactopus-export-svg = "didactopus.export_svg:main" +didactopus-render-bundle = "didactopus.render_bundle:main" [tool.setuptools.packages.find] where = ["src"] diff --git a/src/didactopus/api.py b/src/didactopus/api.py index 38663d9..9ee62c4 100644 --- a/src/didactopus/api.py +++ b/src/didactopus/api.py @@ -1,12 +1,17 @@ from __future__ import annotations -from fastapi import FastAPI, HTTPException, Header, Depends +from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks from fastapi.middleware.cors import CORSMiddleware import uvicorn from .db import Base, engine -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 .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, MediaRenderRequest +from .repository import ( + authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token, + list_packs_for_user, get_pack, get_pack_row, create_learner, learner_owned_by_user, load_learner_state, save_learner_state, + create_render_job, update_render_job, list_render_jobs, list_artifacts +) from .auth import issue_access_token, issue_refresh_token, decode_token, new_token_id -from .engine import build_graph_frames +from .engine import build_graph_frames, stable_layout +from .worker import process_render_job Base.metadata.create_all(bind=engine) @@ -87,6 +92,12 @@ def api_put_learner_state(learner_id: str, state: LearnerState, user = Depends(c raise HTTPException(status_code=400, detail="Learner ID mismatch") return save_learner_state(state).model_dump() +@app.get("/api/packs/{pack_id}/layout") +def api_pack_layout(pack_id: str, user = Depends(current_user)): + ensure_pack_access(user, pack_id) + pack = get_pack(pack_id) + return {"pack_id": pack_id, "layout": stable_layout(pack)} if pack else {"pack_id": pack_id, "layout": {}} + @app.get("/api/learners/{learner_id}/graph-animation/{pack_id}") def api_graph_animation(learner_id: str, pack_id: str, user = Depends(current_user)): ensure_learner_access(user, learner_id) @@ -99,8 +110,36 @@ def api_graph_animation(learner_id: str, pack_id: str, user = Depends(current_us "pack_id": pack_id, "pack_title": pack.title if pack else "", "frames": frames, - "concepts": [{"id": c.id, "title": c.title, "prerequisites": c.prerequisites} for c in pack.concepts] if pack else [], + "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 [], } +@app.post("/api/learners/{learner_id}/render-jobs/{pack_id}") +def api_render_job(learner_id: str, pack_id: str, payload: MediaRenderRequest, background_tasks: BackgroundTasks, user = Depends(current_user)): + ensure_learner_access(user, learner_id) + ensure_pack_access(user, pack_id) + pack = get_pack(pack_id) + state = load_learner_state(learner_id) + animation = { + "learner_id": learner_id, + "pack_id": pack_id, + "pack_title": pack.title if pack else "", + "frames": build_graph_frames(state, pack), + } + job_id = create_render_job(learner_id, pack_id, payload.format, payload.fps, payload.theme) + background_tasks.add_task(process_render_job, job_id, learner_id, pack_id, payload.format, payload.fps, payload.theme, animation) + return {"job_id": job_id, "status": "queued"} + +@app.get("/api/render-jobs") +def api_list_render_jobs(learner_id: str | None = None, user = Depends(current_user)): + if learner_id: + ensure_learner_access(user, learner_id) + return list_render_jobs(learner_id) + +@app.get("/api/artifacts") +def api_list_artifacts(learner_id: str | None = None, user = Depends(current_user)): + if learner_id: + ensure_learner_access(user, learner_id) + return list_artifacts(learner_id) + def main(): uvicorn.run(app, host="127.0.0.1", port=8011) diff --git a/src/didactopus/engine.py b/src/didactopus/engine.py index f9032e0..6b7c236 100644 --- a/src/didactopus/engine.py +++ b/src/didactopus/engine.py @@ -1,11 +1,41 @@ 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 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(scores: dict[str, float], concept, min_score: float = 0.65) -> bool: for pid in concept.prerequisites: @@ -23,9 +53,18 @@ def concept_status(scores: dict[str, float], concept, min_score: float = 0.65) - 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 @@ -33,27 +72,39 @@ def build_graph_frames(state: LearnerState, pack: PackData): 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"], }) - edges = [] - for cid, concept in concepts.items(): - for pre in concept.prerequisites: - edges.append({"source": pre, "target": cid}) frames.append({ "index": idx, "timestamp": ev.timestamp, "event_kind": ev.kind, "focus_concept_id": ev.concept_id, "nodes": nodes, - "edges": edges, + "edges": static_edges, + "cross_pack_links": static_cross, }) if not frames: - nodes = [{"id": c.id, "title": c.title, "score": 0.0, "status": "available" if not c.prerequisites else "locked", "size": 20} for c in pack.concepts] - edges = [{"source": pre, "target": c.id} for c in pack.concepts for pre in c.prerequisites] - frames.append({"index": 0, "timestamp": "", "event_kind": "empty", "focus_concept_id": "", "nodes": nodes, "edges": edges}) + 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/models.py b/src/didactopus/models.py index b1db4f5..fb9adf3 100644 --- a/src/didactopus/models.py +++ b/src/didactopus/models.py @@ -15,12 +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 = "" + position: GraphPosition | None = None + cross_pack_links: list[CrossPackLink] = Field(default_factory=list) class PackData(BaseModel): id: str @@ -56,3 +68,10 @@ class LearnerState(BaseModel): learner_id: str records: list[MasteryRecord] = Field(default_factory=list) history: list[EvidenceEvent] = Field(default_factory=list) + +class MediaRenderRequest(BaseModel): + learner_id: str + pack_id: str + format: str = "gif" + fps: int = 2 + theme: str = "default" diff --git a/src/didactopus/orm.py b/src/didactopus/orm.py index 49c1d57..3a0d355 100644 --- a/src/didactopus/orm.py +++ b/src/didactopus/orm.py @@ -56,3 +56,30 @@ 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 RenderJobORM(Base): + __tablename__ = "render_jobs" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + learner_id: Mapped[str] = mapped_column(String(100), index=True) + pack_id: Mapped[str] = mapped_column(String(100), index=True) + requested_format: Mapped[str] = mapped_column(String(20), default="gif") + fps: Mapped[int] = mapped_column(Integer, default=2) + theme: Mapped[str] = mapped_column(String(100), default="default") + status: Mapped[str] = mapped_column(String(50), default="queued") + bundle_dir: Mapped[str] = mapped_column(Text, default="") + payload_json: Mapped[str] = mapped_column(Text, default="") + manifest_path: Mapped[str] = mapped_column(Text, default="") + script_path: Mapped[str] = mapped_column(Text, default="") + error_text: Mapped[str] = mapped_column(Text, default="") + +class ArtifactORM(Base): + __tablename__ = "artifacts" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + render_job_id: Mapped[int] = mapped_column(ForeignKey("render_jobs.id"), index=True) + learner_id: Mapped[str] = mapped_column(String(100), index=True) + pack_id: Mapped[str] = mapped_column(String(100), index=True) + artifact_type: Mapped[str] = mapped_column(String(50), default="render_bundle") + format: Mapped[str] = mapped_column(String(20), default="gif") + title: Mapped[str] = mapped_column(String(255), default="") + path: Mapped[str] = mapped_column(Text, default="") + metadata_json: Mapped[str] = mapped_column(Text, default="{}") diff --git a/src/didactopus/seed.py b/src/didactopus/seed.py index a3723f5..bdc7b86 100644 --- a/src/didactopus/seed.py +++ b/src/didactopus/seed.py @@ -4,7 +4,7 @@ from .db import Base, engine, SessionLocal from .orm import UserORM from .auth import hash_password from .repository import upsert_pack, create_learner -from .models import PackData, PackConcept +from .models import PackData, PackConcept, GraphPosition, CrossPackLink def main(): Base.metadata.create_all(bind=engine) @@ -20,9 +20,10 @@ def main(): subtitle="Personal pack example.", level="novice-friendly", concepts=[ - PackConcept(id="intro", title="Intro", prerequisites=[]), - PackConcept(id="second", title="Second concept", prerequisites=["intro"]), - PackConcept(id="third", title="Third concept", prerequisites=["second"]), + PackConcept(id="intro", title="Intro", prerequisites=[], position=GraphPosition(x=150, y=120)), + PackConcept(id="second", title="Second concept", prerequisites=["intro"], position=GraphPosition(x=420, y=120)), + PackConcept(id="third", title="Third concept", prerequisites=["second"], position=GraphPosition(x=700, y=120), cross_pack_links=[CrossPackLink(source_concept_id="third", target_pack_id="advanced-pack", target_concept_id="adv-1", relationship="next_pack")]), + PackConcept(id="branch", title="Branch concept", prerequisites=["intro"], position=GraphPosition(x=420, y=320)), ], onboarding={"headline":"Start privately"}, compliance={} diff --git a/src/didactopus/worker.py b/src/didactopus/worker.py index ad99705..28b6809 100644 --- a/src/didactopus/worker.py +++ b/src/didactopus/worker.py @@ -1,21 +1,37 @@ from __future__ import annotations -from .repository import get_evaluator_job, update_evaluator_job, load_learner_state, save_learner_state -from .engine import apply_evidence -from .models import EvidenceEvent -import time +import json, tempfile +from pathlib import Path +from .repository import update_render_job, register_artifact +from .render_bundle import make_render_bundle -def process_job(job_id: int): - job = get_evaluator_job(job_id) - if job is None: - return - score = 0.78 if len(job.submitted_text.strip()) > 20 else 0.62 - confidence_hint = 0.72 if len(job.submitted_text.strip()) > 20 else 0.45 - notes = "Prototype evaluator: longer responses scored somewhat higher." - update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes, trace={"notes": ["completed"]}) - state = load_learner_state(job.learner_id) - state = apply_evidence(state, EvidenceEvent(concept_id=job.concept_id, dimension="mastery", score=score, confidence_hint=confidence_hint, timestamp="2026-03-13T12:00:00+00:00", kind="review", source_id=f"evaluator-job-{job_id}")) - save_learner_state(state) - -def main(): - while True: - time.sleep(60) +def process_render_job(job_id: int, learner_id: str, pack_id: str, fmt: str, fps: int, theme: str, animation_payload: dict): + update_render_job(job_id, status="running") + try: + base = Path(tempfile.mkdtemp(prefix=f"didactopus_job_{job_id}_")) + payload_json = base / "animation_payload.json" + payload_json.write_text(json.dumps(animation_payload, indent=2), encoding="utf-8") + out_dir = base / "bundle" + make_render_bundle(str(payload_json), str(out_dir), fps=fps, fmt=fmt) + manifest_path = out_dir / "render_manifest.json" + script_path = out_dir / "render.sh" + update_render_job( + job_id, + status="completed", + bundle_dir=str(out_dir), + payload_json=str(payload_json), + manifest_path=str(manifest_path), + script_path=str(script_path), + error_text="", + ) + register_artifact( + render_job_id=job_id, + learner_id=learner_id, + pack_id=pack_id, + artifact_type="render_bundle", + fmt=fmt, + title=f"{pack_id} animation bundle", + path=str(out_dir), + metadata={"fps": fps, "theme": theme, "manifest_path": str(manifest_path), "script_path": str(script_path)}, + ) + except Exception as e: + update_render_job(job_id, status="failed", error_text=str(e)) diff --git a/tests/test_scaffold_files.py b/tests/test_scaffold_files.py index 2e9b522..6b26544 100644 --- a/tests/test_scaffold_files.py +++ b/tests/test_scaffold_files.py @@ -3,5 +3,6 @@ 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/worker.py").exists() + assert Path("src/didactopus/render_bundle.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 0214f55..fec3a06 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3,7 +3,7 @@
-Replay node-level mastery, unlocks, and prerequisite structure over time.
+Track render jobs and produced media artifacts as first-class Didactopus objects.
{JSON.stringify(jobs, null, 2)}
{JSON.stringify(graphData, null, 2)}
+ {JSON.stringify(artifacts, null, 2)}