Apply ZIP update: 220-didactopus-media-rendering-pipeline-layer.zip [2026-03-14T13:20:42]
This commit is contained in:
parent
03ceea79fe
commit
9f51559b2b
|
|
@ -6,7 +6,19 @@ build-backend = "setuptools.build_meta"
|
||||||
name = "didactopus"
|
name = "didactopus"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = ["pydantic>=2.7", "pyyaml>=6.0"]
|
dependencies = [
|
||||||
|
"pydantic>=2.7",
|
||||||
|
"fastapi>=0.115",
|
||||||
|
"uvicorn>=0.30",
|
||||||
|
"sqlalchemy>=2.0",
|
||||||
|
"passlib[bcrypt]>=1.7",
|
||||||
|
"python-jose[cryptography]>=3.3"
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
didactopus-api = "didactopus.api:main"
|
||||||
|
didactopus-export-svg = "didactopus.export_svg:main"
|
||||||
|
didactopus-render-bundle = "didactopus.render_bundle:main"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
|
||||||
|
|
@ -1,103 +1,47 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import json
|
from fastapi import FastAPI, HTTPException, Header, Depends
|
||||||
from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
import uvicorn
|
import uvicorn, json, tempfile
|
||||||
from .config import load_settings
|
from pathlib import Path
|
||||||
from .db import Base, engine
|
from .db import Base, engine
|
||||||
from .models import (
|
from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, MediaRenderRequest
|
||||||
LoginRequest, ServiceAccountLoginRequest, ServiceAccountCreateRequest, ServiceAccountRotateRequest,
|
from .repository import authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token, list_packs_for_user, get_pack, get_pack_row, create_learner, learner_owned_by_user, load_learner_state, save_learner_state
|
||||||
ServiceAccountStateRequest, ServiceToken, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState,
|
from .auth import issue_access_token, issue_refresh_token, decode_token, new_token_id
|
||||||
EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest, AgentCapabilityManifest,
|
from .engine import build_graph_frames, stable_layout
|
||||||
AgentLearnerPlanRequest, AgentLearnerPlanResponse, LearnerRunCreateRequest, WorkflowEventCreateRequest
|
from .render_bundle import make_render_bundle
|
||||||
)
|
|
||||||
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,
|
|
||||||
create_learner_run, end_learner_run, list_learner_runs, add_workflow_event, list_workflow_events,
|
|
||||||
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, build_animation_frames
|
|
||||||
from .auth import issue_access_token, issue_refresh_token, issue_service_access_token, decode_token, new_token_id, new_secret
|
|
||||||
from .worker import process_job
|
|
||||||
|
|
||||||
settings = load_settings()
|
|
||||||
Base.metadata.create_all(bind=engine)
|
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_actor(authorization: str = Header(default="")):
|
def current_user(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:
|
if not payload or payload.get("kind") != "access":
|
||||||
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 {"actor_type": "user", "user": user, "scopes": None}
|
return user
|
||||||
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(actor = Depends(current_actor)):
|
def ensure_learner_access(user, learner_id: str):
|
||||||
if actor["actor_type"] != "user" or actor["user"].role != "admin":
|
|
||||||
raise HTTPException(status_code=403, detail="Admin role required")
|
|
||||||
return actor["user"]
|
|
||||||
|
|
||||||
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 actor")
|
raise HTTPException(status_code=403, detail="Learner not accessible by this user")
|
||||||
|
|
||||||
def ensure_pack_access(actor, pack_id: str):
|
def ensure_pack_access(user, pack_id: str):
|
||||||
row = get_pack_row(pack_id)
|
row = get_pack_row(pack_id)
|
||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(status_code=404, detail="Pack not found")
|
raise HTTPException(status_code=404, detail="Pack not found")
|
||||||
if actor["actor_type"] == "service":
|
|
||||||
return row
|
|
||||||
user = actor["user"]
|
|
||||||
if user.role == "admin":
|
if user.role == "admin":
|
||||||
return row
|
return row
|
||||||
if row.policy_lane == "community":
|
if row.policy_lane == "community":
|
||||||
return row
|
return row
|
||||||
if row.owner_user_id == user.id:
|
if row.owner_user_id == user.id:
|
||||||
return row
|
return row
|
||||||
raise HTTPException(status_code=403, detail="Pack not accessible by this actor")
|
raise HTTPException(status_code=403, detail="Pack not accessible by this user")
|
||||||
|
|
||||||
@app.post("/api/login", response_model=TokenPair)
|
@app.post("/api/login", response_model=TokenPair)
|
||||||
def login(payload: LoginRequest):
|
def login(payload: LoginRequest):
|
||||||
|
|
@ -108,14 +52,6 @@ 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)
|
||||||
|
|
@ -132,189 +68,73 @@ 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(actor = Depends(require_scope("packs:read"))):
|
def api_list_packs(user = Depends(current_user)):
|
||||||
user_id = actor["user"].id if actor["actor_type"] == "user" else None
|
return [p.model_dump() for p in list_packs_for_user(user.id, include_unpublished=(user.role == "admin"))]
|
||||||
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.post("/api/packs")
|
|
||||||
def api_upsert_personal_pack(payload: CreatePackRequest, actor = Depends(require_scope("packs:write_personal"))):
|
|
||||||
if payload.policy_lane != "personal":
|
|
||||||
raise HTTPException(status_code=403, detail="This endpoint is for personal-lane write access")
|
|
||||||
if actor["actor_type"] != "user":
|
|
||||||
raise HTTPException(status_code=403, detail="Service accounts may not own personal packs in this scaffold")
|
|
||||||
upsert_pack(payload.pack, submitted_by_user_id=actor["user"].id, policy_lane="personal", is_published=payload.is_published, change_summary=payload.change_summary)
|
|
||||||
return {"ok": True, "pack_id": payload.pack.id, "policy_lane": "personal"}
|
|
||||||
|
|
||||||
@app.post("/api/learners")
|
@app.post("/api/learners")
|
||||||
def api_create_learner(payload: CreateLearnerRequest, actor = Depends(require_scope("learners:write"))):
|
def api_create_learner(payload: CreateLearnerRequest, user = Depends(current_user)):
|
||||||
if actor["actor_type"] != "user":
|
create_learner(user.id, payload.learner_id, payload.display_name)
|
||||||
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/{learner_id}/state")
|
@app.get("/api/learners/{learner_id}/state")
|
||||||
def api_get_learner_state(learner_id: str, actor = Depends(require_scope("learners:read"))):
|
def api_get_learner_state(learner_id: str, user = Depends(current_user)):
|
||||||
ensure_learner_access(actor, learner_id)
|
ensure_learner_access(user, learner_id)
|
||||||
state = load_learner_state(learner_id).model_dump()
|
return 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, actor = Depends(require_scope("learners:write"))):
|
def api_put_learner_state(learner_id: str, state: LearnerState, user = Depends(current_user)):
|
||||||
ensure_learner_access(actor, learner_id)
|
ensure_learner_access(user, 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")
|
||||||
result = save_learner_state(state).model_dump()
|
return 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.get("/api/packs/{pack_id}/layout")
|
||||||
def api_post_evidence(learner_id: str, event: EvidenceEvent, actor = Depends(require_scope("learners:write"))):
|
def api_pack_layout(pack_id: str, user = Depends(current_user)):
|
||||||
ensure_learner_access(actor, learner_id)
|
ensure_pack_access(user, pack_id)
|
||||||
state = load_learner_state(learner_id)
|
|
||||||
state = apply_evidence(state, event)
|
|
||||||
result = save_learner_state(state).model_dump()
|
|
||||||
audit_service_action(actor, "learner_evidence_post", learner_id, "ok", {"concept_id": event.concept_id})
|
|
||||||
return result
|
|
||||||
|
|
||||||
@app.get("/api/learners/{learner_id}/recommendations/{pack_id}")
|
|
||||||
def api_get_recommendations(learner_id: str, pack_id: str, actor = Depends(require_scope("recommendations:read"))):
|
|
||||||
ensure_learner_access(actor, learner_id)
|
|
||||||
ensure_pack_access(actor, pack_id)
|
|
||||||
state = load_learner_state(learner_id)
|
|
||||||
pack = get_pack(pack_id)
|
pack = get_pack(pack_id)
|
||||||
if pack is None:
|
return {"pack_id": pack_id, "layout": stable_layout(pack)} if pack else {"pack_id": pack_id, "layout": {}}
|
||||||
raise HTTPException(status_code=404, detail="Pack not found")
|
|
||||||
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.get("/api/learners/{learner_id}/graph-animation/{pack_id}")
|
||||||
def api_submit_evaluator_job(learner_id: str, payload: EvaluatorSubmission, background_tasks: BackgroundTasks, actor = Depends(require_scope("evaluators:submit"))):
|
def api_graph_animation(learner_id: str, pack_id: str, user = Depends(current_user)):
|
||||||
ensure_learner_access(actor, learner_id)
|
ensure_learner_access(user, learner_id)
|
||||||
ensure_pack_access(actor, payload.pack_id)
|
ensure_pack_access(user, pack_id)
|
||||||
job_id = create_evaluator_job(learner_id, payload.pack_id, payload.concept_id, payload.submitted_text)
|
|
||||||
background_tasks.add_task(process_job, job_id)
|
|
||||||
audit_service_action(actor, "evaluator_job_submit", str(job_id), "ok", {"learner_id": learner_id, "pack_id": payload.pack_id})
|
|
||||||
return EvaluatorJobStatus(job_id=job_id, status="queued")
|
|
||||||
|
|
||||||
@app.get("/api/evaluator-jobs/{job_id}", response_model=EvaluatorJobStatus)
|
|
||||||
def api_get_evaluator_job(job_id: int, actor = Depends(require_scope("evaluators:read"))):
|
|
||||||
job = get_evaluator_job(job_id)
|
|
||||||
if job is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Job not found")
|
|
||||||
audit_service_action(actor, "evaluator_job_read", str(job_id), "ok", {})
|
|
||||||
return EvaluatorJobStatus(job_id=job.id, status=job.status, result_score=job.result_score, result_confidence_hint=job.result_confidence_hint, result_notes=job.result_notes)
|
|
||||||
|
|
||||||
@app.get("/api/learners/{learner_id}/evaluator-history")
|
|
||||||
def api_get_evaluator_history(learner_id: str, actor = Depends(require_scope("evaluators:read"))):
|
|
||||||
ensure_learner_access(actor, learner_id)
|
|
||||||
jobs = list_evaluator_jobs_for_learner(learner_id)
|
|
||||||
audit_service_action(actor, "evaluator_history_read", learner_id, "ok", {"jobs": len(jobs)})
|
|
||||||
return [{"job_id": j.id, "status": j.status, "concept_id": j.concept_id, "result_score": j.result_score, "result_confidence_hint": j.result_confidence_hint, "result_notes": j.result_notes} for j in jobs]
|
|
||||||
|
|
||||||
@app.post("/api/learner-runs")
|
|
||||||
def api_create_learner_run(payload: LearnerRunCreateRequest, actor = Depends(current_actor)):
|
|
||||||
ensure_learner_access(actor, payload.learner_id)
|
|
||||||
ensure_pack_access(actor, payload.pack_id)
|
|
||||||
actor_name = actor["service_account_name"] if actor["actor_type"] == "service" else actor["user"].username
|
|
||||||
run_id = create_learner_run(payload.learner_id, payload.pack_id, payload.actor_kind, actor_name, payload.title)
|
|
||||||
audit_service_action(actor, "learner_run_create", str(run_id), "ok", {"learner_id": payload.learner_id, "pack_id": payload.pack_id})
|
|
||||||
return {"run_id": run_id}
|
|
||||||
|
|
||||||
@app.post("/api/learner-runs/{run_id}/complete")
|
|
||||||
def api_complete_learner_run(run_id: int, actor = Depends(current_actor)):
|
|
||||||
row = end_learner_run(run_id)
|
|
||||||
if row is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Run not found")
|
|
||||||
audit_service_action(actor, "learner_run_complete", str(run_id), "ok", {})
|
|
||||||
return {"run_id": run_id, "status": row.status}
|
|
||||||
|
|
||||||
@app.get("/api/learners/{learner_id}/runs")
|
|
||||||
def api_list_learner_runs(learner_id: str, actor = Depends(current_actor)):
|
|
||||||
ensure_learner_access(actor, learner_id)
|
|
||||||
return list_learner_runs(learner_id)
|
|
||||||
|
|
||||||
@app.post("/api/workflow-events")
|
|
||||||
def api_add_workflow_event(payload: WorkflowEventCreateRequest, actor = Depends(current_actor)):
|
|
||||||
ensure_learner_access(actor, payload.learner_id)
|
|
||||||
event_id = add_workflow_event(payload.run_id, payload.learner_id, payload.event_type, payload.concept_id, payload.timestamp, payload.detail)
|
|
||||||
audit_service_action(actor, "workflow_event_add", str(event_id), "ok", {"run_id": payload.run_id, "event_type": payload.event_type})
|
|
||||||
return {"event_id": event_id}
|
|
||||||
|
|
||||||
@app.get("/api/learner-runs/{run_id}/workflow-events")
|
|
||||||
def api_list_workflow_events(run_id: int, actor = Depends(current_actor)):
|
|
||||||
events = list_workflow_events(run_id)
|
|
||||||
audit_service_action(actor, "workflow_events_read", str(run_id), "ok", {"count": len(events)})
|
|
||||||
return events
|
|
||||||
|
|
||||||
@app.get("/api/learners/{learner_id}/animation/{pack_id}")
|
|
||||||
def api_learning_animation(learner_id: str, pack_id: str, actor = Depends(current_actor)):
|
|
||||||
ensure_learner_access(actor, learner_id)
|
|
||||||
ensure_pack_access(actor, pack_id)
|
|
||||||
pack = get_pack(pack_id)
|
pack = get_pack(pack_id)
|
||||||
state = load_learner_state(learner_id)
|
state = load_learner_state(learner_id)
|
||||||
frames = build_animation_frames(state)
|
frames = build_graph_frames(state, pack)
|
||||||
audit_service_action(actor, "learning_animation_read", f"{learner_id}:{pack_id}", "ok", {"frames": len(frames)})
|
|
||||||
return {
|
return {
|
||||||
"learner_id": learner_id,
|
"learner_id": learner_id,
|
||||||
"pack_id": pack_id,
|
"pack_id": pack_id,
|
||||||
"pack_title": pack.title if pack else "",
|
"pack_title": pack.title if pack else "",
|
||||||
"frames": frames,
|
"frames": frames,
|
||||||
"concepts": [c.id for c in pack.concepts] if pack else [],
|
"concepts": [{"id": c.id, "title": c.title, "prerequisites": c.prerequisites, "cross_pack_links": [l.model_dump() for l in c.cross_pack_links]} for c in pack.concepts] if pack else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
@app.post("/api/learners/{learner_id}/render-bundle/{pack_id}")
|
||||||
|
def api_render_bundle(learner_id: str, pack_id: str, payload: MediaRenderRequest, user = Depends(current_user)):
|
||||||
|
ensure_learner_access(user, learner_id)
|
||||||
|
ensure_pack_access(user, pack_id)
|
||||||
|
pack = get_pack(pack_id)
|
||||||
|
state = load_learner_state(learner_id)
|
||||||
|
animation = {
|
||||||
|
"learner_id": learner_id,
|
||||||
|
"pack_id": pack_id,
|
||||||
|
"pack_title": pack.title if pack else "",
|
||||||
|
"frames": build_graph_frames(state, pack),
|
||||||
|
}
|
||||||
|
base = Path(tempfile.mkdtemp(prefix="didactopus_render_"))
|
||||||
|
payload_json = base / "animation_payload.json"
|
||||||
|
payload_json.write_text(json.dumps(animation, indent=2), encoding="utf-8")
|
||||||
|
out_dir = base / "bundle"
|
||||||
|
make_render_bundle(str(payload_json), str(out_dir), fps=payload.fps, fmt=payload.format)
|
||||||
|
return {
|
||||||
|
"bundle_dir": str(out_dir),
|
||||||
|
"payload_json": str(payload_json),
|
||||||
|
"manifest": str(out_dir / "render_manifest.json"),
|
||||||
|
"script": str(out_dir / "render.sh"),
|
||||||
|
"format": payload.format,
|
||||||
|
"fps": payload.fps,
|
||||||
}
|
}
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
uvicorn.run(app, host=settings.host, port=settings.port)
|
uvicorn.run(app, host="127.0.0.1", port=8011)
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,6 @@ 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])
|
||||||
|
|
@ -36,6 +33,3 @@ 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)
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ class Settings(BaseModel):
|
||||||
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()
|
||||||
|
|
|
||||||
|
|
@ -1,76 +1,110 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from .models import LearnerState, EvidenceEvent, MasteryRecord, PackData
|
from collections import defaultdict
|
||||||
|
from .models import LearnerState, PackData
|
||||||
|
|
||||||
def get_record(state: LearnerState, concept_id: str, dimension: str = "mastery") -> MasteryRecord | None:
|
def concept_depths(pack: PackData) -> dict[str, int]:
|
||||||
for rec in state.records:
|
concept_map = {c.id: c for c in pack.concepts}
|
||||||
if rec.concept_id == concept_id and rec.dimension == dimension:
|
memo = {}
|
||||||
return rec
|
def depth(cid: str) -> int:
|
||||||
return None
|
if cid in memo:
|
||||||
|
return memo[cid]
|
||||||
|
c = concept_map[cid]
|
||||||
|
if not c.prerequisites:
|
||||||
|
memo[cid] = 0
|
||||||
|
else:
|
||||||
|
memo[cid] = 1 + max(depth(pid) for pid in c.prerequisites if pid in concept_map)
|
||||||
|
return memo[cid]
|
||||||
|
for cid in concept_map:
|
||||||
|
depth(cid)
|
||||||
|
return memo
|
||||||
|
|
||||||
def apply_evidence(state: LearnerState, event: EvidenceEvent, decay: float = 0.05, reinforcement: float = 0.25) -> LearnerState:
|
def stable_layout(pack: PackData, width: int = 900, height: int = 520):
|
||||||
rec = get_record(state, event.concept_id, event.dimension)
|
depths = concept_depths(pack)
|
||||||
if rec is None:
|
layers = defaultdict(list)
|
||||||
rec = MasteryRecord(concept_id=event.concept_id, dimension=event.dimension, score=0.0, confidence=0.0, evidence_count=0, last_updated=event.timestamp)
|
for c in pack.concepts:
|
||||||
state.records.append(rec)
|
layers[depths.get(c.id, 0)].append(c)
|
||||||
weight = max(0.05, min(1.0, event.confidence_hint))
|
positions = {}
|
||||||
rec.score = ((rec.score * rec.evidence_count) + (event.score * weight)) / max(1, rec.evidence_count + 1)
|
max_depth = max(layers.keys()) if layers else 0
|
||||||
rec.confidence = min(1.0, max(0.0, rec.confidence * (1.0 - decay) + reinforcement * weight + 0.10 * max(0.0, min(1.0, event.score))))
|
for d in sorted(layers):
|
||||||
rec.evidence_count += 1
|
nodes = sorted(layers[d], key=lambda c: c.id)
|
||||||
rec.last_updated = event.timestamp
|
y = 90 + d * ((height - 160) / max(1, max_depth))
|
||||||
state.history.append(event)
|
for idx, node in enumerate(nodes):
|
||||||
return state
|
if node.position is not None:
|
||||||
|
positions[node.id] = {"x": node.position.x, "y": node.position.y, "source": "pack_authored"}
|
||||||
|
else:
|
||||||
|
spacing = width / (len(nodes) + 1)
|
||||||
|
x = spacing * (idx + 1)
|
||||||
|
positions[node.id] = {"x": x, "y": y, "source": "auto_layered"}
|
||||||
|
return positions
|
||||||
|
|
||||||
def prereqs_satisfied(state: LearnerState, concept, min_score: float = 0.65, min_confidence: float = 0.45) -> bool:
|
def prereqs_satisfied(scores: dict[str, float], concept, min_score: float = 0.65) -> bool:
|
||||||
for pid in concept.prerequisites:
|
for pid in concept.prerequisites:
|
||||||
rec = get_record(state, pid, concept.masteryDimension)
|
if scores.get(pid, 0.0) < min_score:
|
||||||
if rec is None or rec.score < min_score or rec.confidence < min_confidence:
|
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def concept_status(state: LearnerState, concept, min_score: float = 0.65, min_confidence: float = 0.45) -> str:
|
def concept_status(scores: dict[str, float], concept, min_score: float = 0.65) -> str:
|
||||||
rec = get_record(state, concept.id, concept.masteryDimension)
|
score = scores.get(concept.id, 0.0)
|
||||||
if rec and rec.score >= min_score and rec.confidence >= min_confidence:
|
if score >= min_score:
|
||||||
return "mastered"
|
return "mastered"
|
||||||
if prereqs_satisfied(state, concept, min_score, min_confidence):
|
if prereqs_satisfied(scores, concept, min_score):
|
||||||
return "active" if rec else "available"
|
return "active" if score > 0 else "available"
|
||||||
return "locked"
|
return "locked"
|
||||||
|
|
||||||
def recommend_next(state: LearnerState, pack: PackData) -> list[dict]:
|
def build_graph_frames(state: LearnerState, pack: PackData):
|
||||||
cards = []
|
concepts = {c.id: c for c in pack.concepts}
|
||||||
for concept in pack.concepts:
|
layout = stable_layout(pack)
|
||||||
status = concept_status(state, concept)
|
scores = {c.id: 0.0 for c in pack.concepts}
|
||||||
rec = get_record(state, concept.id, concept.masteryDimension)
|
|
||||||
if status in {"available", "active"}:
|
|
||||||
cards.append({
|
|
||||||
"id": concept.id,
|
|
||||||
"title": f"Work on {concept.title}",
|
|
||||||
"minutes": 15 if status == "available" else 10,
|
|
||||||
"reason": "Prerequisites are satisfied, so this is the best next unlock." if status == "available" else "You have started this concept, but mastery is not yet secure.",
|
|
||||||
"why": [
|
|
||||||
"Prerequisite check passed",
|
|
||||||
f"Current score: {rec.score:.2f}" if rec else "No evidence recorded yet",
|
|
||||||
f"Current confidence: {rec.confidence:.2f}" if rec else "Confidence starts after your first exercise",
|
|
||||||
],
|
|
||||||
"reward": concept.exerciseReward or f"{concept.title} progress recorded",
|
|
||||||
"conceptId": concept.id,
|
|
||||||
"scoreHint": 0.82 if status == "available" else 0.76,
|
|
||||||
"confidenceHint": 0.72 if status == "available" else 0.55,
|
|
||||||
})
|
|
||||||
return cards[:4]
|
|
||||||
|
|
||||||
def build_animation_frames(state: LearnerState):
|
|
||||||
concepts = sorted({ev.concept_id for ev in state.history} | {r.concept_id for r in state.records})
|
|
||||||
frames = []
|
frames = []
|
||||||
running = {c: 0.0 for c in concepts}
|
history = sorted(state.history, key=lambda x: x.timestamp)
|
||||||
for idx, ev in enumerate(sorted(state.history, key=lambda x: x.timestamp)):
|
static_edges = [{"source": pre, "target": c.id, "kind": "prerequisite"} for c in pack.concepts for pre in c.prerequisites]
|
||||||
running[ev.concept_id] = ev.score
|
static_cross = [{
|
||||||
|
"source": c.id,
|
||||||
|
"target_pack_id": link.target_pack_id,
|
||||||
|
"target_concept_id": link.target_concept_id,
|
||||||
|
"relationship": link.relationship,
|
||||||
|
"kind": "cross_pack"
|
||||||
|
} for c in pack.concepts for link in c.cross_pack_links]
|
||||||
|
for idx, ev in enumerate(history):
|
||||||
|
if ev.concept_id in scores:
|
||||||
|
scores[ev.concept_id] = ev.score
|
||||||
|
nodes = []
|
||||||
|
for cid, concept in concepts.items():
|
||||||
|
score = scores.get(cid, 0.0)
|
||||||
|
status = concept_status(scores, concept)
|
||||||
|
pos = layout[cid]
|
||||||
|
nodes.append({
|
||||||
|
"id": cid,
|
||||||
|
"title": concept.title,
|
||||||
|
"score": score,
|
||||||
|
"status": status,
|
||||||
|
"size": 20 + int(score * 30),
|
||||||
|
"x": pos["x"],
|
||||||
|
"y": pos["y"],
|
||||||
|
"layout_source": pos["source"],
|
||||||
|
})
|
||||||
frames.append({
|
frames.append({
|
||||||
"index": idx,
|
"index": idx,
|
||||||
"timestamp": ev.timestamp,
|
"timestamp": ev.timestamp,
|
||||||
"event_kind": ev.kind,
|
"event_kind": ev.kind,
|
||||||
"concept_id": ev.concept_id,
|
"focus_concept_id": ev.concept_id,
|
||||||
"scores": dict(running),
|
"nodes": nodes,
|
||||||
|
"edges": static_edges,
|
||||||
|
"cross_pack_links": static_cross,
|
||||||
})
|
})
|
||||||
if not frames:
|
if not frames:
|
||||||
frames.append({"index": 0, "timestamp": "", "event_kind": "empty", "concept_id": "", "scores": dict(running)})
|
nodes = []
|
||||||
|
for c in pack.concepts:
|
||||||
|
pos = layout[c.id]
|
||||||
|
nodes.append({
|
||||||
|
"id": c.id,
|
||||||
|
"title": c.title,
|
||||||
|
"score": 0.0,
|
||||||
|
"status": "available" if not c.prerequisites else "locked",
|
||||||
|
"size": 20,
|
||||||
|
"x": pos["x"],
|
||||||
|
"y": pos["y"],
|
||||||
|
"layout_source": pos["source"],
|
||||||
|
})
|
||||||
|
frames.append({"index": 0, "timestamp": "", "event_kind": "empty", "focus_concept_id": "", "nodes": nodes, "edges": static_edges, "cross_pack_links": static_cross})
|
||||||
return frames
|
return frames
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ def frame_to_svg(frame: dict, width: int = 960, height: int = 560) -> str:
|
||||||
dst = next((n for n in frame["nodes"] if n["id"] == edge["target"]), None)
|
dst = next((n for n in frame["nodes"] if n["id"] == edge["target"]), None)
|
||||||
if src and dst:
|
if src and dst:
|
||||||
parts.append(f'<line x1="{src["x"]}" y1="{src["y"]}" x2="{dst["x"]}" y2="{dst["y"]}" stroke="#b8c2cf" stroke-width="3"/>')
|
parts.append(f'<line x1="{src["x"]}" y1="{src["y"]}" x2="{dst["x"]}" y2="{dst["y"]}" stroke="#b8c2cf" stroke-width="3"/>')
|
||||||
|
for edge in frame.get("cross_pack_links", []):
|
||||||
|
src = next((n for n in frame["nodes"] if n["id"] == edge["source"]), None)
|
||||||
|
if src:
|
||||||
|
parts.append(f'<line x1="{src["x"]}" y1="{src["y"]}" x2="{src["x"]+120}" y2="{src["y"]-60}" stroke="#cc4bc2" stroke-width="2" stroke-dasharray="8 6"/>')
|
||||||
for node in frame.get("nodes", []):
|
for node in frame.get("nodes", []):
|
||||||
fill = color_for_status(node["status"])
|
fill = color_for_status(node["status"])
|
||||||
parts.append(f'<circle cx="{node["x"]}" cy="{node["y"]}" r="{node["size"]}" fill="{fill}" />')
|
parts.append(f'<circle cx="{node["x"]}" cy="{node["y"]}" r="{node["size"]}" fill="{fill}" />')
|
||||||
|
|
@ -33,12 +37,3 @@ def export_svg_frames(payload_path: str, out_dir: str):
|
||||||
for frame in payload.get("frames", []):
|
for frame in payload.get("frames", []):
|
||||||
svg = frame_to_svg(frame)
|
svg = frame_to_svg(frame)
|
||||||
(out / f'frame_{frame["index"]:04d}.svg').write_text(svg, encoding="utf-8")
|
(out / f'frame_{frame["index"]:04d}.svg').write_text(svg, encoding="utf-8")
|
||||||
|
|
||||||
def main():
|
|
||||||
import argparse
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("payload_json")
|
|
||||||
parser.add_argument("out_dir")
|
|
||||||
args = parser.parse_args()
|
|
||||||
export_svg_frames(args.payload_json, args.out_dir)
|
|
||||||
print(f"Exported SVG frames to {args.out_dir}")
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,5 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
EvidenceKind = Literal["checkpoint", "project", "exercise", "review"]
|
|
||||||
PolicyLane = Literal["personal", "community"]
|
|
||||||
|
|
||||||
class TokenPair(BaseModel):
|
class TokenPair(BaseModel):
|
||||||
access_token: str
|
access_token: str
|
||||||
|
|
@ -12,58 +8,22 @@ 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):
|
class GraphPosition(BaseModel):
|
||||||
profile_name: str
|
x: float
|
||||||
default_personal_lane_enabled: bool = True
|
y: float
|
||||||
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):
|
class CrossPackLink(BaseModel):
|
||||||
supports_pack_listing: bool = True
|
source_concept_id: str
|
||||||
supports_pack_write_personal: bool = True
|
target_pack_id: str
|
||||||
supports_pack_submit_community: bool = True
|
target_concept_id: str
|
||||||
supports_recommendations: bool = True
|
relationship: str = "related"
|
||||||
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
|
|
||||||
supports_learner_runs: bool = True
|
|
||||||
supports_learning_animation: bool = True
|
|
||||||
|
|
||||||
class PackConcept(BaseModel):
|
class PackConcept(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
|
|
@ -71,13 +31,8 @@ class PackConcept(BaseModel):
|
||||||
prerequisites: list[str] = Field(default_factory=list)
|
prerequisites: list[str] = Field(default_factory=list)
|
||||||
masteryDimension: str = "mastery"
|
masteryDimension: str = "mastery"
|
||||||
exerciseReward: str = ""
|
exerciseReward: str = ""
|
||||||
|
position: GraphPosition | None = None
|
||||||
class PackCompliance(BaseModel):
|
cross_pack_links: list[CrossPackLink] = Field(default_factory=list)
|
||||||
sources: int = 0
|
|
||||||
attributionRequired: bool = False
|
|
||||||
shareAlikeRequired: bool = False
|
|
||||||
noncommercialOnly: bool = False
|
|
||||||
flags: list[str] = Field(default_factory=list)
|
|
||||||
|
|
||||||
class PackData(BaseModel):
|
class PackData(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
|
|
@ -86,32 +41,12 @@ class PackData(BaseModel):
|
||||||
level: str = "novice-friendly"
|
level: str = "novice-friendly"
|
||||||
concepts: list[PackConcept] = Field(default_factory=list)
|
concepts: list[PackConcept] = Field(default_factory=list)
|
||||||
onboarding: dict = Field(default_factory=dict)
|
onboarding: dict = Field(default_factory=dict)
|
||||||
compliance: PackCompliance = Field(default_factory=PackCompliance)
|
compliance: dict = Field(default_factory=dict)
|
||||||
|
|
||||||
class CreatePackRequest(BaseModel):
|
|
||||||
pack: PackData
|
|
||||||
policy_lane: PolicyLane = "personal"
|
|
||||||
is_published: bool = False
|
|
||||||
change_summary: str = ""
|
|
||||||
|
|
||||||
class CreateLearnerRequest(BaseModel):
|
class CreateLearnerRequest(BaseModel):
|
||||||
learner_id: str
|
learner_id: str
|
||||||
display_name: str = ""
|
display_name: str = ""
|
||||||
|
|
||||||
class LearnerRunCreateRequest(BaseModel):
|
|
||||||
learner_id: str
|
|
||||||
pack_id: str
|
|
||||||
title: str = ""
|
|
||||||
actor_kind: str = "human"
|
|
||||||
|
|
||||||
class WorkflowEventCreateRequest(BaseModel):
|
|
||||||
run_id: int
|
|
||||||
learner_id: str
|
|
||||||
event_type: str
|
|
||||||
concept_id: str = ""
|
|
||||||
timestamp: str
|
|
||||||
detail: dict = Field(default_factory=dict)
|
|
||||||
|
|
||||||
class MasteryRecord(BaseModel):
|
class MasteryRecord(BaseModel):
|
||||||
concept_id: str
|
concept_id: str
|
||||||
dimension: str
|
dimension: str
|
||||||
|
|
@ -126,7 +61,7 @@ class EvidenceEvent(BaseModel):
|
||||||
score: float
|
score: float
|
||||||
confidence_hint: float = 0.5
|
confidence_hint: float = 0.5
|
||||||
timestamp: str
|
timestamp: str
|
||||||
kind: EvidenceKind = "exercise"
|
kind: str = "exercise"
|
||||||
source_id: str = ""
|
source_id: str = ""
|
||||||
|
|
||||||
class LearnerState(BaseModel):
|
class LearnerState(BaseModel):
|
||||||
|
|
@ -134,25 +69,9 @@ 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 EvaluatorSubmission(BaseModel):
|
class MediaRenderRequest(BaseModel):
|
||||||
pack_id: str
|
|
||||||
concept_id: str
|
|
||||||
submitted_text: str
|
|
||||||
kind: str = "checkpoint"
|
|
||||||
|
|
||||||
class EvaluatorJobStatus(BaseModel):
|
|
||||||
job_id: int
|
|
||||||
status: str
|
|
||||||
result_score: float | None = None
|
|
||||||
result_confidence_hint: float | None = None
|
|
||||||
result_notes: str = ""
|
|
||||||
|
|
||||||
class AgentLearnerPlanRequest(BaseModel):
|
|
||||||
learner_id: str
|
learner_id: str
|
||||||
pack_id: str
|
pack_id: str
|
||||||
|
format: str = "gif"
|
||||||
class AgentLearnerPlanResponse(BaseModel):
|
fps: int = 2
|
||||||
learner_id: str
|
theme: str = "default"
|
||||||
pack_id: str
|
|
||||||
next_cards: list[dict] = Field(default_factory=list)
|
|
||||||
suggested_actions: list[str] = Field(default_factory=list)
|
|
||||||
|
|
|
||||||
|
|
@ -10,49 +10,6 @@ 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 LearnerRunORM(Base):
|
|
||||||
__tablename__ = "learner_runs"
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
||||||
learner_id: Mapped[str] = mapped_column(String(100), index=True)
|
|
||||||
pack_id: Mapped[str] = mapped_column(String(100), index=True)
|
|
||||||
actor_kind: Mapped[str] = mapped_column(String(50), default="human")
|
|
||||||
actor_name: Mapped[str] = mapped_column(String(120), default="")
|
|
||||||
title: Mapped[str] = mapped_column(String(255), default="")
|
|
||||||
status: Mapped[str] = mapped_column(String(50), default="running")
|
|
||||||
started_at: Mapped[str] = mapped_column(String(100), default="")
|
|
||||||
ended_at: Mapped[str] = mapped_column(String(100), default="")
|
|
||||||
|
|
||||||
class WorkflowEventORM(Base):
|
|
||||||
__tablename__ = "workflow_events"
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
||||||
run_id: Mapped[int] = mapped_column(ForeignKey("learner_runs.id"), index=True)
|
|
||||||
learner_id: Mapped[str] = mapped_column(String(100), index=True)
|
|
||||||
event_type: Mapped[str] = mapped_column(String(100), index=True)
|
|
||||||
concept_id: Mapped[str] = mapped_column(String(100), default="")
|
|
||||||
timestamp: Mapped[str] = mapped_column(String(100), default="")
|
|
||||||
detail_json: Mapped[str] = mapped_column(Text, 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)
|
||||||
|
|
@ -69,10 +26,6 @@ class PackORM(Base):
|
||||||
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)
|
||||||
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)
|
is_published: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
class LearnerORM(Base):
|
class LearnerORM(Base):
|
||||||
|
|
@ -103,16 +56,3 @@ class EvidenceEventORM(Base):
|
||||||
timestamp: Mapped[str] = mapped_column(String(100), default="")
|
timestamp: Mapped[str] = mapped_column(String(100), default="")
|
||||||
kind: Mapped[str] = mapped_column(String(50), default="exercise")
|
kind: Mapped[str] = mapped_column(String(50), default="exercise")
|
||||||
source_id: Mapped[str] = mapped_column(String(255), default="")
|
source_id: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
|
||||||
class EvaluatorJobORM(Base):
|
|
||||||
__tablename__ = "evaluator_jobs"
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
||||||
learner_id: Mapped[str] = mapped_column(ForeignKey("learners.id"), index=True)
|
|
||||||
pack_id: Mapped[str] = mapped_column(ForeignKey("packs.id"), index=True)
|
|
||||||
concept_id: Mapped[str] = mapped_column(String(100), index=True)
|
|
||||||
submitted_text: Mapped[str] = mapped_column(Text, default="")
|
|
||||||
status: Mapped[str] = mapped_column(String(50), default="queued")
|
|
||||||
result_score: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
||||||
result_confidence_hint: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
||||||
result_notes: Mapped[str] = mapped_column(Text, default="")
|
|
||||||
trace_json: Mapped[str] = mapped_column(Text, default="{}")
|
|
||||||
|
|
|
||||||
|
|
@ -23,3 +23,14 @@ def make_render_bundle(payload_json: str, out_dir: str, fps: int = 2, fmt: str =
|
||||||
f"ffmpeg -framerate {fps} -pattern_type glob -i '{frames_dir}/*.svg' '{out / ('animation.' + fmt)}'",
|
f"ffmpeg -framerate {fps} -pattern_type glob -i '{frames_dir}/*.svg' '{out / ('animation.' + fmt)}'",
|
||||||
])
|
])
|
||||||
(out / "render.sh").write_text(script, encoding="utf-8")
|
(out / "render.sh").write_text(script, encoding="utf-8")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
import argparse
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("payload_json")
|
||||||
|
parser.add_argument("out_dir")
|
||||||
|
parser.add_argument("--fps", type=int, default=2)
|
||||||
|
parser.add_argument("--format", default="gif", choices=["gif", "mp4"])
|
||||||
|
args = parser.parse_args()
|
||||||
|
make_render_bundle(args.payload_json, args.out_dir, fps=args.fps, fmt=args.format)
|
||||||
|
print(f"Render bundle written to {args.out_dir}")
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,10 @@
|
||||||
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, ServiceAccountORM, AgentAuditLogORM, LearnerRunORM, WorkflowEventORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM
|
from .orm import UserORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM
|
||||||
from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent, DeploymentPolicyProfile
|
from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent
|
||||||
from .auth import verify_password, hash_password
|
from .auth import verify_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:
|
||||||
|
|
@ -38,153 +20,6 @@ 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 create_learner_run(learner_id: str, pack_id: str, actor_kind: str, actor_name: str, title: str):
|
|
||||||
with SessionLocal() as db:
|
|
||||||
row = LearnerRunORM(
|
|
||||||
learner_id=learner_id,
|
|
||||||
pack_id=pack_id,
|
|
||||||
actor_kind=actor_kind,
|
|
||||||
actor_name=actor_name,
|
|
||||||
title=title,
|
|
||||||
status="running",
|
|
||||||
started_at=now_iso(),
|
|
||||||
ended_at="",
|
|
||||||
)
|
|
||||||
db.add(row)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(row)
|
|
||||||
return row.id
|
|
||||||
|
|
||||||
def end_learner_run(run_id: int):
|
|
||||||
with SessionLocal() as db:
|
|
||||||
row = db.get(LearnerRunORM, run_id)
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
row.status = "completed"
|
|
||||||
row.ended_at = now_iso()
|
|
||||||
db.commit()
|
|
||||||
return row
|
|
||||||
|
|
||||||
def list_learner_runs(learner_id: str):
|
|
||||||
with SessionLocal() as db:
|
|
||||||
rows = db.execute(select(LearnerRunORM).where(LearnerRunORM.learner_id == learner_id).order_by(LearnerRunORM.id.desc())).scalars().all()
|
|
||||||
return [{
|
|
||||||
"run_id": r.id,
|
|
||||||
"learner_id": r.learner_id,
|
|
||||||
"pack_id": r.pack_id,
|
|
||||||
"actor_kind": r.actor_kind,
|
|
||||||
"actor_name": r.actor_name,
|
|
||||||
"title": r.title,
|
|
||||||
"status": r.status,
|
|
||||||
"started_at": r.started_at,
|
|
||||||
"ended_at": r.ended_at,
|
|
||||||
} for r in rows]
|
|
||||||
|
|
||||||
def add_workflow_event(run_id: int, learner_id: str, event_type: str, concept_id: str, timestamp: str, detail: dict):
|
|
||||||
with SessionLocal() as db:
|
|
||||||
row = WorkflowEventORM(
|
|
||||||
run_id=run_id,
|
|
||||||
learner_id=learner_id,
|
|
||||||
event_type=event_type,
|
|
||||||
concept_id=concept_id,
|
|
||||||
timestamp=timestamp,
|
|
||||||
detail_json=json.dumps(detail),
|
|
||||||
)
|
|
||||||
db.add(row)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(row)
|
|
||||||
return row.id
|
|
||||||
|
|
||||||
def list_workflow_events(run_id: int):
|
|
||||||
with SessionLocal() as db:
|
|
||||||
rows = db.execute(select(WorkflowEventORM).where(WorkflowEventORM.run_id == run_id).order_by(WorkflowEventORM.id)).scalars().all()
|
|
||||||
return [{
|
|
||||||
"id": r.id,
|
|
||||||
"run_id": r.run_id,
|
|
||||||
"learner_id": r.learner_id,
|
|
||||||
"event_type": r.event_type,
|
|
||||||
"concept_id": r.concept_id,
|
|
||||||
"timestamp": r.timestamp,
|
|
||||||
"detail": json.loads(r.detail_json or "{}"),
|
|
||||||
} 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))
|
||||||
|
|
@ -225,9 +60,7 @@ def get_pack_row(pack_id: str):
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
return db.get(PackORM, pack_id)
|
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 = ""):
|
def upsert_pack(pack: PackData, submitted_by_user_id: int, policy_lane: str = "personal", is_published: bool = False):
|
||||||
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())
|
||||||
|
|
@ -240,10 +73,6 @@ def upsert_pack(pack: PackData, submitted_by_user_id: int, policy_lane: str = "p
|
||||||
subtitle=pack.subtitle,
|
subtitle=pack.subtitle,
|
||||||
level=pack.level,
|
level=pack.level,
|
||||||
data_json=payload,
|
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,
|
is_published=is_published if policy_lane == "personal" else False,
|
||||||
)
|
)
|
||||||
db.add(row)
|
db.add(row)
|
||||||
|
|
@ -254,9 +83,6 @@ def upsert_pack(pack: PackData, submitted_by_user_id: int, policy_lane: str = "p
|
||||||
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":
|
if policy_lane == "personal":
|
||||||
row.is_published = is_published
|
row.is_published = is_published
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -292,32 +118,3 @@ def save_learner_state(state: LearnerState):
|
||||||
db.add(EvidenceEventORM(learner_id=state.learner_id, concept_id=h.concept_id, dimension=h.dimension, score=h.score, confidence_hint=h.confidence_hint, timestamp=h.timestamp, kind=h.kind, source_id=h.source_id))
|
db.add(EvidenceEventORM(learner_id=state.learner_id, concept_id=h.concept_id, dimension=h.dimension, score=h.score, confidence_hint=h.confidence_hint, timestamp=h.timestamp, kind=h.kind, source_id=h.source_id))
|
||||||
db.commit()
|
db.commit()
|
||||||
return state
|
return state
|
||||||
|
|
||||||
def create_evaluator_job(learner_id: str, pack_id: str, concept_id: str, submitted_text: str):
|
|
||||||
with SessionLocal() as db:
|
|
||||||
job = EvaluatorJobORM(learner_id=learner_id, pack_id=pack_id, concept_id=concept_id, submitted_text=submitted_text, status="queued", trace_json=json.dumps({"notes": ["Job queued"]}))
|
|
||||||
db.add(job)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(job)
|
|
||||||
return job.id
|
|
||||||
|
|
||||||
def list_evaluator_jobs_for_learner(learner_id: str):
|
|
||||||
with SessionLocal() as db:
|
|
||||||
return db.execute(select(EvaluatorJobORM).where(EvaluatorJobORM.learner_id == learner_id).order_by(EvaluatorJobORM.id.desc())).scalars().all()
|
|
||||||
|
|
||||||
def get_evaluator_job(job_id: int):
|
|
||||||
with SessionLocal() as db:
|
|
||||||
return db.get(EvaluatorJobORM, job_id)
|
|
||||||
|
|
||||||
def update_evaluator_job(job_id: int, status: str, score: float | None = None, confidence_hint: float | None = None, notes: str = "", trace: dict | None = None):
|
|
||||||
with SessionLocal() as db:
|
|
||||||
job = db.get(EvaluatorJobORM, job_id)
|
|
||||||
if job is None:
|
|
||||||
return
|
|
||||||
job.status = status
|
|
||||||
job.result_score = score
|
|
||||||
job.result_confidence_hint = confidence_hint
|
|
||||||
job.result_notes = notes
|
|
||||||
if trace is not None:
|
|
||||||
job.trace_json = json.dumps(trace)
|
|
||||||
db.commit()
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from .db import Base, engine, SessionLocal
|
||||||
from .orm import UserORM
|
from .orm import UserORM
|
||||||
from .auth import hash_password
|
from .auth import hash_password
|
||||||
from .repository import upsert_pack, create_learner
|
from .repository import upsert_pack, create_learner
|
||||||
from .models import PackData, PackConcept, PackCompliance
|
from .models import PackData, PackConcept, GraphPosition, CrossPackLink
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
@ -20,14 +20,15 @@ def main():
|
||||||
subtitle="Personal pack example.",
|
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="intro", title="Intro", prerequisites=[], position=GraphPosition(x=150, y=120)),
|
||||||
PackConcept(id="second", title="Second concept", prerequisites=["intro"], masteryDimension="mastery", exerciseReward="Second marker"),
|
PackConcept(id="second", title="Second concept", prerequisites=["intro"], position=GraphPosition(x=420, y=120)),
|
||||||
|
PackConcept(id="third", title="Third concept", prerequisites=["second"], position=GraphPosition(x=700, y=120), cross_pack_links=[CrossPackLink(source_concept_id="third", target_pack_id="advanced-pack", target_concept_id="adv-1", relationship="next_pack")]),
|
||||||
|
PackConcept(id="branch", title="Branch concept", prerequisites=["intro"], position=GraphPosition(x=420, y=320)),
|
||||||
],
|
],
|
||||||
onboarding={"headline":"Start privately","body":"Personal pack lane.","checklist":["Create pack","Use pack"]},
|
onboarding={"headline":"Start privately"},
|
||||||
compliance=PackCompliance()
|
compliance={}
|
||||||
),
|
),
|
||||||
submitted_by_user_id=1,
|
submitted_by_user_id=1,
|
||||||
policy_lane="personal",
|
policy_lane="personal",
|
||||||
is_published=True,
|
is_published=True,
|
||||||
change_summary="Initial personal pack"
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,6 @@ from pathlib import Path
|
||||||
def test_scaffold_files_exist():
|
def test_scaffold_files_exist():
|
||||||
assert Path("src/didactopus/api.py").exists()
|
assert Path("src/didactopus/api.py").exists()
|
||||||
assert Path("src/didactopus/repository.py").exists()
|
assert Path("src/didactopus/repository.py").exists()
|
||||||
|
assert Path("src/didactopus/export_svg.py").exists()
|
||||||
|
assert Path("src/didactopus/render_bundle.py").exists()
|
||||||
assert Path("webui/src/App.jsx").exists()
|
assert Path("webui/src/App.jsx").exists()
|
||||||
assert Path("webui/src/api.js").exists()
|
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,8 @@
|
||||||
<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 Live Learner Prototype</title>
|
<title>Didactopus Media Rendering</title>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body><div id="root"></div></body>
|
||||||
<div id="root"></div>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,9 @@
|
||||||
{
|
{
|
||||||
"name": "didactopus-live-learner-ui",
|
"name": "didactopus-media-rendering-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,236 +1,145 @@
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { domains } from "./domainData";
|
import { login, refresh, fetchPacks, fetchLearnerState, putLearnerState, fetchGraphAnimation, createRenderBundle } from "./api";
|
||||||
import {
|
import { loadAuth, saveAuth, clearAuth } from "./authStore";
|
||||||
applyEvidence,
|
|
||||||
buildMasteryMap,
|
|
||||||
claimReadiness,
|
|
||||||
milestoneMessages,
|
|
||||||
progressPercent,
|
|
||||||
recommendNext,
|
|
||||||
starterLearnerState
|
|
||||||
} from "./engine";
|
|
||||||
|
|
||||||
function DomainCard({ domain, selected, onSelect }) {
|
function LoginView({ onAuth }) {
|
||||||
return (
|
const [username, setUsername] = useState("wesley");
|
||||||
<button className={`domain-card ${selected ? "selected" : ""}`} onClick={() => onSelect(domain.id)}>
|
const [password, setPassword] = useState("demo-pass");
|
||||||
<div className="domain-title">{domain.title}</div>
|
const [error, setError] = useState("");
|
||||||
<div className="domain-subtitle">{domain.subtitle}</div>
|
async function doLogin() {
|
||||||
<div className="domain-meta">
|
try {
|
||||||
<span>{domain.level}</span>
|
const result = await login(username, password);
|
||||||
<span>{domain.concepts.length} concepts</span>
|
saveAuth(result);
|
||||||
</div>
|
onAuth(result);
|
||||||
</button>
|
} catch { setError("Login failed"); }
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function OnboardingPanel({ domain, learnerName }) {
|
|
||||||
return (
|
return (
|
||||||
<section className="card">
|
<div className="page narrow-page">
|
||||||
<h2>First-session onboarding</h2>
|
<section className="card narrow">
|
||||||
<h3>{domain.onboarding.headline}</h3>
|
<h1>Didactopus login</h1>
|
||||||
<p>{domain.onboarding.body}</p>
|
<label>Username<input value={username} onChange={(e) => setUsername(e.target.value)} /></label>
|
||||||
<p className="muted">Learner: {learnerName || "Unnamed learner"}</p>
|
<label>Password<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} /></label>
|
||||||
<ul>
|
<button className="primary" onClick={doLogin}>Login</button>
|
||||||
{domain.onboarding.checklist.map((item, idx) => <li key={idx}>{item}</li>)}
|
{error ? <div className="error">{error}</div> : null}
|
||||||
</ul>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NextStepCard({ step, onSimulate }) {
|
|
||||||
return (
|
|
||||||
<div className="step-card">
|
|
||||||
<div className="step-header">
|
|
||||||
<div>
|
|
||||||
<h3>{step.title}</h3>
|
|
||||||
<div className="muted">{step.minutes} minutes</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="reward-pill">{step.reward}</div>
|
|
||||||
</div>
|
|
||||||
<p>{step.reason}</p>
|
|
||||||
<details>
|
|
||||||
<summary>Why this is recommended</summary>
|
|
||||||
<ul>
|
|
||||||
{step.why.map((item, idx) => <li key={idx}>{item}</li>)}
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
<button className="primary" onClick={() => onSimulate(step)}>Simulate completing this step</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MasteryMap({ nodes }) {
|
|
||||||
return (
|
|
||||||
<section className="card">
|
|
||||||
<h2>Visible mastery map</h2>
|
|
||||||
<div className="map-grid">
|
|
||||||
{nodes.map((node) => (
|
|
||||||
<div key={node.id} className={`map-node ${node.status}`}>
|
|
||||||
<div className="node-label">{node.label}</div>
|
|
||||||
<div className="node-status">{node.status}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProgressPanel({ progress, readiness }) {
|
|
||||||
return (
|
|
||||||
<section className="card">
|
|
||||||
<h2>Progress</h2>
|
|
||||||
<div className="progress-wrap">
|
|
||||||
<div className="progress-label">Mastery progress</div>
|
|
||||||
<div className="progress-bar"><div className="progress-fill" style={{ width: `${progress}%` }} /></div>
|
|
||||||
<div className="muted">{progress}%</div>
|
|
||||||
</div>
|
|
||||||
<div className={`readiness-box ${readiness.ready ? "ready" : ""}`}>
|
|
||||||
<strong>{readiness.ready ? "Usable expertise threshold met" : "Still building toward usable expertise"}</strong>
|
|
||||||
<div className="muted">Mastered concepts: {readiness.mastered}</div>
|
|
||||||
<div className="muted">Average score: {readiness.avgScore.toFixed(2)}</div>
|
|
||||||
<div className="muted">Average confidence: {readiness.avgConfidence.toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MilestonePanel({ milestones, lastReward }) {
|
|
||||||
return (
|
|
||||||
<section className="card">
|
|
||||||
<h2>Milestones and rewards</h2>
|
|
||||||
{lastReward ? <div className="reward-banner">{lastReward}</div> : null}
|
|
||||||
<ul>
|
|
||||||
{milestones.map((m, idx) => <li key={idx}>{m}</li>)}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CompliancePanel({ compliance }) {
|
|
||||||
return (
|
|
||||||
<section className="card">
|
|
||||||
<h2>Source attribution and compliance</h2>
|
|
||||||
<div className="compliance-grid">
|
|
||||||
<div><strong>Sources</strong><br />{compliance.sources}</div>
|
|
||||||
<div><strong>Attribution</strong><br />{compliance.attributionRequired ? "required" : "not required"}</div>
|
|
||||||
<div><strong>Share-alike</strong><br />{compliance.shareAlikeRequired ? "yes" : "no"}</div>
|
|
||||||
<div><strong>Noncommercial</strong><br />{compliance.noncommercialOnly ? "yes" : "no"}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flag-row">
|
|
||||||
{compliance.flags.length ? compliance.flags.map((f) => <span className="flag" key={f}>{f}</span>) : <span className="muted">No extra flags</span>}
|
|
||||||
</div>
|
|
||||||
<p className="muted">
|
|
||||||
Provenance-sensitive packs should remain inspectable so that learners and maintainers do not have to guess at reuse constraints.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EvidenceLog({ history }) {
|
|
||||||
return (
|
|
||||||
<section className="card">
|
|
||||||
<h2>Evidence log</h2>
|
|
||||||
{history.length === 0 ? (
|
|
||||||
<div className="muted">No evidence recorded yet.</div>
|
|
||||||
) : (
|
|
||||||
<ul>
|
|
||||||
{history.slice().reverse().map((item, idx) => (
|
|
||||||
<li key={idx}>
|
|
||||||
{item.concept_id} · score {item.score.toFixed(2)} · confidence hint {item.confidence_hint.toFixed(2)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [learnerName, setLearnerName] = useState("Wesley");
|
const [auth, setAuth] = useState(loadAuth());
|
||||||
const [selectedDomainId, setSelectedDomainId] = useState(domains[0].id);
|
const [packs, setPacks] = useState([]);
|
||||||
const [domainStates, setDomainStates] = useState(() =>
|
const [learnerId] = useState("wesley-learner");
|
||||||
Object.fromEntries(domains.map((d) => [d.id, starterLearnerState(`learner-${d.id}`)]))
|
const [packId, setPackId] = useState("");
|
||||||
);
|
const [bundle, setBundle] = useState(null);
|
||||||
const [lastReward, setLastReward] = useState("");
|
const [format, setFormat] = useState("gif");
|
||||||
|
const [fps, setFps] = useState(2);
|
||||||
|
const [message, setMessage] = useState("");
|
||||||
|
|
||||||
const domain = useMemo(() => domains.find((d) => d.id === selectedDomainId) || domains[0], [selectedDomainId]);
|
async function refreshAuthToken() {
|
||||||
const learnerState = domainStates[selectedDomainId];
|
if (!auth?.refresh_token) return null;
|
||||||
|
try {
|
||||||
const masteryMap = useMemo(() => buildMasteryMap(learnerState, domain), [learnerState, domain]);
|
const result = await refresh(auth.refresh_token);
|
||||||
const progress = useMemo(() => progressPercent(learnerState, domain), [learnerState, domain]);
|
saveAuth(result);
|
||||||
const recs = useMemo(() => recommendNext(learnerState, domain), [learnerState, domain]);
|
setAuth(result);
|
||||||
const milestones = useMemo(() => milestoneMessages(learnerState, domain), [learnerState, domain]);
|
return result;
|
||||||
const readiness = useMemo(() => claimReadiness(learnerState, domain), [learnerState, domain]);
|
} catch {
|
||||||
|
clearAuth();
|
||||||
function simulateStep(step) {
|
setAuth(null);
|
||||||
const timestamp = new Date().toISOString();
|
return null;
|
||||||
const updated = applyEvidence(learnerState, {
|
}
|
||||||
concept_id: step.conceptId,
|
|
||||||
dimension: "mastery",
|
|
||||||
score: step.scoreHint,
|
|
||||||
confidence_hint: step.confidenceHint,
|
|
||||||
timestamp,
|
|
||||||
kind: "checkpoint",
|
|
||||||
source_id: `ui-${step.id}`
|
|
||||||
});
|
|
||||||
setDomainStates((prev) => ({ ...prev, [selectedDomainId]: updated }));
|
|
||||||
setLastReward(step.reward);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetDomain() {
|
async function guarded(fn) {
|
||||||
setDomainStates((prev) => ({ ...prev, [selectedDomainId]: starterLearnerState(`learner-${selectedDomainId}`) }));
|
try { return await fn(auth.access_token); }
|
||||||
setLastReward("");
|
catch {
|
||||||
|
const next = await refreshAuthToken();
|
||||||
|
if (!next) throw new Error("auth failed");
|
||||||
|
return await fn(next.access_token);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!auth) return;
|
||||||
|
async function load() {
|
||||||
|
const p = await guarded((token) => fetchPacks(token));
|
||||||
|
setPacks(p);
|
||||||
|
setPackId(p[0]?.id || "");
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}, [auth]);
|
||||||
|
|
||||||
|
async function generateDemo() {
|
||||||
|
let state = await guarded((token) => fetchLearnerState(token, learnerId));
|
||||||
|
const base = Date.now();
|
||||||
|
const events = [
|
||||||
|
["intro", 0.30, "exercise", 0],
|
||||||
|
["intro", 0.78, "review", 1000],
|
||||||
|
["second", 0.42, "exercise", 2000],
|
||||||
|
["second", 0.72, "review", 3000],
|
||||||
|
["third", 0.25, "exercise", 4000],
|
||||||
|
["branch", 0.60, "exercise", 5000],
|
||||||
|
];
|
||||||
|
const latest = {}
|
||||||
|
for (const [cid, score, kind, offset] of events) {
|
||||||
|
const ts = new Date(base + offset).toISOString();
|
||||||
|
state.history.push({ concept_id: cid, dimension: "mastery", score, confidence_hint: 0.6, timestamp: ts, kind, source_id: `demo-${cid}-${offset}` });
|
||||||
|
latest[cid] = { concept_id: cid, dimension: "mastery", score, confidence: Math.min(0.9, score), evidence_count: (latest[cid]?.evidence_count || 0) + 1, last_updated: ts };
|
||||||
|
}
|
||||||
|
state.records = Object.values(latest);
|
||||||
|
await guarded((token) => putLearnerState(token, learnerId, state));
|
||||||
|
setMessage("Demo state generated.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderNow() {
|
||||||
|
const result = await guarded((token) => createRenderBundle(token, learnerId, packId, { learner_id: learnerId, pack_id: packId, format, fps, theme: "default" }));
|
||||||
|
setBundle(result);
|
||||||
|
setMessage("Render bundle created.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!auth) return <LoginView onAuth={setAuth} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<header className="hero">
|
<header className="hero">
|
||||||
<div>
|
<div>
|
||||||
<h1>Didactopus learner prototype</h1>
|
<h1>Didactopus media rendering pipeline</h1>
|
||||||
<p>
|
<p>Create GIF/MP4-ready render bundles from animated learning graphs.</p>
|
||||||
Pick a topic, get a clear onboarding path, see live progress, and inspect exactly why the system recommends each next step.
|
<div className="muted">{message}</div>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="hero-controls">
|
<div className="controls">
|
||||||
<label>
|
<label>Pack
|
||||||
Learner name
|
<select value={packId} onChange={(e) => setPackId(e.target.value)}>
|
||||||
<input value={learnerName} onChange={(e) => setLearnerName(e.target.value)} />
|
{packs.map((p) => <option key={p.id} value={p.id}>{p.title}</option>)}
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<button onClick={resetDomain}>Reset selected domain</button>
|
<label>Format
|
||||||
|
<select value={format} onChange={(e) => setFormat(e.target.value)}>
|
||||||
|
<option value="gif">GIF</option>
|
||||||
|
<option value="mp4">MP4</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>FPS
|
||||||
|
<input type="number" value={fps} onChange={(e) => setFps(Number(e.target.value || 2))} />
|
||||||
|
</label>
|
||||||
|
<button onClick={generateDemo}>Generate demo state</button>
|
||||||
|
<button onClick={renderNow}>Create render bundle</button>
|
||||||
|
<button onClick={() => { clearAuth(); setAuth(null); }}>Logout</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="domain-grid">
|
<main className="layout twocol">
|
||||||
{domains.map((d) => (
|
|
||||||
<DomainCard key={d.id} domain={d} selected={d.id === domain.id} onSelect={setSelectedDomainId} />
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<main className="layout">
|
|
||||||
<div className="left-col">
|
|
||||||
<OnboardingPanel domain={domain} learnerName={learnerName} />
|
|
||||||
<MasteryMap nodes={masteryMap} />
|
|
||||||
<EvidenceLog history={learnerState.history} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="center-col">
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2>What should I do next?</h2>
|
<h2>Bundle output</h2>
|
||||||
{recs.length === 0 ? (
|
<pre className="prebox">{JSON.stringify(bundle, null, 2)}</pre>
|
||||||
<div className="muted">No immediate recommendation available. You may have completed the current progression or need a new pack.</div>
|
|
||||||
) : (
|
|
||||||
<div className="steps-stack">
|
|
||||||
{recs.map((step) => <NextStepCard key={step.id} step={step} onSimulate={simulateStep} />)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
|
<section className="card">
|
||||||
|
<h2>What this bundle contains</h2>
|
||||||
|
<div className="explain">
|
||||||
|
<p>Each bundle includes SVG frames, a JSON manifest, and a render shell script suitable for FFmpeg-based conversion.</p>
|
||||||
|
<p>This keeps artifact generation decoupled from the API server while still making render production straightforward.</p>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
<div className="right-col">
|
|
||||||
<ProgressPanel progress={progress} readiness={readiness} />
|
|
||||||
<MilestonePanel milestones={milestones} lastReward={lastReward} />
|
|
||||||
<CompliancePanel compliance={domain.compliance} />
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,5 @@ export async function refresh(refreshToken) {
|
||||||
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 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 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 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 putLearnerState(token, learnerId, state) { const res = await fetch(`${API}/learners/${learnerId}/state`, { method: "PUT", headers: authHeaders(token), body: JSON.stringify(state) }); if (!res.ok) throw new Error("putLearnerState failed"); return await res.json(); }
|
export async function putLearnerState(token, learnerId, state) { const res = await fetch(`${API}/learners/${learnerId}/state`, { method: "PUT", headers: authHeaders(token), body: JSON.stringify(state) }); if (!res.ok) throw new Error("putLearnerState failed"); return await res.json(); }
|
||||||
export async function createLearnerRun(token, payload) { const res = await fetch(`${API}/learner-runs`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("createLearnerRun failed"); return await res.json(); }
|
export async function fetchGraphAnimation(token, learnerId, packId) { const res = await fetch(`${API}/learners/${learnerId}/graph-animation/${packId}`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchGraphAnimation failed"); return await res.json(); }
|
||||||
export async function addWorkflowEvent(token, payload) { const res = await fetch(`${API}/workflow-events`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("addWorkflowEvent failed"); return await res.json(); }
|
export async function createRenderBundle(token, learnerId, packId, payload) { const res = await fetch(`${API}/learners/${learnerId}/render-bundle/${packId}`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("createRenderBundle failed"); return await res.json(); }
|
||||||
export async function fetchAnimation(token, learnerId, packId) { const res = await fetch(`${API}/learners/${learnerId}/animation/${packId}`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchAnimation failed"); return await res.json(); }
|
|
||||||
|
|
|
||||||
|
|
@ -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,208 +1,24 @@
|
||||||
:root {
|
:root {
|
||||||
--bg: #f6f8fb;
|
--bg:#f6f8fb; --card:#ffffff; --text:#1f2430; --muted:#60697a; --border:#dbe1ea; --accent:#2d6cdf;
|
||||||
--card: #ffffff;
|
|
||||||
--text: #1f2430;
|
|
||||||
--muted: #60697a;
|
|
||||||
--border: #dbe1ea;
|
|
||||||
--accent: #2d6cdf;
|
|
||||||
--soft: #eef4ff;
|
|
||||||
}
|
}
|
||||||
* { box-sizing:border-box; }
|
* { box-sizing:border-box; }
|
||||||
body {
|
body { margin:0; font-family:Arial, Helvetica, sans-serif; background:var(--bg); color:var(--text); }
|
||||||
margin: 0;
|
.page { max-width:1500px; margin:0 auto; padding:24px; }
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
.narrow-page { max-width:520px; }
|
||||||
background: var(--bg);
|
.hero { background:var(--card); border:1px solid var(--border); border-radius:22px; padding:24px; display:flex; justify-content:space-between; gap:16px; align-items:flex-start; }
|
||||||
color: var(--text);
|
.controls { display:flex; gap:10px; align-items:flex-end; flex-wrap:wrap; }
|
||||||
}
|
label { display:block; font-weight:600; }
|
||||||
.page {
|
input, select { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; }
|
||||||
max-width: 1500px;
|
button { border:1px solid var(--border); background:white; border-radius:12px; padding:10px 14px; cursor:pointer; }
|
||||||
margin: 0 auto;
|
.card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; }
|
||||||
padding: 20px;
|
.narrow { margin-top:60px; }
|
||||||
}
|
.layout { display:grid; gap:16px; }
|
||||||
.hero {
|
.twocol { grid-template-columns:1fr 1fr; }
|
||||||
background: var(--card);
|
.prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:460px; }
|
||||||
border: 1px solid var(--border);
|
.muted { color:var(--muted); }
|
||||||
border-radius: 22px;
|
.error { color:#b42318; margin-top:10px; }
|
||||||
padding: 24px;
|
.explain p { margin-top:0; }
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
.hero-controls {
|
|
||||||
min-width: 260px;
|
|
||||||
}
|
|
||||||
.hero-controls input {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 6px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 10px;
|
|
||||||
font: inherit;
|
|
||||||
}
|
|
||||||
.hero-controls button {
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
.domain-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 16px;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
.domain-card {
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: var(--card);
|
|
||||||
border-radius: 18px;
|
|
||||||
padding: 16px;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.domain-card.selected {
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: 0 0 0 2px rgba(45,108,223,0.12);
|
|
||||||
}
|
|
||||||
.domain-title {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.domain-subtitle {
|
|
||||||
margin-top: 6px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
.domain-meta {
|
|
||||||
margin-top: 10px;
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.layout {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1.25fr 0.95fr;
|
|
||||||
gap: 16px;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
background: var(--card);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 18px;
|
|
||||||
}
|
|
||||||
.muted {
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
.steps-stack {
|
|
||||||
display: grid;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
.step-card {
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 14px;
|
|
||||||
background: #fcfdff;
|
|
||||||
}
|
|
||||||
.step-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
.reward-pill {
|
|
||||||
background: var(--soft);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 10px 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.primary {
|
|
||||||
margin-top: 10px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
.map-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.map-node {
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 14px;
|
|
||||||
}
|
|
||||||
.map-node.mastered { background: #eef9f0; }
|
|
||||||
.map-node.active, .map-node.available { background: #eef4ff; }
|
|
||||||
.map-node.locked { background: #f6f7fa; }
|
|
||||||
.node-label {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.node-status {
|
|
||||||
margin-top: 6px;
|
|
||||||
color: var(--muted);
|
|
||||||
text-transform: capitalize;
|
|
||||||
}
|
|
||||||
.progress-wrap {
|
|
||||||
margin-bottom: 14px;
|
|
||||||
}
|
|
||||||
.progress-bar {
|
|
||||||
width: 100%;
|
|
||||||
height: 12px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #e9edf4;
|
|
||||||
overflow: hidden;
|
|
||||||
margin: 8px 0;
|
|
||||||
}
|
|
||||||
.progress-fill {
|
|
||||||
height: 100%;
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
|
||||||
.reward-banner {
|
|
||||||
background: #fff7dd;
|
|
||||||
border: 1px solid #ecdca2;
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 12px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.readiness-box {
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: #fbfcfe;
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
.readiness-box.ready {
|
|
||||||
background: #eef9f0;
|
|
||||||
}
|
|
||||||
.compliance-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
.flag-row {
|
|
||||||
margin-top: 12px;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.flag {
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
background: #f4f7fc;
|
|
||||||
border-radius: 999px;
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
details summary {
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
@media (max-width:1100px) {
|
@media (max-width:1100px) {
|
||||||
.layout { grid-template-columns: 1fr; }
|
|
||||||
.domain-grid { grid-template-columns: 1fr; }
|
|
||||||
.hero { flex-direction:column; }
|
.hero { flex-direction:column; }
|
||||||
|
.twocol { grid-template-columns:1fr; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue