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 @@
-