diff --git a/pyproject.toml b/pyproject.toml index eb5549c..17c47da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,10 +6,19 @@ build-backend = "setuptools.build_meta" name = "didactopus" version = "0.1.0" requires-python = ">=3.10" -dependencies = ["pydantic>=2.7", "pyyaml>=6.0"] +dependencies = [ + "pydantic>=2.7", + "pyyaml>=6.0", + "fastapi>=0.115", + "uvicorn>=0.30", + "sqlalchemy>=2.0", + "psycopg[binary]>=3.1", + "passlib[bcrypt]>=1.7", + "python-jose[cryptography]>=3.3" +] [project.scripts] -didactopus-compliance-demo = "didactopus.course_ingestion_compliance:main" +didactopus-api = "didactopus.api:main" [tool.setuptools.packages.find] where = ["src"] diff --git a/src/didactopus/api.py b/src/didactopus/api.py index cd83f60..64fd3f3 100644 --- a/src/didactopus/api.py +++ b/src/didactopus/api.py @@ -5,12 +5,12 @@ 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, GovernanceAction, ReviewCommentCreate, ContributionSubmissionCreate +from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest, GovernanceAction, ReviewCommentCreate, ContributionSubmissionCreate, AgentCapabilityManifest, AgentLearnerPlanRequest, AgentLearnerPlanResponse 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, + deployment_policy_profile, list_packs_for_user, list_pack_admin_rows, get_pack, get_pack_row, 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, + set_pack_publication, can_publish_pack, 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 ) @@ -45,6 +45,47 @@ def ensure_learner_access(user, learner_id: str): if not learner_owned_by_user(user.id, learner_id): raise HTTPException(status_code=403, detail="Learner not accessible by this user") +def ensure_pack_access(user, pack_id: str): + row = get_pack_row(pack_id) + if row is None: + raise HTTPException(status_code=404, detail="Pack not found") + if user.role == "admin": + return row + if row.policy_lane == "community": + return row + if row.owner_user_id == user.id: + return row + raise HTTPException(status_code=403, detail="Pack not accessible by this user") + +@app.get("/api/deployment-policy") +def api_deployment_policy(user = Depends(current_user)): + return deployment_policy_profile().model_dump() + +@app.get("/api/agent/capabilities", response_model=AgentCapabilityManifest) +def api_agent_capabilities(user = Depends(current_user)): + return AgentCapabilityManifest() + +@app.post("/api/agent/learner-plan", response_model=AgentLearnerPlanResponse) +def api_agent_learner_plan(payload: AgentLearnerPlanRequest, user = Depends(current_user)): + ensure_learner_access(user, payload.learner_id) + ensure_pack_access(user, payload.pack_id) + state = load_learner_state(payload.learner_id) + pack = get_pack(payload.pack_id) + if pack is None: + raise HTTPException(status_code=404, detail="Pack not found") + cards = recommend_next(state, pack) + return AgentLearnerPlanResponse( + learner_id=payload.learner_id, + pack_id=payload.pack_id, + next_cards=cards, + suggested_actions=[ + "Read current learner state", + "Select the highest-priority next card", + "Attempt the exercise or checkpoint", + "Post evidence and refresh recommendations", + ], + ) + @app.post("/api/login", response_model=TokenPair) def login(payload: LoginRequest): user = authenticate_user(payload.username, payload.password) @@ -72,7 +113,15 @@ def refresh(payload: RefreshRequest): @app.get("/api/packs") def api_list_packs(user = Depends(current_user)): - return [p.model_dump() for p in list_packs(include_unpublished=(user.role == "admin"))] + return [p.model_dump() for p in list_packs_for_user(user.id, include_unpublished=(user.role == "admin"))] + +@app.post("/api/packs") +def api_upsert_personal_pack(payload: CreatePackRequest, user = Depends(current_user)): + lane = payload.policy_lane + if lane == "community" and user.role != "admin": + raise HTTPException(status_code=403, detail="Community lane direct upsert is admin-only; use contribution submission") + upsert_pack(payload.pack, submitted_by_user_id=user.id, policy_lane=lane, is_published=payload.is_published if lane == "personal" else False, change_summary=payload.change_summary) + return {"ok": True, "pack_id": payload.pack.id, "policy_lane": lane} @app.post("/api/contributions") def api_create_contribution(payload: ContributionSubmissionCreate, user = Depends(current_user)): @@ -115,23 +164,28 @@ def api_admin_pack_versions(pack_id: str, user = Depends(require_admin)): def api_admin_pack_comments(pack_id: str, user = Depends(require_admin)): return list_review_comments(pack_id) +@app.get("/api/admin/packs/{pack_id}/publishability") +def api_pack_publishability(pack_id: str, user = Depends(require_admin)): + ok, reason = can_publish_pack(pack_id) + return {"ok": ok, "reason": reason} + @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) + upsert_pack(payload.pack, submitted_by_user_id=user.id, policy_lane=payload.policy_lane, is_published=payload.is_published if payload.policy_lane == "personal" else False, 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) + ok, reason = 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} + raise HTTPException(status_code=400, detail=reason) + return {"ok": True, "pack_id": pack_id, "is_published": is_published, "reason": reason} @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") + raise HTTPException(status_code=400, detail="Governance transition blocked") return {"ok": True, "pack_id": pack_id, "status": payload.status} @app.post("/api/admin/packs/{pack_id}/comments") @@ -171,6 +225,7 @@ def api_post_evidence(learner_id: str, event: EvidenceEvent, user = Depends(curr @app.get("/api/learners/{learner_id}/recommendations/{pack_id}") def api_get_recommendations(learner_id: str, pack_id: str, user = Depends(current_user)): ensure_learner_access(user, learner_id) + ensure_pack_access(user, pack_id) state = load_learner_state(learner_id) pack = get_pack(pack_id) if pack is None: @@ -180,6 +235,7 @@ def api_get_recommendations(learner_id: str, pack_id: str, user = Depends(curren @app.post("/api/learners/{learner_id}/evaluator-jobs", response_model=EvaluatorJobStatus) def api_submit_evaluator_job(learner_id: str, payload: EvaluatorSubmission, background_tasks: BackgroundTasks, user = Depends(current_user)): ensure_learner_access(user, learner_id) + ensure_pack_access(user, payload.pack_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") diff --git a/src/didactopus/config.py b/src/didactopus/config.py index 6db28e1..0a1ffa3 100644 --- a/src/didactopus/config.py +++ b/src/didactopus/config.py @@ -8,6 +8,7 @@ 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" + deployment_policy_profile: str = os.getenv("DIDACTOPUS_POLICY_PROFILE", "single_user") def load_settings() -> Settings: return Settings() diff --git a/src/didactopus/models.py b/src/didactopus/models.py index 5be4550..99ca091 100644 --- a/src/didactopus/models.py +++ b/src/didactopus/models.py @@ -3,6 +3,7 @@ from pydantic import BaseModel, Field from typing import Literal EvidenceKind = Literal["checkpoint", "project", "exercise", "review"] +PolicyLane = Literal["personal", "community"] class TokenPair(BaseModel): access_token: str @@ -18,6 +19,26 @@ class LoginRequest(BaseModel): class RefreshRequest(BaseModel): refresh_token: str +class DeploymentPolicyProfile(BaseModel): + profile_name: str + default_personal_lane_enabled: bool = True + default_community_lane_enabled: bool = True + community_publish_requires_approval: bool = True + personal_publish_direct: bool = True + reviewer_assignment_required: bool = False + description: str = "" + +class AgentCapabilityManifest(BaseModel): + supports_pack_listing: bool = True + supports_pack_write_personal: bool = True + supports_pack_submit_community: bool = True + supports_recommendations: bool = True + supports_learner_state_read: bool = True + supports_learner_state_write: bool = True + supports_evaluator_jobs: bool = True + supports_governance_endpoints: bool = True + supports_review_queue: bool = True + class PackConcept(BaseModel): id: str title: str @@ -43,6 +64,7 @@ class PackData(BaseModel): class CreatePackRequest(BaseModel): pack: PackData + policy_lane: PolicyLane = "personal" is_published: bool = False change_summary: str = "" @@ -96,3 +118,13 @@ class EvaluatorJobStatus(BaseModel): result_score: float | None = None result_confidence_hint: float | None = None result_notes: str = "" + +class AgentLearnerPlanRequest(BaseModel): + learner_id: str + pack_id: str + +class AgentLearnerPlanResponse(BaseModel): + learner_id: str + pack_id: str + next_cards: list[dict] = Field(default_factory=list) + suggested_actions: list[str] = Field(default_factory=list) diff --git a/src/didactopus/orm.py b/src/didactopus/orm.py index 44755fa..e953eb4 100644 --- a/src/didactopus/orm.py +++ b/src/didactopus/orm.py @@ -20,6 +20,8 @@ class RefreshTokenORM(Base): class PackORM(Base): __tablename__ = "packs" id: Mapped[str] = mapped_column(String(100), primary_key=True) + owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + policy_lane: Mapped[str] = mapped_column(String(50), default="personal") title: Mapped[str] = mapped_column(String(255)) subtitle: Mapped[str] = mapped_column(Text, default="") level: Mapped[str] = mapped_column(String(100), default="novice-friendly") @@ -36,6 +38,7 @@ class PackVersionORM(Base): 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) + policy_lane: Mapped[str] = mapped_column(String(50), default="personal") status: Mapped[str] = mapped_column(String(50), default="draft") data_json: Mapped[str] = mapped_column(Text) change_summary: Mapped[str] = mapped_column(Text, default="") @@ -56,6 +59,7 @@ 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) + policy_lane: Mapped[str] = mapped_column(String(50), default="community") 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") diff --git a/src/didactopus/repository.py b/src/didactopus/repository.py index ec9173e..b2258ed 100644 --- a/src/didactopus/repository.py +++ b/src/didactopus/repository.py @@ -4,12 +4,47 @@ from datetime import datetime, timezone from sqlalchemy import select from .db import SessionLocal 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, DeploymentPolicyProfile from .auth import verify_password +from .config import load_settings + +settings = load_settings() def now_iso() -> str: return datetime.now(timezone.utc).isoformat() +def deployment_policy_profile() -> DeploymentPolicyProfile: + profile = settings.deployment_policy_profile + if profile == "community_repo": + return DeploymentPolicyProfile( + profile_name="community_repo", + default_personal_lane_enabled=True, + default_community_lane_enabled=True, + community_publish_requires_approval=True, + personal_publish_direct=True, + reviewer_assignment_required=True, + description="Shared repository deployment with stronger community controls." + ) + if profile == "team_lab": + return DeploymentPolicyProfile( + profile_name="team_lab", + default_personal_lane_enabled=True, + default_community_lane_enabled=True, + community_publish_requires_approval=True, + personal_publish_direct=True, + reviewer_assignment_required=False, + description="Team deployment with shared review but moderate overhead." + ) + return DeploymentPolicyProfile( + profile_name="single_user", + default_personal_lane_enabled=True, + default_community_lane_enabled=True, + community_publish_requires_approval=True, + personal_publish_direct=True, + reviewer_assignment_required=False, + description="Single-user/private-first deployment." + ) + 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", [])} @@ -74,24 +109,34 @@ def revoke_refresh_token(token_id: str): row.is_revoked = True db.commit() -def list_packs(include_unpublished: bool = False): +def list_packs_for_user(user_id: int | None = None, include_unpublished: bool = False): with SessionLocal() as db: stmt = select(PackORM) if not include_unpublished: stmt = stmt.where(PackORM.is_published == True) rows = db.execute(stmt).scalars().all() - return [PackData.model_validate(json.loads(r.data_json)) for r in rows] + out = [] + for r in rows: + if r.policy_lane == "community": + out.append(PackData.model_validate(json.loads(r.data_json))) + elif user_id is not None and r.owner_user_id == user_id: + out.append(PackData.model_validate(json.loads(r.data_json))) + return out def 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] + return [{"id": r.id, "title": r.title, "policy_lane": r.policy_lane, "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_row(pack_id: str): + with SessionLocal() as db: + return db.get(PackORM, pack_id) + def get_pack_validation(pack_id: str): with SessionLocal() as db: row = db.get(PackORM, pack_id) @@ -119,36 +164,44 @@ def validation_and_provenance_for_pack(pack: PackData): } return validation, provenance -def upsert_pack(pack: PackData, submitted_by_user_id: int, is_published: bool = False, change_summary: str = ""): +def upsert_pack(pack: PackData, submitted_by_user_id: int, policy_lane: str = "personal", 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, + id=pack.id, + owner_user_id=submitted_by_user_id if policy_lane == "personal" else None, + policy_lane=policy_lane, + title=pack.title, subtitle=pack.subtitle, level=pack.level, data_json=payload, validation_json=json.dumps(validation), provenance_json=json.dumps(provenance), - governance_state="draft", current_version=1, is_published=is_published + governance_state="draft" if policy_lane == "community" else "personal_ready", + current_version=1, is_published=is_published if policy_lane == "personal" else False ) db.add(row) version_number = 1 else: + row.owner_user_id = submitted_by_user_id if policy_lane == "personal" else row.owner_user_id + row.policy_lane = policy_lane row.title = pack.title row.subtitle = pack.subtitle row.level = pack.level row.data_json = payload 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" + row.governance_state = "draft" if policy_lane == "community" else "personal_ready" + if policy_lane == "personal": + row.is_published = is_published 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", + policy_lane=policy_lane, + status="draft" if policy_lane == "community" else "personal_ready", data_json=payload, change_summary=change_summary, created_at=now_iso(), @@ -166,8 +219,12 @@ def create_submission(pack: PackData, contributor_user_id: int, submission_summa proposed_payload = pack.model_dump() diff = pack_diff(current_payload, proposed_payload) gates = gate_summary(validation, provenance) + task_note = "Community submission awaiting reviewer attention" + if deployment_policy_profile().reviewer_assignment_required: + task_note += " (reviewer assignment required by deployment policy)" sub = ContributionSubmissionORM( pack_id=pack.id, + policy_lane="community", proposed_version_number=proposed_version, contributor_user_id=contributor_user_id, status="submitted", @@ -183,7 +240,7 @@ def create_submission(pack: PackData, contributor_user_id: int, submission_summa submission_id=sub.id, reviewer_user_id=None, task_status="open", - task_note="Submission awaiting reviewer attention", + task_note=task_note, created_at=now_iso(), )) db.commit() @@ -195,6 +252,7 @@ def list_submissions(): return [{ "submission_id": r.id, "pack_id": r.pack_id, + "policy_lane": r.policy_lane, "proposed_version_number": r.proposed_version_number, "contributor_user_id": r.contributor_user_id, "status": r.status, @@ -202,10 +260,6 @@ def list_submissions(): "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) @@ -228,21 +282,49 @@ def list_review_tasks(): "created_at": r.created_at, } for r in rows] +def can_publish_pack(pack_id: str) -> tuple[bool, str]: + with SessionLocal() as db: + row = db.get(PackORM, pack_id) + if row is None: + return False, "Pack not found" + if row.policy_lane == "personal": + return True, "Personal lane pack may publish directly" + if deployment_policy_profile().community_publish_requires_approval and row.governance_state != "approved": + return False, "Community lane pack must be approved before publication" + validation = json.loads(row.validation_json or "{}") + provenance = json.loads(row.provenance_json or "{}") + gates = gate_summary(validation, provenance) + if not gates.get("ready_for_review", False): + return False, "Community lane gates not satisfied" + return True, "Community lane pack passed publish gates" + 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 + return False, "Pack not found" + if is_published: + ok, reason = can_publish_pack(pack_id) + if not ok: + return False, reason row.is_published = is_published db.commit() - return True + return True, "Updated" 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 + if row.policy_lane == "personal": + row.governance_state = status + else: + validation = json.loads(row.validation_json or "{}") + provenance = json.loads(row.provenance_json or "{}") + gates = gate_summary(validation, provenance) + if status == "approved" and not gates.get("ready_for_review", False): + 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 @@ -255,6 +337,7 @@ def list_pack_versions(pack_id: str): 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, + "policy_lane": r.policy_lane, "status": r.status, "change_summary": r.change_summary, "created_at": r.created_at, diff --git a/src/didactopus/seed.py b/src/didactopus/seed.py index c158b90..945e51f 100644 --- a/src/didactopus/seed.py +++ b/src/didactopus/seed.py @@ -28,7 +28,23 @@ def main(): compliance=PackCompliance(sources=2, attributionRequired=True, shareAlikeRequired=True, noncommercialOnly=True, flags=["share-alike","noncommercial","excluded-third-party-content"]) ), submitted_by_user_id=1, + policy_lane="community", is_published=True, - change_summary="Initial seed version" + change_summary="Initial shared seed version" + ) + upsert_pack( + PackData( + id="wesley-private-pack", + title="Wesley Private Pack", + subtitle="Personal pack example without community friction.", + level="novice-friendly", + concepts=[PackConcept(id="intro", title="Intro", prerequisites=[], masteryDimension="mastery", exerciseReward="Intro marker")], + onboarding={"headline":"Start privately","body":"Personal pack lane.","checklist":["Create pack","Use pack"]}, + compliance=PackCompliance(sources=0, attributionRequired=False, shareAlikeRequired=False, noncommercialOnly=False, flags=[]) + ), + submitted_by_user_id=1, + policy_lane="personal", + is_published=True, + change_summary="Initial personal pack" ) print("Seeded database. Demo users: wesley/demo-pass and contrib/demo-pass") diff --git a/webui/index.html b/webui/index.html index a3c39a2..7cbbc61 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3,10 +3,8 @@ - Didactopus Learner Prototype + Didactopus Deployment Policy + Agent Hooks - -
- +
diff --git a/webui/package.json b/webui/package.json index 040e6f5..fa83249 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,17 +1,9 @@ { - "name": "didactopus-learner-ui", + "name": "didactopus-deployment-policy-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 d70fc33..3c39a92 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -1,148 +1,279 @@ -import React, { useMemo, useState } from "react"; -import { domains } from "./sampleData"; +import React, { useEffect, useState } from "react"; +import { login, refresh, fetchDeploymentPolicy, fetchAgentCapabilities, fetchPacks, upsertPack, createContribution, fetchAdminPacks, fetchPackValidation, fetchPackProvenance, fetchPackVersions, fetchPackComments, fetchSubmissions, fetchSubmissionDiff, fetchSubmissionGates, fetchReviewTasks, publishPack, fetchPublishability } from "./api"; +import { loadAuth, saveAuth, clearAuth } from "./authStore"; -function DomainCard({ domain, selected, onSelect }) { +function LoginView({ onAuth }) { + const [username, setUsername] = useState("wesley"); + const [password, setPassword] = useState("demo-pass"); + const [error, setError] = useState(""); + async function doLogin() { + try { + const result = await login(username, password); + saveAuth(result); + onAuth(result); + } catch { setError("Login failed"); } + } return ( - - ); -} - -function OnboardingPanel({ onboarding }) { - return ( -
-

First-session onboarding

-

{onboarding.headline}

-

{onboarding.body}

- -
- ); -} - -function NextStepCard({ step }) { - return ( -
-
-
-

{step.title}

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

{step.reason}

-
- Why this is recommended - -
- +
+
+

Didactopus login

+ + + + {error ?
{error}
: null} +
); } -function MasteryMap({ nodes }) { +function NavTabs({ tab, setTab, role }) { return ( -
-

Visible mastery map

-
- {nodes.map((node) => ( -
-
{node.label}
-
{node.status}
-
- ))} -
-
- ); -} - -function MilestonePanel({ milestones, rewardLabel, progress }) { - return ( -
-

Milestones and rewards

-
-
Mastery progress
-
-
{progress}%
-
-
{rewardLabel}
-
    - {milestones.map((m, idx) =>
  • {m}
  • )} -
-
- ); -} - -function CompliancePanel({ compliance }) { - return ( -
-

Source attribution and compliance

-
-
Sources
{compliance.sources}
-
Attribution
{compliance.attributionRequired ? "required" : "not required"}
-
Share-alike
{compliance.shareAlikeRequired ? "yes" : "no"}
-
Noncommercial
{compliance.noncommercialOnly ? "yes" : "no"}
-
-
- {compliance.flags.length ? compliance.flags.map((f) => {f}) : No extra flags} -
-

- This panel is here so a learner or curator can inspect provenance-sensitive packs without needing to guess what the reuse constraints are. -

-
+
+ + + + {role === "admin" ? <> + + + : null} +
); } export default function App() { - const [selectedDomainId, setSelectedDomainId] = useState(domains[0].id); - const domain = useMemo(() => domains.find((d) => d.id === selectedDomainId) || domains[0], [selectedDomainId]); + const [auth, setAuth] = useState(loadAuth()); + const [tab, setTab] = useState("policy"); + const [deploymentPolicy, setDeploymentPolicy] = useState(null); + const [agentCapabilities, setAgentCapabilities] = useState(null); + const [packs, setPacks] = useState([]); + const [adminPacks, setAdminPacks] = useState([]); + const [selectedPackId, setSelectedPackId] = useState(""); + const [validation, setValidation] = useState(null); + const [provenance, setProvenance] = useState(null); + const [versions, setVersions] = useState([]); + const [comments, setComments] = useState([]); + const [submissions, setSubmissions] = useState([]); + const [selectedSubmissionId, setSelectedSubmissionId] = useState(null); + const [submissionDiff, setSubmissionDiff] = useState(null); + const [submissionGates, setSubmissionGates] = useState(null); + const [publishability, setPublishability] = useState(null); + const [reviewTasks, setReviewTasks] = useState([]); + const [message, setMessage] = useState(""); + const [personalPack, setPersonalPack] = useState({ + id: "my-private-pack", + title: "My Private Pack", + subtitle: "Personal lane scaffold", + level: "novice-friendly", + concepts: [{ id: "intro", title: "Intro", prerequisites: [], masteryDimension: "mastery", exerciseReward: "Intro" }], + onboarding: { headline: "Start privately", body: "Personal pack lane", checklist: [] }, + compliance: { sources: 0, attributionRequired: false, shareAlikeRequired: false, noncommercialOnly: false, flags: [] } + }); + const [contribPack, setContribPack] = useState({ + id: "bayes-pack", + title: "Bayesian Reasoning", + subtitle: "Contributor revision scaffold", + level: "novice-friendly", + concepts: [{ id: "prior", title: "Prior", prerequisites: [], masteryDimension: "mastery", exerciseReward: "Prior badge earned" }], + onboarding: { headline: "Start here", body: "Begin", checklist: [] }, + compliance: { sources: 1, attributionRequired: true, shareAlikeRequired: false, noncommercialOnly: false, flags: [] } + }); + + async function refreshAuthToken() { + if (!auth?.refresh_token) return null; + try { + const result = await refresh(auth.refresh_token); + saveAuth(result); + setAuth(result); + return result; + } catch { + clearAuth(); + setAuth(null); + return null; + } + } + + async function guarded(fn) { + try { return await fn(auth.access_token); } + catch { + const next = await refreshAuthToken(); + if (!next) throw new Error("auth failed"); + return await fn(next.access_token); + } + } + + useEffect(() => { + if (!auth) return; + async function load() { + setDeploymentPolicy(await guarded((token) => fetchDeploymentPolicy(token))); + setAgentCapabilities(await guarded((token) => fetchAgentCapabilities(token))); + const p = await guarded((token) => fetchPacks(token)); + setPacks(p); + if (auth.role === "admin") { + const ap = await guarded((token) => fetchAdminPacks(token)); + setAdminPacks(ap); + setSelectedPackId((prev) => prev || ap[0]?.id || ""); + setSubmissions(await guarded((token) => fetchSubmissions(token))); + setReviewTasks(await guarded((token) => fetchReviewTasks(token))); + } + } + load(); + }, [auth]); + + useEffect(() => { + if (!auth?.role || auth.role !== "admin" || !selectedPackId) return; + async function loadReview() { + setValidation(await guarded((token) => fetchPackValidation(token, selectedPackId))); + setProvenance(await guarded((token) => fetchPackProvenance(token, selectedPackId))); + setVersions(await guarded((token) => fetchPackVersions(token, selectedPackId))); + setComments(await guarded((token) => fetchPackComments(token, selectedPackId))); + setPublishability(await guarded((token) => fetchPublishability(token, selectedPackId))); + } + loadReview(); + }, [auth, selectedPackId]); + + useEffect(() => { + if (!auth?.role || auth.role !== "admin" || !selectedSubmissionId) return; + async function loadSubmission() { + setSubmissionDiff(await guarded((token) => fetchSubmissionDiff(token, selectedSubmissionId))); + setSubmissionGates(await guarded((token) => fetchSubmissionGates(token, selectedSubmissionId))); + } + loadSubmission(); + }, [auth, selectedSubmissionId]); + + async function savePersonalPack() { + const result = await guarded((token) => upsertPack(token, { pack: personalPack, policy_lane: "personal", is_published: true, change_summary: "Saved through personal lane UI" })); + setMessage(`Personal pack saved: ${result.pack_id}`); + setPacks(await guarded((token) => fetchPacks(token))); + } + + async function submitContribution() { + const result = await guarded((token) => createContribution(token, { pack: contribPack, submission_summary: "Contributor-submitted revision from UI scaffold" })); + setMessage(`Community submission created: ${result.submission_id}`); + } + + async function publishSelected() { + const result = await guarded((token) => publishPack(token, selectedPackId, true)); + setMessage(result.reason || "Publish updated"); + setAdminPacks(await guarded((token) => fetchAdminPacks(token))); + setPublishability(await guarded((token) => fetchPublishability(token, selectedPackId))); + } + + if (!auth) return ; return (
-

Didactopus learner prototype

-

- Pick a topic, get a clear first session, see your mastery map, and understand why the system suggests each next step. -

+

Didactopus deployment policy + agent hooks

+

Policy profiles plus explicit API parity for human UI use and AI learner use.

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

What should I do next?

-
- {domain.nextSteps.map((step) => )} -
+

Deployment policy profile

+
{JSON.stringify(deploymentPolicy, null, 2)}
-
+
+

AI learner capability hooks

+

This installation exposes direct API hooks for an AI learner instead of requiring UI mediation.

+
{JSON.stringify(agentCapabilities, null, 2)}
+
+
+ )} -
- - -
- + {tab === "personal" && ( +
+
+

Personal lane authoring

+

This lane is intended not to hamper an individual building packs for private use.

+ + + + +
{JSON.stringify(personalPack, null, 2)}
+
+
+ )} + + {tab === "contribute" && ( +
+
+

Community contribution lane

+

Use this lane for packs intended to enter shared review and publication workflows.

+ + + + +
{JSON.stringify(contribPack, null, 2)}
+
+
+ )} + + {tab === "submissions" && auth.role === "admin" && ( +
+
+

Submission queue

+ + + + {submissions.map((s) => ( + + + + + + + + + ))} + +
IDPackLaneVersionStatusSelect
{s.submission_id}{s.pack_id}{s.policy_lane}{s.proposed_version_number}{s.status}
+

Review tasks

+
{JSON.stringify(reviewTasks, null, 2)}
+
+
+

Submission diff and gates

+

Diff summary

+
{JSON.stringify(submissionDiff, null, 2)}
+

Gate summary

+
{JSON.stringify(submissionGates, null, 2)}
+
+
+ )} + + {tab === "review" && auth.role === "admin" && ( +
+
+

Governance and publishability

+ +

Publishability

+
{JSON.stringify(publishability, null, 2)}
+

Validation

+
{JSON.stringify(validation, null, 2)}
+

Provenance

+
{JSON.stringify(provenance, null, 2)}
+
+
+

Versions and comments

+

Versions

+
{JSON.stringify(versions, null, 2)}
+

Comments

+
{JSON.stringify(comments, null, 2)}
+
+
+ )}
); } diff --git a/webui/src/api.js b/webui/src/api.js index 993f7ca..af39326 100644 --- a/webui/src/api.js +++ b/webui/src/api.js @@ -16,7 +16,10 @@ export async function refresh(refreshToken) { if (!res.ok) throw new Error("refresh failed"); return await res.json(); } +export async function fetchDeploymentPolicy(token) { const res = await fetch(`${API}/deployment-policy`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchDeploymentPolicy failed"); return await res.json(); } +export async function fetchAgentCapabilities(token) { const res = await fetch(`${API}/agent/capabilities`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchAgentCapabilities failed"); 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 upsertPack(token, payload) { const res = await fetch(`${API}/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 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(); } @@ -27,7 +30,5 @@ export async function fetchSubmissions(token) { const res = await fetch(`${API}/ 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(); } +export async function fetchPublishability(token, packId) { const res = await fetch(`${API}/admin/packs/${packId}/publishability`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPublishability failed"); return await res.json(); } diff --git a/webui/src/main.jsx b/webui/src/main.jsx index 8ad26cf..7352818 100644 --- a/webui/src/main.jsx +++ b/webui/src/main.jsx @@ -2,5 +2,4 @@ import React from "react"; import { createRoot } from "react-dom/client"; import App from "./App"; import "./styles.css"; - createRoot(document.getElementById("root")).render(); diff --git a/webui/src/styles.css b/webui/src/styles.css index 675dd2f..9919182 100644 --- a/webui/src/styles.css +++ b/webui/src/styles.css @@ -1,177 +1,29 @@ :root { - --bg: #f6f8fb; - --card: #ffffff; - --text: #1f2430; - --muted: #60697a; - --border: #dbe1ea; - --accent: #2d6cdf; - --soft: #eef4ff; + --bg:#f6f8fb; --card:#ffffff; --text:#1f2430; --muted:#60697a; --border:#dbe1ea; --accent:#2d6cdf; --soft:#eef4ff; } -* { box-sizing: border-box; } -body { - margin: 0; - font-family: Arial, Helvetica, sans-serif; - background: var(--bg); - color: var(--text); -} -.page { - max-width: 1500px; - margin: 0 auto; - padding: 20px; -} -.hero { - background: var(--card); - border: 1px solid var(--border); - border-radius: 22px; - padding: 24px; -} -.domain-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 16px; - margin-top: 16px; -} -.domain-card { - border: 1px solid var(--border); - background: var(--card); - border-radius: 18px; - padding: 16px; - text-align: left; - cursor: pointer; -} -.domain-card.selected { - border-color: var(--accent); - box-shadow: 0 0 0 2px rgba(45,108,223,0.12); -} -.domain-title { - font-size: 20px; - font-weight: 700; -} -.domain-subtitle { - margin-top: 6px; - color: var(--muted); -} -.domain-meta { - margin-top: 10px; - display: flex; - gap: 12px; - color: var(--muted); - font-size: 14px; -} -.layout { - display: grid; - grid-template-columns: 1fr 1.3fr 0.9fr; - gap: 16px; - margin-top: 16px; -} -.card { - background: var(--card); - border: 1px solid var(--border); - border-radius: 20px; - padding: 18px; -} -.muted { - color: var(--muted); -} -.steps-stack { - display: grid; - gap: 14px; -} -.step-card { - border: 1px solid var(--border); - border-radius: 16px; - padding: 14px; - background: #fcfdff; -} -.step-header { - display: flex; - justify-content: space-between; - gap: 12px; - align-items: start; -} -.reward-pill { - background: var(--soft); - border: 1px solid var(--border); - border-radius: 999px; - padding: 8px 10px; - font-size: 12px; -} -.primary { - margin-top: 10px; - background: var(--accent); - color: white; - border: none; - border-radius: 12px; - padding: 10px 14px; - cursor: pointer; -} -.map-grid { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 12px; -} -.map-node { - border: 1px solid var(--border); - border-radius: 16px; - padding: 14px; -} -.map-node.mastered { background: #eef9f0; } -.map-node.active { background: #eef4ff; } -.map-node.locked { background: #f6f7fa; } -.node-label { - font-weight: 700; -} -.node-status { - margin-top: 6px; - color: var(--muted); - text-transform: capitalize; -} -.progress-wrap { - margin-bottom: 14px; -} -.progress-bar { - width: 100%; - height: 12px; - border-radius: 999px; - background: #e9edf4; - overflow: hidden; - margin: 8px 0; -} -.progress-fill { - height: 100%; - background: var(--accent); -} -.reward-banner { - background: #fff7dd; - border: 1px solid #ecdca2; - border-radius: 14px; - padding: 12px; - margin-bottom: 12px; - font-weight: 700; -} -.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; } +* { box-sizing:border-box; } +body { margin:0; font-family:Arial, Helvetica, sans-serif; background:var(--bg); color:var(--text); } +.page { max-width:1500px; margin:0 auto; padding:24px; } +.narrow-page { max-width:520px; } +.hero { background:var(--card); border:1px solid var(--border); border-radius:22px; padding:24px; display:flex; justify-content:space-between; gap:16px; } +.hero-controls { min-width:280px; display:flex; flex-direction:column; gap:12px; } +label { display:block; font-weight:600; } +input, select, textarea { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; } +.card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; } +.narrow { margin-top:60px; } +.tab-row { display:flex; gap:10px; margin:16px 0; flex-wrap:wrap; } +.tab-row button, button { border:1px solid var(--border); background:white; border-radius:12px; padding:10px 14px; cursor:pointer; } +.active-tab, .primary { background:var(--accent); color:white; border-color:var(--accent); } +.layout { display:grid; gap:16px; } +.twocol { grid-template-columns:1fr 1fr; } +.onecol { grid-template-columns:1fr; } +.muted { color:var(--muted); } +.error { color:#b42318; margin-top:10px; } +.message { margin-top:10px; padding:10px 12px; border:1px solid var(--border); background:#fff7dd; border-radius:12px; } +.table { width:100%; border-collapse:collapse; } +.table th, .table td { border-bottom:1px solid var(--border); text-align:left; padding:8px; vertical-align:top; } +.prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:320px; } +@media (max-width:1100px) { + .hero { flex-direction:column; } + .twocol { grid-template-columns:1fr; } }