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" requires-python = ">=3.10"
dependencies = [ dependencies = [
"pydantic>=2.7", "pydantic>=2.7",
"pyyaml>=6.0",
"fastapi>=0.115", "fastapi>=0.115",
"uvicorn>=0.30", "uvicorn>=0.30",
"sqlalchemy>=2.0", "sqlalchemy>=2.0",
"psycopg[binary]>=3.1",
"passlib[bcrypt]>=1.7", "passlib[bcrypt]>=1.7",
"python-jose[cryptography]>=3.3" "python-jose[cryptography]>=3.3"
] ]

View File

@ -1,52 +1,102 @@
from __future__ import annotations from __future__ import annotations
import json
from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
import uvicorn import uvicorn
from .config import load_settings from .config import load_settings
from .db import Base, engine from .db import Base, engine
from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest from .models import (
LoginRequest, ServiceAccountLoginRequest, ServiceAccountCreateRequest, ServiceAccountRotateRequest,
ServiceAccountStateRequest, ServiceToken, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState,
EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest, AgentCapabilityManifest,
AgentLearnerPlanRequest, AgentLearnerPlanResponse
)
from .repository import ( from .repository import (
authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token, authenticate_user, get_user_by_id, create_service_account, list_service_accounts, authenticate_service_account,
list_packs, list_pack_admin_rows, get_pack, upsert_pack, set_pack_publication, rotate_service_account_secret, set_service_account_active, add_agent_audit_log, list_agent_audit_logs,
create_learner, list_learners_for_user, learner_owned_by_user, load_learner_state, 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 save_learner_state, create_evaluator_job, get_evaluator_job, list_evaluator_jobs_for_learner
) )
from .engine import apply_evidence, recommend_next 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 from .worker import process_job
settings = load_settings() settings = load_settings()
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
app = FastAPI(title="Didactopus API Prototype") app = FastAPI(title="Didactopus API Prototype")
app.add_middleware( app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
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() token = authorization.removeprefix("Bearer ").strip()
payload = decode_token(token) if token else None 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") raise HTTPException(status_code=401, detail="Unauthorized")
user = get_user_by_id(int(payload["sub"])) if payload.get("kind") == "access":
if user is None or not user.is_active: user = get_user_by_id(int(payload["sub"]))
raise HTTPException(status_code=401, detail="Unauthorized") if user is None or not user.is_active:
return user 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)): def require_admin(actor = Depends(current_actor)):
if user.role != "admin": if actor["actor_type"] != "user" or actor["user"].role != "admin":
raise HTTPException(status_code=403, detail="Admin role required") 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": if user.role == "admin":
return return
if not learner_owned_by_user(user.id, learner_id): if not learner_owned_by_user(user.id, learner_id):
raise HTTPException(status_code=403, detail="Learner not accessible by this user") raise HTTPException(status_code=403, detail="Learner not accessible by this 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) @app.post("/api/login", response_model=TokenPair)
def login(payload: LoginRequest): def login(payload: LoginRequest):
@ -55,12 +105,15 @@ def login(payload: LoginRequest):
raise HTTPException(status_code=401, detail="Invalid credentials") raise HTTPException(status_code=401, detail="Invalid credentials")
token_id = new_token_id() token_id = new_token_id()
store_refresh_token(user.id, token_id) store_refresh_token(user.id, token_id)
return TokenPair( 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)
access_token=issue_access_token(user.id, user.username, user.role),
refresh_token=issue_refresh_token(user.id, user.username, user.role, token_id), @app.post("/api/service-accounts/login", response_model=ServiceToken)
username=user.username, def service_login(payload: ServiceAccountLoginRequest):
role=user.role, 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) @app.post("/api/refresh", response_model=TokenPair)
def refresh(payload: RefreshRequest): def refresh(payload: RefreshRequest):
@ -76,94 +129,140 @@ def refresh(payload: RefreshRequest):
revoke_refresh_token(token_id) revoke_refresh_token(token_id)
new_jti = new_token_id() new_jti = new_token_id()
store_refresh_token(user.id, new_jti) store_refresh_token(user.id, new_jti)
return TokenPair( 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)
access_token=issue_access_token(user.id, user.username, user.role),
refresh_token=issue_refresh_token(user.id, user.username, user.role, new_jti), @app.get("/api/deployment-policy")
username=user.username, def api_deployment_policy(actor = Depends(current_actor)):
role=user.role, 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") @app.get("/api/packs")
def api_list_packs(user = Depends(current_user)): def api_list_packs(actor = Depends(require_scope("packs:read"))):
include_unpublished = user.role == "admin" user_id = actor["user"].id if actor["actor_type"] == "user" else None
return [p.model_dump() for p in list_packs(include_unpublished=include_unpublished)] 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") @app.post("/api/packs")
def api_admin_list_packs(user = Depends(require_admin)): def api_upsert_personal_pack(payload: CreatePackRequest, actor = Depends(require_scope("packs:write_personal"))):
return list_pack_admin_rows() if payload.policy_lane != "personal":
raise HTTPException(status_code=403, detail="This endpoint is for personal-lane write access")
@app.post("/api/admin/packs") if actor["actor_type"] != "user":
def api_upsert_pack(payload: CreatePackRequest, user = Depends(require_admin)): raise HTTPException(status_code=403, detail="Service accounts may not own personal packs in this scaffold")
upsert_pack(payload.pack, is_published=payload.is_published) 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} return {"ok": True, "pack_id": payload.pack.id, "policy_lane": "personal"}
@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/learners") @app.post("/api/learners")
def api_create_learner(payload: CreateLearnerRequest, user = Depends(current_user)): def api_create_learner(payload: CreateLearnerRequest, actor = Depends(require_scope("learners:write"))):
create_learner(user.id, payload.learner_id, payload.display_name) 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} 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") @app.get("/api/learners/{learner_id}/state")
def api_get_learner_state(learner_id: str, user = Depends(current_user)): def api_get_learner_state(learner_id: str, actor = Depends(require_scope("learners:read"))):
ensure_learner_access(user, learner_id) ensure_learner_access(actor, learner_id)
return load_learner_state(learner_id).model_dump() 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") @app.put("/api/learners/{learner_id}/state")
def api_put_learner_state(learner_id: str, state: LearnerState, user = Depends(current_user)): def api_put_learner_state(learner_id: str, state: LearnerState, actor = Depends(require_scope("learners:write"))):
ensure_learner_access(user, learner_id) ensure_learner_access(actor, learner_id)
if learner_id != state.learner_id: if learner_id != state.learner_id:
raise HTTPException(status_code=400, detail="Learner ID mismatch") 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") @app.post("/api/learners/{learner_id}/evidence")
def api_post_evidence(learner_id: str, event: EvidenceEvent, user = Depends(current_user)): def api_post_evidence(learner_id: str, event: EvidenceEvent, actor = Depends(require_scope("learners:write"))):
ensure_learner_access(user, learner_id) ensure_learner_access(actor, learner_id)
state = load_learner_state(learner_id) state = load_learner_state(learner_id)
state = apply_evidence(state, event) state = apply_evidence(state, event)
save_learner_state(state) result = save_learner_state(state).model_dump()
return 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}") @app.get("/api/learners/{learner_id}/recommendations/{pack_id}")
def api_get_recommendations(learner_id: str, pack_id: str, user = Depends(current_user)): def api_get_recommendations(learner_id: str, pack_id: str, actor = Depends(require_scope("recommendations:read"))):
ensure_learner_access(user, learner_id) ensure_learner_access(actor, learner_id)
ensure_pack_access(actor, pack_id)
state = load_learner_state(learner_id) state = load_learner_state(learner_id)
pack = get_pack(pack_id) pack = get_pack(pack_id)
if pack is None: if pack is None:
raise HTTPException(status_code=404, detail="Pack not found") 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) @app.post("/api/learners/{learner_id}/evaluator-jobs", response_model=EvaluatorJobStatus)
def api_submit_evaluator_job(learner_id: str, payload: EvaluatorSubmission, background_tasks: BackgroundTasks, user = Depends(current_user)): def api_submit_evaluator_job(learner_id: str, payload: EvaluatorSubmission, background_tasks: BackgroundTasks, actor = Depends(require_scope("evaluators:submit"))):
ensure_learner_access(user, learner_id) 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) job_id = create_evaluator_job(learner_id, payload.pack_id, payload.concept_id, payload.submitted_text)
background_tasks.add_task(process_job, job_id) background_tasks.add_task(process_job, job_id)
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") return EvaluatorJobStatus(job_id=job_id, status="queued")
@app.get("/api/evaluator-jobs/{job_id}", response_model=EvaluatorJobStatus) @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) job = get_evaluator_job(job_id)
if job is None: if job is None:
raise HTTPException(status_code=404, detail="Job not found") 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) 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") @app.get("/api/learners/{learner_id}/evaluator-history")
def api_get_evaluator_history(learner_id: str, user = Depends(current_user)): def api_get_evaluator_history(learner_id: str, actor = Depends(require_scope("evaluators:read"))):
ensure_learner_access(user, learner_id) ensure_learner_access(actor, learner_id)
jobs = list_evaluator_jobs_for_learner(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] 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(): def main():
uvicorn.run(app, host=settings.host, port=settings.port) 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) return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def issue_access_token(user_id: int, username: str, role: str) -> str: 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: 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: def decode_token(token: str) -> dict | None:
try: try:
@ -33,3 +36,6 @@ def decode_token(token: str) -> dict | None:
def new_token_id() -> str: def new_token_id() -> str:
return secrets.token_urlsafe(24) 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 from pydantic import BaseModel
class Settings(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") host: str = os.getenv("DIDACTOPUS_HOST", "127.0.0.1")
port: int = int(os.getenv("DIDACTOPUS_PORT", "8011")) port: int = int(os.getenv("DIDACTOPUS_PORT", "8011"))
jwt_secret: str = os.getenv("DIDACTOPUS_JWT_SECRET", "change-me") jwt_secret: str = os.getenv("DIDACTOPUS_JWT_SECRET", "change-me")
jwt_algorithm: str = "HS256" jwt_algorithm: str = "HS256"
access_token_minutes: int = 30 deployment_policy_profile: str = os.getenv("DIDACTOPUS_POLICY_PROFILE", "single_user")
refresh_token_days: int = 14
def load_settings() -> Settings: def load_settings() -> Settings:
return Settings() return Settings()

View File

@ -3,6 +3,7 @@ from pydantic import BaseModel, Field
from typing import Literal from typing import Literal
EvidenceKind = Literal["checkpoint", "project", "exercise", "review"] EvidenceKind = Literal["checkpoint", "project", "exercise", "review"]
PolicyLane = Literal["personal", "community"]
class TokenPair(BaseModel): class TokenPair(BaseModel):
access_token: str access_token: str
@ -11,13 +12,57 @@ class TokenPair(BaseModel):
username: str username: str
role: str role: str
class ServiceToken(BaseModel):
access_token: str
token_type: str = "bearer"
service_account_name: str
scopes: list[str]
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str username: str
password: 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): class RefreshRequest(BaseModel):
refresh_token: str refresh_token: str
class DeploymentPolicyProfile(BaseModel):
profile_name: str
default_personal_lane_enabled: bool = True
default_community_lane_enabled: bool = True
community_publish_requires_approval: bool = True
personal_publish_direct: bool = True
reviewer_assignment_required: bool = False
description: str = ""
class AgentCapabilityManifest(BaseModel):
supports_pack_listing: bool = True
supports_pack_write_personal: bool = True
supports_pack_submit_community: bool = True
supports_recommendations: bool = True
supports_learner_state_read: bool = True
supports_learner_state_write: bool = True
supports_evaluator_jobs: bool = True
supports_governance_endpoints: bool = True
supports_review_queue: bool = True
supports_service_accounts: bool = True
supports_agent_audit_logs: bool = True
supports_service_account_rotation: bool = True
class PackConcept(BaseModel): class PackConcept(BaseModel):
id: str id: str
title: str title: str
@ -41,6 +86,16 @@ class PackData(BaseModel):
onboarding: dict = Field(default_factory=dict) onboarding: dict = Field(default_factory=dict)
compliance: PackCompliance = Field(default_factory=PackCompliance) 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): class MasteryRecord(BaseModel):
concept_id: str concept_id: str
dimension: str dimension: str
@ -63,10 +118,6 @@ class LearnerState(BaseModel):
records: list[MasteryRecord] = Field(default_factory=list) records: list[MasteryRecord] = Field(default_factory=list)
history: list[EvidenceEvent] = Field(default_factory=list) history: list[EvidenceEvent] = Field(default_factory=list)
class CreateLearnerRequest(BaseModel):
learner_id: str
display_name: str = ""
class EvaluatorSubmission(BaseModel): class EvaluatorSubmission(BaseModel):
pack_id: str pack_id: str
concept_id: str concept_id: str
@ -80,6 +131,12 @@ class EvaluatorJobStatus(BaseModel):
result_confidence_hint: float | None = None result_confidence_hint: float | None = None
result_notes: str = "" result_notes: str = ""
class CreatePackRequest(BaseModel): class AgentLearnerPlanRequest(BaseModel):
pack: PackData learner_id: str
is_published: bool = True 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") role: Mapped[str] = mapped_column(String(50), default="learner")
is_active: Mapped[bool] = mapped_column(Boolean, default=True) 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): class RefreshTokenORM(Base):
__tablename__ = "refresh_tokens" __tablename__ = "refresh_tokens"
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
@ -20,11 +41,17 @@ class RefreshTokenORM(Base):
class PackORM(Base): class PackORM(Base):
__tablename__ = "packs" __tablename__ = "packs"
id: Mapped[str] = mapped_column(String(100), primary_key=True) id: Mapped[str] = mapped_column(String(100), primary_key=True)
owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
policy_lane: Mapped[str] = mapped_column(String(50), default="personal")
title: Mapped[str] = mapped_column(String(255)) title: Mapped[str] = mapped_column(String(255))
subtitle: Mapped[str] = mapped_column(Text, default="") subtitle: Mapped[str] = mapped_column(Text, default="")
level: Mapped[str] = mapped_column(String(100), default="novice-friendly") level: Mapped[str] = mapped_column(String(100), default="novice-friendly")
data_json: Mapped[str] = mapped_column(Text) 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): class LearnerORM(Base):
__tablename__ = "learners" __tablename__ = "learners"
@ -66,3 +93,4 @@ class EvaluatorJobORM(Base):
result_score: Mapped[float | None] = mapped_column(Float, nullable=True) result_score: Mapped[float | None] = mapped_column(Float, nullable=True)
result_confidence_hint: 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="") 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 from __future__ import annotations
import json import json
from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from .db import SessionLocal from .db import SessionLocal
from .orm import UserORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM from .orm import UserORM, ServiceAccountORM, AgentAuditLogORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM
from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent, DeploymentPolicyProfile
from .auth import verify_password from .auth import verify_password, 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): def get_user_by_username(username: str):
with SessionLocal() as db: with SessionLocal() as db:
@ -20,6 +38,83 @@ def authenticate_user(username: str, password: str):
return None return None
return user 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): def store_refresh_token(user_id: int, token_id: str):
with SessionLocal() as db: with SessionLocal() as db:
db.add(RefreshTokenORM(user_id=user_id, token_id=token_id, is_revoked=False)) 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 row.is_revoked = True
db.commit() 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: with SessionLocal() as db:
stmt = select(PackORM) stmt = select(PackORM)
if not include_unpublished: if not include_unpublished:
stmt = stmt.where(PackORM.is_published == True) stmt = stmt.where(PackORM.is_published == True)
return [PackData.model_validate(json.loads(r.data_json)) for r in db.execute(stmt).scalars().all()] rows = db.execute(stmt).scalars().all()
out = []
def list_pack_admin_rows(): for r in rows:
with SessionLocal() as db: if r.policy_lane == "community":
rows = db.execute(select(PackORM).order_by(PackORM.id)).scalars().all() out.append(PackData.model_validate(json.loads(r.data_json)))
return [{"id": r.id, "title": r.title, "is_published": r.is_published, "subtitle": r.subtitle} for r in rows] 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): def get_pack(pack_id: str):
with SessionLocal() as db: with SessionLocal() as db:
row = db.get(PackORM, pack_id) row = db.get(PackORM, pack_id)
return None if row is None else PackData.model_validate(json.loads(row.data_json)) return None if row is None else PackData.model_validate(json.loads(row.data_json))
def 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: with SessionLocal() as db:
row = db.get(PackORM, pack.id) row = db.get(PackORM, pack.id)
payload = json.dumps(pack.model_dump()) payload = json.dumps(pack.model_dump())
if row is None: if row is None:
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: else:
row.owner_user_id = submitted_by_user_id if policy_lane == "personal" else row.owner_user_id
row.policy_lane = policy_lane
row.title = pack.title row.title = pack.title
row.subtitle = pack.subtitle row.subtitle = pack.subtitle
row.level = pack.level row.level = pack.level
row.data_json = payload row.data_json = payload
row.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() 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 = ""): def create_learner(owner_user_id: int, learner_id: str, display_name: str = ""):
with SessionLocal() as db: with SessionLocal() as db:
if db.get(LearnerORM, learner_id) is None: 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.add(LearnerORM(id=learner_id, owner_user_id=owner_user_id, display_name=display_name))
db.commit() 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: def learner_owned_by_user(user_id: int, learner_id: str) -> bool:
with SessionLocal() as db: with SessionLocal() as db:
learner = db.get(LearnerORM, learner_id) learner = db.get(LearnerORM, learner_id)
return learner is not None and learner.owner_user_id == user_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: with SessionLocal() as db:
records = db.execute(select(MasteryRecordORM).where(MasteryRecordORM.learner_id == learner_id)).scalars().all() 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() 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() db.commit()
return state 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: 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.add(job)
db.commit() db.commit()
db.refresh(job) 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): def list_evaluator_jobs_for_learner(learner_id: str):
with SessionLocal() as db: with SessionLocal() as db:
rows = db.execute(select(EvaluatorJobORM).where(EvaluatorJobORM.learner_id == learner_id).order_by(EvaluatorJobORM.id.desc())).scalars().all() return db.execute(select(EvaluatorJobORM).where(EvaluatorJobORM.learner_id == learner_id).order_by(EvaluatorJobORM.id.desc())).scalars().all()
return rows
def get_evaluator_job(job_id: int): def get_evaluator_job(job_id: int):
with SessionLocal() as db: with SessionLocal() as db:
return db.get(EvaluatorJobORM, job_id) 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: with SessionLocal() as db:
job = db.get(EvaluatorJobORM, job_id) job = db.get(EvaluatorJobORM, job_id)
if job is None: 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_score = score
job.result_confidence_hint = confidence_hint job.result_confidence_hint = confidence_hint
job.result_notes = notes job.result_notes = notes
if trace is not None:
job.trace_json = json.dumps(trace)
db.commit() db.commit()

View File

@ -1,48 +1,29 @@
from __future__ import annotations from __future__ import annotations
import json
from sqlalchemy import select from sqlalchemy import select
from .db import Base, engine, SessionLocal from .db import Base, engine, SessionLocal
from .orm import UserORM, PackORM from .orm import UserORM
from .auth import hash_password from .auth import hash_password
from .repository import upsert_pack
PACKS = [ from .models import PackData, PackConcept, PackCompliance
{
"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": []}
}
]
def main(): def main():
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
with SessionLocal() as db: with SessionLocal() as db:
if db.execute(select(UserORM).where(UserORM.username == "wesley")).scalar_one_or_none() is None: 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)) 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() db.commit()
print("Seeded database. Demo user: wesley / demo-pass") upsert_pack(
PackData(
if __name__ == "__main__": id="wesley-private-pack",
main() 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) job = get_evaluator_job(job_id)
if job is None: if job is None:
return return
update_evaluator_job(job_id, "running")
score = 0.78 if len(job.submitted_text.strip()) > 20 else 0.62 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 confidence_hint = 0.72 if len(job.submitted_text.strip()) > 20 else 0.45
notes = "Prototype evaluator: longer responses scored somewhat higher." 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 = load_learner_state(job.learner_id)
state = apply_evidence(state, EvidenceEvent( 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}"))
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) save_learner_state(state)
def main(): def main():
print("Didactopus worker scaffold running. Replace this with a real queue worker.")
while True: while True:
time.sleep(60) time.sleep(60)
if __name__ == "__main__":
main()

View File

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

View File

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

View File

@ -1,26 +1,18 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useState } from "react";
import { import { login, refresh, fetchDeploymentPolicy, fetchAgentCapabilities, listServiceAccounts, createServiceAccount, rotateServiceAccount, setServiceAccountState, listAgentAuditLogs } from "./api";
login, refresh, fetchPacks, fetchAdminPacks, upsertPack, publishPack,
createLearner, listLearners, fetchLearnerState, fetchRecommendations, postEvidence,
submitEvaluatorJob, fetchEvaluatorHistory
} from "./api";
import { loadAuth, saveAuth, clearAuth } from "./authStore"; import { loadAuth, saveAuth, clearAuth } from "./authStore";
function LoginView({ onAuth }) { function LoginView({ onAuth }) {
const [username, setUsername] = useState("wesley"); const [username, setUsername] = useState("wesley");
const [password, setPassword] = useState("demo-pass"); const [password, setPassword] = useState("demo-pass");
const [error, setError] = useState(""); const [error, setError] = useState("");
async function doLogin() { async function doLogin() {
try { try {
const result = await login(username, password); const result = await login(username, password);
saveAuth(result); saveAuth(result);
onAuth(result); onAuth(result);
} catch { } catch { setError("Login failed"); }
setError("Login failed");
}
} }
return ( return (
<div className="page narrow-page"> <div className="page narrow-page">
<section className="card narrow"> <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() { export default function App() {
const [auth, setAuth] = useState(loadAuth()); const [auth, setAuth] = useState(loadAuth());
const [tab, setTab] = useState("learner"); const [policy, setPolicy] = useState(null);
const [packs, setPacks] = useState([]); const [caps, setCaps] = useState(null);
const [adminPacks, setAdminPacks] = useState([]); const [serviceAccounts, setServiceAccounts] = useState([]);
const [learners, setLearners] = useState([]); const [auditLogs, setAuditLogs] = useState([]);
const [selectedLearnerId, setSelectedLearnerId] = useState("wesley-learner"); const [created, setCreated] = useState(null);
const [selectedPackId, setSelectedPackId] = useState(""); const [rotated, setRotated] = useState(null);
const [learnerState, setLearnerState] = 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"] });
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("");
async function refreshAuthToken() { async function refreshAuthToken() {
if (!auth?.refresh_token) return null if (!auth?.refresh_token) return null;
try { try {
const result = await refresh(auth.refresh_token) const result = await refresh(auth.refresh_token);
saveAuth(result) saveAuth(result);
setAuth(result) setAuth(result);
return result return result;
} catch { } catch {
clearAuth() clearAuth();
setAuth(null) setAuth(null);
return null return null;
} }
} }
async function guarded(fn) { async function guarded(fn) {
try { try { return await fn(auth.access_token); }
return await fn(auth.access_token) catch {
} catch { const next = await refreshAuthToken();
const next = await refreshAuthToken() if (!next) throw new Error("auth failed");
if (!next) throw new Error("auth failed") return await fn(next.access_token);
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(() => { useEffect(() => {
if (!auth) return if (!auth) return;
async function load() { reload();
const p = await guarded((token) => fetchPacks(token)) }, [auth]);
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])
useEffect(() => { async function createNow() {
if (!auth || !selectedLearnerId || !selectedPackId) return const result = await guarded((token) => createServiceAccount(token, form));
async function loadLearnerStuff() { setCreated(result);
const state = await guarded((token) => fetchLearnerState(token, selectedLearnerId)) await reload();
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 runEvaluator() { async function rotateNow(name) {
if (!pack?.concepts?.length) return const result = await guarded((token) => rotateServiceAccount(token, name));
await guarded((token) => submitEvaluatorJob(token, selectedLearnerId, { setRotated(result);
pack_id: selectedPackId, await reload();
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 createLearnerNow() { async function toggleState(name, isActive) {
await guarded((token) => createLearner(token, newLearnerId, newLearnerId)) await guarded((token) => setServiceAccountState(token, name, isActive));
const ls = await guarded((token) => listLearners(token)) await reload();
setLearners(ls)
setSelectedLearnerId(newLearnerId)
} }
async function savePack() { if (!auth) return <LoginView onAuth={setAuth} />;
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} />
return ( return (
<div className="page"> <div className="page">
<header className="hero"> <header className="hero">
<div> <div>
<h1>Didactopus workflow scaffold</h1> <h1>Didactopus agent audit + key rotation</h1>
<p>Login, refresh, learner management, evaluator history, and admin pack publication workflows.</p> <p>Scoped machine identities with audit events, rotation, and disable controls.</p>
<div className="muted">Signed in as {auth.username} ({auth.role})</div> <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> </div>
<button onClick={() => { clearAuth(); setAuth(null); }}>Logout</button>
</header> </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" && ( <section className="card">
<main className="layout"> <h2>Service accounts</h2>
<section className="card"> {auth.role === "admin" ? (
<h2>Learner dashboard</h2> <>
<p><strong>Pack:</strong> {pack?.title || "-"}</p> <label>Name<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></label>
<p>{pack?.subtitle || ""}</p> <label>Description<input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></label>
<button onClick={runEvaluator}>Submit demo evaluator job</button> <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>
<h3>Next actions</h3> <button className="primary" onClick={createNow}>Create service account</button>
<div className="steps-stack"> <h3>Created credential</h3>
{cards.length ? cards.map((card) => ( <pre className="prebox">{JSON.stringify(created, null, 2)}</pre>
<div key={card.id} className="step-card"> <h3>Rotated credential</h3>
<div className="step-header"> <pre className="prebox">{JSON.stringify(rotated, null, 2)}</pre>
<div> <h3>Existing accounts</h3>
<h4>{card.title}</h4> <div className="table-wrap">
<div className="muted">{card.minutes} minutes</div> <table className="table">
</div> <thead><tr><th>Name</th><th>Active</th><th>Scopes</th><th>Actions</th></tr></thead>
<div className="reward-pill">{card.reward}</div> <tbody>
</div> {serviceAccounts.map((sa) => (
<p>{card.reason}</p> <tr key={sa.id}>
<details> <td>{sa.name}</td>
<summary>Why this is recommended</summary> <td>{String(sa.is_active)}</td>
<ul>{card.why.map((w, idx) => <li key={idx}>{w}</li>)}</ul> <td>{sa.scopes.join(", ")}</td>
</details> <td>
<button className="primary" onClick={() => simulateCard(card)}>Simulate step</button> <button onClick={() => rotateNow(sa.name)}>Rotate</button>
</div> <button onClick={() => toggleState(sa.name, !sa.is_active)}>{sa.is_active ? "Disable" : "Enable"}</button>
)) : <div className="muted">No recommendations available.</div>} </td>
</div> </tr>
<h3>Learner state snapshot</h3> ))}
<pre className="prebox">{JSON.stringify(learnerState, null, 2)}</pre> </tbody>
</section> </table>
</main> </div>
)} </>
) : (
<div className="muted">Admin required.</div>
)}
</section>
{tab === "history" && ( <section className="card full">
<main className="layout onecol"> <h2>Agent audit logs</h2>
<section className="card"> <pre className="prebox tall">{JSON.stringify(auditLogs, null, 2)}</pre>
<h2>Evaluator history</h2> </section>
{history.length ? ( </main>
<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>
)}
</div> </div>
) );
} }

View File

@ -1,112 +1,25 @@
const API = "http://127.0.0.1:8011/api"; const API = "http://127.0.0.1:8011/api";
function authHeaders(token, json=true) { function authHeaders(token, json=true) {
const h = { "Authorization": `Bearer ${token}` }; const h = { Authorization: `Bearer ${token}` };
if (json) h["Content-Type"] = "application/json"; if (json) h["Content-Type"] = "application/json";
return h; return h;
} }
export async function login(username, password) { export async function login(username, password) {
const res = await fetch(`${API}/login`, { const res = await fetch(`${API}/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }) });
method: "POST", if (!res.ok) throw new Error("login failed");
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
if (!res.ok) throw new Error("Login failed");
return await res.json(); return await res.json();
} }
export async function refresh(refreshToken) { export async function refresh(refreshToken) {
const res = await fetch(`${API}/refresh`, { const res = await fetch(`${API}/refresh`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: refreshToken }) });
method: "POST", if (!res.ok) throw new Error("refresh failed");
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");
return await res.json(); return await res.json();
} }
export async function fetchDeploymentPolicy(token) { const res = await fetch(`${API}/deployment-policy`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchDeploymentPolicy failed"); return await res.json(); }
export async function fetchAgentCapabilities(token) { const res = await fetch(`${API}/agent/capabilities`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchAgentCapabilities failed"); return await res.json(); }
export async function 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"; const KEY = "didactopus-auth";
export function loadAuth() { try { return JSON.parse(localStorage.getItem(KEY) || "null"); } catch { return null; } }
export function loadAuth() { export function saveAuth(data) { localStorage.setItem(KEY, JSON.stringify(data)); }
try { export function clearAuth() { localStorage.removeItem(KEY); }
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 { createRoot } from "react-dom/client";
import App from "./App"; import App from "./App";
import "./styles.css"; import "./styles.css";
createRoot(document.getElementById("root")).render(<App />); createRoot(document.getElementById("root")).render(<App />);

View File

@ -1,35 +1,24 @@
:root { :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; } * { box-sizing:border-box; }
body { margin:0; font-family:Arial, Helvetica, sans-serif; background:var(--bg); color:var(--text); } body { margin:0; font-family:Arial, Helvetica, sans-serif; background:var(--bg); color:var(--text); }
.page { max-width:1400px; margin:0 auto; padding:24px; } .page { max-width:1400px; margin:0 auto; padding:24px; }
.narrow-page { max-width:520px; } .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 { 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; margin-bottom:10px; }
label { display:block; font-weight:600; } input { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; }
input, select, textarea { 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; } .card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; }
.narrow { margin-top:60px; } .narrow { margin-top:60px; }
.tab-row { display:flex; gap:10px; margin:16px 0; } .layout { display:grid; gap:16px; }
.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; }
.twocol { grid-template-columns:1fr 1fr; } .twocol { grid-template-columns:1fr 1fr; }
.onecol { grid-template-columns:1fr; } .full { grid-column:1 / -1; }
.steps-stack { display:grid; gap:14px; } .prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:280px; }
.step-card { border:1px solid var(--border); border-radius:16px; padding:14px; background:#fcfdff; } .prebox.tall { max-height:420px; }
.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; }
.muted { color:var(--muted); } .muted { color:var(--muted); }
.error { color:#b42318; margin-top:10px; } .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 { width:100%; border-collapse:collapse; }
.table th, .table td { border-bottom:1px solid var(--border); text-align:left; padding:8px; vertical-align:top; } .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; } @media (max-width:1100px) { .twocol { grid-template-columns:1fr; } .hero { flex-direction:column; } .full { grid-column:auto; } }
.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; }
}