Apply ZIP update: 180-didactopus-contribution-management-layer.zip [2026-03-14T13:20:17]

This commit is contained in:
welsberr 2026-03-14 13:29:55 -04:00
parent efafa2f289
commit 3fa217c5bc
18 changed files with 917 additions and 497 deletions

View File

@ -10,7 +10,11 @@ dependencies = [
"pydantic>=2.7", "pydantic>=2.7",
"pyyaml>=6.0", "pyyaml>=6.0",
"fastapi>=0.115", "fastapi>=0.115",
"uvicorn>=0.30" "uvicorn>=0.30",
"sqlalchemy>=2.0",
"psycopg[binary]>=3.1",
"passlib[bcrypt]>=1.7",
"python-jose[cryptography]>=3.3"
] ]
[project.scripts] [project.scripts]

View File

@ -1,62 +1,208 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path import json
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
import uvicorn import uvicorn
from .models import LearnerState, EvidenceEvent from .config import load_settings
from .storage import FileStorage 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 .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" settings = load_settings()
storage = FileStorage(BASE_DIR) Base.metadata.create_all(bind=engine)
app = FastAPI(title="Didactopus API Prototype") app = FastAPI(title="Didactopus API Prototype")
app.add_middleware( app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
CORSMiddleware,
allow_origins=["*"], def current_user(authorization: str = Header(default="")):
allow_credentials=True, token = authorization.removeprefix("Bearer ").strip()
allow_methods=["*"], payload = decode_token(token) if token else None
allow_headers=["*"], 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") @app.get("/api/packs")
def list_packs(): def api_list_packs(user = Depends(current_user)):
return [p.model_dump() for p in storage.list_packs()] return [p.model_dump() for p in list_packs(include_unpublished=(user.role == "admin"))]
@app.get("/api/packs/{pack_id}") @app.post("/api/contributions")
def get_pack(pack_id: str): def api_create_contribution(payload: ContributionSubmissionCreate, user = Depends(current_user)):
pack = storage.get_pack(pack_id) submission_id = create_submission(payload.pack, user.id, payload.submission_summary)
if pack is None: 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") 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") @app.get("/api/learners/{learner_id}/state")
def get_learner_state(learner_id: str): def api_get_learner_state(learner_id: str, user = Depends(current_user)):
return storage.get_learner_state(learner_id).model_dump() ensure_learner_access(user, learner_id)
return load_learner_state(learner_id).model_dump()
@app.put("/api/learners/{learner_id}/state") @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: if learner_id != state.learner_id:
raise HTTPException(status_code=400, detail="Learner ID mismatch") raise HTTPException(status_code=400, detail="Learner ID mismatch")
return storage.save_learner_state(state).model_dump() return save_learner_state(state).model_dump()
@app.post("/api/learners/{learner_id}/evidence") @app.post("/api/learners/{learner_id}/evidence")
def post_evidence(learner_id: str, event: EvidenceEvent): def api_post_evidence(learner_id: str, event: EvidenceEvent, user = Depends(current_user)):
state = storage.get_learner_state(learner_id) ensure_learner_access(user, learner_id)
state = load_learner_state(learner_id)
state = apply_evidence(state, event) state = apply_evidence(state, event)
storage.save_learner_state(state) save_learner_state(state)
return state.model_dump() return state.model_dump()
@app.get("/api/learners/{learner_id}/recommendations/{pack_id}") @app.get("/api/learners/{learner_id}/recommendations/{pack_id}")
def get_recommendations(learner_id: str, pack_id: str): def api_get_recommendations(learner_id: str, pack_id: str, user = Depends(current_user)):
state = storage.get_learner_state(learner_id) ensure_learner_access(user, learner_id)
pack = storage.get_pack(pack_id) state = load_learner_state(learner_id)
pack = get_pack(pack_id)
if pack is None: if pack is None:
raise HTTPException(status_code=404, detail="Pack not found") raise HTTPException(status_code=404, detail="Pack not found")
return {"cards": recommend_next(state, pack)} return {"cards": recommend_next(state, pack)}
def main(): @app.post("/api/learners/{learner_id}/evaluator-jobs", response_model=EvaluatorJobStatus)
uvicorn.run(app, host="127.0.0.1", port=8011) 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__": @app.get("/api/evaluator-jobs/{job_id}", response_model=EvaluatorJobStatus)
main() 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)

View File

@ -1,8 +1,12 @@
from __future__ import annotations from __future__ import annotations
import secrets from datetime import datetime, timedelta, timezone
from jose import jwt, JWTError
from passlib.context import CryptContext from passlib.context import CryptContext
import secrets
from .config import load_settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
settings = load_settings()
def hash_password(password: str) -> str: def hash_password(password: str) -> str:
return pwd_context.hash(password) return pwd_context.hash(password)
@ -10,5 +14,22 @@ def hash_password(password: str) -> str:
def verify_password(password: str, password_hash: str) -> bool: def verify_password(password: str, password_hash: str) -> bool:
return pwd_context.verify(password, password_hash) 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) return secrets.token_urlsafe(24)

View File

