Apply ZIP update: 190-didactopus-deployment-policy-and-agent-hooks.zip [2026-03-14T13:20:24]
This commit is contained in:
parent
d7b7c235a5
commit
27bc03f77c
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }) {
|
||||||
return (
|
const [username, setUsername] = useState("wesley");
|
||||||
<button className={`domain-card ${selected ? "selected" : ""}`} onClick={() => onSelect(domain.id)}>
|
const [password, setPassword] = useState("demo-pass");
|
||||||
<div className="domain-title">{domain.title}</div>
|
const [error, setError] = useState("");
|
||||||
<div className="domain-subtitle">{domain.subtitle}</div>
|
async function doLogin() {
|
||||||
<div className="domain-meta">
|
try {
|
||||||
<span>{domain.level}</span>
|
const result = await login(username, password);
|
||||||
<span>{domain.progress}% progress</span>
|
saveAuth(result);
|
||||||
</div>
|
onAuth(result);
|
||||||
</button>
|
} catch { setError("Login failed"); }
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function OnboardingPanel({ onboarding }) {
|
|
||||||
return (
|
return (
|
||||||
<section className="card">
|
<div className="page narrow-page">
|
||||||
<h2>First-session onboarding</h2>
|
<section className="card narrow">
|
||||||
<h3>{onboarding.headline}</h3>
|
<h1>Didactopus login</h1>
|
||||||
<p>{onboarding.body}</p>
|
<label>Username<input value={username} onChange={(e) => setUsername(e.target.value)} /></label>
|
||||||
<ul>
|
<label>Password<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} /></label>
|
||||||
{onboarding.checklist.map((item, idx) => <li key={idx}>{item}</li>)}
|
<button className="primary" onClick={doLogin}>Login</button>
|
||||||
</ul>
|
{error ? <div className="error">{error}</div> : null}
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(); }
|
|
||||||
|
|
|
||||||
|
|
@ -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 />);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
.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) {
|
@media (max-width:1100px) {
|
||||||
.layout { grid-template-columns: 1fr; }
|
.hero { flex-direction:column; }
|
||||||
.domain-grid { grid-template-columns: 1fr; }
|
.twocol { grid-template-columns:1fr; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue