Apply ZIP update: 160-didactopus-artifact-registry-layer.zip [2026-03-14T13:19:33]

This commit is contained in:
welsberr 2026-03-14 13:29:55 -04:00
parent f94619c304
commit 59d97e3e61
13 changed files with 255 additions and 156 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<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>
</head>
<body><div id="root"></div></body>

View File

@ -1,5 +1,5 @@
{
"name": "didactopus-animated-concept-graph-ui",
"name": "didactopus-artifact-registry-ui",
"private": true,
"version": "0.1.0",
"type": "module",

View File

@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from "react";
import { login, refresh, fetchPacks, fetchLearnerState, putLearnerState, fetchGraphAnimation } from "./api";
import React, { useEffect, useState } from "react";
import { login, refresh, fetchPacks, fetchLearnerState, putLearnerState, createRenderJob, listRenderJobs, listArtifacts } from "./api";
import { loadAuth, saveAuth, clearAuth } from "./authStore";
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() {
const [auth, setAuth] = useState(loadAuth());
const [packs, setPacks] = useState([]);
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 [jobs, setJobs] = useState([]);
const [artifacts, setArtifacts] = useState([]);
const [format, setFormat] = useState("gif");
const [fps, setFps] = useState(2);
const [message, setMessage] = useState("");
async function refreshAuthToken() {
@ -101,10 +60,9 @@ export default function App() {
}
}
async function reload(pid) {
const data = await guarded((token) => fetchGraphAnimation(token, learnerId, pid));
setGraphData(data);
setFrameIndex(0);
async function reloadLists() {
setJobs(await guarded((token) => listRenderJobs(token, learnerId)));
setArtifacts(await guarded((token) => listArtifacts(token, learnerId)));
}
useEffect(() => {
@ -112,53 +70,38 @@ export default function App() {
async function load() {
const p = await guarded((token) => fetchPacks(token));
setPacks(p);
const pid = p[0]?.id || "";
setPackId(pid);
if (pid) await reload(pid);
setPackId(p[0]?.id || "");
await reloadLists();
}
load();
}, [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() {
let state = await guarded((token) => fetchLearnerState(token, learnerId));
const now1 = new Date().toISOString();
state.history.push({ concept_id: "intro", dimension: "mastery", score: 0.30, confidence_hint: 0.5, timestamp: now1, kind: "exercise", source_id: "demo-1" });
state.records = [{ concept_id: "intro", dimension: "mastery", score: 0.30, confidence: 0.30, evidence_count: 1, last_updated: now1 }];
await guarded((token) => putLearnerState(token, learnerId, state));
const now2 = new Date(Date.now() + 1000).toISOString();
state.history.push({ concept_id: "intro", dimension: "mastery", score: 0.78, confidence_hint: 0.7, timestamp: now2, kind: "review", source_id: "demo-2" });
state.records = [{ concept_id: "intro", dimension: "mastery", score: 0.78, confidence: 0.70, evidence_count: 2, last_updated: now2 }];
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 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 };
}
state.records = Object.values(latest);
await guarded((token) => putLearnerState(token, learnerId, state));
setMessage("Demo state generated.");
}
const now4 = new Date(Date.now() + 3000).toISOString();
state.history.push({ concept_id: "second", dimension: "mastery", score: 0.72, confidence_hint: 0.7, timestamp: now4, kind: "review", source_id: "demo-4" });
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.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.");
async function createJob() {
const result = await guarded((token) => createRenderJob(token, learnerId, packId, { learner_id: learnerId, pack_id: packId, format, fps, theme: "default" }));
setMessage(`Render job ${result.job_id} queued.`);
setTimeout(() => reloadLists(), 500);
}
if (!auth) return <LoginView onAuth={setAuth} />;
@ -167,36 +110,40 @@ export default function App() {
<div className="page">
<header className="hero">
<div>
<h1>Didactopus animated concept graph</h1>
<p>Replay node-level mastery, unlocks, and prerequisite structure over time.</p>
<h1>Didactopus artifact registry</h1>
<p>Track render jobs and produced media artifacts as first-class Didactopus objects.</p>
<div className="muted">{message}</div>
</div>
<div className="controls">
<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>)}
</select>
</label>
<button onClick={() => setPlaying((x) => !x)}>{playing ? "Pause" : "Play"}</button>
<button onClick={generateDemo}>Generate demo</button>
<label>Format
<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>
</div>
</header>
<main className="layout twocol">
<section className="card">
<h2>Graph animation</h2>
<div className="frame-meta">
<div><strong>Frame:</strong> {frameIndex + 1} / {graphData?.frames?.length || 0}</div>
<div><strong>Event:</strong> {frame?.event_kind || "-"}</div>
<div><strong>Focus:</strong> {frame?.focus_concept_id || "-"}</div>
<div><strong>Timestamp:</strong> {frame?.timestamp || "-"}</div>
</div>
<GraphView frame={frame} />
<h2>Render jobs</h2>
<pre className="prebox">{JSON.stringify(jobs, null, 2)}</pre>
</section>
<section className="card">
<h2>Graph payload</h2>
<pre className="prebox">{JSON.stringify(graphData, null, 2)}</pre>
<h2>Artifacts</h2>
<pre className="prebox">{JSON.stringify(artifacts, null, 2)}</pre>
</section>
</main>
</div>

View File

@ -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 fetchLearnerState(token, learnerId) { const res = await fetch(`${API}/learners/${learnerId}/state`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchLearnerState failed"); return await res.json(); }
export async function putLearnerState(token, learnerId, state) { const res = await fetch(`${API}/learners/${learnerId}/state`, { method: "PUT", headers: authHeaders(token), body: JSON.stringify(state) }); if (!res.ok) throw new Error("putLearnerState failed"); return await res.json(); }
export async function fetchGraphAnimation(token, learnerId, packId) { const res = await fetch(`${API}/learners/${learnerId}/graph-animation/${packId}`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchGraphAnimation failed"); return await res.json(); }
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(); }

View File

@ -10,7 +10,6 @@ body { margin:0; font-family:Arial, Helvetica, sans-serif; background:var(--bg);
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; }
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; }
.narrow { margin-top:60px; }
.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; }
.muted { color:var(--muted); }
.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) {
.hero { flex-direction:column; }
.twocol { grid-template-columns:1fr; }
.frame-meta { grid-template-columns:1fr; }
}