Apply ZIP update: 175-didactopus-backend-api-prototype.zip [2026-03-14T13:20:13]

This commit is contained in:
welsberr 2026-03-14 13:29:55 -04:00
parent d851515201
commit efafa2f289
10 changed files with 122 additions and 285 deletions

View File

@ -10,14 +10,11 @@ dependencies = [
"pydantic>=2.7", "pydantic>=2.7",
"pyyaml>=6.0", "pyyaml>=6.0",
"fastapi>=0.115", "fastapi>=0.115",
"uvicorn>=0.30", "uvicorn>=0.30"
"sqlalchemy>=2.0",
"passlib[bcrypt]>=1.7"
] ]
[project.scripts] [project.scripts]
didactopus-api = "didactopus.api:main" didactopus-api = "didactopus.api:main"
didactopus-seed-db = "didactopus.seed:main"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]

View File

@ -1,19 +1,14 @@
from __future__ import annotations 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 from fastapi.middleware.cors import CORSMiddleware
import uvicorn import uvicorn
from .config import load_settings from .models import LearnerState, EvidenceEvent
from .db import Base, engine from .storage import FileStorage
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 .engine import apply_evidence, recommend_next from .engine import apply_evidence, recommend_next
settings = load_settings() BASE_DIR = Path(__file__).resolve().parents[2] / "data"
Base.metadata.create_all(bind=engine) storage = FileStorage(BASE_DIR)
app = FastAPI(title="Didactopus API Prototype") app = FastAPI(title="Didactopus API Prototype")
app.add_middleware( app.add_middleware(
@ -24,112 +19,44 @@ app.add_middleware(
allow_headers=["*"], 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") @app.get("/api/packs")
def api_list_packs(user = Depends(current_user)): def list_packs():
return [p.model_dump() for p in list_packs()] return [p.model_dump() for p in storage.list_packs()]
@app.get("/api/packs/{pack_id}") @app.get("/api/packs/{pack_id}")
def api_get_pack(pack_id: str, user = Depends(current_user)): def get_pack(pack_id: str):
pack = get_pack(pack_id) pack = storage.get_pack(pack_id)
if pack is None: if pack is None:
raise HTTPException(status_code=404, detail="Pack not found") raise HTTPException(status_code=404, detail="Pack not found")
return pack.model_dump() 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") @app.get("/api/learners/{learner_id}/state")
def api_get_learner_state(learner_id: str, user = Depends(current_user)): def get_learner_state(learner_id: str):
ensure_learner_access(user, learner_id) return storage.get_learner_state(learner_id).model_dump()
return load_learner_state(learner_id).model_dump()
@app.put("/api/learners/{learner_id}/state") @app.put("/api/learners/{learner_id}/state")
def api_put_learner_state(learner_id: str, state: LearnerState, user = Depends(current_user)): def put_learner_state(learner_id: str, state: LearnerState):
ensure_learner_access(user, learner_id)
if learner_id != state.learner_id: if learner_id != state.learner_id:
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 storage.save_learner_state(state).model_dump()
@app.post("/api/learners/{learner_id}/evidence") @app.post("/api/learners/{learner_id}/evidence")
def api_post_evidence(learner_id: str, event: EvidenceEvent, user = Depends(current_user)): def post_evidence(learner_id: str, event: EvidenceEvent):
ensure_learner_access(user, learner_id) state = storage.get_learner_state(learner_id)
state = load_learner_state(learner_id)
state = apply_evidence(state, event) state = apply_evidence(state, event)
save_learner_state(state) storage.save_learner_state(state)
return state.model_dump() return state.model_dump()
@app.get("/api/learners/{learner_id}/recommendations/{pack_id}") @app.get("/api/learners/{learner_id}/recommendations/{pack_id}")
def api_get_recommendations(learner_id: str, pack_id: str, user = Depends(current_user)): def get_recommendations(learner_id: str, pack_id: str):
ensure_learner_access(user, learner_id) state = storage.get_learner_state(learner_id)
state = load_learner_state(learner_id) pack = storage.get_pack(pack_id)
pack = get_pack(pack_id)
if pack is None: if pack is None:
raise HTTPException(status_code=404, detail="Pack not found") raise HTTPException(status_code=404, detail="Pack not found")
return {"cards": recommend_next(state, pack)} 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(): def main():
uvicorn.run(app, host=settings.host, port=settings.port) uvicorn.run(app, host="127.0.0.1", port=8011)
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -48,28 +48,3 @@ 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 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 = ""

View File

@ -2,16 +2,9 @@ from pathlib import Path
from didactopus.pack_to_frontend import convert_pack from didactopus.pack_to_frontend import convert_pack
def test_convert_pack(tmp_path: Path): def test_convert_pack(tmp_path: Path):
(tmp_path / "pack.yaml").write_text( (tmp_path / "pack.yaml").write_text("name: p1\ndisplay_name: Pack 1\ndescription: Demo pack\n", encoding="utf-8")
"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 / "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) payload = convert_pack(tmp_path)
assert payload["id"] == "p1" assert payload["id"] == "p1"
assert payload["concepts"][0]["id"] == "c1" assert payload["concepts"][0]["id"] == "c1"

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 Auth API UI</title> <title>Didactopus API UI</title>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</head> </head>
<body> <body>

View File

@ -1,5 +1,5 @@
{ {
"name": "didactopus-auth-api-ui", "name": "didactopus-api-ui",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",

View File

@ -1,38 +1,16 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { login, fetchPacks, createLearner, fetchLearnerState, fetchRecommendations, postEvidence, submitEvaluatorJob, fetchEvaluatorJob } from "./api"; import { fetchPacks, fetchLearnerState, fetchRecommendations, postEvidence } from "./api";
import { buildMasteryMap, progressPercent } from "./localEngine"; import { buildMasteryMap, progressPercent, milestoneMessages, claimReadiness } 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>
);
}
function DomainCard({ domain, selected, onSelect }) { function DomainCard({ domain, selected, onSelect }) {
return ( return (
<button className={`domain-card ${selected ? "selected" : ""}`} onClick={() => onSelect(domain.id)}> <button className={`domain-card ${selected ? "selected" : ""}`} onClick={() => onSelect(domain.id)}>
<div className="domain-title">{domain.title}</div> <div className="domain-title">{domain.title}</div>
<div className="domain-subtitle">{domain.subtitle}</div> <div className="domain-subtitle">{domain.subtitle}</div>
<div className="domain-meta">
<span>{domain.level}</span>
<span>{domain.concepts.length} concepts</span>
</div>
</button> </button>
); );
} }
@ -52,51 +30,49 @@ function NextStepCard({ step, onSimulate }) {
<summary>Why this is recommended</summary> <summary>Why this is recommended</summary>
<ul>{step.why.map((item, idx) => <li key={idx}>{item}</li>)}</ul> <ul>{step.why.map((item, idx) => <li key={idx}>{item}</li>)}</ul>
</details> </details>
<div className="button-row"> <button className="primary" onClick={() => onSimulate(step)}>Simulate completing this step</button>
<button className="primary" onClick={() => onSimulate(step)}>Simulate step</button>
</div>
</div> </div>
); );
} }
export default function App() { export default function App() {
const [token, setToken] = useState("");
const [username, setUsername] = useState("");
const [packs, setPacks] = useState([]); const [packs, setPacks] = useState([]);
const [selectedDomainId, setSelectedDomainId] = useState(""); const [selectedDomainId, setSelectedDomainId] = useState("");
const [learnerId, setLearnerId] = useState("wesley-learner"); const [learnerName, setLearnerName] = useState("Wesley");
const [learnerState, setLearnerState] = useState(null); const [learnerState, setLearnerState] = useState(null);
const [cards, setCards] = useState([]); const [cards, setCards] = useState([]);
const [jobStatus, setJobStatus] = useState(null); const [lastReward, setLastReward] = useState("");
const learnerId = "wesley-demo";
async function handleLogin(user, pass) { useEffect(() => {
const auth = await login(user, pass); async function load() {
setToken(auth.token); const loadedPacks = await fetchPacks();
setUsername(auth.username);
await createLearner(auth.token, learnerId, learnerId);
const loadedPacks = await fetchPacks(auth.token);
setPacks(loadedPacks); setPacks(loadedPacks);
setSelectedDomainId(loadedPacks[0]?.id || ""); const first = loadedPacks[0]?.id || "";
const state = await fetchLearnerState(auth.token, learnerId); setSelectedDomainId(first);
const state = await fetchLearnerState(learnerId);
setLearnerState(state); setLearnerState(state);
return auth;
} }
load();
}, []);
useEffect(() => { useEffect(() => {
async function loadCards() { async function loadCards() {
if (!token || !selectedDomainId) return; if (!selectedDomainId) return;
const data = await fetchRecommendations(token, learnerId, selectedDomainId); const data = await fetchRecommendations(learnerId, selectedDomainId);
setCards(data.cards || []); setCards(data.cards || []);
} }
loadCards(); if (selectedDomainId) loadCards();
}, [token, learnerId, selectedDomainId, learnerState]); }, [selectedDomainId, learnerState]);
const domain = useMemo(() => packs.find((d) => d.id === selectedDomainId) || null, [packs, selectedDomainId]); const domain = useMemo(() => packs.find((d) => d.id === selectedDomainId) || null, [packs, selectedDomainId]);
const masteryMap = useMemo(() => domain && learnerState ? buildMasteryMap(learnerState, domain) : [], [learnerState, domain]); const masteryMap = useMemo(() => domain && learnerState ? buildMasteryMap(learnerState, domain) : [], [learnerState, domain]);
const progress = useMemo(() => domain && learnerState ? progressPercent(learnerState, domain) : 0, [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) { async function simulateStep(step) {
const nextState = await postEvidence(token, learnerId, { const nextState = await postEvidence(learnerId, {
concept_id: step.conceptId, concept_id: step.conceptId,
dimension: "mastery", dimension: "mastery",
score: step.scoreHint, score: step.scoreHint,
@ -106,32 +82,11 @@ export default function App() {
source_id: `ui-${step.id}` source_id: `ui-${step.id}`
}); });
setLearnerState(nextState); setLearnerState(nextState);
} setLastReward(step.reward);
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>;
} }
if (!domain || !learnerState) { 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 ( return (
@ -139,12 +94,13 @@ export default function App() {
<header className="hero"> <header className="hero">
<div> <div>
<h1>Didactopus learner prototype</h1> <h1>Didactopus learner prototype</h1>
<p>Authenticated multi-user scaffold with DB-backed state and async evaluator jobs.</p> <p>Backend-driven pack registry, learner-state persistence, and evaluator-style evidence ingestion.</p>
<div className="muted">Signed in as {username}</div>
</div> </div>
<div className="hero-controls"> <div className="hero-controls">
<label>Learner ID<input value={learnerId} onChange={(e) => setLearnerId(e.target.value)} /></label> <label>
<button onClick={submitDemoEvaluator}>Submit demo evaluator job</button> Learner name
<input value={learnerName} onChange={(e) => setLearnerName(e.target.value)} />
</label>
</div> </div>
</header> </header>
@ -155,9 +111,10 @@ export default function App() {
<main className="layout"> <main className="layout">
<div className="left-col"> <div className="left-col">
<section className="card"> <section className="card">
<h2>Onboarding</h2> <h2>First-session onboarding</h2>
<h3>{domain.onboarding.headline}</h3> <h3>{domain.onboarding.headline}</h3>
<p>{domain.onboarding.body}</p> <p>{domain.onboarding.body}</p>
<p className="muted">Learner: {learnerName}</p>
<ul>{(domain.onboarding.checklist || []).map((item, idx) => <li key={idx}>{item}</li>)}</ul> <ul>{(domain.onboarding.checklist || []).map((item, idx) => <li key={idx}>{item}</li>)}</ul>
</section> </section>
@ -174,16 +131,13 @@ export default function App() {
</section> </section>
<section className="card"> <section className="card">
<h2>Async evaluator job</h2> <h2>Evidence log</h2>
{jobStatus ? ( {learnerState.history.length === 0 ? <div className="muted">No evidence recorded yet.</div> : (
<div> <ul>
<div><strong>Status:</strong> {jobStatus.status}</div> {learnerState.history.slice().reverse().map((item, idx) => (
<div><strong>Score:</strong> {jobStatus.result_score ?? "-"}</div> <li key={idx}>{item.concept_id} · score {item.score.toFixed(2)} · confidence hint {item.confidence_hint.toFixed(2)}</li>
<div><strong>Confidence hint:</strong> {jobStatus.result_confidence_hint ?? "-"}</div> ))}
<div className="muted">{jobStatus.result_notes || ""}</div> </ul>
</div>
) : (
<div className="muted">No evaluator job submitted yet.</div>
)} )}
</section> </section>
</div> </div>
@ -207,27 +161,31 @@ export default function App() {
<div className="progress-bar"><div className="progress-fill" style={{ width: `${progress}%` }} /></div> <div className="progress-bar"><div className="progress-fill" style={{ width: `${progress}%` }} /></div>
<div className="muted">{progress}%</div> <div className="muted">{progress}%</div>
</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>
<section className="card"> <section className="card">
<h2>Evidence log</h2> <h2>Milestones and rewards</h2>
{learnerState.history.length === 0 ? <div className="muted">No evidence recorded yet.</div> : ( {lastReward ? <div className="reward-banner">{lastReward}</div> : null}
<ul> <ul>{milestones.map((m, idx) => <li key={idx}>{m}</li>)}</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>
)}
</section> </section>
<section className="card"> <section className="card">
<h2>Compliance</h2> <h2>Source attribution and compliance</h2>
<div className="compliance-grid"> <div className="compliance-grid">
<div><strong>Sources</strong><br />{domain.compliance.sources}</div> <div><strong>Sources</strong><br />{domain.compliance.sources}</div>
<div><strong>Attribution</strong><br />{domain.compliance.attributionRequired ? "required" : "not required"}</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>Share-alike</strong><br />{domain.compliance.shareAlikeRequired ? "yes" : "no"}</div>
<div><strong>Noncommercial</strong><br />{domain.compliance.noncommercialOnly ? "yes" : "no"}</div> <div><strong>Noncommercial</strong><br />{domain.compliance.noncommercialOnly ? "yes" : "no"}</div>
</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> </section>
</div> </div>
</main> </main>

View File

@ -1,62 +1,25 @@
const API = "http://127.0.0.1:8011/api"; const API = "http://127.0.0.1:8011/api";
export async function login(username, password) { export async function fetchPacks() {
const res = await fetch(`${API}/login`, { const res = await fetch(`${API}/packs`);
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ username, password })
});
if (!res.ok) throw new Error("Login failed");
return await res.json(); return await res.json();
} }
function authHeaders(token) { export async function fetchLearnerState(learnerId) {
return { "Content-Type": "application/json", "Authorization": `Bearer ${token}` }; const res = await fetch(`${API}/learners/${learnerId}/state`);
}
export async function fetchPacks(token) {
const res = await fetch(`${API}/packs`, { headers: authHeaders(token) });
return await res.json(); return await res.json();
} }
export async function createLearner(token, learnerId, displayName) { export async function postEvidence(learnerId, event) {
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) {
const res = await fetch(`${API}/learners/${learnerId}/evidence`, { const res = await fetch(`${API}/learners/${learnerId}/evidence`, {
method: "POST", method: "POST",
headers: authHeaders(token), headers: {"Content-Type": "application/json"},
body: JSON.stringify(event) body: JSON.stringify(event)
}); });
return await res.json(); return await res.json();
} }
export async function fetchRecommendations(token, learnerId, packId) { export async function fetchRecommendations(learnerId, packId) {
const res = await fetch(`${API}/learners/${learnerId}/recommendations/${packId}`, { headers: authHeaders(token) }); const res = await fetch(`${API}/learners/${learnerId}/recommendations/${packId}`);
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) });
return await res.json(); return await res.json();
} }

View File

@ -22,3 +22,27 @@ export function progressPercent(state, domain) {
const mastered = domain.concepts.filter((c) => conceptStatus(state, c) === "mastered").length; const mastered = domain.concepts.filter((c) => conceptStatus(state, c) === "mastered").length;
return Math.round((mastered / total) * 100); 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
};
}

View File

@ -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; } .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; } .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; } .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); } .muted { color: var(--muted); }
.error { color: #b42318; margin-top: 10px; }
.steps-stack { display: grid; gap: 14px; } .steps-stack { display: grid; gap: 14px; }
.step-card { border: 1px solid var(--border); border-radius: 16px; padding: 14px; background: #fcfdff; } .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; } .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; } .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; } button { border: 1px solid var(--border); background: white; border-radius: 12px; padding: 10px 14px; cursor: pointer; }
.primary { background: var(--accent); color: white; border: none; } .primary { margin-top: 10px; background: var(--accent); color: white; border: none; }
.button-row { display: flex; gap: 10px; }
.map-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; } .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 { border: 1px solid var(--border); border-radius: 16px; padding: 14px; }
.map-node.mastered { background: #eef9f0; } .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-wrap { margin-bottom: 14px; }
.progress-bar { width: 100%; height: 12px; border-radius: 999px; background: #e9edf4; overflow: hidden; margin: 8px 0; } .progress-bar { width: 100%; height: 12px; border-radius: 999px; background: #e9edf4; overflow: hidden; margin: 8px 0; }
.progress-fill { height: 100%; background: var(--accent); } .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; } .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); } details summary { cursor: pointer; color: var(--accent); }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.layout { grid-template-columns: 1fr; } .layout { grid-template-columns: 1fr; }