Apply ZIP update: 190-didactopus-deployment-policy-and-agent-hooks.zip [2026-03-14T13:20:24]

This commit is contained in:
welsberr 2026-03-14 13:29:55 -04:00
parent d7b7c235a5
commit 27bc03f77c
13 changed files with 520 additions and 346 deletions

View File

@ -6,10 +6,19 @@ build-backend = "setuptools.build_meta"
name = "didactopus" name = "didactopus"
version = "0.1.0" version = "0.1.0"
requires-python = ">=3.10" 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] [project.scripts]
didactopus-compliance-demo = "didactopus.course_ingestion_compliance:main" didactopus-api = "didactopus.api:main"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]

View File

@ -5,12 +5,12 @@ from fastapi.middleware.cors import CORSMiddleware
import uvicorn import uvicorn
from .config import load_settings from .config import load_settings
from .db import Base, engine 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 ( from .repository import (
authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token, 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, 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, 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 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): if not learner_owned_by_user(user.id, learner_id):
raise HTTPException(status_code=403, detail="Learner not accessible by this user") 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) @app.post("/api/login", response_model=TokenPair)
def login(payload: LoginRequest): def login(payload: LoginRequest):
user = authenticate_user(payload.username, payload.password) user = authenticate_user(payload.username, payload.password)
@ -72,7 +113,15 @@ def refresh(payload: RefreshRequest):
@app.get("/api/packs") @app.get("/api/packs")
def api_list_packs(user = Depends(current_user)): 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") @app.post("/api/contributions")
def api_create_contribution(payload: ContributionSubmissionCreate, user = Depends(current_user)): 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)): def api_admin_pack_comments(pack_id: str, user = Depends(require_admin)):
return list_review_comments(pack_id) 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") @app.post("/api/admin/packs")
def api_upsert_pack(payload: CreatePackRequest, user = Depends(require_admin)): 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} return {"ok": True, "pack_id": payload.pack.id}
@app.post("/api/admin/packs/{pack_id}/publish") @app.post("/api/admin/packs/{pack_id}/publish")
def api_publish_pack(pack_id: str, is_published: bool, user = Depends(require_admin)): 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: if not ok:
raise HTTPException(status_code=404, detail="Pack not found") raise HTTPException(status_code=400, detail=reason)
return {"ok": True, "pack_id": pack_id, "is_published": is_published} return {"ok": True, "pack_id": pack_id, "is_published": is_published, "reason": reason}
@app.post("/api/admin/packs/{pack_id}/governance") @app.post("/api/admin/packs/{pack_id}/governance")
def api_governance_action(pack_id: str, payload: GovernanceAction, user = Depends(require_admin)): def api_governance_action(pack_id: str, payload: GovernanceAction, user = Depends(require_admin)):
ok = set_governance_state(pack_id, payload.status, payload.review_summary) ok = set_governance_state(pack_id, payload.status, payload.review_summary)
if not ok: 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} return {"ok": True, "pack_id": pack_id, "status": payload.status}
@app.post("/api/admin/packs/{pack_id}/comments") @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}") @app.get("/api/learners/{learner_id}/recommendations/{pack_id}")
def api_get_recommendations(learner_id: str, pack_id: str, user = Depends(current_user)): def api_get_recommendations(learner_id: str, pack_id: str, user = Depends(current_user)):
ensure_learner_access(user, learner_id) ensure_learner_access(user, learner_id)
ensure_pack_access(user, pack_id)
state = load_learner_state(learner_id) state = load_learner_state(learner_id)
pack = get_pack(pack_id) pack = get_pack(pack_id)
if pack is None: 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) @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)): def api_submit_evaluator_job(learner_id: str, payload: EvaluatorSubmission, background_tasks: BackgroundTasks, user = Depends(current_user)):
ensure_learner_access(user, learner_id) 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) job_id = create_evaluator_job(learner_id, payload.pack_id, payload.concept_id, payload.submitted_text)
background_tasks.add_task(process_job, job_id) background_tasks.add_task(process_job, job_id)
return EvaluatorJobStatus(job_id=job_id, status="queued") return EvaluatorJobStatus(job_id=job_id, status="queued")

View File

@ -8,6 +8,7 @@ class Settings(BaseModel):
port: int = int(os.getenv("DIDACTOPUS_PORT", "8011")) port: int = int(os.getenv("DIDACTOPUS_PORT", "8011"))
jwt_secret: str = os.getenv("DIDACTOPUS_JWT_SECRET", "change-me") jwt_secret: str = os.getenv("DIDACTOPUS_JWT_SECRET", "change-me")
jwt_algorithm: str = "HS256" jwt_algorithm: str = "HS256"
deployment_policy_profile: str = os.getenv("DIDACTOPUS_POLICY_PROFILE", "single_user")
def load_settings() -> Settings: def load_settings() -> Settings:
return Settings() return Settings()

View File

@ -3,6 +3,7 @@ from pydantic import BaseModel, Field
from typing import Literal from typing import Literal
EvidenceKind = Literal["checkpoint", "project", "exercise", "review"] EvidenceKind = Literal["checkpoint", "project", "exercise", "review"]
PolicyLane = Literal["personal", "community"]
class TokenPair(BaseModel): class TokenPair(BaseModel):
access_token: str access_token: str
@ -18,6 +19,26 @@ class LoginRequest(BaseModel):
class RefreshRequest(BaseModel): class RefreshRequest(BaseModel):
refresh_token: str 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): class PackConcept(BaseModel):
id: str id: str
title: str title: str
@ -43,6 +64,7 @@ class PackData(BaseModel):
class CreatePackRequest(BaseModel): class CreatePackRequest(BaseModel):
pack: PackData pack: PackData
policy_lane: PolicyLane = "personal"
is_published: bool = False is_published: bool = False
change_summary: str = "" change_summary: str = ""
@ -96,3 +118,13 @@ class EvaluatorJobStatus(BaseModel):
result_score: float | None = None result_score: float | None = None
result_confidence_hint: float | None = None result_confidence_hint: float | None = None
result_notes: str = "" 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)

View File

@ -20,6 +20,8 @@ class RefreshTokenORM(Base):
class PackORM(Base): class PackORM(Base):
__tablename__ = "packs" __tablename__ = "packs"
id: Mapped[str] = mapped_column(String(100), primary_key=True) id: Mapped[str] = mapped_column(String(100), primary_key=True)
owner_user_id: Mapped[int | 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)) title: Mapped[str] = mapped_column(String(255))
subtitle: Mapped[str] = mapped_column(Text, default="") subtitle: Mapped[str] = mapped_column(Text, default="")
level: Mapped[str] = mapped_column(String(100), default="novice-friendly") level: Mapped[str] = mapped_column(String(100), default="novice-friendly")
@ -36,6 +38,7 @@ class PackVersionORM(Base):
pack_id: Mapped[str] = mapped_column(ForeignKey("packs.id"), index=True) pack_id: Mapped[str] = mapped_column(ForeignKey("packs.id"), index=True)
version_number: Mapped[int] = mapped_column(Integer) version_number: Mapped[int] = mapped_column(Integer)
submitted_by_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) 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") status: Mapped[str] = mapped_column(String(50), default="draft")
data_json: Mapped[str] = mapped_column(Text) data_json: Mapped[str] = mapped_column(Text)
change_summary: Mapped[str] = mapped_column(Text, default="") change_summary: Mapped[str] = mapped_column(Text, default="")
@ -56,6 +59,7 @@ class ContributionSubmissionORM(Base):
__tablename__ = "contribution_submissions" __tablename__ = "contribution_submissions"
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
pack_id: Mapped[str] = mapped_column(String(100), index=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) proposed_version_number: Mapped[int] = mapped_column(Integer, default=1)
contributor_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) contributor_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
status: Mapped[str] = mapped_column(String(50), default="submitted") status: Mapped[str] = mapped_column(String(50), default="submitted")

View File

@ -4,12 +4,47 @@ from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from .db import SessionLocal from .db import SessionLocal
from .orm import UserORM, RefreshTokenORM, PackORM, PackVersionORM, ReviewCommentORM, ContributionSubmissionORM, ReviewTaskORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM from .orm import UserORM, RefreshTokenORM, PackORM, PackVersionORM, ReviewCommentORM, ContributionSubmissionORM, ReviewTaskORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM
from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent, DeploymentPolicyProfile
from .auth import verify_password from .auth import verify_password
from .config import load_settings
settings = load_settings()
def now_iso() -> str: def now_iso() -> str:
return datetime.now(timezone.utc).isoformat() 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: def pack_diff(old_pack: dict | None, new_pack: dict) -> dict:
old_pack = old_pack or {} old_pack = old_pack or {}
old_concepts = {c.get("id"): c for c in old_pack.get("concepts", [])} 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 row.is_revoked = True
db.commit() 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: with SessionLocal() as db:
stmt = select(PackORM) stmt = select(PackORM)
if not include_unpublished: if not include_unpublished:
stmt = stmt.where(PackORM.is_published == True) stmt = stmt.where(PackORM.is_published == True)
rows = db.execute(stmt).scalars().all() 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(): def list_pack_admin_rows():
with SessionLocal() as db: with SessionLocal() as db:
rows = db.execute(select(PackORM).order_by(PackORM.id)).scalars().all() 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): def get_pack(pack_id: str):
with SessionLocal() as db: with SessionLocal() as db:
row = db.get(PackORM, pack_id) row = db.get(PackORM, pack_id)
return None if row is None else PackData.model_validate(json.loads(row.data_json)) 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): def get_pack_validation(pack_id: str):
with SessionLocal() as db: with SessionLocal() as db:
row = db.get(PackORM, pack_id) row = db.get(PackORM, pack_id)
@ -119,36 +164,44 @@ def validation_and_provenance_for_pack(pack: PackData):
} }
return validation, provenance 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) validation, provenance = validation_and_provenance_for_pack(pack)
with SessionLocal() as db: with SessionLocal() as db:
row = db.get(PackORM, pack.id) row = db.get(PackORM, pack.id)
payload = json.dumps(pack.model_dump()) payload = json.dumps(pack.model_dump())
if row is None: if row is None:
row = PackORM( 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), 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) db.add(row)
version_number = 1 version_number = 1
else: 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.title = pack.title
row.subtitle = pack.subtitle row.subtitle = pack.subtitle
row.level = pack.level row.level = pack.level
row.data_json = payload row.data_json = payload
row.validation_json = json.dumps(validation) row.validation_json = json.dumps(validation)
row.provenance_json = json.dumps(provenance) row.provenance_json = json.dumps(provenance)
row.is_published = is_published
row.current_version += 1 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 version_number = row.current_version
db.flush() db.flush()
db.add(PackVersionORM( db.add(PackVersionORM(
pack_id=pack.id, pack_id=pack.id,
version_number=version_number, version_number=version_number,
submitted_by_user_id=submitted_by_user_id, 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, data_json=payload,
change_summary=change_summary, change_summary=change_summary,
created_at=now_iso(), created_at=now_iso(),
@ -166,8 +219,12 @@ def create_submission(pack: PackData, contributor_user_id: int, submission_summa
proposed_payload = pack.model_dump() proposed_payload = pack.model_dump()
diff = pack_diff(current_payload, proposed_payload) diff = pack_diff(current_payload, proposed_payload)
gates = gate_summary(validation, provenance) 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( sub = ContributionSubmissionORM(
pack_id=pack.id, pack_id=pack.id,
policy_lane="community",
proposed_version_number=proposed_version, proposed_version_number=proposed_version,
contributor_user_id=contributor_user_id, contributor_user_id=contributor_user_id,
status="submitted", status="submitted",
@ -183,7 +240,7 @@ def create_submission(pack: PackData, contributor_user_id: int, submission_summa
submission_id=sub.id, submission_id=sub.id,
reviewer_user_id=None, reviewer_user_id=None,
task_status="open", task_status="open",
task_note="Submission awaiting reviewer attention", task_note=task_note,
created_at=now_iso(), created_at=now_iso(),
)) ))
db.commit() db.commit()
@ -195,6 +252,7 @@ def list_submissions():
return [{ return [{
"submission_id": r.id, "submission_id": r.id,
"pack_id": r.pack_id, "pack_id": r.pack_id,
"policy_lane": r.policy_lane,
"proposed_version_number": r.proposed_version_number, "proposed_version_number": r.proposed_version_number,
"contributor_user_id": r.contributor_user_id, "contributor_user_id": r.contributor_user_id,
"status": r.status, "status": r.status,
@ -202,10 +260,6 @@ def list_submissions():
"created_at": r.created_at, "created_at": r.created_at,
} for r in rows] } 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): def get_submission_diff(submission_id: int):
with SessionLocal() as db: with SessionLocal() as db:
row = db.get(ContributionSubmissionORM, submission_id) row = db.get(ContributionSubmissionORM, submission_id)
@ -228,20 +282,48 @@ def list_review_tasks():
"created_at": r.created_at, "created_at": r.created_at,
} for r in rows] } 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): def set_pack_publication(pack_id: str, is_published: bool):
with SessionLocal() as db: with SessionLocal() as db:
row = db.get(PackORM, pack_id) row = db.get(PackORM, pack_id)
if row is None: if row is None:
return 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 row.is_published = is_published
db.commit() db.commit()
return True return True, "Updated"
def set_governance_state(pack_id: str, status: str, review_summary: str): def set_governance_state(pack_id: str, status: str, review_summary: str):
with SessionLocal() as db: with SessionLocal() as db:
row = db.get(PackORM, pack_id) row = db.get(PackORM, pack_id)
if row is None: if row is None:
return False return False
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 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() 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: if version is not None:
@ -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() rows = db.execute(select(PackVersionORM).where(PackVersionORM.pack_id == pack_id).order_by(PackVersionORM.version_number.desc())).scalars().all()
return [{ return [{
"version_number": r.version_number, "version_number": r.version_number,
"policy_lane": r.policy_lane,
"status": r.status, "status": r.status,
"change_summary": r.change_summary, "change_summary": r.change_summary,
"created_at": r.created_at, "created_at": r.created_at,

View File

@ -28,7 +28,23 @@ def main():
compliance=PackCompliance(sources=2, attributionRequired=True, shareAlikeRequired=True, noncommercialOnly=True, flags=["share-alike","noncommercial","excluded-third-party-content"]) compliance=PackCompliance(sources=2, attributionRequired=True, shareAlikeRequired=True, noncommercialOnly=True, flags=["share-alike","noncommercial","excluded-third-party-content"])
), ),
submitted_by_user_id=1, submitted_by_user_id=1,
policy_lane="community",
is_published=True, 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") print("Seeded database. Demo users: wesley/demo-pass and contrib/demo-pass")

View File

@ -3,10 +3,8 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Didactopus Learner Prototype</title> <title>Didactopus Deployment Policy + Agent Hooks</title>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</head> </head>
<body> <body><div id="root"></div></body>
<div id="root"></div>
</body>
</html> </html>

View File

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

View File

@ -1,148 +1,279 @@
import React, { useMemo, useState } from "react"; import React, { useEffect, useState } from "react";
import { domains } from "./sampleData"; 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 ( return (
<button className={`domain-card ${selected ? "selected" : ""}`} onClick={() => onSelect(domain.id)}> <div className="page narrow-page">
<div className="domain-title">{domain.title}</div> <section className="card narrow">
<div className="domain-subtitle">{domain.subtitle}</div> <h1>Didactopus login</h1>
<div className="domain-meta"> <label>Username<input value={username} onChange={(e) => setUsername(e.target.value)} /></label>
<span>{domain.level}</span> <label>Password<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} /></label>
<span>{domain.progress}% progress</span> <button className="primary" onClick={doLogin}>Login</button>
</div> {error ? <div className="error">{error}</div> : null}
</button>
);
}
function OnboardingPanel({ onboarding }) {
return (
<section className="card">
<h2>First-session onboarding</h2>
<h3>{onboarding.headline}</h3>
<p>{onboarding.body}</p>
<ul>
{onboarding.checklist.map((item, idx) => <li key={idx}>{item}</li>)}
</ul>
</section> </section>
);
}
function NextStepCard({ step }) {
return (
<div className="step-card">
<div className="step-header">
<div>
<h3>{step.title}</h3>
<div className="muted">{step.minutes} minutes</div>
</div>
<div className="reward-pill">{step.reward}</div>
</div>
<p>{step.reason}</p>
<details>
<summary>Why this is recommended</summary>
<ul>
{step.why.map((item, idx) => <li key={idx}>{item}</li>)}
</ul>
</details>
<button className="primary">Start this step</button>
</div> </div>
); );
} }
function MasteryMap({ nodes }) { function NavTabs({ tab, setTab, role }) {
return ( return (
<section className="card"> <div className="tab-row">
<h2>Visible mastery map</h2> <button className={tab==="policy" ? "active-tab" : ""} onClick={() => setTab("policy")}>Policy & agent hooks</button>
<div className="map-grid"> <button className={tab==="personal" ? "active-tab" : ""} onClick={() => setTab("personal")}>Personal packs</button>
{nodes.map((node) => ( <button className={tab==="contribute" ? "active-tab" : ""} onClick={() => setTab("contribute")}>Community contribution</button>
<div key={node.id} className={`map-node ${node.status}`}> {role === "admin" ? <>
<div className="node-label">{node.label}</div> <button className={tab==="submissions" ? "active-tab" : ""} onClick={() => setTab("submissions")}>Submissions</button>
<div className="node-status">{node.status}</div> <button className={tab==="review" ? "active-tab" : ""} onClick={() => setTab("review")}>Governance</button>
</> : null}
</div> </div>
))}
</div>
</section>
);
}
function MilestonePanel({ milestones, rewardLabel, progress }) {
return (
<section className="card">
<h2>Milestones and rewards</h2>
<div className="progress-wrap">
<div className="progress-label">Mastery progress</div>
<div className="progress-bar"><div className="progress-fill" style={{ width: `${progress}%` }} /></div>
<div className="muted">{progress}%</div>
</div>
<div className="reward-banner">{rewardLabel}</div>
<ul>
{milestones.map((m, idx) => <li key={idx}>{m}</li>)}
</ul>
</section>
);
}
function CompliancePanel({ compliance }) {
return (
<section className="card">
<h2>Source attribution and compliance</h2>
<div className="compliance-grid">
<div><strong>Sources</strong><br />{compliance.sources}</div>
<div><strong>Attribution</strong><br />{compliance.attributionRequired ? "required" : "not required"}</div>
<div><strong>Share-alike</strong><br />{compliance.shareAlikeRequired ? "yes" : "no"}</div>
<div><strong>Noncommercial</strong><br />{compliance.noncommercialOnly ? "yes" : "no"}</div>
</div>
<div className="flag-row">
{compliance.flags.length ? compliance.flags.map((f) => <span className="flag" key={f}>{f}</span>) : <span className="muted">No extra flags</span>}
</div>
<p className="muted">
This panel is here so a learner or curator can inspect provenance-sensitive packs without needing to guess what the reuse constraints are.
</p>
</section>
); );
} }
export default function App() { export default function App() {
const [selectedDomainId, setSelectedDomainId] = useState(domains[0].id); const [auth, setAuth] = useState(loadAuth());
const domain = useMemo(() => domains.find((d) => d.id === selectedDomainId) || domains[0], [selectedDomainId]); 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 <LoginView onAuth={setAuth} />;
return ( return (
<div className="page"> <div className="page">
<header className="hero"> <header className="hero">
<div> <div>
<h1>Didactopus learner prototype</h1> <h1>Didactopus deployment policy + agent hooks</h1>
<p> <p>Policy profiles plus explicit API parity for human UI use and AI learner use.</p>
Pick a topic, get a clear first session, see your mastery map, and understand why the system suggests each next step. <div className="muted">Signed in as {auth.username} ({auth.role})</div>
</p> {message ? <div className="message">{message}</div> : null}
</div>
<div className="hero-controls">
{auth.role === "admin" ? (
<label>Pack<select value={selectedPackId} onChange={(e) => setSelectedPackId(e.target.value)}>{adminPacks.map((p) => <option key={p.id} value={p.id}>{p.title} [{p.policy_lane}]</option>)}</select></label>
) : null}
<button onClick={() => { clearAuth(); setAuth(null); }}>Logout</button>
</div> </div>
</header> </header>
<section className="domain-grid"> <NavTabs tab={tab} setTab={setTab} role={auth.role} />
{domains.map((d) => (
<DomainCard key={d.id} domain={d} selected={d.id === domain.id} onSelect={setSelectedDomainId} />
))}
</section>
<main className="layout"> {tab === "policy" && (
<div className="left-col"> <main className="layout twocol">
<OnboardingPanel onboarding={domain.onboarding} />
<MasteryMap nodes={domain.masteryMap} />
</div>
<div className="center-col">
<section className="card"> <section className="card">
<h2>What should I do next?</h2> <h2>Deployment policy profile</h2>
<div className="steps-stack"> <pre className="prebox">{JSON.stringify(deploymentPolicy, null, 2)}</pre>
{domain.nextSteps.map((step) => <NextStepCard key={step.id} step={step} />)} </section>
</div> <section className="card">
<h2>AI learner capability hooks</h2>
<p className="muted">This installation exposes direct API hooks for an AI learner instead of requiring UI mediation.</p>
<pre className="prebox">{JSON.stringify(agentCapabilities, null, 2)}</pre>
</section> </section>
</div>
<div className="right-col">
<MilestonePanel milestones={domain.milestones} rewardLabel={domain.rewardLabel} progress={domain.progress} />
<CompliancePanel compliance={domain.compliance} />
</div>
</main> </main>
)}
{tab === "personal" && (
<main className="layout onecol">
<section className="card">
<h2>Personal lane authoring</h2>
<p className="muted">This lane is intended not to hamper an individual building packs for private use.</p>
<label>Pack ID<input value={personalPack.id} onChange={(e) => setPersonalPack({ ...personalPack, id: e.target.value })} /></label>
<label>Title<input value={personalPack.title} onChange={(e) => setPersonalPack({ ...personalPack, title: e.target.value })} /></label>
<label>Subtitle<input value={personalPack.subtitle} onChange={(e) => setPersonalPack({ ...personalPack, subtitle: e.target.value })} /></label>
<button className="primary" onClick={savePersonalPack}>Save personal pack directly</button>
<pre className="prebox">{JSON.stringify(personalPack, null, 2)}</pre>
</section>
</main>
)}
{tab === "contribute" && (
<main className="layout onecol">
<section className="card">
<h2>Community contribution lane</h2>
<p className="muted">Use this lane for packs intended to enter shared review and publication workflows.</p>
<label>Pack ID<input value={contribPack.id} onChange={(e) => setContribPack({ ...contribPack, id: e.target.value })} /></label>
<label>Title<input value={contribPack.title} onChange={(e) => setContribPack({ ...contribPack, title: e.target.value })} /></label>
<label>Subtitle<input value={contribPack.subtitle} onChange={(e) => setContribPack({ ...contribPack, subtitle: e.target.value })} /></label>
<button className="primary" onClick={submitContribution}>Submit for community review</button>
<pre className="prebox">{JSON.stringify(contribPack, null, 2)}</pre>
</section>
</main>
)}
{tab === "submissions" && auth.role === "admin" && (
<main className="layout twocol">
<section className="card">
<h2>Submission queue</h2>
<table className="table">
<thead><tr><th>ID</th><th>Pack</th><th>Lane</th><th>Version</th><th>Status</th><th>Select</th></tr></thead>
<tbody>
{submissions.map((s) => (
<tr key={s.submission_id}>
<td>{s.submission_id}</td>
<td>{s.pack_id}</td>
<td>{s.policy_lane}</td>
<td>{s.proposed_version_number}</td>
<td>{s.status}</td>
<td><button onClick={() => setSelectedSubmissionId(s.submission_id)}>Inspect</button></td>
</tr>
))}
</tbody>
</table>
<h3>Review tasks</h3>
<pre className="prebox">{JSON.stringify(reviewTasks, null, 2)}</pre>
</section>
<section className="card">
<h2>Submission diff and gates</h2>
<h3>Diff summary</h3>
<pre className="prebox">{JSON.stringify(submissionDiff, null, 2)}</pre>
<h3>Gate summary</h3>
<pre className="prebox">{JSON.stringify(submissionGates, null, 2)}</pre>
</section>
</main>
)}
{tab === "review" && auth.role === "admin" && (
<main className="layout twocol">
<section className="card">
<h2>Governance and publishability</h2>
<button onClick={publishSelected}>Publish</button>
<h3>Publishability</h3>
<pre className="prebox">{JSON.stringify(publishability, null, 2)}</pre>
<h3>Validation</h3>
<pre className="prebox">{JSON.stringify(validation, null, 2)}</pre>
<h3>Provenance</h3>
<pre className="prebox">{JSON.stringify(provenance, null, 2)}</pre>
</section>
<section className="card">
<h2>Versions and comments</h2>
<h3>Versions</h3>
<pre className="prebox">{JSON.stringify(versions, null, 2)}</pre>
<h3>Comments</h3>
<pre className="prebox">{JSON.stringify(comments, null, 2)}</pre>
</section>
</main>
)}
</div> </div>
); );
} }

View File

@ -16,7 +16,10 @@ export async function refresh(refreshToken) {
if (!res.ok) throw new Error("refresh failed"); if (!res.ok) throw new Error("refresh failed");
return await res.json(); 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 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 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 fetchAdminPacks(token) { const res = await fetch(`${API}/admin/packs`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchAdminPacks failed"); return await res.json(); }
export async function fetchPackValidation(token, packId) { const res = await fetch(`${API}/admin/packs/${packId}/validation`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPackValidation failed"); return await res.json(); } export async function 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 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 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 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 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 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(); }
export async function addReviewComment(token, packId, versionNumber, payload) { const res = await fetch(`${API}/admin/packs/${packId}/comments?version_number=${versionNumber}`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("addReviewComment failed"); return await res.json(); }

View File

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

View File

@ -1,177 +1,29 @@
:root { :root {
--bg: #f6f8fb; --bg:#f6f8fb; --card:#ffffff; --text:#1f2430; --muted:#60697a; --border:#dbe1ea; --accent:#2d6cdf; --soft:#eef4ff;
--card: #ffffff;
--text: #1f2430;
--muted: #60697a;
--border: #dbe1ea;
--accent: #2d6cdf;
--soft: #eef4ff;
} }
* { box-sizing: border-box; } * { box-sizing:border-box; }
body { body { margin:0; font-family:Arial, Helvetica, sans-serif; background:var(--bg); color:var(--text); }
margin: 0; .page { max-width:1500px; margin:0 auto; padding:24px; }
font-family: Arial, Helvetica, sans-serif; .narrow-page { max-width:520px; }
background: var(--bg); .hero { background:var(--card); border:1px solid var(--border); border-radius:22px; padding:24px; display:flex; justify-content:space-between; gap:16px; }
color: var(--text); .hero-controls { min-width:280px; display:flex; flex-direction:column; gap:12px; }
} label { display:block; font-weight:600; }
.page { input, select, textarea { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; }
max-width: 1500px; .card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; }
margin: 0 auto; .narrow { margin-top:60px; }
padding: 20px; .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; }
.hero { .active-tab, .primary { background:var(--accent); color:white; border-color:var(--accent); }
background: var(--card); .layout { display:grid; gap:16px; }
border: 1px solid var(--border); .twocol { grid-template-columns:1fr 1fr; }
border-radius: 22px; .onecol { grid-template-columns:1fr; }
padding: 24px; .muted { color:var(--muted); }
} .error { color:#b42318; margin-top:10px; }
.domain-grid { .message { margin-top:10px; padding:10px 12px; border:1px solid var(--border); background:#fff7dd; border-radius:12px; }
display: grid; .table { width:100%; border-collapse:collapse; }
grid-template-columns: repeat(2, 1fr); .table th, .table td { border-bottom:1px solid var(--border); text-align:left; padding:8px; vertical-align:top; }
gap: 16px; .prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:320px; }
margin-top: 16px; @media (max-width:1100px) {
} .hero { flex-direction:column; }
.domain-card { .twocol { grid-template-columns:1fr; }
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; }
} }