@ -1,10 +1,13 @@
from pathlib import Path from __future__ import annotations
import os
from pydantic import BaseModel from pydantic import BaseModel
class Settings(BaseModel): class Settings(BaseModel):
database_url: str = "sqlite:///./didactopus.db" database_url: str = os.getenv("DIDACTOPUS_DATABASE_URL", "postgresql+psycopg://didactopus:didactopus-dev-password@localhost:5432/didactopus")
host: str = "127.0.0.1" host: str = os.getenv("DIDACTOPUS_HOST", "127.0.0.1")
port: int = 8011 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: def load_settings() -> Settings:
return Settings() return Settings()

View File

@ -3,7 +3,6 @@ from sqlalchemy.orm import declarative_base, sessionmaker
from .config import load_settings from .config import load_settings
settings = 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)
engine = create_engine(settings.database_url, future=True, connect_args=connect_args)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
Base = declarative_base() Base = declarative_base()

View File

@ -7,30 +7,14 @@ def get_record(state: LearnerState, concept_id: str, dimension: str = "mastery")
return rec return rec
return None return None
def apply_evidence( def apply_evidence(state: LearnerState, event: EvidenceEvent, decay: float = 0.05, reinforcement: float = 0.25) -> LearnerState:
state: LearnerState,
event: EvidenceEvent,
decay: float = 0.05,
reinforcement: float = 0.25,
) -> LearnerState:
rec = get_record(state, event.concept_id, event.dimension) rec = get_record(state, event.concept_id, event.dimension)
if rec is None: if rec is None:
rec = MasteryRecord( rec = MasteryRecord(concept_id=event.concept_id, dimension=event.dimension, score=0.0, confidence=0.0, evidence_count=0, last_updated=event.timestamp)
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) state.records.append(rec)
weight = max(0.05, min(1.0, event.confidence_hint)) 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.score = ((rec.score * rec.evidence_count) + (event.score * weight)) / max(1, rec.evidence_count + 1)
rec.confidence = min( 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))))
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.evidence_count += 1
rec.last_updated = event.timestamp rec.last_updated = event.timestamp
state.history.append(event) state.history.append(event)
@ -61,11 +45,7 @@ def recommend_next(state: LearnerState, pack: PackData) -> list[dict]:
"id": concept.id, "id": concept.id,
"title": f"Work on {concept.title}", "title": f"Work on {concept.title}",
"minutes": 15 if status == "available" else 10, "minutes": 15 if status == "available" else 10,
"reason": ( "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.",
"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": [ "why": [
"Prerequisite check passed", "Prerequisite check passed",
f"Current score: {rec.score:.2f}" if rec else "No evidence recorded yet", 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, "scoreHint": 0.82 if status == "available" else 0.76,
"confidenceHint": 0.72 if status == "available" else 0.55, "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] return cards[:4]

View File

@ -4,6 +4,20 @@ from typing import Literal
EvidenceKind = Literal["checkpoint", "project", "exercise", "review"] 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): class PackConcept(BaseModel):
id: str id: str
title: str title: str
@ -27,6 +41,27 @@ class PackData(BaseModel):
onboarding: dict = Field(default_factory=dict) onboarding: dict = Field(default_factory=dict)
compliance: PackCompliance = Field(default_factory=PackCompliance) 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): class MasteryRecord(BaseModel):
concept_id: str concept_id: str
dimension: str dimension: str
@ -48,3 +83,16 @@ class LearnerState(BaseModel):
learner_id: str learner_id: str
records: list[MasteryRecord] = Field(default_factory=list) records: list[MasteryRecord] = Field(default_factory=list)
history: list[EvidenceEvent] = Field(default_factory=list) history: list[EvidenceEvent] = Field(default_factory=list)
class EvaluatorSubmission(BaseModel):
pack_id: str
concept_id: str
submitted_text: str
kind: str = "checkpoint"
class EvaluatorJobStatus(BaseModel):
job_id: int
status: str
result_score: float | None = None
result_confidence_hint: float | None = None
result_notes: str = ""

View File

@ -1,5 +1,5 @@
from sqlalchemy import String, Integer, Float, Boolean, ForeignKey, Text from sqlalchemy import String, Integer, Float, ForeignKey, Text, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column
from .db import Base from .db import Base
class UserORM(Base): class UserORM(Base):
@ -7,7 +7,15 @@ class UserORM(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(100), unique=True, index=True) username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255)) 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): class PackORM(Base):
__tablename__ = "packs" __tablename__ = "packs"
@ -16,13 +24,61 @@ class PackORM(Base):
subtitle: Mapped[str] = mapped_column(Text, default="") subtitle: Mapped[str] = mapped_column(Text, default="")
level: Mapped[str] = mapped_column(String(100), default="novice-friendly") level: Mapped[str] = mapped_column(String(100), default="novice-friendly")
data_json: Mapped[str] = mapped_column(Text) 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): class LearnerORM(Base):
__tablename__ = "learners" __tablename__ = "learners"
id: Mapped[str] = mapped_column(String(100), primary_key=True) id: Mapped[str] = mapped_column(String(100), primary_key=True)
owner_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) owner_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
display_name: Mapped[str] = mapped_column(String(255), default="") display_name: Mapped[str] = mapped_column(String(255), default="")
owner = relationship("UserORM")
class MasteryRecordORM(Base): class MasteryRecordORM(Base):
__tablename__ = "mastery_records" __tablename__ = "mastery_records"
@ -34,7 +90,6 @@ class MasteryRecordORM(Base):
confidence: Mapped[float] = mapped_column(Float, default=0.0) confidence: Mapped[float] = mapped_column(Float, default=0.0)
evidence_count: Mapped[int] = mapped_column(Integer, default=0) evidence_count: Mapped[int] = mapped_column(Integer, default=0)
last_updated: Mapped[str] = mapped_column(String(100), default="") last_updated: Mapped[str] = mapped_column(String(100), default="")
learner = relationship("LearnerORM")
class EvidenceEventORM(Base): class EvidenceEventORM(Base):
__tablename__ = "evidence_events" __tablename__ = "evidence_events"
@ -47,7 +102,6 @@ class EvidenceEventORM(Base):
timestamp: Mapped[str] = mapped_column(String(100), default="") timestamp: Mapped[str] = mapped_column(String(100), default="")
kind: Mapped[str] = mapped_column(String(50), default="exercise") kind: Mapped[str] = mapped_column(String(50), default="exercise")
source_id: Mapped[str] = mapped_column(String(255), default="") source_id: Mapped[str] = mapped_column(String(255), default="")
learner = relationship("LearnerORM")
class EvaluatorJobORM(Base): class EvaluatorJobORM(Base):
__tablename__ = "evaluator_jobs" __tablename__ = "evaluator_jobs"
@ -60,5 +114,4 @@ class EvaluatorJobORM(Base):
result_score: Mapped[float | None] = mapped_column(Float, nullable=True) result_score: Mapped[float | None] = mapped_column(Float, nullable=True)
result_confidence_hint: 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="") result_notes: Mapped[str] = mapped_column(Text, default="")
learner = relationship("LearnerORM") trace_json: Mapped[str] = mapped_column(Text, default="{}")
pack = relationship("PackORM")

View File

@ -1,124 +1,341 @@
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from .db import SessionLocal 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 .models import PackData, LearnerState, MasteryRecord, EvidenceEvent
from .auth import verify_password from .auth import verify_password
def get_user_by_token(token: str) -> UserORM | None: def now_iso() -> str:
with SessionLocal() as db: return datetime.now(timezone.utc).isoformat()
return db.execute(select(UserORM).where(UserORM.token == token)).scalar_one_or_none()
def authenticate_user(username: str, password: str) -> UserORM | None: 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 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: with SessionLocal() as db:
user = db.execute(select(UserORM).where(UserORM.username == username)).scalar_one_or_none() return db.execute(select(UserORM).where(UserORM.username == username)).scalar_one_or_none()
if user is None:
return None def get_user_by_id(user_id: int):
if not verify_password(password, user.password_hash): 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 None
return user return user
def list_packs() -> list[PackData]: def store_refresh_token(user_id: int, token_id: str):
with SessionLocal() as db: with SessionLocal() as db:
rows = db.execute(select(PackORM)).scalars().all() 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] 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: with SessionLocal() as db:
row = db.get(PackORM, pack_id) row = db.get(PackORM, pack_id)
if row is None: if row is None:
return None return False
return PackData.model_validate(json.loads(row.data_json)) 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: with SessionLocal() as db:
if db.get(LearnerORM, learner_id) is None: 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.add(LearnerORM(id=learner_id, owner_user_id=owner_user_id, display_name=display_name))
db.commit() 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: def learner_owned_by_user(user_id: int, learner_id: str) -> bool:
with SessionLocal() as db: with SessionLocal() as db:
learner = db.get(LearnerORM, learner_id) learner = db.get(LearnerORM, learner_id)
return learner is not None and learner.owner_user_id == user_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: with SessionLocal() as db:
records = db.execute(select(MasteryRecordORM).where(MasteryRecordORM.learner_id == learner_id)).scalars().all() 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() history = db.execute(select(EvidenceEventORM).where(EvidenceEventORM.learner_id == learner_id)).scalars().all()
return LearnerState( return LearnerState(
learner_id=learner_id, learner_id=learner_id,
records=[ 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],
MasteryRecord( 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],
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: 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(MasteryRecordORM).filter(MasteryRecordORM.learner_id == state.learner_id).delete()
db.query(EvidenceEventORM).filter(EvidenceEventORM.learner_id == state.learner_id).delete() db.query(EvidenceEventORM).filter(EvidenceEventORM.learner_id == state.learner_id).delete()
for r in state.records: for r in state.records:
db.add(MasteryRecordORM( 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))
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: for h in state.history:
db.add(EvidenceEventORM( 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))
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() db.commit()
return state 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: with SessionLocal() as db:
job = EvaluatorJobORM( trace = {"rubric_dimension_scores": [{"dimension": "mastery", "score": 0.0}], "notes": ["Job queued", "Awaiting evaluator"], "token_count_estimate": len(submitted_text.split())}
learner_id=learner_id, 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))
pack_id=pack_id,
concept_id=concept_id,
submitted_text=submitted_text,
status="queued",
)
db.add(job) db.add(job)
db.commit() db.commit()
db.refresh(job) db.refresh(job)
return job.id 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: with SessionLocal() as db:
return db.get(EvaluatorJobORM, job_id) 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: with SessionLocal() as db:
job = db.get(EvaluatorJobORM, job_id) job = db.get(EvaluatorJobORM, job_id)
if job is None: 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_score = score
job.result_confidence_hint = confidence_hint job.result_confidence_hint = confidence_hint
job.result_notes = notes job.result_notes = notes
if trace is not None:
job.trace_json = json.dumps(trace)
db.commit() db.commit()

View File

@ -1,79 +1,34 @@
from __future__ import annotations 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 from sqlalchemy import select
from .db import Base, engine, SessionLocal
PACKS = [ from .orm import UserORM
{ from .auth import hash_password
"id": "bayes-pack", from .repository import upsert_pack
"title": "Bayesian Reasoning", from .models import PackData, PackConcept, PackCompliance
"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(): def main():
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
with SessionLocal() as db: with SessionLocal() as db:
existing = db.execute(select(UserORM).where(UserORM.username == "wesley")).scalar_one_or_none() if db.execute(select(UserORM).where(UserORM.username == "wesley")).scalar_one_or_none() is None:
if existing is None: db.add(UserORM(username="wesley", password_hash=hash_password("demo-pass"), role="admin", is_active=True))
db.add(UserORM(username="wesley", password_hash=hash_password("demo-pass"), token=issue_token())) if db.execute(select(UserORM).where(UserORM.username == "contrib")).scalar_one_or_none() is None:
for pack in PACKS: db.add(UserORM(username="contrib", password_hash=hash_password("demo-pass"), role="learner", is_active=True))
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() db.commit()
print("Seeded database. Demo user: wesley / demo-pass") upsert_pack(
PackData(
if __name__ == "__main__": id="bayes-pack",
main() 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")

View File

@ -1,37 +1,24 @@
from __future__ import annotations from __future__ import annotations
import json, tempfile from .repository import get_evaluator_job, update_evaluator_job, load_learner_state, save_learner_state
from pathlib import Path from .engine import apply_evidence
from .repository import update_render_job, register_artifact from .models import EvidenceEvent
from .render_bundle import make_render_bundle import time
def process_render_job(job_id: int, learner_id: str, pack_id: str, fmt: str, fps: int, theme: str, animation_payload: dict): def process_job(job_id: int):
update_render_job(job_id, status="running") job = get_evaluator_job(job_id)
try: if job is None:
base = Path(tempfile.mkdtemp(prefix=f"didactopus_job_{job_id}_")) return
payload_json = base / "animation_payload.json" 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())})
payload_json.write_text(json.dumps(animation_payload, indent=2), encoding="utf-8") score = 0.78 if len(job.submitted_text.strip()) > 20 else 0.62
out_dir = base / "bundle" confidence_hint = 0.72 if len(job.submitted_text.strip()) > 20 else 0.45
make_render_bundle(str(payload_json), str(out_dir), fps=fps, fmt=fmt) notes = "Prototype evaluator: longer responses scored somewhat higher."
manifest_path = out_dir / "render_manifest.json" 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"]}
script_path = out_dir / "render.sh" update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes, trace=trace)
update_render_job( state = load_learner_state(job.learner_id)
job_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}"))
status="completed", save_learner_state(state)
bundle_dir=str(out_dir),
payload_json=str(payload_json), def main():
manifest_path=str(manifest_path), print("Didactopus worker scaffold running. Replace this with a real queue worker.")
script_path=str(script_path), while True:
error_text="", time.sleep(60)
)
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))

View File

@ -3,6 +3,5 @@ from pathlib import Path
def test_scaffold_files_exist(): def test_scaffold_files_exist():
assert Path("src/didactopus/api.py").exists() assert Path("src/didactopus/api.py").exists()
assert Path("src/didactopus/repository.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/App.jsx").exists()
assert Path("webui/src/api.js").exists()

View File

@ -3,10 +3,8 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Didactopus API UI</title> <title>Didactopus Contribution Management Layer</title>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</head> </head>
<body> <body><div id="root"></div></body>
<div id="root"></div>
</body>
</html> </html>

View File

@ -1,17 +1,9 @@
{ {
"name": "didactopus-api-ui", "name": "didactopus-contribution-ui",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": { "dev": "vite", "build": "vite build" },
"dev": "vite", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" },
"build": "vite build" "devDependencies": { "vite": "^5.4.0" }
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"vite": "^5.4.0"
}
} }

View File

@ -1,194 +1,243 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useState } from "react";
import { fetchPacks, fetchLearnerState, fetchRecommendations, postEvidence } from "./api"; import { login, refresh, fetchPacks, createContribution, fetchAdminPacks, fetchPackValidation, fetchPackProvenance, fetchPackVersions, fetchPackComments, fetchSubmissions, fetchSubmissionDiff, fetchSubmissionGates, fetchReviewTasks, upsertPack, publishPack, governanceAction, addReviewComment } from "./api";
import { buildMasteryMap, progressPercent, milestoneMessages, claimReadiness } from "./localEngine"; 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 ( return (
<button className={`domain-card ${selected ? "selected" : ""}`} onClick={() => onSelect(domain.id)}> <div className="page narrow-page">
<div className="domain-title">{domain.title}</div> <section className="card narrow">
<div className="domain-subtitle">{domain.subtitle}</div> <h1>Didactopus login</h1>
<div className="domain-meta"> <label>Username<input value={username} onChange={(e) => setUsername(e.target.value)} /></label>
<span>{domain.level}</span> <label>Password<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} /></label>
<span>{domain.concepts.length} concepts</span> <button className="primary" onClick={doLogin}>Login</button>
{error ? <div className="error">{error}</div> : null}
</section>
</div> </div>
</button>
); );
} }
function NextStepCard({ step, onSimulate }) { function NavTabs({ tab, setTab, role }) {
return ( return (
<div className="step-card"> <div className="tab-row">
<div className="step-header"> <button className={tab==="contribute" ? "active-tab" : ""} onClick={() => setTab("contribute")}>Contribute</button>
<div> {role === "admin" ? <>
<h3>{step.title}</h3> <button className={tab==="submissions" ? "active-tab" : ""} onClick={() => setTab("submissions")}>Submissions</button>
<div className="muted">{step.minutes} minutes</div> <button className={tab==="review" ? "active-tab" : ""} onClick={() => setTab("review")}>Governance</button>
</div> </> : null}
<div className="reward-pill">{step.reward}</div>
</div>
<p>{step.reason}</p>
<details>
<summary>Why this is recommended</summary>
<ul>{step.why.map((item, idx) => <li key={idx}>{item}</li>)}</ul>
</details>
<button className="primary" onClick={() => onSimulate(step)}>Simulate completing this step</button>
</div> </div>
); );
} }
export default function App() { export default function App() {
const [auth, setAuth] = useState(loadAuth());
const [tab, setTab] = useState("contribute");
const [packs, setPacks] = useState([]); const [packs, setPacks] = useState([]);
const [selectedDomainId, setSelectedDomainId] = useState(""); const [adminPacks, setAdminPacks] = useState([]);
const [learnerName, setLearnerName] = useState("Wesley"); const [selectedPackId, setSelectedPackId] = useState("");
const [learnerState, setLearnerState] = useState(null); const [validation, setValidation] = useState(null);
const [cards, setCards] = useState([]); const [provenance, setProvenance] = useState(null);
const [lastReward, setLastReward] = useState(""); const [versions, setVersions] = useState([]);
const learnerId = "wesley-demo"; 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(() => { useEffect(() => {
if (!auth) return;
async function load() { async function load() {
const loadedPacks = await fetchPacks(); const p = await guarded((token) => fetchPacks(token));
setPacks(loadedPacks); setPacks(p);
const first = loadedPacks[0]?.id || ""; setSelectedPackId((prev) => prev || p[0]?.id || "");
setSelectedDomainId(first); if (auth.role === "admin") {
const state = await fetchLearnerState(learnerId); setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
setLearnerState(state); setSubmissions(await guarded((token) => fetchSubmissions(token)));
setReviewTasks(await guarded((token) => fetchReviewTasks(token)));
}
} }
load(); load();
}, []); }, [auth]);
useEffect(() => { useEffect(() => {
async function loadCards() { if (!auth?.role || auth.role !== "admin" || !selectedPackId) return;
if (!selectedDomainId) return; async function loadPackReview() {
const data = await fetchRecommendations(learnerId, selectedDomainId); setValidation(await guarded((token) => fetchPackValidation(token, selectedPackId)));
setCards(data.cards || []); setProvenance(await guarded((token) => fetchPackProvenance(token, selectedPackId)));
setVersions(await guarded((token) => fetchPackVersions(token, selectedPackId)));
setComments(await guarded((token) => fetchPackComments(token, selectedPackId)));
} }
if (selectedDomainId) loadCards(); loadPackReview();
}, [selectedDomainId, learnerState]); }, [auth, selectedPackId]);
const domain = useMemo(() => packs.find((d) => d.id === selectedDomainId) || null, [packs, selectedDomainId]); useEffect(() => {
const masteryMap = useMemo(() => domain && learnerState ? buildMasteryMap(learnerState, domain) : [], [learnerState, domain]); if (!auth?.role || auth.role !== "admin" || !selectedSubmissionId) return;
const progress = useMemo(() => domain && learnerState ? progressPercent(learnerState, domain) : 0, [learnerState, domain]); async function loadSubmission() {
const milestones = useMemo(() => domain && learnerState ? milestoneMessages(learnerState, domain) : [], [learnerState, domain]); setSubmissionDiff(await guarded((token) => fetchSubmissionDiff(token, selectedSubmissionId)));
const readiness = useMemo(() => domain && learnerState ? claimReadiness(learnerState, domain) : {ready:false, mastered:0, avgScore:0, avgConfidence:0}, [learnerState, domain]); setSubmissionGates(await guarded((token) => fetchSubmissionGates(token, selectedSubmissionId)));
}
loadSubmission()
}, [auth, selectedSubmissionId]);
async function simulateStep(step) { async function submitContribution() {
const nextState = await postEvidence(learnerId, { const result = await guarded((token) => createContribution(token, { pack: contribPack, submission_summary: "Contributor-submitted revision from UI scaffold" }));
concept_id: step.conceptId, setMessage(`Submission created: ${result.submission_id}`);
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);
} }
if (!domain || !learnerState) { async function doGovernance(status) {
return <div className="page"><div className="card">Loading backend data...</div></div>; 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 <LoginView onAuth={setAuth} />;
return ( return (
<div className="page"> <div className="page">
<header className="hero"> <header className="hero">
<div> <div>
<h1>Didactopus learner prototype</h1> <h1>Didactopus contribution management layer</h1>
<p>Backend-driven pack registry, learner-state persistence, and evaluator-style evidence ingestion.</p> <p>Contributor submissions, diffs, QA/provenance gates, and reviewer task queue scaffolding.</p>
<div className="muted">Signed in as {auth.username} ({auth.role})</div>
{message ? <div className="message">{message}</div> : null}
</div> </div>
<div className="hero-controls"> <div className="hero-controls">
<label> {auth.role === "admin" ? (
Learner name <label>Pack<select value={selectedPackId} onChange={(e) => setSelectedPackId(e.target.value)}>{adminPacks.map((p) => <option key={p.id} value={p.id}>{p.title}</option>)}</select></label>
<input value={learnerName} onChange={(e) => setLearnerName(e.target.value)} /> ) : (
</label> <label>Base pack<select value={contribPack.id} onChange={(e) => setContribPack({ ...contribPack, id: e.target.value })}>{packs.map((p) => <option key={p.id} value={p.id}>{p.title}</option>)}</select></label>
)}
<button onClick={() => { clearAuth(); setAuth(null); }}>Logout</button>
</div> </div>
</header> </header>
<section className="domain-grid"> <NavTabs tab={tab} setTab={setTab} role={auth.role} />
{packs.map((d) => <DomainCard key={d.id} domain={d} selected={d.id === domain.id} onSelect={setSelectedDomainId} />)}
</section>
<main className="layout"> {tab === "contribute" && (
<div className="left-col"> <main className="layout onecol">
<section className="card"> <section className="card">
<h2>First-session onboarding</h2> <h2>Contributor submission</h2>
<h3>{domain.onboarding.headline}</h3> <label>Pack title<input value={contribPack.title} onChange={(e) => setContribPack({ ...contribPack, title: e.target.value })} /></label>
<p>{domain.onboarding.body}</p> <label>Subtitle<input value={contribPack.subtitle} onChange={(e) => setContribPack({ ...contribPack, subtitle: e.target.value })} /></label>
<p className="muted">Learner: {learnerName}</p> <label>Onboarding headline<input value={contribPack.onboarding.headline} onChange={(e) => setContribPack({ ...contribPack, onboarding: { ...contribPack.onboarding, headline: e.target.value } })} /></label>
<ul>{(domain.onboarding.checklist || []).map((item, idx) => <li key={idx}>{item}</li>)}</ul> <label>Onboarding body<textarea value={contribPack.onboarding.body} onChange={(e) => setContribPack({ ...contribPack, onboarding: { ...contribPack.onboarding, body: e.target.value } })} /></label>
<button className="primary" onClick={submitContribution}>Submit contribution</button>
<pre className="prebox">{JSON.stringify(contribPack, null, 2)}</pre>
</section> </section>
<section className="card">
<h2>Visible mastery map</h2>
<div className="map-grid">
{masteryMap.map((node) => (
<div key={node.id} className={`map-node ${node.status}`}>
<div className="node-label">{node.label}</div>
<div className="node-status">{node.status}</div>
</div>
))}
</div>
</section>
<section className="card">
<h2>Evidence log</h2>
{learnerState.history.length === 0 ? <div className="muted">No evidence recorded yet.</div> : (
<ul>
{learnerState.history.slice().reverse().map((item, idx) => (
<li key={idx}>{item.concept_id} · score {item.score.toFixed(2)} · confidence hint {item.confidence_hint.toFixed(2)}</li>
))}
</ul>
)}
</section>
</div>
<div className="center-col">
<section className="card">
<h2>What should I do next?</h2>
{cards.length === 0 ? <div className="muted">No immediate recommendation available.</div> : (
<div className="steps-stack">
{cards.map((step) => <NextStepCard key={step.id} step={step} onSimulate={simulateStep} />)}
</div>
)}
</section>
</div>
<div className="right-col">
<section className="card">
<h2>Progress</h2>
<div className="progress-wrap">
<div className="progress-label">Mastery progress</div>
<div className="progress-bar"><div className="progress-fill" style={{ width: `${progress}%` }} /></div>
<div className="muted">{progress}%</div>
</div>
<div className={`readiness-box ${readiness.ready ? "ready" : ""}`}>
<strong>{readiness.ready ? "Usable expertise threshold met" : "Still building toward usable expertise"}</strong>
<div className="muted">Mastered concepts: {readiness.mastered}</div>
<div className="muted">Average score: {readiness.avgScore.toFixed(2)}</div>
<div className="muted">Average confidence: {readiness.avgConfidence.toFixed(2)}</div>
</div>
</section>
<section className="card">
<h2>Milestones and rewards</h2>
{lastReward ? <div className="reward-banner">{lastReward}</div> : null}
<ul>{milestones.map((m, idx) => <li key={idx}>{m}</li>)}</ul>
</section>
<section className="card">
<h2>Source attribution and compliance</h2>
<div className="compliance-grid">
<div><strong>Sources</strong><br />{domain.compliance.sources}</div>
<div><strong>Attribution</strong><br />{domain.compliance.attributionRequired ? "required" : "not required"}</div>
<div><strong>Share-alike</strong><br />{domain.compliance.shareAlikeRequired ? "yes" : "no"}</div>
<div><strong>Noncommercial</strong><br />{domain.compliance.noncommercialOnly ? "yes" : "no"}</div>
</div>
<div className="flag-row">
{domain.compliance.flags.length ? domain.compliance.flags.map((f) => <span className="flag" key={f}>{f}</span>) : <span className="muted">No extra flags</span>}
</div>
</section>
</div>
</main> </main>
)}
{tab === "submissions" && auth.role === "admin" && (
<main className="layout twocol">
<section className="card">
<h2>Submission queue</h2>
<table className="table">
<thead><tr><th>ID</th><th>Pack</th><th>Version</th><th>Status</th><th>Select</th></tr></thead>
<tbody>
{submissions.map((s) => (
<tr key={s.submission_id}>
<td>{s.submission_id}</td>
<td>{s.pack_id}</td>
<td>{s.proposed_version_number}</td>
<td>{s.status}</td>
<td><button onClick={() => setSelectedSubmissionId(s.submission_id)}>Inspect</button></td>
</tr>
))}
</tbody>
</table>
<h3>Review tasks</h3>
<pre className="prebox">{JSON.stringify(reviewTasks, null, 2)}</pre>
</section>
<section className="card">
<h2>Submission diff and gates</h2>
<h3>Diff summary</h3>
<pre className="prebox">{JSON.stringify(submissionDiff, null, 2)}</pre>
<h3>Gate summary</h3>
<pre className="prebox">{JSON.stringify(submissionGates, null, 2)}</pre>
</section>
</main>
)}
{tab === "review" && auth.role === "admin" && (
<main className="layout twocol">
<section className="card">
<h2>Governance and approval gates</h2>
<div className="button-row">
<button onClick={() => doGovernance("in_review")}>Move to in_review</button>
<button onClick={() => doGovernance("approved")}>Approve</button>
<button onClick={() => doGovernance("rejected")}>Reject</button>
<button onClick={() => publishPack(auth.access_token, selectedPackId, true)}>Publish directly</button>
</div>
<label>Review summary<textarea value={reviewSummary} onChange={(e) => setReviewSummary(e.target.value)} /></label>
<h3>Validation</h3>
<pre className="prebox">{JSON.stringify(validation, null, 2)}</pre>
<h3>Provenance</h3>
<pre className="prebox">{JSON.stringify(provenance, null, 2)}</pre>
</section>
<section className="card">
<h2>Versions and comments</h2>
<h3>Versions</h3>
<pre className="prebox">{JSON.stringify(versions, null, 2)}</pre>
<label>Reviewer comment<textarea value={commentText} onChange={(e) => setCommentText(e.target.value)} /></label>
<button className="primary" onClick={addCommentNow}>Add comment</button>
<h3>Comments</h3>
<pre className="prebox">{JSON.stringify(comments, null, 2)}</pre>
</section>
</main>
)}
</div> </div>
); );
} }

View File

@ -1,25 +1,33 @@
const API = "http://127.0.0.1:8011/api"; const API = "http://127.0.0.1:8011/api";
export async function fetchPacks() { function authHeaders(token, json=true) {
const res = await fetch(`${API}/packs`); const h = { Authorization: `Bearer ${token}` };
return await res.json(); if (json) h["Content-Type"] = "application/json";
return h;
} }
export async function fetchLearnerState(learnerId) { export async function login(username, password) {
const res = await fetch(`${API}/learners/${learnerId}/state`); 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(); return await res.json();
} }
export async function refresh(refreshToken) {
export async function postEvidence(learnerId, event) { const res = await fetch(`${API}/refresh`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: refreshToken }) });
const res = await fetch(`${API}/learners/${learnerId}/evidence`, { if (!res.ok) throw new Error("refresh failed");
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(event)
});
return await res.json();
}
export async function fetchRecommendations(learnerId, packId) {
const res = await fetch(`${API}/learners/${learnerId}/recommendations/${packId}`);
return await res.json(); 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 createContribution(token, payload) { const res = await fetch(`${API}/contributions`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("createContribution failed"); return await res.json(); }
export async function fetchAdminPacks(token) { const res = await fetch(`${API}/admin/packs`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchAdminPacks failed"); return await res.json(); }
export async function fetchPackValidation(token, packId) { const res = await fetch(`${API}/admin/packs/${packId}/validation`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPackValidation failed"); return await res.json(); }
export async function fetchPackProvenance(token, packId) { const res = await fetch(`${API}/admin/packs/${packId}/provenance`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPackProvenance failed"); return await res.json(); }
export async function fetchPackVersions(token, packId) { const res = await fetch(`${API}/admin/packs/${packId}/versions`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPackVersions failed"); return await res.json(); }
export async function fetchPackComments(token, packId) { const res = await fetch(`${API}/admin/packs/${packId}/comments`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPackComments failed"); return await res.json(); }
export async function fetchSubmissions(token) { const res = await fetch(`${API}/admin/submissions`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchSubmissions failed"); return await res.json(); }
export async function fetchSubmissionDiff(token, submissionId) { const res = await fetch(`${API}/admin/submissions/${submissionId}/diff`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchSubmissionDiff failed"); return await res.json(); }
export async function fetchSubmissionGates(token, submissionId) { const res = await fetch(`${API}/admin/submissions/${submissionId}/gates`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchSubmissionGates failed"); return await res.json(); }
export async function fetchReviewTasks(token) { const res = await fetch(`${API}/admin/review-tasks`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchReviewTasks failed"); return await res.json(); }
export async function upsertPack(token, payload) { const res = await fetch(`${API}/admin/packs`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("upsertPack failed"); return await res.json(); }
export async function publishPack(token, packId, isPublished) { const res = await fetch(`${API}/admin/packs/${packId}/publish?is_published=${isPublished}`, { method: "POST", headers: authHeaders(token, false) }); if (!res.ok) throw new Error("publishPack failed"); return await res.json(); }
export async function governanceAction(token, packId, payload) { const res = await fetch(`${API}/admin/packs/${packId}/governance`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("governanceAction failed"); return await res.json(); }
export async function addReviewComment(token, packId, versionNumber, payload) { const res = await fetch(`${API}/admin/packs/${packId}/comments?version_number=${versionNumber}`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("addReviewComment failed"); return await res.json(); }

View File

@ -2,5 +2,4 @@ import React from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import App from "./App"; import App from "./App";
import "./styles.css"; import "./styles.css";
createRoot(document.getElementById("root")).render(<App />); createRoot(document.getElementById("root")).render(<App />);

View File

@ -1,52 +1,30 @@
:root { :root {
--bg: #f6f8fb; --bg:#f6f8fb; --card:#ffffff; --text:#1f2430; --muted:#60697a; --border:#dbe1ea; --accent:#2d6cdf; --soft:#eef4ff;
--card: #ffffff;
--text: #1f2430;
--muted: #60697a;
--border: #dbe1ea;
--accent: #2d6cdf;
--soft: #eef4ff;
} }
* { box-sizing: border-box; } * { box-sizing:border-box; }
body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: var(--bg); color: var(--text); } body { margin:0; font-family:Arial, Helvetica, sans-serif; background:var(--bg); color:var(--text); }
.page { max-width: 1500px; margin: 0 auto; padding: 20px; } .page { max-width:1500px; margin:0 auto; padding:24px; }
.hero { background: var(--card); border: 1px solid var(--border); border-radius: 22px; padding: 24px; display: flex; justify-content: space-between; gap: 16px; } .narrow-page { max-width:520px; }
.hero-controls { min-width: 260px; } .hero { background:var(--card); border:1px solid var(--border); border-radius:22px; padding:24px; display:flex; justify-content:space-between; gap:16px; }
.hero-controls input { width: 100%; margin-top: 6px; border: 1px solid var(--border); border-radius: 10px; padding: 10px; font: inherit; } .hero-controls { min-width:280px; display:flex; flex-direction:column; gap:12px; }
.domain-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-top: 16px; } label { display:block; font-weight:600; }
.domain-card { border: 1px solid var(--border); background: var(--card); border-radius: 18px; padding: 16px; text-align: left; cursor: pointer; } input, select, textarea { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; }
.domain-card.selected { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(45,108,223,0.12); } .card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; }
.domain-title { font-size: 20px; font-weight: 700; } .narrow { margin-top:60px; }
.domain-subtitle { margin-top: 6px; color: var(--muted); } .tab-row { display:flex; gap:10px; margin:16px 0; flex-wrap:wrap; }
.domain-meta { margin-top: 10px; display: flex; gap: 12px; color: var(--muted); font-size: 14px; } .tab-row button, button { border:1px solid var(--border); background:white; border-radius:12px; padding:10px 14px; cursor:pointer; }
.layout { display: grid; grid-template-columns: 1fr 1.25fr 0.95fr; gap: 16px; margin-top: 16px; } .active-tab, .primary { background:var(--accent); color:white; border-color:var(--accent); }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 20px; padding: 18px; } .layout { display:grid; gap:16px; }
.muted { color: var(--muted); } .twocol { grid-template-columns:1fr 1fr; }
.steps-stack { display: grid; gap: 14px; } .onecol { grid-template-columns:1fr; }
.step-card { border: 1px solid var(--border); border-radius: 16px; padding: 14px; background: #fcfdff; } .muted { color:var(--muted); }
.step-header { display: flex; justify-content: space-between; gap: 12px; align-items: start; } .error { color:#b42318; margin-top:10px; }
.reward-pill { background: var(--soft); border: 1px solid var(--border); border-radius: 999px; padding: 8px 10px; font-size: 12px; } .message { margin-top:10px; padding:10px 12px; border:1px solid var(--border); background:#fff7dd; border-radius:12px; }
button { border: 1px solid var(--border); background: white; border-radius: 12px; padding: 10px 14px; cursor: pointer; } .table { width:100%; border-collapse:collapse; }
.primary { margin-top: 10px; background: var(--accent); color: white; border: none; } .table th, .table td { border-bottom:1px solid var(--border); text-align:left; padding:8px; vertical-align:top; }
.map-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; } .prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:320px; }
.map-node { border: 1px solid var(--border); border-radius: 16px; padding: 14px; } .button-row { display:flex; gap:10px; flex-wrap:wrap; margin-bottom:12px; }
.map-node.mastered { background: #eef9f0; } @media (max-width:1100px) {
.map-node.active, .map-node.available { background: #eef4ff; } .hero { flex-direction:column; }
.map-node.locked { background: #f6f7fa; } .twocol { grid-template-columns:1fr; }
.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); }
.reward-banner { background: #fff7dd; border: 1px solid #ecdca2; border-radius: 14px; padding: 12px; margin-bottom: 12px; font-weight: 700; }
.readiness-box { border: 1px solid var(--border); background: #fbfcfe; border-radius: 14px; padding: 12px; }
.readiness-box.ready { background: #eef9f0; }
.compliance-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.flag-row { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 8px; }
.flag { border: 1px solid var(--border); background: #f4f7fc; border-radius: 999px; padding: 6px 10px; font-size: 12px; }
details summary { cursor: pointer; color: var(--accent); }
@media (max-width: 1100px) {
.layout { grid-template-columns: 1fr; }
.domain-grid { grid-template-columns: 1fr; }
.hero { flex-direction: column; }
} }