Apply ZIP update: 145-didactopus-agent-audit-and-key-rotation-layer.zip [2026-03-14T13:19:23]

This commit is contained in:
welsberr 2026-03-14 13:29:55 -04:00
parent 5b4221d430
commit 5c3ba24e90
16 changed files with 574 additions and 601 deletions

View File

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

View File

@ -1,52 +1,102 @@
from __future__ import annotations
import json
from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks
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
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, store_refresh_token, refresh_token_active, revoke_refresh_token,
list_packs, list_pack_admin_rows, get_pack, upsert_pack, set_pack_publication,
create_learner, list_learners_for_user, learner_owned_by_user, load_learner_state,
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()
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=["*"],
)
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):
@ -55,12 +105,15 @@ def login(payload: LoginRequest):
raise HTTPException(status_code=401, detail="Invalid credentials")
token_id = new_token_id()
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,
)
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):
@ -76,94 +129,140 @@ def refresh(payload: RefreshRequest):
revoke_refresh_token(token_id)
new_jti = new_token_id()
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,
)
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)):
include_unpublished = user.role == "admin"
return [p.model_dump() for p in list_packs(include_unpublished=include_unpublished)]
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.post("/api/admin/packs")
def api_upsert_pack(payload: CreatePackRequest, user = Depends(require_admin)):
upsert_pack(payload.pack, is_published=payload.is_published)
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/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/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():
uvicorn.run(app, host=settings.host, port=settings.port)
if __name__ == "__main__":
main()

View File

@ -20,10 +20,13 @@ def _encode_token(payload: dict, expires_delta: timedelta) -> str:
return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def issue_access_token(user_id: int, username: str, role: str) -> str:
return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "access"}, timedelta(minutes=settings.access_token_minutes))
return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "access"}, timedelta(minutes=30))
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=settings.refresh_token_days))
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:
@ -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)

View File

@ -3,13 +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"
access_token_minutes: int = 30
refresh_token_days: int = 14
deployment_policy_profile: str = os.getenv("DIDACTOPUS_POLICY_PROFILE", "single_user")
def load_settings() -> Settings:
return Settings()

View File

@ -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
@ -41,6 +86,16 @@ class PackData(BaseModel):
onboarding: dict = Field(default_factory=dict)
compliance: PackCompliance = Field(default_factory=PackCompliance)
class CreatePackRequest(BaseModel):
pack: PackData
policy_lane: PolicyLane = "personal"
is_published: bool = False
change_summary: str = ""
class CreateLearnerRequest(BaseModel):
learner_id: str
display_name: str = ""
class MasteryRecord(BaseModel):
concept_id: str
dimension: str
@ -63,10 +118,6 @@ class LearnerState(BaseModel):
records: list[MasteryRecord] = Field(default_factory=list)
history: list[EvidenceEvent] = Field(default_factory=list)
class CreateLearnerRequest(BaseModel):
learner_id: str
display_name: str = ""
class EvaluatorSubmission(BaseModel):
pack_id: str
concept_id: str
@ -80,6 +131,12 @@ class EvaluatorJobStatus(BaseModel):
result_confidence_hint: float | None = None
result_notes: str = ""
class CreatePackRequest(BaseModel):
pack: PackData
is_published: bool = True
class AgentLearnerPlanRequest(BaseModel):
learner_id: str
pack_id: str
class AgentLearnerPlanResponse(BaseModel):
learner_id: str
pack_id: str
next_cards: list[dict] = Field(default_factory=list)
suggested_actions: list[str] = Field(default_factory=list)

View File

@ -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,11 +41,17 @@ 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")
data_json: Mapped[str] = mapped_column(Text)
is_published: Mapped[bool] = mapped_column(Boolean, default=True)
validation_json: Mapped[str] = mapped_column(Text, default="{}")
provenance_json: Mapped[str] = mapped_column(Text, default="{}")
governance_state: Mapped[str] = mapped_column(String(50), default="draft")
current_version: Mapped[int] = mapped_column(Integer, default=1)
is_published: Mapped[bool] = mapped_column(Boolean, default=False)
class LearnerORM(Base):
__tablename__ = "learners"
@ -66,3 +93,4 @@ class EvaluatorJobORM(Base):
result_score: Mapped[float | None] = mapped_column(Float, nullable=True)
result_confidence_hint: Mapped[float | None] = mapped_column(Float, nullable=True)
result_notes: Mapped[str] = mapped_column(Text, default="")
trace_json: Mapped[str] = mapped_column(Text, default="{}")

