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

-
- - - - {serviceAccounts.map((sa) => ( - - - - - - - ))} - -
NameActiveScopesActions
{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; } +}