From 82f827b8bd542d58089c7536f96ab95d54bb47f9 Mon Sep 17 00:00:00 2001 From: welsberr Date: Sat, 14 Mar 2026 13:29:56 -0400 Subject: [PATCH] Apply ZIP update: 210-didactopus-learning-animation-layer.zip [2026-03-14T13:20:36] --- pyproject.toml | 12 +- src/didactopus/api.py | 280 ++++++++++++++++++++++++++++++----- src/didactopus/auth.py | 6 + src/didactopus/config.py | 1 + src/didactopus/engine.py | 148 +++++++----------- src/didactopus/models.py | 112 ++++++++++++-- src/didactopus/orm.py | 60 ++++++++ src/didactopus/repository.py | 211 +++++++++++++++++++++++++- src/didactopus/seed.py | 13 +- src/didactopus/worker.py | 5 +- tests/test_scaffold_files.py | 2 +- webui/index.html | 2 +- webui/package.json | 2 +- webui/src/App.jsx | 144 +++++++++--------- webui/src/api.js | 4 +- webui/src/styles.css | 12 +- 16 files changed, 776 insertions(+), 238 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1fcb487..53c7bda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,17 @@ build-backend = "setuptools.build_meta" name = "didactopus" version = "0.1.0" requires-python = ">=3.10" -dependencies = ["pydantic>=2.7", "pyyaml>=6.0"] +dependencies = [ + "pydantic>=2.7", + "fastapi>=0.115", + "uvicorn>=0.30", + "sqlalchemy>=2.0", + "passlib[bcrypt]>=1.7", + "python-jose[cryptography]>=3.3" +] + +[project.scripts] +didactopus-api = "didactopus.api:main" [tool.setuptools.packages.find] where = ["src"] diff --git a/src/didactopus/api.py b/src/didactopus/api.py index 3d03db4..4c91a9b 100644 --- a/src/didactopus/api.py +++ b/src/didactopus/api.py @@ -1,45 +1,103 @@ from __future__ import annotations -from fastapi import FastAPI, HTTPException, Header, Depends +import json +from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks from fastapi.middleware.cors import CORSMiddleware import uvicorn +from .config import load_settings from .db import Base, engine -from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState -from .repository import authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token, list_packs_for_user, get_pack, get_pack_row, create_learner, learner_owned_by_user, load_learner_state, save_learner_state -from .auth import issue_access_token, issue_refresh_token, decode_token, new_token_id -from .engine import build_graph_frames, stable_layout +from .models import ( + LoginRequest, ServiceAccountLoginRequest, ServiceAccountCreateRequest, ServiceAccountRotateRequest, + ServiceAccountStateRequest, ServiceToken, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, + EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest, AgentCapabilityManifest, + AgentLearnerPlanRequest, AgentLearnerPlanResponse, LearnerRunCreateRequest, WorkflowEventCreateRequest +) +from .repository import ( + authenticate_user, get_user_by_id, create_service_account, list_service_accounts, authenticate_service_account, + rotate_service_account_secret, set_service_account_active, add_agent_audit_log, list_agent_audit_logs, + create_learner_run, end_learner_run, list_learner_runs, add_workflow_event, list_workflow_events, + store_refresh_token, refresh_token_active, revoke_refresh_token, deployment_policy_profile, list_packs_for_user, + get_pack, get_pack_row, upsert_pack, create_learner, learner_owned_by_user, load_learner_state, + save_learner_state, create_evaluator_job, get_evaluator_job, list_evaluator_jobs_for_learner +) +from .engine import apply_evidence, recommend_next, build_animation_frames +from .auth import issue_access_token, issue_refresh_token, issue_service_access_token, decode_token, new_token_id, new_secret +from .worker import process_job +settings = load_settings() Base.metadata.create_all(bind=engine) app = FastAPI(title="Didactopus API Prototype") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) -def current_user(authorization: str = Header(default="")): +def current_actor(authorization: str = Header(default="")): token = authorization.removeprefix("Bearer ").strip() payload = decode_token(token) if token else None - if not payload or payload.get("kind") != "access": + if not payload: raise HTTPException(status_code=401, detail="Unauthorized") - 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 + 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") -def ensure_learner_access(user, learner_id: str): +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"] 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 user") + raise HTTPException(status_code=403, detail="Learner not accessible by this actor") -def ensure_pack_access(user, pack_id: str): +def ensure_pack_access(actor, pack_id: str): row = get_pack_row(pack_id) if row is None: raise HTTPException(status_code=404, detail="Pack not found") + if actor["actor_type"] == "service": + return row + user = actor["user"] if user.role == "admin": return row if row.policy_lane == "community": return row if row.owner_user_id == user.id: return row - raise HTTPException(status_code=403, detail="Pack not accessible by this user") + raise HTTPException(status_code=403, detail="Pack not accessible by this actor") @app.post("/api/login", response_model=TokenPair) def login(payload: LoginRequest): @@ -50,6 +108,14 @@ 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) @@ -66,47 +132,189 @@ 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(user = Depends(current_user)): - return [p.model_dump() for p in list_packs_for_user(user.id, include_unpublished=(user.role == "admin"))] +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"} @app.post("/api/learners") -def api_create_learner(payload: CreateLearnerRequest, user = Depends(current_user)): - create_learner(user.id, payload.learner_id, payload.display_name) +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) return {"ok": True, "learner_id": payload.learner_id} @app.get("/api/learners/{learner_id}/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() +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 @app.put("/api/learners/{learner_id}/state") -def api_put_learner_state(learner_id: str, state: LearnerState, user = Depends(current_user)): - ensure_learner_access(user, learner_id) +def api_put_learner_state(learner_id: str, state: LearnerState, actor = Depends(require_scope("learners:write"))): + ensure_learner_access(actor, learner_id) if learner_id != state.learner_id: raise HTTPException(status_code=400, detail="Learner ID mismatch") - return save_learner_state(state).model_dump() + result = save_learner_state(state).model_dump() + audit_service_action(actor, "learner_state_write", learner_id, "ok", {"records": len(result.get("records", []))}) + return result -@app.get("/api/packs/{pack_id}/layout") -def api_pack_layout(pack_id: str, user = Depends(current_user)): - ensure_pack_access(user, pack_id) +@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) pack = get_pack(pack_id) - return {"pack_id": pack_id, "layout": stable_layout(pack)} if pack else {"pack_id": pack_id, "layout": {}} + 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} -@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) +@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/evaluator-jobs/{job_id}", response_model=EvaluatorJobStatus) +def api_get_evaluator_job(job_id: int, actor = Depends(require_scope("evaluators:read"))): + job = get_evaluator_job(job_id) + if job is None: + raise HTTPException(status_code=404, detail="Job not found") + audit_service_action(actor, "evaluator_job_read", str(job_id), "ok", {}) + return EvaluatorJobStatus(job_id=job.id, status=job.status, result_score=job.result_score, result_confidence_hint=job.result_confidence_hint, result_notes=job.result_notes) + +@app.get("/api/learners/{learner_id}/evaluator-history") +def api_get_evaluator_history(learner_id: str, actor = Depends(require_scope("evaluators:read"))): + ensure_learner_access(actor, learner_id) + jobs = list_evaluator_jobs_for_learner(learner_id) + audit_service_action(actor, "evaluator_history_read", learner_id, "ok", {"jobs": len(jobs)}) + return [{"job_id": j.id, "status": j.status, "concept_id": j.concept_id, "result_score": j.result_score, "result_confidence_hint": j.result_confidence_hint, "result_notes": j.result_notes} for j in jobs] + +@app.post("/api/learner-runs") +def api_create_learner_run(payload: LearnerRunCreateRequest, actor = Depends(current_actor)): + ensure_learner_access(actor, payload.learner_id) + ensure_pack_access(actor, payload.pack_id) + actor_name = actor["service_account_name"] if actor["actor_type"] == "service" else actor["user"].username + run_id = create_learner_run(payload.learner_id, payload.pack_id, payload.actor_kind, actor_name, payload.title) + audit_service_action(actor, "learner_run_create", str(run_id), "ok", {"learner_id": payload.learner_id, "pack_id": payload.pack_id}) + return {"run_id": run_id} + +@app.post("/api/learner-runs/{run_id}/complete") +def api_complete_learner_run(run_id: int, actor = Depends(current_actor)): + row = end_learner_run(run_id) + if row is None: + raise HTTPException(status_code=404, detail="Run not found") + audit_service_action(actor, "learner_run_complete", str(run_id), "ok", {}) + return {"run_id": run_id, "status": row.status} + +@app.get("/api/learners/{learner_id}/runs") +def api_list_learner_runs(learner_id: str, actor = Depends(current_actor)): + ensure_learner_access(actor, learner_id) + return list_learner_runs(learner_id) + +@app.post("/api/workflow-events") +def api_add_workflow_event(payload: WorkflowEventCreateRequest, actor = Depends(current_actor)): + ensure_learner_access(actor, payload.learner_id) + event_id = add_workflow_event(payload.run_id, payload.learner_id, payload.event_type, payload.concept_id, payload.timestamp, payload.detail) + audit_service_action(actor, "workflow_event_add", str(event_id), "ok", {"run_id": payload.run_id, "event_type": payload.event_type}) + return {"event_id": event_id} + +@app.get("/api/learner-runs/{run_id}/workflow-events") +def api_list_workflow_events(run_id: int, actor = Depends(current_actor)): + events = list_workflow_events(run_id) + audit_service_action(actor, "workflow_events_read", str(run_id), "ok", {"count": len(events)}) + return events + +@app.get("/api/learners/{learner_id}/animation/{pack_id}") +def api_learning_animation(learner_id: str, pack_id: str, actor = Depends(current_actor)): + ensure_learner_access(actor, learner_id) + ensure_pack_access(actor, pack_id) pack = get_pack(pack_id) state = load_learner_state(learner_id) - frames = build_graph_frames(state, pack) + frames = build_animation_frames(state) + audit_service_action(actor, "learning_animation_read", f"{learner_id}:{pack_id}", "ok", {"frames": len(frames)}) 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 [], + "concepts": [c.id for c in pack.concepts] if pack else [], } def main(): - uvicorn.run(app, host="127.0.0.1", port=8011) + uvicorn.run(app, host=settings.host, port=settings.port) diff --git a/src/didactopus/auth.py b/src/didactopus/auth.py index 54745ac..c7b3117 100644 --- a/src/didactopus/auth.py +++ b/src/didactopus/auth.py @@ -25,6 +25,9 @@ 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]) @@ -33,3 +36,6 @@ 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 1f71733..88e5551 100644 --- a/src/didactopus/config.py +++ b/src/didactopus/config.py @@ -8,6 +8,7 @@ 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 6b7c236..1531303 100644 --- a/src/didactopus/engine.py +++ b/src/didactopus/engine.py @@ -1,110 +1,76 @@ from __future__ import annotations -from collections import defaultdict -from .models import LearnerState, PackData +from .models import LearnerState, EvidenceEvent, MasteryRecord, PackData -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 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 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 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 prereqs_satisfied(scores: dict[str, float], concept, min_score: float = 0.65) -> bool: +def prereqs_satisfied(state: LearnerState, concept, min_score: float = 0.65, min_confidence: float = 0.45) -> bool: for pid in concept.prerequisites: - if scores.get(pid, 0.0) < min_score: + rec = get_record(state, pid, concept.masteryDimension) + if rec is None or rec.score < min_score or rec.confidence < min_confidence: return False return True -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: +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: return "mastered" - if prereqs_satisfied(scores, concept, min_score): - return "active" if score > 0 else "available" + if prereqs_satisfied(state, concept, min_score, min_confidence): + return "active" if rec else "available" return "locked" -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"], +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, }) + return cards[:4] + +def build_animation_frames(state: LearnerState): + concepts = sorted({ev.concept_id for ev in state.history} | {r.concept_id for r in state.records}) + frames = [] + running = {c: 0.0 for c in concepts} + for idx, ev in enumerate(sorted(state.history, key=lambda x: x.timestamp)): + running[ev.concept_id] = ev.score 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, + "concept_id": ev.concept_id, + "scores": dict(running), }) 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}) + frames.append({"index": 0, "timestamp": "", "event_kind": "empty", "concept_id": "", "scores": dict(running)}) return frames diff --git a/src/didactopus/models.py b/src/didactopus/models.py index 42e0f8a..2d99f28 100644 --- a/src/didactopus/models.py +++ b/src/didactopus/models.py @@ -1,5 +1,9 @@ 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 @@ -8,22 +12,58 @@ 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 GraphPosition(BaseModel): - x: float - y: float +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 CrossPackLink(BaseModel): - source_concept_id: str - target_pack_id: str - target_concept_id: str - relationship: str = "related" +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 + supports_learner_runs: bool = True + supports_learning_animation: bool = True class PackConcept(BaseModel): id: str @@ -31,8 +71,13 @@ class PackConcept(BaseModel): prerequisites: list[str] = Field(default_factory=list) masteryDimension: str = "mastery" exerciseReward: str = "" - position: GraphPosition | None = None - cross_pack_links: list[CrossPackLink] = Field(default_factory=list) + +class PackCompliance(BaseModel): + sources: int = 0 + attributionRequired: bool = False + shareAlikeRequired: bool = False + noncommercialOnly: bool = False + flags: list[str] = Field(default_factory=list) class PackData(BaseModel): id: str @@ -41,12 +86,32 @@ class PackData(BaseModel): level: str = "novice-friendly" concepts: list[PackConcept] = Field(default_factory=list) onboarding: dict = Field(default_factory=dict) - compliance: 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 = "" class CreateLearnerRequest(BaseModel): learner_id: str display_name: str = "" +class LearnerRunCreateRequest(BaseModel): + learner_id: str + pack_id: str + title: str = "" + actor_kind: str = "human" + +class WorkflowEventCreateRequest(BaseModel): + run_id: int + learner_id: str + event_type: str + concept_id: str = "" + timestamp: str + detail: dict = Field(default_factory=dict) + class MasteryRecord(BaseModel): concept_id: str dimension: str @@ -61,10 +126,33 @@ class EvidenceEvent(BaseModel): score: float confidence_hint: float = 0.5 timestamp: str - kind: str = "exercise" + kind: EvidenceKind = "exercise" source_id: str = "" class LearnerState(BaseModel): learner_id: str 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): + learner_id: str + pack_id: str + +class AgentLearnerPlanResponse(BaseModel): + learner_id: str + pack_id: str + next_cards: list[dict] = Field(default_factory=list) + suggested_actions: list[str] = Field(default_factory=list) diff --git a/src/didactopus/orm.py b/src/didactopus/orm.py index 49c1d57..55eed2d 100644 --- a/src/didactopus/orm.py +++ b/src/didactopus/orm.py @@ -10,6 +10,49 @@ 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 LearnerRunORM(Base): + __tablename__ = "learner_runs" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + learner_id: Mapped[str] = mapped_column(String(100), index=True) + pack_id: Mapped[str] = mapped_column(String(100), index=True) + actor_kind: Mapped[str] = mapped_column(String(50), default="human") + actor_name: Mapped[str] = mapped_column(String(120), default="") + title: Mapped[str] = mapped_column(String(255), default="") + status: Mapped[str] = mapped_column(String(50), default="running") + started_at: Mapped[str] = mapped_column(String(100), default="") + ended_at: Mapped[str] = mapped_column(String(100), default="") + +class WorkflowEventORM(Base): + __tablename__ = "workflow_events" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + run_id: Mapped[int] = mapped_column(ForeignKey("learner_runs.id"), index=True) + learner_id: Mapped[str] = mapped_column(String(100), index=True) + event_type: Mapped[str] = mapped_column(String(100), index=True) + concept_id: Mapped[str] = mapped_column(String(100), default="") + timestamp: Mapped[str] = mapped_column(String(100), default="") + detail_json: Mapped[str] = mapped_column(Text, default="{}") + class RefreshTokenORM(Base): __tablename__ = "refresh_tokens" id: Mapped[int] = mapped_column(Integer, primary_key=True) @@ -26,6 +69,10 @@ 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): @@ -56,3 +103,16 @@ class EvidenceEventORM(Base): timestamp: Mapped[str] = mapped_column(String(100), default="") kind: Mapped[str] = mapped_column(String(50), default="exercise") source_id: Mapped[str] = mapped_column(String(255), default="") + +class EvaluatorJobORM(Base): + __tablename__ = "evaluator_jobs" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + learner_id: Mapped[str] = mapped_column(ForeignKey("learners.id"), index=True) + pack_id: Mapped[str] = mapped_column(ForeignKey("packs.id"), index=True) + concept_id: Mapped[str] = mapped_column(String(100), index=True) + submitted_text: Mapped[str] = mapped_column(Text, default="") + status: Mapped[str] = mapped_column(String(50), default="queued") + result_score: Mapped[float | None] = mapped_column(Float, nullable=True) + result_confidence_hint: Mapped[float | None] = mapped_column(Float, nullable=True) + result_notes: Mapped[str] = mapped_column(Text, default="") + trace_json: Mapped[str] = mapped_column(Text, default="{}") diff --git a/src/didactopus/repository.py b/src/didactopus/repository.py index bc8e397..a53d041 100644 --- a/src/didactopus/repository.py +++ b/src/didactopus/repository.py @@ -1,10 +1,28 @@ from __future__ import annotations import json +from datetime import datetime, timezone from sqlalchemy import select from .db import SessionLocal -from .orm import UserORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM -from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent -from .auth import verify_password +from .orm import UserORM, ServiceAccountORM, AgentAuditLogORM, LearnerRunORM, WorkflowEventORM, 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." + ) def get_user_by_username(username: str): with SessionLocal() as db: @@ -20,6 +38,153 @@ 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 create_learner_run(learner_id: str, pack_id: str, actor_kind: str, actor_name: str, title: str): + with SessionLocal() as db: + row = LearnerRunORM( + learner_id=learner_id, + pack_id=pack_id, + actor_kind=actor_kind, + actor_name=actor_name, + title=title, + status="running", + started_at=now_iso(), + ended_at="", + ) + db.add(row) + db.commit() + db.refresh(row) + return row.id + +def end_learner_run(run_id: int): + with SessionLocal() as db: + row = db.get(LearnerRunORM, run_id) + if row is None: + return None + row.status = "completed" + row.ended_at = now_iso() + db.commit() + return row + +def list_learner_runs(learner_id: str): + with SessionLocal() as db: + rows = db.execute(select(LearnerRunORM).where(LearnerRunORM.learner_id == learner_id).order_by(LearnerRunORM.id.desc())).scalars().all() + return [{ + "run_id": r.id, + "learner_id": r.learner_id, + "pack_id": r.pack_id, + "actor_kind": r.actor_kind, + "actor_name": r.actor_name, + "title": r.title, + "status": r.status, + "started_at": r.started_at, + "ended_at": r.ended_at, + } for r in rows] + +def add_workflow_event(run_id: int, learner_id: str, event_type: str, concept_id: str, timestamp: str, detail: dict): + with SessionLocal() as db: + row = WorkflowEventORM( + run_id=run_id, + learner_id=learner_id, + event_type=event_type, + concept_id=concept_id, + timestamp=timestamp, + detail_json=json.dumps(detail), + ) + db.add(row) + db.commit() + db.refresh(row) + return row.id + +def list_workflow_events(run_id: int): + with SessionLocal() as db: + rows = db.execute(select(WorkflowEventORM).where(WorkflowEventORM.run_id == run_id).order_by(WorkflowEventORM.id)).scalars().all() + return [{ + "id": r.id, + "run_id": r.run_id, + "learner_id": r.learner_id, + "event_type": r.event_type, + "concept_id": r.concept_id, + "timestamp": r.timestamp, + "detail": json.loads(r.detail_json or "{}"), + } for r in rows] + def store_refresh_token(user_id: int, token_id: str): with SessionLocal() as db: db.add(RefreshTokenORM(user_id=user_id, token_id=token_id, is_revoked=False)) @@ -60,7 +225,9 @@ 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): +def upsert_pack(pack: PackData, submitted_by_user_id: int, policy_lane: str = "personal", is_published: bool = False, change_summary: str = ""): + validation = {"ok": len(pack.concepts) > 0, "warnings": [] if len(pack.concepts) > 0 else ["Pack has no concepts."], "errors": []} + provenance = {"source_count": pack.compliance.sources, "restrictive_flags": list(pack.compliance.flags)} with SessionLocal() as db: row = db.get(PackORM, pack.id) payload = json.dumps(pack.model_dump()) @@ -73,6 +240,10 @@ 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) @@ -83,6 +254,9 @@ 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() @@ -118,3 +292,32 @@ def save_learner_state(state: LearnerState): db.add(EvidenceEventORM(learner_id=state.learner_id, concept_id=h.concept_id, dimension=h.dimension, score=h.score, confidence_hint=h.confidence_hint, timestamp=h.timestamp, kind=h.kind, source_id=h.source_id)) db.commit() return state + +def create_evaluator_job(learner_id: str, pack_id: str, concept_id: str, submitted_text: str): + with SessionLocal() as db: + job = EvaluatorJobORM(learner_id=learner_id, pack_id=pack_id, concept_id=concept_id, submitted_text=submitted_text, status="queued", trace_json=json.dumps({"notes": ["Job queued"]})) + db.add(job) + db.commit() + db.refresh(job) + return job.id + +def list_evaluator_jobs_for_learner(learner_id: str): + with SessionLocal() as db: + return db.execute(select(EvaluatorJobORM).where(EvaluatorJobORM.learner_id == learner_id).order_by(EvaluatorJobORM.id.desc())).scalars().all() + +def get_evaluator_job(job_id: int): + with SessionLocal() as db: + return db.get(EvaluatorJobORM, job_id) + +def update_evaluator_job(job_id: int, status: str, score: float | None = None, confidence_hint: float | None = None, notes: str = "", trace: dict | None = None): + with SessionLocal() as db: + job = db.get(EvaluatorJobORM, job_id) + if job is None: + return + job.status = status + job.result_score = score + job.result_confidence_hint = confidence_hint + job.result_notes = notes + if trace is not None: + job.trace_json = json.dumps(trace) + db.commit() diff --git a/src/didactopus/seed.py b/src/didactopus/seed.py index bdc7b86..c7562a3 100644 --- a/src/didactopus/seed.py +++ b/src/didactopus/seed.py @@ -4,7 +4,7 @@ from .db import Base, engine, SessionLocal from .orm import UserORM from .auth import hash_password from .repository import upsert_pack, create_learner -from .models import PackData, PackConcept, GraphPosition, CrossPackLink +from .models import PackData, PackConcept, PackCompliance def main(): Base.metadata.create_all(bind=engine) @@ -20,15 +20,14 @@ def main(): subtitle="Personal pack example.", level="novice-friendly", 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)), + PackConcept(id="intro", title="Intro", prerequisites=[], masteryDimension="mastery", exerciseReward="Intro marker"), + PackConcept(id="second", title="Second concept", prerequisites=["intro"], masteryDimension="mastery", exerciseReward="Second marker"), ], - onboarding={"headline":"Start privately"}, - compliance={} + onboarding={"headline":"Start privately","body":"Personal pack lane.","checklist":["Create pack","Use pack"]}, + compliance=PackCompliance() ), submitted_by_user_id=1, policy_lane="personal", is_published=True, + change_summary="Initial personal pack" ) diff --git a/src/didactopus/worker.py b/src/didactopus/worker.py index 6d28da5..ad99705 100644 --- a/src/didactopus/worker.py +++ b/src/didactopus/worker.py @@ -8,17 +8,14 @@ def process_job(job_id: int): job = get_evaluator_job(job_id) if job is None: return - update_evaluator_job(job_id, "running", trace={"rubric_dimension_scores": [{"dimension": "mastery", "score": 0.35}], "notes": ["Job running", "Prototype trace generated"], "token_count_estimate": len(job.submitted_text.split())}) 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." - trace = {"rubric_dimension_scores": [{"dimension": "mastery", "score": score}], "notes": ["Prototype evaluator completed", notes], "token_count_estimate": len(job.submitted_text.split()), "decision_basis": ["response length heuristic", "single-dimension mastery proxy"]} - update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes, trace=trace) + update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes, trace={"notes": ["completed"]}) state = load_learner_state(job.learner_id) state = apply_evidence(state, EvidenceEvent(concept_id=job.concept_id, dimension="mastery", score=score, confidence_hint=confidence_hint, timestamp="2026-03-13T12:00:00+00:00", kind="review", source_id=f"evaluator-job-{job_id}")) save_learner_state(state) def main(): - print("Didactopus worker scaffold running. Replace this with a real queue worker.") while True: time.sleep(60) diff --git a/tests/test_scaffold_files.py b/tests/test_scaffold_files.py index c028d17..2e9b522 100644 --- a/tests/test_scaffold_files.py +++ b/tests/test_scaffold_files.py @@ -3,5 +3,5 @@ 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("src/didactopus/export_svg.py").exists() assert Path("webui/src/App.jsx").exists() + assert Path("webui/src/api.js").exists() diff --git a/webui/index.html b/webui/index.html index 8220d96..7b86087 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3,7 +3,7 @@ - Didactopus Layout-Aware Graph + Didactopus Learning Animation
diff --git a/webui/package.json b/webui/package.json index 467065d..d852efb 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,5 +1,5 @@ { - "name": "didactopus-layout-aware-graph-ui", + "name": "didactopus-learning-animation-ui", "private": true, "version": "0.1.0", "type": "module", diff --git a/webui/src/App.jsx b/webui/src/App.jsx index df96ee9..19ba5d8 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -1,5 +1,5 @@ -import React, { useEffect, useState } from "react"; -import { login, refresh, fetchPacks, fetchLearnerState, putLearnerState, fetchGraphAnimation } from "./api"; +import React, { useEffect, useMemo, useState } from "react"; +import { login, refresh, fetchPacks, fetchLearnerState, putLearnerState, createLearnerRun, addWorkflowEvent, fetchAnimation } from "./api"; import { loadAuth, saveAuth, clearAuth } from "./authStore"; function LoginView({ onAuth }) { @@ -26,41 +26,23 @@ function LoginView({ onAuth }) { ); } -function nodeColor(status) { - if (status === "mastered") return "#1f7a1f"; - if (status === "active") return "#2d6cdf"; - if (status === "available") return "#c48a00"; - return "#9aa4b2"; -} - -function GraphView({ frame }) { +function AnimationBars({ frame, concepts }) { if (!frame) return null; return ( - - {frame.edges.map((edge, idx) => { - const s = frame.nodes.find((n) => n.id === edge.source); - const t = frame.nodes.find((n) => n.id === edge.target); - if (!s || !t) return null; - return ; +
+ {concepts.map((concept) => { + const value = frame.scores?.[concept] ?? 0; + return ( +
+
{concept}
+
+
+
+
{value.toFixed(2)}
+
+ ); })} - {frame.cross_pack_links.map((edge, idx) => { - const s = frame.nodes.find((n) => n.id === edge.source); - if (!s) return null; - return ; - })} - - - - - - {frame.nodes.map((node) => ( - - - {node.title} - {node.score.toFixed(2)} ยท {node.status} - - ))} - +
); } @@ -69,7 +51,7 @@ export default function App() { const [packs, setPacks] = useState([]); const [learnerId] = useState("wesley-learner"); const [packId, setPackId] = useState(""); - const [graphData, setGraphData] = useState(null); + const [animation, setAnimation] = useState(null); const [frameIndex, setFrameIndex] = useState(0); const [playing, setPlaying] = useState(false); const [message, setMessage] = useState(""); @@ -97,9 +79,9 @@ export default function App() { } } - async function reload(pid) { - const data = await guarded((token) => fetchGraphAnimation(token, learnerId, pid)); - setGraphData(data); + async function reloadAnimation(pid) { + const data = await guarded((token) => fetchAnimation(token, learnerId, pid)); + setAnimation(data); setFrameIndex(0); } @@ -110,80 +92,92 @@ export default function App() { setPacks(p); const pid = p[0]?.id || ""; setPackId(pid); - if (pid) await reload(pid); + if (pid) await reloadAnimation(pid); } load(); }, [auth]); useEffect(() => { - if (!playing || !graphData?.frames?.length) return; + if (!playing || !animation?.frames?.length) return; const t = setInterval(() => { - setFrameIndex((idx) => idx >= graphData.frames.length - 1 ? 0 : idx + 1); + setFrameIndex((idx) => { + if (idx >= animation.frames.length - 1) { + return 0; + } + return idx + 1; + }); }, 900); return () => clearInterval(t); - }, [playing, graphData]); + }, [playing, animation]); - async function generateDemo() { + const currentFrame = animation?.frames?.[frameIndex]; + const concepts = useMemo(() => animation?.concepts || [], [animation]); + + async function simulateLearning() { + const run = await guarded((token) => createLearnerRun(token, { learner_id: learnerId, pack_id: packId, title: "Demo animation run", actor_kind: "human" })); 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); + const now1 = new Date().toISOString(); + state.history.push({ concept_id: "intro", dimension: "mastery", score: 0.35, confidence_hint: 0.5, timestamp: now1, kind: "exercise", source_id: "demo-1" }); + state.records = [{ concept_id: "intro", dimension: "mastery", score: 0.35, confidence: 0.35, evidence_count: 1, last_updated: now1 }]; await guarded((token) => putLearnerState(token, learnerId, state)); - await reload(packId); - setMessage("Stable-layout graph demo generated."); + await guarded((token) => addWorkflowEvent(token, { run_id: run.run_id, learner_id: learnerId, event_type: "exercise_completed", concept_id: "intro", timestamp: now1, detail: { score: 0.35 } })); + + const now2 = new Date(Date.now() + 1000).toISOString(); + state.history.push({ concept_id: "intro", dimension: "mastery", score: 0.75, confidence_hint: 0.7, timestamp: now2, kind: "review", source_id: "demo-2" }); + state.records = [{ concept_id: "intro", dimension: "mastery", score: 0.75, confidence: 0.68, evidence_count: 2, last_updated: now2 }]; + await guarded((token) => putLearnerState(token, learnerId, state)); + await guarded((token) => addWorkflowEvent(token, { run_id: run.run_id, learner_id: learnerId, event_type: "review_completed", concept_id: "intro", timestamp: now2, detail: { score: 0.75 } })); + + const now3 = new Date(Date.now() + 2000).toISOString(); + state.history.push({ concept_id: "second", dimension: "mastery", score: 0.45, confidence_hint: 0.5, timestamp: now3, kind: "exercise", source_id: "demo-3" }); + state.records = [ + { concept_id: "intro", dimension: "mastery", score: 0.75, confidence: 0.68, evidence_count: 2, last_updated: now2 }, + { concept_id: "second", dimension: "mastery", score: 0.45, confidence: 0.40, evidence_count: 1, last_updated: now3 }, + ]; + await guarded((token) => putLearnerState(token, learnerId, state)); + await guarded((token) => addWorkflowEvent(token, { run_id: run.run_id, learner_id: learnerId, event_type: "unlock_progress", concept_id: "second", timestamp: now3, detail: { score: 0.45 } })); + + await reloadAnimation(packId); + setMessage(`Demo run ${run.run_id} generated animation frames.`); } if (!auth) return ; - const frame = graphData?.frames?.[frameIndex]; - return (
-

Didactopus layout-aware graph engine

-

Stable node positions, cross-pack links, and export-ready graph frames.

+

Didactopus learning animation

+

Replay mastery changes for human or AI learners over time.

{message}
- +
-

Graph animation

+

Current frame

-
Frame: {frameIndex + 1} / {graphData?.frames?.length || 0}
-
Event: {frame?.event_kind || "-"}
-
Focus: {frame?.focus_concept_id || "-"}
-
Timestamp: {frame?.timestamp || "-"}
+
Frame: {frameIndex + 1} / {animation?.frames?.length || 0}
+
Event: {currentFrame?.event_kind || "-"}
+
Concept: {currentFrame?.concept_id || "-"}
+
Timestamp: {currentFrame?.timestamp || "-"}
- +
+
-

Frame payload

-
{JSON.stringify(graphData, null, 2)}
+

Animation data

+
{JSON.stringify(animation, null, 2)}
diff --git a/webui/src/api.js b/webui/src/api.js index 975a975..dfa210b 100644 --- a/webui/src/api.js +++ b/webui/src/api.js @@ -19,4 +19,6 @@ export async function refresh(refreshToken) { export async function fetchPacks(token) { const res = await fetch(`${API}/packs`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPacks failed"); return await res.json(); } export async function 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 fetchGraphAnimation(token, learnerId, packId) { const res = await fetch(`${API}/learners/${learnerId}/graph-animation/${packId}`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchGraphAnimation failed"); return await res.json(); } +export async function createLearnerRun(token, payload) { const res = await fetch(`${API}/learner-runs`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("createLearnerRun failed"); return await res.json(); } +export async function addWorkflowEvent(token, payload) { const res = await fetch(`${API}/workflow-events`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("addWorkflowEvent failed"); return await res.json(); } +export async function fetchAnimation(token, learnerId, packId) { const res = await fetch(`${API}/learners/${learnerId}/animation/${packId}`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchAnimation failed"); return await res.json(); } diff --git a/webui/src/styles.css b/webui/src/styles.css index 10bbea0..8743edb 100644 --- a/webui/src/styles.css +++ b/webui/src/styles.css @@ -10,19 +10,23 @@ body { margin:0; font-family:Arial, Helvetica, sans-serif; background:var(--bg); 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; } +.primary { background:var(--accent); color:white; border-color:var(--accent); } .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; } -.prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:460px; } +.prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:420px; } .muted { color:var(--muted); } .error { color:#b42318; margin-top:10px; } .frame-meta { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:16px; } -.graph { width:100%; height:auto; border:1px solid var(--border); border-radius:16px; background:#fbfcfe; } -.svg-label { font-size:12px; fill:#fff; font-weight:bold; } -.svg-small { font-size:10px; fill:#fff; } +.bars { display:grid; gap:12px; } +.bar-row { display:grid; grid-template-columns:140px 1fr 70px; gap:10px; align-items:center; } +.bar-track { height:22px; border-radius:999px; background:#eef2f7; overflow:hidden; border:1px solid var(--border); } +.bar-fill { height:100%; background:var(--accent); border-radius:999px; } +.bar-label, .bar-value { font-size:14px; } @media (max-width:1100px) { .hero { flex-direction:column; } .twocol { grid-template-columns:1fr; } .frame-meta { grid-template-columns:1fr; } + .bar-row { grid-template-columns:1fr; } }