diff --git a/pyproject.toml b/pyproject.toml index b51b837..17c47da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,11 @@ dependencies = [ "pydantic>=2.7", "pyyaml>=6.0", "fastapi>=0.115", - "uvicorn>=0.30" + "uvicorn>=0.30", + "sqlalchemy>=2.0", + "psycopg[binary]>=3.1", + "passlib[bcrypt]>=1.7", + "python-jose[cryptography]>=3.3" ] [project.scripts] diff --git a/src/didactopus/api.py b/src/didactopus/api.py index f0b3f59..cd83f60 100644 --- a/src/didactopus/api.py +++ b/src/didactopus/api.py @@ -1,62 +1,208 @@ from __future__ import annotations -from pathlib import Path -from fastapi import FastAPI, HTTPException +import json +from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks from fastapi.middleware.cors import CORSMiddleware import uvicorn -from .models import LearnerState, EvidenceEvent -from .storage import FileStorage +from .config import load_settings +from .db import Base, engine +from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest, GovernanceAction, ReviewCommentCreate, ContributionSubmissionCreate +from .repository import ( + authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token, + list_packs, list_pack_admin_rows, get_pack, get_pack_validation, get_pack_provenance, + upsert_pack, create_submission, list_submissions, get_submission_diff, get_submission_gates, list_review_tasks, + set_pack_publication, set_governance_state, list_pack_versions, add_review_comment, list_review_comments, + create_learner, list_learners_for_user, learner_owned_by_user, load_learner_state, + save_learner_state, create_evaluator_job, get_evaluator_job, list_evaluator_jobs_for_learner +) from .engine import apply_evidence, recommend_next +from .auth import issue_access_token, issue_refresh_token, decode_token, new_token_id +from .worker import process_job -BASE_DIR = Path(__file__).resolve().parents[2] / "data" -storage = FileStorage(BASE_DIR) +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: + raise HTTPException(status_code=401, detail="Unauthorized") + return user + +def require_admin(user = Depends(current_user)): + if user.role != "admin": + raise HTTPException(status_code=403, detail="Admin role required") + 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") + +@app.post("/api/login", response_model=TokenPair) +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) @app.get("/api/packs") -def list_packs(): - return [p.model_dump() for p in storage.list_packs()] +def api_list_packs(user = Depends(current_user)): + return [p.model_dump() for p in list_packs(include_unpublished=(user.role == "admin"))] -@app.get("/api/packs/{pack_id}") -def get_pack(pack_id: str): - pack = storage.get_pack(pack_id) - if pack is None: +@app.post("/api/contributions") +def api_create_contribution(payload: ContributionSubmissionCreate, user = Depends(current_user)): + submission_id = create_submission(payload.pack, user.id, payload.submission_summary) + return {"ok": True, "submission_id": submission_id} + +@app.get("/api/admin/submissions") +def api_admin_submissions(user = Depends(require_admin)): + return list_submissions() + +@app.get("/api/admin/submissions/{submission_id}/diff") +def api_admin_submission_diff(submission_id: int, user = Depends(require_admin)): + return get_submission_diff(submission_id) + +@app.get("/api/admin/submissions/{submission_id}/gates") +def api_admin_submission_gates(submission_id: int, user = Depends(require_admin)): + return get_submission_gates(submission_id) + +@app.get("/api/admin/review-tasks") +def api_admin_review_tasks(user = Depends(require_admin)): + return list_review_tasks() + +@app.get("/api/admin/packs") +def api_admin_list_packs(user = Depends(require_admin)): + return list_pack_admin_rows() + +@app.get("/api/admin/packs/{pack_id}/validation") +def api_admin_pack_validation(pack_id: str, user = Depends(require_admin)): + return get_pack_validation(pack_id) + +@app.get("/api/admin/packs/{pack_id}/provenance") +def api_admin_pack_provenance(pack_id: str, user = Depends(require_admin)): + return get_pack_provenance(pack_id) + +@app.get("/api/admin/packs/{pack_id}/versions") +def api_admin_pack_versions(pack_id: str, user = Depends(require_admin)): + return list_pack_versions(pack_id) + +@app.get("/api/admin/packs/{pack_id}/comments") +def api_admin_pack_comments(pack_id: str, user = Depends(require_admin)): + return list_review_comments(pack_id) + +@app.post("/api/admin/packs") +def api_upsert_pack(payload: CreatePackRequest, user = Depends(require_admin)): + upsert_pack(payload.pack, submitted_by_user_id=user.id, is_published=payload.is_published, change_summary=payload.change_summary) + return {"ok": True, "pack_id": payload.pack.id} + +@app.post("/api/admin/packs/{pack_id}/publish") +def api_publish_pack(pack_id: str, is_published: bool, user = Depends(require_admin)): + ok = set_pack_publication(pack_id, is_published) + if not ok: raise HTTPException(status_code=404, detail="Pack not found") - return pack.model_dump() + return {"ok": True, "pack_id": pack_id, "is_published": is_published} + +@app.post("/api/admin/packs/{pack_id}/governance") +def api_governance_action(pack_id: str, payload: GovernanceAction, user = Depends(require_admin)): + ok = set_governance_state(pack_id, payload.status, payload.review_summary) + if not ok: + raise HTTPException(status_code=404, detail="Pack not found") + return {"ok": True, "pack_id": pack_id, "status": payload.status} + +@app.post("/api/admin/packs/{pack_id}/comments") +def api_add_review_comment(pack_id: str, version_number: int, payload: ReviewCommentCreate, user = Depends(require_admin)): + add_review_comment(pack_id, version_number, user.id, payload.comment_text, payload.disposition) + return {"ok": True} + +@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") +def api_list_learners(user = Depends(current_user)): + return list_learners_for_user(user.id, is_admin=(user.role == "admin")) @app.get("/api/learners/{learner_id}/state") -def get_learner_state(learner_id: str): - return storage.get_learner_state(learner_id).model_dump() +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() @app.put("/api/learners/{learner_id}/state") -def put_learner_state(learner_id: str, state: LearnerState): +def api_put_learner_state(learner_id: str, state: LearnerState, user = Depends(current_user)): + ensure_learner_access(user, learner_id) if learner_id != state.learner_id: raise HTTPException(status_code=400, detail="Learner ID mismatch") - return storage.save_learner_state(state).model_dump() + return save_learner_state(state).model_dump() @app.post("/api/learners/{learner_id}/evidence") -def post_evidence(learner_id: str, event: EvidenceEvent): - state = storage.get_learner_state(learner_id) +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) state = apply_evidence(state, event) - storage.save_learner_state(state) + save_learner_state(state) return state.model_dump() @app.get("/api/learners/{learner_id}/recommendations/{pack_id}") -def get_recommendations(learner_id: str, pack_id: str): - state = storage.get_learner_state(learner_id) - pack = storage.get_pack(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) if pack is None: raise HTTPException(status_code=404, detail="Pack not found") return {"cards": recommend_next(state, pack)} -def main(): - uvicorn.run(app, host="127.0.0.1", port=8011) +@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(process_job, job_id) + return EvaluatorJobStatus(job_id=job_id, status="queued") -if __name__ == "__main__": - main() +@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) + +@app.get("/api/evaluator-jobs/{job_id}/trace") +def api_get_evaluator_job_trace(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 json.loads(job.trace_json or "{}") + +@app.get("/api/learners/{learner_id}/evaluator-history") +def api_get_evaluator_history(learner_id: str, user = Depends(current_user)): + ensure_learner_access(user, learner_id) + jobs = list_evaluator_jobs_for_learner(learner_id) + return [{"job_id": j.id, "status": j.status, "concept_id": j.concept_id, "result_score": j.result_score, "result_confidence_hint": j.result_confidence_hint, "result_notes": j.result_notes} for j in jobs] + +def main(): + uvicorn.run(app, host=settings.host, port=settings.port) diff --git a/src/didactopus/auth.py b/src/didactopus/auth.py index 10ebe87..54745ac 100644 --- a/src/didactopus/auth.py +++ b/src/didactopus/auth.py @@ -1,8 +1,12 @@ from __future__ import annotations -import secrets +from datetime import datetime, timedelta, timezone +from jose import jwt, JWTError from passlib.context import CryptContext +import secrets +from .config import load_settings pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +settings = load_settings() def hash_password(password: str) -> str: return pwd_context.hash(password) @@ -10,5 +14,22 @@ def hash_password(password: str) -> str: def verify_password(password: str, password_hash: str) -> bool: return pwd_context.verify(password, password_hash) -def issue_token() -> str: +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: return secrets.token_urlsafe(24) diff --git a/src/didactopus/config.py b/src/didactopus/config.py index c9684d1..6db28e1 100644 --- a/src/didactopus/config.py +++ b/src/didactopus/config.py @@ -1,10 +1,13 @@ -from pathlib import Path +from __future__ import annotations +import os from pydantic import BaseModel class Settings(BaseModel): - database_url: str = "sqlite:///./didactopus.db" - host: str = "127.0.0.1" - port: int = 8011 + database_url: str = os.getenv("DIDACTOPUS_DATABASE_URL", "postgresql+psycopg://didactopus:didactopus-dev-password@localhost:5432/didactopus") + 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" def load_settings() -> Settings: return Settings() diff --git a/src/didactopus/db.py b/src/didactopus/db.py index e0973b6..771e336 100644 --- a/src/didactopus/db.py +++ b/src/didactopus/db.py @@ -3,7 +3,6 @@ from sqlalchemy.orm import declarative_base, sessionmaker from .config import load_settings settings = load_settings() -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) +engine = create_engine(settings.database_url, future=True) 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 d888035..c1e52bf 100644 --- a/src/didactopus/engine.py +++ b/src/didactopus/engine.py @@ -7,30 +7,14 @@ def get_record(state: LearnerState, concept_id: str, dimension: str = "mastery") return rec return None -def apply_evidence( - state: LearnerState, - event: EvidenceEvent, - decay: float = 0.05, - reinforcement: float = 0.25, -) -> LearnerState: +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, - ) + 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) - 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.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) @@ -61,11 +45,7 @@ def recommend_next(state: LearnerState, pack: PackData) -> list[dict]: "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." - ), + "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", @@ -76,22 +56,4 @@ def recommend_next(state: LearnerState, pack: PackData) -> list[dict]: "scoreHint": 0.82 if status == "available" else 0.76, "confidenceHint": 0.72 if status == "available" else 0.55, }) - 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 2ea8228..5be4550 100644 --- a/src/didactopus/models.py +++ b/src/didactopus/models.py @@ -4,6 +4,20 @@ from typing import Literal EvidenceKind = Literal["checkpoint", "project", "exercise", "review"] +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 PackConcept(BaseModel): id: str title: str @@ -27,6 +41,27 @@ class PackData(BaseModel): onboarding: dict = Field(default_factory=dict) compliance: PackCompliance = Field(default_factory=PackCompliance) +class CreatePackRequest(BaseModel): + pack: PackData + is_published: bool = False + change_summary: str = "" + +class GovernanceAction(BaseModel): + status: str + review_summary: str = "" + +class ReviewCommentCreate(BaseModel): + comment_text: str + disposition: str = "comment" + +class ContributionSubmissionCreate(BaseModel): + pack: PackData + submission_summary: str = "" + +class CreateLearnerRequest(BaseModel): + learner_id: str + display_name: str = "" + class MasteryRecord(BaseModel): concept_id: str dimension: str @@ -48,3 +83,16 @@ class LearnerState(BaseModel): learner_id: str records: list[MasteryRecord] = Field(default_factory=list) history: list[EvidenceEvent] = Field(default_factory=list) + +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/src/didactopus/orm.py b/src/didactopus/orm.py index 6df7815..44755fa 100644 --- a/src/didactopus/orm.py +++ b/src/didactopus/orm.py @@ -1,5 +1,5 @@ -from sqlalchemy import String, Integer, Float, Boolean, ForeignKey, Text -from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy import String, Integer, Float, ForeignKey, Text, Boolean +from sqlalchemy.orm import Mapped, mapped_column from .db import Base class UserORM(Base): @@ -7,7 +7,15 @@ 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)) - token: Mapped[str] = mapped_column(String(255), unique=True, index=True) + 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) class PackORM(Base): __tablename__ = "packs" @@ -16,13 +24,61 @@ class PackORM(Base): subtitle: Mapped[str] = mapped_column(Text, default="") level: Mapped[str] = mapped_column(String(100), default="novice-friendly") data_json: Mapped[str] = mapped_column(Text) + validation_json: Mapped[str] = mapped_column(Text, default="{}") + provenance_json: Mapped[str] = mapped_column(Text, default="{}") + governance_state: Mapped[str] = mapped_column(String(50), default="draft") + current_version: Mapped[int] = mapped_column(Integer, default=1) + is_published: Mapped[bool] = mapped_column(Boolean, default=False) + +class PackVersionORM(Base): + __tablename__ = "pack_versions" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + pack_id: Mapped[str] = mapped_column(ForeignKey("packs.id"), index=True) + version_number: Mapped[int] = mapped_column(Integer) + submitted_by_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) + status: Mapped[str] = mapped_column(String(50), default="draft") + data_json: Mapped[str] = mapped_column(Text) + change_summary: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[str] = mapped_column(String(100), default="") + review_summary: Mapped[str] = mapped_column(Text, default="") + +class ReviewCommentORM(Base): + __tablename__ = "review_comments" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + pack_id: Mapped[str] = mapped_column(ForeignKey("packs.id"), index=True) + version_number: Mapped[int] = mapped_column(Integer) + reviewer_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) + comment_text: Mapped[str] = mapped_column(Text, default="") + disposition: Mapped[str] = mapped_column(String(50), default="comment") + created_at: Mapped[str] = mapped_column(String(100), default="") + +class ContributionSubmissionORM(Base): + __tablename__ = "contribution_submissions" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + pack_id: Mapped[str] = mapped_column(String(100), index=True) + proposed_version_number: Mapped[int] = mapped_column(Integer, default=1) + contributor_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) + status: Mapped[str] = mapped_column(String(50), default="submitted") + submission_summary: Mapped[str] = mapped_column(Text, default="") + proposed_data_json: Mapped[str] = mapped_column(Text, default="{}") + diff_json: Mapped[str] = mapped_column(Text, default="{}") + gate_json: Mapped[str] = mapped_column(Text, default="{}") + created_at: Mapped[str] = mapped_column(String(100), default="") + +class ReviewTaskORM(Base): + __tablename__ = "review_tasks" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + submission_id: Mapped[int] = mapped_column(ForeignKey("contribution_submissions.id"), index=True) + reviewer_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + task_status: Mapped[str] = mapped_column(String(50), default="open") + task_note: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[str] = mapped_column(String(100), default="") 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" @@ -34,7 +90,6 @@ 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" @@ -47,7 +102,6 @@ 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 EvaluatorJobORM(Base): __tablename__ = "evaluator_jobs" @@ -60,5 +114,4 @@ class EvaluatorJobORM(Base): 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") + trace_json: Mapped[str] = mapped_column(Text, default="{}") diff --git a/src/didactopus/repository.py b/src/didactopus/repository.py index 917a351..ec9173e 100644 --- a/src/didactopus/repository.py +++ b/src/didactopus/repository.py @@ -1,124 +1,341 @@ from __future__ import annotations import json +from datetime import datetime, timezone from sqlalchemy import select from .db import SessionLocal -from .orm import UserORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM +from .orm import UserORM, RefreshTokenORM, PackORM, PackVersionORM, ReviewCommentORM, ContributionSubmissionORM, ReviewTaskORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent from .auth import verify_password -def get_user_by_token(token: str) -> UserORM | None: - with SessionLocal() as db: - return db.execute(select(UserORM).where(UserORM.token == token)).scalar_one_or_none() +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() -def authenticate_user(username: str, password: str) -> UserORM | None: - with SessionLocal() as db: - 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 pack_diff(old_pack: dict | None, new_pack: dict) -> dict: + old_pack = old_pack or {} + old_concepts = {c.get("id"): c for c in old_pack.get("concepts", [])} + new_concepts = {c.get("id"): c for c in new_pack.get("concepts", [])} + added = sorted([cid for cid in new_concepts if cid not in old_concepts]) + removed = sorted([cid for cid in old_concepts if cid not in new_concepts]) + changed = sorted([cid for cid in new_concepts if cid in old_concepts and new_concepts[cid] != old_concepts[cid]]) + return { + "title_changed": old_pack.get("title") != new_pack.get("title"), + "subtitle_changed": old_pack.get("subtitle") != new_pack.get("subtitle"), + "concepts_added": added, + "concepts_removed": removed, + "concepts_changed": changed, + "onboarding_changed": old_pack.get("onboarding") != new_pack.get("onboarding"), + "compliance_changed": old_pack.get("compliance") != new_pack.get("compliance"), + } -def list_packs() -> list[PackData]: +def gate_summary(validation: dict, provenance: dict) -> dict: + warnings = list(validation.get("warnings", []) or []) + errors = list(validation.get("errors", []) or []) + restrictive_flags = list(provenance.get("restrictive_flags", []) or []) + qa_ok = validation.get("ok", False) and len(errors) == 0 + provenance_ok = provenance.get("source_count", 0) >= 0 + ready_for_review = qa_ok and provenance_ok + return { + "qa_ok": qa_ok, + "provenance_ok": provenance_ok, + "ready_for_review": ready_for_review, + "warnings": warnings, + "errors": errors, + "restrictive_flags": restrictive_flags, + } + +def get_user_by_username(username: str): with SessionLocal() as db: - rows = db.execute(select(PackORM)).scalars().all() + return db.execute(select(UserORM).where(UserORM.username == username)).scalar_one_or_none() + +def get_user_by_id(user_id: int): + with SessionLocal() as db: + return db.get(UserORM, user_id) + +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): + with SessionLocal() as db: + db.add(RefreshTokenORM(user_id=user_id, token_id=token_id, is_revoked=False)) + db.commit() + +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(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() return [PackData.model_validate(json.loads(r.data_json)) for r in rows] -def get_pack(pack_id: str) -> PackData | None: +def list_pack_admin_rows(): + with SessionLocal() as db: + rows = db.execute(select(PackORM).order_by(PackORM.id)).scalars().all() + return [{"id": r.id, "title": r.title, "is_published": r.is_published, "subtitle": r.subtitle, "governance_state": r.governance_state, "current_version": r.current_version} for r in rows] + +def get_pack(pack_id: str): + 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_validation(pack_id: str): + with SessionLocal() as db: + row = db.get(PackORM, pack_id) + return {} if row is None else json.loads(row.validation_json or "{}") + +def get_pack_provenance(pack_id: str): + with SessionLocal() as db: + row = db.get(PackORM, pack_id) + return {} if row is None else json.loads(row.provenance_json or "{}") + +def validation_and_provenance_for_pack(pack: PackData): + validation = { + "ok": len(pack.concepts) > 0, + "warnings": [] if len(pack.concepts) > 0 else ["Pack has no concepts."], + "errors": [], + "summary": {"concept_count": len(pack.concepts), "has_onboarding": bool(pack.onboarding)} + } + provenance = { + "source_count": pack.compliance.sources, + "licenses_present": ["CC BY-NC-SA 4.0"] if pack.compliance.shareAlikeRequired or pack.compliance.noncommercialOnly else [], + "restrictive_flags": list(pack.compliance.flags), + "sources": [ + {"source_id": "sample-source-1", "title": f"Provenance placeholder for {pack.title}", "license_id": "CC BY-NC-SA 4.0" if pack.compliance.shareAlikeRequired or pack.compliance.noncommercialOnly else "unspecified", "attribution_text": "Sample attribution text placeholder"} + ] if pack.compliance.sources else [] + } + return validation, provenance + +def upsert_pack(pack: PackData, submitted_by_user_id: int, is_published: bool = False, change_summary: str = ""): + validation, provenance = validation_and_provenance_for_pack(pack) + 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, title=pack.title, subtitle=pack.subtitle, level=pack.level, + data_json=payload, validation_json=json.dumps(validation), provenance_json=json.dumps(provenance), + governance_state="draft", current_version=1, is_published=is_published + ) + db.add(row) + version_number = 1 + else: + row.title = pack.title + row.subtitle = pack.subtitle + row.level = pack.level + row.data_json = payload + row.validation_json = json.dumps(validation) + row.provenance_json = json.dumps(provenance) + row.is_published = is_published + row.current_version += 1 + row.governance_state = "draft" + version_number = row.current_version + db.flush() + db.add(PackVersionORM( + pack_id=pack.id, + version_number=version_number, + submitted_by_user_id=submitted_by_user_id, + status="draft", + data_json=payload, + change_summary=change_summary, + created_at=now_iso(), + review_summary="" + )) + db.commit() + +def create_submission(pack: PackData, contributor_user_id: int, submission_summary: str): + validation, provenance = validation_and_provenance_for_pack(pack) + with SessionLocal() as db: + current_pack = db.get(PackORM, pack.id) + current_payload = json.loads(current_pack.data_json) if current_pack is not None else None + current_version = current_pack.current_version if current_pack is not None else 0 + proposed_version = current_version + 1 + proposed_payload = pack.model_dump() + diff = pack_diff(current_payload, proposed_payload) + gates = gate_summary(validation, provenance) + sub = ContributionSubmissionORM( + pack_id=pack.id, + proposed_version_number=proposed_version, + contributor_user_id=contributor_user_id, + status="submitted", + submission_summary=submission_summary, + proposed_data_json=json.dumps(proposed_payload), + diff_json=json.dumps(diff), + gate_json=json.dumps(gates), + created_at=now_iso(), + ) + db.add(sub) + db.flush() + db.add(ReviewTaskORM( + submission_id=sub.id, + reviewer_user_id=None, + task_status="open", + task_note="Submission awaiting reviewer attention", + created_at=now_iso(), + )) + db.commit() + return sub.id + +def list_submissions(): + with SessionLocal() as db: + rows = db.execute(select(ContributionSubmissionORM).order_by(ContributionSubmissionORM.id.desc())).scalars().all() + return [{ + "submission_id": r.id, + "pack_id": r.pack_id, + "proposed_version_number": r.proposed_version_number, + "contributor_user_id": r.contributor_user_id, + "status": r.status, + "submission_summary": r.submission_summary, + "created_at": r.created_at, + } for r in rows] + +def get_submission(submission_id: int): + with SessionLocal() as db: + return db.get(ContributionSubmissionORM, submission_id) + +def get_submission_diff(submission_id: int): + with SessionLocal() as db: + row = db.get(ContributionSubmissionORM, submission_id) + return {} if row is None else json.loads(row.diff_json or "{}") + +def get_submission_gates(submission_id: int): + with SessionLocal() as db: + row = db.get(ContributionSubmissionORM, submission_id) + return {} if row is None else json.loads(row.gate_json or "{}") + +def list_review_tasks(): + with SessionLocal() as db: + rows = db.execute(select(ReviewTaskORM).order_by(ReviewTaskORM.id.desc())).scalars().all() + return [{ + "task_id": r.id, + "submission_id": r.submission_id, + "reviewer_user_id": r.reviewer_user_id, + "task_status": r.task_status, + "task_note": r.task_note, + "created_at": r.created_at, + } for r in rows] + +def set_pack_publication(pack_id: str, is_published: bool): with SessionLocal() as db: row = db.get(PackORM, pack_id) if row is None: - return None - return PackData.model_validate(json.loads(row.data_json)) + return False + row.is_published = is_published + db.commit() + return True -def create_learner(owner_user_id: int, learner_id: str, display_name: str = "") -> None: +def set_governance_state(pack_id: str, status: str, review_summary: str): + with SessionLocal() as db: + row = db.get(PackORM, pack_id) + if row is None: + return False + row.governance_state = status + version = db.execute(select(PackVersionORM).where(PackVersionORM.pack_id == pack_id, PackVersionORM.version_number == row.current_version)).scalar_one_or_none() + if version is not None: + version.status = status + version.review_summary = review_summary + db.commit() + return True + +def list_pack_versions(pack_id: str): + with SessionLocal() as db: + rows = db.execute(select(PackVersionORM).where(PackVersionORM.pack_id == pack_id).order_by(PackVersionORM.version_number.desc())).scalars().all() + return [{ + "version_number": r.version_number, + "status": r.status, + "change_summary": r.change_summary, + "created_at": r.created_at, + "review_summary": r.review_summary, + "submitted_by_user_id": r.submitted_by_user_id + } for r in rows] + +def add_review_comment(pack_id: str, version_number: int, reviewer_user_id: int, comment_text: str, disposition: str): + with SessionLocal() as db: + db.add(ReviewCommentORM(pack_id=pack_id, version_number=version_number, reviewer_user_id=reviewer_user_id, comment_text=comment_text, disposition=disposition, created_at=now_iso())) + db.commit() + +def list_review_comments(pack_id: str): + with SessionLocal() as db: + rows = db.execute(select(ReviewCommentORM).where(ReviewCommentORM.pack_id == pack_id).order_by(ReviewCommentORM.id.desc())).scalars().all() + return [{ + "version_number": r.version_number, + "reviewer_user_id": r.reviewer_user_id, + "comment_text": r.comment_text, + "disposition": r.disposition, + "created_at": r.created_at + } for r in rows] + +def create_learner(owner_user_id: int, learner_id: str, display_name: str = ""): 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)) db.commit() +def list_learners_for_user(user_id: int, is_admin: bool = False): + with SessionLocal() as db: + stmt = select(LearnerORM).order_by(LearnerORM.id) + if not is_admin: + stmt = stmt.where(LearnerORM.owner_user_id == user_id) + rows = db.execute(stmt).scalars().all() + return [{"learner_id": r.id, "display_name": r.display_name, "owner_user_id": r.owner_user_id} for r in rows] + def learner_owned_by_user(user_id: int, learner_id: str) -> bool: with SessionLocal() as db: 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) -> LearnerState: +def load_learner_state(learner_id: str): 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) -> LearnerState: +def save_learner_state(state: 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: +def create_evaluator_job(learner_id: str, pack_id: str, concept_id: str, submitted_text: str): with SessionLocal() as db: - job = EvaluatorJobORM( - learner_id=learner_id, - pack_id=pack_id, - concept_id=concept_id, - submitted_text=submitted_text, - status="queued", - ) + trace = {"rubric_dimension_scores": [{"dimension": "mastery", "score": 0.0}], "notes": ["Job queued", "Awaiting evaluator"], "token_count_estimate": len(submitted_text.split())} + job = EvaluatorJobORM(learner_id=learner_id, pack_id=pack_id, concept_id=concept_id, submitted_text=submitted_text, status="queued", trace_json=json.dumps(trace)) db.add(job) db.commit() db.refresh(job) return job.id -def get_evaluator_job(job_id: int) -> EvaluatorJobORM | None: +def list_evaluator_jobs_for_learner(learner_id: str): + with SessionLocal() as db: + return db.execute(select(EvaluatorJobORM).where(EvaluatorJobORM.learner_id == learner_id).order_by(EvaluatorJobORM.id.desc())).scalars().all() + +def get_evaluator_job(job_id: int): 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: +def update_evaluator_job(job_id: int, status: str, score: float | None = None, confidence_hint: float | None = None, notes: str = "", trace: dict | None = None): with SessionLocal() as db: job = db.get(EvaluatorJobORM, job_id) if job is None: @@ -127,4 +344,6 @@ def update_evaluator_job(job_id: int, status: str, score: float | None = None, c job.result_score = score job.result_confidence_hint = confidence_hint job.result_notes = notes + if trace is not None: + job.trace_json = json.dumps(trace) db.commit() diff --git a/src/didactopus/seed.py b/src/didactopus/seed.py index f3fdf4a..c158b90 100644 --- a/src/didactopus/seed.py +++ b/src/didactopus/seed.py @@ -1,79 +1,34 @@ from __future__ import annotations -import json -from .db import Base, engine, SessionLocal -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": [] - } - } -] +from .db import Base, engine, SessionLocal +from .orm import UserORM +from .auth import hash_password +from .repository import upsert_pack +from .models import PackData, PackConcept, PackCompliance def main(): Base.metadata.create_all(bind=engine) with SessionLocal() as db: - 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))) + 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)) + if db.execute(select(UserORM).where(UserORM.username == "contrib")).scalar_one_or_none() is None: + db.add(UserORM(username="contrib", password_hash=hash_password("demo-pass"), role="learner", is_active=True)) db.commit() - print("Seeded database. Demo user: wesley / demo-pass") - -if __name__ == "__main__": - main() + upsert_pack( + PackData( + id="bayes-pack", + title="Bayesian Reasoning", + subtitle="Probability, evidence, updating, and model criticism.", + level="novice-friendly", + concepts=[ + PackConcept(id="prior", title="Prior", prerequisites=[], masteryDimension="mastery", exerciseReward="Prior badge earned"), + PackConcept(id="posterior", title="Posterior", prerequisites=["prior"], masteryDimension="mastery", exerciseReward="Posterior path opened"), + ], + 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=PackCompliance(sources=2, attributionRequired=True, shareAlikeRequired=True, noncommercialOnly=True, flags=["share-alike","noncommercial","excluded-third-party-content"]) + ), + submitted_by_user_id=1, + is_published=True, + change_summary="Initial seed version" + ) + print("Seeded database. Demo users: wesley/demo-pass and contrib/demo-pass") diff --git a/src/didactopus/worker.py b/src/didactopus/worker.py index 28b6809..6d28da5 100644 --- a/src/didactopus/worker.py +++ b/src/didactopus/worker.py @@ -1,37 +1,24 @@ from __future__ import annotations -import json, tempfile -from pathlib import Path -from .repository import update_render_job, register_artifact -from .render_bundle import make_render_bundle +from .repository import get_evaluator_job, update_evaluator_job, load_learner_state, save_learner_state +from .engine import apply_evidence +from .models import EvidenceEvent +import time -def process_render_job(job_id: int, learner_id: str, pack_id: str, fmt: str, fps: int, theme: str, animation_payload: dict): - update_render_job(job_id, status="running") - try: - base = Path(tempfile.mkdtemp(prefix=f"didactopus_job_{job_id}_")) - payload_json = base / "animation_payload.json" - payload_json.write_text(json.dumps(animation_payload, indent=2), encoding="utf-8") - out_dir = base / "bundle" - make_render_bundle(str(payload_json), str(out_dir), fps=fps, fmt=fmt) - manifest_path = out_dir / "render_manifest.json" - script_path = out_dir / "render.sh" - update_render_job( - job_id, - status="completed", - bundle_dir=str(out_dir), - payload_json=str(payload_json), - manifest_path=str(manifest_path), - script_path=str(script_path), - error_text="", - ) - register_artifact( - render_job_id=job_id, - learner_id=learner_id, - pack_id=pack_id, - artifact_type="render_bundle", - fmt=fmt, - title=f"{pack_id} animation bundle", - path=str(out_dir), - metadata={"fps": fps, "theme": theme, "manifest_path": str(manifest_path), "script_path": str(script_path)}, - ) - except Exception as e: - update_render_job(job_id, status="failed", error_text=str(e)) +def process_job(job_id: int): + job = get_evaluator_job(job_id) + if job is None: + return + update_evaluator_job(job_id, "running", trace={"rubric_dimension_scores": [{"dimension": "mastery", "score": 0.35}], "notes": ["Job running", "Prototype trace generated"], "token_count_estimate": len(job.submitted_text.split())}) + 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." + trace = {"rubric_dimension_scores": [{"dimension": "mastery", "score": score}], "notes": ["Prototype evaluator completed", notes], "token_count_estimate": len(job.submitted_text.split()), "decision_basis": ["response length heuristic", "single-dimension mastery proxy"]} + update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes, trace=trace) + 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) + +def main(): + print("Didactopus worker scaffold running. Replace this with a real queue worker.") + while True: + time.sleep(60) diff --git a/tests/test_scaffold_files.py b/tests/test_scaffold_files.py index 6b26544..2e9b522 100644 --- a/tests/test_scaffold_files.py +++ b/tests/test_scaffold_files.py @@ -3,6 +3,5 @@ from pathlib import Path def test_scaffold_files_exist(): assert Path("src/didactopus/api.py").exists() assert Path("src/didactopus/repository.py").exists() - assert Path("src/didactopus/worker.py").exists() - assert Path("src/didactopus/render_bundle.py").exists() assert Path("webui/src/App.jsx").exists() + assert Path("webui/src/api.js").exists() diff --git a/webui/index.html b/webui/index.html index 0ef5c68..722e9d2 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3,10 +3,8 @@ - Didactopus API UI + Didactopus Contribution Management Layer - -
- +
diff --git a/webui/package.json b/webui/package.json index 7093a6c..69118be 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,17 +1,9 @@ { - "name": "didactopus-api-ui", + "name": "didactopus-contribution-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 769e4d0..307ff58 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -1,194 +1,243 @@ -import React, { useEffect, useMemo, useState } from "react"; -import { fetchPacks, fetchLearnerState, fetchRecommendations, postEvidence } from "./api"; -import { buildMasteryMap, progressPercent, milestoneMessages, claimReadiness } from "./localEngine"; +import React, { useEffect, useState } from "react"; +import { login, refresh, fetchPacks, createContribution, fetchAdminPacks, fetchPackValidation, fetchPackProvenance, fetchPackVersions, fetchPackComments, fetchSubmissions, fetchSubmissionDiff, fetchSubmissionGates, fetchReviewTasks, upsertPack, publishPack, governanceAction, addReviewComment } from "./api"; +import { loadAuth, saveAuth, clearAuth } from "./authStore"; -function DomainCard({ domain, selected, onSelect }) { +function LoginView({ onAuth }) { + const [username, setUsername] = useState("wesley"); + const [password, setPassword] = useState("demo-pass"); + const [error, setError] = useState(""); + async function doLogin() { + try { + const result = await login(username, password); + saveAuth(result); + onAuth(result); + } catch { setError("Login failed"); } + } return ( - +
+
+

Didactopus login

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

{step.title}

-
{step.minutes} minutes
-
-
{step.reward}
-
-

{step.reason}

-
- Why this is recommended - -
- +
+ + {role === "admin" ? <> + + + : null}
); } export default function App() { + const [auth, setAuth] = useState(loadAuth()); + const [tab, setTab] = useState("contribute"); const [packs, setPacks] = useState([]); - const [selectedDomainId, setSelectedDomainId] = useState(""); - const [learnerName, setLearnerName] = useState("Wesley"); - const [learnerState, setLearnerState] = useState(null); - const [cards, setCards] = useState([]); - const [lastReward, setLastReward] = useState(""); - const learnerId = "wesley-demo"; + const [adminPacks, setAdminPacks] = useState([]); + const [selectedPackId, setSelectedPackId] = useState(""); + const [validation, setValidation] = useState(null); + const [provenance, setProvenance] = useState(null); + const [versions, setVersions] = useState([]); + const [comments, setComments] = useState([]); + const [submissions, setSubmissions] = useState([]); + const [selectedSubmissionId, setSelectedSubmissionId] = useState(null); + const [submissionDiff, setSubmissionDiff] = useState(null); + const [submissionGates, setSubmissionGates] = useState(null); + const [reviewTasks, setReviewTasks] = useState([]); + const [commentText, setCommentText] = useState("Looks structurally plausible."); + const [reviewSummary, setReviewSummary] = useState("Reviewed and ready for next stage."); + const [message, setMessage] = useState(""); + const [contribPack, setContribPack] = useState({ + id: "bayes-pack", + title: "Bayesian Reasoning", + subtitle: "Contributor revision scaffold", + level: "novice-friendly", + concepts: [{ id: "prior", title: "Prior", prerequisites: [], masteryDimension: "mastery", exerciseReward: "Prior badge earned" }], + onboarding: { headline: "Start here", body: "Begin", checklist: [] }, + compliance: { sources: 1, attributionRequired: true, shareAlikeRequired: false, noncommercialOnly: false, flags: [] } + }); + + 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); + } + } useEffect(() => { + if (!auth) return; async function load() { - const loadedPacks = await fetchPacks(); - setPacks(loadedPacks); - const first = loadedPacks[0]?.id || ""; - setSelectedDomainId(first); - const state = await fetchLearnerState(learnerId); - setLearnerState(state); + const p = await guarded((token) => fetchPacks(token)); + setPacks(p); + setSelectedPackId((prev) => prev || p[0]?.id || ""); + if (auth.role === "admin") { + setAdminPacks(await guarded((token) => fetchAdminPacks(token))); + setSubmissions(await guarded((token) => fetchSubmissions(token))); + setReviewTasks(await guarded((token) => fetchReviewTasks(token))); + } } load(); - }, []); + }, [auth]); useEffect(() => { - async function loadCards() { - if (!selectedDomainId) return; - const data = await fetchRecommendations(learnerId, selectedDomainId); - setCards(data.cards || []); + if (!auth?.role || auth.role !== "admin" || !selectedPackId) return; + async function loadPackReview() { + setValidation(await guarded((token) => fetchPackValidation(token, selectedPackId))); + setProvenance(await guarded((token) => fetchPackProvenance(token, selectedPackId))); + setVersions(await guarded((token) => fetchPackVersions(token, selectedPackId))); + setComments(await guarded((token) => fetchPackComments(token, selectedPackId))); } - if (selectedDomainId) loadCards(); - }, [selectedDomainId, learnerState]); + loadPackReview(); + }, [auth, selectedPackId]); - 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]); + useEffect(() => { + if (!auth?.role || auth.role !== "admin" || !selectedSubmissionId) return; + async function loadSubmission() { + setSubmissionDiff(await guarded((token) => fetchSubmissionDiff(token, selectedSubmissionId))); + setSubmissionGates(await guarded((token) => fetchSubmissionGates(token, selectedSubmissionId))); + } + loadSubmission() + }, [auth, selectedSubmissionId]); - async function simulateStep(step) { - const nextState = await postEvidence(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); - setLastReward(step.reward); + async function submitContribution() { + const result = await guarded((token) => createContribution(token, { pack: contribPack, submission_summary: "Contributor-submitted revision from UI scaffold" })); + setMessage(`Submission created: ${result.submission_id}`); } - if (!domain || !learnerState) { - return
Loading backend data...
; + async function doGovernance(status) { + await guarded((token) => governanceAction(token, selectedPackId, { status, review_summary: reviewSummary })); + setAdminPacks(await guarded((token) => fetchAdminPacks(token))); + setVersions(await guarded((token) => fetchPackVersions(token, selectedPackId))); + setMessage(`Pack moved to ${status}`); } + async function addCommentNow() { + const versionNumber = versions[0]?.version_number || 1; + await guarded((token) => addReviewComment(token, selectedPackId, versionNumber, { comment_text: commentText, disposition: "comment" })); + setComments(await guarded((token) => fetchPackComments(token, selectedPackId))); + setMessage("Review comment added"); + } + + if (!auth) return ; + return (
-

Didactopus learner prototype

-

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

+

Didactopus contribution management layer

+

Contributor submissions, diffs, QA/provenance gates, and reviewer task queue scaffolding.

+
Signed in as {auth.username} ({auth.role})
+ {message ?
{message}
: null}
- + {auth.role === "admin" ? ( + + ) : ( + + )} +
-
- {packs.map((d) => )} -
+ -
-
+ {tab === "contribute" && ( +
-

First-session onboarding

-

{domain.onboarding.headline}

-

{domain.onboarding.body}

-

Learner: {learnerName}

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

Contributor submission

+ + + +