Apply ZIP update: 175-didactopus-backend-api-prototype.zip [2026-03-14T13:20:13]
This commit is contained in:
parent
d851515201
commit
efafa2f289
|
|
@ -10,14 +10,11 @@ dependencies = [
|
|||
"pydantic>=2.7",
|
||||
"pyyaml>=6.0",
|
||||
"fastapi>=0.115",
|
||||
"uvicorn>=0.30",
|
||||
"sqlalchemy>=2.0",
|
||||
"passlib[bcrypt]>=1.7"
|
||||
"uvicorn>=0.30"
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
didactopus-api = "didactopus.api:main"
|
||||
didactopus-seed-db = "didactopus.seed:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
|
|
|||
|
|
@ -1,19 +1,14 @@
|
|||
from __future__ import annotations
|
||||
from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks
|
||||
from pathlib import Path
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import uvicorn
|
||||
from .config import load_settings
|
||||
from .db import Base, engine
|
||||
from .models import LoginRequest, LoginResponse, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus
|
||||
from .repository import (
|
||||
authenticate_user, get_user_by_token, list_packs, get_pack, create_learner,
|
||||
learner_owned_by_user, load_learner_state, save_learner_state,
|
||||
create_evaluator_job, get_evaluator_job, update_evaluator_job
|
||||
)
|
||||
from .models import LearnerState, EvidenceEvent
|
||||
from .storage import FileStorage
|
||||
from .engine import apply_evidence, recommend_next
|
||||
|
||||
settings = load_settings()
|
||||
Base.metadata.create_all(bind=engine)
|
||||
BASE_DIR = Path(__file__).resolve().parents[2] / "data"
|
||||
storage = FileStorage(BASE_DIR)
|
||||
|
||||
app = FastAPI(title="Didactopus API Prototype")
|
||||
app.add_middleware(
|
||||
|
|
@ -24,112 +19,44 @@ app.add_middleware(
|
|||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
def current_user(authorization: str = Header(default="")):
|
||||
token = authorization.removeprefix("Bearer ").strip()
|
||||
user = get_user_by_token(token) if token else None
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
return user
|
||||
|
||||
def ensure_learner_access(user, learner_id: str):
|
||||
if not learner_owned_by_user(user.id, learner_id):
|
||||
raise HTTPException(status_code=403, detail="Learner not accessible by this user")
|
||||
|
||||
def simulate_evaluator_job(job_id: int):
|
||||
job = get_evaluator_job(job_id)
|
||||
if job is None:
|
||||
return
|
||||
update_evaluator_job(job_id, "running")
|
||||
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)
|
||||
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)
|
||||
|
||||
@app.post("/api/login", response_model=LoginResponse)
|
||||
def login(payload: LoginRequest):
|
||||
user = authenticate_user(payload.username, payload.password)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
return LoginResponse(token=user.token, username=user.username)
|
||||
|
||||
@app.get("/api/packs")
|
||||
def api_list_packs(user = Depends(current_user)):
|
||||
return [p.model_dump() for p in list_packs()]
|
||||
def list_packs():
|
||||
return [p.model_dump() for p in storage.list_packs()]
|
||||
|
||||
@app.get("/api/packs/{pack_id}")
|
||||
def api_get_pack(pack_id: str, user = Depends(current_user)):
|
||||
pack = get_pack(pack_id)
|
||||
def get_pack(pack_id: str):
|
||||
pack = storage.get_pack(pack_id)
|
||||
if pack is None:
|
||||
raise HTTPException(status_code=404, detail="Pack not found")
|
||||
return pack.model_dump()
|
||||
|
||||
@app.post("/api/learners")
|
||||
def api_create_learner(payload: CreateLearnerRequest, user = Depends(current_user)):
|
||||
create_learner(user.id, payload.learner_id, payload.display_name)
|
||||
return {"ok": True, "learner_id": payload.learner_id}
|
||||
|
||||
@app.get("/api/learners/{learner_id}/state")
|
||||
def api_get_learner_state(learner_id: str, user = Depends(current_user)):
|
||||
ensure_learner_access(user, learner_id)
|
||||
return load_learner_state(learner_id).model_dump()
|
||||
def get_learner_state(learner_id: str):
|
||||
return storage.get_learner_state(learner_id).model_dump()
|
||||
|
||||
@app.put("/api/learners/{learner_id}/state")
|
||||
def api_put_learner_state(learner_id: str, state: LearnerState, user = Depends(current_user)):
|
||||
ensure_learner_access(user, learner_id)
|
||||
def put_learner_state(learner_id: str, state: LearnerState):
|
||||
if learner_id != state.learner_id:
|
||||
raise HTTPException(status_code=400, detail="Learner ID mismatch")
|
||||
return save_learner_state(state).model_dump()
|
||||
return storage.save_learner_state(state).model_dump()
|
||||
|
||||
@app.post("/api/learners/{learner_id}/evidence")
|
||||
def api_post_evidence(learner_id: str, event: EvidenceEvent, user = Depends(current_user)):
|
||||
ensure_learner_access(user, learner_id)
|
||||
state = load_learner_state(learner_id)
|
||||
def post_evidence(learner_id: str, event: EvidenceEvent):
|
||||
state = storage.get_learner_state(learner_id)
|
||||
state = apply_evidence(state, event)
|
||||
save_learner_state(state)
|
||||
storage.save_learner_state(state)
|
||||
return state.model_dump()
|
||||
|
||||
@app.get("/api/learners/{learner_id}/recommendations/{pack_id}")
|
||||
def api_get_recommendations(learner_id: str, pack_id: str, user = Depends(current_user)):
|
||||
ensure_learner_access(user, learner_id)
|
||||
state = load_learner_state(learner_id)
|
||||
pack = get_pack(pack_id)
|
||||
def get_recommendations(learner_id: str, pack_id: str):
|
||||
state = storage.get_learner_state(learner_id)
|
||||
pack = storage.get_pack(pack_id)
|
||||
if pack is None:
|
||||
raise HTTPException(status_code=404, detail="Pack not found")
|
||||
return {"cards": recommend_next(state, pack)}
|
||||
|
||||
@app.post("/api/learners/{learner_id}/evaluator-jobs", response_model=EvaluatorJobStatus)
|
||||
def api_submit_evaluator_job(learner_id: str, payload: EvaluatorSubmission, background_tasks: BackgroundTasks, user = Depends(current_user)):
|
||||
ensure_learner_access(user, learner_id)
|
||||
job_id = create_evaluator_job(learner_id, payload.pack_id, payload.concept_id, payload.submitted_text)
|
||||
background_tasks.add_task(simulate_evaluator_job, job_id)
|
||||
return EvaluatorJobStatus(job_id=job_id, status="queued")
|
||||
|
||||
@app.get("/api/evaluator-jobs/{job_id}", response_model=EvaluatorJobStatus)
|
||||
def api_get_evaluator_job(job_id: int, user = Depends(current_user)):
|
||||
job = get_evaluator_job(job_id)
|
||||
if job is None:
|
||||
raise HTTPException(status_code=404, detail="Job not found")
|
||||
return EvaluatorJobStatus(
|
||||
job_id=job.id,
|
||||
status=job.status,
|
||||
result_score=job.result_score,
|
||||
result_confidence_hint=job.result_confidence_hint,
|
||||
result_notes=job.result_notes,
|
||||
)
|
||||
|
||||
def main():
|
||||
uvicorn.run(app, host=settings.host, port=settings.port)
|
||||
uvicorn.run(app, host="127.0.0.1", port=8011)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -48,28 +48,3 @@ class LearnerState(BaseModel):
|
|||
learner_id: str
|
||||
records: list[MasteryRecord] = Field(default_factory=list)
|
||||
history: list[EvidenceEvent] = Field(default_factory=list)
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
token: str
|
||||
username: str
|
||||
|
||||
class CreateLearnerRequest(BaseModel):
|
||||
learner_id: str
|
||||
display_name: str = ""
|
||||
|
||||
class EvaluatorSubmission(BaseModel):
|
||||
pack_id: str
|
||||
concept_id: str
|
||||
submitted_text: str
|
||||
kind: str = "checkpoint"
|
||||
|
||||
class EvaluatorJobStatus(BaseModel):
|
||||
job_id: int
|
||||
status: str
|
||||
result_score: float | None = None
|
||||
result_confidence_hint: float | None = None
|
||||
result_notes: str = ""
|
||||
|
|
|
|||
|
|
@ -2,16 +2,9 @@ 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"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Didactopus Auth API UI</title>
|
||||
<title>Didactopus API UI</title>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "didactopus-auth-api-ui",
|
||||
"name": "didactopus-api-ui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,38 +1,16 @@
|
|||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { login, fetchPacks, createLearner, fetchLearnerState, fetchRecommendations, postEvidence, submitEvaluatorJob, fetchEvaluatorJob } from "./api";
|
||||
import { buildMasteryMap, progressPercent } from "./localEngine";
|
||||
|
||||
function LoginPanel({ onLogin }) {
|
||||
const [username, setUsername] = useState("wesley");
|
||||
const [password, setPassword] = useState("demo-pass");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleLogin() {
|
||||
try {
|
||||
setError("");
|
||||
const result = await onLogin(username, password);
|
||||
return result;
|
||||
} catch (e) {
|
||||
setError("Login failed");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card narrow">
|
||||
<h1>Didactopus login</h1>
|
||||
<label>Username<input value={username} onChange={(e) => setUsername(e.target.value)} /></label>
|
||||
<label>Password<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} /></label>
|
||||
<button className="primary" onClick={handleLogin}>Login</button>
|
||||
{error ? <div className="error">{error}</div> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
import { fetchPacks, fetchLearnerState, fetchRecommendations, postEvidence } from "./api";
|
||||
import { buildMasteryMap, progressPercent, milestoneMessages, claimReadiness } from "./localEngine";
|
||||
|
||||
function DomainCard({ domain, selected, onSelect }) {
|
||||
return (
|
||||
<button className={`domain-card ${selected ? "selected" : ""}`} onClick={() => onSelect(domain.id)}>
|
||||
<div className="domain-title">{domain.title}</div>
|
||||
<div className="domain-subtitle">{domain.subtitle}</div>
|
||||
<div className="domain-meta">
|
||||
<span>{domain.level}</span>
|
||||
<span>{domain.concepts.length} concepts</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -52,51 +30,49 @@ function NextStepCard({ step, onSimulate }) {
|
|||
<summary>Why this is recommended</summary>
|
||||
<ul>{step.why.map((item, idx) => <li key={idx}>{item}</li>)}</ul>
|
||||
</details>
|
||||
<div className="button-row">
|
||||
<button className="primary" onClick={() => onSimulate(step)}>Simulate step</button>
|
||||
</div>
|
||||
<button className="primary" onClick={() => onSimulate(step)}>Simulate completing this step</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [token, setToken] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [packs, setPacks] = useState([]);
|
||||
const [selectedDomainId, setSelectedDomainId] = useState("");
|
||||
const [learnerId, setLearnerId] = useState("wesley-learner");
|
||||
const [learnerName, setLearnerName] = useState("Wesley");
|
||||
const [learnerState, setLearnerState] = useState(null);
|
||||
const [cards, setCards] = useState([]);
|
||||
const [jobStatus, setJobStatus] = useState(null);
|
||||
const [lastReward, setLastReward] = useState("");
|
||||
const learnerId = "wesley-demo";
|
||||
|
||||
async function handleLogin(user, pass) {
|
||||
const auth = await login(user, pass);
|
||||
setToken(auth.token);
|
||||
setUsername(auth.username);
|
||||
await createLearner(auth.token, learnerId, learnerId);
|
||||
const loadedPacks = await fetchPacks(auth.token);
|
||||
setPacks(loadedPacks);
|
||||
setSelectedDomainId(loadedPacks[0]?.id || "");
|
||||
const state = await fetchLearnerState(auth.token, learnerId);
|
||||
setLearnerState(state);
|
||||
return auth;
|
||||
}
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const loadedPacks = await fetchPacks();
|
||||
setPacks(loadedPacks);
|
||||
const first = loadedPacks[0]?.id || "";
|
||||
setSelectedDomainId(first);
|
||||
const state = await fetchLearnerState(learnerId);
|
||||
setLearnerState(state);
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadCards() {
|
||||
if (!token || !selectedDomainId) return;
|
||||
const data = await fetchRecommendations(token, learnerId, selectedDomainId);
|
||||
if (!selectedDomainId) return;
|
||||
const data = await fetchRecommendations(learnerId, selectedDomainId);
|
||||
setCards(data.cards || []);
|
||||
}
|
||||
loadCards();
|
||||
}, [token, learnerId, selectedDomainId, learnerState]);
|
||||
if (selectedDomainId) loadCards();
|
||||
}, [selectedDomainId, learnerState]);
|
||||
|
||||
const domain = useMemo(() => packs.find((d) => d.id === selectedDomainId) || null, [packs, selectedDomainId]);
|
||||
const masteryMap = useMemo(() => domain && learnerState ? buildMasteryMap(learnerState, domain) : [], [learnerState, domain]);
|
||||
const progress = useMemo(() => domain && learnerState ? progressPercent(learnerState, domain) : 0, [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]);
|
||||
|
||||
async function simulateStep(step) {
|
||||
const nextState = await postEvidence(token, learnerId, {
|
||||
const nextState = await postEvidence(learnerId, {
|
||||
concept_id: step.conceptId,
|
||||
dimension: "mastery",
|
||||
score: step.scoreHint,
|
||||
|
|
@ -106,32 +82,11 @@ export default function App() {
|
|||
source_id: `ui-${step.id}`
|
||||
});
|
||||
setLearnerState(nextState);
|
||||
}
|
||||
|
||||
async function submitDemoEvaluator() {
|
||||
if (!domain) return;
|
||||
const firstConcept = domain.concepts[0]?.id;
|
||||
const job = await submitEvaluatorJob(token, learnerId, {
|
||||
pack_id: domain.id,
|
||||
concept_id: firstConcept,
|
||||
submitted_text: "This is a moderately detailed learner response intended to trigger a somewhat better prototype evaluator score.",
|
||||
kind: "checkpoint"
|
||||
});
|
||||
setJobStatus(job);
|
||||
setTimeout(async () => {
|
||||
const refreshed = await fetchEvaluatorJob(token, job.job_id);
|
||||
setJobStatus(refreshed);
|
||||
const state = await fetchLearnerState(token, learnerId);
|
||||
setLearnerState(state);
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return <div className="page"><LoginPanel onLogin={handleLogin} /></div>;
|
||||
setLastReward(step.reward);
|
||||
}
|
||||
|
||||
if (!domain || !learnerState) {
|
||||
return <div className="page"><div className="card">Loading authenticated learner view...</div></div>;
|
||||
return <div className="page"><div className="card">Loading backend data...</div></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -139,12 +94,13 @@ export default function App() {
|
|||
<header className="hero">
|
||||
<div>
|
||||
<h1>Didactopus learner prototype</h1>
|
||||
<p>Authenticated multi-user scaffold with DB-backed state and async evaluator jobs.</p>
|
||||
<div className="muted">Signed in as {username}</div>
|
||||
<p>Backend-driven pack registry, learner-state persistence, and evaluator-style evidence ingestion.</p>
|
||||
</div>
|
||||
<div className="hero-controls">
|
||||
<label>Learner ID<input value={learnerId} onChange={(e) => setLearnerId(e.target.value)} /></label>
|
||||
<button onClick={submitDemoEvaluator}>Submit demo evaluator job</button>
|
||||
<label>
|
||||
Learner name
|
||||
<input value={learnerName} onChange={(e) => setLearnerName(e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
@ -155,9 +111,10 @@ export default function App() {
|
|||
<main className="layout">
|
||||
<div className="left-col">
|
||||
<section className="card">
|
||||
<h2>Onboarding</h2>
|
||||
<h2>First-session onboarding</h2>
|
||||
<h3>{domain.onboarding.headline}</h3>
|
||||
<p>{domain.onboarding.body}</p>
|
||||
<p className="muted">Learner: {learnerName}</p>
|
||||
<ul>{(domain.onboarding.checklist || []).map((item, idx) => <li key={idx}>{item}</li>)}</ul>
|
||||
</section>
|
||||
|
||||
|
|
@ -174,16 +131,13 @@ export default function App() {
|
|||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h2>Async evaluator job</h2>
|
||||
{jobStatus ? (
|
||||
<div>
|
||||
<div><strong>Status:</strong> {jobStatus.status}</div>
|
||||
<div><strong>Score:</strong> {jobStatus.result_score ?? "-"}</div>
|
||||
<div><strong>Confidence hint:</strong> {jobStatus.result_confidence_hint ?? "-"}</div>
|
||||
<div className="muted">{jobStatus.result_notes || ""}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="muted">No evaluator job submitted yet.</div>
|
||||
<h2>Evidence log</h2>
|
||||
{learnerState.history.length === 0 ? <div className="muted">No evidence recorded yet.</div> : (
|
||||
<ul>
|
||||
{learnerState.history.slice().reverse().map((item, idx) => (
|
||||
<li key={idx}>{item.concept_id} · score {item.score.toFixed(2)} · confidence hint {item.confidence_hint.toFixed(2)}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
|
@ -207,27 +161,31 @@ export default function App() {
|
|||
<div className="progress-bar"><div className="progress-fill" style={{ width: `${progress}%` }} /></div>
|
||||
<div className="muted">{progress}%</div>
|
||||
</div>
|
||||
<div className={`readiness-box ${readiness.ready ? "ready" : ""}`}>
|
||||
<strong>{readiness.ready ? "Usable expertise threshold met" : "Still building toward usable expertise"}</strong>
|
||||
<div className="muted">Mastered concepts: {readiness.mastered}</div>
|
||||
<div className="muted">Average score: {readiness.avgScore.toFixed(2)}</div>
|
||||
<div className="muted">Average confidence: {readiness.avgConfidence.toFixed(2)}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h2>Evidence log</h2>
|
||||
{learnerState.history.length === 0 ? <div className="muted">No evidence recorded yet.</div> : (
|
||||
<ul>
|
||||
{learnerState.history.slice().reverse().map((item, idx) => (
|
||||
<li key={idx}>{item.concept_id} · score {item.score.toFixed(2)} · hint {item.confidence_hint.toFixed(2)} · {item.kind}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<h2>Milestones and rewards</h2>
|
||||
{lastReward ? <div className="reward-banner">{lastReward}</div> : null}
|
||||
<ul>{milestones.map((m, idx) => <li key={idx}>{m}</li>)}</ul>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h2>Compliance</h2>
|
||||
<h2>Source attribution and compliance</h2>
|
||||
<div className="compliance-grid">
|
||||
<div><strong>Sources</strong><br />{domain.compliance.sources}</div>
|
||||
<div><strong>Attribution</strong><br />{domain.compliance.attributionRequired ? "required" : "not required"}</div>
|
||||
<div><strong>Share-alike</strong><br />{domain.compliance.shareAlikeRequired ? "yes" : "no"}</div>
|
||||
<div><strong>Noncommercial</strong><br />{domain.compliance.noncommercialOnly ? "yes" : "no"}</div>
|
||||
</div>
|
||||
<div className="flag-row">
|
||||
{domain.compliance.flags.length ? domain.compliance.flags.map((f) => <span className="flag" key={f}>{f}</span>) : <span className="muted">No extra flags</span>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -1,62 +1,25 @@
|
|||
const API = "http://127.0.0.1:8011/api";
|
||||
|
||||
export async function login(username, password) {
|
||||
const res = await fetch(`${API}/login`, {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
if (!res.ok) throw new Error("Login failed");
|
||||
export async function fetchPacks() {
|
||||
const res = await fetch(`${API}/packs`);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
function authHeaders(token) {
|
||||
return { "Content-Type": "application/json", "Authorization": `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function fetchPacks(token) {
|
||||
const res = await fetch(`${API}/packs`, { headers: authHeaders(token) });
|
||||
export async function fetchLearnerState(learnerId) {
|
||||
const res = await fetch(`${API}/learners/${learnerId}/state`);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function createLearner(token, learnerId, displayName) {
|
||||
const res = await fetch(`${API}/learners`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(token),
|
||||
body: JSON.stringify({ learner_id: learnerId, display_name: displayName })
|
||||
});
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function fetchLearnerState(token, learnerId) {
|
||||
const res = await fetch(`${API}/learners/${learnerId}/state`, { headers: authHeaders(token) });
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function postEvidence(token, learnerId, event) {
|
||||
export async function postEvidence(learnerId, event) {
|
||||
const res = await fetch(`${API}/learners/${learnerId}/evidence`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(token),
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(event)
|
||||
});
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function fetchRecommendations(token, learnerId, packId) {
|
||||
const res = await fetch(`${API}/learners/${learnerId}/recommendations/${packId}`, { headers: authHeaders(token) });
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function submitEvaluatorJob(token, learnerId, payload) {
|
||||
const res = await fetch(`${API}/learners/${learnerId}/evaluator-jobs`, {
|
||||
method: "POST",
|
||||
headers: authHeaders(token),
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function fetchEvaluatorJob(token, jobId) {
|
||||
const res = await fetch(`${API}/evaluator-jobs/${jobId}`, { headers: authHeaders(token) });
|
||||
export async function fetchRecommendations(learnerId, packId) {
|
||||
const res = await fetch(`${API}/learners/${learnerId}/recommendations/${packId}`);
|
||||
return await res.json();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,3 +22,27 @@ export function progressPercent(state, domain) {
|
|||
const mastered = domain.concepts.filter((c) => conceptStatus(state, c) === "mastered").length;
|
||||
return Math.round((mastered / total) * 100);
|
||||
}
|
||||
|
||||
export function milestoneMessages(state, domain) {
|
||||
const msgs = [];
|
||||
for (const concept of domain.concepts) {
|
||||
if (conceptStatus(state, concept) === "mastered") msgs.push(`${concept.title} mastered`);
|
||||
}
|
||||
if (msgs.length === 0) msgs.push("Complete your first guided exercise to earn a visible mastery marker");
|
||||
return msgs;
|
||||
}
|
||||
|
||||
export function claimReadiness(state, domain, minScore = 0.75, minConfidence = 0.60) {
|
||||
const records = domain.concepts
|
||||
.map((c) => getRecord(state, c.id, c.masteryDimension || "mastery"))
|
||||
.filter(Boolean);
|
||||
const mastered = records.filter((r) => r.score >= minScore && r.confidence >= minConfidence).length;
|
||||
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;
|
||||
return {
|
||||
ready: mastered >= Math.max(1, domain.concepts.length - 1) && avgScore >= minScore && avgConfidence >= minConfidence,
|
||||
mastered,
|
||||
avgScore,
|
||||
avgConfidence
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,18 +21,13 @@ body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: var(--b
|
|||
.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; }
|
||||
.narrow { max-width: 420px; margin: 60px auto; }
|
||||
label { display: block; font-weight: 600; margin-bottom: 12px; }
|
||||
input { width: 100%; margin-top: 6px; border: 1px solid var(--border); border-radius: 10px; padding: 10px; font: inherit; }
|
||||
.muted { color: var(--muted); }
|
||||
.error { color: #b42318; margin-top: 10px; }
|
||||
.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 { background: var(--accent); color: white; border: none; }
|
||||
.button-row { display: flex; gap: 10px; }
|
||||
.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; }
|
||||
|
|
@ -43,7 +38,12 @@ button { border: 1px solid var(--border); background: white; border-radius: 12px
|
|||
.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; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue