Apply ZIP update: 145-didactopus-agent-audit-and-key-rotation-layer.zip [2026-03-14T13:19:23]
This commit is contained in:
parent
5b4221d430
commit
5c3ba24e90
|
|
@ -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"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
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):
|
||||||
|
|
@ -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()
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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="{}")
|
||||||
|
|
|
||||||
|
|
@ -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.validation_json = json.dumps(validation)
|
||||||
|
row.provenance_json = json.dumps(provenance)
|
||||||
|
row.current_version += 1
|
||||||
|
if policy_lane == "personal":
|
||||||
row.is_published = is_published
|
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()
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
async function reload() {
|
||||||
if (!auth) return
|
setPolicy(await guarded((token) => fetchDeploymentPolicy(token)));
|
||||||
async function load() {
|
setCaps(await guarded((token) => fetchAgentCapabilities(token)));
|
||||||
const p = await guarded((token) => fetchPacks(token))
|
|
||||||
setPacks(p)
|
|
||||||
setSelectedPackId((prev) => prev || p[0]?.id || "")
|
|
||||||
const ls = await guarded((token) => listLearners(token))
|
|
||||||
setLearners(ls)
|
|
||||||
if (ls.length === 0) {
|
|
||||||
await guarded((token) => createLearner(token, selectedLearnerId, selectedLearnerId))
|
|
||||||
const ls2 = await guarded((token) => listLearners(token))
|
|
||||||
setLearners(ls2)
|
|
||||||
}
|
|
||||||
if (auth.role === "admin") {
|
if (auth.role === "admin") {
|
||||||
const ap = await guarded((token) => fetchAdminPacks(token))
|
setServiceAccounts(await guarded((token) => listServiceAccounts(token)));
|
||||||
setAdminPacks(ap)
|
setAuditLogs(await guarded((token) => listAgentAuditLogs(token)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
load()
|
|
||||||
}, [auth])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!auth || !selectedLearnerId || !selectedPackId) return
|
if (!auth) return;
|
||||||
async function loadLearnerStuff() {
|
reload();
|
||||||
const state = await guarded((token) => fetchLearnerState(token, selectedLearnerId))
|
}, [auth]);
|
||||||
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 createNow() {
|
||||||
|
const result = await guarded((token) => createServiceAccount(token, form));
|
||||||
async function simulateCard(card) {
|
setCreated(result);
|
||||||
await guarded((token) => postEvidence(token, selectedLearnerId, {
|
await reload();
|
||||||
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>
|
||||||
<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">
|
|
||||||
<section className="card">
|
|
||||||
<h2>Learner dashboard</h2>
|
|
||||||
<p><strong>Pack:</strong> {pack?.title || "-"}</p>
|
|
||||||
<p>{pack?.subtitle || ""}</p>
|
|
||||||
<button onClick={runEvaluator}>Submit demo evaluator job</button>
|
|
||||||
<h3>Next actions</h3>
|
|
||||||
<div className="steps-stack">
|
|
||||||
{cards.length ? cards.map((card) => (
|
|
||||||
<div key={card.id} className="step-card">
|
|
||||||
<div className="step-header">
|
|
||||||
<div>
|
|
||||||
<h4>{card.title}</h4>
|
|
||||||
<div className="muted">{card.minutes} minutes</div>
|
|
||||||
</div>
|
|
||||||
<div className="reward-pill">{card.reward}</div>
|
|
||||||
</div>
|
|
||||||
<p>{card.reason}</p>
|
|
||||||
<details>
|
|
||||||
<summary>Why this is recommended</summary>
|
|
||||||
<ul>{card.why.map((w, idx) => <li key={idx}>{w}</li>)}</ul>
|
|
||||||
</details>
|
|
||||||
<button className="primary" onClick={() => simulateCard(card)}>Simulate step</button>
|
|
||||||
</div>
|
|
||||||
)) : <div className="muted">No recommendations available.</div>}
|
|
||||||
</div>
|
|
||||||
<h3>Learner state snapshot</h3>
|
|
||||||
<pre className="prebox">{JSON.stringify(learnerState, null, 2)}</pre>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "history" && (
|
|
||||||
<main className="layout onecol">
|
|
||||||
<section className="card">
|
|
||||||
<h2>Evaluator history</h2>
|
|
||||||
{history.length ? (
|
|
||||||
<table className="table">
|
|
||||||
<thead><tr><th>Job</th><th>Status</th><th>Concept</th><th>Score</th><th>Confidence</th><th>Notes</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{history.map((row) => (
|
|
||||||
<tr key={row.job_id}>
|
|
||||||
<td>{row.job_id}</td>
|
|
||||||
<td>{row.status}</td>
|
|
||||||
<td>{row.concept_id}</td>
|
|
||||||
<td>{row.result_score ?? "-"}</td>
|
|
||||||
<td>{row.result_confidence_hint ?? "-"}</td>
|
|
||||||
<td>{row.result_notes || ""}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
) : <div className="muted">No evaluator jobs yet.</div>}
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "manage" && (
|
|
||||||
<main className="layout twocol">
|
<main className="layout twocol">
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2>Learner management</h2>
|
<h2>Deployment policy</h2>
|
||||||
<label>New learner ID<input value={newLearnerId} onChange={(e) => setNewLearnerId(e.target.value)} /></label>
|
<pre className="prebox">{JSON.stringify(policy, null, 2)}</pre>
|
||||||
<button className="primary" onClick={createLearnerNow}>Create learner</button>
|
<h2>Agent capabilities</h2>
|
||||||
|
<pre className="prebox">{JSON.stringify(caps, null, 2)}</pre>
|
||||||
</section>
|
</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">
|
<section className="card">
|
||||||
<h2>Pack editor</h2>
|
<h2>Service accounts</h2>
|
||||||
<textarea className="bigtext" value={newPackJson} onChange={(e) => setNewPackJson(e.target.value)} />
|
{auth.role === "admin" ? (
|
||||||
<button className="primary" onClick={savePack}>Save pack</button>
|
<>
|
||||||
</section>
|
<label>Name<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></label>
|
||||||
<section className="card">
|
<label>Description<input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></label>
|
||||||
<h2>Pack administration</h2>
|
<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>ID</th><th>Title</th><th>Published</th><th>Action</th></tr></thead>
|
<thead><tr><th>Name</th><th>Active</th><th>Scopes</th><th>Actions</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{adminPacks.map((row) => (
|
{serviceAccounts.map((sa) => (
|
||||||
<tr key={row.id}>
|
<tr key={sa.id}>
|
||||||
<td>{row.id}</td>
|
<td>{sa.name}</td>
|
||||||
<td>{row.title}</td>
|
<td>{String(sa.is_active)}</td>
|
||||||
<td>{String(row.is_published)}</td>
|
<td>{sa.scopes.join(", ")}</td>
|
||||||
<td><button onClick={() => togglePublish(row.id, !row.is_published)}>{row.is_published ? "Unpublish" : "Publish"}</button></td>
|
<td>
|
||||||
|
<button onClick={() => rotateNow(sa.name)}>Rotate</button>
|
||||||
|
<button onClick={() => toggleState(sa.name, !sa.is_active)}>{sa.is_active ? "Disable" : "Enable"}</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="muted">Admin required.</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card full">
|
||||||
|
<h2>Agent audit logs</h2>
|
||||||
|
<pre className="prebox tall">{JSON.stringify(auditLogs, null, 2)}</pre>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
111
webui/src/api.js
111
webui/src/api.js
|
|
@ -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(); }
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,5 +2,4 @@ import React from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")).render(<App />);
|
createRoot(document.getElementById("root")).render(<App />);
|
||||||
|
|
|
||||||
|
|
@ -1,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; }
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue