Apply ZIP update: 250-didactopus-artifact-lifecycle-and-knowledge-export-layer.zip [2026-03-14T13:21:04]

This commit is contained in:
welsberr 2026-03-14 13:29:56 -04:00
parent 14e1ccffdc
commit 58fba8788c
18 changed files with 599 additions and 698 deletions

120
FAQ.md
View File

@ -1,88 +1,66 @@
# Didactopus FAQ # Didactopus FAQ: Artifact Lifecycle and Knowledge Reuse
## What is Didactopus for? ## Why keep artifacts after rendering?
Didactopus helps represent learning as a knowledge graph with evidence, mastery, Artifacts are evidence of learning trajectories, pack structure, and interpretation.
artifacts, and reusable outputs. It supports both learners and the systems that They support:
author, review, and improve learning materials. - learner reflection
- mentor review
- debugging AI learners
- presentation and publication
## Is it only for AI learners? ## Why do retention policies matter?
No. It is built for: Not every artifact should be stored forever. Some are transient debugging outputs;
others are durable portfolio items or research artifacts.
- human learners Retention policy support lets deployments distinguish:
- AI learners - short-lived temporary outputs
- hybrid workflows where AI and humans both contribute - retained educational outputs
- archival artifacts worth preserving
## Why emphasize synthesis? ## How can learner knowledge be used outside Didactopus?
Because understanding often improves when learners recognize structural overlap A learner's activity can be exported into structured forms that support:
between different domains. Transfer, analogy, and conceptual reuse are central to - revised or expanded domain packs
real intellectual progress. - lesson plans and conventional curriculum products
- AI skill definitions or prompts
- mentor-facing notes about misconceptions and discoveries
Examples include: ## Can learners improve domain packs?
- entropy in thermodynamics and information theory Yes. Learners sometimes notice:
- drift in population genetics and random walks - confusing sequence order
- feedback in engineering, biology, and machine learning - hidden prerequisites
- missing examples
- better analogies
- edge cases mentors overlooked
Didactopus tries to surface these overlaps rather than treating subjects as sealed Didactopus should capture these as improvement suggestions rather than losing them.
containers.
## Why not automatically trust learner-derived knowledge? ## How could this support agentic AI skills?
Learner-derived knowledge can be valuable, but it still needs review, A learner knowledge export can be mapped into:
validation, and provenance. A learner may discover something surprising and - scope and goals
useful, but the system should preserve both usefulness and caution. - prerequisite structure
## What can learner-derived knowledge become?
Depending on review outcome, it can be promoted into:
- accepted pack improvements
- curriculum drafts
- reusable skill bundles
- archived but unadopted suggestions
## What is the review-and-promotion workflow?
It is the process by which exported learner observations are triaged, reviewed,
validated, and either promoted or archived.
## What is the synthesis engine?
The synthesis engine analyzes concept graphs and learner evidence to identify
candidate conceptual overlaps, analogies, and transferable structures across
packs.
## Can Didactopus produce traditional educational outputs?
Yes. Knowledge exports can seed:
- lesson outlines
- study guides
- exercise sets
- instructor notes
- curriculum maps
## Can Didactopus produce AI skill-like outputs?
Yes. Structured exports can support:
- skill manifests
- evaluation checklists
- failure-mode notes
- canonical examples - canonical examples
- prerequisite maps - failure modes
- evaluation checks
- recommended actions
## What happens to artifacts over time? That makes it a plausible source for building agent skills or skill-like bundles.
Artifacts can be: ## How could this support traditional curriculum products?
- retained Knowledge export can seed:
- archived - lesson outlines
- expired - exercise sets
- soft-deleted - study guides
- formative assessment prompts
- instructor notes
- capstone project ideas
Retention policy support is included so temporary debugging products and durable ## Is exported learner knowledge treated as automatically correct?
portfolio artifacts can be treated differently.
No. Exported learner knowledge should be treated as candidate structured knowledge.
It is useful, but it still needs review, validation, and provenance tracking.

View File

@ -17,6 +17,9 @@ dependencies = [
[project.scripts] [project.scripts]
didactopus-api = "didactopus.api:main" didactopus-api = "didactopus.api:main"
didactopus-export-svg = "didactopus.export_svg:main"
didactopus-render-bundle = "didactopus.render_bundle:main"
didactopus-export-knowledge = "didactopus.knowledge_export:main"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]

View File

@ -1,102 +1,57 @@
from __future__ import annotations from __future__ import annotations
import json
from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
import uvicorn import uvicorn
from .config import load_settings from datetime import datetime, timedelta, timezone
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, ArtifactRetentionUpdate, KnowledgeExportRequest
LoginRequest, ServiceAccountLoginRequest, ServiceAccountCreateRequest, ServiceAccountRotateRequest,
ServiceAccountStateRequest, ServiceToken, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState,
EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest, AgentCapabilityManifest,
AgentLearnerPlanRequest, AgentLearnerPlanResponse
)
from .repository import ( from .repository import (
authenticate_user, get_user_by_id, create_service_account, list_service_accounts, authenticate_service_account, authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token,
rotate_service_account_secret, set_service_account_active, add_agent_audit_log, list_agent_audit_logs, list_packs_for_user, get_pack, get_pack_row, create_learner, learner_owned_by_user, load_learner_state, save_learner_state,
store_refresh_token, refresh_token_active, revoke_refresh_token, deployment_policy_profile, list_packs_for_user, create_render_job, list_render_jobs, list_artifacts, get_artifact, update_artifact_retention, soft_delete_artifact
get_pack, get_pack_row, upsert_pack, create_learner, learner_owned_by_user, load_learner_state,
save_learner_state, create_evaluator_job, get_evaluator_job, list_evaluator_jobs_for_learner
) )
from .engine import apply_evidence, recommend_next from .auth import issue_access_token, issue_refresh_token, decode_token, new_token_id
from .auth import issue_access_token, issue_refresh_token, issue_service_access_token, decode_token, new_token_id, new_secret from .engine import build_graph_frames, stable_layout
from .worker import process_job from .worker import process_render_job
from .knowledge_export import build_knowledge_snapshot
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")
def future_iso(days: int) -> str:
return (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
@app.post("/api/login", response_model=TokenPair) @app.post("/api/login", response_model=TokenPair)
def login(payload: LoginRequest): def login(payload: LoginRequest):
@ -107,14 +62,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)
@ -131,138 +78,118 @@ 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) pack = get_pack(pack_id)
background_tasks.add_task(process_job, job_id) state = load_learner_state(learner_id)
audit_service_action(actor, "evaluator_job_submit", str(job_id), "ok", {"learner_id": learner_id, "pack_id": payload.pack_id}) frames = build_graph_frames(state, pack)
return EvaluatorJobStatus(job_id=job_id, status="queued") return {
"learner_id": learner_id,
"pack_id": pack_id,
"pack_title": pack.title if pack else "",
"frames": frames,
"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.get("/api/evaluator-jobs/{job_id}", response_model=EvaluatorJobStatus) @app.post("/api/learners/{learner_id}/render-jobs/{pack_id}")
def api_get_evaluator_job(job_id: int, actor = Depends(require_scope("evaluators:read"))): def api_render_job(learner_id: str, pack_id: str, payload: MediaRenderRequest, background_tasks: BackgroundTasks, user = Depends(current_user)):
job = get_evaluator_job(job_id) ensure_learner_access(user, learner_id)
if job is None: ensure_pack_access(user, pack_id)
raise HTTPException(status_code=404, detail="Job not found") pack = get_pack(pack_id)
audit_service_action(actor, "evaluator_job_read", str(job_id), "ok", {}) state = load_learner_state(learner_id)
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) animation = {
"learner_id": learner_id,
"pack_id": pack_id,
"pack_title": pack.title if pack else "",
"frames": build_graph_frames(state, pack),
}
job_id = create_render_job(learner_id, pack_id, payload.format, payload.fps, payload.theme)
background_tasks.add_task(process_render_job, job_id, learner_id, pack_id, payload.format, payload.fps, payload.theme, payload.retention_class, payload.retention_days, animation)
return {"job_id": job_id, "status": "queued"}
@app.get("/api/learners/{learner_id}/evaluator-history") @app.get("/api/render-jobs")
def api_get_evaluator_history(learner_id: str, actor = Depends(require_scope("evaluators:read"))): def api_list_render_jobs(learner_id: str | None = None, user = Depends(current_user)):
ensure_learner_access(actor, learner_id) if learner_id:
jobs = list_evaluator_jobs_for_learner(learner_id) ensure_learner_access(user, learner_id)
audit_service_action(actor, "evaluator_history_read", learner_id, "ok", {"jobs": len(jobs)}) return list_render_jobs(learner_id)
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.get("/api/artifacts")
def api_list_artifacts(learner_id: str | None = None, user = Depends(current_user)):
if learner_id:
ensure_learner_access(user, learner_id)
return list_artifacts(learner_id)
@app.get("/api/artifacts/{artifact_id}/download")
def api_download_artifact(artifact_id: int, user = Depends(current_user)):
artifact = get_artifact(artifact_id)
if artifact is None or artifact.is_deleted:
raise HTTPException(status_code=404, detail="Artifact not found")
ensure_learner_access(user, artifact.learner_id)
path = Path(artifact.path)
if not path.exists():
raise HTTPException(status_code=404, detail="Artifact path missing")
if path.is_dir():
manifest = path / "render_manifest.json"
if not manifest.exists():
raise HTTPException(status_code=404, detail="Artifact manifest missing")
return FileResponse(str(manifest), filename=f"artifact-{artifact_id}-manifest.json")
return FileResponse(str(path), filename=path.name)
@app.post("/api/artifacts/{artifact_id}/retention")
def api_update_artifact_retention(artifact_id: int, payload: ArtifactRetentionUpdate, user = Depends(current_user)):
artifact = get_artifact(artifact_id)
if artifact is None or artifact.is_deleted:
raise HTTPException(status_code=404, detail="Artifact not found")
ensure_learner_access(user, artifact.learner_id)
expires_at = "" if payload.retention_days is None else future_iso(payload.retention_days)
updated = update_artifact_retention(artifact_id, payload.retention_class, expires_at)
return {"artifact_id": updated.id, "retention_class": updated.retention_class, "expires_at": updated.expires_at}
@app.delete("/api/artifacts/{artifact_id}")
def api_delete_artifact(artifact_id: int, user = Depends(current_user)):
artifact = get_artifact(artifact_id)
if artifact is None or artifact.is_deleted:
raise HTTPException(status_code=404, detail="Artifact not found")
ensure_learner_access(user, artifact.learner_id)
updated = soft_delete_artifact(artifact_id)
return {"artifact_id": updated.id, "is_deleted": updated.is_deleted}
@app.post("/api/learners/{learner_id}/knowledge-export/{pack_id}")
def api_knowledge_export(learner_id: str, pack_id: str, payload: KnowledgeExportRequest, user = Depends(current_user)):
ensure_learner_access(user, learner_id)
ensure_pack_access(user, pack_id)
snapshot = build_knowledge_snapshot(learner_id, pack_id)
snapshot["requested_export_kind"] = payload.export_kind
return snapshot
def main(): def main():
uvicorn.run(app, host=settings.host, port=settings.port) uvicorn.run(app, host="127.0.0.1", port=8011)

View File

@ -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)

View File

@ -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()

View File

@ -1,59 +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) frames = []
if status in {"available", "active"}: history = sorted(state.history, key=lambda x: x.timestamp)
cards.append({ static_edges = [{"source": pre, "target": c.id, "kind": "prerequisite"} for c in pack.concepts for pre in c.prerequisites]
"id": concept.id, static_cross = [{
"title": f"Work on {concept.title}", "source": c.id,
"minutes": 15 if status == "available" else 10, "target_pack_id": link.target_pack_id,
"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.", "target_concept_id": link.target_concept_id,
"why": [ "relationship": link.relationship,
"Prerequisite check passed", "kind": "cross_pack"
f"Current score: {rec.score:.2f}" if rec else "No evidence recorded yet", } for c in pack.concepts for link in c.cross_pack_links]
f"Current confidence: {rec.confidence:.2f}" if rec else "Confidence starts after your first exercise", for idx, ev in enumerate(history):
], if ev.concept_id in scores:
"reward": concept.exerciseReward or f"{concept.title} progress recorded", scores[ev.concept_id] = ev.score
"conceptId": concept.id, nodes = []
"scoreHint": 0.82 if status == "available" else 0.76, for cid, concept in concepts.items():
"confidenceHint": 0.72 if status == "available" else 0.55, 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"],
}) })
return cards[:4] frames.append({
"index": idx,
"timestamp": ev.timestamp,
"event_kind": ev.kind,
"focus_concept_id": ev.concept_id,
"nodes": nodes,
"edges": static_edges,
"cross_pack_links": static_cross,
})
if not frames:
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

View File

@ -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,56 +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
class PackConcept(BaseModel): class PackConcept(BaseModel):
id: str id: str
@ -69,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
@ -84,13 +41,7 @@ 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
@ -110,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):
@ -118,25 +69,20 @@ 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"
fps: int = 2
theme: str = "default"
retention_class: str = "standard"
retention_days: int = 30
class AgentLearnerPlanResponse(BaseModel): class ArtifactRetentionUpdate(BaseModel):
retention_class: str
retention_days: int | None = None
class KnowledgeExportRequest(BaseModel):
learner_id: str learner_id: str
pack_id: str pack_id: str
next_cards: list[dict] = Field(default_factory=list) export_kind: str = "knowledge_snapshot"
suggested_actions: list[str] = Field(default_factory=list)

View File

@ -10,27 +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 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)
@ -47,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):
@ -82,15 +57,32 @@ class EvidenceEventORM(Base):
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): class RenderJobORM(Base):
__tablename__ = "evaluator_jobs" __tablename__ = "render_jobs"
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
learner_id: Mapped[str] = mapped_column(ForeignKey("learners.id"), index=True) learner_id: Mapped[str] = mapped_column(String(100), index=True)
pack_id: Mapped[str] = mapped_column(ForeignKey("packs.id"), index=True) pack_id: Mapped[str] = mapped_column(String(100), index=True)
concept_id: Mapped[str] = mapped_column(String(100), index=True) requested_format: Mapped[str] = mapped_column(String(20), default="gif")
submitted_text: Mapped[str] = mapped_column(Text, default="") fps: Mapped[int] = mapped_column(Integer, default=2)
theme: Mapped[str] = mapped_column(String(100), default="default")
status: Mapped[str] = mapped_column(String(50), default="queued") status: Mapped[str] = mapped_column(String(50), default="queued")
result_score: Mapped[float | None] = mapped_column(Float, nullable=True) bundle_dir: Mapped[str] = mapped_column(Text, default="")
result_confidence_hint: Mapped[float | None] = mapped_column(Float, nullable=True) payload_json: Mapped[str] = mapped_column(Text, default="")
result_notes: Mapped[str] = mapped_column(Text, default="") manifest_path: Mapped[str] = mapped_column(Text, default="")
trace_json: Mapped[str] = mapped_column(Text, default="{}") script_path: Mapped[str] = mapped_column(Text, default="")
error_text: Mapped[str] = mapped_column(Text, default="")
class ArtifactORM(Base):
__tablename__ = "artifacts"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
render_job_id: Mapped[int] = mapped_column(ForeignKey("render_jobs.id"), index=True)
learner_id: Mapped[str] = mapped_column(String(100), index=True)
pack_id: Mapped[str] = mapped_column(String(100), index=True)
artifact_type: Mapped[str] = mapped_column(String(50), default="render_bundle")
format: Mapped[str] = mapped_column(String(20), default="gif")
title: Mapped[str] = mapped_column(String(255), default="")
path: Mapped[str] = mapped_column(Text, default="")
metadata_json: Mapped[str] = mapped_column(Text, default="{}")
retention_class: Mapped[str] = mapped_column(String(50), default="standard")
expires_at: Mapped[str] = mapped_column(String(100), default="")
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)

View File

@ -23,14 +23,3 @@ 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}")

View File

@ -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, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM from .orm import UserORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, RenderJobORM, ArtifactORM
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,83 +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 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))
@ -155,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())
@ -170,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)
@ -184,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()
@ -223,31 +119,117 @@ def save_learner_state(state: LearnerState):
db.commit() db.commit()
return state return state
def create_evaluator_job(learner_id: str, pack_id: str, concept_id: str, submitted_text: str): def create_render_job(learner_id: str, pack_id: str, requested_format: str, fps: int, theme: str):
with SessionLocal() as db: with SessionLocal() as db:
job = EvaluatorJobORM(learner_id=learner_id, pack_id=pack_id, concept_id=concept_id, submitted_text=submitted_text, status="queued", trace_json=json.dumps({"notes": ["Job queued"]})) row = RenderJobORM(
db.add(job) learner_id=learner_id,
pack_id=pack_id,
requested_format=requested_format,
fps=fps,
theme=theme,
status="queued",
)
db.add(row)
db.commit() db.commit()
db.refresh(job) db.refresh(row)
return job.id return row.id
def list_evaluator_jobs_for_learner(learner_id: str): def update_render_job(job_id: int, **fields):
with SessionLocal() as db: with SessionLocal() as db:
return db.execute(select(EvaluatorJobORM).where(EvaluatorJobORM.learner_id == learner_id).order_by(EvaluatorJobORM.id.desc())).scalars().all() row = db.get(RenderJobORM, job_id)
if row is None:
def get_evaluator_job(job_id: int): return None
with SessionLocal() as db: for k, v in fields.items():
return db.get(EvaluatorJobORM, job_id) setattr(row, k, v)
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() db.commit()
db.refresh(row)
return row
def list_render_jobs(learner_id: str | None = None):
with SessionLocal() as db:
stmt = select(RenderJobORM).order_by(RenderJobORM.id.desc())
if learner_id:
stmt = stmt.where(RenderJobORM.learner_id == learner_id)
rows = db.execute(stmt).scalars().all()
return [{
"job_id": r.id,
"learner_id": r.learner_id,
"pack_id": r.pack_id,
"requested_format": r.requested_format,
"fps": r.fps,
"theme": r.theme,
"status": r.status,
"bundle_dir": r.bundle_dir,
"payload_json": r.payload_json,
"manifest_path": r.manifest_path,
"script_path": r.script_path,
"error_text": r.error_text,
} for r in rows]
def register_artifact(render_job_id: int, learner_id: str, pack_id: str, artifact_type: str, fmt: str, title: str, path: str, metadata: dict, retention_class: str = "standard", expires_at: str = ""):
with SessionLocal() as db:
row = ArtifactORM(
render_job_id=render_job_id,
learner_id=learner_id,
pack_id=pack_id,
artifact_type=artifact_type,
format=fmt,
title=title,
path=path,
metadata_json=json.dumps(metadata),
retention_class=retention_class,
expires_at=expires_at,
is_deleted=False,
)
db.add(row)
db.commit()
db.refresh(row)
return row.id
def list_artifacts(learner_id: str | None = None, include_deleted: bool = False):
with SessionLocal() as db:
stmt = select(ArtifactORM).order_by(ArtifactORM.id.desc())
if learner_id:
stmt = stmt.where(ArtifactORM.learner_id == learner_id)
if not include_deleted:
stmt = stmt.where(ArtifactORM.is_deleted == False)
rows = db.execute(stmt).scalars().all()
return [{
"artifact_id": r.id,
"render_job_id": r.render_job_id,
"learner_id": r.learner_id,
"pack_id": r.pack_id,
"artifact_type": r.artifact_type,
"format": r.format,
"title": r.title,
"path": r.path,
"retention_class": r.retention_class,
"expires_at": r.expires_at,
"is_deleted": r.is_deleted,
"metadata": json.loads(r.metadata_json or "{}"),
} for r in rows]
def get_artifact(artifact_id: int):
with SessionLocal() as db:
return db.get(ArtifactORM, artifact_id)
def update_artifact_retention(artifact_id: int, retention_class: str, expires_at: str):
with SessionLocal() as db:
row = db.get(ArtifactORM, artifact_id)
if row is None:
return None
row.retention_class = retention_class
row.expires_at = expires_at
db.commit()
db.refresh(row)
return row
def soft_delete_artifact(artifact_id: int):
with SessionLocal() as db:
row = db.get(ArtifactORM, artifact_id)
if row is None:
return None
row.is_deleted = True
db.commit()
db.refresh(row)
return row

View File

@ -3,8 +3,8 @@ from sqlalchemy import select
from .db import Base, engine, SessionLocal 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 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)
@ -12,18 +12,23 @@ def main():
if db.execute(select(UserORM).where(UserORM.username == "wesley")).scalar_one_or_none() is None: if db.execute(select(UserORM).where(UserORM.username == "wesley")).scalar_one_or_none() is None:
db.add(UserORM(username="wesley", password_hash=hash_password("demo-pass"), role="admin", is_active=True)) db.add(UserORM(username="wesley", password_hash=hash_password("demo-pass"), role="admin", is_active=True))
db.commit() db.commit()
create_learner(1, "wesley-learner", "Wesley learner")
upsert_pack( upsert_pack(
PackData( PackData(
id="wesley-private-pack", id="wesley-private-pack",
title="Wesley Private Pack", title="Wesley Private Pack",
subtitle="Personal pack example.", subtitle="Personal pack example.",
level="novice-friendly", level="novice-friendly",
concepts=[PackConcept(id="intro", title="Intro", prerequisites=[], masteryDimension="mastery", exerciseReward="Intro marker")], concepts=[
onboarding={"headline":"Start privately","body":"Personal pack lane.","checklist":["Create pack","Use pack"]}, PackConcept(id="intro", title="Intro", prerequisites=[], position=GraphPosition(x=150, y=120)),
compliance=PackCompliance() 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"},
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"
) )

View File

@ -1,21 +1,43 @@
from __future__ import annotations from __future__ import annotations
from .repository import get_evaluator_job, update_evaluator_job, load_learner_state, save_learner_state import json, tempfile
from .engine import apply_evidence from pathlib import Path
from .models import EvidenceEvent from datetime import datetime, timedelta, timezone
import time from .repository import update_render_job, register_artifact
from .render_bundle import make_render_bundle
def process_job(job_id: int): def future_iso(days: int) -> str:
job = get_evaluator_job(job_id) return (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
if job is None:
return
score = 0.78 if len(job.submitted_text.strip()) > 20 else 0.62
confidence_hint = 0.72 if len(job.submitted_text.strip()) > 20 else 0.45
notes = "Prototype evaluator: longer responses scored somewhat higher."
update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes, trace={"notes": ["completed"]})
state = load_learner_state(job.learner_id)
state = apply_evidence(state, EvidenceEvent(concept_id=job.concept_id, dimension="mastery", score=score, confidence_hint=confidence_hint, timestamp="2026-03-13T12:00:00+00:00", kind="review", source_id=f"evaluator-job-{job_id}"))
save_learner_state(state)
def main(): def process_render_job(job_id: int, learner_id: str, pack_id: str, fmt: str, fps: int, theme: str, retention_class: str, retention_days: int, animation_payload: dict):
while True: update_render_job(job_id, status="running")
time.sleep(60) try:
base = Path(tempfile.mkdtemp(prefix=f"didactopus_job_{job_id}_"))
payload_json = base / "animation_payload.json"
payload_json.write_text(json.dumps(animation_payload, indent=2), encoding="utf-8")
out_dir = base / "bundle"
make_render_bundle(str(payload_json), str(out_dir), fps=fps, fmt=fmt)
manifest_path = out_dir / "render_manifest.json"
script_path = out_dir / "render.sh"
update_render_job(
job_id,
status="completed",
bundle_dir=str(out_dir),
payload_json=str(payload_json),
manifest_path=str(manifest_path),
script_path=str(script_path),
error_text="",
)
register_artifact(
render_job_id=job_id,
learner_id=learner_id,
pack_id=pack_id,
artifact_type="render_bundle",
fmt=fmt,
title=f"{pack_id} animation bundle",
path=str(out_dir),
metadata={"fps": fps, "theme": theme, "manifest_path": str(manifest_path), "script_path": str(script_path)},
retention_class=retention_class,
expires_at=future_iso(retention_days),
)
except Exception as e:
update_render_job(job_id, status="failed", error_text=str(e))

View File

@ -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("webui/src/App.jsx").exists() assert Path("src/didactopus/worker.py").exists()
assert Path("webui/src/api.js").exists() assert Path("src/didactopus/knowledge_export.py").exists()
assert Path("FAQ.md").exists()

View File

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

View File

@ -1,5 +1,5 @@
{ {
"name": "didactopus-agent-audit-ui", "name": "didactopus-artifact-lifecycle-ui",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { login, refresh, fetchDeploymentPolicy, fetchAgentCapabilities, listServiceAccounts, createServiceAccount, rotateServiceAccount, setServiceAccountState, listAgentAuditLogs } from "./api"; import { login, refresh, fetchPacks, fetchLearnerState, putLearnerState, createRenderJob, listRenderJobs, listArtifacts, updateRetention, exportKnowledge } from "./api";
import { loadAuth, saveAuth, clearAuth } from "./authStore"; import { loadAuth, saveAuth, clearAuth } from "./authStore";
function LoginView({ onAuth }) { function LoginView({ onAuth }) {
@ -28,13 +28,15 @@ function LoginView({ onAuth }) {
export default function App() { export default function App() {
const [auth, setAuth] = useState(loadAuth()); const [auth, setAuth] = useState(loadAuth());
const [policy, setPolicy] = useState(null); const [packs, setPacks] = useState([]);
const [caps, setCaps] = useState(null); const [learnerId] = useState("wesley-learner");
const [serviceAccounts, setServiceAccounts] = useState([]); const [packId, setPackId] = useState("");
const [auditLogs, setAuditLogs] = useState([]); const [jobs, setJobs] = useState([]);
const [created, setCreated] = useState(null); const [artifacts, setArtifacts] = useState([]);
const [rotated, setRotated] = useState(null); const [knowledge, setKnowledge] = useState(null);
const [form, setForm] = useState({ name: "agent-learner-1", description: "AI learner account", scopes: ["packs:read","learners:read","learners:write","recommendations:read","evaluators:submit","evaluators:read"] }); const [format, setFormat] = useState("gif");
const [fps, setFps] = useState(2);
const [message, setMessage] = useState("");
async function refreshAuthToken() { async function refreshAuthToken() {
if (!auth?.refresh_token) return null; if (!auth?.refresh_token) return null;
@ -59,35 +61,60 @@ export default function App() {
} }
} }
async function reload() { async function reloadLists() {
setPolicy(await guarded((token) => fetchDeploymentPolicy(token))); setJobs(await guarded((token) => listRenderJobs(token, learnerId)));
setCaps(await guarded((token) => fetchAgentCapabilities(token))); setArtifacts(await guarded((token) => listArtifacts(token, learnerId)));
if (auth.role === "admin") {
setServiceAccounts(await guarded((token) => listServiceAccounts(token)));
setAuditLogs(await guarded((token) => listAgentAuditLogs(token)));
}
} }
useEffect(() => { useEffect(() => {
if (!auth) return; if (!auth) return;
reload(); async function load() {
const p = await guarded((token) => fetchPacks(token));
setPacks(p);
setPackId(p[0]?.id || "");
await reloadLists();
}
load();
}, [auth]); }, [auth]);
async function createNow() { async function generateDemo() {
const result = await guarded((token) => createServiceAccount(token, form)); let state = await guarded((token) => fetchLearnerState(token, learnerId));
setCreated(result); const base = Date.now();
await reload(); 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 rotateNow(name) { async function createJob() {
const result = await guarded((token) => rotateServiceAccount(token, name)); const result = await guarded((token) => createRenderJob(token, learnerId, packId, { learner_id: learnerId, pack_id: packId, format, fps, theme: "default", retention_class: "standard", retention_days: 30 }));
setRotated(result); setMessage(`Render job ${result.job_id} queued.`);
await reload(); setTimeout(() => reloadLists(), 500);
} }
async function toggleState(name, isActive) { async function changeRetention(artifactId) {
await guarded((token) => setServiceAccountState(token, name, isActive)); await guarded((token) => updateRetention(token, artifactId, { retention_class: "archive", retention_days: 365 }));
await reload(); await reloadLists();
setMessage(`Artifact ${artifactId} retention updated.`);
}
async function runKnowledgeExport() {
const result = await guarded((token) => exportKnowledge(token, learnerId, packId, { learner_id: learnerId, pack_id: packId, export_kind: "knowledge_snapshot" }));
setKnowledge(result);
setMessage("Knowledge export generated.");
} }
if (!auth) return <LoginView onAuth={setAuth} />; if (!auth) return <LoginView onAuth={setAuth} />;
@ -96,61 +123,46 @@ export default function App() {
<div className="page"> <div className="page">
<header className="hero"> <header className="hero">
<div> <div>
<h1>Didactopus agent audit + key rotation</h1> <h1>Didactopus artifact lifecycle + knowledge export</h1>
<p>Scoped machine identities with audit events, rotation, and disable controls.</p> <p>Manage artifact retention and turn learner state into reusable knowledge outputs.</p>
<div className="muted">Signed in as {auth.username} ({auth.role})</div> <div className="muted">{message}</div>
</div> </div>
<div className="controls">
<label>Pack
<select value={packId} onChange={(e) => setPackId(e.target.value)}>
{packs.map((p) => <option key={p.id} value={p.id}>{p.title}</option>)}
</select>
</label>
<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={createJob}>Create render job</button>
<button onClick={runKnowledgeExport}>Export knowledge</button>
<button onClick={reloadLists}>Refresh lists</button>
<button onClick={() => { clearAuth(); setAuth(null); }}>Logout</button> <button onClick={() => { clearAuth(); setAuth(null); }}>Logout</button>
</div>
</header> </header>
<main className="layout twocol"> <main className="layout threecol">
<section className="card"> <section className="card">
<h2>Deployment policy</h2> <h2>Render jobs</h2>
<pre className="prebox">{JSON.stringify(policy, null, 2)}</pre> <pre className="prebox">{JSON.stringify(jobs, null, 2)}</pre>
<h2>Agent capabilities</h2>
<pre className="prebox">{JSON.stringify(caps, null, 2)}</pre>
</section> </section>
<section className="card"> <section className="card">
<h2>Service accounts</h2> <h2>Artifacts</h2>
{auth.role === "admin" ? ( <pre className="prebox">{JSON.stringify(artifacts, null, 2)}</pre>
<> {artifacts[0] ? <button onClick={() => changeRetention(artifacts[0].artifact_id)}>Archive newest artifact</button> : null}
<label>Name<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></label>
<label>Description<input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></label>
<label>Scopes (comma-separated)<input value={form.scopes.join(", ")} onChange={(e) => setForm({ ...form, scopes: e.target.value.split(",").map(s => s.trim()).filter(Boolean) })} /></label>
<button className="primary" onClick={createNow}>Create service account</button>
<h3>Created credential</h3>
<pre className="prebox">{JSON.stringify(created, null, 2)}</pre>
<h3>Rotated credential</h3>
<pre className="prebox">{JSON.stringify(rotated, null, 2)}</pre>
<h3>Existing accounts</h3>
<div className="table-wrap">
<table className="table">
<thead><tr><th>Name</th><th>Active</th><th>Scopes</th><th>Actions</th></tr></thead>
<tbody>
{serviceAccounts.map((sa) => (
<tr key={sa.id}>
<td>{sa.name}</td>
<td>{String(sa.is_active)}</td>
<td>{sa.scopes.join(", ")}</td>
<td>
<button onClick={() => rotateNow(sa.name)}>Rotate</button>
<button onClick={() => toggleState(sa.name, !sa.is_active)}>{sa.is_active ? "Disable" : "Enable"}</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
) : (
<div className="muted">Admin required.</div>
)}
</section> </section>
<section className="card">
<section className="card full"> <h2>Knowledge export</h2>
<h2>Agent audit logs</h2> <pre className="prebox">{JSON.stringify(knowledge, null, 2)}</pre>
<pre className="prebox tall">{JSON.stringify(auditLogs, null, 2)}</pre>
</section> </section>
</main> </main>
</div> </div>

View File

@ -16,10 +16,11 @@ export async function refresh(refreshToken) {
if (!res.ok) throw new Error("refresh failed"); if (!res.ok) throw new Error("refresh failed");
return await res.json(); return await res.json();
} }
export async function fetchDeploymentPolicy(token) { const res = await fetch(`${API}/deployment-policy`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchDeploymentPolicy failed"); return await res.json(); } export async function fetchPacks(token) { const res = await fetch(`${API}/packs`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPacks failed"); return await res.json(); }
export async function fetchAgentCapabilities(token) { const res = await fetch(`${API}/agent/capabilities`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchAgentCapabilities failed"); return await res.json(); } export async function 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 listServiceAccounts(token) { const res = await fetch(`${API}/admin/service-accounts`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listServiceAccounts failed"); return await res.json(); } export async function 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 createServiceAccount(token, payload) { const res = await fetch(`${API}/admin/service-accounts`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("createServiceAccount failed"); return await res.json(); } export async function createRenderJob(token, learnerId, packId, payload) { const res = await fetch(`${API}/learners/${learnerId}/render-jobs/${packId}`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("createRenderJob failed"); return await res.json(); }
export async function rotateServiceAccount(token, name) { const res = await fetch(`${API}/admin/service-accounts/rotate`, { method: "POST", headers: authHeaders(token), body: JSON.stringify({ name }) }); if (!res.ok) throw new Error("rotateServiceAccount failed"); return await res.json(); } export async function listRenderJobs(token, learnerId) { const res = await fetch(`${API}/render-jobs?learner_id=${encodeURIComponent(learnerId)}`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listRenderJobs failed"); return await res.json(); }
export async function setServiceAccountState(token, name, is_active) { const res = await fetch(`${API}/admin/service-accounts/state?name=${encodeURIComponent(name)}`, { method: "POST", headers: authHeaders(token), body: JSON.stringify({ is_active }) }); if (!res.ok) throw new Error("setServiceAccountState failed"); return await res.json(); } export async function listArtifacts(token, learnerId) { const res = await fetch(`${API}/artifacts?learner_id=${encodeURIComponent(learnerId)}`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listArtifacts failed"); return await res.json(); }
export async function listAgentAuditLogs(token) { const res = await fetch(`${API}/admin/agent-audit-logs`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listAgentAuditLogs failed"); return await res.json(); } export async function updateRetention(token, artifactId, payload) { const res = await fetch(`${API}/artifacts/${artifactId}/retention`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("updateRetention failed"); return await res.json(); }
export async function exportKnowledge(token, learnerId, packId, payload) { const res = await fetch(`${API}/learners/${learnerId}/knowledge-export/${packId}`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("exportKnowledge failed"); return await res.json(); }

View File

@ -3,22 +3,21 @@
} }
* { box-sizing:border-box; } * { box-sizing:border-box; }
body { margin:0; font-family:Arial, Helvetica, sans-serif; background:var(--bg); color:var(--text); } body { margin:0; font-family:Arial, Helvetica, sans-serif; background:var(--bg); color:var(--text); }
.page { max-width:1400px; margin:0 auto; padding:24px; } .page { max-width:1600px; margin:0 auto; padding:24px; }
.narrow-page { max-width:520px; } .narrow-page { max-width:520px; }
.hero { background:var(--card); border:1px solid var(--border); border-radius:22px; padding:24px; display:flex; justify-content:space-between; gap:16px; } .hero { background:var(--card); border:1px solid var(--border); border-radius:22px; padding:24px; display:flex; justify-content:space-between; gap:16px; align-items:flex-start; }
label { display:block; font-weight:600; margin-bottom:10px; } .controls { display:flex; gap:10px; align-items:flex-end; flex-wrap:wrap; }
input { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; } label { display:block; font-weight:600; }
button { border:1px solid var(--border); background:white; border-radius:12px; padding:8px 12px; cursor:pointer; margin-right:8px; } input, select { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; }
.primary { background:var(--accent); color:white; border-color:var(--accent); } button { border:1px solid var(--border); background:white; border-radius:12px; padding:10px 14px; cursor:pointer; }
.card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; } .card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; }
.narrow { margin-top:60px; } .narrow { margin-top:60px; }
.layout { display:grid; gap:16px; } .layout { display:grid; gap:16px; }
.twocol { grid-template-columns:1fr 1fr; } .threecol { grid-template-columns:1fr 1fr 1fr; }
.full { grid-column:1 / -1; } .prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:460px; }
.prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:280px; }
.prebox.tall { max-height:420px; }
.muted { color:var(--muted); } .muted { color:var(--muted); }
.error { color:#b42318; margin-top:10px; } .error { color:#b42318; margin-top:10px; }
.table { width:100%; border-collapse:collapse; } @media (max-width:1200px) {
.table th, .table td { border-bottom:1px solid var(--border); text-align:left; padding:8px; vertical-align:top; } .hero { flex-direction:column; }
@media (max-width:1100px) { .twocol { grid-template-columns:1fr; } .hero { flex-direction:column; } .full { grid-column:auto; } } .threecol { grid-template-columns:1fr; }
}