diff --git a/pyproject.toml b/pyproject.toml index 56e83a0..09093c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,10 +6,18 @@ build-backend = "setuptools.build_meta" name = "didactopus" version = "0.1.0" requires-python = ">=3.10" -dependencies = ["pydantic>=2.7", "pyyaml>=6.0"] +dependencies = [ + "pydantic>=2.7", + "pyyaml>=6.0", + "fastapi>=0.115", + "uvicorn>=0.30", + "sqlalchemy>=2.0", + "passlib[bcrypt]>=1.7" +] [project.scripts] -didactopus-build-attribution = "didactopus.attribution_builder:main" +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 9ee62c4..b1de0a3 100644 --- a/src/didactopus/api.py +++ b/src/didactopus/api.py @@ -2,78 +2,77 @@ from __future__ import annotations from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks from fastapi.middleware.cors import CORSMiddleware import uvicorn +from .config import load_settings from .db import Base, engine -from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, MediaRenderRequest +from .models import LoginRequest, LoginResponse, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus from .repository import ( - authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token, - list_packs_for_user, get_pack, get_pack_row, create_learner, learner_owned_by_user, load_learner_state, save_learner_state, - create_render_job, update_render_job, list_render_jobs, list_artifacts + 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 .auth import issue_access_token, issue_refresh_token, decode_token, new_token_id -from .engine import build_graph_frames, stable_layout -from .worker import process_render_job +from .engine import apply_evidence, recommend_next +settings = load_settings() Base.metadata.create_all(bind=engine) app = FastAPI(title="Didactopus API Prototype") -app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) def current_user(authorization: str = Header(default="")): token = authorization.removeprefix("Bearer ").strip() - payload = decode_token(token) if token else None - if not payload or payload.get("kind") != "access": - raise HTTPException(status_code=401, detail="Unauthorized") - user = get_user_by_id(int(payload["sub"])) - if user is None or not user.is_active: + 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 user.role == "admin": - return if not learner_owned_by_user(user.id, learner_id): raise HTTPException(status_code=403, detail="Learner not accessible by this user") -def ensure_pack_access(user, pack_id: str): - row = get_pack_row(pack_id) - if row is None: - raise HTTPException(status_code=404, detail="Pack not found") - if user.role == "admin": - return row - if row.policy_lane == "community": - return row - if row.owner_user_id == user.id: - return row - raise HTTPException(status_code=403, detail="Pack 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=TokenPair) +@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") - token_id = new_token_id() - store_refresh_token(user.id, token_id) - return TokenPair(access_token=issue_access_token(user.id, user.username, user.role), refresh_token=issue_refresh_token(user.id, user.username, user.role, token_id), username=user.username, role=user.role) - -@app.post("/api/refresh", response_model=TokenPair) -def refresh(payload: RefreshRequest): - data = decode_token(payload.refresh_token) - if not data or data.get("kind") != "refresh": - raise HTTPException(status_code=401, detail="Invalid refresh token") - token_id = data.get("jti") - if not token_id or not refresh_token_active(token_id): - raise HTTPException(status_code=401, detail="Refresh token inactive") - user = get_user_by_id(int(data["sub"])) - if user is None: - raise HTTPException(status_code=401, detail="User not found") - revoke_refresh_token(token_id) - new_jti = new_token_id() - store_refresh_token(user.id, new_jti) - return TokenPair(access_token=issue_access_token(user.id, user.username, user.role), refresh_token=issue_refresh_token(user.id, user.username, user.role, new_jti), username=user.username, role=user.role) + 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_for_user(user.id, include_unpublished=(user.role == "admin"))] + return [p.model_dump() for p in list_packs()] + +@app.get("/api/packs/{pack_id}") +def api_get_pack(pack_id: str, user = Depends(current_user)): + pack = 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)): @@ -92,54 +91,45 @@ def api_put_learner_state(learner_id: str, state: LearnerState, user = Depends(c raise HTTPException(status_code=400, detail="Learner ID mismatch") return save_learner_state(state).model_dump() -@app.get("/api/packs/{pack_id}/layout") -def api_pack_layout(pack_id: str, user = Depends(current_user)): - ensure_pack_access(user, pack_id) - pack = get_pack(pack_id) - return {"pack_id": pack_id, "layout": stable_layout(pack)} if pack else {"pack_id": pack_id, "layout": {}} - -@app.get("/api/learners/{learner_id}/graph-animation/{pack_id}") -def api_graph_animation(learner_id: str, pack_id: str, user = Depends(current_user)): +@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) - ensure_pack_access(user, pack_id) - pack = get_pack(pack_id) state = load_learner_state(learner_id) - frames = build_graph_frames(state, pack) - return { - "learner_id": learner_id, - "pack_id": pack_id, - "pack_title": pack.title if pack else "", - "frames": frames, - "concepts": [{"id": c.id, "title": c.title, "prerequisites": c.prerequisites, "cross_pack_links": [l.model_dump() for l in c.cross_pack_links]} for c in pack.concepts] if pack else [], - } + state = apply_evidence(state, event) + save_learner_state(state) + return state.model_dump() -@app.post("/api/learners/{learner_id}/render-jobs/{pack_id}") -def api_render_job(learner_id: str, pack_id: str, payload: MediaRenderRequest, background_tasks: BackgroundTasks, user = Depends(current_user)): +@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) - ensure_pack_access(user, pack_id) - pack = get_pack(pack_id) state = load_learner_state(learner_id) - animation = { - "learner_id": learner_id, - "pack_id": pack_id, - "pack_title": pack.title if pack else "", - "frames": build_graph_frames(state, pack), - } - job_id = create_render_job(learner_id, pack_id, payload.format, payload.fps, payload.theme) - background_tasks.add_task(process_render_job, job_id, learner_id, pack_id, payload.format, payload.fps, payload.theme, animation) - return {"job_id": job_id, "status": "queued"} + pack = get_pack(pack_id) + if pack is None: + raise HTTPException(status_code=404, detail="Pack not found") + return {"cards": recommend_next(state, pack)} -@app.get("/api/render-jobs") -def api_list_render_jobs(learner_id: str | None = None, user = Depends(current_user)): - if learner_id: - ensure_learner_access(user, learner_id) - return list_render_jobs(learner_id) +@app.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/artifacts") -def api_list_artifacts(learner_id: str | None = None, user = Depends(current_user)): - if learner_id: - ensure_learner_access(user, learner_id) - return list_artifacts(learner_id) +@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="127.0.0.1", port=8011) + uvicorn.run(app, host=settings.host, port=settings.port) + +if __name__ == "__main__": + main() diff --git a/src/didactopus/auth.py b/src/didactopus/auth.py index 54745ac..10ebe87 100644 --- a/src/didactopus/auth.py +++ b/src/didactopus/auth.py @@ -1,12 +1,8 @@ from __future__ import annotations -from datetime import datetime, timedelta, timezone -from jose import jwt, JWTError -from passlib.context import CryptContext import secrets -from .config import load_settings +from passlib.context import CryptContext pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") -settings = load_settings() def hash_password(password: str) -> str: return pwd_context.hash(password) @@ -14,22 +10,5 @@ def hash_password(password: str) -> str: def verify_password(password: str, password_hash: str) -> bool: return pwd_context.verify(password, password_hash) -def _encode_token(payload: dict, expires_delta: timedelta) -> str: - to_encode = dict(payload) - to_encode["exp"] = datetime.now(timezone.utc) + expires_delta - return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm) - -def issue_access_token(user_id: int, username: str, role: str) -> str: - return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "access"}, timedelta(minutes=30)) - -def issue_refresh_token(user_id: int, username: str, role: str, token_id: str) -> str: - return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "refresh", "jti": token_id}, timedelta(days=14)) - -def decode_token(token: str) -> dict | None: - try: - return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]) - except JWTError: - return None - -def new_token_id() -> str: +def issue_token() -> str: return secrets.token_urlsafe(24) diff --git a/src/didactopus/config.py b/src/didactopus/config.py index 1f71733..c9684d1 100644 --- a/src/didactopus/config.py +++ b/src/didactopus/config.py @@ -1,13 +1,10 @@ -from __future__ import annotations -import os +from pathlib import Path from pydantic import BaseModel class Settings(BaseModel): - database_url: str = os.getenv("DIDACTOPUS_DATABASE_URL", "sqlite+pysqlite:///:memory:") - host: str = os.getenv("DIDACTOPUS_HOST", "127.0.0.1") - port: int = int(os.getenv("DIDACTOPUS_PORT", "8011")) - jwt_secret: str = os.getenv("DIDACTOPUS_JWT_SECRET", "change-me") - jwt_algorithm: str = "HS256" + database_url: str = "sqlite:///./didactopus.db" + host: str = "127.0.0.1" + port: int = 8011 def load_settings() -> Settings: return Settings() diff --git a/src/didactopus/db.py b/src/didactopus/db.py index 771e336..e0973b6 100644 --- a/src/didactopus/db.py +++ b/src/didactopus/db.py @@ -3,6 +3,7 @@ from sqlalchemy.orm import declarative_base, sessionmaker from .config import load_settings settings = load_settings() -engine = create_engine(settings.database_url, future=True) +connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} +engine = create_engine(settings.database_url, future=True, connect_args=connect_args) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) Base = declarative_base() diff --git a/src/didactopus/engine.py b/src/didactopus/engine.py index 6b7c236..d888035 100644 --- a/src/didactopus/engine.py +++ b/src/didactopus/engine.py @@ -1,110 +1,97 @@ from __future__ import annotations -from collections import defaultdict -from .models import LearnerState, PackData +from .models import LearnerState, EvidenceEvent, MasteryRecord, PackData -def concept_depths(pack: PackData) -> dict[str, int]: - concept_map = {c.id: c for c in pack.concepts} - memo = {} - def depth(cid: str) -> int: - if cid in memo: - return memo[cid] - c = concept_map[cid] - if not c.prerequisites: - memo[cid] = 0 - else: - memo[cid] = 1 + max(depth(pid) for pid in c.prerequisites if pid in concept_map) - return memo[cid] - for cid in concept_map: - depth(cid) - return memo +def get_record(state: LearnerState, concept_id: str, dimension: str = "mastery") -> MasteryRecord | None: + for rec in state.records: + if rec.concept_id == concept_id and rec.dimension == dimension: + return rec + return None -def stable_layout(pack: PackData, width: int = 900, height: int = 520): - depths = concept_depths(pack) - layers = defaultdict(list) - for c in pack.concepts: - layers[depths.get(c.id, 0)].append(c) - positions = {} - max_depth = max(layers.keys()) if layers else 0 - for d in sorted(layers): - nodes = sorted(layers[d], key=lambda c: c.id) - y = 90 + d * ((height - 160) / max(1, max_depth)) - for idx, node in enumerate(nodes): - if node.position is not None: - positions[node.id] = {"x": node.position.x, "y": node.position.y, "source": "pack_authored"} - else: - spacing = width / (len(nodes) + 1) - x = spacing * (idx + 1) - positions[node.id] = {"x": x, "y": y, "source": "auto_layered"} - return positions +def apply_evidence( + state: LearnerState, + event: EvidenceEvent, + decay: float = 0.05, + reinforcement: float = 0.25, +) -> LearnerState: + rec = get_record(state, event.concept_id, event.dimension) + if rec is None: + rec = MasteryRecord( + concept_id=event.concept_id, + dimension=event.dimension, + score=0.0, + confidence=0.0, + evidence_count=0, + last_updated=event.timestamp, + ) + state.records.append(rec) -def prereqs_satisfied(scores: dict[str, float], concept, min_score: float = 0.65) -> bool: + weight = max(0.05, min(1.0, event.confidence_hint)) + rec.score = ((rec.score * rec.evidence_count) + (event.score * weight)) / max(1, rec.evidence_count + 1) + rec.confidence = min( + 1.0, + max(0.0, rec.confidence * (1.0 - decay) + reinforcement * weight + 0.10 * max(0.0, min(1.0, event.score))), + ) + rec.evidence_count += 1 + rec.last_updated = event.timestamp + state.history.append(event) + return state + +def prereqs_satisfied(state: LearnerState, concept, min_score: float = 0.65, min_confidence: float = 0.45) -> bool: for pid in concept.prerequisites: - if scores.get(pid, 0.0) < min_score: + rec = get_record(state, pid, concept.masteryDimension) + if rec is None or rec.score < min_score or rec.confidence < min_confidence: return False return True -def concept_status(scores: dict[str, float], concept, min_score: float = 0.65) -> str: - score = scores.get(concept.id, 0.0) - if score >= min_score: +def concept_status(state: LearnerState, concept, min_score: float = 0.65, min_confidence: float = 0.45) -> str: + rec = get_record(state, concept.id, concept.masteryDimension) + if rec and rec.score >= min_score and rec.confidence >= min_confidence: return "mastered" - if prereqs_satisfied(scores, concept, min_score): - return "active" if score > 0 else "available" + if prereqs_satisfied(state, concept, min_score, min_confidence): + return "active" if rec else "available" return "locked" -def build_graph_frames(state: LearnerState, pack: PackData): - concepts = {c.id: c for c in pack.concepts} - layout = stable_layout(pack) - scores = {c.id: 0.0 for c in pack.concepts} - frames = [] - history = sorted(state.history, key=lambda x: x.timestamp) - static_edges = [{"source": pre, "target": c.id, "kind": "prerequisite"} for c in pack.concepts for pre in c.prerequisites] - static_cross = [{ - "source": c.id, - "target_pack_id": link.target_pack_id, - "target_concept_id": link.target_concept_id, - "relationship": link.relationship, - "kind": "cross_pack" - } for c in pack.concepts for link in c.cross_pack_links] - for idx, ev in enumerate(history): - if ev.concept_id in scores: - scores[ev.concept_id] = ev.score - nodes = [] - for cid, concept in concepts.items(): - score = scores.get(cid, 0.0) - status = concept_status(scores, concept) - pos = layout[cid] - nodes.append({ - "id": cid, - "title": concept.title, - "score": score, - "status": status, - "size": 20 + int(score * 30), - "x": pos["x"], - "y": pos["y"], - "layout_source": pos["source"], +def recommend_next(state: LearnerState, pack: PackData) -> list[dict]: + cards = [] + for concept in pack.concepts: + status = concept_status(state, concept) + rec = get_record(state, concept.id, concept.masteryDimension) + if status in {"available", "active"}: + cards.append({ + "id": concept.id, + "title": f"Work on {concept.title}", + "minutes": 15 if status == "available" else 10, + "reason": ( + "Prerequisites are satisfied, so this is the best next unlock." + if status == "available" + else "You have started this concept, but mastery is not yet secure." + ), + "why": [ + "Prerequisite check passed", + f"Current score: {rec.score:.2f}" if rec else "No evidence recorded yet", + f"Current confidence: {rec.confidence:.2f}" if rec else "Confidence starts after your first exercise", + ], + "reward": concept.exerciseReward or f"{concept.title} progress recorded", + "conceptId": concept.id, + "scoreHint": 0.82 if status == "available" else 0.76, + "confidenceHint": 0.72 if status == "available" else 0.55, }) - frames.append({ - "index": idx, - "timestamp": ev.timestamp, - "event_kind": ev.kind, - "focus_concept_id": ev.concept_id, - "nodes": nodes, - "edges": static_edges, - "cross_pack_links": static_cross, - }) - if not frames: - nodes = [] - for c in pack.concepts: - pos = layout[c.id] - nodes.append({ - "id": c.id, - "title": c.title, - "score": 0.0, - "status": "available" if not c.prerequisites else "locked", - "size": 20, - "x": pos["x"], - "y": pos["y"], - "layout_source": pos["source"], - }) - frames.append({"index": 0, "timestamp": "", "event_kind": "empty", "focus_concept_id": "", "nodes": nodes, "edges": static_edges, "cross_pack_links": static_cross}) - return frames + for rec in state.records: + if rec.dimension == "mastery" and rec.confidence < 0.40: + concept = next((c for c in pack.concepts if c.id == rec.concept_id), None) + if concept: + cards.append({ + "id": f"{concept.id}-reinforce", + "title": f"Reinforce {concept.title}", + "minutes": 8, + "reason": "Your score is promising, but confidence is still thin.", + "why": [ + f"Confidence {rec.confidence:.2f} is below reinforcement threshold", + "A small fresh exercise can stabilize recall", + ], + "reward": "Confidence ring grows", + "conceptId": concept.id, + "scoreHint": max(0.60, rec.score), + "confidenceHint": 0.30, + }) + return cards[:4] diff --git a/src/didactopus/models.py b/src/didactopus/models.py index fb9adf3..b48284f 100644 --- a/src/didactopus/models.py +++ b/src/didactopus/models.py @@ -1,29 +1,8 @@ from __future__ import annotations from pydantic import BaseModel, Field +from typing import Literal -class TokenPair(BaseModel): - access_token: str - refresh_token: str - token_type: str = "bearer" - username: str - role: str - -class LoginRequest(BaseModel): - username: str - password: str - -class RefreshRequest(BaseModel): - refresh_token: str - -class GraphPosition(BaseModel): - x: float - y: float - -class CrossPackLink(BaseModel): - source_concept_id: str - target_pack_id: str - target_concept_id: str - relationship: str = "related" +EvidenceKind = Literal["checkpoint", "project", "exercise", "review"] class PackConcept(BaseModel): id: str @@ -31,8 +10,13 @@ class PackConcept(BaseModel): prerequisites: list[str] = Field(default_factory=list) masteryDimension: str = "mastery" exerciseReward: str = "" - position: GraphPosition | None = None - cross_pack_links: list[CrossPackLink] = Field(default_factory=list) + +class PackCompliance(BaseModel): + sources: int = 0 + attributionRequired: bool = False + shareAlikeRequired: bool = False + noncommercialOnly: bool = False + flags: list[str] = Field(default_factory=list) class PackData(BaseModel): id: str @@ -41,11 +25,7 @@ class PackData(BaseModel): level: str = "novice-friendly" concepts: list[PackConcept] = Field(default_factory=list) onboarding: dict = Field(default_factory=dict) - compliance: dict = Field(default_factory=dict) - -class CreateLearnerRequest(BaseModel): - learner_id: str - display_name: str = "" + compliance: PackCompliance = Field(default_factory=PackCompliance) class MasteryRecord(BaseModel): concept_id: str @@ -61,7 +41,7 @@ class EvidenceEvent(BaseModel): score: float confidence_hint: float = 0.5 timestamp: str - kind: str = "exercise" + kind: EvidenceKind = "exercise" source_id: str = "" class LearnerState(BaseModel): @@ -69,9 +49,27 @@ class LearnerState(BaseModel): records: list[MasteryRecord] = Field(default_factory=list) history: list[EvidenceEvent] = Field(default_factory=list) -class MediaRenderRequest(BaseModel): +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 - format: str = "gif" - fps: int = 2 - theme: str = "default" + 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/src/didactopus/orm.py b/src/didactopus/orm.py index 3a0d355..6df7815 100644 --- a/src/didactopus/orm.py +++ b/src/didactopus/orm.py @@ -1,5 +1,5 @@ -from sqlalchemy import String, Integer, Float, ForeignKey, Text, Boolean -from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy import String, Integer, Float, Boolean, ForeignKey, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship from .db import Base class UserORM(Base): @@ -7,32 +7,22 @@ class UserORM(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True) username: Mapped[str] = mapped_column(String(100), unique=True, index=True) password_hash: Mapped[str] = mapped_column(String(255)) - role: Mapped[str] = mapped_column(String(50), default="learner") - is_active: Mapped[bool] = mapped_column(Boolean, default=True) - -class RefreshTokenORM(Base): - __tablename__ = "refresh_tokens" - id: Mapped[int] = mapped_column(Integer, primary_key=True) - user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) - token_id: Mapped[str] = mapped_column(String(255), unique=True, index=True) - is_revoked: Mapped[bool] = mapped_column(Boolean, default=False) + token: Mapped[str] = mapped_column(String(255), unique=True, index=True) class PackORM(Base): __tablename__ = "packs" id: Mapped[str] = mapped_column(String(100), primary_key=True) - owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) - policy_lane: Mapped[str] = mapped_column(String(50), default="personal") title: Mapped[str] = mapped_column(String(255)) subtitle: Mapped[str] = mapped_column(Text, default="") level: Mapped[str] = mapped_column(String(100), default="novice-friendly") data_json: Mapped[str] = mapped_column(Text) - is_published: Mapped[bool] = mapped_column(Boolean, default=False) class LearnerORM(Base): __tablename__ = "learners" id: Mapped[str] = mapped_column(String(100), primary_key=True) owner_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) display_name: Mapped[str] = mapped_column(String(255), default="") + owner = relationship("UserORM") class MasteryRecordORM(Base): __tablename__ = "mastery_records" @@ -44,6 +34,7 @@ class MasteryRecordORM(Base): confidence: Mapped[float] = mapped_column(Float, default=0.0) evidence_count: Mapped[int] = mapped_column(Integer, default=0) last_updated: Mapped[str] = mapped_column(String(100), default="") + learner = relationship("LearnerORM") class EvidenceEventORM(Base): __tablename__ = "evidence_events" @@ -56,30 +47,18 @@ class EvidenceEventORM(Base): timestamp: Mapped[str] = mapped_column(String(100), default="") kind: Mapped[str] = mapped_column(String(50), default="exercise") source_id: Mapped[str] = mapped_column(String(255), default="") + learner = relationship("LearnerORM") -class RenderJobORM(Base): - __tablename__ = "render_jobs" +class EvaluatorJobORM(Base): + __tablename__ = "evaluator_jobs" id: Mapped[int] = mapped_column(Integer, primary_key=True) - learner_id: Mapped[str] = mapped_column(String(100), index=True) - pack_id: Mapped[str] = mapped_column(String(100), index=True) - requested_format: Mapped[str] = mapped_column(String(20), default="gif") - fps: Mapped[int] = mapped_column(Integer, default=2) - theme: Mapped[str] = mapped_column(String(100), default="default") + learner_id: Mapped[str] = mapped_column(ForeignKey("learners.id"), index=True) + pack_id: Mapped[str] = mapped_column(ForeignKey("packs.id"), index=True) + concept_id: Mapped[str] = mapped_column(String(100), index=True) + submitted_text: Mapped[str] = mapped_column(Text, default="") status: Mapped[str] = mapped_column(String(50), default="queued") - bundle_dir: Mapped[str] = mapped_column(Text, default="") - payload_json: Mapped[str] = mapped_column(Text, default="") - manifest_path: Mapped[str] = mapped_column(Text, default="") - script_path: Mapped[str] = mapped_column(Text, default="") - error_text: Mapped[str] = mapped_column(Text, default="") - -class ArtifactORM(Base): - __tablename__ = "artifacts" - id: Mapped[int] = mapped_column(Integer, primary_key=True) - render_job_id: Mapped[int] = mapped_column(ForeignKey("render_jobs.id"), index=True) - learner_id: Mapped[str] = mapped_column(String(100), index=True) - pack_id: Mapped[str] = mapped_column(String(100), index=True) - artifact_type: Mapped[str] = mapped_column(String(50), default="render_bundle") - format: Mapped[str] = mapped_column(String(20), default="gif") - title: Mapped[str] = mapped_column(String(255), default="") - path: Mapped[str] = mapped_column(Text, default="") - metadata_json: Mapped[str] = mapped_column(Text, default="{}") + result_score: Mapped[float | None] = mapped_column(Float, nullable=True) + result_confidence_hint: Mapped[float | None] = mapped_column(Float, nullable=True) + result_notes: Mapped[str] = mapped_column(Text, default="") + learner = relationship("LearnerORM") + pack = relationship("PackORM") diff --git a/src/didactopus/repository.py b/src/didactopus/repository.py index bc8e397..917a351 100644 --- a/src/didactopus/repository.py +++ b/src/didactopus/repository.py @@ -2,92 +2,36 @@ from __future__ import annotations import json from sqlalchemy import select from .db import SessionLocal -from .orm import UserORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM +from .orm import UserORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent from .auth import verify_password -def get_user_by_username(username: str): +def get_user_by_token(token: str) -> UserORM | None: with SessionLocal() as db: - return db.execute(select(UserORM).where(UserORM.username == username)).scalar_one_or_none() + return db.execute(select(UserORM).where(UserORM.token == token)).scalar_one_or_none() -def get_user_by_id(user_id: int): +def authenticate_user(username: str, password: str) -> UserORM | None: with SessionLocal() as db: - return db.get(UserORM, user_id) + user = db.execute(select(UserORM).where(UserORM.username == username)).scalar_one_or_none() + if user is None: + return None + if not verify_password(password, user.password_hash): + return None + return user -def authenticate_user(username: str, password: str): - user = get_user_by_username(username) - if user is None or not verify_password(password, user.password_hash) or not user.is_active: - return None - return user - -def store_refresh_token(user_id: int, token_id: str): +def list_packs() -> list[PackData]: with SessionLocal() as db: - db.add(RefreshTokenORM(user_id=user_id, token_id=token_id, is_revoked=False)) - db.commit() + rows = db.execute(select(PackORM)).scalars().all() + return [PackData.model_validate(json.loads(r.data_json)) for r in rows] -def refresh_token_active(token_id: str) -> bool: - with SessionLocal() as db: - row = db.execute(select(RefreshTokenORM).where(RefreshTokenORM.token_id == token_id)).scalar_one_or_none() - return row is not None and not row.is_revoked - -def revoke_refresh_token(token_id: str): - with SessionLocal() as db: - row = db.execute(select(RefreshTokenORM).where(RefreshTokenORM.token_id == token_id)).scalar_one_or_none() - if row: - row.is_revoked = True - db.commit() - -def list_packs_for_user(user_id: int | None = None, include_unpublished: bool = False): - with SessionLocal() as db: - stmt = select(PackORM) - if not include_unpublished: - stmt = stmt.where(PackORM.is_published == True) - rows = db.execute(stmt).scalars().all() - out = [] - for r in rows: - if r.policy_lane == "community": - out.append(PackData.model_validate(json.loads(r.data_json))) - elif user_id is not None and r.owner_user_id == user_id: - out.append(PackData.model_validate(json.loads(r.data_json))) - return out - -def get_pack(pack_id: str): +def get_pack(pack_id: str) -> PackData | None: with SessionLocal() as db: row = db.get(PackORM, pack_id) - return None if row is None else PackData.model_validate(json.loads(row.data_json)) - -def get_pack_row(pack_id: str): - with SessionLocal() as db: - return db.get(PackORM, pack_id) - -def upsert_pack(pack: PackData, submitted_by_user_id: int, policy_lane: str = "personal", is_published: bool = False): - with SessionLocal() as db: - row = db.get(PackORM, pack.id) - payload = json.dumps(pack.model_dump()) if row is None: - row = PackORM( - id=pack.id, - owner_user_id=submitted_by_user_id if policy_lane == "personal" else None, - policy_lane=policy_lane, - title=pack.title, - subtitle=pack.subtitle, - level=pack.level, - data_json=payload, - is_published=is_published if policy_lane == "personal" else False, - ) - db.add(row) - else: - row.owner_user_id = submitted_by_user_id if policy_lane == "personal" else row.owner_user_id - row.policy_lane = policy_lane - row.title = pack.title - row.subtitle = pack.subtitle - row.level = pack.level - row.data_json = payload - if policy_lane == "personal": - row.is_published = is_published - db.commit() + return None + return PackData.model_validate(json.loads(row.data_json)) -def create_learner(owner_user_id: int, learner_id: str, display_name: str = ""): +def create_learner(owner_user_id: int, learner_id: str, display_name: str = "") -> None: with SessionLocal() as db: if db.get(LearnerORM, learner_id) is None: db.add(LearnerORM(id=learner_id, owner_user_id=owner_user_id, display_name=display_name)) @@ -98,23 +42,89 @@ def learner_owned_by_user(user_id: int, learner_id: str) -> bool: learner = db.get(LearnerORM, learner_id) return learner is not None and learner.owner_user_id == user_id -def load_learner_state(learner_id: str): +def load_learner_state(learner_id: str) -> LearnerState: with SessionLocal() as db: records = db.execute(select(MasteryRecordORM).where(MasteryRecordORM.learner_id == learner_id)).scalars().all() history = db.execute(select(EvidenceEventORM).where(EvidenceEventORM.learner_id == learner_id)).scalars().all() return LearnerState( learner_id=learner_id, - records=[MasteryRecord(concept_id=r.concept_id, dimension=r.dimension, score=r.score, confidence=r.confidence, evidence_count=r.evidence_count, last_updated=r.last_updated) for r in records], - history=[EvidenceEvent(concept_id=h.concept_id, dimension=h.dimension, score=h.score, confidence_hint=h.confidence_hint, timestamp=h.timestamp, kind=h.kind, source_id=h.source_id) for h in history], + records=[ + MasteryRecord( + concept_id=r.concept_id, + dimension=r.dimension, + score=r.score, + confidence=r.confidence, + evidence_count=r.evidence_count, + last_updated=r.last_updated, + ) for r in records + ], + history=[ + EvidenceEvent( + concept_id=h.concept_id, + dimension=h.dimension, + score=h.score, + confidence_hint=h.confidence_hint, + timestamp=h.timestamp, + kind=h.kind, + source_id=h.source_id, + ) for h in history + ] ) -def save_learner_state(state: LearnerState): +def save_learner_state(state: LearnerState) -> LearnerState: with SessionLocal() as db: + db.execute(select(LearnerORM).where(LearnerORM.id == state.learner_id)) db.query(MasteryRecordORM).filter(MasteryRecordORM.learner_id == state.learner_id).delete() db.query(EvidenceEventORM).filter(EvidenceEventORM.learner_id == state.learner_id).delete() for r in state.records: - db.add(MasteryRecordORM(learner_id=state.learner_id, concept_id=r.concept_id, dimension=r.dimension, score=r.score, confidence=r.confidence, evidence_count=r.evidence_count, last_updated=r.last_updated)) + db.add(MasteryRecordORM( + learner_id=state.learner_id, + concept_id=r.concept_id, + dimension=r.dimension, + score=r.score, + confidence=r.confidence, + evidence_count=r.evidence_count, + last_updated=r.last_updated, + )) for h in state.history: - db.add(EvidenceEventORM(learner_id=state.learner_id, concept_id=h.concept_id, dimension=h.dimension, score=h.score, confidence_hint=h.confidence_hint, timestamp=h.timestamp, kind=h.kind, source_id=h.source_id)) + db.add(EvidenceEventORM( + learner_id=state.learner_id, + concept_id=h.concept_id, + dimension=h.dimension, + score=h.score, + confidence_hint=h.confidence_hint, + timestamp=h.timestamp, + kind=h.kind, + source_id=h.source_id, + )) db.commit() return state + +def create_evaluator_job(learner_id: str, pack_id: str, concept_id: str, submitted_text: str) -> int: + with SessionLocal() as db: + job = EvaluatorJobORM( + learner_id=learner_id, + pack_id=pack_id, + concept_id=concept_id, + submitted_text=submitted_text, + status="queued", + ) + db.add(job) + db.commit() + db.refresh(job) + return job.id + +def get_evaluator_job(job_id: int) -> EvaluatorJobORM | None: + with SessionLocal() as db: + return db.get(EvaluatorJobORM, job_id) + +def update_evaluator_job(job_id: int, status: str, score: float | None = None, confidence_hint: float | None = None, notes: str = "") -> None: + with SessionLocal() as db: + job = db.get(EvaluatorJobORM, job_id) + if job is None: + return + job.status = status + job.result_score = score + job.result_confidence_hint = confidence_hint + job.result_notes = notes + db.commit() diff --git a/src/didactopus/seed.py b/src/didactopus/seed.py index bdc7b86..f3fdf4a 100644 --- a/src/didactopus/seed.py +++ b/src/didactopus/seed.py @@ -1,34 +1,79 @@ from __future__ import annotations -from sqlalchemy import select +import json from .db import Base, engine, SessionLocal -from .orm import UserORM -from .auth import hash_password -from .repository import upsert_pack, create_learner -from .models import PackData, PackConcept, GraphPosition, CrossPackLink +from .orm import UserORM, PackORM +from .auth import hash_password, issue_token +from sqlalchemy import select + +PACKS = [ + { + "id": "bayes-pack", + "title": "Bayesian Reasoning", + "subtitle": "Probability, evidence, updating, and model criticism.", + "level": "novice-friendly", + "concepts": [ + {"id": "prior", "title": "Prior", "prerequisites": [], "masteryDimension": "mastery", "exerciseReward": "Prior badge earned"}, + {"id": "posterior", "title": "Posterior", "prerequisites": ["prior"], "masteryDimension": "mastery", "exerciseReward": "Posterior path opened"}, + {"id": "model-checking", "title": "Model Checking", "prerequisites": ["posterior"], "masteryDimension": "mastery", "exerciseReward": "Model-checking unlocked"} + ], + "onboarding": { + "headline": "Start with a fast visible win", + "body": "Read one short orientation, answer one guided question, and leave with your first mastery marker.", + "checklist": [ + "Read the one-screen topic orientation", + "Answer one guided exercise", + "Write one explanation in your own words" + ] + }, + "compliance": { + "sources": 2, + "attributionRequired": True, + "shareAlikeRequired": True, + "noncommercialOnly": True, + "flags": ["share-alike", "noncommercial", "excluded-third-party-content"] + } + }, + { + "id": "stats-pack", + "title": "Introductory Statistics", + "subtitle": "Descriptive statistics, sampling, and inference.", + "level": "novice-friendly", + "concepts": [ + {"id": "descriptive", "title": "Descriptive Statistics", "prerequisites": [], "masteryDimension": "mastery", "exerciseReward": "Descriptive tools unlocked"}, + {"id": "sampling", "title": "Sampling", "prerequisites": ["descriptive"], "masteryDimension": "mastery", "exerciseReward": "Sampling pathway opened"}, + {"id": "inference", "title": "Inference", "prerequisites": ["sampling"], "masteryDimension": "mastery", "exerciseReward": "Inference challenge unlocked"} + ], + "onboarding": { + "headline": "Build your first useful data skill", + "body": "You will learn one concept that immediately helps you summarize real data.", + "checklist": [ + "See one worked example", + "Compute one short example yourself", + "Explain what the result means" + ] + }, + "compliance": { + "sources": 1, + "attributionRequired": True, + "shareAlikeRequired": False, + "noncommercialOnly": False, + "flags": [] + } + } +] def main(): Base.metadata.create_all(bind=engine) with SessionLocal() as db: - if db.execute(select(UserORM).where(UserORM.username == "wesley")).scalar_one_or_none() is None: - db.add(UserORM(username="wesley", password_hash=hash_password("demo-pass"), role="admin", is_active=True)) + existing = db.execute(select(UserORM).where(UserORM.username == "wesley")).scalar_one_or_none() + if existing is None: + db.add(UserORM(username="wesley", password_hash=hash_password("demo-pass"), token=issue_token())) + for pack in PACKS: + row = db.get(PackORM, pack["id"]) + if row is None: + db.add(PackORM(id=pack["id"], title=pack["title"], subtitle=pack["subtitle"], level=pack["level"], data_json=json.dumps(pack))) db.commit() - create_learner(1, "wesley-learner", "Wesley learner") - upsert_pack( - PackData( - id="wesley-private-pack", - title="Wesley Private Pack", - subtitle="Personal pack example.", - level="novice-friendly", - concepts=[ - PackConcept(id="intro", title="Intro", prerequisites=[], position=GraphPosition(x=150, y=120)), - PackConcept(id="second", title="Second concept", prerequisites=["intro"], position=GraphPosition(x=420, y=120)), - PackConcept(id="third", title="Third concept", prerequisites=["second"], position=GraphPosition(x=700, y=120), cross_pack_links=[CrossPackLink(source_concept_id="third", target_pack_id="advanced-pack", target_concept_id="adv-1", relationship="next_pack")]), - PackConcept(id="branch", title="Branch concept", prerequisites=["intro"], position=GraphPosition(x=420, y=320)), - ], - onboarding={"headline":"Start privately"}, - compliance={} - ), - submitted_by_user_id=1, - policy_lane="personal", - is_published=True, - ) + print("Seeded database. Demo user: wesley / demo-pass") + +if __name__ == "__main__": + main() diff --git a/webui/index.html b/webui/index.html index fec3a06..0869131 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3,8 +3,10 @@ - Didactopus Artifact Registry + Didactopus Auth API UI -
+ +
+ diff --git a/webui/package.json b/webui/package.json index e4e7051..685fd94 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,9 +1,17 @@ { - "name": "didactopus-artifact-registry-ui", + "name": "didactopus-auth-api-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 d592555..5bac568 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -1,150 +1,235 @@ -import React, { useEffect, useState } from "react"; -import { login, refresh, fetchPacks, fetchLearnerState, putLearnerState, createRenderJob, listRenderJobs, listArtifacts } from "./api"; -import { loadAuth, saveAuth, clearAuth } from "./authStore"; +import React, { useEffect, useMemo, useState } from "react"; +import { login, fetchPacks, createLearner, fetchLearnerState, fetchRecommendations, postEvidence, submitEvaluatorJob, fetchEvaluatorJob } from "./api"; +import { buildMasteryMap, progressPercent } from "./localEngine"; -function LoginView({ onAuth }) { +function LoginPanel({ onLogin }) { const [username, setUsername] = useState("wesley"); const [password, setPassword] = useState("demo-pass"); const [error, setError] = useState(""); - async function doLogin() { + + async function handleLogin() { try { - const result = await login(username, password); - saveAuth(result); - onAuth(result); - } catch { setError("Login failed"); } + setError(""); + const result = await onLogin(username, password); + return result; + } catch (e) { + setError("Login failed"); + } } + return ( -
-
-

Didactopus login

- - - - {error ?
{error}
: null} -
+
+

Didactopus login

+ + + + {error ?
{error}
: null} +
+ ); +} + +function DomainCard({ domain, selected, onSelect }) { + return ( + + ); +} + +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 [token, setToken] = useState(""); + const [username, setUsername] = useState(""); const [packs, setPacks] = useState([]); - const [learnerId] = useState("wesley-learner"); - const [packId, setPackId] = useState(""); - const [jobs, setJobs] = useState([]); - const [artifacts, setArtifacts] = useState([]); - const [format, setFormat] = useState("gif"); - const [fps, setFps] = useState(2); - const [message, setMessage] = useState(""); + const [selectedDomainId, setSelectedDomainId] = useState(""); + const [learnerId, setLearnerId] = useState("wesley-learner"); + const [learnerState, setLearnerState] = useState(null); + const [cards, setCards] = useState([]); + const [jobStatus, setJobStatus] = useState(null); - 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); - } - } - - async function reloadLists() { - setJobs(await guarded((token) => listRenderJobs(token, learnerId))); - setArtifacts(await guarded((token) => listArtifacts(token, learnerId))); + 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(() => { - if (!auth) return; - async function load() { - const p = await guarded((token) => fetchPacks(token)); - setPacks(p); - setPackId(p[0]?.id || ""); - await reloadLists(); + async function loadCards() { + if (!token || !selectedDomainId) return; + const data = await fetchRecommendations(token, learnerId, selectedDomainId); + setCards(data.cards || []); } - load(); - }, [auth]); + loadCards(); + }, [token, learnerId, selectedDomainId, learnerState]); - 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 masteryMap = useMemo(() => domain && learnerState ? buildMasteryMap(learnerState, domain) : [], [learnerState, domain]); + const progress = useMemo(() => domain && learnerState ? progressPercent(learnerState, domain) : 0, [learnerState, domain]); + + async function simulateStep(step) { + const nextState = await postEvidence(token, learnerId, { + concept_id: step.conceptId, + dimension: "mastery", + score: step.scoreHint, + confidence_hint: step.confidenceHint, + timestamp: new Date().toISOString(), + kind: "checkpoint", + source_id: `ui-${step.id}` + }); + setLearnerState(nextState); } - async function createJob() { - const result = await guarded((token) => createRenderJob(token, learnerId, packId, { learner_id: learnerId, pack_id: packId, format, fps, theme: "default" })); - setMessage(`Render job ${result.job_id} queued.`); - setTimeout(() => reloadLists(), 500); + 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 (!auth) return ; + if (!token) { + return
; + } + + if (!domain || !learnerState) { + return
Loading authenticated learner view...
; + } return (
-

Didactopus artifact registry

-

Track render jobs and produced media artifacts as first-class Didactopus objects.

-
{message}
+

Didactopus learner prototype

+

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

+
Signed in as {username}
-
- - - - - - - +
+ +
-
-
-

Render jobs

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

Artifacts

-
{JSON.stringify(artifacts, null, 2)}
-
+
+ {packs.map((d) => )} +
+ +
+
+
+

Onboarding

+

{domain.onboarding.headline}

+

{domain.onboarding.body}

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

Visible mastery map

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

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.
+ )} +
+
+ +
+
+

What should I do next?

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

Progress

+
+
Mastery progress
+
+
{progress}%
+
+
+ +
+

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)} · hint {item.confidence_hint.toFixed(2)} · {item.kind}
  • + ))} +
+ )} +
+ +
+

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"}
+
+
+
); diff --git a/webui/src/api.js b/webui/src/api.js index aaefb3d..7d68e72 100644 --- a/webui/src/api.js +++ b/webui/src/api.js @@ -1,24 +1,62 @@ const API = "http://127.0.0.1:8011/api"; -function authHeaders(token, json=true) { - const h = { Authorization: `Bearer ${token}` }; - if (json) h["Content-Type"] = "application/json"; - return h; +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"); + return await res.json(); } -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"); +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) }); return await res.json(); } -export async function refresh(refreshToken) { - const res = await fetch(`${API}/refresh`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: refreshToken }) }); - if (!res.ok) throw new Error("refresh failed"); + +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) { + const res = await fetch(`${API}/learners/${learnerId}/evidence`, { + method: "POST", + headers: authHeaders(token), + 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) }); return await res.json(); } -export async function fetchPacks(token) { const res = await fetch(`${API}/packs`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPacks failed"); return await res.json(); } -export async function fetchLearnerState(token, learnerId) { const res = await fetch(`${API}/learners/${learnerId}/state`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchLearnerState failed"); return await res.json(); } -export async function putLearnerState(token, learnerId, state) { const res = await fetch(`${API}/learners/${learnerId}/state`, { method: "PUT", headers: authHeaders(token), body: JSON.stringify(state) }); if (!res.ok) throw new Error("putLearnerState failed"); return await res.json(); } -export async function createRenderJob(token, learnerId, packId, payload) { const res = await fetch(`${API}/learners/${learnerId}/render-jobs/${packId}`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("createRenderJob failed"); return await res.json(); } -export async function listRenderJobs(token, learnerId) { const res = await fetch(`${API}/render-jobs?learner_id=${encodeURIComponent(learnerId)}`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listRenderJobs failed"); return await res.json(); } -export async function listArtifacts(token, learnerId) { const res = await fetch(`${API}/artifacts?learner_id=${encodeURIComponent(learnerId)}`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listArtifacts failed"); return await res.json(); } diff --git a/webui/src/localEngine.js b/webui/src/localEngine.js index f331af6..6dcb20e 100644 --- a/webui/src/localEngine.js +++ b/webui/src/localEngine.js @@ -22,27 +22,3 @@ 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/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 e000ac1..1bde8d8 100644 --- a/webui/src/styles.css +++ b/webui/src/styles.css @@ -1,23 +1,52 @@ :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; } -@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; } +.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; } +.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; } +.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); } +.compliance-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; } +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; } }