diff --git a/pyproject.toml b/pyproject.toml index 09093c2..b51b837 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/src/didactopus/api.py b/src/didactopus/api.py index b1de0a3..f0b3f59 100644 --- a/src/didactopus/api.py +++ b/src/didactopus/api.py @@ -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() diff --git a/src/didactopus/models.py b/src/didactopus/models.py index b48284f..2ea8228 100644 --- a/src/didactopus/models.py +++ b/src/didactopus/models.py @@ -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 = "" diff --git a/tests/test_pack_export.py b/tests/test_pack_export.py index 85670f5..0d0704f 100644 --- a/tests/test_pack_export.py +++ b/tests/test_pack_export.py @@ -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" diff --git a/webui/index.html b/webui/index.html index 0869131..0ef5c68 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3,7 +3,7 @@ - Didactopus Auth API UI + Didactopus API UI diff --git a/webui/package.json b/webui/package.json index 685fd94..7093a6c 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,5 +1,5 @@ { - "name": "didactopus-auth-api-ui", + "name": "didactopus-api-ui", "private": true, "version": "0.1.0", "type": "module", diff --git a/webui/src/App.jsx b/webui/src/App.jsx index 5bac568..769e4d0 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -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 ( -
-

Didactopus login

- - - - {error ?
{error}
: null} -
- ); -} +import { fetchPacks, fetchLearnerState, fetchRecommendations, postEvidence } from "./api"; +import { buildMasteryMap, progressPercent, milestoneMessages, claimReadiness } from "./localEngine"; function DomainCard({ domain, selected, onSelect }) { return ( ); } @@ -52,51 +30,49 @@ function NextStepCard({ step, onSimulate }) { Why this is recommended -
- -
+ ); } 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
; + setLastReward(step.reward); } if (!domain || !learnerState) { - return
Loading authenticated learner view...
; + return
Loading backend data...
; } return ( @@ -139,12 +94,13 @@ export default function App() {

Didactopus learner prototype

-

Authenticated multi-user scaffold with DB-backed state and async evaluator jobs.

-
Signed in as {username}
+

Backend-driven pack registry, learner-state persistence, and evaluator-style evidence ingestion.

- - +
@@ -155,9 +111,10 @@ export default function App() {
-

Onboarding

+

First-session onboarding

{domain.onboarding.headline}

{domain.onboarding.body}

+

Learner: {learnerName}

    {(domain.onboarding.checklist || []).map((item, idx) =>
  • {item}
  • )}
@@ -174,16 +131,13 @@ export default function App() {
-

Async evaluator job

- {jobStatus ? ( -
-
Status: {jobStatus.status}
-
Score: {jobStatus.result_score ?? "-"}
-
Confidence hint: {jobStatus.result_confidence_hint ?? "-"}
-
{jobStatus.result_notes || ""}
-
- ) : ( -
No evaluator job submitted yet.
+

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)}
  • + ))} +
)}
@@ -207,27 +161,31 @@ export default function App() {
{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)}
+
-

Evidence log

- {learnerState.history.length === 0 ?
No evidence recorded yet.
: ( - - )} +

Milestones and rewards

+ {lastReward ?
{lastReward}
: null} +
-

Compliance

+

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/api.js b/webui/src/api.js index 7d68e72..0091ccf 100644 --- a/webui/src/api.js +++ b/webui/src/api.js @@ -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(); } diff --git a/webui/src/localEngine.js b/webui/src/localEngine.js index 6dcb20e..f331af6 100644 --- a/webui/src/localEngine.js +++ b/webui/src/localEngine.js @@ -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 + }; +} diff --git a/webui/src/styles.css b/webui/src/styles.css index 1bde8d8..119d2a9 100644 --- a/webui/src/styles.css +++ b/webui/src/styles.css @@ -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; }