diff --git a/pyproject.toml b/pyproject.toml index 6892d71..b6064b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ requires-python = ">=3.10" dependencies = ["pydantic>=2.7", "pyyaml>=6.0"] [project.scripts] -didactopus-demo-run = "didactopus.demo_run:main" +didactopus-pack-to-frontend = "didactopus.pack_to_frontend:main" [tool.setuptools.packages.find] where = ["src"] diff --git a/tests/test_pack_export.py b/tests/test_pack_export.py index 0d0704f..85670f5 100644 --- a/tests/test_pack_export.py +++ b/tests/test_pack_export.py @@ -2,9 +2,16 @@ from pathlib import Path from didactopus.pack_to_frontend import convert_pack def test_convert_pack(tmp_path: Path): - (tmp_path / "pack.yaml").write_text("name: p1\ndisplay_name: Pack 1\ndescription: Demo pack\n", encoding="utf-8") - (tmp_path / "concepts.yaml").write_text("concepts:\n - id: c1\n title: Concept 1\n prerequisites: []\n", encoding="utf-8") - (tmp_path / "pack_compliance_manifest.json").write_text('{"derived_from_sources":["s1"],"attribution_required":true,"share_alike_required":false,"noncommercial_only":false,"restrictive_flags":[]}', encoding="utf-8") + (tmp_path / "pack.yaml").write_text( + "name: p1\ndisplay_name: Pack 1\ndescription: Demo pack\n", encoding="utf-8" + ) + (tmp_path / "concepts.yaml").write_text( + "concepts:\n - id: c1\n title: Concept 1\n prerequisites: []\n", encoding="utf-8" + ) + (tmp_path / "pack_compliance_manifest.json").write_text( + '{"derived_from_sources":["s1"],"attribution_required":true,"share_alike_required":false,"noncommercial_only":false,"restrictive_flags":[]}', + encoding="utf-8" + ) payload = convert_pack(tmp_path) assert payload["id"] == "p1" assert payload["concepts"][0]["id"] == "c1" diff --git a/tests/test_ui_files.py b/tests/test_ui_files.py index ceba475..7a06f82 100644 --- a/tests/test_ui_files.py +++ b/tests/test_ui_files.py @@ -2,5 +2,5 @@ from pathlib import Path def test_ui_files_exist(): assert Path("webui/src/App.jsx").exists() - assert Path("webui/src/domainData.js").exists() - assert Path("webui/src/engine.js").exists() + assert Path("webui/src/storage.js").exists() + assert Path("webui/public/packs/bayes-pack.json").exists() diff --git a/webui/index.html b/webui/index.html index 453c3ca..45716ea 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3,8 +3,10 @@ - Didactopus Media Rendering + Didactopus Pack UI -
+ +
+ diff --git a/webui/package.json b/webui/package.json index e9a6121..5d364c7 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,9 +1,17 @@ { - "name": "didactopus-media-rendering-ui", + "name": "didactopus-live-pack-ui", "private": true, "version": "0.1.0", "type": "module", - "scripts": { "dev": "vite", "build": "vite build" }, - "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" }, - "devDependencies": { "vite": "^5.4.0" } + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "vite": "^5.4.0" + } } diff --git a/webui/src/App.jsx b/webui/src/App.jsx index 68e6f92..6c3533b 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -1,145 +1,206 @@ -import React, { useEffect, useState } from "react"; -import { login, refresh, fetchPacks, fetchLearnerState, putLearnerState, fetchGraphAnimation, createRenderBundle } from "./api"; -import { loadAuth, saveAuth, clearAuth } from "./authStore"; +import React, { useEffect, useMemo, useState } from "react"; +import { applyEvidence, buildMasteryMap, claimReadiness, milestoneMessages, progressPercent, recommendNext } from "./engine"; +import { loadLearnerState, saveLearnerState, resetLearnerState } from "./storage"; -function LoginView({ onAuth }) { - const [username, setUsername] = useState("wesley"); - const [password, setPassword] = useState("demo-pass"); - const [error, setError] = useState(""); - async function doLogin() { - try { - const result = await login(username, password); - saveAuth(result); - onAuth(result); - } catch { setError("Login failed"); } - } +const PACKS = ["/packs/bayes-pack.json", "/packs/stats-pack.json"]; + +function DomainCard({ domain, selected, onSelect }) { return ( -
-
-

Didactopus login

- - - - {error ?
{error}
: null} -
+ + ); +} + +function NextStepCard({ step, onSimulate }) { + return ( +
+
+
+

{step.title}

+
{step.minutes} minutes
+
+
{step.reward}
+
+

{step.reason}

+
+ Why this is recommended +
    + {step.why.map((item, idx) =>
  • {item}
  • )} +
+
+
); } export default function App() { - const [auth, setAuth] = useState(loadAuth()); const [packs, setPacks] = useState([]); - const [learnerId] = useState("wesley-learner"); - const [packId, setPackId] = useState(""); - const [bundle, setBundle] = useState(null); - const [format, setFormat] = useState("gif"); - const [fps, setFps] = useState(2); - const [message, setMessage] = useState(""); - - async function refreshAuthToken() { - if (!auth?.refresh_token) return null; - try { - const result = await refresh(auth.refresh_token); - saveAuth(result); - setAuth(result); - return result; - } catch { - clearAuth(); - setAuth(null); - return null; - } - } - - async function guarded(fn) { - try { return await fn(auth.access_token); } - catch { - const next = await refreshAuthToken(); - if (!next) throw new Error("auth failed"); - return await fn(next.access_token); - } - } + const [selectedDomainId, setSelectedDomainId] = useState(""); + const [learnerName, setLearnerName] = useState("Wesley"); + const [domainStates, setDomainStates] = useState({}); + const [lastReward, setLastReward] = useState(""); useEffect(() => { - if (!auth) return; - async function load() { - const p = await guarded((token) => fetchPacks(token)); - setPacks(p); - setPackId(p[0]?.id || ""); - } - load(); - }, [auth]); + Promise.all(PACKS.map((u) => fetch(u).then((r) => r.json()))).then((loaded) => { + setPacks(loaded); + setSelectedDomainId(loaded[0]?.id || ""); + const states = {}; + for (const pack of loaded) { + states[pack.id] = loadLearnerState(pack.id); + } + setDomainStates(states); + }); + }, []); - async function generateDemo() { - let state = await guarded((token) => fetchLearnerState(token, learnerId)); - const base = Date.now(); - const events = [ - ["intro", 0.30, "exercise", 0], - ["intro", 0.78, "review", 1000], - ["second", 0.42, "exercise", 2000], - ["second", 0.72, "review", 3000], - ["third", 0.25, "exercise", 4000], - ["branch", 0.60, "exercise", 5000], - ]; - const latest = {} - for (const [cid, score, kind, offset] of events) { - const ts = new Date(base + offset).toISOString(); - state.history.push({ concept_id: cid, dimension: "mastery", score, confidence_hint: 0.6, timestamp: ts, kind, source_id: `demo-${cid}-${offset}` }); - latest[cid] = { concept_id: cid, dimension: "mastery", score, confidence: Math.min(0.9, score), evidence_count: (latest[cid]?.evidence_count || 0) + 1, last_updated: ts }; - } - state.records = Object.values(latest); - await guarded((token) => putLearnerState(token, learnerId, state)); - setMessage("Demo state generated."); + const domain = useMemo(() => packs.find((d) => d.id === selectedDomainId) || null, [packs, selectedDomainId]); + const learnerState = domain ? (domainStates[domain.id] || loadLearnerState(domain.id)) : null; + + const masteryMap = useMemo(() => domain && learnerState ? buildMasteryMap(learnerState, domain) : [], [learnerState, domain]); + const progress = useMemo(() => domain && learnerState ? progressPercent(learnerState, domain) : 0, [learnerState, domain]); + const recs = useMemo(() => domain && learnerState ? recommendNext(learnerState, domain) : [], [learnerState, domain]); + const milestones = useMemo(() => domain && learnerState ? milestoneMessages(learnerState, domain) : [], [learnerState, domain]); + const readiness = useMemo(() => domain && learnerState ? claimReadiness(learnerState, domain) : {ready:false, mastered:0, avgScore:0, avgConfidence:0}, [learnerState, domain]); + + function updateState(domainId, nextState) { + saveLearnerState(domainId, nextState); + setDomainStates((prev) => ({ ...prev, [domainId]: nextState })); } - async function renderNow() { - const result = await guarded((token) => createRenderBundle(token, learnerId, packId, { learner_id: learnerId, pack_id: packId, format, fps, theme: "default" })); - setBundle(result); - setMessage("Render bundle created."); + function simulateStep(step) { + if (!domain || !learnerState) return; + const timestamp = new Date().toISOString(); + const updated = applyEvidence(learnerState, { + concept_id: step.conceptId, + dimension: "mastery", + score: step.scoreHint, + confidence_hint: step.confidenceHint, + timestamp, + kind: "checkpoint", + source_id: `ui-${step.id}` + }); + updateState(domain.id, updated); + setLastReward(step.reward); } - if (!auth) return ; + function resetSelectedDomain() { + if (!domain) return; + resetLearnerState(domain.id); + updateState(domain.id, loadLearnerState(domain.id)); + setLastReward(""); + } + + if (!domain || !learnerState) { + return
Loading packs...
; + } return (
-

Didactopus media rendering pipeline

-

Create GIF/MP4-ready render bundles from animated learning graphs.

-
{message}
+

Didactopus learner prototype

+

Real pack files, persistent learner state, and live recommendation updates.

-
-
-
-
-

Bundle output

-
{JSON.stringify(bundle, null, 2)}
-
-
-

What this bundle contains

-
-

Each bundle includes SVG frames, a JSON manifest, and a render shell script suitable for FFmpeg-based conversion.

-

This keeps artifact generation decoupled from the API server while still making render production straightforward.

-
-
+
+ {packs.map((d) => )} +
+ +
+
+
+

First-session onboarding

+

{domain.onboarding.headline}

+

{domain.onboarding.body}

+

Learner: {learnerName || "Unnamed learner"}

+
    {domain.onboarding.checklist.map((item, idx) =>
  • {item}
  • )}
+
+ +
+

Visible mastery map

+
+ {masteryMap.map((node) => ( +
+
{node.label}
+
{node.status}
+
+ ))} +
+
+ +
+

Evidence log

+ {learnerState.history.length === 0 ?
No evidence recorded yet.
: ( +
    + {learnerState.history.slice().reverse().map((item, idx) => ( +
  • {item.concept_id} · score {item.score.toFixed(2)} · confidence hint {item.confidence_hint.toFixed(2)}
  • + ))} +
+ )} +
+
+ +
+
+

What should I do next?

+ {recs.length === 0 ? ( +
No immediate recommendation available.
+ ) : ( +
+ {recs.map((step) => )} +
+ )} +
+
+ +
+
+

Progress

+
+
Mastery progress
+
+
{progress}%
+
+
+ {readiness.ready ? "Usable expertise threshold met" : "Still building toward usable expertise"} +
Mastered concepts: {readiness.mastered}
+
Average score: {readiness.avgScore.toFixed(2)}
+
Average confidence: {readiness.avgConfidence.toFixed(2)}
+
+
+ +
+

Milestones and rewards

+ {lastReward ?
{lastReward}
: null} +
    {milestones.map((m, idx) =>
  • {m}
  • )}
+
+ +
+

Source attribution and compliance

+
+
Sources
{domain.compliance.sources}
+
Attribution
{domain.compliance.attributionRequired ? "required" : "not required"}
+
Share-alike
{domain.compliance.shareAlikeRequired ? "yes" : "no"}
+
Noncommercial
{domain.compliance.noncommercialOnly ? "yes" : "no"}
+
+
+ {domain.compliance.flags.length ? domain.compliance.flags.map((f) => {f}) : No extra flags} +
+
+
); diff --git a/webui/src/engine.js b/webui/src/engine.js index bc743a7..1cb1fe2 100644 --- a/webui/src/engine.js +++ b/webui/src/engine.js @@ -73,21 +73,19 @@ export function recommendNext(state, domain) { minutes: status === "available" ? 15 : 10, reason: status === "available" ? "Prerequisites are satisfied, so this is the best next unlock." - : "You have started this concept, but the system does not yet consider mastery secure.", + : "You have started this concept, but mastery is not yet secure.", why: [ "Prerequisite check passed", rec ? `Current score: ${rec.score.toFixed(2)}` : "No evidence recorded yet", - rec ? `Current confidence: ${rec.confidence.toFixed(2)}` : "Confidence will start growing after your first exercise" + rec ? `Current confidence: ${rec.confidence.toFixed(2)}` : "Confidence starts after your first exercise" ], - reward: concept.exerciseReward, + reward: concept.exerciseReward || `${concept.title} progress recorded`, conceptId: concept.id, scoreHint: status === "available" ? 0.82 : 0.76, confidenceHint: status === "available" ? 0.72 : 0.55 }); } } - - // reinforcement targets for (const rec of state.records) { if (rec.dimension === "mastery" && rec.confidence < 0.40) { const concept = domain.concepts.find((c) => c.id === rec.concept_id); @@ -99,7 +97,7 @@ export function recommendNext(state, domain) { reason: "Your score is promising, but confidence is still thin.", why: [ `Confidence ${rec.confidence.toFixed(2)} is below reinforcement threshold`, - "A small fresh exercise can stabilize recall and raise readiness" + "A small fresh exercise can stabilize recall" ], reward: "Confidence ring grows", conceptId: concept.id, @@ -109,7 +107,6 @@ export function recommendNext(state, domain) { } } } - return cards.slice(0, 4); } @@ -129,10 +126,7 @@ export function claimReadiness(state, domain, minScore = 0.75, minConfidence = 0 return rec && rec.score >= minScore && rec.confidence >= minConfidence; }).length; - const records = domain.concepts - .map((c) => getRecord(state, c.id, c.masteryDimension || "mastery")) - .filter(Boolean); - + const records = domain.concepts.map((c) => getRecord(state, c.id, c.masteryDimension || "mastery")).filter(Boolean); const avgScore = records.length ? records.reduce((a, r) => a + r.score, 0) / records.length : 0; const avgConfidence = records.length ? records.reduce((a, r) => a + r.confidence, 0) / records.length : 0; @@ -143,11 +137,3 @@ export function claimReadiness(state, domain, minScore = 0.75, minConfidence = 0 avgConfidence }; } - -export function starterLearnerState(learnerId) { - return { - learner_id: learnerId, - records: [], - history: [] - }; -} diff --git a/webui/src/main.jsx b/webui/src/main.jsx index 7352818..8ad26cf 100644 --- a/webui/src/main.jsx +++ b/webui/src/main.jsx @@ -2,4 +2,5 @@ import React from "react"; import { createRoot } from "react-dom/client"; import App from "./App"; import "./styles.css"; + createRoot(document.getElementById("root")).render(); diff --git a/webui/src/styles.css b/webui/src/styles.css index 268757c..0de0492 100644 --- a/webui/src/styles.css +++ b/webui/src/styles.css @@ -1,24 +1,53 @@ :root { - --bg:#f6f8fb; --card:#ffffff; --text:#1f2430; --muted:#60697a; --border:#dbe1ea; --accent:#2d6cdf; + --bg: #f6f8fb; + --card: #ffffff; + --text: #1f2430; + --muted: #60697a; + --border: #dbe1ea; + --accent: #2d6cdf; + --soft: #eef4ff; } -* { box-sizing:border-box; } -body { margin:0; font-family:Arial, Helvetica, sans-serif; background:var(--bg); color:var(--text); } -.page { max-width:1500px; margin:0 auto; padding:24px; } -.narrow-page { max-width:520px; } -.hero { background:var(--card); border:1px solid var(--border); border-radius:22px; padding:24px; display:flex; justify-content:space-between; gap:16px; align-items:flex-start; } -.controls { display:flex; gap:10px; align-items:flex-end; flex-wrap:wrap; } -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; } -.card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; } -.narrow { margin-top:60px; } -.layout { display:grid; gap:16px; } -.twocol { grid-template-columns:1fr 1fr; } -.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; } -.explain p { margin-top:0; } -@media (max-width:1100px) { - .hero { flex-direction:column; } - .twocol { grid-template-columns:1fr; } +* { box-sizing: border-box; } +body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: var(--bg); color: var(--text); } +.page { max-width: 1500px; margin: 0 auto; padding: 20px; } +.hero { background: var(--card); border: 1px solid var(--border); border-radius: 22px; padding: 24px; display: flex; justify-content: space-between; gap: 16px; } +.hero-controls { min-width: 260px; } +.hero-controls input { width: 100%; margin-top: 6px; border: 1px solid var(--border); border-radius: 10px; padding: 10px; font: inherit; } +.hero-controls button { margin-top: 12px; } +.domain-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-top: 16px; } +.domain-card { border: 1px solid var(--border); background: var(--card); border-radius: 18px; padding: 16px; text-align: left; cursor: pointer; } +.domain-card.selected { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(45,108,223,0.12); } +.domain-title { font-size: 20px; font-weight: 700; } +.domain-subtitle { margin-top: 6px; color: var(--muted); } +.domain-meta { margin-top: 10px; display: flex; gap: 12px; color: var(--muted); font-size: 14px; } +.layout { display: grid; grid-template-columns: 1fr 1.25fr 0.95fr; gap: 16px; margin-top: 16px; } +.card { background: var(--card); border: 1px solid var(--border); border-radius: 20px; padding: 18px; } +.muted { color: var(--muted); } +.steps-stack { display: grid; gap: 14px; } +.step-card { border: 1px solid var(--border); border-radius: 16px; padding: 14px; background: #fcfdff; } +.step-header { display: flex; justify-content: space-between; gap: 12px; align-items: start; } +.reward-pill { background: var(--soft); border: 1px solid var(--border); border-radius: 999px; padding: 8px 10px; font-size: 12px; } +button { border: 1px solid var(--border); background: white; border-radius: 12px; padding: 10px 14px; cursor: pointer; } +.primary { margin-top: 10px; background: var(--accent); color: white; border: none; } +.map-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; } +.map-node { border: 1px solid var(--border); border-radius: 16px; padding: 14px; } +.map-node.mastered { background: #eef9f0; } +.map-node.active, .map-node.available { background: #eef4ff; } +.map-node.locked { background: #f6f7fa; } +.node-label { font-weight: 700; } +.node-status { margin-top: 6px; color: var(--muted); text-transform: capitalize; } +.progress-wrap { margin-bottom: 14px; } +.progress-bar { width: 100%; height: 12px; border-radius: 999px; background: #e9edf4; overflow: hidden; margin: 8px 0; } +.progress-fill { height: 100%; background: var(--accent); } +.reward-banner { background: #fff7dd; border: 1px solid #ecdca2; border-radius: 14px; padding: 12px; margin-bottom: 12px; font-weight: 700; } +.readiness-box { border: 1px solid var(--border); background: #fbfcfe; border-radius: 14px; padding: 12px; } +.readiness-box.ready { background: #eef9f0; } +.compliance-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; } +.flag-row { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 8px; } +.flag { border: 1px solid var(--border); background: #f4f7fc; border-radius: 999px; padding: 6px 10px; font-size: 12px; } +details summary { cursor: pointer; color: var(--accent); } +@media (max-width: 1100px) { + .layout { grid-template-columns: 1fr; } + .domain-grid { grid-template-columns: 1fr; } + .hero { flex-direction: column; } }