View File

@ -1,10 +1,28 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from sqlalchemy import select
from .db import SessionLocal
from .orm import UserORM, RefreshTokenORM, PackORM, 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:
@ -20,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))
@ -37,66 +132,77 @@ def revoke_refresh_token(token_id: str):
row.is_revoked = True
db.commit()
def list_packs(include_unpublished: bool = False) -> list[PackData]:
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)
return [PackData.model_validate(json.loads(r.data_json)) for r in db.execute(stmt).scalars().all()]
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} for r in rows]
rows = db.execute(stmt).scalars().all()
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 upsert_pack(pack: PackData, is_published: bool = True):
def get_pack_row(pack_id: str):
with SessionLocal() as db:
return db.get(PackORM, pack_id)
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:
db.add(PackORM(id=pack.id, title=pack.title, subtitle=pack.subtitle, level=pack.level, data_json=payload, is_published=is_published))
row = PackORM(
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)
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.is_published = is_published
row.validation_json = json.dumps(validation)
row.provenance_json = json.dumps(provenance)
row.current_version += 1
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 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)
return learner is not None and learner.owner_user_id == user_id
def load_learner_state(learner_id: str) -> LearnerState:
def load_learner_state(learner_id: str):
with SessionLocal() as db:
records = db.execute(select(MasteryRecordORM).where(MasteryRecordORM.learner_id == learner_id)).scalars().all()
history = db.execute(select(EvidenceEventORM).where(EvidenceEventORM.learner_id == learner_id)).scalars().all()
@ -117,9 +223,9 @@ def save_learner_state(state: LearnerState):
db.commit()
return state
def create_evaluator_job(learner_id: str, pack_id: str, concept_id: str, submitted_text: str) -> int:
def create_evaluator_job(learner_id: str, pack_id: str, concept_id: str, submitted_text: str):
with SessionLocal() as db:
job = EvaluatorJobORM(learner_id=learner_id, pack_id=pack_id, concept_id=concept_id, submitted_text=submitted_text, status="queued")
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)
@ -127,14 +233,13 @@ def create_evaluator_job(learner_id: str, pack_id: str, concept_id: str, submitt
def list_evaluator_jobs_for_learner(learner_id: str):
with SessionLocal() as db:
rows = db.execute(select(EvaluatorJobORM).where(EvaluatorJobORM.learner_id == learner_id).order_by(EvaluatorJobORM.id.desc())).scalars().all()
return rows
return db.execute(select(EvaluatorJobORM).where(EvaluatorJobORM.learner_id == learner_id).order_by(EvaluatorJobORM.id.desc())).scalars().all()
def get_evaluator_job(job_id: int):
with SessionLocal() as db:
return db.get(EvaluatorJobORM, job_id)
def update_evaluator_job(job_id: int, status: str, score: float | None = None, confidence_hint: float | None = None, notes: str = ""):
def update_evaluator_job(job_id: int, status: str, score: float | None = None, confidence_hint: float | None = None, notes: str = "", trace: dict | None = None):
with SessionLocal() as db:
job = db.get(EvaluatorJobORM, job_id)
if job is None:
@ -143,4 +248,6 @@ def update_evaluator_job(job_id: int, status: str, score: float | None = None, c
job.result_score = score
job.result_confidence_hint = confidence_hint
job.result_notes = notes
if trace is not None:
job.trace_json = json.dumps(trace)
db.commit()

View File

@ -1,48 +1,29 @@
from __future__ import annotations
import json
from sqlalchemy import select
from .db import Base, engine, SessionLocal
from .orm import UserORM, PackORM
from .orm import UserORM
from .auth import hash_password
PACKS = [
{
"id": "bayes-pack",
"title": "Bayesian Reasoning",
"subtitle": "Probability, evidence, updating, and model criticism.",
"level": "novice-friendly",
"concepts": [
{"id": "prior", "title": "Prior", "prerequisites": [], "masteryDimension": "mastery", "exerciseReward": "Prior badge earned"},
{"id": "posterior", "title": "Posterior", "prerequisites": ["prior"], "masteryDimension": "mastery", "exerciseReward": "Posterior path opened"},
{"id": "model-checking", "title": "Model Checking", "prerequisites": ["posterior"], "masteryDimension": "mastery", "exerciseReward": "Model-checking unlocked"}
],
"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": {"sources": 2, "attributionRequired": True, "shareAlikeRequired": True, "noncommercialOnly": True, "flags": ["share-alike", "noncommercial", "excluded-third-party-content"]}
},
{
"id": "stats-pack",
"title": "Introductory Statistics",
"subtitle": "Descriptive statistics, sampling, and inference.",
"level": "novice-friendly",
"concepts": [
{"id": "descriptive", "title": "Descriptive Statistics", "prerequisites": [], "masteryDimension": "mastery", "exerciseReward": "Descriptive tools unlocked"},
{"id": "sampling", "title": "Sampling", "prerequisites": ["descriptive"], "masteryDimension": "mastery", "exerciseReward": "Sampling pathway opened"}
],
"onboarding": {"headline": "Build your first useful data skill", "body": "You will learn one concept that immediately helps you summarize real data.", "checklist": ["See one worked example", "Compute one short example yourself", "Explain what the result means"]},
"compliance": {"sources": 1, "attributionRequired": True, "shareAlikeRequired": False, "noncommercialOnly": False, "flags": []}
}
]
from .repository import upsert_pack
from .models import PackData, PackConcept, PackCompliance
def main():
Base.metadata.create_all(bind=engine)
with SessionLocal() as db:
if db.execute(select(UserORM).where(UserORM.username == "wesley")).scalar_one_or_none() is None:
db.add(UserORM(username="wesley", password_hash=hash_password("demo-pass"), role="admin", is_active=True))
for pack in PACKS:
if db.get(PackORM, pack["id"]) is None:
db.add(PackORM(id=pack["id"], title=pack["title"], subtitle=pack["subtitle"], level=pack["level"], data_json=json.dumps(pack), is_published=True))
db.commit()
print("Seeded database. Demo user: wesley / demo-pass")
if __name__ == "__main__":
main()
upsert_pack(
PackData(
id="wesley-private-pack",
title="Wesley Private Pack",
subtitle="Personal pack example.",
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()
),
submitted_by_user_id=1,
policy_lane="personal",
is_published=True,
change_summary="Initial personal pack"
)

View File

@ -8,27 +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")
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."
update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes)
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}",
))
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)
if __name__ == "__main__":
main()

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Didactopus UI Workflows</title>
<title>Didactopus Agent Audit and Key Rotation</title>
<script type="module" src="/src/main.jsx"></script>
</head>
<body><div id="root"></div></body>

View File

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

View File

@ -1,26 +1,18 @@
import React, { useEffect, useMemo, useState } from "react";
import {
login, refresh, fetchPacks, fetchAdminPacks, upsertPack, publishPack,
createLearner, listLearners, fetchLearnerState, fetchRecommendations, postEvidence,
submitEvaluatorJob, fetchEvaluatorHistory
} from "./api";
import React, { useEffect, useState } from "react";
import { login, refresh, fetchDeploymentPolicy, fetchAgentCapabilities, listServiceAccounts, createServiceAccount, rotateServiceAccount, setServiceAccountState, listAgentAuditLogs } from "./api";
import { loadAuth, saveAuth, clearAuth } from "./authStore";
function LoginView({ onAuth }) {
const [username, setUsername] = useState("wesley");
const [password, setPassword] = useState("demo-pass");
const [error, setError] = useState("");
async function doLogin() {
try {
const result = await login(username, password);
saveAuth(result);
onAuth(result);
} catch {
setError("Login failed");
}
} catch { setError("Login failed"); }
}
return (
<div className="page narrow-page">
<section className="card narrow">
@ -34,294 +26,133 @@ function LoginView({ onAuth }) {
);
}
function NavTabs({ tab, setTab, role }) {
return (
<div className="tab-row">
<button className={tab==="learner" ? "active-tab" : ""} onClick={() => setTab("learner")}>Learner</button>
<button className={tab==="history" ? "active-tab" : ""} onClick={() => setTab("history")}>Evaluator history</button>
<button className={tab==="manage" ? "active-tab" : ""} onClick={() => setTab("manage")}>Learners</button>
{role === "admin" ? <button className={tab==="admin" ? "active-tab" : ""} onClick={() => setTab("admin")}>Pack admin</button> : null}
</div>
);
}
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 [newLearnerId, setNewLearnerId] = useState("wesley-learner");
const [newPackJson, setNewPackJson] = useState(JSON.stringify({
pack: {
id: "new-pack",
title: "New Pack",
subtitle: "Editable admin pack scaffold",
level: "novice-friendly",
concepts: [],
onboarding: { headline: "Start here", body: "Begin", checklist: [] },
compliance: { sources: 0, attributionRequired: false, shareAlikeRequired: false, noncommercialOnly: false, flags: [] }
},
is_published: false
}, null, 2));
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
if (!auth?.refresh_token) return null;
try {
const result = await refresh(auth.refresh_token)
saveAuth(result)
setAuth(result)
return result
const result = await refresh(auth.refresh_token);
saveAuth(result);
setAuth(result);
return result;
} catch {
clearAuth()
setAuth(null)
return null
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)
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);
}
}
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 || "")
const ls = await guarded((token) => listLearners(token))
setLearners(ls)
if (ls.length === 0) {
await guarded((token) => createLearner(token, selectedLearnerId, selectedLearnerId))
const ls2 = await guarded((token) => listLearners(token))
setLearners(ls2)
}
if (auth.role === "admin") {
const ap = await guarded((token) => fetchAdminPacks(token))
setAdminPacks(ap)
}
}
load()
}, [auth])
if (!auth) return;
reload();
}, [auth]);
useEffect(() => {
if (!auth || !selectedLearnerId || !selectedPackId) return
async function loadLearnerStuff() {
const state = await guarded((token) => fetchLearnerState(token, selectedLearnerId))
setLearnerState(state)
const recs = await guarded((token) => fetchRecommendations(token, selectedLearnerId, selectedPackId))
setCards(recs.cards || [])
const hist = await guarded((token) => fetchEvaluatorHistory(token, selectedLearnerId))
setHistory(hist)
}
loadLearnerStuff()
}, [auth, selectedLearnerId, selectedPackId])
const pack = useMemo(() => packs.find((p) => p.id === selectedPackId) || null, [packs, 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}`
}))
const state = await guarded((token) => fetchLearnerState(token, selectedLearnerId))
setLearnerState(state)
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() {
if (!pack?.concepts?.length) return
await guarded((token) => submitEvaluatorJob(token, selectedLearnerId, {
pack_id: selectedPackId,
concept_id: pack.concepts[0].id,
submitted_text: "This is a moderately detailed learner response intended to trigger a somewhat better prototype evaluator score.",
kind: "checkpoint"
}))
setTimeout(async () => {
const hist = await guarded((token) => fetchEvaluatorHistory(token, selectedLearnerId))
setHistory(hist)
const state = await guarded((token) => fetchLearnerState(token, selectedLearnerId))
setLearnerState(state)
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 toggleState(name, isActive) {
await guarded((token) => setServiceAccountState(token, name, isActive));
await reload();
}
async function savePack() {
const payload = JSON.parse(newPackJson)
await guarded((token) => upsertPack(token, payload))
const ap = await guarded((token) => fetchAdminPacks(token))
const p = await guarded((token) => fetchPacks(token))
setAdminPacks(ap)
setPacks(p)
}
async function togglePublish(packId, isPublished) {
await guarded((token) => publishPack(token, packId, isPublished))
const ap = await guarded((token) => fetchAdminPacks(token))
const p = await guarded((token) => fetchPacks(token))
setAdminPacks(ap)
setPacks(p)
}
if (!auth) return <LoginView onAuth={setAuth} />
if (!auth) return <LoginView onAuth={setAuth} />;
return (
<div className="page">
<header className="hero">
<div>
<h1>Didactopus workflow scaffold</h1>
<p>Login, refresh, learner management, evaluator history, and admin pack publication workflows.</p>
<h1>Didactopus agent audit + key rotation</h1>
<p>Scoped machine identities with audit events, rotation, and disable controls.</p>
<div className="muted">Signed in as {auth.username} ({auth.role})</div>
{message ? <div className="message">{message}</div> : null}
</div>
<div className="hero-controls">
<label>Learner<select value={selectedLearnerId} onChange={(e) => setSelectedLearnerId(e.target.value)}>
{learners.map((l) => <option key={l.learner_id} value={l.learner_id}>{l.display_name || l.learner_id}</option>)}
</select></label>
<label>Pack<select value={selectedPackId} onChange={(e) => setSelectedPackId(e.target.value)}>
{packs.map((p) => <option key={p.id} value={p.id}>{p.title}</option>)}
</select></label>
<button onClick={() => { clearAuth(); setAuth(null); }}>Logout</button>
</div>
<button onClick={() => { clearAuth(); setAuth(null); }}>Logout</button>
</header>
<NavTabs tab={tab} setTab={setTab} role={auth.role} />
<main className="layout twocol">
<section className="card">
<h2>Deployment policy</h2>
<pre className="prebox">{JSON.stringify(policy, null, 2)}</pre>
<h2>Agent capabilities</h2>
<pre className="prebox">{JSON.stringify(caps, null, 2)}</pre>
</section>
{tab === "learner" && (
<main className="layout">
<section className="card">
<h2>Learner dashboard</h2>
<p><strong>Pack:</strong> {pack?.title || "-"}</p>
<p>{pack?.subtitle || ""}</p>
<button onClick={runEvaluator}>Submit demo evaluator job</button>
<h3>Next actions</h3>
<div className="steps-stack">
{cards.length ? cards.map((card) => (
<div key={card.id} className="step-card">
<div className="step-header">
<div>
<h4>{card.title}</h4>
<div className="muted">{card.minutes} minutes</div>
</div>
<div className="reward-pill">{card.reward}</div>
</div>
<p>{card.reason}</p>
<details>
<summary>Why this is recommended</summary>
<ul>{card.why.map((w, idx) => <li key={idx}>{w}</li>)}</ul>
</details>
<button className="primary" onClick={() => simulateCard(card)}>Simulate step</button>
</div>
)) : <div className="muted">No recommendations available.</div>}
</div>
<h3>Learner state snapshot</h3>
<pre className="prebox">{JSON.stringify(learnerState, null, 2)}</pre>
</section>
</main>
)}
<section className="card">
<h2>Service accounts</h2>
{auth.role === "admin" ? (
<>
<label>Name<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></label>
<label>Description<input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></label>
<label>Scopes (comma-separated)<input value={form.scopes.join(", ")} onChange={(e) => setForm({ ...form, scopes: e.target.value.split(",").map(s => s.trim()).filter(Boolean) })} /></label>
<button className="primary" onClick={createNow}>Create service account</button>
<h3>Created credential</h3>
<pre className="prebox">{JSON.stringify(created, null, 2)}</pre>
<h3>Rotated credential</h3>
<pre className="prebox">{JSON.stringify(rotated, null, 2)}</pre>
<h3>Existing accounts</h3>
<div className="table-wrap">
<table className="table">
<thead><tr><th>Name</th><th>Active</th><th>Scopes</th><th>Actions</th></tr></thead>
<tbody>
{serviceAccounts.map((sa) => (
<tr key={sa.id}>
<td>{sa.name}</td>
<td>{String(sa.is_active)}</td>
<td>{sa.scopes.join(", ")}</td>
<td>
<button onClick={() => rotateNow(sa.name)}>Rotate</button>
<button onClick={() => toggleState(sa.name, !sa.is_active)}>{sa.is_active ? "Disable" : "Enable"}</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
) : (
<div className="muted">Admin required.</div>
)}
</section>
{tab === "history" && (
<main className="layout onecol">
<section className="card">
<h2>Evaluator history</h2>
{history.length ? (
<table className="table">
<thead><tr><th>Job</th><th>Status</th><th>Concept</th><th>Score</th><th>Confidence</th><th>Notes</th></tr></thead>
<tbody>
{history.map((row) => (
<tr key={row.job_id}>
<td>{row.job_id}</td>
<td>{row.status}</td>
<td>{row.concept_id}</td>
<td>{row.result_score ?? "-"}</td>
<td>{row.result_confidence_hint ?? "-"}</td>
<td>{row.result_notes || ""}</td>
</tr>
))}
</tbody>
</table>
) : <div className="muted">No evaluator jobs yet.</div>}
</section>
</main>
)}
{tab === "manage" && (
<main className="layout twocol">
<section className="card">
<h2>Learner management</h2>
<label>New learner ID<input value={newLearnerId} onChange={(e) => setNewLearnerId(e.target.value)} /></label>
<button className="primary" onClick={createLearnerNow}>Create learner</button>
</section>
<section className="card">
<h2>Existing learners</h2>
<table className="table">
<thead><tr><th>Learner ID</th><th>Display name</th><th>Owner</th></tr></thead>
<tbody>
{learners.map((row) => (
<tr key={row.learner_id}>
<td>{row.learner_id}</td>
<td>{row.display_name}</td>
<td>{row.owner_user_id}</td>
</tr>
))}
</tbody>
</table>
</section>
</main>
)}
{tab === "admin" && auth.role === "admin" && (
<main className="layout twocol">
<section className="card">
<h2>Pack editor</h2>
<textarea className="bigtext" value={newPackJson} onChange={(e) => setNewPackJson(e.target.value)} />
<button className="primary" onClick={savePack}>Save pack</button>
</section>
<section className="card">
<h2>Pack administration</h2>
<table className="table">
<thead><tr><th>ID</th><th>Title</th><th>Published</th><th>Action</th></tr></thead>
<tbody>
{adminPacks.map((row) => (
<tr key={row.id}>
<td>{row.id}</td>
<td>{row.title}</td>
<td>{String(row.is_published)}</td>
<td><button onClick={() => togglePublish(row.id, !row.is_published)}>{row.is_published ? "Unpublish" : "Publish"}</button></td>
</tr>
))}
</tbody>
</table>
</section>
</main>
)}
<section className="card full">
<h2>Agent audit logs</h2>
<pre className="prebox tall">{JSON.stringify(auditLogs, null, 2)}</pre>
</section>
</main>
</div>
)
);
}

View File

@ -1,112 +1,25 @@
const API = "http://127.0.0.1:8011/api";
function authHeaders(token, json=true) {
const h = { "Authorization": `Bearer ${token}` };
const h = { Authorization: `Bearer ${token}` };
if (json) h["Content-Type"] = "application/json";
return h;
}
export async function login(username, password) {
const res = await fetch(`${API}/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
if (!res.ok) throw new Error("Login failed");
const res = await fetch(`${API}/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ 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 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 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 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 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");
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 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(); }

View File

@ -1,17 +1,4 @@
const KEY = "didactopus-auth";
export function loadAuth() {
try {
return JSON.parse(localStorage.getItem(KEY) || "null");
} catch {
return null;
}
}
export function saveAuth(data) {
localStorage.setItem(KEY, JSON.stringify(data));
}
export function clearAuth() {
localStorage.removeItem(KEY);
}
export function loadAuth() { try { return JSON.parse(localStorage.getItem(KEY) || "null"); } catch { return null; } }
export function saveAuth(data) { localStorage.setItem(KEY, JSON.stringify(data)); }
export function clearAuth() { localStorage.removeItem(KEY); }

View File

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

View File

@ -1,35 +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: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; }
.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; grid-template-columns:1fr; gap:16px; }
.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:360px; }
.bigtext { min-height:340px; font-family:monospace; }
details summary { cursor:pointer; color:var(--accent); }
@media (max-width:1100px) {
.hero { flex-direction:column; }
.twocol { grid-template-columns:1fr; }
}
@media (max-width:1100px) { .twocol { grid-template-columns:1fr; } .hero { flex-direction:column; } .full { grid-column:auto; } }