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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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