Apply ZIP update: 160-didactopus-artifact-registry-layer.zip [2026-03-14T13:19:33]
This commit is contained in:
parent
f94619c304
commit
59d97e3e61
|
|
@ -17,6 +17,8 @@ dependencies = [
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
didactopus-api = "didactopus.api:main"
|
didactopus-api = "didactopus.api:main"
|
||||||
|
didactopus-export-svg = "didactopus.export_svg:main"
|
||||||
|
didactopus-render-bundle = "didactopus.render_bundle:main"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
from __future__ import annotations
|
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
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from .db import Base, engine
|
from .db import Base, engine
|
||||||
from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState
|
from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, MediaRenderRequest
|
||||||
from .repository import authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token, list_packs_for_user, get_pack, get_pack_row, create_learner, learner_owned_by_user, load_learner_state, save_learner_state
|
from .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 .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)
|
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")
|
raise HTTPException(status_code=400, detail="Learner ID mismatch")
|
||||||
return save_learner_state(state).model_dump()
|
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}")
|
@app.get("/api/learners/{learner_id}/graph-animation/{pack_id}")
|
||||||
def api_graph_animation(learner_id: str, pack_id: str, user = Depends(current_user)):
|
def api_graph_animation(learner_id: str, pack_id: str, user = Depends(current_user)):
|
||||||
ensure_learner_access(user, learner_id)
|
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_id": pack_id,
|
||||||
"pack_title": pack.title if pack else "",
|
"pack_title": pack.title if pack else "",
|
||||||
"frames": frames,
|
"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():
|
def main():
|
||||||
uvicorn.run(app, host="127.0.0.1", port=8011)
|
uvicorn.run(app, host="127.0.0.1", port=8011)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,41 @@
|
||||||
from __future__ import annotations
|
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:
|
def concept_depths(pack: PackData) -> dict[str, int]:
|
||||||
for rec in state.records:
|
concept_map = {c.id: c for c in pack.concepts}
|
||||||
if rec.concept_id == concept_id and rec.dimension == dimension:
|
memo = {}
|
||||||
return rec
|
def depth(cid: str) -> int:
|
||||||
return None
|
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:
|
def prereqs_satisfied(scores: dict[str, float], concept, min_score: float = 0.65) -> bool:
|
||||||
for pid in concept.prerequisites:
|
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):
|
def build_graph_frames(state: LearnerState, pack: PackData):
|
||||||
concepts = {c.id: c for c in pack.concepts}
|
concepts = {c.id: c for c in pack.concepts}
|
||||||
|
layout = stable_layout(pack)
|
||||||
scores = {c.id: 0.0 for c in pack.concepts}
|
scores = {c.id: 0.0 for c in pack.concepts}
|
||||||
frames = []
|
frames = []
|
||||||
history = sorted(state.history, key=lambda x: x.timestamp)
|
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):
|
for idx, ev in enumerate(history):
|
||||||
if ev.concept_id in scores:
|
if ev.concept_id in scores:
|
||||||
scores[ev.concept_id] = ev.score
|
scores[ev.concept_id] = ev.score
|
||||||
|
|
@ -33,27 +72,39 @@ def build_graph_frames(state: LearnerState, pack: PackData):
|
||||||
for cid, concept in concepts.items():
|
for cid, concept in concepts.items():
|
||||||
score = scores.get(cid, 0.0)
|
score = scores.get(cid, 0.0)
|
||||||
status = concept_status(scores, concept)
|
status = concept_status(scores, concept)
|
||||||
|
pos = layout[cid]
|
||||||
nodes.append({
|
nodes.append({
|
||||||
"id": cid,
|
"id": cid,
|
||||||
"title": concept.title,
|
"title": concept.title,
|
||||||
"score": score,
|
"score": score,
|
||||||
"status": status,
|
"status": status,
|
||||||
"size": 20 + int(score * 30),
|
"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({
|
frames.append({
|
||||||
"index": idx,
|
"index": idx,
|
||||||
"timestamp": ev.timestamp,
|
"timestamp": ev.timestamp,
|
||||||
"event_kind": ev.kind,
|
"event_kind": ev.kind,
|
||||||
"focus_concept_id": ev.concept_id,
|
"focus_concept_id": ev.concept_id,
|
||||||
"nodes": nodes,
|
"nodes": nodes,
|
||||||
"edges": edges,
|
"edges": static_edges,
|
||||||
|
"cross_pack_links": static_cross,
|
||||||
})
|
})
|
||||||
if not frames:
|
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]
|
nodes = []
|
||||||
edges = [{"source": pre, "target": c.id} for c in pack.concepts for pre in c.prerequisites]
|
for c in pack.concepts:
|
||||||
frames.append({"index": 0, "timestamp": "", "event_kind": "empty", "focus_concept_id": "", "nodes": nodes, "edges": edges})
|
pos = layout[c.id]
|
||||||
|
nodes.append({
|
||||||
|
"id": c.id,
|
||||||
|
"title": c.title,
|
||||||
|
"score": 0.0,
|
||||||
|
"status": "available" if not c.prerequisites else "locked",
|
||||||
|
"size": 20,
|
||||||
|
"x": pos["x"],
|
||||||
|
"y": pos["y"],
|
||||||
|
"layout_source": pos["source"],
|
||||||
|
})
|
||||||
|
frames.append({"index": 0, "timestamp": "", "event_kind": "empty", "focus_concept_id": "", "nodes": nodes, "edges": static_edges, "cross_pack_links": static_cross})
|
||||||
return frames
|
return frames
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,24 @@ class LoginRequest(BaseModel):
|
||||||
class RefreshRequest(BaseModel):
|
class RefreshRequest(BaseModel):
|
||||||
refresh_token: str
|
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):
|
class PackConcept(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
title: str
|
title: str
|
||||||
prerequisites: list[str] = Field(default_factory=list)
|
prerequisites: list[str] = Field(default_factory=list)
|
||||||
masteryDimension: str = "mastery"
|
masteryDimension: str = "mastery"
|
||||||
exerciseReward: str = ""
|
exerciseReward: str = ""
|
||||||
|
position: GraphPosition | None = None
|
||||||
|
cross_pack_links: list[CrossPackLink] = Field(default_factory=list)
|
||||||
|
|
||||||
class PackData(BaseModel):
|
class PackData(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
|
|
@ -56,3 +68,10 @@ class LearnerState(BaseModel):
|
||||||
learner_id: str
|
learner_id: str
|
||||||
records: list[MasteryRecord] = Field(default_factory=list)
|
records: list[MasteryRecord] = Field(default_factory=list)
|
||||||
history: list[EvidenceEvent] = 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"
|
||||||
|
|
|
||||||
|
|
@ -56,3 +56,30 @@ class EvidenceEventORM(Base):
|
||||||
timestamp: Mapped[str] = mapped_column(String(100), default="")
|
timestamp: Mapped[str] = mapped_column(String(100), default="")
|
||||||
kind: Mapped[str] = mapped_column(String(50), default="exercise")
|
kind: Mapped[str] = mapped_column(String(50), default="exercise")
|
||||||
source_id: Mapped[str] = mapped_column(String(255), default="")
|
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="{}")
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from .db import Base, engine, SessionLocal
|
||||||
from .orm import UserORM
|
from .orm import UserORM
|
||||||
from .auth import hash_password
|
from .auth import hash_password
|
||||||
from .repository import upsert_pack, create_learner
|
from .repository import upsert_pack, create_learner
|
||||||
from .models import PackData, PackConcept
|
from .models import PackData, PackConcept, GraphPosition, CrossPackLink
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
@ -20,9 +20,10 @@ def main():
|
||||||
subtitle="Personal pack example.",
|
subtitle="Personal pack example.",
|
||||||
level="novice-friendly",
|
level="novice-friendly",
|
||||||
concepts=[
|
concepts=[
|
||||||
PackConcept(id="intro", title="Intro", prerequisites=[]),
|
PackConcept(id="intro", title="Intro", prerequisites=[], position=GraphPosition(x=150, y=120)),
|
||||||
PackConcept(id="second", title="Second concept", prerequisites=["intro"]),
|
PackConcept(id="second", title="Second concept", prerequisites=["intro"], position=GraphPosition(x=420, y=120)),
|
||||||
PackConcept(id="third", title="Third concept", prerequisites=["second"]),
|
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"},
|
onboarding={"headline":"Start privately"},
|
||||||
compliance={}
|
compliance={}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,37 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from .repository import get_evaluator_job, update_evaluator_job, load_learner_state, save_learner_state
|
import json, tempfile
|
||||||
from .engine import apply_evidence
|
from pathlib import Path
|
||||||
from .models import EvidenceEvent
|
from .repository import update_render_job, register_artifact
|
||||||
import time
|
from .render_bundle import make_render_bundle
|
||||||
|
|
||||||
def process_job(job_id: int):
|
def process_render_job(job_id: int, learner_id: str, pack_id: str, fmt: str, fps: int, theme: str, animation_payload: dict):
|
||||||
job = get_evaluator_job(job_id)
|
update_render_job(job_id, status="running")
|
||||||
if job is None:
|
try:
|
||||||
return
|
base = Path(tempfile.mkdtemp(prefix=f"didactopus_job_{job_id}_"))
|
||||||
score = 0.78 if len(job.submitted_text.strip()) > 20 else 0.62
|
payload_json = base / "animation_payload.json"
|
||||||
confidence_hint = 0.72 if len(job.submitted_text.strip()) > 20 else 0.45
|
payload_json.write_text(json.dumps(animation_payload, indent=2), encoding="utf-8")
|
||||||
notes = "Prototype evaluator: longer responses scored somewhat higher."
|
out_dir = base / "bundle"
|
||||||
update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes, trace={"notes": ["completed"]})
|
make_render_bundle(str(payload_json), str(out_dir), fps=fps, fmt=fmt)
|
||||||
state = load_learner_state(job.learner_id)
|
manifest_path = out_dir / "render_manifest.json"
|
||||||
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}"))
|
script_path = out_dir / "render.sh"
|
||||||
save_learner_state(state)
|
update_render_job(
|
||||||
|
job_id,
|
||||||
def main():
|
status="completed",
|
||||||
while True:
|
bundle_dir=str(out_dir),
|
||||||
time.sleep(60)
|
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))
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,6 @@ from pathlib import Path
|
||||||
def test_scaffold_files_exist():
|
def test_scaffold_files_exist():
|
||||||
assert Path("src/didactopus/api.py").exists()
|
assert Path("src/didactopus/api.py").exists()
|
||||||
assert Path("src/didactopus/repository.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/App.jsx").exists()
|
||||||
assert Path("webui/src/api.js").exists()
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Didactopus Animated Concept Graph</title>
|
<title>Didactopus Artifact Registry</title>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
</head>
|
</head>
|
||||||
<body><div id="root"></div></body>
|
<body><div id="root"></div></body>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "didactopus-animated-concept-graph-ui",
|
"name": "didactopus-artifact-registry-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { login, refresh, fetchPacks, fetchLearnerState, putLearnerState, fetchGraphAnimation } from "./api";
|
import { login, refresh, fetchPacks, fetchLearnerState, putLearnerState, createRenderJob, listRenderJobs, listArtifacts } from "./api";
|
||||||
import { loadAuth, saveAuth, clearAuth } from "./authStore";
|
import { loadAuth, saveAuth, clearAuth } from "./authStore";
|
||||||
|
|
||||||
function LoginView({ onAuth }) {
|
function LoginView({ onAuth }) {
|
||||||
|
|
@ -26,56 +26,15 @@ function LoginView({ onAuth }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
const width = 760;
|
|
||||||
const height = 420;
|
|
||||||
const positions = {};
|
|
||||||
frame.nodes.forEach((node, idx) => {
|
|
||||||
positions[node.id] = { x: 120 + idx * 220, y: 120 + (idx % 2) * 150 };
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<svg viewBox={`0 0 ${width} ${height}`} className="graph">
|
|
||||||
{frame.edges.map((edge, idx) => {
|
|
||||||
const s = positions[edge.source];
|
|
||||||
const t = positions[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)" />;
|
|
||||||
})}
|
|
||||||
<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) => {
|
|
||||||
const p = positions[node.id];
|
|
||||||
return (
|
|
||||||
<g key={node.id}>
|
|
||||||
<circle cx={p.x} cy={p.y} r={node.size} fill={nodeColor(node.status)} opacity="0.9" />
|
|
||||||
<text x={p.x} y={p.y - 4} textAnchor="middle" className="svg-label">{node.title}</text>
|
|
||||||
<text x={p.x} y={p.y + 14} textAnchor="middle" className="svg-small">{node.score.toFixed(2)} · {node.status}</text>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [auth, setAuth] = useState(loadAuth());
|
const [auth, setAuth] = useState(loadAuth());
|
||||||
const [packs, setPacks] = useState([]);
|
const [packs, setPacks] = useState([]);
|
||||||
const [learnerId] = useState("wesley-learner");
|
const [learnerId] = useState("wesley-learner");
|
||||||
const [packId, setPackId] = useState("");
|
const [packId, setPackId] = useState("");
|
||||||
const [graphData, setGraphData] = useState(null);
|
const [jobs, setJobs] = useState([]);
|
||||||
const [frameIndex, setFrameIndex] = useState(0);
|
const [artifacts, setArtifacts] = useState([]);
|
||||||
const [playing, setPlaying] = useState(false);
|
const [format, setFormat] = useState("gif");
|
||||||
|
const [fps, setFps] = useState(2);
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
|
|
||||||
async function refreshAuthToken() {
|
async function refreshAuthToken() {
|
||||||
|
|
@ -101,10 +60,9 @@ export default function App() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reload(pid) {
|
async function reloadLists() {
|
||||||
const data = await guarded((token) => fetchGraphAnimation(token, learnerId, pid));
|
setJobs(await guarded((token) => listRenderJobs(token, learnerId)));
|
||||||
setGraphData(data);
|
setArtifacts(await guarded((token) => listArtifacts(token, learnerId)));
|
||||||
setFrameIndex(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -112,53 +70,38 @@ export default function App() {
|
||||||
async function load() {
|
async function load() {
|
||||||
const p = await guarded((token) => fetchPacks(token));
|
const p = await guarded((token) => fetchPacks(token));
|
||||||
setPacks(p);
|
setPacks(p);
|
||||||
const pid = p[0]?.id || "";
|
setPackId(p[0]?.id || "");
|
||||||
setPackId(pid);
|
await reloadLists();
|
||||||
if (pid) await reload(pid);
|
|
||||||
}
|
}
|
||||||
load();
|
load();
|
||||||
}, [auth]);
|
}, [auth]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const frame = graphData?.frames?.[frameIndex];
|
|
||||||
|
|
||||||
async function generateDemo() {
|
async function generateDemo() {
|
||||||
let state = await guarded((token) => fetchLearnerState(token, learnerId));
|
let state = await guarded((token) => fetchLearnerState(token, learnerId));
|
||||||
const now1 = new Date().toISOString();
|
const base = Date.now()
|
||||||
state.history.push({ concept_id: "intro", dimension: "mastery", score: 0.30, confidence_hint: 0.5, timestamp: now1, kind: "exercise", source_id: "demo-1" });
|
const events = [
|
||||||
state.records = [{ concept_id: "intro", dimension: "mastery", score: 0.30, confidence: 0.30, evidence_count: 1, last_updated: now1 }];
|
["intro", 0.30, "exercise", 0],
|
||||||
await guarded((token) => putLearnerState(token, learnerId, state));
|
["intro", 0.78, "review", 1000],
|
||||||
|
["second", 0.42, "exercise", 2000],
|
||||||
const now2 = new Date(Date.now() + 1000).toISOString();
|
["second", 0.72, "review", 3000],
|
||||||
state.history.push({ concept_id: "intro", dimension: "mastery", score: 0.78, confidence_hint: 0.7, timestamp: now2, kind: "review", source_id: "demo-2" });
|
["third", 0.25, "exercise", 4000],
|
||||||
state.records = [{ concept_id: "intro", dimension: "mastery", score: 0.78, confidence: 0.70, evidence_count: 2, last_updated: now2 }];
|
["branch", 0.60, "exercise", 5000],
|
||||||
await guarded((token) => putLearnerState(token, learnerId, state));
|
|
||||||
|
|
||||||
const now3 = new Date(Date.now() + 2000).toISOString();
|
|
||||||
state.history.push({ concept_id: "second", dimension: "mastery", score: 0.42, confidence_hint: 0.5, timestamp: now3, kind: "exercise", source_id: "demo-3" });
|
|
||||||
state.records = [
|
|
||||||
{ concept_id: "intro", dimension: "mastery", score: 0.78, confidence: 0.70, evidence_count: 2, last_updated: now2 },
|
|
||||||
{ concept_id: "second", dimension: "mastery", score: 0.42, confidence: 0.40, evidence_count: 1, last_updated: now3 }
|
|
||||||
];
|
];
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
state.records = Object.values(latest);
|
||||||
await guarded((token) => putLearnerState(token, learnerId, state));
|
await guarded((token) => putLearnerState(token, learnerId, state));
|
||||||
|
setMessage("Demo state generated.");
|
||||||
|
}
|
||||||
|
|
||||||
const now4 = new Date(Date.now() + 3000).toISOString();
|
async function createJob() {
|
||||||
state.history.push({ concept_id: "second", dimension: "mastery", score: 0.72, confidence_hint: 0.7, timestamp: now4, kind: "review", source_id: "demo-4" });
|
const result = await guarded((token) => createRenderJob(token, learnerId, packId, { learner_id: learnerId, pack_id: packId, format, fps, theme: "default" }));
|
||||||
state.records = [
|
setMessage(`Render job ${result.job_id} queued.`);
|
||||||
{ concept_id: "intro", dimension: "mastery", score: 0.78, confidence: 0.70, evidence_count: 2, last_updated: now2 },
|
setTimeout(() => reloadLists(), 500);
|
||||||
{ concept_id: "second", dimension: "mastery", score: 0.72, confidence: 0.65, evidence_count: 2, last_updated: now4 }
|
|
||||||
];
|
|
||||||
await guarded((token) => putLearnerState(token, learnerId, state));
|
|
||||||
|
|
||||||
await reload(packId);
|
|
||||||
setMessage("Demo graph animation frames generated.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!auth) return <LoginView onAuth={setAuth} />;
|
if (!auth) return <LoginView onAuth={setAuth} />;
|
||||||
|
|
@ -167,36 +110,40 @@ export default function App() {
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<header className="hero">
|
<header className="hero">
|
||||||
<div>
|
<div>
|
||||||
<h1>Didactopus animated concept graph</h1>
|
<h1>Didactopus artifact registry</h1>
|
||||||
<p>Replay node-level mastery, unlocks, and prerequisite structure over time.</p>
|
<p>Track render jobs and produced media artifacts as first-class Didactopus objects.</p>
|
||||||
<div className="muted">{message}</div>
|
<div className="muted">{message}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="controls">
|
<div className="controls">
|
||||||
<label>Pack
|
<label>Pack
|
||||||
<select value={packId} onChange={async (e) => { setPackId(e.target.value); await reload(e.target.value); }}>
|
<select value={packId} onChange={(e) => setPackId(e.target.value)}>
|
||||||
{packs.map((p) => <option key={p.id} value={p.id}>{p.title}</option>)}
|
{packs.map((p) => <option key={p.id} value={p.id}>{p.title}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button onClick={() => setPlaying((x) => !x)}>{playing ? "Pause" : "Play"}</button>
|
<label>Format
|
||||||
<button onClick={generateDemo}>Generate demo</button>
|
<select value={format} onChange={(e) => setFormat(e.target.value)}>
|
||||||
|
<option value="gif">GIF</option>
|
||||||
|
<option value="mp4">MP4</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>FPS
|
||||||
|
<input type="number" value={fps} onChange={(e) => setFps(Number(e.target.value || 2))} />
|
||||||
|
</label>
|
||||||
|
<button onClick={generateDemo}>Generate demo state</button>
|
||||||
|
<button onClick={createJob}>Create render job</button>
|
||||||
|
<button onClick={reloadLists}>Refresh lists</button>
|
||||||
<button onClick={() => { clearAuth(); setAuth(null); }}>Logout</button>
|
<button onClick={() => { clearAuth(); setAuth(null); }}>Logout</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="layout twocol">
|
<main className="layout twocol">
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2>Graph animation</h2>
|
<h2>Render jobs</h2>
|
||||||
<div className="frame-meta">
|
<pre className="prebox">{JSON.stringify(jobs, null, 2)}</pre>
|
||||||
<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>
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2>Graph payload</h2>
|
<h2>Artifacts</h2>
|
||||||
<pre className="prebox">{JSON.stringify(graphData, null, 2)}</pre>
|
<pre className="prebox">{JSON.stringify(artifacts, null, 2)}</pre>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -19,4 +19,6 @@ export async function refresh(refreshToken) {
|
||||||
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 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 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 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 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(); }
|
export async function createRenderJob(token, learnerId, packId, payload) { const res = await fetch(`${API}/learners/${learnerId}/render-jobs/${packId}`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("createRenderJob failed"); return await res.json(); }
|
||||||
|
export async function listRenderJobs(token, learnerId) { const res = await fetch(`${API}/render-jobs?learner_id=${encodeURIComponent(learnerId)}`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listRenderJobs failed"); return await res.json(); }
|
||||||
|
export async function listArtifacts(token, learnerId) { const res = await fetch(`${API}/artifacts?learner_id=${encodeURIComponent(learnerId)}`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listArtifacts failed"); return await res.json(); }
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ body { margin:0; font-family:Arial, Helvetica, sans-serif; background:var(--bg);
|
||||||
label { display:block; font-weight:600; }
|
label { display:block; font-weight:600; }
|
||||||
input, select { 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; }
|
button { border:1px solid var(--border); background:white; border-radius:12px; padding:10px 14px; cursor:pointer; }
|
||||||
.primary { background:var(--accent); color:white; border-color:var(--accent); }
|
|
||||||
.card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; }
|
.card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; }
|
||||||
.narrow { margin-top:60px; }
|
.narrow { margin-top:60px; }
|
||||||
.layout { display:grid; gap:16px; }
|
.layout { display:grid; gap:16px; }
|
||||||
|
|
@ -18,12 +17,7 @@ button { border:1px solid var(--border); background:white; border-radius:12px; p
|
||||||
.prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:460px; }
|
.prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:460px; }
|
||||||
.muted { color:var(--muted); }
|
.muted { color:var(--muted); }
|
||||||
.error { color:#b42318; margin-top:10px; }
|
.error { color:#b42318; margin-top:10px; }
|
||||||
.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) {
|
@media (max-width:1100px) {
|
||||||
.hero { flex-direction:column; }
|
.hero { flex-direction:column; }
|
||||||
.twocol { grid-template-columns:1fr; }
|
.twocol { grid-template-columns:1fr; }
|
||||||
.frame-meta { grid-template-columns:1fr; }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue