diff --git a/pyproject.toml b/pyproject.toml index 35368af..17c47da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,6 @@ dependencies = [ [project.scripts] didactopus-api = "didactopus.api:main" -didactopus-worker = "didactopus.worker:main" [tool.setuptools.packages.find] where = ["src"] diff --git a/src/didactopus/api.py b/src/didactopus/api.py index e494573..28c4614 100644 --- a/src/didactopus/api.py +++ b/src/didactopus/api.py @@ -1,15 +1,12 @@ from __future__ import annotations +import json from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks from fastapi.middleware.cors import CORSMiddleware import uvicorn from .config import load_settings from .db import Base, engine -from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest -from .repository import ( - authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token, - list_packs, get_pack, upsert_pack, create_learner, learner_owned_by_user, load_learner_state, - save_learner_state, create_evaluator_job, get_evaluator_job, list_evaluator_jobs_for_learner -) +from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest, GovernanceAction, ReviewCommentCreate +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, 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 @@ -18,13 +15,7 @@ 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() @@ -54,12 +45,7 @@ def login(payload: LoginRequest): 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, - ) + 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): @@ -75,35 +61,65 @@ def refresh(payload: RefreshRequest): revoke_refresh_token(token_id) new_jti = new_token_id() store_refresh_token(user.id, new_jti) - return TokenPair( - access_token=issue_access_token(user.id, user.username, user.role), - refresh_token=issue_refresh_token(user.id, user.username, user.role, new_jti), - username=user.username, - role=user.role, - ) + return 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 api_list_packs(user = Depends(current_user)): - include_unpublished = user.role == "admin" - return [p.model_dump() for p in list_packs(include_unpublished=include_unpublished)] + return [p.model_dump() for p in list_packs(include_unpublished=(user.role == "admin"))] -@app.get("/api/packs/{pack_id}") -def api_get_pack(pack_id: str, user = Depends(current_user)): - pack = get_pack(pack_id) - if pack is None: - raise HTTPException(status_code=404, detail="Pack not found") - return pack.model_dump() +@app.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, is_published=payload.is_published) + 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 {"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 api_get_learner_state(learner_id: str, user = Depends(current_user)): ensure_learner_access(user, learner_id) @@ -147,6 +163,13 @@ def api_get_evaluator_job(job_id: int, user = Depends(current_user)): 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) @@ -155,6 +178,3 @@ def api_get_evaluator_history(learner_id: str, user = Depends(current_user)): def main(): uvicorn.run(app, host=settings.host, port=settings.port) - -if __name__ == "__main__": - main() diff --git a/src/didactopus/auth.py b/src/didactopus/auth.py index a2d088d..54745ac 100644 --- a/src/didactopus/auth.py +++ b/src/didactopus/auth.py @@ -20,10 +20,10 @@ def _encode_token(payload: dict, expires_delta: timedelta) -> str: 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=settings.access_token_minutes)) + 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=settings.refresh_token_days)) + 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: diff --git a/src/didactopus/config.py b/src/didactopus/config.py index 9890f28..6db28e1 100644 --- a/src/didactopus/config.py +++ b/src/didactopus/config.py @@ -8,8 +8,6 @@ class Settings(BaseModel): port: int = int(os.getenv("DIDACTOPUS_PORT", "8011")) jwt_secret: str = os.getenv("DIDACTOPUS_JWT_SECRET", "change-me") jwt_algorithm: str = "HS256" - access_token_minutes: int = 30 - refresh_token_days: int = 14 def load_settings() -> Settings: return Settings() diff --git a/src/didactopus/models.py b/src/didactopus/models.py index e2a0b8b..fc04c4a 100644 --- a/src/didactopus/models.py +++ b/src/didactopus/models.py @@ -41,6 +41,23 @@ 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 CreateLearnerRequest(BaseModel): + learner_id: str + display_name: str = "" + class MasteryRecord(BaseModel): concept_id: str dimension: str @@ -63,10 +80,6 @@ class LearnerState(BaseModel): records: list[MasteryRecord] = Field(default_factory=list) history: list[EvidenceEvent] = Field(default_factory=list) -class CreateLearnerRequest(BaseModel): - learner_id: str - display_name: str = "" - class EvaluatorSubmission(BaseModel): pack_id: str concept_id: str @@ -79,7 +92,3 @@ class EvaluatorJobStatus(BaseModel): result_score: float | None = None result_confidence_hint: float | None = None result_notes: str = "" - -class CreatePackRequest(BaseModel): - pack: PackData - is_published: bool = True diff --git a/src/didactopus/orm.py b/src/didactopus/orm.py index fb79b36..81ba232 100644 --- a/src/didactopus/orm.py +++ b/src/didactopus/orm.py @@ -1,5 +1,5 @@ from sqlalchemy import String, Integer, Float, ForeignKey, Text, Boolean -from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.orm import Mapped, mapped_column from .db import Base class UserORM(Base): @@ -24,7 +24,33 @@ 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) - is_published: Mapped[bool] = mapped_column(Boolean, default=True) + 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 LearnerORM(Base): __tablename__ = "learners" @@ -66,3 +92,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="") + trace_json: Mapped[str] = mapped_column(Text, default="{}") diff --git a/src/didactopus/repository.py b/src/didactopus/repository.py index 90ad6a9..2e3cda0 100644 --- a/src/didactopus/repository.py +++ b/src/didactopus/repository.py @@ -1,11 +1,15 @@ from __future__ import annotations import json -from sqlalchemy import select +from datetime import datetime, timezone +from sqlalchemy import select, func from .db import SessionLocal -from .orm import UserORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM +from .orm import UserORM, RefreshTokenORM, PackORM, PackVersionORM, ReviewCommentORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent from .auth import verify_password +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + def get_user_by_username(username: str): with SessionLocal() as db: return db.execute(select(UserORM).where(UserORM.username == username)).scalar_one_or_none() @@ -37,7 +41,7 @@ def revoke_refresh_token(token_id: str): row.is_revoked = True db.commit() -def list_packs(include_unpublished: bool = False) -> list[PackData]: +def list_packs(include_unpublished: bool = False): with SessionLocal() as db: stmt = select(PackORM) if not include_unpublished: @@ -45,37 +49,162 @@ def list_packs(include_unpublished: bool = False) -> list[PackData]: rows = db.execute(stmt).scalars().all() return [PackData.model_validate(json.loads(r.data_json)) for r in rows] +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 upsert_pack(pack: PackData, is_published: bool = True): +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 upsert_pack(pack: PackData, submitted_by_user_id: int, is_published: bool = False, change_summary: str = ""): + 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 [] + } with SessionLocal() as db: row = db.get(PackORM, pack.id) payload = json.dumps(pack.model_dump()) if row is None: - db.add(PackORM(id=pack.id, title=pack.title, subtitle=pack.subtitle, level=pack.level, data_json=payload, is_published=is_published)) + 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 set_pack_publication(pack_id: str, is_published: bool): + with SessionLocal() as db: + row = db.get(PackORM, pack_id) + if row is None: + return False + row.is_published = is_published + db.commit() + return True + +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() @@ -96,15 +225,16 @@ def save_learner_state(state: LearnerState): 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 list_evaluator_jobs_for_learner(learner_id: str) -> list[EvaluatorJobORM]: +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() @@ -112,7 +242,7 @@ 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 = ""): +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: @@ -121,4 +251,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 f77244d..c62edae 100644 --- a/src/didactopus/seed.py +++ b/src/didactopus/seed.py @@ -1,36 +1,32 @@ from __future__ import annotations -import json from sqlalchemy import select from .db import Base, engine, SessionLocal -from .orm import UserORM, PackORM +from .orm import UserORM from .auth import hash_password - -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"]} - } -] +from .repository import upsert_pack +from .models import PackData, PackConcept, PackCompliance def main(): Base.metadata.create_all(bind=engine) with SessionLocal() as db: if db.execute(select(UserORM).where(UserORM.username == "wesley")).scalar_one_or_none() is None: db.add(UserORM(username="wesley", password_hash=hash_password("demo-pass"), role="admin", is_active=True)) - for pack in PACKS: - if db.get(PackORM, pack["id"]) is None: - db.add(PackORM(id=pack["id"], title=pack["title"], subtitle=pack["subtitle"], level=pack["level"], data_json=json.dumps(pack), is_published=True)) db.commit() + 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 user: wesley / demo-pass") - -if __name__ == "__main__": - main() diff --git a/src/didactopus/worker.py b/src/didactopus/worker.py index aa3f154..6d28da5 100644 --- a/src/didactopus/worker.py +++ b/src/didactopus/worker.py @@ -8,27 +8,17 @@ def process_job(job_id: int): job = get_evaluator_job(job_id) if job is None: return - update_evaluator_job(job_id, "running") + 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." - update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes) + 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}", - )) + 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. In a real deployment this would poll a queue.") + print("Didactopus worker scaffold running. Replace this with a real queue worker.") while True: time.sleep(60) - -if __name__ == "__main__": - main() diff --git a/tests/test_scaffold_files.py b/tests/test_scaffold_files.py index 61a1f95..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/export_svg.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 be6856a..ec562c5 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3,10 +3,8 @@ - Didactopus Production UI Scaffold + Didactopus Review Governance Layer - -
- +
diff --git a/webui/package.json b/webui/package.json index 369022a..5408ebd 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,17 +1,9 @@ { - "name": "didactopus-production-ui", + "name": "didactopus-governance-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 5256ec2..eb8c177 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -1,11 +1,354 @@ -import React from "react"; -export default function App() { +import React, { useEffect, useState } from "react"; +import { login, refresh, fetchPacks, fetchAdminPacks, fetchPackValidation, fetchPackProvenance, fetchPackVersions, fetchPackComments, upsertPack, publishPack, governanceAction, addReviewComment, listLearners, createLearner, fetchLearnerState, fetchRecommendations, postEvidence, submitEvaluatorJob, fetchEvaluatorHistory, fetchEvaluatorTrace } from "./api"; +import { loadAuth, saveAuth, clearAuth } from "./authStore"; + +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 productionization scaffold

-

This UI scaffold is intended for later connection to evaluator history, learner management, and admin pack management endpoints.

-
+
+
+

Didactopus login

+ + + + {error ?
{error}
: null} +
+
+ ); +} + +function NavTabs({ tab, setTab, role }) { + return ( +
+ + + + {role === "admin" ? <> + + + : null} +
+ ); +} + +function PackAuthorForm({ value, onChange, onSave }) { + function setField(field, val) { onChange({ ...value, [field]: val }); } + function setCompliance(field, val) { onChange({ ...value, compliance: { ...value.compliance, [field]: val } }); } + return ( +
+ + + + + + +