diff --git a/pyproject.toml b/pyproject.toml
index 17c47da..53c7bda 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,11 +8,9 @@ version = "0.1.0"
requires-python = ">=3.10"
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"
]
diff --git a/src/didactopus/api.py b/src/didactopus/api.py
index 28c4614..ea39309 100644
--- a/src/didactopus/api.py
+++ b/src/didactopus/api.py
@@ -5,10 +5,21 @@ from fastapi.middleware.cors import CORSMiddleware
import uvicorn
from .config import load_settings
from .db import Base, engine
-from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest, GovernanceAction, ReviewCommentCreate
-from .repository import authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token, list_packs, list_pack_admin_rows, get_pack, get_pack_validation, get_pack_provenance, upsert_pack, set_pack_publication, set_governance_state, list_pack_versions, add_review_comment, list_review_comments, create_learner, list_learners_for_user, learner_owned_by_user, load_learner_state, save_learner_state, create_evaluator_job, get_evaluator_job, list_evaluator_jobs_for_learner
+from .models import (
+ LoginRequest, ServiceAccountLoginRequest, ServiceAccountCreateRequest, ServiceAccountRotateRequest,
+ ServiceAccountStateRequest, ServiceToken, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState,
+ EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest, AgentCapabilityManifest,
+ AgentLearnerPlanRequest, AgentLearnerPlanResponse
+)
+from .repository import (
+ authenticate_user, get_user_by_id, create_service_account, list_service_accounts, authenticate_service_account,
+ rotate_service_account_secret, set_service_account_active, add_agent_audit_log, list_agent_audit_logs,
+ store_refresh_token, refresh_token_active, revoke_refresh_token, deployment_policy_profile, list_packs_for_user,
+ get_pack, get_pack_row, upsert_pack, create_learner, learner_owned_by_user, load_learner_state,
+ save_learner_state, create_evaluator_job, get_evaluator_job, list_evaluator_jobs_for_learner
+)
from .engine import apply_evidence, recommend_next
-from .auth import issue_access_token, issue_refresh_token, decode_token, new_token_id
+from .auth import issue_access_token, issue_refresh_token, issue_service_access_token, decode_token, new_token_id, new_secret
from .worker import process_job
settings = load_settings()
@@ -17,26 +28,75 @@ Base.metadata.create_all(bind=engine)
app = FastAPI(title="Didactopus API Prototype")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
-def current_user(authorization: str = Header(default="")):
+def current_actor(authorization: str = Header(default="")):
token = authorization.removeprefix("Bearer ").strip()
payload = decode_token(token) if token else None
- if not payload or payload.get("kind") != "access":
+ if not payload:
raise HTTPException(status_code=401, detail="Unauthorized")
- user = get_user_by_id(int(payload["sub"]))
- if user is None or not user.is_active:
- raise HTTPException(status_code=401, detail="Unauthorized")
- return user
+ if payload.get("kind") == "access":
+ user = get_user_by_id(int(payload["sub"]))
+ if user is None or not user.is_active:
+ raise HTTPException(status_code=401, detail="Unauthorized")
+ return {"actor_type": "user", "user": user, "scopes": None}
+ if payload.get("kind") == "service":
+ return {
+ "actor_type": "service",
+ "service_account_id": int(payload["sub"]),
+ "service_account_name": payload.get("service_account_name"),
+ "scopes": payload.get("scopes", []),
+ }
+ raise HTTPException(status_code=401, detail="Unauthorized")
-def require_admin(user = Depends(current_user)):
- if user.role != "admin":
+def require_admin(actor = Depends(current_actor)):
+ if actor["actor_type"] != "user" or actor["user"].role != "admin":
raise HTTPException(status_code=403, detail="Admin role required")
- return user
+ return actor["user"]
-def ensure_learner_access(user, learner_id: str):
+def audit_service_action(actor, action: str, target: str, outcome: str = "ok", detail: dict | None = None):
+ if actor["actor_type"] == "service":
+ add_agent_audit_log(
+ actor["service_account_id"],
+ actor["service_account_name"],
+ action,
+ target,
+ outcome,
+ detail or {},
+ )
+
+def require_scope(scope: str):
+ def inner(actor = Depends(current_actor)):
+ if actor["actor_type"] == "user":
+ return actor
+ scopes = set(actor.get("scopes") or [])
+ if scope not in scopes:
+ audit_service_action(actor, f"scope_denied:{scope}", "", "denied", {"scope": scope})
+ raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
+ return actor
+ return inner
+
+def ensure_learner_access(actor, learner_id: str):
+ if actor["actor_type"] == "service":
+ return
+ user = actor["user"]
if user.role == "admin":
return
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 actor")
+
+def ensure_pack_access(actor, pack_id: str):
+ row = get_pack_row(pack_id)
+ if row is None:
+ raise HTTPException(status_code=404, detail="Pack not found")
+ if actor["actor_type"] == "service":
+ return row
+ user = actor["user"]
+ 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 actor")
@app.post("/api/login", response_model=TokenPair)
def login(payload: LoginRequest):
@@ -47,6 +107,14 @@ def login(payload: LoginRequest):
store_refresh_token(user.id, token_id)
return TokenPair(access_token=issue_access_token(user.id, user.username, user.role), refresh_token=issue_refresh_token(user.id, user.username, user.role, token_id), username=user.username, role=user.role)
+@app.post("/api/service-accounts/login", response_model=ServiceToken)
+def service_login(payload: ServiceAccountLoginRequest):
+ sa = authenticate_service_account(payload.name, payload.secret)
+ if sa is None:
+ raise HTTPException(status_code=401, detail="Invalid service account credentials")
+ scopes = json.loads(sa.scopes_json or "[]")
+ return ServiceToken(access_token=issue_service_access_token(sa.id, sa.name, scopes), service_account_name=sa.name, scopes=scopes)
+
@app.post("/api/refresh", response_model=TokenPair)
def refresh(payload: RefreshRequest):
data = decode_token(payload.refresh_token)
@@ -63,117 +131,137 @@ def refresh(payload: RefreshRequest):
store_refresh_token(user.id, new_jti)
return TokenPair(access_token=issue_access_token(user.id, user.username, user.role), refresh_token=issue_refresh_token(user.id, user.username, user.role, new_jti), username=user.username, role=user.role)
+@app.get("/api/deployment-policy")
+def api_deployment_policy(actor = Depends(current_actor)):
+ return deployment_policy_profile().model_dump()
+
+@app.get("/api/agent/capabilities", response_model=AgentCapabilityManifest)
+def api_agent_capabilities(actor = Depends(current_actor)):
+ return AgentCapabilityManifest()
+
+@app.post("/api/agent/learner-plan", response_model=AgentLearnerPlanResponse)
+def api_agent_learner_plan(payload: AgentLearnerPlanRequest, actor = Depends(require_scope("recommendations:read"))):
+ ensure_learner_access(actor, payload.learner_id)
+ ensure_pack_access(actor, 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)
+ audit_service_action(actor, "agent_learner_plan", f"{payload.learner_id}:{payload.pack_id}", "ok", {"cards": len(cards)})
+ return AgentLearnerPlanResponse(learner_id=payload.learner_id, pack_id=payload.pack_id, next_cards=cards, suggested_actions=["Read learner state", "Choose next card", "Submit evidence", "Refresh recommendations"])
+
+@app.post("/api/admin/service-accounts")
+def api_create_service_account(payload: ServiceAccountCreateRequest, user = Depends(require_admin)):
+ secret = new_secret()
+ sa = create_service_account(payload.name, user.id, payload.description, payload.scopes, secret)
+ return {"id": sa.id, "name": sa.name, "scopes": payload.scopes, "secret": secret}
+
+@app.get("/api/admin/service-accounts")
+def api_list_service_accounts(user = Depends(require_admin)):
+ return list_service_accounts()
+
+@app.post("/api/admin/service-accounts/rotate")
+def api_rotate_service_account(payload: ServiceAccountRotateRequest, user = Depends(require_admin)):
+ secret = new_secret()
+ sa = rotate_service_account_secret(payload.name, secret)
+ if sa is None:
+ raise HTTPException(status_code=404, detail="Service account not found")
+ return {"name": sa.name, "secret": secret}
+
+@app.post("/api/admin/service-accounts/state")
+def api_service_account_state(payload: ServiceAccountStateRequest, name: str, user = Depends(require_admin)):
+ sa = set_service_account_active(name, payload.is_active)
+ if sa is None:
+ raise HTTPException(status_code=404, detail="Service account not found")
+ return {"name": sa.name, "is_active": sa.is_active}
+
+@app.get("/api/admin/agent-audit-logs")
+def api_agent_audit_logs(user = Depends(require_admin)):
+ return list_agent_audit_logs()
+
@app.get("/api/packs")
-def api_list_packs(user = Depends(current_user)):
- return [p.model_dump() for p in list_packs(include_unpublished=(user.role == "admin"))]
+def api_list_packs(actor = Depends(require_scope("packs:read"))):
+ user_id = actor["user"].id if actor["actor_type"] == "user" else None
+ packs = [p.model_dump() for p in list_packs_for_user(user_id, include_unpublished=(actor["actor_type"] == "user" and actor["user"].role == "admin"))]
+ audit_service_action(actor, "packs_list", "packs", "ok", {"count": len(packs)})
+ return packs
-@app.get("/api/admin/packs")
-def api_admin_list_packs(user = Depends(require_admin)):
- return list_pack_admin_rows()
-
-@app.get("/api/admin/packs/{pack_id}/validation")
-def api_admin_pack_validation(pack_id: str, user = Depends(require_admin)):
- return get_pack_validation(pack_id)
-
-@app.get("/api/admin/packs/{pack_id}/provenance")
-def api_admin_pack_provenance(pack_id: str, user = Depends(require_admin)):
- return get_pack_provenance(pack_id)
-
-@app.get("/api/admin/packs/{pack_id}/versions")
-def api_admin_pack_versions(pack_id: str, user = Depends(require_admin)):
- return list_pack_versions(pack_id)
-
-@app.get("/api/admin/packs/{pack_id}/comments")
-def api_admin_pack_comments(pack_id: str, user = Depends(require_admin)):
- return list_review_comments(pack_id)
-
-@app.post("/api/admin/packs")
-def api_upsert_pack(payload: CreatePackRequest, user = Depends(require_admin)):
- upsert_pack(payload.pack, submitted_by_user_id=user.id, is_published=payload.is_published, change_summary=payload.change_summary)
- return {"ok": True, "pack_id": payload.pack.id}
-
-@app.post("/api/admin/packs/{pack_id}/publish")
-def api_publish_pack(pack_id: str, is_published: bool, user = Depends(require_admin)):
- ok = set_pack_publication(pack_id, is_published)
- if not ok:
- raise HTTPException(status_code=404, detail="Pack not found")
- return {"ok": True, "pack_id": pack_id, "is_published": is_published}
-
-@app.post("/api/admin/packs/{pack_id}/governance")
-def api_governance_action(pack_id: str, payload: GovernanceAction, user = Depends(require_admin)):
- ok = set_governance_state(pack_id, payload.status, payload.review_summary)
- if not ok:
- raise HTTPException(status_code=404, detail="Pack not found")
- return {"ok": True, "pack_id": pack_id, "status": payload.status}
-
-@app.post("/api/admin/packs/{pack_id}/comments")
-def api_add_review_comment(pack_id: str, version_number: int, payload: ReviewCommentCreate, user = Depends(require_admin)):
- add_review_comment(pack_id, version_number, user.id, payload.comment_text, payload.disposition)
- return {"ok": True}
+@app.post("/api/packs")
+def api_upsert_personal_pack(payload: CreatePackRequest, actor = Depends(require_scope("packs:write_personal"))):
+ if payload.policy_lane != "personal":
+ raise HTTPException(status_code=403, detail="This endpoint is for personal-lane write access")
+ if actor["actor_type"] != "user":
+ raise HTTPException(status_code=403, detail="Service accounts may not own personal packs in this scaffold")
+ upsert_pack(payload.pack, submitted_by_user_id=actor["user"].id, policy_lane="personal", is_published=payload.is_published, change_summary=payload.change_summary)
+ return {"ok": True, "pack_id": payload.pack.id, "policy_lane": "personal"}
@app.post("/api/learners")
-def api_create_learner(payload: CreateLearnerRequest, user = Depends(current_user)):
- create_learner(user.id, payload.learner_id, payload.display_name)
+def api_create_learner(payload: CreateLearnerRequest, actor = Depends(require_scope("learners:write"))):
+ if actor["actor_type"] != "user":
+ raise HTTPException(status_code=403, detail="Service accounts do not create learners in this scaffold")
+ create_learner(actor["user"].id, payload.learner_id, payload.display_name)
return {"ok": True, "learner_id": payload.learner_id}
-@app.get("/api/learners")
-def api_list_learners(user = Depends(current_user)):
- return list_learners_for_user(user.id, is_admin=(user.role == "admin"))
-
@app.get("/api/learners/{learner_id}/state")
-def api_get_learner_state(learner_id: str, user = Depends(current_user)):
- ensure_learner_access(user, learner_id)
- return load_learner_state(learner_id).model_dump()
+def api_get_learner_state(learner_id: str, actor = Depends(require_scope("learners:read"))):
+ ensure_learner_access(actor, learner_id)
+ state = load_learner_state(learner_id).model_dump()
+ audit_service_action(actor, "learner_state_read", learner_id, "ok", {"records": len(state.get("records", []))})
+ return state
@app.put("/api/learners/{learner_id}/state")
-def api_put_learner_state(learner_id: str, state: LearnerState, user = Depends(current_user)):
- ensure_learner_access(user, learner_id)
+def api_put_learner_state(learner_id: str, state: LearnerState, actor = Depends(require_scope("learners:write"))):
+ ensure_learner_access(actor, learner_id)
if learner_id != state.learner_id:
raise HTTPException(status_code=400, detail="Learner ID mismatch")
- return save_learner_state(state).model_dump()
+ result = save_learner_state(state).model_dump()
+ audit_service_action(actor, "learner_state_write", learner_id, "ok", {"records": len(result.get("records", []))})
+ return result
@app.post("/api/learners/{learner_id}/evidence")
-def api_post_evidence(learner_id: str, event: EvidenceEvent, user = Depends(current_user)):
- ensure_learner_access(user, learner_id)
+def api_post_evidence(learner_id: str, event: EvidenceEvent, actor = Depends(require_scope("learners:write"))):
+ ensure_learner_access(actor, learner_id)
state = load_learner_state(learner_id)
state = apply_evidence(state, event)
- save_learner_state(state)
- return state.model_dump()
+ result = save_learner_state(state).model_dump()
+ audit_service_action(actor, "learner_evidence_post", learner_id, "ok", {"concept_id": event.concept_id})
+ return result
@app.get("/api/learners/{learner_id}/recommendations/{pack_id}")
-def api_get_recommendations(learner_id: str, pack_id: str, user = Depends(current_user)):
- ensure_learner_access(user, learner_id)
+def api_get_recommendations(learner_id: str, pack_id: str, actor = Depends(require_scope("recommendations:read"))):
+ ensure_learner_access(actor, learner_id)
+ ensure_pack_access(actor, pack_id)
state = load_learner_state(learner_id)
pack = get_pack(pack_id)
if pack is None:
raise HTTPException(status_code=404, detail="Pack not found")
- return {"cards": recommend_next(state, pack)}
+ cards = recommend_next(state, pack)
+ audit_service_action(actor, "recommendations_read", f"{learner_id}:{pack_id}", "ok", {"cards": len(cards)})
+ return {"cards": cards}
@app.post("/api/learners/{learner_id}/evaluator-jobs", response_model=EvaluatorJobStatus)
-def api_submit_evaluator_job(learner_id: str, payload: EvaluatorSubmission, background_tasks: BackgroundTasks, user = Depends(current_user)):
- ensure_learner_access(user, learner_id)
+def api_submit_evaluator_job(learner_id: str, payload: EvaluatorSubmission, background_tasks: BackgroundTasks, actor = Depends(require_scope("evaluators:submit"))):
+ ensure_learner_access(actor, learner_id)
+ ensure_pack_access(actor, payload.pack_id)
job_id = create_evaluator_job(learner_id, payload.pack_id, payload.concept_id, payload.submitted_text)
background_tasks.add_task(process_job, job_id)
+ audit_service_action(actor, "evaluator_job_submit", str(job_id), "ok", {"learner_id": learner_id, "pack_id": payload.pack_id})
return EvaluatorJobStatus(job_id=job_id, status="queued")
@app.get("/api/evaluator-jobs/{job_id}", response_model=EvaluatorJobStatus)
-def api_get_evaluator_job(job_id: int, user = Depends(current_user)):
+def api_get_evaluator_job(job_id: int, actor = Depends(require_scope("evaluators:read"))):
job = get_evaluator_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job not found")
+ audit_service_action(actor, "evaluator_job_read", str(job_id), "ok", {})
return EvaluatorJobStatus(job_id=job.id, status=job.status, result_score=job.result_score, result_confidence_hint=job.result_confidence_hint, result_notes=job.result_notes)
-@app.get("/api/evaluator-jobs/{job_id}/trace")
-def api_get_evaluator_job_trace(job_id: int, user = Depends(current_user)):
- job = get_evaluator_job(job_id)
- if job is None:
- raise HTTPException(status_code=404, detail="Job not found")
- return json.loads(job.trace_json or "{}")
-
@app.get("/api/learners/{learner_id}/evaluator-history")
-def api_get_evaluator_history(learner_id: str, user = Depends(current_user)):
- ensure_learner_access(user, learner_id)
+def api_get_evaluator_history(learner_id: str, actor = Depends(require_scope("evaluators:read"))):
+ ensure_learner_access(actor, learner_id)
jobs = list_evaluator_jobs_for_learner(learner_id)
+ audit_service_action(actor, "evaluator_history_read", learner_id, "ok", {"jobs": len(jobs)})
return [{"job_id": j.id, "status": j.status, "concept_id": j.concept_id, "result_score": j.result_score, "result_confidence_hint": j.result_confidence_hint, "result_notes": j.result_notes} for j in jobs]
def main():
diff --git a/src/didactopus/auth.py b/src/didactopus/auth.py
index 54745ac..c7b3117 100644
--- a/src/didactopus/auth.py
+++ b/src/didactopus/auth.py
@@ -25,6 +25,9 @@ def issue_access_token(user_id: int, username: str, role: str) -> str:
def issue_refresh_token(user_id: int, username: str, role: str, token_id: str) -> str:
return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "refresh", "jti": token_id}, timedelta(days=14))
+def issue_service_access_token(service_account_id: int, name: str, scopes: list[str]) -> str:
+ return _encode_token({"sub": str(service_account_id), "service_account_name": name, "kind": "service", "scopes": scopes}, timedelta(hours=8))
+
def decode_token(token: str) -> dict | None:
try:
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
@@ -33,3 +36,6 @@ def decode_token(token: str) -> dict | None:
def new_token_id() -> str:
return secrets.token_urlsafe(24)
+
+def new_secret() -> str:
+ return secrets.token_urlsafe(24)
diff --git a/src/didactopus/config.py b/src/didactopus/config.py
index 6db28e1..88e5551 100644
--- a/src/didactopus/config.py
+++ b/src/didactopus/config.py
@@ -3,11 +3,12 @@ import os
from pydantic import BaseModel
class Settings(BaseModel):
- database_url: str = os.getenv("DIDACTOPUS_DATABASE_URL", "postgresql+psycopg://didactopus:didactopus-dev-password@localhost:5432/didactopus")
+ database_url: str = os.getenv("DIDACTOPUS_DATABASE_URL", "sqlite+pysqlite:///:memory:")
host: str = os.getenv("DIDACTOPUS_HOST", "127.0.0.1")
port: int = int(os.getenv("DIDACTOPUS_PORT", "8011"))
jwt_secret: str = os.getenv("DIDACTOPUS_JWT_SECRET", "change-me")
jwt_algorithm: str = "HS256"
+ deployment_policy_profile: str = os.getenv("DIDACTOPUS_POLICY_PROFILE", "single_user")
def load_settings() -> Settings:
return Settings()
diff --git a/src/didactopus/models.py b/src/didactopus/models.py
index fc04c4a..4462baa 100644
--- a/src/didactopus/models.py
+++ b/src/didactopus/models.py
@@ -3,6 +3,7 @@ from pydantic import BaseModel, Field
from typing import Literal
EvidenceKind = Literal["checkpoint", "project", "exercise", "review"]
+PolicyLane = Literal["personal", "community"]
class TokenPair(BaseModel):
access_token: str
@@ -11,13 +12,57 @@ class TokenPair(BaseModel):
username: str
role: str
+class ServiceToken(BaseModel):
+ access_token: str
+ token_type: str = "bearer"
+ service_account_name: str
+ scopes: list[str]
+
class LoginRequest(BaseModel):
username: str
password: str
+class ServiceAccountLoginRequest(BaseModel):
+ name: str
+ secret: str
+
+class ServiceAccountCreateRequest(BaseModel):
+ name: str
+ description: str = ""
+ scopes: list[str] = Field(default_factory=list)
+
+class ServiceAccountRotateRequest(BaseModel):
+ name: str
+
+class ServiceAccountStateRequest(BaseModel):
+ is_active: bool
+
class RefreshRequest(BaseModel):
refresh_token: str
+class DeploymentPolicyProfile(BaseModel):
+ profile_name: str
+ default_personal_lane_enabled: bool = True
+ default_community_lane_enabled: bool = True
+ community_publish_requires_approval: bool = True
+ personal_publish_direct: bool = True
+ reviewer_assignment_required: bool = False
+ description: str = ""
+
+class AgentCapabilityManifest(BaseModel):
+ supports_pack_listing: bool = True
+ supports_pack_write_personal: bool = True
+ supports_pack_submit_community: bool = True
+ supports_recommendations: bool = True
+ supports_learner_state_read: bool = True
+ supports_learner_state_write: bool = True
+ supports_evaluator_jobs: bool = True
+ supports_governance_endpoints: bool = True
+ supports_review_queue: bool = True
+ supports_service_accounts: bool = True
+ supports_agent_audit_logs: bool = True
+ supports_service_account_rotation: bool = True
+
class PackConcept(BaseModel):
id: str
title: str
@@ -43,17 +88,10 @@ class PackData(BaseModel):
class CreatePackRequest(BaseModel):
pack: PackData
+ policy_lane: PolicyLane = "personal"
is_published: bool = False
change_summary: str = ""
-class GovernanceAction(BaseModel):
- status: str
- review_summary: str = ""
-
-class ReviewCommentCreate(BaseModel):
- comment_text: str
- disposition: str = "comment"
-
class CreateLearnerRequest(BaseModel):
learner_id: str
display_name: str = ""
@@ -92,3 +130,13 @@ class EvaluatorJobStatus(BaseModel):
result_score: float | None = None
result_confidence_hint: float | None = None
result_notes: str = ""
+
+class AgentLearnerPlanRequest(BaseModel):
+ learner_id: str
+ pack_id: str
+
+class AgentLearnerPlanResponse(BaseModel):
+ learner_id: str
+ pack_id: str
+ next_cards: list[dict] = Field(default_factory=list)
+ suggested_actions: list[str] = Field(default_factory=list)
diff --git a/src/didactopus/orm.py b/src/didactopus/orm.py
index 81ba232..4abc0ed 100644
--- a/src/didactopus/orm.py
+++ b/src/didactopus/orm.py
@@ -10,6 +10,27 @@ class UserORM(Base):
role: Mapped[str] = mapped_column(String(50), default="learner")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
+class ServiceAccountORM(Base):
+ __tablename__ = "service_accounts"
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ name: Mapped[str] = mapped_column(String(120), unique=True, index=True)
+ owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
+ description: Mapped[str] = mapped_column(Text, default="")
+ scopes_json: Mapped[str] = mapped_column(Text, default="[]")
+ secret_hash: Mapped[str] = mapped_column(String(255))
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
+
+class AgentAuditLogORM(Base):
+ __tablename__ = "agent_audit_logs"
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+ service_account_id: Mapped[int] = mapped_column(ForeignKey("service_accounts.id"), index=True)
+ service_account_name: Mapped[str] = mapped_column(String(120), index=True)
+ action: Mapped[str] = mapped_column(String(120), index=True)
+ target: Mapped[str] = mapped_column(String(255), default="")
+ outcome: Mapped[str] = mapped_column(String(50), default="ok")
+ detail_json: Mapped[str] = mapped_column(Text, default="{}")
+ created_at: Mapped[str] = mapped_column(String(100), default="")
+
class RefreshTokenORM(Base):
__tablename__ = "refresh_tokens"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
@@ -20,6 +41,8 @@ class RefreshTokenORM(Base):
class PackORM(Base):
__tablename__ = "packs"
id: Mapped[str] = mapped_column(String(100), primary_key=True)
+ owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
+ policy_lane: Mapped[str] = mapped_column(String(50), default="personal")
title: Mapped[str] = mapped_column(String(255))
subtitle: Mapped[str] = mapped_column(Text, default="")
level: Mapped[str] = mapped_column(String(100), default="novice-friendly")
@@ -30,28 +53,6 @@ class PackORM(Base):
current_version: Mapped[int] = mapped_column(Integer, default=1)
is_published: Mapped[bool] = mapped_column(Boolean, default=False)
-class PackVersionORM(Base):
- __tablename__ = "pack_versions"
- id: Mapped[int] = mapped_column(Integer, primary_key=True)
- pack_id: Mapped[str] = mapped_column(ForeignKey("packs.id"), index=True)
- version_number: Mapped[int] = mapped_column(Integer)
- submitted_by_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
- status: Mapped[str] = mapped_column(String(50), default="draft")
- data_json: Mapped[str] = mapped_column(Text)
- change_summary: Mapped[str] = mapped_column(Text, default="")
- created_at: Mapped[str] = mapped_column(String(100), default="")
- review_summary: Mapped[str] = mapped_column(Text, default="")
-
-class ReviewCommentORM(Base):
- __tablename__ = "review_comments"
- id: Mapped[int] = mapped_column(Integer, primary_key=True)
- pack_id: Mapped[str] = mapped_column(ForeignKey("packs.id"), index=True)
- version_number: Mapped[int] = mapped_column(Integer)
- reviewer_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
- comment_text: Mapped[str] = mapped_column(Text, default="")
- disposition: Mapped[str] = mapped_column(String(50), default="comment")
- created_at: Mapped[str] = mapped_column(String(100), default="")
-
class LearnerORM(Base):
__tablename__ = "learners"
id: Mapped[str] = mapped_column(String(100), primary_key=True)
diff --git a/src/didactopus/repository.py b/src/didactopus/repository.py
index 2e3cda0..fb8fc6a 100644
--- a/src/didactopus/repository.py
+++ b/src/didactopus/repository.py
@@ -1,15 +1,29 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
-from sqlalchemy import select, func
+from sqlalchemy import select
from .db import SessionLocal
-from .orm import UserORM, RefreshTokenORM, PackORM, PackVersionORM, ReviewCommentORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM
-from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent
-from .auth import verify_password
+from .orm import UserORM, ServiceAccountORM, AgentAuditLogORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM
+from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent, DeploymentPolicyProfile
+from .auth import verify_password, hash_password
+from .config import load_settings
+
+settings = load_settings()
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
+def deployment_policy_profile() -> DeploymentPolicyProfile:
+ return DeploymentPolicyProfile(
+ profile_name=settings.deployment_policy_profile,
+ default_personal_lane_enabled=True,
+ default_community_lane_enabled=True,
+ community_publish_requires_approval=True,
+ personal_publish_direct=True,
+ reviewer_assignment_required=False,
+ description="Deployment policy scaffold."
+ )
+
def get_user_by_username(username: str):
with SessionLocal() as db:
return db.execute(select(UserORM).where(UserORM.username == username)).scalar_one_or_none()
@@ -24,6 +38,83 @@ def authenticate_user(username: str, password: str):
return None
return user
+def create_service_account(name: str, owner_user_id: int | None, description: str, scopes: list[str], secret: str):
+ with SessionLocal() as db:
+ sa = ServiceAccountORM(
+ name=name,
+ owner_user_id=owner_user_id,
+ description=description,
+ scopes_json=json.dumps(scopes),
+ secret_hash=hash_password(secret),
+ is_active=True,
+ )
+ db.add(sa)
+ db.commit()
+ db.refresh(sa)
+ return sa
+
+def list_service_accounts():
+ with SessionLocal() as db:
+ rows = db.execute(select(ServiceAccountORM).order_by(ServiceAccountORM.id)).scalars().all()
+ return [{"id": r.id, "name": r.name, "owner_user_id": r.owner_user_id, "description": r.description, "scopes": json.loads(r.scopes_json or "[]"), "is_active": r.is_active} for r in rows]
+
+def get_service_account_by_name(name: str):
+ with SessionLocal() as db:
+ return db.execute(select(ServiceAccountORM).where(ServiceAccountORM.name == name)).scalar_one_or_none()
+
+def authenticate_service_account(name: str, secret: str):
+ sa = get_service_account_by_name(name)
+ if sa is None or not sa.is_active or not verify_password(secret, sa.secret_hash):
+ return None
+ return sa
+
+def rotate_service_account_secret(name: str, new_secret: str):
+ with SessionLocal() as db:
+ sa = db.execute(select(ServiceAccountORM).where(ServiceAccountORM.name == name)).scalar_one_or_none()
+ if sa is None:
+ return None
+ sa.secret_hash = hash_password(new_secret)
+ db.commit()
+ db.refresh(sa)
+ return sa
+
+def set_service_account_active(name: str, is_active: bool):
+ with SessionLocal() as db:
+ sa = db.execute(select(ServiceAccountORM).where(ServiceAccountORM.name == name)).scalar_one_or_none()
+ if sa is None:
+ return None
+ sa.is_active = is_active
+ db.commit()
+ db.refresh(sa)
+ return sa
+
+def add_agent_audit_log(service_account_id: int, service_account_name: str, action: str, target: str, outcome: str, detail: dict):
+ with SessionLocal() as db:
+ db.add(AgentAuditLogORM(
+ service_account_id=service_account_id,
+ service_account_name=service_account_name,
+ action=action,
+ target=target,
+ outcome=outcome,
+ detail_json=json.dumps(detail),
+ created_at=now_iso(),
+ ))
+ db.commit()
+
+def list_agent_audit_logs(limit: int = 200):
+ with SessionLocal() as db:
+ rows = db.execute(select(AgentAuditLogORM).order_by(AgentAuditLogORM.id.desc())).scalars().all()[:limit]
+ return [{
+ "id": r.id,
+ "service_account_id": r.service_account_id,
+ "service_account_name": r.service_account_name,
+ "action": r.action,
+ "target": r.target,
+ "outcome": r.outcome,
+ "detail": json.loads(r.detail_json or "{}"),
+ "created_at": r.created_at,
+ } for r in rows]
+
def store_refresh_token(user_id: int, token_id: str):
with SessionLocal() as db:
db.add(RefreshTokenORM(user_id=user_id, token_id=token_id, is_revoked=False))
@@ -41,164 +132,71 @@ def revoke_refresh_token(token_id: str):
row.is_revoked = True
db.commit()
-def list_packs(include_unpublished: bool = False):
+def list_packs_for_user(user_id: int | None = None, include_unpublished: bool = False):
with SessionLocal() as db:
stmt = select(PackORM)
if not include_unpublished:
stmt = stmt.where(PackORM.is_published == True)
rows = db.execute(stmt).scalars().all()
- return [PackData.model_validate(json.loads(r.data_json)) for r in rows]
-
-def list_pack_admin_rows():
- with SessionLocal() as db:
- rows = db.execute(select(PackORM).order_by(PackORM.id)).scalars().all()
- return [{"id": r.id, "title": r.title, "is_published": r.is_published, "subtitle": r.subtitle, "governance_state": r.governance_state, "current_version": r.current_version} for r in rows]
+ 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 get_pack(pack_id: str):
with SessionLocal() as db:
row = db.get(PackORM, pack_id)
return None if row is None else PackData.model_validate(json.loads(row.data_json))
-def get_pack_validation(pack_id: str):
+def get_pack_row(pack_id: str):
with SessionLocal() as db:
- row = db.get(PackORM, pack_id)
- return {} if row is None else json.loads(row.validation_json or "{}")
+ return db.get(PackORM, pack_id)
-def get_pack_provenance(pack_id: str):
- with SessionLocal() as db:
- row = db.get(PackORM, pack_id)
- return {} if row is None else json.loads(row.provenance_json or "{}")
-
-def upsert_pack(pack: PackData, submitted_by_user_id: int, is_published: bool = False, change_summary: str = ""):
- validation = {
- "ok": len(pack.concepts) > 0,
- "warnings": [] if len(pack.concepts) > 0 else ["Pack has no concepts."],
- "errors": [],
- "summary": {"concept_count": len(pack.concepts), "has_onboarding": bool(pack.onboarding)}
- }
- provenance = {
- "source_count": pack.compliance.sources,
- "licenses_present": ["CC BY-NC-SA 4.0"] if pack.compliance.shareAlikeRequired or pack.compliance.noncommercialOnly else [],
- "restrictive_flags": list(pack.compliance.flags),
- "sources": [
- {"source_id": "sample-source-1", "title": f"Provenance placeholder for {pack.title}", "license_id": "CC BY-NC-SA 4.0" if pack.compliance.shareAlikeRequired or pack.compliance.noncommercialOnly else "unspecified", "attribution_text": "Sample attribution text placeholder"}
- ] if pack.compliance.sources else []
- }
+def upsert_pack(pack: PackData, submitted_by_user_id: int, policy_lane: str = "personal", is_published: bool = False, change_summary: str = ""):
+ validation = {"ok": len(pack.concepts) > 0, "warnings": [] if len(pack.concepts) > 0 else ["Pack has no concepts."], "errors": []}
+ provenance = {"source_count": pack.compliance.sources, "restrictive_flags": list(pack.compliance.flags)}
with SessionLocal() as db:
row = db.get(PackORM, pack.id)
payload = json.dumps(pack.model_dump())
if row is None:
row = PackORM(
- id=pack.id, title=pack.title, subtitle=pack.subtitle, level=pack.level,
- data_json=payload, validation_json=json.dumps(validation), provenance_json=json.dumps(provenance),
- governance_state="draft", current_version=1, is_published=is_published
+ id=pack.id,
+ owner_user_id=submitted_by_user_id if policy_lane == "personal" else None,
+ policy_lane=policy_lane,
+ title=pack.title,
+ subtitle=pack.subtitle,
+ level=pack.level,
+ data_json=payload,
+ validation_json=json.dumps(validation),
+ provenance_json=json.dumps(provenance),
+ governance_state="personal_ready" if policy_lane == "personal" else "draft",
+ current_version=1,
+ is_published=is_published if policy_lane == "personal" else False,
)
db.add(row)
- version_number = 1
else:
+ row.owner_user_id = submitted_by_user_id if policy_lane == "personal" else row.owner_user_id
+ row.policy_lane = policy_lane
row.title = pack.title
row.subtitle = pack.subtitle
row.level = pack.level
row.data_json = payload
row.validation_json = json.dumps(validation)
row.provenance_json = json.dumps(provenance)
- row.is_published = is_published
row.current_version += 1
- row.governance_state = "draft"
- version_number = row.current_version
- db.flush()
- db.add(PackVersionORM(
- pack_id=pack.id,
- version_number=version_number,
- submitted_by_user_id=submitted_by_user_id,
- status="draft",
- data_json=payload,
- change_summary=change_summary,
- created_at=now_iso(),
- review_summary=""
- ))
+ if policy_lane == "personal":
+ row.is_published = is_published
db.commit()
-def set_pack_publication(pack_id: str, is_published: bool):
- with SessionLocal() as db:
- row = db.get(PackORM, pack_id)
- if row is None:
- return False
- row.is_published = is_published
- db.commit()
- return True
-
-def set_governance_state(pack_id: str, status: str, review_summary: str):
- with SessionLocal() as db:
- row = db.get(PackORM, pack_id)
- if row is None:
- return False
- row.governance_state = status
- version = db.execute(
- select(PackVersionORM).where(
- PackVersionORM.pack_id == pack_id,
- PackVersionORM.version_number == row.current_version
- )
- ).scalar_one_or_none()
- if version is not None:
- version.status = status
- version.review_summary = review_summary
- db.commit()
- return True
-
-def list_pack_versions(pack_id: str):
- with SessionLocal() as db:
- rows = db.execute(
- select(PackVersionORM).where(PackVersionORM.pack_id == pack_id).order_by(PackVersionORM.version_number.desc())
- ).scalars().all()
- return [{
- "version_number": r.version_number,
- "status": r.status,
- "change_summary": r.change_summary,
- "created_at": r.created_at,
- "review_summary": r.review_summary,
- "submitted_by_user_id": r.submitted_by_user_id
- } for r in rows]
-
-def add_review_comment(pack_id: str, version_number: int, reviewer_user_id: int, comment_text: str, disposition: str):
- with SessionLocal() as db:
- db.add(ReviewCommentORM(
- pack_id=pack_id,
- version_number=version_number,
- reviewer_user_id=reviewer_user_id,
- comment_text=comment_text,
- disposition=disposition,
- created_at=now_iso()
- ))
- db.commit()
-
-def list_review_comments(pack_id: str):
- with SessionLocal() as db:
- rows = db.execute(
- select(ReviewCommentORM).where(ReviewCommentORM.pack_id == pack_id).order_by(ReviewCommentORM.id.desc())
- ).scalars().all()
- return [{
- "version_number": r.version_number,
- "reviewer_user_id": r.reviewer_user_id,
- "comment_text": r.comment_text,
- "disposition": r.disposition,
- "created_at": r.created_at
- } for r in rows]
-
def create_learner(owner_user_id: int, learner_id: str, display_name: str = ""):
with SessionLocal() as db:
if db.get(LearnerORM, learner_id) is None:
db.add(LearnerORM(id=learner_id, owner_user_id=owner_user_id, display_name=display_name))
db.commit()
-def list_learners_for_user(user_id: int, is_admin: bool = False):
- with SessionLocal() as db:
- stmt = select(LearnerORM).order_by(LearnerORM.id)
- if not is_admin:
- stmt = stmt.where(LearnerORM.owner_user_id == user_id)
- rows = db.execute(stmt).scalars().all()
- return [{"learner_id": r.id, "display_name": r.display_name, "owner_user_id": r.owner_user_id} for r in rows]
-
def learner_owned_by_user(user_id: int, learner_id: str) -> bool:
with SessionLocal() as db:
learner = db.get(LearnerORM, learner_id)
@@ -227,8 +225,7 @@ def save_learner_state(state: LearnerState):
def create_evaluator_job(learner_id: str, pack_id: str, concept_id: str, submitted_text: str):
with SessionLocal() as db:
- trace = {"rubric_dimension_scores": [{"dimension": "mastery", "score": 0.0}], "notes": ["Job queued", "Awaiting evaluator"], "token_count_estimate": len(submitted_text.split())}
- job = EvaluatorJobORM(learner_id=learner_id, pack_id=pack_id, concept_id=concept_id, submitted_text=submitted_text, status="queued", trace_json=json.dumps(trace))
+ job = EvaluatorJobORM(learner_id=learner_id, pack_id=pack_id, concept_id=concept_id, submitted_text=submitted_text, status="queued", trace_json=json.dumps({"notes": ["Job queued"]}))
db.add(job)
db.commit()
db.refresh(job)
diff --git a/src/didactopus/seed.py b/src/didactopus/seed.py
index c62edae..4b29cbd 100644
--- a/src/didactopus/seed.py
+++ b/src/didactopus/seed.py
@@ -14,19 +14,16 @@ def main():
db.commit()
upsert_pack(
PackData(
- id="bayes-pack",
- title="Bayesian Reasoning",
- subtitle="Probability, evidence, updating, and model criticism.",
+ id="wesley-private-pack",
+ title="Wesley Private Pack",
+ subtitle="Personal pack example.",
level="novice-friendly",
- concepts=[
- PackConcept(id="prior", title="Prior", prerequisites=[], masteryDimension="mastery", exerciseReward="Prior badge earned"),
- PackConcept(id="posterior", title="Posterior", prerequisites=["prior"], masteryDimension="mastery", exerciseReward="Posterior path opened"),
- ],
- onboarding={"headline":"Start with a fast visible win","body":"Read one short orientation, answer one guided question, and leave with your first mastery marker.","checklist":["Read the one-screen topic orientation","Answer one guided exercise","Write one explanation in your own words"]},
- compliance=PackCompliance(sources=2, attributionRequired=True, shareAlikeRequired=True, noncommercialOnly=True, flags=["share-alike","noncommercial","excluded-third-party-content"])
+ 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()
),
submitted_by_user_id=1,
+ policy_lane="personal",
is_published=True,
- change_summary="Initial seed version"
+ change_summary="Initial personal pack"
)
- print("Seeded database. Demo user: wesley / demo-pass")
diff --git a/src/didactopus/worker.py b/src/didactopus/worker.py
index 6d28da5..ad99705 100644
--- a/src/didactopus/worker.py
+++ b/src/didactopus/worker.py
@@ -8,17 +8,14 @@ def process_job(job_id: int):
job = get_evaluator_job(job_id)
if job is None:
return
- update_evaluator_job(job_id, "running", trace={"rubric_dimension_scores": [{"dimension": "mastery", "score": 0.35}], "notes": ["Job running", "Prototype trace generated"], "token_count_estimate": len(job.submitted_text.split())})
score = 0.78 if len(job.submitted_text.strip()) > 20 else 0.62
confidence_hint = 0.72 if len(job.submitted_text.strip()) > 20 else 0.45
notes = "Prototype evaluator: longer responses scored somewhat higher."
- trace = {"rubric_dimension_scores": [{"dimension": "mastery", "score": score}], "notes": ["Prototype evaluator completed", notes], "token_count_estimate": len(job.submitted_text.split()), "decision_basis": ["response length heuristic", "single-dimension mastery proxy"]}
- update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes, trace=trace)
+ update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes, trace={"notes": ["completed"]})
state = load_learner_state(job.learner_id)
state = apply_evidence(state, EvidenceEvent(concept_id=job.concept_id, dimension="mastery", score=score, confidence_hint=confidence_hint, timestamp="2026-03-13T12:00:00+00:00", kind="review", source_id=f"evaluator-job-{job_id}"))
save_learner_state(state)
def main():
- print("Didactopus worker scaffold running. Replace this with a real queue worker.")
while True:
time.sleep(60)
diff --git a/webui/index.html b/webui/index.html
index ec562c5..2a0a12d 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -3,7 +3,7 @@
- Didactopus Review Governance Layer
+ Didactopus Agent Audit and Key Rotation
diff --git a/webui/package.json b/webui/package.json
index 5408ebd..da8cee7 100644
--- a/webui/package.json
+++ b/webui/package.json
@@ -1,5 +1,5 @@
{
- "name": "didactopus-governance-ui",
+ "name": "didactopus-agent-audit-ui",
"private": true,
"version": "0.1.0",
"type": "module",
diff --git a/webui/src/App.jsx b/webui/src/App.jsx
index eb8c177..3948204 100644
--- a/webui/src/App.jsx
+++ b/webui/src/App.jsx
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
-import { login, refresh, fetchPacks, fetchAdminPacks, fetchPackValidation, fetchPackProvenance, fetchPackVersions, fetchPackComments, upsertPack, publishPack, governanceAction, addReviewComment, listLearners, createLearner, fetchLearnerState, fetchRecommendations, postEvidence, submitEvaluatorJob, fetchEvaluatorHistory, fetchEvaluatorTrace } from "./api";
+import { login, refresh, fetchDeploymentPolicy, fetchAgentCapabilities, listServiceAccounts, createServiceAccount, rotateServiceAccount, setServiceAccountState, listAgentAuditLogs } from "./api";
import { loadAuth, saveAuth, clearAuth } from "./authStore";
function LoginView({ onAuth }) {
@@ -26,63 +26,15 @@ function LoginView({ onAuth }) {
);
}
-function NavTabs({ tab, setTab, role }) {
- return (
-
-
-
-
- {role === "admin" ? <>
-
-
- > : null}
-
- );
-}
-
-function PackAuthorForm({ value, onChange, onSave }) {
- function setField(field, val) { onChange({ ...value, [field]: val }); }
- function setCompliance(field, val) { onChange({ ...value, compliance: { ...value.compliance, [field]: val } }); }
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
export default function App() {
const [auth, setAuth] = useState(loadAuth());
- const [tab, setTab] = useState("learner");
- const [packs, setPacks] = useState([]);
- const [adminPacks, setAdminPacks] = useState([]);
- const [learners, setLearners] = useState([]);
- const [selectedLearnerId, setSelectedLearnerId] = useState("wesley-learner");
- const [selectedPackId, setSelectedPackId] = useState("");
- const [learnerState, setLearnerState] = useState(null);
- const [cards, setCards] = useState([]);
- const [history, setHistory] = useState([]);
- const [selectedTrace, setSelectedTrace] = useState(null);
- const [validation, setValidation] = useState(null);
- const [provenance, setProvenance] = useState(null);
- const [versions, setVersions] = useState([]);
- const [comments, setComments] = useState([]);
- const [commentText, setCommentText] = useState("Looks structurally plausible.");
- const [reviewSummary, setReviewSummary] = useState("Reviewed and ready for next stage.");
- const [newLearnerId, setNewLearnerId] = useState("wesley-learner");
- const [formPack, setFormPack] = useState({ id: "new-pack", title: "New Pack", subtitle: "Editable governance scaffold", level: "novice-friendly", concepts: [], onboarding: { headline: "Start here", body: "Begin", checklist: [] }, compliance: { sources: 0, attributionRequired: false, shareAlikeRequired: false, noncommercialOnly: false, flags: [] } });
- const [message, setMessage] = useState("");
+ const [policy, setPolicy] = useState(null);
+ const [caps, setCaps] = useState(null);
+ const [serviceAccounts, setServiceAccounts] = useState([]);
+ const [auditLogs, setAuditLogs] = useState([]);
+ const [created, setCreated] = useState(null);
+ const [rotated, setRotated] = useState(null);
+ const [form, setForm] = useState({ name: "agent-learner-1", description: "AI learner account", scopes: ["packs:read","learners:read","learners:write","recommendations:read","evaluators:submit","evaluators:read"] });
async function refreshAuthToken() {
if (!auth?.refresh_token) return null;
@@ -107,97 +59,35 @@ export default function App() {
}
}
+ async function reload() {
+ setPolicy(await guarded((token) => fetchDeploymentPolicy(token)));
+ setCaps(await guarded((token) => fetchAgentCapabilities(token)));
+ if (auth.role === "admin") {
+ setServiceAccounts(await guarded((token) => listServiceAccounts(token)));
+ setAuditLogs(await guarded((token) => listAgentAuditLogs(token)));
+ }
+ }
+
useEffect(() => {
if (!auth) return;
- async function load() {
- const p = await guarded((token) => fetchPacks(token));
- setPacks(p);
- setSelectedPackId((prev) => prev || p[0]?.id || "");
- let ls = await guarded((token) => listLearners(token));
- if (ls.length === 0) {
- await guarded((token) => createLearner(token, selectedLearnerId, selectedLearnerId));
- ls = await guarded((token) => listLearners(token));
- }
- setLearners(ls);
- if (auth.role === "admin") {
- setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
- }
- }
- load();
+ reload();
}, [auth]);
- useEffect(() => {
- if (!auth || !selectedLearnerId || !selectedPackId) return;
- async function loadStuff() {
- setLearnerState(await guarded((token) => fetchLearnerState(token, selectedLearnerId)));
- const recs = await guarded((token) => fetchRecommendations(token, selectedLearnerId, selectedPackId));
- setCards(recs.cards || []);
- setHistory(await guarded((token) => fetchEvaluatorHistory(token, selectedLearnerId)));
- if (auth.role === "admin") {
- 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)));
- }
- }
- loadStuff();
- }, [auth, selectedLearnerId, selectedPackId]);
-
- async function simulateCard(card) {
- await guarded((token) => postEvidence(token, selectedLearnerId, { concept_id: card.conceptId, dimension: "mastery", score: card.scoreHint, confidence_hint: card.confidenceHint, timestamp: new Date().toISOString(), kind: "checkpoint", source_id: `ui-${card.id}` }));
- setLearnerState(await guarded((token) => fetchLearnerState(token, selectedLearnerId)));
- const recs = await guarded((token) => fetchRecommendations(token, selectedLearnerId, selectedPackId));
- setCards(recs.cards || []);
- setMessage(card.reward);
+ async function createNow() {
+ const result = await guarded((token) => createServiceAccount(token, form));
+ setCreated(result);
+ await reload();
}
- async function runEvaluator() {
- const conceptId = packs.find((p) => p.id === selectedPackId)?.concepts?.[0]?.id || "prior";
- await guarded((token) => submitEvaluatorJob(token, selectedLearnerId, { pack_id: selectedPackId, concept_id: conceptId, submitted_text: "This is a moderately detailed learner response intended to trigger a somewhat better prototype evaluator score.", kind: "checkpoint" }));
- setTimeout(async () => {
- setHistory(await guarded((token) => fetchEvaluatorHistory(token, selectedLearnerId)));
- setLearnerState(await guarded((token) => fetchLearnerState(token, selectedLearnerId)));
- const recs = await guarded((token) => fetchRecommendations(token, selectedLearnerId, selectedPackId));
- setCards(recs.cards || []);
- }, 1200);
+ async function rotateNow(name) {
+ const result = await guarded((token) => rotateServiceAccount(token, name));
+ setRotated(result);
+ await reload();
}
- async function createLearnerNow() {
- await guarded((token) => createLearner(token, newLearnerId, newLearnerId));
- const ls = await guarded((token) => listLearners(token));
- setLearners(ls);
- setSelectedLearnerId(newLearnerId);
- }
-
- async function savePack() {
- await guarded((token) => upsertPack(token, { pack: formPack, is_published: false, change_summary: "Submitted from form editor" }));
- setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
- setPacks(await guarded((token) => fetchPacks(token)));
- setMessage("Draft saved");
- }
-
- async function togglePublish(packId, isPublished) {
- await guarded((token) => publishPack(token, packId, isPublished));
- setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
- setPacks(await guarded((token) => fetchPacks(token)));
- }
-
- async function doGovernance(status) {
- await guarded((token) => governanceAction(token, selectedPackId, { status, review_summary: reviewSummary }));
- setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
- setVersions(await guarded((token) => fetchPackVersions(token, selectedPackId)));
- setMessage(`Pack moved to ${status}`);
- }
-
- async function addCommentNow() {
- const versionNumber = versions[0]?.version_number || 1;
- await guarded((token) => addReviewComment(token, selectedPackId, versionNumber, { comment_text: commentText, disposition: "comment" }));
- setComments(await guarded((token) => fetchPackComments(token, selectedPackId)));
- setMessage("Review comment added");
- }
-
- async function loadTrace(jobId) {
- setSelectedTrace(await guarded((token) => fetchEvaluatorTrace(token, jobId)));
+ async function toggleState(name, isActive) {
+ await guarded((token) => setServiceAccountState(token, name, isActive));
+ await reload();
}
if (!auth) return ;
@@ -206,149 +96,63 @@ export default function App() {
-
Didactopus review governance layer
-
Versioning, review comments, governance states, evaluator traces, and curator-facing pack workflows.
+
Didactopus agent audit + key rotation
+
Scoped machine identities with audit events, rotation, and disable controls.
Signed in as {auth.username} ({auth.role})
- {message ?
{message}
: null}
-
-
-
-
-
+
-
+
+
+ Deployment policy
+ {JSON.stringify(policy, null, 2)}
+ Agent capabilities
+ {JSON.stringify(caps, null, 2)}
+
- {tab === "learner" && (
-
-
- Learner dashboard
-
-
- {cards.length ? cards.map((card) => (
-
-
-
{card.title}
{card.minutes} minutes
-
{card.reward}
-
-
{card.reason}
-
Why this is recommended
{card.why.map((w, idx) => - {w}
)}
-
-
- )) :
No recommendations available.
}
-
- Learner state snapshot
- {JSON.stringify(learnerState, null, 2)}
-
-
- )}
+
+ Service accounts
+ {auth.role === "admin" ? (
+ <>
+
+
+
+
+ Created credential
+ {JSON.stringify(created, null, 2)}
+ Rotated credential
+ {JSON.stringify(rotated, null, 2)}
+ Existing accounts
+
+
+ | Name | Active | Scopes | Actions |
+
+ {serviceAccounts.map((sa) => (
+
+ | {sa.name} |
+ {String(sa.is_active)} |
+ {sa.scopes.join(", ")} |
+
+
+
+ |
+
+ ))}
+
+
+
+ >
+ ) : (
+ Admin required.
+ )}
+
- {tab === "history" && (
-
-
- Evaluator history
- {history.length ? (
-
- | Job | Status | Concept | Score | Trace |
-
- {history.map((row) => (
-
- | {row.job_id} |
- {row.status} |
- {row.concept_id} |
- {row.result_score ?? "-"} |
- |
-
- ))}
-
-
- ) : No evaluator jobs yet.
}
-
-
- Evaluator trace
- {JSON.stringify(selectedTrace, null, 2)}
-
-
- )}
-
- {tab === "manage" && (
-
-
-
- Existing learners
-
- | Learner ID | Display name | Owner |
-
- {learners.map((row) => (
-
- | {row.learner_id} |
- {row.display_name} |
- {row.owner_user_id} |
-
- ))}
-
-
-
-
- )}
-
- {tab === "admin" && auth.role === "admin" && (
-
-
-
- Pack administration
-
- | ID | Version | State | Published | Action |
-
- {adminPacks.map((row) => (
-
- | {row.id} |
- {row.current_version} |
- {row.governance_state} |
- {String(row.is_published)} |
- |
-
- ))}
-
-
-
-
- )}
-
- {tab === "review" && auth.role === "admin" && (
-
-
- Governance review
-
-
-
-
-
-
- Validation
- {JSON.stringify(validation, null, 2)}
- Provenance
- {JSON.stringify(provenance, null, 2)}
-
-
- Versions and comments
- Pack versions
- {JSON.stringify(versions, null, 2)}
-
-
- Review comments
- {JSON.stringify(comments, null, 2)}
-
-
- )}
+
+ Agent audit logs
+ {JSON.stringify(auditLogs, null, 2)}
+
+
);
}
diff --git a/webui/src/api.js b/webui/src/api.js
index 70a89fc..3b96811 100644
--- a/webui/src/api.js
+++ b/webui/src/api.js
@@ -11,28 +11,15 @@ export async function login(username, password) {
if (!res.ok) throw new Error("login failed");
return await res.json();
}
-
export async function refresh(refreshToken) {
const res = await fetch(`${API}/refresh`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: refreshToken }) });
if (!res.ok) throw new Error("refresh 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 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 fetchPackProvenance(token, packId) { const res = await fetch(`${API}/admin/packs/${packId}/provenance`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPackProvenance failed"); return await res.json(); }
-export async function fetchPackVersions(token, packId) { const res = await fetch(`${API}/admin/packs/${packId}/versions`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPackVersions failed"); return await res.json(); }
-export async function fetchPackComments(token, packId) { const res = await fetch(`${API}/admin/packs/${packId}/comments`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPackComments failed"); return await res.json(); }
-export async function upsertPack(token, payload) { const res = await fetch(`${API}/admin/packs`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("upsertPack failed"); return await res.json(); }
-export async function publishPack(token, packId, isPublished) { const res = await fetch(`${API}/admin/packs/${packId}/publish?is_published=${isPublished}`, { method: "POST", headers: authHeaders(token, false) }); if (!res.ok) throw new Error("publishPack failed"); return await res.json(); }
-export async function governanceAction(token, packId, payload) { const res = await fetch(`${API}/admin/packs/${packId}/governance`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("governanceAction failed"); return await res.json(); }
-export async function addReviewComment(token, packId, versionNumber, payload) { const res = await fetch(`${API}/admin/packs/${packId}/comments?version_number=${versionNumber}`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("addReviewComment failed"); return await res.json(); }
-export async function listLearners(token) { const res = await fetch(`${API}/learners`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listLearners failed"); return await res.json(); }
-export async function createLearner(token, learnerId, displayName) { const res = await fetch(`${API}/learners`, { method: "POST", headers: authHeaders(token), body: JSON.stringify({ learner_id: learnerId, display_name: displayName }) }); if (!res.ok) throw new Error("createLearner failed"); return await res.json(); }
-export async function fetchLearnerState(token, learnerId) { const res = await fetch(`${API}/learners/${learnerId}/state`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchLearnerState failed"); return await res.json(); }
-export async function fetchRecommendations(token, learnerId, packId) { const res = await fetch(`${API}/learners/${learnerId}/recommendations/${packId}`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchRecommendations failed"); return await res.json(); }
-export async function postEvidence(token, learnerId, event) { const res = await fetch(`${API}/learners/${learnerId}/evidence`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(event) }); if (!res.ok) throw new Error("postEvidence failed"); return await res.json(); }
-export async function submitEvaluatorJob(token, learnerId, payload) { const res = await fetch(`${API}/learners/${learnerId}/evaluator-jobs`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("submitEvaluatorJob failed"); return await res.json(); }
-export async function fetchEvaluatorHistory(token, learnerId) { const res = await fetch(`${API}/learners/${learnerId}/evaluator-history`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchEvaluatorHistory failed"); return await res.json(); }
-export async function fetchEvaluatorTrace(token, jobId) { const res = await fetch(`${API}/evaluator-jobs/${jobId}/trace`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchEvaluatorTrace failed"); return await res.json(); }
+export async function fetchDeploymentPolicy(token) { const res = await fetch(`${API}/deployment-policy`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchDeploymentPolicy failed"); return await res.json(); }
+export async function fetchAgentCapabilities(token) { const res = await fetch(`${API}/agent/capabilities`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchAgentCapabilities failed"); return await res.json(); }
+export async function listServiceAccounts(token) { const res = await fetch(`${API}/admin/service-accounts`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listServiceAccounts failed"); return await res.json(); }
+export async function createServiceAccount(token, payload) { const res = await fetch(`${API}/admin/service-accounts`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("createServiceAccount failed"); return await res.json(); }
+export async function rotateServiceAccount(token, name) { const res = await fetch(`${API}/admin/service-accounts/rotate`, { method: "POST", headers: authHeaders(token), body: JSON.stringify({ name }) }); if (!res.ok) throw new Error("rotateServiceAccount failed"); return await res.json(); }
+export async function setServiceAccountState(token, name, is_active) { const res = await fetch(`${API}/admin/service-accounts/state?name=${encodeURIComponent(name)}`, { method: "POST", headers: authHeaders(token), body: JSON.stringify({ is_active }) }); if (!res.ok) throw new Error("setServiceAccountState failed"); return await res.json(); }
+export async function listAgentAuditLogs(token) { const res = await fetch(`${API}/admin/agent-audit-logs`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listAgentAuditLogs failed"); return await res.json(); }
diff --git a/webui/src/styles.css b/webui/src/styles.css
index 7c87600..5d4c37e 100644
--- a/webui/src/styles.css
+++ b/webui/src/styles.css
@@ -1,38 +1,24 @@
:root {
- --bg:#f6f8fb; --card:#ffffff; --text:#1f2430; --muted:#60697a; --border:#dbe1ea; --accent:#2d6cdf; --soft:#eef4ff;
+ --bg:#f6f8fb; --card:#ffffff; --text:#1f2430; --muted:#60697a; --border:#dbe1ea; --accent:#2d6cdf;
}
* { box-sizing:border-box; }
body { margin:0; font-family:Arial, Helvetica, sans-serif; background:var(--bg); color:var(--text); }
-.page { max-width:1500px; margin:0 auto; padding:24px; }
+.page { max-width:1400px; margin:0 auto; padding:24px; }
.narrow-page { max-width:520px; }
.hero { background:var(--card); border:1px solid var(--border); border-radius:22px; padding:24px; display:flex; justify-content:space-between; gap:16px; }
-.hero-controls { min-width:280px; display:flex; flex-direction:column; gap:12px; }
-label { display:block; font-weight:600; }
-input, select, textarea { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; }
+label { display:block; font-weight:600; margin-bottom:10px; }
+input { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; }
+button { border:1px solid var(--border); background:white; border-radius:12px; padding:8px 12px; cursor:pointer; margin-right:8px; }
+.primary { background:var(--accent); color:white; border-color:var(--accent); }
.card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; }
.narrow { margin-top:60px; }
-.tab-row { display:flex; gap:10px; margin:16px 0; flex-wrap:wrap; }
-.tab-row button, button { border:1px solid var(--border); background:white; border-radius:12px; padding:10px 14px; cursor:pointer; }
-.active-tab, .primary { background:var(--accent); color:white; border-color:var(--accent); }
.layout { display:grid; gap:16px; }
.twocol { grid-template-columns:1fr 1fr; }
-.onecol { grid-template-columns:1fr; }
-.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; }
+.full { grid-column:1 / -1; }
+.prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:280px; }
+.prebox.tall { max-height:420px; }
.muted { color:var(--muted); }
.error { color:#b42318; margin-top:10px; }
-.message { margin-top:10px; padding:10px 12px; border:1px solid var(--border); background:#fff7dd; border-radius:12px; }
.table { width:100%; border-collapse:collapse; }
.table th, .table td { border-bottom:1px solid var(--border); text-align:left; padding:8px; vertical-align:top; }
-.prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:320px; }
-.form-grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
-.full { grid-column:1 / -1; }
-.checkrow { display:flex; gap:16px; flex-wrap:wrap; align-items:center; }
-.button-row { display:flex; gap:10px; flex-wrap:wrap; margin-bottom:12px; }
-details summary { cursor:pointer; color:var(--accent); }
-@media (max-width:1100px) {
- .hero { flex-direction:column; }
- .twocol, .form-grid { grid-template-columns:1fr; }
-}
+@media (max-width:1100px) { .twocol { grid-template-columns:1fr; } .hero { flex-direction:column; } .full { grid-column:auto; } }