diff --git a/FAQ.md b/FAQ.md
index 30d7f69..ae0b8f4 100644
--- a/FAQ.md
+++ b/FAQ.md
@@ -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, and reusable outputs. It supports both learners and the systems that
-author, review, and improve learning materials.
+Artifacts are evidence of learning trajectories, pack structure, and interpretation.
+They support:
+- 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
-- AI learners
-- hybrid workflows where AI and humans both contribute
+Retention policy support lets deployments distinguish:
+- short-lived temporary outputs
+- 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
-between different domains. Transfer, analogy, and conceptual reuse are central to
-real intellectual progress.
+A learner's activity can be exported into structured forms that support:
+- revised or expanded domain packs
+- 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
-- drift in population genetics and random walks
-- feedback in engineering, biology, and machine learning
+Yes. Learners sometimes notice:
+- confusing sequence order
+- hidden prerequisites
+- missing examples
+- better analogies
+- edge cases mentors overlooked
-Didactopus tries to surface these overlaps rather than treating subjects as sealed
-containers.
+Didactopus should capture these as improvement suggestions rather than losing them.
-## 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,
-validation, and provenance. A learner may discover something surprising and
-useful, but the system should preserve both usefulness and caution.
-
-## 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
+A learner knowledge export can be mapped into:
+- scope and goals
+- prerequisite structure
- 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
-- archived
-- expired
-- soft-deleted
+Knowledge export can seed:
+- lesson outlines
+- exercise sets
+- study guides
+- formative assessment prompts
+- instructor notes
+- capstone project ideas
-Retention policy support is included so temporary debugging products and durable
-portfolio artifacts can be treated differently.
+## Is exported learner knowledge treated as automatically correct?
+
+No. Exported learner knowledge should be treated as candidate structured knowledge.
+It is useful, but it still needs review, validation, and provenance tracking.
diff --git a/pyproject.toml b/pyproject.toml
index 53c7bda..0b01271 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,6 +17,9 @@ dependencies = [
[project.scripts]
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]
where = ["src"]
diff --git a/src/didactopus/api.py b/src/didactopus/api.py
index ea39309..3699cfd 100644
--- a/src/didactopus/api.py
+++ b/src/didactopus/api.py
@@ -1,102 +1,57 @@
from __future__ import annotations
-import json
from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import FileResponse
import uvicorn
-from .config import load_settings
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
from .db import Base, engine
-from .models import (
- LoginRequest, ServiceAccountLoginRequest, ServiceAccountCreateRequest, ServiceAccountRotateRequest,
- ServiceAccountStateRequest, ServiceToken, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState,
- EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest, AgentCapabilityManifest,
- AgentLearnerPlanRequest, AgentLearnerPlanResponse
-)
+from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, MediaRenderRequest, ArtifactRetentionUpdate, KnowledgeExportRequest
from .repository import (
- authenticate_user, get_user_by_id, create_service_account, list_service_accounts, authenticate_service_account,
- rotate_service_account_secret, set_service_account_active, add_agent_audit_log, list_agent_audit_logs,
- store_refresh_token, refresh_token_active, revoke_refresh_token, deployment_policy_profile, list_packs_for_user,
- get_pack, get_pack_row, upsert_pack, create_learner, learner_owned_by_user, load_learner_state,
- save_learner_state, create_evaluator_job, get_evaluator_job, list_evaluator_jobs_for_learner
+ 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,
+ create_render_job, list_render_jobs, list_artifacts, get_artifact, update_artifact_retention, soft_delete_artifact
)
-from .engine import apply_evidence, recommend_next
-from .auth import issue_access_token, issue_refresh_token, issue_service_access_token, decode_token, new_token_id, new_secret
-from .worker import process_job
+from .auth import issue_access_token, issue_refresh_token, decode_token, new_token_id
+from .engine import build_graph_frames, stable_layout
+from .worker import process_render_job
+from .knowledge_export import build_knowledge_snapshot
-settings = load_settings()
Base.metadata.create_all(bind=engine)
app = FastAPI(title="Didactopus API Prototype")
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()
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")
- if payload.get("kind") == "access":
- user = get_user_by_id(int(payload["sub"]))
- if user is None or not user.is_active:
- raise HTTPException(status_code=401, detail="Unauthorized")
- return {"actor_type": "user", "user": user, "scopes": None}
- if payload.get("kind") == "service":
- return {
- "actor_type": "service",
- "service_account_id": int(payload["sub"]),
- "service_account_name": payload.get("service_account_name"),
- "scopes": payload.get("scopes", []),
- }
- raise HTTPException(status_code=401, detail="Unauthorized")
+ user = get_user_by_id(int(payload["sub"]))
+ if user is None or not user.is_active:
+ raise HTTPException(status_code=401, detail="Unauthorized")
+ return user
-def require_admin(actor = Depends(current_actor)):
- 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"]
+def ensure_learner_access(user, learner_id: str):
if user.role == "admin":
return
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)
if row is None:
raise HTTPException(status_code=404, detail="Pack not found")
- if actor["actor_type"] == "service":
- return row
- user = actor["user"]
if user.role == "admin":
return row
if row.policy_lane == "community":
return row
if row.owner_user_id == user.id:
return row
- raise HTTPException(status_code=403, detail="Pack not accessible by this actor")
+ 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)
def login(payload: LoginRequest):
@@ -107,14 +62,6 @@ def login(payload: LoginRequest):
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)
-@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)
def refresh(payload: RefreshRequest):
data = decode_token(payload.refresh_token)
@@ -131,138 +78,118 @@ def refresh(payload: RefreshRequest):
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)
-@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")
-def api_list_packs(actor = Depends(require_scope("packs:read"))):
- user_id = actor["user"].id if actor["actor_type"] == "user" else None
- packs = [p.model_dump() for p in list_packs_for_user(user_id, include_unpublished=(actor["actor_type"] == "user" and actor["user"].role == "admin"))]
- audit_service_action(actor, "packs_list", "packs", "ok", {"count": len(packs)})
- return packs
-
-@app.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"}
+def api_list_packs(user = Depends(current_user)):
+ return [p.model_dump() for p in list_packs_for_user(user.id, include_unpublished=(user.role == "admin"))]
@app.post("/api/learners")
-def api_create_learner(payload: CreateLearnerRequest, actor = Depends(require_scope("learners:write"))):
- if actor["actor_type"] != "user":
- raise HTTPException(status_code=403, detail="Service accounts do not create learners in this scaffold")
- create_learner(actor["user"].id, payload.learner_id, payload.display_name)
+def api_create_learner(payload: CreateLearnerRequest, user = Depends(current_user)):
+ create_learner(user.id, payload.learner_id, payload.display_name)
return {"ok": True, "learner_id": payload.learner_id}
@app.get("/api/learners/{learner_id}/state")
-def api_get_learner_state(learner_id: str, actor = Depends(require_scope("learners:read"))):
- ensure_learner_access(actor, learner_id)
- state = load_learner_state(learner_id).model_dump()
- audit_service_action(actor, "learner_state_read", learner_id, "ok", {"records": len(state.get("records", []))})
- return state
+def api_get_learner_state(learner_id: str, user = Depends(current_user)):
+ ensure_learner_access(user, learner_id)
+ return load_learner_state(learner_id).model_dump()
@app.put("/api/learners/{learner_id}/state")
-def api_put_learner_state(learner_id: str, state: LearnerState, actor = Depends(require_scope("learners:write"))):
- ensure_learner_access(actor, learner_id)
+def api_put_learner_state(learner_id: str, state: LearnerState, user = Depends(current_user)):
+ ensure_learner_access(user, learner_id)
if learner_id != state.learner_id:
raise HTTPException(status_code=400, detail="Learner ID mismatch")
- result = save_learner_state(state).model_dump()
- audit_service_action(actor, "learner_state_write", learner_id, "ok", {"records": len(result.get("records", []))})
- return result
+ return save_learner_state(state).model_dump()
-@app.post("/api/learners/{learner_id}/evidence")
-def api_post_evidence(learner_id: str, event: EvidenceEvent, actor = Depends(require_scope("learners:write"))):
- ensure_learner_access(actor, learner_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)
+@app.get("/api/packs/{pack_id}/layout")
+def api_pack_layout(pack_id: str, user = Depends(current_user)):
+ ensure_pack_access(user, pack_id)
pack = get_pack(pack_id)
- if pack is None:
- 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}
+ return {"pack_id": pack_id, "layout": stable_layout(pack)} if pack else {"pack_id": pack_id, "layout": {}}
-@app.post("/api/learners/{learner_id}/evaluator-jobs", response_model=EvaluatorJobStatus)
-def api_submit_evaluator_job(learner_id: str, payload: EvaluatorSubmission, background_tasks: BackgroundTasks, actor = Depends(require_scope("evaluators:submit"))):
- ensure_learner_access(actor, learner_id)
- ensure_pack_access(actor, payload.pack_id)
- job_id = create_evaluator_job(learner_id, payload.pack_id, payload.concept_id, payload.submitted_text)
- 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/learners/{learner_id}/graph-animation/{pack_id}")
+def api_graph_animation(learner_id: str, pack_id: str, 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)
+ frames = build_graph_frames(state, pack)
+ 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)
-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.post("/api/learners/{learner_id}/render-jobs/{pack_id}")
+def api_render_job(learner_id: str, pack_id: str, payload: MediaRenderRequest, background_tasks: BackgroundTasks, 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),
+ }
+ 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")
-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.get("/api/render-jobs")
+def api_list_render_jobs(learner_id: str | None = None, user = Depends(current_user)):
+ if learner_id:
+ ensure_learner_access(user, learner_id)
+ return list_render_jobs(learner_id)
+
+@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():
- uvicorn.run(app, host=settings.host, port=settings.port)
+ uvicorn.run(app, host="127.0.0.1", port=8011)
diff --git a/src/didactopus/auth.py b/src/didactopus/auth.py
index c7b3117..54745ac 100644
--- a/src/didactopus/auth.py
+++ b/src/didactopus/auth.py
@@ -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:
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:
try:
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:
return secrets.token_urlsafe(24)
-
-def new_secret() -> str:
- return secrets.token_urlsafe(24)
diff --git a/src/didactopus/config.py b/src/didactopus/config.py
index 88e5551..1f71733 100644
--- a/src/didactopus/config.py
+++ b/src/didactopus/config.py
@@ -8,7 +8,6 @@ class Settings(BaseModel):
port: int = int(os.getenv("DIDACTOPUS_PORT", "8011"))
jwt_secret: str = os.getenv("DIDACTOPUS_JWT_SECRET", "change-me")
jwt_algorithm: str = "HS256"
- deployment_policy_profile: str = os.getenv("DIDACTOPUS_POLICY_PROFILE", "single_user")
def load_settings() -> Settings:
return Settings()
diff --git a/src/didactopus/engine.py b/src/didactopus/engine.py
index c1e52bf..6b7c236 100644
--- a/src/didactopus/engine.py
+++ b/src/didactopus/engine.py
@@ -1,59 +1,110 @@
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:
- for rec in state.records:
- if rec.concept_id == concept_id and rec.dimension == dimension:
- return rec
- return None
+def concept_depths(pack: PackData) -> dict[str, int]:
+ concept_map = {c.id: c for c in pack.concepts}
+ memo = {}
+ def depth(cid: str) -> int:
+ 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:
- rec = get_record(state, event.concept_id, event.dimension)
- if rec is None:
- rec = MasteryRecord(concept_id=event.concept_id, dimension=event.dimension, score=0.0, confidence=0.0, evidence_count=0, last_updated=event.timestamp)
- state.records.append(rec)
- weight = max(0.05, min(1.0, event.confidence_hint))
- rec.score = ((rec.score * rec.evidence_count) + (event.score * weight)) / max(1, rec.evidence_count + 1)
- 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))))
- rec.evidence_count += 1
- rec.last_updated = event.timestamp
- state.history.append(event)
- return state
+def stable_layout(pack: PackData, width: int = 900, height: int = 520):
+ depths = concept_depths(pack)
+ layers = defaultdict(list)
+ for c in pack.concepts:
+ layers[depths.get(c.id, 0)].append(c)
+ positions = {}
+ max_depth = max(layers.keys()) if layers else 0
+ for d in sorted(layers):
+ nodes = sorted(layers[d], key=lambda c: c.id)
+ y = 90 + d * ((height - 160) / max(1, max_depth))
+ for idx, node in enumerate(nodes):
+ 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:
- rec = get_record(state, pid, concept.masteryDimension)
- if rec is None or rec.score < min_score or rec.confidence < min_confidence:
+ if scores.get(pid, 0.0) < min_score:
return False
return True
-def concept_status(state: LearnerState, concept, min_score: float = 0.65, min_confidence: float = 0.45) -> str:
- rec = get_record(state, concept.id, concept.masteryDimension)
- if rec and rec.score >= min_score and rec.confidence >= min_confidence:
+def concept_status(scores: dict[str, float], concept, min_score: float = 0.65) -> str:
+ score = scores.get(concept.id, 0.0)
+ if score >= min_score:
return "mastered"
- if prereqs_satisfied(state, concept, min_score, min_confidence):
- return "active" if rec else "available"
+ if prereqs_satisfied(scores, concept, min_score):
+ return "active" if score > 0 else "available"
return "locked"
-def recommend_next(state: LearnerState, pack: PackData) -> list[dict]:
- cards = []
- for concept in pack.concepts:
- status = concept_status(state, concept)
- 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,
+def build_graph_frames(state: LearnerState, pack: PackData):
+ concepts = {c.id: c for c in pack.concepts}
+ layout = stable_layout(pack)
+ scores = {c.id: 0.0 for c in pack.concepts}
+ frames = []
+ history = 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]
+ 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"],
})
- 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
diff --git a/src/didactopus/models.py b/src/didactopus/models.py
index 4462baa..8840b5e 100644
--- a/src/didactopus/models.py
+++ b/src/didactopus/models.py
@@ -1,9 +1,5 @@
from __future__ import annotations
from pydantic import BaseModel, Field
-from typing import Literal
-
-EvidenceKind = Literal["checkpoint", "project", "exercise", "review"]
-PolicyLane = Literal["personal", "community"]
class TokenPair(BaseModel):
access_token: str
@@ -12,56 +8,22 @@ class TokenPair(BaseModel):
username: str
role: str
-class ServiceToken(BaseModel):
- access_token: str
- token_type: str = "bearer"
- service_account_name: str
- scopes: list[str]
-
class LoginRequest(BaseModel):
username: 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):
refresh_token: str
-class DeploymentPolicyProfile(BaseModel):
- profile_name: str
- default_personal_lane_enabled: bool = True
- default_community_lane_enabled: bool = True
- community_publish_requires_approval: bool = True
- personal_publish_direct: bool = True
- reviewer_assignment_required: bool = False
- description: str = ""
+class GraphPosition(BaseModel):
+ x: float
+ y: float
-class AgentCapabilityManifest(BaseModel):
- supports_pack_listing: bool = True
- supports_pack_write_personal: bool = True
- supports_pack_submit_community: bool = True
- supports_recommendations: bool = True
- supports_learner_state_read: bool = True
- supports_learner_state_write: bool = True
- supports_evaluator_jobs: bool = True
- supports_governance_endpoints: bool = True
- supports_review_queue: bool = True
- supports_service_accounts: bool = True
- supports_agent_audit_logs: bool = True
- supports_service_account_rotation: bool = True
+class CrossPackLink(BaseModel):
+ source_concept_id: str
+ target_pack_id: str
+ target_concept_id: str
+ relationship: str = "related"
class PackConcept(BaseModel):
id: str
@@ -69,13 +31,8 @@ class PackConcept(BaseModel):
prerequisites: list[str] = Field(default_factory=list)
masteryDimension: str = "mastery"
exerciseReward: str = ""
-
-class PackCompliance(BaseModel):
- sources: int = 0
- attributionRequired: bool = False
- shareAlikeRequired: bool = False
- noncommercialOnly: bool = False
- flags: list[str] = Field(default_factory=list)
+ position: GraphPosition | None = None
+ cross_pack_links: list[CrossPackLink] = Field(default_factory=list)
class PackData(BaseModel):
id: str
@@ -84,13 +41,7 @@ class PackData(BaseModel):
level: str = "novice-friendly"
concepts: list[PackConcept] = Field(default_factory=list)
onboarding: dict = Field(default_factory=dict)
- compliance: PackCompliance = Field(default_factory=PackCompliance)
-
-class CreatePackRequest(BaseModel):
- pack: PackData
- policy_lane: PolicyLane = "personal"
- is_published: bool = False
- change_summary: str = ""
+ compliance: dict = Field(default_factory=dict)
class CreateLearnerRequest(BaseModel):
learner_id: str
@@ -110,7 +61,7 @@ class EvidenceEvent(BaseModel):
score: float
confidence_hint: float = 0.5
timestamp: str
- kind: EvidenceKind = "exercise"
+ kind: str = "exercise"
source_id: str = ""
class LearnerState(BaseModel):
@@ -118,25 +69,20 @@ class LearnerState(BaseModel):
records: list[MasteryRecord] = Field(default_factory=list)
history: list[EvidenceEvent] = Field(default_factory=list)
-class EvaluatorSubmission(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):
+class MediaRenderRequest(BaseModel):
learner_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
pack_id: str
- next_cards: list[dict] = Field(default_factory=list)
- suggested_actions: list[str] = Field(default_factory=list)
+ export_kind: str = "knowledge_snapshot"
diff --git a/src/didactopus/orm.py b/src/didactopus/orm.py
index 4abc0ed..300796e 100644
--- a/src/didactopus/orm.py
+++ b/src/didactopus/orm.py
@@ -10,27 +10,6 @@ class UserORM(Base):
role: Mapped[str] = mapped_column(String(50), default="learner")
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):
__tablename__ = "refresh_tokens"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
@@ -47,10 +26,6 @@ class PackORM(Base):
subtitle: Mapped[str] = mapped_column(Text, default="")
level: Mapped[str] = mapped_column(String(100), default="novice-friendly")
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)
class LearnerORM(Base):
@@ -82,15 +57,32 @@ class EvidenceEventORM(Base):
kind: Mapped[str] = mapped_column(String(50), default="exercise")
source_id: Mapped[str] = mapped_column(String(255), default="")
-class EvaluatorJobORM(Base):
- __tablename__ = "evaluator_jobs"
+class RenderJobORM(Base):
+ __tablename__ = "render_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="")
+ learner_id: Mapped[str] = mapped_column(String(100), index=True)
+ pack_id: Mapped[str] = mapped_column(String(100), index=True)
+ requested_format: Mapped[str] = mapped_column(String(20), default="gif")
+ 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")
- 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="{}")
+ bundle_dir: Mapped[str] = mapped_column(Text, default="")
+ payload_json: Mapped[str] = mapped_column(Text, default="")
+ manifest_path: 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)
diff --git a/src/didactopus/render_bundle.py b/src/didactopus/render_bundle.py
index 97bc20b..7e29dff 100644
--- a/src/didactopus/render_bundle.py
+++ b/src/didactopus/render_bundle.py
@@ -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)}'",
])
(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}")
diff --git a/src/didactopus/repository.py b/src/didactopus/repository.py
index fb8fc6a..4610a8c 100644
--- a/src/didactopus/repository.py
+++ b/src/didactopus/repository.py
@@ -1,28 +1,10 @@
from __future__ import annotations
import json
-from datetime import datetime, timezone
from sqlalchemy import select
from .db import SessionLocal
-from .orm import UserORM, ServiceAccountORM, AgentAuditLogORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM
-from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent, DeploymentPolicyProfile
-from .auth import verify_password, hash_password
-from .config import load_settings
-
-settings = load_settings()
-
-def now_iso() -> str:
- return datetime.now(timezone.utc).isoformat()
-
-def deployment_policy_profile() -> DeploymentPolicyProfile:
- return DeploymentPolicyProfile(
- profile_name=settings.deployment_policy_profile,
- default_personal_lane_enabled=True,
- default_community_lane_enabled=True,
- community_publish_requires_approval=True,
- personal_publish_direct=True,
- reviewer_assignment_required=False,
- description="Deployment policy scaffold."
- )
+from .orm import UserORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, RenderJobORM, ArtifactORM
+from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent
+from .auth import verify_password
def get_user_by_username(username: str):
with SessionLocal() as db:
@@ -38,83 +20,6 @@ def authenticate_user(username: str, password: str):
return None
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):
with SessionLocal() as db:
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:
return db.get(PackORM, pack_id)
-def upsert_pack(pack: PackData, submitted_by_user_id: int, policy_lane: str = "personal", is_published: bool = False, change_summary: str = ""):
- validation = {"ok": len(pack.concepts) > 0, "warnings": [] if len(pack.concepts) > 0 else ["Pack has no concepts."], "errors": []}
- provenance = {"source_count": pack.compliance.sources, "restrictive_flags": list(pack.compliance.flags)}
+def upsert_pack(pack: PackData, submitted_by_user_id: int, policy_lane: str = "personal", is_published: bool = False):
with SessionLocal() as db:
row = db.get(PackORM, pack.id)
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,
level=pack.level,
data_json=payload,
- validation_json=json.dumps(validation),
- provenance_json=json.dumps(provenance),
- governance_state="personal_ready" if policy_lane == "personal" else "draft",
- current_version=1,
is_published=is_published if policy_lane == "personal" else False,
)
db.add(row)
@@ -184,9 +83,6 @@ def upsert_pack(pack: PackData, submitted_by_user_id: int, policy_lane: str = "p
row.subtitle = pack.subtitle
row.level = pack.level
row.data_json = payload
- row.validation_json = json.dumps(validation)
- row.provenance_json = json.dumps(provenance)
- row.current_version += 1
if policy_lane == "personal":
row.is_published = is_published
db.commit()
@@ -223,31 +119,117 @@ def save_learner_state(state: LearnerState):
db.commit()
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:
- 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)
+ row = RenderJobORM(
+ learner_id=learner_id,
+ pack_id=pack_id,
+ requested_format=requested_format,
+ fps=fps,
+ theme=theme,
+ status="queued",
+ )
+ db.add(row)
db.commit()
- db.refresh(job)
- return job.id
+ db.refresh(row)
+ return row.id
-def list_evaluator_jobs_for_learner(learner_id: str):
+def update_render_job(job_id: int, **fields):
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)
+ row = db.get(RenderJobORM, job_id)
+ if row is None:
+ return None
+ for k, v in fields.items():
+ setattr(row, k, v)
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
diff --git a/src/didactopus/seed.py b/src/didactopus/seed.py
index 4b29cbd..bdc7b86 100644
--- a/src/didactopus/seed.py
+++ b/src/didactopus/seed.py
@@ -3,8 +3,8 @@ from sqlalchemy import select
from .db import Base, engine, SessionLocal
from .orm import UserORM
from .auth import hash_password
-from .repository import upsert_pack
-from .models import PackData, PackConcept, PackCompliance
+from .repository import upsert_pack, create_learner
+from .models import PackData, PackConcept, GraphPosition, CrossPackLink
def main():
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:
db.add(UserORM(username="wesley", password_hash=hash_password("demo-pass"), role="admin", is_active=True))
db.commit()
+ create_learner(1, "wesley-learner", "Wesley learner")
upsert_pack(
PackData(
id="wesley-private-pack",
title="Wesley Private Pack",
subtitle="Personal pack example.",
level="novice-friendly",
- concepts=[PackConcept(id="intro", title="Intro", prerequisites=[], masteryDimension="mastery", exerciseReward="Intro marker")],
- onboarding={"headline":"Start privately","body":"Personal pack lane.","checklist":["Create pack","Use pack"]},
- compliance=PackCompliance()
+ concepts=[
+ PackConcept(id="intro", title="Intro", prerequisites=[], position=GraphPosition(x=150, y=120)),
+ 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,
policy_lane="personal",
is_published=True,
- change_summary="Initial personal pack"
)
diff --git a/src/didactopus/worker.py b/src/didactopus/worker.py
index ad99705..6202bb3 100644
--- a/src/didactopus/worker.py
+++ b/src/didactopus/worker.py
@@ -1,21 +1,43 @@
from __future__ import annotations
-from .repository import get_evaluator_job, update_evaluator_job, load_learner_state, save_learner_state
-from .engine import apply_evidence
-from .models import EvidenceEvent
-import time
+import json, tempfile
+from pathlib import Path
+from datetime import datetime, timedelta, timezone
+from .repository import update_render_job, register_artifact
+from .render_bundle import make_render_bundle
-def process_job(job_id: int):
- job = get_evaluator_job(job_id)
- 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 future_iso(days: int) -> str:
+ return (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
-def main():
- while True:
- time.sleep(60)
+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):
+ update_render_job(job_id, status="running")
+ 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))
diff --git a/tests/test_scaffold_files.py b/tests/test_scaffold_files.py
index 2e9b522..a8a1f89 100644
--- a/tests/test_scaffold_files.py
+++ b/tests/test_scaffold_files.py
@@ -3,5 +3,6 @@ from pathlib import Path
def test_scaffold_files_exist():
assert Path("src/didactopus/api.py").exists()
assert Path("src/didactopus/repository.py").exists()
- assert Path("webui/src/App.jsx").exists()
- assert Path("webui/src/api.js").exists()
+ assert Path("src/didactopus/worker.py").exists()
+ assert Path("src/didactopus/knowledge_export.py").exists()
+ assert Path("FAQ.md").exists()
diff --git a/webui/index.html b/webui/index.html
index 2a0a12d..bce756b 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -3,7 +3,7 @@
- Didactopus Agent Audit and Key Rotation
+ Didactopus Artifact Lifecycle
diff --git a/webui/package.json b/webui/package.json
index da8cee7..be233a0 100644
--- a/webui/package.json
+++ b/webui/package.json
@@ -1,5 +1,5 @@
{
- "name": "didactopus-agent-audit-ui",
+ "name": "didactopus-artifact-lifecycle-ui",
"private": true,
"version": "0.1.0",
"type": "module",
diff --git a/webui/src/App.jsx b/webui/src/App.jsx
index 3948204..7b8824a 100644
--- a/webui/src/App.jsx
+++ b/webui/src/App.jsx
@@ -1,5 +1,5 @@
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";
function LoginView({ onAuth }) {
@@ -28,13 +28,15 @@ function LoginView({ onAuth }) {
export default function App() {
const [auth, setAuth] = useState(loadAuth());
- const [policy, setPolicy] = useState(null);
- const [caps, setCaps] = useState(null);
- const [serviceAccounts, setServiceAccounts] = useState([]);
- const [auditLogs, setAuditLogs] = useState([]);
- const [created, setCreated] = useState(null);
- const [rotated, setRotated] = 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 [packs, setPacks] = useState([]);
+ const [learnerId] = useState("wesley-learner");
+ const [packId, setPackId] = useState("");
+ const [jobs, setJobs] = useState([]);
+ const [artifacts, setArtifacts] = useState([]);
+ const [knowledge, setKnowledge] = useState(null);
+ const [format, setFormat] = useState("gif");
+ const [fps, setFps] = useState(2);
+ const [message, setMessage] = useState("");
async function refreshAuthToken() {
if (!auth?.refresh_token) return null;
@@ -59,35 +61,60 @@ export default function App() {
}
}
- async function reload() {
- setPolicy(await guarded((token) => fetchDeploymentPolicy(token)));
- setCaps(await guarded((token) => fetchAgentCapabilities(token)));
- if (auth.role === "admin") {
- setServiceAccounts(await guarded((token) => listServiceAccounts(token)));
- setAuditLogs(await guarded((token) => listAgentAuditLogs(token)));
- }
+ async function reloadLists() {
+ setJobs(await guarded((token) => listRenderJobs(token, learnerId)));
+ setArtifacts(await guarded((token) => listArtifacts(token, learnerId)));
}
useEffect(() => {
if (!auth) return;
- reload();
+ async function load() {
+ const p = await guarded((token) => fetchPacks(token));
+ setPacks(p);
+ setPackId(p[0]?.id || "");
+ await reloadLists();
+ }
+ load();
}, [auth]);
- async function createNow() {
- const result = await guarded((token) => createServiceAccount(token, form));
- setCreated(result);
- await reload();
+ 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 rotateNow(name) {
- const result = await guarded((token) => rotateServiceAccount(token, name));
- setRotated(result);
- await reload();
+ async function createJob() {
+ 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 }));
+ setMessage(`Render job ${result.job_id} queued.`);
+ setTimeout(() => reloadLists(), 500);
}
- async function toggleState(name, isActive) {
- await guarded((token) => setServiceAccountState(token, name, isActive));
- await reload();
+ async function changeRetention(artifactId) {
+ await guarded((token) => updateRetention(token, artifactId, { retention_class: "archive", retention_days: 365 }));
+ 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 ;
@@ -96,61 +123,46 @@ export default function App() {
-
Didactopus agent audit + key rotation
-
Scoped machine identities with audit events, rotation, and disable controls.
-
Signed in as {auth.username} ({auth.role})
+
Didactopus artifact lifecycle + knowledge export
+
Manage artifact retention and turn learner state into reusable knowledge outputs.
+
{message}
+
+
+
+
+
+
+
+
+
+
-
-
+
- Deployment policy
- {JSON.stringify(policy, null, 2)}
- Agent capabilities
- {JSON.stringify(caps, null, 2)}
+ Render jobs
+ {JSON.stringify(jobs, null, 2)}
-
- Service accounts
- {auth.role === "admin" ? (
- <>
-
-
-
-
- Created credential
- {JSON.stringify(created, null, 2)}
- Rotated credential
- {JSON.stringify(rotated, null, 2)}
- Existing accounts
-
-
- | Name | Active | Scopes | Actions |
-
- {serviceAccounts.map((sa) => (
-
- | {sa.name} |
- {String(sa.is_active)} |
- {sa.scopes.join(", ")} |
-
-
-
- |
-
- ))}
-
-
-
- >
- ) : (
- Admin required.
- )}
+ Artifacts
+ {JSON.stringify(artifacts, null, 2)}
+ {artifacts[0] ? : null}
-
-
- Agent audit logs
- {JSON.stringify(auditLogs, null, 2)}
+
+ Knowledge export
+ {JSON.stringify(knowledge, null, 2)}
diff --git a/webui/src/api.js b/webui/src/api.js
index 3b96811..71816cb 100644
--- a/webui/src/api.js
+++ b/webui/src/api.js
@@ -16,10 +16,11 @@ export async function refresh(refreshToken) {
if (!res.ok) throw new Error("refresh failed");
return await res.json();
}
-export async function fetchDeploymentPolicy(token) { const res = await fetch(`${API}/deployment-policy`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchDeploymentPolicy failed"); return await res.json(); }
-export async function fetchAgentCapabilities(token) { const res = await fetch(`${API}/agent/capabilities`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchAgentCapabilities failed"); return await res.json(); }
-export async function listServiceAccounts(token) { const res = await fetch(`${API}/admin/service-accounts`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listServiceAccounts failed"); return await res.json(); }
-export async function createServiceAccount(token, payload) { const res = await fetch(`${API}/admin/service-accounts`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("createServiceAccount failed"); return await res.json(); }
-export async function rotateServiceAccount(token, name) { const res = await fetch(`${API}/admin/service-accounts/rotate`, { method: "POST", headers: authHeaders(token), body: JSON.stringify({ name }) }); if (!res.ok) throw new Error("rotateServiceAccount failed"); return await res.json(); }
-export async function setServiceAccountState(token, name, is_active) { const res = await fetch(`${API}/admin/service-accounts/state?name=${encodeURIComponent(name)}`, { method: "POST", headers: authHeaders(token), body: JSON.stringify({ is_active }) }); if (!res.ok) throw new Error("setServiceAccountState failed"); return await res.json(); }
-export async function listAgentAuditLogs(token) { const res = await fetch(`${API}/admin/agent-audit-logs`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listAgentAuditLogs failed"); return await res.json(); }
+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 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 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 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 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 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(); }
diff --git a/webui/src/styles.css b/webui/src/styles.css
index 5d4c37e..e2085a0 100644
--- a/webui/src/styles.css
+++ b/webui/src/styles.css
@@ -3,22 +3,21 @@
}
* { box-sizing:border-box; }
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; }
-.hero { background:var(--card); border:1px solid var(--border); border-radius:22px; padding:24px; display:flex; justify-content:space-between; gap:16px; }
-label { display:block; font-weight:600; margin-bottom:10px; }
-input { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; }
-button { border:1px solid var(--border); background:white; border-radius:12px; padding:8px 12px; cursor:pointer; margin-right:8px; }
-.primary { background:var(--accent); color:white; border-color:var(--accent); }
+.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; }
+.controls { display:flex; gap:10px; align-items:flex-end; flex-wrap:wrap; }
+label { display:block; font-weight:600; }
+input, select { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; }
+button { border:1px solid var(--border); background:white; border-radius:12px; padding:10px 14px; cursor:pointer; }
.card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; }
.narrow { margin-top:60px; }
.layout { display:grid; gap:16px; }
-.twocol { grid-template-columns:1fr 1fr; }
-.full { grid-column:1 / -1; }
-.prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:280px; }
-.prebox.tall { max-height:420px; }
+.threecol { grid-template-columns:1fr 1fr 1fr; }
+.prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:460px; }
.muted { color:var(--muted); }
.error { color:#b42318; margin-top:10px; }
-.table { width:100%; border-collapse:collapse; }
-.table th, .table td { border-bottom:1px solid var(--border); text-align:left; padding:8px; vertical-align:top; }
-@media (max-width:1100px) { .twocol { grid-template-columns:1fr; } .hero { flex-direction:column; } .full { grid-column:auto; } }
+@media (max-width:1200px) {
+ .hero { flex-direction:column; }
+ .threecol { grid-template-columns:1fr; }
+}