Apply ZIP update: 210-didactopus-learning-animation-layer.zip [2026-03-14T13:20:36]

This commit is contained in:
welsberr 2026-03-14 13:29:56 -04:00
parent ed60c1d8f2
commit 82f827b8bd
16 changed files with 776 additions and 238 deletions

View File

@ -6,7 +6,17 @@ build-backend = "setuptools.build_meta"
name = "didactopus" name = "didactopus"
version = "0.1.0" version = "0.1.0"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = ["pydantic>=2.7", "pyyaml>=6.0"] dependencies = [
"pydantic>=2.7",
"fastapi>=0.115",
"uvicorn>=0.30",
"sqlalchemy>=2.0",
"passlib[bcrypt]>=1.7",
"python-jose[cryptography]>=3.3"
]
[project.scripts]
didactopus-api = "didactopus.api:main"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]

View File

@ -1,45 +1,103 @@
from __future__ import annotations 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 from fastapi.middleware.cors import CORSMiddleware
import uvicorn import uvicorn
from .config import load_settings
from .db import Base, engine from .db import Base, engine
from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState from .models import (
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 LoginRequest, ServiceAccountLoginRequest, ServiceAccountCreateRequest, ServiceAccountRotateRequest,
from .auth import issue_access_token, issue_refresh_token, decode_token, new_token_id ServiceAccountStateRequest, ServiceToken, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState,
from .engine import build_graph_frames, stable_layout 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) Base.metadata.create_all(bind=engine)
app = FastAPI(title="Didactopus API Prototype") app = FastAPI(title="Didactopus API Prototype")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
def current_user(authorization: str = Header(default="")): def current_actor(authorization: str = Header(default="")):
token = authorization.removeprefix("Bearer ").strip() token = authorization.removeprefix("Bearer ").strip()
payload = decode_token(token) if token else None payload = decode_token(token) if token else None
if not payload or payload.get("kind") != "access": if not payload:
raise HTTPException(status_code=401, detail="Unauthorized") raise HTTPException(status_code=401, detail="Unauthorized")
if payload.get("kind") == "access":
user = get_user_by_id(int(payload["sub"])) user = get_user_by_id(int(payload["sub"]))
if user is None or not user.is_active: if user is None or not user.is_active:
raise HTTPException(status_code=401, detail="Unauthorized") raise HTTPException(status_code=401, detail="Unauthorized")
return user 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": if user.role == "admin":
return return
if not learner_owned_by_user(user.id, learner_id): if not learner_owned_by_user(user.id, learner_id):
raise HTTPException(status_code=403, detail="Learner not accessible by this 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) row = get_pack_row(pack_id)
if row is None: if row is None:
raise HTTPException(status_code=404, detail="Pack not found") raise HTTPException(status_code=404, detail="Pack not found")
if actor["actor_type"] == "service":
return row
user = actor["user"]
if user.role == "admin": if user.role == "admin":
return row return row
if row.policy_lane == "community": if row.policy_lane == "community":
return row return row
if row.owner_user_id == user.id: if row.owner_user_id == user.id:
return row return row
raise HTTPException(status_code=403, detail="Pack not accessible by this user") raise HTTPException(status_code=403, detail="Pack not accessible by this actor")
@app.post("/api/login", response_model=TokenPair) @app.post("/api/login", response_model=TokenPair)
def login(payload: LoginRequest): def login(payload: LoginRequest):
@ -50,6 +108,14 @@ def login(payload: LoginRequest):
store_refresh_token(user.id, token_id) store_refresh_token(user.id, token_id)
return TokenPair(access_token=issue_access_token(user.id, user.username, user.role), refresh_token=issue_refresh_token(user.id, user.username, user.role, token_id), username=user.username, role=user.role) return TokenPair(access_token=issue_access_token(user.id, user.username, user.role), refresh_token=issue_refresh_token(user.id, user.username, user.role, token_id), username=user.username, role=user.role)
@app.post("/api/service-accounts/login", response_model=ServiceToken)
def service_login(payload: ServiceAccountLoginRequest):
sa = authenticate_service_account(payload.name, payload.secret)
if sa is None:
raise HTTPException(status_code=401, detail="Invalid service account credentials")
scopes = json.loads(sa.scopes_json or "[]")
return ServiceToken(access_token=issue_service_access_token(sa.id, sa.name, scopes), service_account_name=sa.name, scopes=scopes)
@app.post("/api/refresh", response_model=TokenPair) @app.post("/api/refresh", response_model=TokenPair)
def refresh(payload: RefreshRequest): def refresh(payload: RefreshRequest):
data = decode_token(payload.refresh_token) data = decode_token(payload.refresh_token)
@ -66,47 +132,189 @@ def refresh(payload: RefreshRequest):
store_refresh_token(user.id, new_jti) store_refresh_token(user.id, new_jti)
return TokenPair(access_token=issue_access_token(user.id, user.username, user.role), refresh_token=issue_refresh_token(user.id, user.username, user.role, new_jti), username=user.username, role=user.role) return TokenPair(access_token=issue_access_token(user.id, user.username, user.role), refresh_token=issue_refresh_token(user.id, user.username, user.role, new_jti), username=user.username, role=user.role)
@app.get("/api/deployment-policy")
def api_deployment_policy(actor = Depends(current_actor)):
return deployment_policy_profile().model_dump()
@app.get("/api/agent/capabilities", response_model=AgentCapabilityManifest)
def api_agent_capabilities(actor = Depends(current_actor)):
return AgentCapabilityManifest()
@app.post("/api/agent/learner-plan", response_model=AgentLearnerPlanResponse)
def api_agent_learner_plan(payload: AgentLearnerPlanRequest, actor = Depends(require_scope("recommendations:read"))):
ensure_learner_access(actor, payload.learner_id)
ensure_pack_access(actor, payload.pack_id)
state = load_learner_state(payload.learner_id)
pack = get_pack(payload.pack_id)
if pack is None:
raise HTTPException(status_code=404, detail="Pack not found")
cards = recommend_next(state, pack)
audit_service_action(actor, "agent_learner_plan", f"{payload.learner_id}:{payload.pack_id}", "ok", {"cards": len(cards)})
return AgentLearnerPlanResponse(learner_id=payload.learner_id, pack_id=payload.pack_id, next_cards=cards, suggested_actions=["Read learner state", "Choose next card", "Submit evidence", "Refresh recommendations"])
@app.post("/api/admin/service-accounts")
def api_create_service_account(payload: ServiceAccountCreateRequest, user = Depends(require_admin)):
secret = new_secret()
sa = create_service_account(payload.name, user.id, payload.description, payload.scopes, secret)
return {"id": sa.id, "name": sa.name, "scopes": payload.scopes, "secret": secret}
@app.get("/api/admin/service-accounts")
def api_list_service_accounts(user = Depends(require_admin)):
return list_service_accounts()
@app.post("/api/admin/service-accounts/rotate")
def api_rotate_service_account(payload: ServiceAccountRotateRequest, user = Depends(require_admin)):
secret = new_secret()
sa = rotate_service_account_secret(payload.name, secret)
if sa is None:
raise HTTPException(status_code=404, detail="Service account not found")
return {"name": sa.name, "secret": secret}
@app.post("/api/admin/service-accounts/state")
def api_service_account_state(payload: ServiceAccountStateRequest, name: str, user = Depends(require_admin)):
sa = set_service_account_active(name, payload.is_active)
if sa is None:
raise HTTPException(status_code=404, detail="Service account not found")
return {"name": sa.name, "is_active": sa.is_active}
@app.get("/api/admin/agent-audit-logs")
def api_agent_audit_logs(user = Depends(require_admin)):
return list_agent_audit_logs()
@app.get("/api/packs") @app.get("/api/packs")
def api_list_packs(user = Depends(current_user)): def api_list_packs(actor = Depends(require_scope("packs:read"))):
return [p.model_dump() for p in list_packs_for_user(user.id, include_unpublished=(user.role == "admin"))] 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") @app.post("/api/learners")
def api_create_learner(payload: CreateLearnerRequest, user = Depends(current_user)): def api_create_learner(payload: CreateLearnerRequest, actor = Depends(require_scope("learners:write"))):
create_learner(user.id, payload.learner_id, payload.display_name) 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} return {"ok": True, "learner_id": payload.learner_id}
@app.get("/api/learners/{learner_id}/state") @app.get("/api/learners/{learner_id}/state")
def api_get_learner_state(learner_id: str, user = Depends(current_user)): def api_get_learner_state(learner_id: str, actor = Depends(require_scope("learners:read"))):
ensure_learner_access(user, learner_id) ensure_learner_access(actor, learner_id)
return load_learner_state(learner_id).model_dump() 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") @app.put("/api/learners/{learner_id}/state")
def api_put_learner_state(learner_id: str, state: LearnerState, user = Depends(current_user)): def api_put_learner_state(learner_id: str, state: LearnerState, actor = Depends(require_scope("learners:write"))):
ensure_learner_access(user, learner_id) ensure_learner_access(actor, learner_id)
if learner_id != state.learner_id: if learner_id != state.learner_id:
raise HTTPException(status_code=400, detail="Learner ID mismatch") raise HTTPException(status_code=400, detail="Learner ID mismatch")
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") @app.post("/api/learners/{learner_id}/evidence")
def api_pack_layout(pack_id: str, user = Depends(current_user)): def api_post_evidence(learner_id: str, event: EvidenceEvent, actor = Depends(require_scope("learners:write"))):
ensure_pack_access(user, pack_id) 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) 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}") @app.post("/api/learners/{learner_id}/evaluator-jobs", response_model=EvaluatorJobStatus)
def api_graph_animation(learner_id: str, pack_id: str, user = Depends(current_user)): def api_submit_evaluator_job(learner_id: str, payload: EvaluatorSubmission, background_tasks: BackgroundTasks, actor = Depends(require_scope("evaluators:submit"))):
ensure_learner_access(user, learner_id) ensure_learner_access(actor, learner_id)
ensure_pack_access(user, pack_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) pack = get_pack(pack_id)
state = load_learner_state(learner_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 { return {
"learner_id": learner_id, "learner_id": learner_id,
"pack_id": pack_id, "pack_id": pack_id,
"pack_title": pack.title if pack else "", "pack_title": pack.title if pack else "",
"frames": frames, "frames": frames,
"concepts": [{"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(): def main():
uvicorn.run(app, host="127.0.0.1", port=8011) uvicorn.run(app, host=settings.host, port=settings.port)

View File

@ -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: def issue_refresh_token(user_id: int, username: str, role: str, token_id: str) -> str:
return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "refresh", "jti": token_id}, timedelta(days=14)) return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "refresh", "jti": token_id}, timedelta(days=14))
def issue_service_access_token(service_account_id: int, name: str, scopes: list[str]) -> str:
return _encode_token({"sub": str(service_account_id), "service_account_name": name, "kind": "service", "scopes": scopes}, timedelta(hours=8))
def decode_token(token: str) -> dict | None: def decode_token(token: str) -> dict | None:
try: try:
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]) return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
@ -33,3 +36,6 @@ def decode_token(token: str) -> dict | None:
def new_token_id() -> str: def new_token_id() -> str:
return secrets.token_urlsafe(24) return secrets.token_urlsafe(24)
def new_secret() -> str:
return secrets.token_urlsafe(24)

View File

@ -8,6 +8,7 @@ class Settings(BaseModel):
port: int = int(os.getenv("DIDACTOPUS_PORT", "8011")) port: int = int(os.getenv("DIDACTOPUS_PORT", "8011"))
jwt_secret: str = os.getenv("DIDACTOPUS_JWT_SECRET", "change-me") jwt_secret: str = os.getenv("DIDACTOPUS_JWT_SECRET", "change-me")
jwt_algorithm: str = "HS256" jwt_algorithm: str = "HS256"
deployment_policy_profile: str = os.getenv("DIDACTOPUS_POLICY_PROFILE", "single_user")
def load_settings() -> Settings: def load_settings() -> Settings:
return Settings() return Settings()

View File

@ -1,110 +1,76 @@
from __future__ import annotations from __future__ import annotations
from collections import defaultdict from .models import LearnerState, EvidenceEvent, MasteryRecord, PackData
from .models import LearnerState, PackData
def concept_depths(pack: PackData) -> dict[str, int]: def get_record(state: LearnerState, concept_id: str, dimension: str = "mastery") -> MasteryRecord | None:
concept_map = {c.id: c for c in pack.concepts} for rec in state.records:
memo = {} if rec.concept_id == concept_id and rec.dimension == dimension:
def depth(cid: str) -> int: return rec
if cid in memo: return None
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 stable_layout(pack: PackData, width: int = 900, height: int = 520): def apply_evidence(state: LearnerState, event: EvidenceEvent, decay: float = 0.05, reinforcement: float = 0.25) -> LearnerState:
depths = concept_depths(pack) rec = get_record(state, event.concept_id, event.dimension)
layers = defaultdict(list) if rec is None:
for c in pack.concepts: rec = MasteryRecord(concept_id=event.concept_id, dimension=event.dimension, score=0.0, confidence=0.0, evidence_count=0, last_updated=event.timestamp)
layers[depths.get(c.id, 0)].append(c) state.records.append(rec)
positions = {} weight = max(0.05, min(1.0, event.confidence_hint))
max_depth = max(layers.keys()) if layers else 0 rec.score = ((rec.score * rec.evidence_count) + (event.score * weight)) / max(1, rec.evidence_count + 1)
for d in sorted(layers): 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))))
nodes = sorted(layers[d], key=lambda c: c.id) rec.evidence_count += 1
y = 90 + d * ((height - 160) / max(1, max_depth)) rec.last_updated = event.timestamp
for idx, node in enumerate(nodes): state.history.append(event)
if node.position is not None: return state
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(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: 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 False
return True return True
def concept_status(scores: dict[str, float], concept, min_score: float = 0.65) -> str: def concept_status(state: LearnerState, concept, min_score: float = 0.65, min_confidence: float = 0.45) -> str:
score = scores.get(concept.id, 0.0) rec = get_record(state, concept.id, concept.masteryDimension)
if score >= min_score: if rec and rec.score >= min_score and rec.confidence >= min_confidence:
return "mastered" return "mastered"
if prereqs_satisfied(scores, concept, min_score): if prereqs_satisfied(state, concept, min_score, min_confidence):
return "active" if score > 0 else "available" return "active" if rec else "available"
return "locked" return "locked"
def build_graph_frames(state: LearnerState, pack: PackData): def recommend_next(state: LearnerState, pack: PackData) -> list[dict]:
concepts = {c.id: c for c in pack.concepts} cards = []
layout = stable_layout(pack) for concept in pack.concepts:
scores = {c.id: 0.0 for c in pack.concepts} status = concept_status(state, concept)
frames = [] rec = get_record(state, concept.id, concept.masteryDimension)
history = sorted(state.history, key=lambda x: x.timestamp) if status in {"available", "active"}:
static_edges = [{"source": pre, "target": c.id, "kind": "prerequisite"} for c in pack.concepts for pre in c.prerequisites] cards.append({
static_cross = [{ "id": concept.id,
"source": c.id, "title": f"Work on {concept.title}",
"target_pack_id": link.target_pack_id, "minutes": 15 if status == "available" else 10,
"target_concept_id": link.target_concept_id, "reason": "Prerequisites are satisfied, so this is the best next unlock." if status == "available" else "You have started this concept, but mastery is not yet secure.",
"relationship": link.relationship, "why": [
"kind": "cross_pack" "Prerequisite check passed",
} for c in pack.concepts for link in c.cross_pack_links] f"Current score: {rec.score:.2f}" if rec else "No evidence recorded yet",
for idx, ev in enumerate(history): f"Current confidence: {rec.confidence:.2f}" if rec else "Confidence starts after your first exercise",
if ev.concept_id in scores: ],
scores[ev.concept_id] = ev.score "reward": concept.exerciseReward or f"{concept.title} progress recorded",
nodes = [] "conceptId": concept.id,
for cid, concept in concepts.items(): "scoreHint": 0.82 if status == "available" else 0.76,
score = scores.get(cid, 0.0) "confidenceHint": 0.72 if status == "available" else 0.55,
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]
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({ frames.append({
"index": idx, "index": idx,
"timestamp": ev.timestamp, "timestamp": ev.timestamp,
"event_kind": ev.kind, "event_kind": ev.kind,
"focus_concept_id": ev.concept_id, "concept_id": ev.concept_id,
"nodes": nodes, "scores": dict(running),
"edges": static_edges,
"cross_pack_links": static_cross,
}) })
if not frames: if not frames:
nodes = [] frames.append({"index": 0, "timestamp": "", "event_kind": "empty", "concept_id": "", "scores": dict(running)})
for c in pack.concepts:
pos = layout[c.id]
nodes.append({
"id": c.id,
"title": c.title,
"score": 0.0,
"status": "available" if not c.prerequisites else "locked",
"size": 20,
"x": pos["x"],
"y": pos["y"],
"layout_source": pos["source"],
})
frames.append({"index": 0, "timestamp": "", "event_kind": "empty", "focus_concept_id": "", "nodes": nodes, "edges": static_edges, "cross_pack_links": static_cross})
return frames return frames

View File

@ -1,5 +1,9 @@
from __future__ import annotations from __future__ import annotations
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Literal
EvidenceKind = Literal["checkpoint", "project", "exercise", "review"]
PolicyLane = Literal["personal", "community"]
class TokenPair(BaseModel): class TokenPair(BaseModel):
access_token: str access_token: str
@ -8,22 +12,58 @@ class TokenPair(BaseModel):
username: str username: str
role: str role: str
class ServiceToken(BaseModel):
access_token: str
token_type: str = "bearer"
service_account_name: str
scopes: list[str]
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str username: str
password: str password: str
class ServiceAccountLoginRequest(BaseModel):
name: str
secret: str
class ServiceAccountCreateRequest(BaseModel):
name: str
description: str = ""
scopes: list[str] = Field(default_factory=list)
class ServiceAccountRotateRequest(BaseModel):
name: str
class ServiceAccountStateRequest(BaseModel):
is_active: bool
class RefreshRequest(BaseModel): class RefreshRequest(BaseModel):
refresh_token: str refresh_token: str
class GraphPosition(BaseModel): class DeploymentPolicyProfile(BaseModel):
x: float profile_name: str
y: float 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): class AgentCapabilityManifest(BaseModel):
source_concept_id: str supports_pack_listing: bool = True
target_pack_id: str supports_pack_write_personal: bool = True
target_concept_id: str supports_pack_submit_community: bool = True
relationship: str = "related" 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): class PackConcept(BaseModel):
id: str id: str
@ -31,8 +71,13 @@ class PackConcept(BaseModel):
prerequisites: list[str] = Field(default_factory=list) prerequisites: list[str] = Field(default_factory=list)
masteryDimension: str = "mastery" masteryDimension: str = "mastery"
exerciseReward: str = "" exerciseReward: str = ""
position: GraphPosition | None = None
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): class PackData(BaseModel):
id: str id: str
@ -41,12 +86,32 @@ class PackData(BaseModel):
level: str = "novice-friendly" level: str = "novice-friendly"
concepts: list[PackConcept] = Field(default_factory=list) concepts: list[PackConcept] = Field(default_factory=list)
onboarding: dict = Field(default_factory=dict) onboarding: dict = Field(default_factory=dict)
compliance: 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): class CreateLearnerRequest(BaseModel):
learner_id: str learner_id: str
display_name: str = "" display_name: str = ""
class LearnerRunCreateRequest(BaseModel):
learner_id: str
pack_id: str
title: str = ""
actor_kind: str = "human"
class WorkflowEventCreateRequest(BaseModel):
run_id: int
learner_id: str
event_type: str
concept_id: str = ""
timestamp: str
detail: dict = Field(default_factory=dict)
class MasteryRecord(BaseModel): class MasteryRecord(BaseModel):
concept_id: str concept_id: str
dimension: str dimension: str
@ -61,10 +126,33 @@ class EvidenceEvent(BaseModel):
score: float score: float
confidence_hint: float = 0.5 confidence_hint: float = 0.5
timestamp: str timestamp: str
kind: str = "exercise" kind: EvidenceKind = "exercise"
source_id: str = "" source_id: str = ""
class LearnerState(BaseModel): class LearnerState(BaseModel):
learner_id: str learner_id: str
records: list[MasteryRecord] = Field(default_factory=list) records: list[MasteryRecord] = Field(default_factory=list)
history: list[EvidenceEvent] = Field(default_factory=list) history: list[EvidenceEvent] = Field(default_factory=list)
class EvaluatorSubmission(BaseModel):
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)

View File

@ -10,6 +10,49 @@ class UserORM(Base):
role: Mapped[str] = mapped_column(String(50), default="learner") role: Mapped[str] = mapped_column(String(50), default="learner")
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
class ServiceAccountORM(Base):
__tablename__ = "service_accounts"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(120), unique=True, index=True)
owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
description: Mapped[str] = mapped_column(Text, default="")
scopes_json: Mapped[str] = mapped_column(Text, default="[]")
secret_hash: Mapped[str] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
class AgentAuditLogORM(Base):
__tablename__ = "agent_audit_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
service_account_id: Mapped[int] = mapped_column(ForeignKey("service_accounts.id"), index=True)
service_account_name: Mapped[str] = mapped_column(String(120), index=True)
action: Mapped[str] = mapped_column(String(120), index=True)
target: Mapped[str] = mapped_column(String(255), default="")
outcome: Mapped[str] = mapped_column(String(50), default="ok")
detail_json: Mapped[str] = mapped_column(Text, default="{}")
created_at: Mapped[str] = mapped_column(String(100), default="")
class LearnerRunORM(Base):
__tablename__ = "learner_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
learner_id: Mapped[str] = mapped_column(String(100), index=True)
pack_id: Mapped[str] = mapped_column(String(100), index=True)
actor_kind: Mapped[str] = mapped_column(String(50), default="human")
actor_name: Mapped[str] = mapped_column(String(120), default="")
title: Mapped[str] = mapped_column(String(255), default="")
status: Mapped[str] = mapped_column(String(50), default="running")
started_at: Mapped[str] = mapped_column(String(100), default="")
ended_at: Mapped[str] = mapped_column(String(100), default="")
class WorkflowEventORM(Base):
__tablename__ = "workflow_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
run_id: Mapped[int] = mapped_column(ForeignKey("learner_runs.id"), index=True)
learner_id: Mapped[str] = mapped_column(String(100), index=True)
event_type: Mapped[str] = mapped_column(String(100), index=True)
concept_id: Mapped[str] = mapped_column(String(100), default="")
timestamp: Mapped[str] = mapped_column(String(100), default="")
detail_json: Mapped[str] = mapped_column(Text, default="{}")
class RefreshTokenORM(Base): class RefreshTokenORM(Base):
__tablename__ = "refresh_tokens" __tablename__ = "refresh_tokens"
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
@ -26,6 +69,10 @@ class PackORM(Base):
subtitle: Mapped[str] = mapped_column(Text, default="") subtitle: Mapped[str] = mapped_column(Text, default="")
level: Mapped[str] = mapped_column(String(100), default="novice-friendly") level: Mapped[str] = mapped_column(String(100), default="novice-friendly")
data_json: Mapped[str] = mapped_column(Text) data_json: Mapped[str] = mapped_column(Text)
validation_json: Mapped[str] = mapped_column(Text, default="{}")
provenance_json: Mapped[str] = mapped_column(Text, default="{}")
governance_state: Mapped[str] = mapped_column(String(50), default="draft")
current_version: Mapped[int] = mapped_column(Integer, default=1)
is_published: Mapped[bool] = mapped_column(Boolean, default=False) is_published: Mapped[bool] = mapped_column(Boolean, default=False)
class LearnerORM(Base): class LearnerORM(Base):
@ -56,3 +103,16 @@ class EvidenceEventORM(Base):
timestamp: Mapped[str] = mapped_column(String(100), default="") timestamp: Mapped[str] = mapped_column(String(100), default="")
kind: Mapped[str] = mapped_column(String(50), default="exercise") kind: Mapped[str] = mapped_column(String(50), default="exercise")
source_id: Mapped[str] = mapped_column(String(255), default="") source_id: Mapped[str] = mapped_column(String(255), default="")
class EvaluatorJobORM(Base):
__tablename__ = "evaluator_jobs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
learner_id: Mapped[str] = mapped_column(ForeignKey("learners.id"), index=True)
pack_id: Mapped[str] = mapped_column(ForeignKey("packs.id"), index=True)
concept_id: Mapped[str] = mapped_column(String(100), index=True)
submitted_text: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(String(50), default="queued")
result_score: Mapped[float | None] = mapped_column(Float, nullable=True)
result_confidence_hint: Mapped[float | None] = mapped_column(Float, nullable=True)
result_notes: Mapped[str] = mapped_column(Text, default="")
trace_json: Mapped[str] = mapped_column(Text, default="{}")

View File

@ -1,10 +1,28 @@
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from .db import SessionLocal from .db import SessionLocal
from .orm import UserORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM from .orm import UserORM, ServiceAccountORM, AgentAuditLogORM, LearnerRunORM, WorkflowEventORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM
from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent, DeploymentPolicyProfile
from .auth import verify_password 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): def get_user_by_username(username: str):
with SessionLocal() as db: with SessionLocal() as db:
@ -20,6 +38,153 @@ def authenticate_user(username: str, password: str):
return None return None
return user return user
def create_service_account(name: str, owner_user_id: int | None, description: str, scopes: list[str], secret: str):
with SessionLocal() as db:
sa = ServiceAccountORM(
name=name,
owner_user_id=owner_user_id,
description=description,
scopes_json=json.dumps(scopes),
secret_hash=hash_password(secret),
is_active=True,
)
db.add(sa)
db.commit()
db.refresh(sa)
return sa
def list_service_accounts():
with SessionLocal() as db:
rows = db.execute(select(ServiceAccountORM).order_by(ServiceAccountORM.id)).scalars().all()
return [{"id": r.id, "name": r.name, "owner_user_id": r.owner_user_id, "description": r.description, "scopes": json.loads(r.scopes_json or "[]"), "is_active": r.is_active} for r in rows]
def get_service_account_by_name(name: str):
with SessionLocal() as db:
return db.execute(select(ServiceAccountORM).where(ServiceAccountORM.name == name)).scalar_one_or_none()
def authenticate_service_account(name: str, secret: str):
sa = get_service_account_by_name(name)
if sa is None or not sa.is_active or not verify_password(secret, sa.secret_hash):
return None
return sa
def rotate_service_account_secret(name: str, new_secret: str):
with SessionLocal() as db:
sa = db.execute(select(ServiceAccountORM).where(ServiceAccountORM.name == name)).scalar_one_or_none()
if sa is None:
return None
sa.secret_hash = hash_password(new_secret)
db.commit()
db.refresh(sa)
return sa
def set_service_account_active(name: str, is_active: bool):
with SessionLocal() as db:
sa = db.execute(select(ServiceAccountORM).where(ServiceAccountORM.name == name)).scalar_one_or_none()
if sa is None:
return None
sa.is_active = is_active
db.commit()
db.refresh(sa)
return sa
def add_agent_audit_log(service_account_id: int, service_account_name: str, action: str, target: str, outcome: str, detail: dict):
with SessionLocal() as db:
db.add(AgentAuditLogORM(
service_account_id=service_account_id,
service_account_name=service_account_name,
action=action,
target=target,
outcome=outcome,
detail_json=json.dumps(detail),
created_at=now_iso(),
))
db.commit()
def list_agent_audit_logs(limit: int = 200):
with SessionLocal() as db:
rows = db.execute(select(AgentAuditLogORM).order_by(AgentAuditLogORM.id.desc())).scalars().all()[:limit]
return [{
"id": r.id,
"service_account_id": r.service_account_id,
"service_account_name": r.service_account_name,
"action": r.action,
"target": r.target,
"outcome": r.outcome,
"detail": json.loads(r.detail_json or "{}"),
"created_at": r.created_at,
} for r in rows]
def create_learner_run(learner_id: str, pack_id: str, actor_kind: str, actor_name: str, title: str):
with SessionLocal() as db:
row = LearnerRunORM(
learner_id=learner_id,
pack_id=pack_id,
actor_kind=actor_kind,
actor_name=actor_name,
title=title,
status="running",
started_at=now_iso(),
ended_at="",
)
db.add(row)
db.commit()
db.refresh(row)
return row.id
def end_learner_run(run_id: int):
with SessionLocal() as db:
row = db.get(LearnerRunORM, run_id)
if row is None:
return None
row.status = "completed"
row.ended_at = now_iso()
db.commit()
return row
def list_learner_runs(learner_id: str):
with SessionLocal() as db:
rows = db.execute(select(LearnerRunORM).where(LearnerRunORM.learner_id == learner_id).order_by(LearnerRunORM.id.desc())).scalars().all()
return [{
"run_id": r.id,
"learner_id": r.learner_id,
"pack_id": r.pack_id,
"actor_kind": r.actor_kind,
"actor_name": r.actor_name,
"title": r.title,
"status": r.status,
"started_at": r.started_at,
"ended_at": r.ended_at,
} for r in rows]
def add_workflow_event(run_id: int, learner_id: str, event_type: str, concept_id: str, timestamp: str, detail: dict):
with SessionLocal() as db:
row = WorkflowEventORM(
run_id=run_id,
learner_id=learner_id,
event_type=event_type,
concept_id=concept_id,
timestamp=timestamp,
detail_json=json.dumps(detail),
)
db.add(row)
db.commit()
db.refresh(row)
return row.id
def list_workflow_events(run_id: int):
with SessionLocal() as db:
rows = db.execute(select(WorkflowEventORM).where(WorkflowEventORM.run_id == run_id).order_by(WorkflowEventORM.id)).scalars().all()
return [{
"id": r.id,
"run_id": r.run_id,
"learner_id": r.learner_id,
"event_type": r.event_type,
"concept_id": r.concept_id,
"timestamp": r.timestamp,
"detail": json.loads(r.detail_json or "{}"),
} for r in rows]
def store_refresh_token(user_id: int, token_id: str): def store_refresh_token(user_id: int, token_id: str):
with SessionLocal() as db: with SessionLocal() as db:
db.add(RefreshTokenORM(user_id=user_id, token_id=token_id, is_revoked=False)) db.add(RefreshTokenORM(user_id=user_id, token_id=token_id, is_revoked=False))
@ -60,7 +225,9 @@ def get_pack_row(pack_id: str):
with SessionLocal() as db: with SessionLocal() as db:
return db.get(PackORM, pack_id) return db.get(PackORM, pack_id)
def upsert_pack(pack: PackData, submitted_by_user_id: int, policy_lane: str = "personal", is_published: bool = False): 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: with SessionLocal() as db:
row = db.get(PackORM, pack.id) row = db.get(PackORM, pack.id)
payload = json.dumps(pack.model_dump()) payload = json.dumps(pack.model_dump())
@ -73,6 +240,10 @@ def upsert_pack(pack: PackData, submitted_by_user_id: int, policy_lane: str = "p
subtitle=pack.subtitle, subtitle=pack.subtitle,
level=pack.level, level=pack.level,
data_json=payload, data_json=payload,
validation_json=json.dumps(validation),
provenance_json=json.dumps(provenance),
governance_state="personal_ready" if policy_lane == "personal" else "draft",
current_version=1,
is_published=is_published if policy_lane == "personal" else False, is_published=is_published if policy_lane == "personal" else False,
) )
db.add(row) db.add(row)
@ -83,6 +254,9 @@ def upsert_pack(pack: PackData, submitted_by_user_id: int, policy_lane: str = "p
row.subtitle = pack.subtitle row.subtitle = pack.subtitle
row.level = pack.level row.level = pack.level
row.data_json = payload row.data_json = payload
row.validation_json = json.dumps(validation)
row.provenance_json = json.dumps(provenance)
row.current_version += 1
if policy_lane == "personal": if policy_lane == "personal":
row.is_published = is_published row.is_published = is_published
db.commit() db.commit()
@ -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.add(EvidenceEventORM(learner_id=state.learner_id, concept_id=h.concept_id, dimension=h.dimension, score=h.score, confidence_hint=h.confidence_hint, timestamp=h.timestamp, kind=h.kind, source_id=h.source_id))
db.commit() db.commit()
return state return state
def create_evaluator_job(learner_id: str, pack_id: str, concept_id: str, submitted_text: str):
with SessionLocal() as db:
job = EvaluatorJobORM(learner_id=learner_id, pack_id=pack_id, concept_id=concept_id, submitted_text=submitted_text, status="queued", trace_json=json.dumps({"notes": ["Job queued"]}))
db.add(job)
db.commit()
db.refresh(job)
return job.id
def list_evaluator_jobs_for_learner(learner_id: str):
with SessionLocal() as db:
return db.execute(select(EvaluatorJobORM).where(EvaluatorJobORM.learner_id == learner_id).order_by(EvaluatorJobORM.id.desc())).scalars().all()
def get_evaluator_job(job_id: int):
with SessionLocal() as db:
return db.get(EvaluatorJobORM, job_id)
def update_evaluator_job(job_id: int, status: str, score: float | None = None, confidence_hint: float | None = None, notes: str = "", trace: dict | None = None):
with SessionLocal() as db:
job = db.get(EvaluatorJobORM, job_id)
if job is None:
return
job.status = status
job.result_score = score
job.result_confidence_hint = confidence_hint
job.result_notes = notes
if trace is not None:
job.trace_json = json.dumps(trace)
db.commit()

View File

@ -4,7 +4,7 @@ from .db import Base, engine, SessionLocal
from .orm import UserORM from .orm import UserORM
from .auth import hash_password from .auth import hash_password
from .repository import upsert_pack, create_learner from .repository import upsert_pack, create_learner
from .models import PackData, PackConcept, GraphPosition, CrossPackLink from .models import PackData, PackConcept, PackCompliance
def main(): def main():
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
@ -20,15 +20,14 @@ def main():
subtitle="Personal pack example.", subtitle="Personal pack example.",
level="novice-friendly", level="novice-friendly",
concepts=[ concepts=[
PackConcept(id="intro", title="Intro", prerequisites=[], position=GraphPosition(x=150, y=120)), PackConcept(id="intro", title="Intro", prerequisites=[], masteryDimension="mastery", exerciseReward="Intro marker"),
PackConcept(id="second", title="Second concept", prerequisites=["intro"], position=GraphPosition(x=420, y=120)), PackConcept(id="second", title="Second concept", prerequisites=["intro"], masteryDimension="mastery", exerciseReward="Second marker"),
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"}, onboarding={"headline":"Start privately","body":"Personal pack lane.","checklist":["Create pack","Use pack"]},
compliance={} compliance=PackCompliance()
), ),
submitted_by_user_id=1, submitted_by_user_id=1,
policy_lane="personal", policy_lane="personal",
is_published=True, is_published=True,
change_summary="Initial personal pack"
) )

View File

@ -8,17 +8,14 @@ def process_job(job_id: int):
job = get_evaluator_job(job_id) job = get_evaluator_job(job_id)
if job is None: if job is None:
return 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 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 confidence_hint = 0.72 if len(job.submitted_text.strip()) > 20 else 0.45
notes = "Prototype evaluator: longer responses scored somewhat higher." 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={"notes": ["completed"]})
update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes, trace=trace)
state = load_learner_state(job.learner_id) 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}")) 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) save_learner_state(state)
def main(): def main():
print("Didactopus worker scaffold running. Replace this with a real queue worker.")
while True: while True:
time.sleep(60) time.sleep(60)

View File

@ -3,5 +3,5 @@ from pathlib import Path
def test_scaffold_files_exist(): def test_scaffold_files_exist():
assert Path("src/didactopus/api.py").exists() assert Path("src/didactopus/api.py").exists()
assert Path("src/didactopus/repository.py").exists() assert Path("src/didactopus/repository.py").exists()
assert Path("src/didactopus/export_svg.py").exists()
assert Path("webui/src/App.jsx").exists() assert Path("webui/src/App.jsx").exists()
assert Path("webui/src/api.js").exists()

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Didactopus Layout-Aware Graph</title> <title>Didactopus Learning Animation</title>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</head> </head>
<body><div id="root"></div></body> <body><div id="root"></div></body>

View File

@ -1,5 +1,5 @@
{ {
"name": "didactopus-layout-aware-graph-ui", "name": "didactopus-learning-animation-ui",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { login, refresh, fetchPacks, fetchLearnerState, putLearnerState, fetchGraphAnimation } from "./api"; import { login, refresh, fetchPacks, fetchLearnerState, putLearnerState, createLearnerRun, addWorkflowEvent, fetchAnimation } from "./api";
import { loadAuth, saveAuth, clearAuth } from "./authStore"; import { loadAuth, saveAuth, clearAuth } from "./authStore";
function LoginView({ onAuth }) { function LoginView({ onAuth }) {
@ -26,41 +26,23 @@ function LoginView({ onAuth }) {
); );
} }
function nodeColor(status) { function AnimationBars({ frame, concepts }) {
if (status === "mastered") return "#1f7a1f";
if (status === "active") return "#2d6cdf";
if (status === "available") return "#c48a00";
return "#9aa4b2";
}
function GraphView({ frame }) {
if (!frame) return null; if (!frame) return null;
return ( return (
<svg viewBox="0 0 960 560" className="graph"> <div className="bars">
{frame.edges.map((edge, idx) => { {concepts.map((concept) => {
const s = frame.nodes.find((n) => n.id === edge.source); const value = frame.scores?.[concept] ?? 0;
const t = frame.nodes.find((n) => n.id === edge.target); return (
if (!s || !t) return null; <div className="bar-row" key={concept}>
return <line key={idx} x1={s.x} y1={s.y} x2={t.x} y2={t.y} stroke="#b8c2cf" strokeWidth="3" markerEnd="url(#arrow)" />; <div className="bar-label">{concept}</div>
<div className="bar-track">
<div className="bar-fill" style={{ width: `${Math.max(0, Math.min(100, value * 100))}%` }} />
</div>
<div className="bar-value">{value.toFixed(2)}</div>
</div>
);
})} })}
{frame.cross_pack_links.map((edge, idx) => { </div>
const s = frame.nodes.find((n) => n.id === edge.source);
if (!s) return null;
return <line key={`c${idx}`} x1={s.x} y1={s.y} x2={s.x + 120} y2={s.y - 60} stroke="#cc4bc2" strokeWidth="2" strokeDasharray="8 6" />;
})}
<defs>
<marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L0,6 L9,3 z" fill="#b8c2cf" />
</marker>
</defs>
{frame.nodes.map((node) => (
<g key={node.id}>
<circle cx={node.x} cy={node.y} r={node.size} fill={nodeColor(node.status)} opacity="0.92" />
<text x={node.x} y={node.y - 4} textAnchor="middle" className="svg-label">{node.title}</text>
<text x={node.x} y={node.y + 14} textAnchor="middle" className="svg-small">{node.score.toFixed(2)} · {node.status}</text>
</g>
))}
</svg>
); );
} }
@ -69,7 +51,7 @@ export default function App() {
const [packs, setPacks] = useState([]); const [packs, setPacks] = useState([]);
const [learnerId] = useState("wesley-learner"); const [learnerId] = useState("wesley-learner");
const [packId, setPackId] = useState(""); const [packId, setPackId] = useState("");
const [graphData, setGraphData] = useState(null); const [animation, setAnimation] = useState(null);
const [frameIndex, setFrameIndex] = useState(0); const [frameIndex, setFrameIndex] = useState(0);
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
@ -97,9 +79,9 @@ export default function App() {
} }
} }
async function reload(pid) { async function reloadAnimation(pid) {
const data = await guarded((token) => fetchGraphAnimation(token, learnerId, pid)); const data = await guarded((token) => fetchAnimation(token, learnerId, pid));
setGraphData(data); setAnimation(data);
setFrameIndex(0); setFrameIndex(0);
} }
@ -110,80 +92,92 @@ export default function App() {
setPacks(p); setPacks(p);
const pid = p[0]?.id || ""; const pid = p[0]?.id || "";
setPackId(pid); setPackId(pid);
if (pid) await reload(pid); if (pid) await reloadAnimation(pid);
} }
load(); load();
}, [auth]); }, [auth]);
useEffect(() => { useEffect(() => {
if (!playing || !graphData?.frames?.length) return; if (!playing || !animation?.frames?.length) return;
const t = setInterval(() => { 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); }, 900);
return () => clearInterval(t); 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)); let state = await guarded((token) => fetchLearnerState(token, learnerId));
const base = Date.now(); const now1 = new Date().toISOString();
const events = [ state.history.push({ concept_id: "intro", dimension: "mastery", score: 0.35, confidence_hint: 0.5, timestamp: now1, kind: "exercise", source_id: "demo-1" });
["intro", 0.30, "exercise", 0], state.records = [{ concept_id: "intro", dimension: "mastery", score: 0.35, confidence: 0.35, evidence_count: 1, last_updated: now1 }];
["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)); await guarded((token) => putLearnerState(token, learnerId, state));
await reload(packId); 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 } }));
setMessage("Stable-layout graph demo generated.");
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 <LoginView onAuth={setAuth} />; if (!auth) return <LoginView onAuth={setAuth} />;
const frame = graphData?.frames?.[frameIndex];
return ( return (
<div className="page"> <div className="page">
<header className="hero"> <header className="hero">
<div> <div>
<h1>Didactopus layout-aware graph engine</h1> <h1>Didactopus learning animation</h1>
<p>Stable node positions, cross-pack links, and export-ready graph frames.</p> <p>Replay mastery changes for human or AI learners over time.</p>
<div className="muted">{message}</div> <div className="muted">{message}</div>
</div> </div>
<div className="controls"> <div className="controls">
<label>Pack <label>Pack
<select value={packId} onChange={async (e) => { setPackId(e.target.value); await reload(e.target.value); }}> <select value={packId} onChange={async (e) => { setPackId(e.target.value); await reloadAnimation(e.target.value); }}>
{packs.map((p) => <option key={p.id} value={p.id}>{p.title}</option>)} {packs.map((p) => <option key={p.id} value={p.id}>{p.title}</option>)}
</select> </select>
</label> </label>
<button onClick={() => setPlaying((x) => !x)}>{playing ? "Pause" : "Play"}</button> <button onClick={() => setPlaying((x) => !x)}>{playing ? "Pause" : "Play"}</button>
<button onClick={generateDemo}>Generate demo</button> <button onClick={simulateLearning}>Generate demo run</button>
<button onClick={() => { clearAuth(); setAuth(null); }}>Logout</button> <button onClick={() => { clearAuth(); setAuth(null); }}>Logout</button>
</div> </div>
</header> </header>
<main className="layout twocol"> <main className="layout twocol">
<section className="card"> <section className="card">
<h2>Graph animation</h2> <h2>Current frame</h2>
<div className="frame-meta"> <div className="frame-meta">
<div><strong>Frame:</strong> {frameIndex + 1} / {graphData?.frames?.length || 0}</div> <div><strong>Frame:</strong> {frameIndex + 1} / {animation?.frames?.length || 0}</div>
<div><strong>Event:</strong> {frame?.event_kind || "-"}</div> <div><strong>Event:</strong> {currentFrame?.event_kind || "-"}</div>
<div><strong>Focus:</strong> {frame?.focus_concept_id || "-"}</div> <div><strong>Concept:</strong> {currentFrame?.concept_id || "-"}</div>
<div><strong>Timestamp:</strong> {frame?.timestamp || "-"}</div> <div><strong>Timestamp:</strong> {currentFrame?.timestamp || "-"}</div>
</div> </div>
<GraphView frame={frame} /> <AnimationBars frame={currentFrame} concepts={concepts} />
</section> </section>
<section className="card"> <section className="card">
<h2>Frame payload</h2> <h2>Animation data</h2>
<pre className="prebox">{JSON.stringify(graphData, null, 2)}</pre> <pre className="prebox">{JSON.stringify(animation, null, 2)}</pre>
</section> </section>
</main> </main>
</div> </div>

View File

@ -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 fetchPacks(token) { const res = await fetch(`${API}/packs`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPacks failed"); return await res.json(); }
export async function fetchLearnerState(token, learnerId) { const res = await fetch(`${API}/learners/${learnerId}/state`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchLearnerState failed"); return await res.json(); } export async function fetchLearnerState(token, learnerId) { const res = await fetch(`${API}/learners/${learnerId}/state`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchLearnerState failed"); return await res.json(); }
export async function putLearnerState(token, learnerId, state) { const res = await fetch(`${API}/learners/${learnerId}/state`, { method: "PUT", headers: authHeaders(token), body: JSON.stringify(state) }); if (!res.ok) throw new Error("putLearnerState failed"); return await res.json(); } export async function putLearnerState(token, learnerId, state) { const res = await fetch(`${API}/learners/${learnerId}/state`, { method: "PUT", headers: authHeaders(token), body: JSON.stringify(state) }); if (!res.ok) throw new Error("putLearnerState failed"); return await res.json(); }
export async function 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(); }

View File

@ -10,19 +10,23 @@ body { margin:0; font-family:Arial, Helvetica, sans-serif; background:var(--bg);
label { display:block; font-weight:600; } 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; } 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; } 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; } .card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; }
.narrow { margin-top:60px; } .narrow { margin-top:60px; }
.layout { display:grid; gap:16px; } .layout { display:grid; gap:16px; }
.twocol { grid-template-columns:1fr 1fr; } .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); } .muted { color:var(--muted); }
.error { color:#b42318; margin-top:10px; } .error { color:#b42318; margin-top:10px; }
.frame-meta { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:16px; } .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; } .bars { display:grid; gap:12px; }
.svg-label { font-size:12px; fill:#fff; font-weight:bold; } .bar-row { display:grid; grid-template-columns:140px 1fr 70px; gap:10px; align-items:center; }
.svg-small { font-size:10px; fill:#fff; } .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) { @media (max-width:1100px) {
.hero { flex-direction:column; } .hero { flex-direction:column; }
.twocol { grid-template-columns:1fr; } .twocol { grid-template-columns:1fr; }
.frame-meta { grid-template-columns:1fr; } .frame-meta { grid-template-columns:1fr; }
.bar-row { grid-template-columns:1fr; }
} }