Apply ZIP update: 245-didactopus-agent-audit-and-key-rotation-layer.zip [2026-03-14T13:21:01]

This commit is contained in:
welsberr 2026-03-14 13:29:56 -04:00
parent 8738897da6
commit 14e1ccffdc
14 changed files with 498 additions and 588 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

@ -5,10 +5,21 @@ from fastapi.middleware.cors import CORSMiddleware
import uvicorn import uvicorn
from .config import load_settings from .config import load_settings
from .db import Base, engine from .db import Base, engine
from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest, GovernanceAction, ReviewCommentCreate from .models import (
from .repository import authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token, list_packs, list_pack_admin_rows, get_pack, get_pack_validation, get_pack_provenance, upsert_pack, set_pack_publication, set_governance_state, list_pack_versions, add_review_comment, list_review_comments, create_learner, list_learners_for_user, learner_owned_by_user, load_learner_state, save_learner_state, create_evaluator_job, get_evaluator_job, list_evaluator_jobs_for_learner LoginRequest, ServiceAccountLoginRequest, ServiceAccountCreateRequest, ServiceAccountRotateRequest,
ServiceAccountStateRequest, ServiceToken, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState,
EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest, AgentCapabilityManifest,
AgentLearnerPlanRequest, AgentLearnerPlanResponse
)
from .repository import (
authenticate_user, get_user_by_id, create_service_account, list_service_accounts, authenticate_service_account,
rotate_service_account_secret, set_service_account_active, add_agent_audit_log, list_agent_audit_logs,
store_refresh_token, refresh_token_active, revoke_refresh_token, deployment_policy_profile, list_packs_for_user,
get_pack, get_pack_row, upsert_pack, create_learner, learner_owned_by_user, load_learner_state,
save_learner_state, create_evaluator_job, get_evaluator_job, list_evaluator_jobs_for_learner
)
from .engine import apply_evidence, recommend_next from .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()
@ -17,26 +28,75 @@ Base.metadata.create_all(bind=engine)
app = FastAPI(title="Didactopus API Prototype") 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() 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")
if payload.get("kind") == "access":
user = get_user_by_id(int(payload["sub"])) user = get_user_by_id(int(payload["sub"]))
if user is None or not user.is_active: if user is None or not user.is_active:
raise HTTPException(status_code=401, detail="Unauthorized") raise HTTPException(status_code=401, detail="Unauthorized")
return user 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):
@ -47,6 +107,14 @@ def login(payload: LoginRequest):
store_refresh_token(user.id, 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) @app.post("/api/refresh", response_model=TokenPair)
def refresh(payload: RefreshRequest): def refresh(payload: RefreshRequest):
data = decode_token(payload.refresh_token) data = decode_token(payload.refresh_token)
@ -63,117 +131,137 @@ def refresh(payload: RefreshRequest):
store_refresh_token(user.id, new_jti) 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") @app.get("/api/packs")
def api_list_packs(user = Depends(current_user)): def api_list_packs(actor = Depends(require_scope("packs:read"))):
return [p.model_dump() for p in list_packs(include_unpublished=(user.role == "admin"))] 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") @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.get("/api/admin/packs/{pack_id}/validation") if actor["actor_type"] != "user":
def api_admin_pack_validation(pack_id: str, user = Depends(require_admin)): raise HTTPException(status_code=403, detail="Service accounts may not own personal packs in this scaffold")
return get_pack_validation(pack_id) 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.get("/api/admin/packs/{pack_id}/provenance")
def api_admin_pack_provenance(pack_id: str, user = Depends(require_admin)):
return get_pack_provenance(pack_id)
@app.get("/api/admin/packs/{pack_id}/versions")
def api_admin_pack_versions(pack_id: str, user = Depends(require_admin)):
return list_pack_versions(pack_id)
@app.get("/api/admin/packs/{pack_id}/comments")
def api_admin_pack_comments(pack_id: str, user = Depends(require_admin)):
return list_review_comments(pack_id)
@app.post("/api/admin/packs")
def api_upsert_pack(payload: CreatePackRequest, user = Depends(require_admin)):
upsert_pack(payload.pack, submitted_by_user_id=user.id, is_published=payload.is_published, change_summary=payload.change_summary)
return {"ok": True, "pack_id": payload.pack.id}
@app.post("/api/admin/packs/{pack_id}/publish")
def api_publish_pack(pack_id: str, is_published: bool, user = Depends(require_admin)):
ok = set_pack_publication(pack_id, is_published)
if not ok:
raise HTTPException(status_code=404, detail="Pack not found")
return {"ok": True, "pack_id": pack_id, "is_published": is_published}
@app.post("/api/admin/packs/{pack_id}/governance")
def api_governance_action(pack_id: str, payload: GovernanceAction, user = Depends(require_admin)):
ok = set_governance_state(pack_id, payload.status, payload.review_summary)
if not ok:
raise HTTPException(status_code=404, detail="Pack not found")
return {"ok": True, "pack_id": pack_id, "status": payload.status}
@app.post("/api/admin/packs/{pack_id}/comments")
def api_add_review_comment(pack_id: str, version_number: int, payload: ReviewCommentCreate, user = Depends(require_admin)):
add_review_comment(pack_id, version_number, user.id, payload.comment_text, payload.disposition)
return {"ok": True}
@app.post("/api/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/evaluator-jobs/{job_id}/trace")
def api_get_evaluator_job_trace(job_id: int, user = Depends(current_user)):
job = get_evaluator_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job not found")
return json.loads(job.trace_json or "{}")
@app.get("/api/learners/{learner_id}/evaluator-history") @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():

View File

@ -25,6 +25,9 @@ def issue_access_token(user_id: int, username: str, role: str) -> str:
def issue_refresh_token(user_id: int, username: str, role: str, token_id: str) -> str: def issue_refresh_token(user_id: int, username: str, role: str, token_id: str) -> str:
return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "refresh", "jti": token_id}, timedelta(days=14)) 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:
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]) return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
@ -33,3 +36,6 @@ def decode_token(token: str) -> dict | None:
def new_token_id() -> str: 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,11 +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"
deployment_policy_profile: str = os.getenv("DIDACTOPUS_POLICY_PROFILE", "single_user")
def load_settings() -> Settings: def load_settings() -> Settings:
return Settings() return Settings()

View File

@ -3,6 +3,7 @@ from pydantic import BaseModel, Field
from typing import Literal from typing import Literal
EvidenceKind = Literal["checkpoint", "project", "exercise", "review"] EvidenceKind = Literal["checkpoint", "project", "exercise", "review"]
PolicyLane = Literal["personal", "community"]
class TokenPair(BaseModel): class TokenPair(BaseModel):
access_token: str access_token: str
@ -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
@ -43,17 +88,10 @@ class PackData(BaseModel):
class CreatePackRequest(BaseModel): class CreatePackRequest(BaseModel):
pack: PackData pack: PackData
policy_lane: PolicyLane = "personal"
is_published: bool = False is_published: bool = False
change_summary: str = "" change_summary: str = ""
class GovernanceAction(BaseModel):
status: str
review_summary: str = ""
class ReviewCommentCreate(BaseModel):
comment_text: str
disposition: str = "comment"
class CreateLearnerRequest(BaseModel): class CreateLearnerRequest(BaseModel):
learner_id: str learner_id: str
display_name: str = "" display_name: str = ""
@ -92,3 +130,13 @@ class EvaluatorJobStatus(BaseModel):
result_score: float | None = None result_score: float | None = None
result_confidence_hint: float | None = None result_confidence_hint: float | None = None
result_notes: str = "" result_notes: str = ""
class AgentLearnerPlanRequest(BaseModel):
learner_id: str
pack_id: str
class AgentLearnerPlanResponse(BaseModel):
learner_id: str
pack_id: str
next_cards: list[dict] = Field(default_factory=list)
suggested_actions: list[str] = Field(default_factory=list)

View File

@ -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,6 +41,8 @@ class RefreshTokenORM(Base):
class PackORM(Base): class PackORM(Base):
__tablename__ = "packs" __tablename__ = "packs"
id: Mapped[str] = mapped_column(String(100), primary_key=True) id: Mapped[str] = mapped_column(String(100), primary_key=True)
owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
policy_lane: Mapped[str] = mapped_column(String(50), default="personal")
title: Mapped[str] = mapped_column(String(255)) title: Mapped[str] = mapped_column(String(255))
subtitle: Mapped[str] = mapped_column(Text, default="") subtitle: Mapped[str] = mapped_column(Text, default="")
level: Mapped[str] = mapped_column(String(100), default="novice-friendly") level: Mapped[str] = mapped_column(String(100), default="novice-friendly")
@ -30,28 +53,6 @@ class PackORM(Base):
current_version: Mapped[int] = mapped_column(Integer, default=1) current_version: Mapped[int] = mapped_column(Integer, default=1)
is_published: Mapped[bool] = mapped_column(Boolean, default=False) is_published: Mapped[bool] = mapped_column(Boolean, default=False)
class PackVersionORM(Base):
__tablename__ = "pack_versions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
pack_id: Mapped[str] = mapped_column(ForeignKey("packs.id"), index=True)
version_number: Mapped[int] = mapped_column(Integer)
submitted_by_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
status: Mapped[str] = mapped_column(String(50), default="draft")
data_json: Mapped[str] = mapped_column(Text)
change_summary: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[str] = mapped_column(String(100), default="")
review_summary: Mapped[str] = mapped_column(Text, default="")
class ReviewCommentORM(Base):
__tablename__ = "review_comments"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
pack_id: Mapped[str] = mapped_column(ForeignKey("packs.id"), index=True)
version_number: Mapped[int] = mapped_column(Integer)
reviewer_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
comment_text: Mapped[str] = mapped_column(Text, default="")
disposition: Mapped[str] = mapped_column(String(50), default="comment")
created_at: Mapped[str] = mapped_column(String(100), default="")
class LearnerORM(Base): class LearnerORM(Base):
__tablename__ = "learners" __tablename__ = "learners"
id: Mapped[str] = mapped_column(String(100), primary_key=True) id: Mapped[str] = mapped_column(String(100), primary_key=True)

View File

@ -1,15 +1,29 @@
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import select, func from sqlalchemy import select
from .db import SessionLocal from .db import SessionLocal
from .orm import UserORM, RefreshTokenORM, PackORM, PackVersionORM, ReviewCommentORM, 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: def now_iso() -> str:
return datetime.now(timezone.utc).isoformat() 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:
return db.execute(select(UserORM).where(UserORM.username == username)).scalar_one_or_none() return db.execute(select(UserORM).where(UserORM.username == username)).scalar_one_or_none()
@ -24,6 +38,83 @@ def authenticate_user(username: str, password: str):
return None return 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))
@ -41,149 +132,64 @@ def revoke_refresh_token(token_id: str):
row.is_revoked = True row.is_revoked = True
db.commit() db.commit()
def list_packs(include_unpublished: bool = False): def list_packs_for_user(user_id: int | None = None, include_unpublished: bool = False):
with SessionLocal() as db: with SessionLocal() as db:
stmt = select(PackORM) stmt = select(PackORM)
if not include_unpublished: if not include_unpublished:
stmt = stmt.where(PackORM.is_published == True) stmt = stmt.where(PackORM.is_published == True)
rows = db.execute(stmt).scalars().all() rows = db.execute(stmt).scalars().all()
return [PackData.model_validate(json.loads(r.data_json)) for r in rows] out = []
for r in rows:
def list_pack_admin_rows(): if r.policy_lane == "community":
with SessionLocal() as db: out.append(PackData.model_validate(json.loads(r.data_json)))
rows = db.execute(select(PackORM).order_by(PackORM.id)).scalars().all() elif user_id is not None and r.owner_user_id == user_id:
return [{"id": r.id, "title": r.title, "is_published": r.is_published, "subtitle": r.subtitle, "governance_state": r.governance_state, "current_version": r.current_version} for r in rows] out.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 get_pack_validation(pack_id: str): def get_pack_row(pack_id: str):
with SessionLocal() as db: with SessionLocal() as db:
row = db.get(PackORM, pack_id) return db.get(PackORM, pack_id)
return {} if row is None else json.loads(row.validation_json or "{}")
def get_pack_provenance(pack_id: str): def upsert_pack(pack: PackData, submitted_by_user_id: int, policy_lane: str = "personal", is_published: bool = False, change_summary: str = ""):
with SessionLocal() as db: validation = {"ok": len(pack.concepts) > 0, "warnings": [] if len(pack.concepts) > 0 else ["Pack has no concepts."], "errors": []}
row = db.get(PackORM, pack_id) provenance = {"source_count": pack.compliance.sources, "restrictive_flags": list(pack.compliance.flags)}
return {} if row is None else json.loads(row.provenance_json or "{}")
def upsert_pack(pack: PackData, submitted_by_user_id: int, is_published: bool = False, change_summary: str = ""):
validation = {
"ok": len(pack.concepts) > 0,
"warnings": [] if len(pack.concepts) > 0 else ["Pack has no concepts."],
"errors": [],
"summary": {"concept_count": len(pack.concepts), "has_onboarding": bool(pack.onboarding)}
}
provenance = {
"source_count": pack.compliance.sources,
"licenses_present": ["CC BY-NC-SA 4.0"] if pack.compliance.shareAlikeRequired or pack.compliance.noncommercialOnly else [],
"restrictive_flags": list(pack.compliance.flags),
"sources": [
{"source_id": "sample-source-1", "title": f"Provenance placeholder for {pack.title}", "license_id": "CC BY-NC-SA 4.0" if pack.compliance.shareAlikeRequired or pack.compliance.noncommercialOnly else "unspecified", "attribution_text": "Sample attribution text placeholder"}
] if pack.compliance.sources else []
}
with SessionLocal() as db: with SessionLocal() as db:
row = db.get(PackORM, pack.id) row = db.get(PackORM, pack.id)
payload = json.dumps(pack.model_dump()) payload = json.dumps(pack.model_dump())
if row is None: if row is None:
row = PackORM( row = PackORM(
id=pack.id, title=pack.title, subtitle=pack.subtitle, level=pack.level, id=pack.id,
data_json=payload, validation_json=json.dumps(validation), provenance_json=json.dumps(provenance), owner_user_id=submitted_by_user_id if policy_lane == "personal" else None,
governance_state="draft", current_version=1, is_published=is_published 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) db.add(row)
version_number = 1
else: else:
row.owner_user_id = submitted_by_user_id if policy_lane == "personal" else row.owner_user_id
row.policy_lane = policy_lane
row.title = pack.title row.title = pack.title
row.subtitle = pack.subtitle row.subtitle = pack.subtitle
row.level = pack.level row.level = pack.level
row.data_json = payload row.data_json = payload
row.validation_json = json.dumps(validation) row.validation_json = json.dumps(validation)
row.provenance_json = json.dumps(provenance) row.provenance_json = json.dumps(provenance)
row.is_published = is_published
row.current_version += 1 row.current_version += 1
row.governance_state = "draft" if policy_lane == "personal":
version_number = row.current_version
db.flush()
db.add(PackVersionORM(
pack_id=pack.id,
version_number=version_number,
submitted_by_user_id=submitted_by_user_id,
status="draft",
data_json=payload,
change_summary=change_summary,
created_at=now_iso(),
review_summary=""
))
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 row.is_published = is_published
db.commit() db.commit()
return True
def set_governance_state(pack_id: str, status: str, review_summary: str):
with SessionLocal() as db:
row = db.get(PackORM, pack_id)
if row is None:
return False
row.governance_state = status
version = db.execute(
select(PackVersionORM).where(
PackVersionORM.pack_id == pack_id,
PackVersionORM.version_number == row.current_version
)
).scalar_one_or_none()
if version is not None:
version.status = status
version.review_summary = review_summary
db.commit()
return True
def list_pack_versions(pack_id: str):
with SessionLocal() as db:
rows = db.execute(
select(PackVersionORM).where(PackVersionORM.pack_id == pack_id).order_by(PackVersionORM.version_number.desc())
).scalars().all()
return [{
"version_number": r.version_number,
"status": r.status,
"change_summary": r.change_summary,
"created_at": r.created_at,
"review_summary": r.review_summary,
"submitted_by_user_id": r.submitted_by_user_id
} for r in rows]
def add_review_comment(pack_id: str, version_number: int, reviewer_user_id: int, comment_text: str, disposition: str):
with SessionLocal() as db:
db.add(ReviewCommentORM(
pack_id=pack_id,
version_number=version_number,
reviewer_user_id=reviewer_user_id,
comment_text=comment_text,
disposition=disposition,
created_at=now_iso()
))
db.commit()
def list_review_comments(pack_id: str):
with SessionLocal() as db:
rows = db.execute(
select(ReviewCommentORM).where(ReviewCommentORM.pack_id == pack_id).order_by(ReviewCommentORM.id.desc())
).scalars().all()
return [{
"version_number": r.version_number,
"reviewer_user_id": r.reviewer_user_id,
"comment_text": r.comment_text,
"disposition": r.disposition,
"created_at": r.created_at
} for r in rows]
def create_learner(owner_user_id: int, learner_id: str, display_name: str = ""): def create_learner(owner_user_id: int, learner_id: str, display_name: str = ""):
with SessionLocal() as db: with SessionLocal() as db:
@ -191,14 +197,6 @@ def create_learner(owner_user_id: int, learner_id: str, display_name: str = ""):
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)
@ -227,8 +225,7 @@ def save_learner_state(state: LearnerState):
def create_evaluator_job(learner_id: str, pack_id: str, concept_id: str, submitted_text: str): def create_evaluator_job(learner_id: str, pack_id: str, concept_id: str, submitted_text: str):
with SessionLocal() as db: with SessionLocal() as db:
trace = {"rubric_dimension_scores": [{"dimension": "mastery", "score": 0.0}], "notes": ["Job queued", "Awaiting evaluator"], "token_count_estimate": len(submitted_text.split())} job = EvaluatorJobORM(learner_id=learner_id, pack_id=pack_id, concept_id=concept_id, submitted_text=submitted_text, status="queued", trace_json=json.dumps({"notes": ["Job 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(trace))
db.add(job) db.add(job)
db.commit() db.commit()
db.refresh(job) db.refresh(job)

View File

@ -14,19 +14,16 @@ def main():
db.commit() db.commit()
upsert_pack( upsert_pack(
PackData( PackData(
id="bayes-pack", id="wesley-private-pack",
title="Bayesian Reasoning", title="Wesley Private Pack",
subtitle="Probability, evidence, updating, and model criticism.", subtitle="Personal pack example.",
level="novice-friendly", level="novice-friendly",
concepts=[ concepts=[PackConcept(id="intro", title="Intro", prerequisites=[], masteryDimension="mastery", exerciseReward="Intro marker")],
PackConcept(id="prior", title="Prior", prerequisites=[], masteryDimension="mastery", exerciseReward="Prior badge earned"), onboarding={"headline":"Start privately","body":"Personal pack lane.","checklist":["Create pack","Use pack"]},
PackConcept(id="posterior", title="Posterior", prerequisites=["prior"], masteryDimension="mastery", exerciseReward="Posterior path opened"), compliance=PackCompliance()
],
onboarding={"headline":"Start with a fast visible win","body":"Read one short orientation, answer one guided question, and leave with your first mastery marker.","checklist":["Read the one-screen topic orientation","Answer one guided exercise","Write one explanation in your own words"]},
compliance=PackCompliance(sources=2, attributionRequired=True, shareAlikeRequired=True, noncommercialOnly=True, flags=["share-alike","noncommercial","excluded-third-party-content"])
), ),
submitted_by_user_id=1, submitted_by_user_id=1,
policy_lane="personal",
is_published=True, is_published=True,
change_summary="Initial seed version" change_summary="Initial personal pack"
) )
print("Seeded database. Demo user: wesley / demo-pass")

View File

@ -8,17 +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", trace={"rubric_dimension_scores": [{"dimension": "mastery", "score": 0.35}], "notes": ["Job running", "Prototype trace generated"], "token_count_estimate": len(job.submitted_text.split())})
score = 0.78 if len(job.submitted_text.strip()) > 20 else 0.62 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."
trace = {"rubric_dimension_scores": [{"dimension": "mastery", "score": score}], "notes": ["Prototype evaluator completed", notes], "token_count_estimate": len(job.submitted_text.split()), "decision_basis": ["response length heuristic", "single-dimension mastery proxy"]} update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes, trace={"notes": ["completed"]})
update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes, trace=trace)
state = load_learner_state(job.learner_id) 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) 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)

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 Review Governance Layer</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,5 +1,5 @@
{ {
"name": "didactopus-governance-ui", "name": "didactopus-agent-audit-ui",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { login, refresh, fetchPacks, fetchAdminPacks, fetchPackValidation, fetchPackProvenance, fetchPackVersions, fetchPackComments, upsertPack, publishPack, governanceAction, addReviewComment, listLearners, createLearner, fetchLearnerState, fetchRecommendations, postEvidence, submitEvaluatorJob, fetchEvaluatorHistory, fetchEvaluatorTrace } from "./api"; import { login, refresh, fetchDeploymentPolicy, fetchAgentCapabilities, listServiceAccounts, createServiceAccount, rotateServiceAccount, setServiceAccountState, listAgentAuditLogs } from "./api";
import { loadAuth, saveAuth, clearAuth } from "./authStore"; import { loadAuth, saveAuth, clearAuth } from "./authStore";
function LoginView({ onAuth }) { function LoginView({ onAuth }) {
@ -26,63 +26,15 @@ 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>
<button className={tab==="review" ? "active-tab" : ""} onClick={() => setTab("review")}>Governance review</button>
</> : null}
</div>
);
}
function PackAuthorForm({ value, onChange, onSave }) {
function setField(field, val) { onChange({ ...value, [field]: val }); }
function setCompliance(field, val) { onChange({ ...value, compliance: { ...value.compliance, [field]: val } }); }
return (
<div className="form-grid">
<label>Pack ID<input value={value.id} onChange={(e) => setField("id", e.target.value)} /></label>
<label>Title<input value={value.title} onChange={(e) => setField("title", e.target.value)} /></label>
<label className="full">Subtitle<input value={value.subtitle} onChange={(e) => setField("subtitle", e.target.value)} /></label>
<label>Level<input value={value.level} onChange={(e) => setField("level", e.target.value)} /></label>
<label>Source count<input type="number" value={value.compliance.sources} onChange={(e) => setCompliance("sources", Number(e.target.value))} /></label>
<label className="full">Onboarding headline<input value={value.onboarding.headline} onChange={(e) => onChange({ ...value, onboarding: { ...value.onboarding, headline: e.target.value } })} /></label>
<label className="full">Onboarding body<textarea value={value.onboarding.body} onChange={(e) => onChange({ ...value, onboarding: { ...value.onboarding, body: e.target.value } })} /></label>
<div className="checkrow full">
<label><input type="checkbox" checked={value.compliance.attributionRequired} onChange={(e) => setCompliance("attributionRequired", e.target.checked)} /> Attribution required</label>
<label><input type="checkbox" checked={value.compliance.shareAlikeRequired} onChange={(e) => setCompliance("shareAlikeRequired", e.target.checked)} /> Share-alike</label>
<label><input type="checkbox" checked={value.compliance.noncommercialOnly} onChange={(e) => setCompliance("noncommercialOnly", e.target.checked)} /> Noncommercial only</label>
</div>
<div className="full"><button className="primary" onClick={onSave}>Save draft</button></div>
</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 [selectedTrace, setSelectedTrace] = useState(null);
const [validation, setValidation] = useState(null);
const [provenance, setProvenance] = useState(null);
const [versions, setVersions] = useState([]);
const [comments, setComments] = useState([]);
const [commentText, setCommentText] = useState("Looks structurally plausible.");
const [reviewSummary, setReviewSummary] = useState("Reviewed and ready for next stage.");
const [newLearnerId, setNewLearnerId] = useState("wesley-learner");
const [formPack, setFormPack] = useState({ id: "new-pack", title: "New Pack", subtitle: "Editable governance scaffold", level: "novice-friendly", concepts: [], onboarding: { headline: "Start here", body: "Begin", checklist: [] }, compliance: { sources: 0, attributionRequired: false, shareAlikeRequired: false, noncommercialOnly: false, flags: [] } });
const [message, setMessage] = useState("");
async function refreshAuthToken() { async function refreshAuthToken() {
if (!auth?.refresh_token) return null; if (!auth?.refresh_token) return null;
@ -107,97 +59,35 @@ export default function App() {
} }
} }
async function reload() {
setPolicy(await guarded((token) => fetchDeploymentPolicy(token)));
setCaps(await guarded((token) => fetchAgentCapabilities(token)));
if (auth.role === "admin") {
setServiceAccounts(await guarded((token) => listServiceAccounts(token)));
setAuditLogs(await guarded((token) => listAgentAuditLogs(token)));
}
}
useEffect(() => { useEffect(() => {
if (!auth) return; if (!auth) return;
async function load() { reload();
const p = await guarded((token) => fetchPacks(token));
setPacks(p);
setSelectedPackId((prev) => prev || p[0]?.id || "");
let ls = await guarded((token) => listLearners(token));
if (ls.length === 0) {
await guarded((token) => createLearner(token, selectedLearnerId, selectedLearnerId));
ls = await guarded((token) => listLearners(token));
}
setLearners(ls);
if (auth.role === "admin") {
setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
}
}
load();
}, [auth]); }, [auth]);
useEffect(() => { async function createNow() {
if (!auth || !selectedLearnerId || !selectedPackId) return; const result = await guarded((token) => createServiceAccount(token, form));
async function loadStuff() { setCreated(result);
setLearnerState(await guarded((token) => fetchLearnerState(token, selectedLearnerId))); await reload();
const recs = await guarded((token) => fetchRecommendations(token, selectedLearnerId, selectedPackId));
setCards(recs.cards || []);
setHistory(await guarded((token) => fetchEvaluatorHistory(token, selectedLearnerId)));
if (auth.role === "admin") {
setValidation(await guarded((token) => fetchPackValidation(token, selectedPackId)));
setProvenance(await guarded((token) => fetchPackProvenance(token, selectedPackId)));
setVersions(await guarded((token) => fetchPackVersions(token, selectedPackId)));
setComments(await guarded((token) => fetchPackComments(token, selectedPackId)));
}
}
loadStuff();
}, [auth, selectedLearnerId, selectedPackId]);
async function simulateCard(card) {
await guarded((token) => postEvidence(token, selectedLearnerId, { concept_id: card.conceptId, dimension: "mastery", score: card.scoreHint, confidence_hint: card.confidenceHint, timestamp: new Date().toISOString(), kind: "checkpoint", source_id: `ui-${card.id}` }));
setLearnerState(await guarded((token) => fetchLearnerState(token, selectedLearnerId)));
const recs = await guarded((token) => fetchRecommendations(token, selectedLearnerId, selectedPackId));
setCards(recs.cards || []);
setMessage(card.reward);
} }
async function runEvaluator() { async function rotateNow(name) {
const conceptId = packs.find((p) => p.id === selectedPackId)?.concepts?.[0]?.id || "prior"; const result = await guarded((token) => rotateServiceAccount(token, name));
await guarded((token) => submitEvaluatorJob(token, selectedLearnerId, { pack_id: selectedPackId, concept_id: conceptId, submitted_text: "This is a moderately detailed learner response intended to trigger a somewhat better prototype evaluator score.", kind: "checkpoint" })); setRotated(result);
setTimeout(async () => { await reload();
setHistory(await guarded((token) => fetchEvaluatorHistory(token, selectedLearnerId)));
setLearnerState(await guarded((token) => fetchLearnerState(token, selectedLearnerId)));
const recs = await guarded((token) => fetchRecommendations(token, selectedLearnerId, selectedPackId));
setCards(recs.cards || []);
}, 1200);
} }
async function 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() {
await guarded((token) => upsertPack(token, { pack: formPack, is_published: false, change_summary: "Submitted from form editor" }));
setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
setPacks(await guarded((token) => fetchPacks(token)));
setMessage("Draft saved");
}
async function togglePublish(packId, isPublished) {
await guarded((token) => publishPack(token, packId, isPublished));
setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
setPacks(await guarded((token) => fetchPacks(token)));
}
async function doGovernance(status) {
await guarded((token) => governanceAction(token, selectedPackId, { status, review_summary: reviewSummary }));
setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
setVersions(await guarded((token) => fetchPackVersions(token, selectedPackId)));
setMessage(`Pack moved to ${status}`);
}
async function addCommentNow() {
const versionNumber = versions[0]?.version_number || 1;
await guarded((token) => addReviewComment(token, selectedPackId, versionNumber, { comment_text: commentText, disposition: "comment" }));
setComments(await guarded((token) => fetchPackComments(token, selectedPackId)));
setMessage("Review comment added");
}
async function loadTrace(jobId) {
setSelectedTrace(await guarded((token) => fetchEvaluatorTrace(token, jobId)));
} }
if (!auth) return <LoginView onAuth={setAuth} />; if (!auth) return <LoginView onAuth={setAuth} />;
@ -206,149 +96,63 @@ export default function App() {
<div className="page"> <div className="page">
<header className="hero"> <header className="hero">
<div> <div>
<h1>Didactopus review governance layer</h1> <h1>Didactopus agent audit + key rotation</h1>
<p>Versioning, review comments, governance states, evaluator traces, and curator-facing pack 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>
<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> <button onClick={() => { clearAuth(); setAuth(null); }}>Logout</button>
</div>
</header> </header>
<NavTabs tab={tab} setTab={setTab} role={auth.role} />
{tab === "learner" && (
<main className="layout onecol">
<section className="card">
<h2>Learner dashboard</h2>
<button onClick={runEvaluator}>Submit demo evaluator job</button>
<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>
)}
{tab === "history" && (
<main className="layout twocol"> <main className="layout twocol">
<section className="card"> <section className="card">
<h2>Evaluator history</h2> <h2>Deployment policy</h2>
{history.length ? ( <pre className="prebox">{JSON.stringify(policy, null, 2)}</pre>
<h2>Agent capabilities</h2>
<pre className="prebox">{JSON.stringify(caps, null, 2)}</pre>
</section>
<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"> <table className="table">
<thead><tr><th>Job</th><th>Status</th><th>Concept</th><th>Score</th><th>Trace</th></tr></thead> <thead><tr><th>Name</th><th>Active</th><th>Scopes</th><th>Actions</th></tr></thead>
<tbody> <tbody>
{history.map((row) => ( {serviceAccounts.map((sa) => (
<tr key={row.job_id}> <tr key={sa.id}>
<td>{row.job_id}</td> <td>{sa.name}</td>
<td>{row.status}</td> <td>{String(sa.is_active)}</td>
<td>{row.concept_id}</td> <td>{sa.scopes.join(", ")}</td>
<td>{row.result_score ?? "-"}</td> <td>
<td><button onClick={() => loadTrace(row.job_id)}>Inspect trace</button></td> <button onClick={() => rotateNow(sa.name)}>Rotate</button>
<button onClick={() => toggleState(sa.name, !sa.is_active)}>{sa.is_active ? "Disable" : "Enable"}</button>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
) : <div className="muted">No evaluator jobs yet.</div>}
</section>
<section className="card">
<h2>Evaluator trace</h2>
<pre className="prebox">{JSON.stringify(selectedTrace, null, 2)}</pre>
</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 authoring</h2>
<PackAuthorForm value={formPack} onChange={setFormPack} onSave={savePack} />
</section>
<section className="card">
<h2>Pack administration</h2>
<table className="table">
<thead><tr><th>ID</th><th>Version</th><th>State</th><th>Published</th><th>Action</th></tr></thead>
<tbody>
{adminPacks.map((row) => (
<tr key={row.id}>
<td>{row.id}</td>
<td>{row.current_version}</td>
<td>{row.governance_state}</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>
)}
{tab === "review" && auth.role === "admin" && (
<main className="layout twocol">
<section className="card">
<h2>Governance review</h2>
<div className="button-row">
<button onClick={() => doGovernance("in_review")}>Move to in_review</button>
<button onClick={() => doGovernance("approved")}>Approve</button>
<button onClick={() => doGovernance("rejected")}>Reject</button>
</div> </div>
<label>Review summary<textarea value={reviewSummary} onChange={(e) => setReviewSummary(e.target.value)} /></label> </>
<h3>Validation</h3> ) : (
<pre className="prebox">{JSON.stringify(validation, null, 2)}</pre> <div className="muted">Admin required.</div>
<h3>Provenance</h3> )}
<pre className="prebox">{JSON.stringify(provenance, null, 2)}</pre>
</section> </section>
<section className="card">
<h2>Versions and comments</h2> <section className="card full">
<h3>Pack versions</h3> <h2>Agent audit logs</h2>
<pre className="prebox">{JSON.stringify(versions, null, 2)}</pre> <pre className="prebox tall">{JSON.stringify(auditLogs, null, 2)}</pre>
<label>Reviewer comment<textarea value={commentText} onChange={(e) => setCommentText(e.target.value)} /></label>
<button className="primary" onClick={addCommentNow}>Add comment</button>
<h3>Review comments</h3>
<pre className="prebox">{JSON.stringify(comments, null, 2)}</pre>
</section> </section>
</main> </main>
)}
</div> </div>
); );
} }

View File

@ -11,28 +11,15 @@ export async function login(username, password) {
if (!res.ok) throw new Error("login failed"); 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`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: 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"); if (!res.ok) throw new Error("refresh failed");
return await res.json(); return await res.json();
} }
export async function fetchDeploymentPolicy(token) { const res = await fetch(`${API}/deployment-policy`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchDeploymentPolicy failed"); return await res.json(); }
export async function 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 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 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 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 fetchPackValidation(token, packId) { const res = await fetch(`${API}/admin/packs/${packId}/validation`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPackValidation failed"); return await res.json(); } export async function 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 fetchPackProvenance(token, packId) { const res = await fetch(`${API}/admin/packs/${packId}/provenance`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPackProvenance failed"); return await res.json(); } export async function 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 fetchPackVersions(token, packId) { const res = await fetch(`${API}/admin/packs/${packId}/versions`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPackVersions failed"); return await res.json(); } export async function 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 fetchPackComments(token, packId) { const res = await fetch(`${API}/admin/packs/${packId}/comments`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPackComments failed"); return await res.json(); } export async function 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(); }
export async function upsertPack(token, payload) { const res = await fetch(`${API}/admin/packs`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("upsertPack failed"); return await res.json(); }
export async function publishPack(token, packId, isPublished) { const res = await fetch(`${API}/admin/packs/${packId}/publish?is_published=${isPublished}`, { method: "POST", headers: authHeaders(token, false) }); if (!res.ok) throw new Error("publishPack failed"); return await res.json(); }
export async function governanceAction(token, packId, payload) { const res = await fetch(`${API}/admin/packs/${packId}/governance`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("governanceAction failed"); return await res.json(); }
export async function addReviewComment(token, packId, versionNumber, payload) { const res = await fetch(`${API}/admin/packs/${packId}/comments?version_number=${versionNumber}`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("addReviewComment failed"); return await res.json(); }
export async function listLearners(token) { const res = await fetch(`${API}/learners`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listLearners failed"); return await res.json(); }
export async function createLearner(token, learnerId, displayName) { const res = await fetch(`${API}/learners`, { method: "POST", headers: authHeaders(token), body: JSON.stringify({ learner_id: learnerId, display_name: displayName }) }); if (!res.ok) throw new Error("createLearner failed"); return await res.json(); }
export async function fetchLearnerState(token, learnerId) { const res = await fetch(`${API}/learners/${learnerId}/state`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchLearnerState failed"); return await res.json(); }
export async function fetchRecommendations(token, learnerId, packId) { const res = await fetch(`${API}/learners/${learnerId}/recommendations/${packId}`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchRecommendations failed"); return await res.json(); }
export async function postEvidence(token, learnerId, event) { const res = await fetch(`${API}/learners/${learnerId}/evidence`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(event) }); if (!res.ok) throw new Error("postEvidence failed"); return await res.json(); }
export async function submitEvaluatorJob(token, learnerId, payload) { const res = await fetch(`${API}/learners/${learnerId}/evaluator-jobs`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("submitEvaluatorJob failed"); return await res.json(); }
export async function fetchEvaluatorHistory(token, learnerId) { const res = await fetch(`${API}/learners/${learnerId}/evaluator-history`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchEvaluatorHistory failed"); return await res.json(); }
export async function fetchEvaluatorTrace(token, jobId) { const res = await fetch(`${API}/evaluator-jobs/${jobId}/trace`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchEvaluatorTrace failed"); return await res.json(); }

View File

@ -1,38 +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:1500px; 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; flex-wrap:wrap; }
.tab-row button, button { border:1px solid var(--border); background:white; border-radius:12px; padding:10px 14px; cursor:pointer; }
.active-tab, .primary { background:var(--accent); color:white; border-color:var(--accent); }
.layout { display:grid; gap:16px; } .layout { display:grid; 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:320px; } @media (max-width:1100px) { .twocol { grid-template-columns:1fr; } .hero { flex-direction:column; } .full { grid-column:auto; } }
.form-grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.full { grid-column:1 / -1; }
.checkrow { display:flex; gap:16px; flex-wrap:wrap; align-items:center; }
.button-row { display:flex; gap:10px; flex-wrap:wrap; margin-bottom:12px; }
details summary { cursor:pointer; color:var(--accent); }
@media (max-width:1100px) {
.hero { flex-direction:column; }
.twocol, .form-grid { grid-template-columns:1fr; }
}