Apply ZIP update: 155-didactopus-animated-concept-graph-layer.zip [2026-03-14T13:19:30]

This commit is contained in:
welsberr 2026-03-14 13:29:55 -04:00
parent 15d52f66bc
commit f94619c304
14 changed files with 241 additions and 475 deletions

View File

@ -8,11 +8,9 @@ version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"pydantic>=2.7",
"pyyaml>=6.0",
"fastapi>=0.115",
"uvicorn>=0.30",
"sqlalchemy>=2.0",
"psycopg[binary]>=3.1",
"passlib[bcrypt]>=1.7",
"python-jose[cryptography]>=3.3"
]

View File

@ -1,92 +1,45 @@
from __future__ import annotations
import json
from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks
from fastapi import FastAPI, HTTPException, Header, Depends
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
from .config import load_settings
from .db import Base, engine
from .models import LoginRequest, ServiceAccountLoginRequest, ServiceAccountCreateRequest, ServiceToken, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest, ContributionSubmissionCreate, AgentCapabilityManifest, AgentLearnerPlanRequest, AgentLearnerPlanResponse
from .repository import authenticate_user, get_user_by_id, create_service_account, list_service_accounts, authenticate_service_account, 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
from .auth import issue_access_token, issue_refresh_token, issue_service_access_token, decode_token, new_token_id, new_secret
from .worker import process_job
from .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
settings = load_settings()
Base.metadata.create_all(bind=engine)
SERVICE_SCOPE_MAP = {
"packs:read": "packs:read",
"packs:write_personal": "packs:write_personal",
"contributions:submit": "contributions:submit",
"learners:read": "learners:read",
"learners:write": "learners:write",
"recommendations:read": "recommendations:read",
"evaluators:submit": "evaluators:submit",
"evaluators:read": "evaluators:read",
"governance:read": "governance:read",
"governance:write": "governance:write",
}
app = FastAPI(title="Didactopus API Prototype")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
def current_actor(authorization: str = Header(default="")):
def current_user(authorization: str = Header(default="")):
token = authorization.removeprefix("Bearer ").strip()
payload = decode_token(token) if token else None
if not payload:
if not payload or payload.get("kind") != "access":
raise HTTPException(status_code=401, detail="Unauthorized")
if payload.get("kind") == "access":
user = get_user_by_id(int(payload["sub"]))
if user is None or not user.is_active:
raise HTTPException(status_code=401, detail="Unauthorized")
return {"actor_type": "user", "user": user, "scopes": None}
if payload.get("kind") == "service":
return {"actor_type": "service", "service_account_id": int(payload["sub"]), "service_account_name": payload.get("service_account_name"), "scopes": payload.get("scopes", [])}
raise HTTPException(status_code=401, detail="Unauthorized")
user = get_user_by_id(int(payload["sub"]))
if user is None or not user.is_active:
raise HTTPException(status_code=401, detail="Unauthorized")
return user
def require_user(actor = Depends(current_actor)):
if actor["actor_type"] != "user":
raise HTTPException(status_code=403, detail="Human user required")
return actor["user"]
def require_admin(actor = Depends(current_actor)):
if actor["actor_type"] != "user" or actor["user"].role != "admin":
raise HTTPException(status_code=403, detail="Admin role required")
return actor["user"]
def 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:
raise HTTPException(status_code=403, detail=f"Missing scope: {scope}")
return actor
return inner
def ensure_learner_access(actor, learner_id: str):
if actor["actor_type"] == "service":
return
user = actor["user"]
def ensure_learner_access(user, learner_id: str):
if user.role == "admin":
return
if not learner_owned_by_user(user.id, learner_id):
raise HTTPException(status_code=403, detail="Learner not accessible by this actor")
raise HTTPException(status_code=403, detail="Learner not accessible by this user")
def ensure_pack_access(actor, pack_id: str):
def ensure_pack_access(user, pack_id: str):
row = get_pack_row(pack_id)
if row is None:
raise HTTPException(status_code=404, detail="Pack not found")
if actor["actor_type"] == "service":
return row
user = actor["user"]
if user.role == "admin":
return row
if row.policy_lane == "community":
return row
if row.owner_user_id == user.id:
return row
raise HTTPException(status_code=403, detail="Pack not accessible by this actor")
raise HTTPException(status_code=403, detail="Pack not accessible by this user")
@app.post("/api/login", response_model=TokenPair)
def login(payload: LoginRequest):
@ -97,14 +50,6 @@ def login(payload: LoginRequest):
store_refresh_token(user.id, token_id)
return TokenPair(access_token=issue_access_token(user.id, user.username, user.role), refresh_token=issue_refresh_token(user.id, user.username, user.role, token_id), username=user.username, role=user.role)
@app.post("/api/service-accounts/login", response_model=ServiceToken)
def service_login(payload: ServiceAccountLoginRequest):
sa = authenticate_service_account(payload.name, payload.secret)
if sa is None:
raise HTTPException(status_code=401, detail="Invalid service account credentials")
scopes = json.loads(sa.scopes_json or "[]")
return ServiceToken(access_token=issue_service_access_token(sa.id, sa.name, scopes), service_account_name=sa.name, scopes=scopes)
@app.post("/api/refresh", response_model=TokenPair)
def refresh(payload: RefreshRequest):
data = decode_token(payload.refresh_token)
@ -121,111 +66,41 @@ 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)
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.get("/api/packs")
def api_list_packs(actor = Depends(require_scope("packs:read"))):
user_id = actor["user"].id if actor["actor_type"] == "user" else None
return [p.model_dump() for p in list_packs_for_user(user_id, include_unpublished=(actor["actor_type"] == "user" and actor["user"].role == "admin"))]
@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/contributions")
def api_create_contribution(payload: ContributionSubmissionCreate, actor = Depends(require_scope("contributions:submit"))):
contributor_id = actor["user"].id if actor["actor_type"] == "user" else 0
return {"ok": True, "note": "Contribution flow placeholder for this scaffold", "contributor_id": contributor_id}
def api_list_packs(user = Depends(current_user)):
return [p.model_dump() for p in list_packs_for_user(user.id, include_unpublished=(user.role == "admin"))]
@app.post("/api/learners")
def api_create_learner(payload: CreateLearnerRequest, actor = Depends(require_scope("learners:write"))):
if actor["actor_type"] != "user":
raise HTTPException(status_code=403, detail="Service accounts do not create learners in this scaffold")
create_learner(actor["user"].id, payload.learner_id, payload.display_name)
def api_create_learner(payload: CreateLearnerRequest, user = Depends(current_user)):
create_learner(user.id, payload.learner_id, payload.display_name)
return {"ok": True, "learner_id": payload.learner_id}
@app.get("/api/learners/{learner_id}/state")
def api_get_learner_state(learner_id: str, actor = Depends(require_scope("learners:read"))):
ensure_learner_access(actor, learner_id)
def api_get_learner_state(learner_id: str, user = Depends(current_user)):
ensure_learner_access(user, learner_id)
return load_learner_state(learner_id).model_dump()
@app.put("/api/learners/{learner_id}/state")
def api_put_learner_state(learner_id: str, state: LearnerState, actor = Depends(require_scope("learners:write"))):
ensure_learner_access(actor, learner_id)
def api_put_learner_state(learner_id: str, state: LearnerState, user = Depends(current_user)):
ensure_learner_access(user, learner_id)
if learner_id != state.learner_id:
raise HTTPException(status_code=400, detail="Learner ID mismatch")
return save_learner_state(state).model_dump()
@app.post("/api/learners/{learner_id}/evidence")
def api_post_evidence(learner_id: str, event: EvidenceEvent, actor = Depends(require_scope("learners:write"))):
ensure_learner_access(actor, learner_id)
state = load_learner_state(learner_id)
state = apply_evidence(state, event)
save_learner_state(state)
return state.model_dump()
@app.get("/api/learners/{learner_id}/recommendations/{pack_id}")
def api_get_recommendations(learner_id: str, pack_id: str, actor = Depends(require_scope("recommendations:read"))):
ensure_learner_access(actor, learner_id)
ensure_pack_access(actor, pack_id)
state = load_learner_state(learner_id)
@app.get("/api/learners/{learner_id}/graph-animation/{pack_id}")
def api_graph_animation(learner_id: str, pack_id: str, user = Depends(current_user)):
ensure_learner_access(user, learner_id)
ensure_pack_access(user, pack_id)
pack = get_pack(pack_id)
if pack is None:
raise HTTPException(status_code=404, detail="Pack not found")
return {"cards": recommend_next(state, pack)}
@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)
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")
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)
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]
state = load_learner_state(learner_id)
frames = build_graph_frames(state, pack)
return {
"learner_id": learner_id,
"pack_id": pack_id,
"pack_title": pack.title if pack else "",
"frames": frames,
"concepts": [{"id": c.id, "title": c.title, "prerequisites": c.prerequisites} for c in pack.concepts] if pack else [],
}
def main():
uvicorn.run(app, host=settings.host, port=settings.port)
uvicorn.run(app, host="127.0.0.1", port=8011)

View File

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

View File

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

View File

@ -7,53 +7,53 @@ def get_record(state: LearnerState, concept_id: str, dimension: str = "mastery")
return rec
return None
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(state: LearnerState, concept, min_score: float = 0.65, min_confidence: float = 0.45) -> bool:
def prereqs_satisfied(scores: dict[str, float], concept, min_score: float = 0.65) -> bool:
for pid in concept.prerequisites:
rec = get_record(state, pid, concept.masteryDimension)
if rec is None or rec.score < min_score or rec.confidence < min_confidence:
if scores.get(pid, 0.0) < min_score:
return False
return True
def concept_status(state: LearnerState, concept, min_score: float = 0.65, min_confidence: float = 0.45) -> str:
rec = get_record(state, concept.id, concept.masteryDimension)
if rec and rec.score >= min_score and rec.confidence >= min_confidence:
def concept_status(scores: dict[str, float], concept, min_score: float = 0.65) -> str:
score = scores.get(concept.id, 0.0)
if score >= min_score:
return "mastered"
if prereqs_satisfied(state, concept, min_score, min_confidence):
return "active" if rec else "available"
if prereqs_satisfied(scores, concept, min_score):
return "active" if score > 0 else "available"
return "locked"
def recommend_next(state: LearnerState, pack: PackData) -> list[dict]:
cards = []
for concept in pack.concepts:
status = concept_status(state, concept)
rec = get_record(state, concept.id, concept.masteryDimension)
if status in {"available", "active"}:
cards.append({
"id": concept.id,
"title": f"Work on {concept.title}",
"minutes": 15 if status == "available" else 10,
"reason": "Prerequisites are satisfied, so this is the best next unlock." if status == "available" else "You have started this concept, but mastery is not yet secure.",
"why": [
"Prerequisite check passed",
f"Current score: {rec.score:.2f}" if rec else "No evidence recorded yet",
f"Current confidence: {rec.confidence:.2f}" if rec else "Confidence starts after your first exercise",
],
"reward": concept.exerciseReward or f"{concept.title} progress recorded",
"conceptId": concept.id,
"scoreHint": 0.82 if status == "available" else 0.76,
"confidenceHint": 0.72 if status == "available" else 0.55,
def build_graph_frames(state: LearnerState, pack: PackData):
concepts = {c.id: c for c in pack.concepts}
scores = {c.id: 0.0 for c in pack.concepts}
frames = []
history = sorted(state.history, key=lambda x: x.timestamp)
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)
nodes.append({
"id": cid,
"title": concept.title,
"score": score,
"status": status,
"size": 20 + int(score * 30),
})
return cards[:4]
edges = []
for cid, concept in concepts.items():
for pre in concept.prerequisites:
edges.append({"source": pre, "target": cid})
frames.append({
"index": idx,
"timestamp": ev.timestamp,
"event_kind": ev.kind,
"focus_concept_id": ev.concept_id,
"nodes": nodes,
"edges": edges,
})
if not frames:
nodes = [{"id": c.id, "title": c.title, "score": 0.0, "status": "available" if not c.prerequisites else "locked", "size": 20} for c in pack.concepts]
edges = [{"source": pre, "target": c.id} for c in pack.concepts for pre in c.prerequisites]
frames.append({"index": 0, "timestamp": "", "event_kind": "empty", "focus_concept_id": "", "nodes": nodes, "edges": edges})
return frames

View File

@ -1,9 +1,5 @@
from __future__ import annotations
from pydantic import BaseModel, Field
from typing import Literal
EvidenceKind = Literal["checkpoint", "project", "exercise", "review"]
PolicyLane = Literal["personal", "community"]
class TokenPair(BaseModel):
access_token: str
@ -12,49 +8,13 @@ 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 RefreshRequest(BaseModel):
refresh_token: str
class DeploymentPolicyProfile(BaseModel):
profile_name: str
default_personal_lane_enabled: bool = True
default_community_lane_enabled: bool = True
community_publish_requires_approval: bool = True
personal_publish_direct: bool = True
reviewer_assignment_required: bool = False
description: str = ""
class 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
class PackConcept(BaseModel):
id: str
title: str
@ -62,13 +22,6 @@ class PackConcept(BaseModel):
masteryDimension: str = "mastery"
exerciseReward: str = ""
class PackCompliance(BaseModel):
sources: int = 0
attributionRequired: bool = False
shareAlikeRequired: bool = False
noncommercialOnly: bool = False
flags: list[str] = Field(default_factory=list)
class PackData(BaseModel):
id: str
title: str
@ -76,17 +29,7 @@ class PackData(BaseModel):
level: str = "novice-friendly"
concepts: list[PackConcept] = Field(default_factory=list)
onboarding: dict = Field(default_factory=dict)
compliance: PackCompliance = Field(default_factory=PackCompliance)
class CreatePackRequest(BaseModel):
pack: PackData
policy_lane: PolicyLane = "personal"
is_published: bool = False
change_summary: str = ""
class ContributionSubmissionCreate(BaseModel):
pack: PackData
submission_summary: str = ""
compliance: dict = Field(default_factory=dict)
class CreateLearnerRequest(BaseModel):
learner_id: str
@ -106,33 +49,10 @@ class EvidenceEvent(BaseModel):
score: float
confidence_hint: float = 0.5
timestamp: str
kind: EvidenceKind = "exercise"
kind: str = "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)

View File

@ -10,16 +10,6 @@ class UserORM(Base):
role: Mapped[str] = mapped_column(String(50), default="learner")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
class ServiceAccountORM(Base):
__tablename__ = "service_accounts"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(120), unique=True, index=True)
owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
description: Mapped[str] = mapped_column(Text, default="")
scopes_json: Mapped[str] = mapped_column(Text, default="[]")
secret_hash: Mapped[str] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
class RefreshTokenORM(Base):
__tablename__ = "refresh_tokens"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
@ -36,10 +26,6 @@ class PackORM(Base):
subtitle: Mapped[str] = mapped_column(Text, default="")
level: Mapped[str] = mapped_column(String(100), default="novice-friendly")
data_json: Mapped[str] = mapped_column(Text)
validation_json: Mapped[str] = mapped_column(Text, default="{}")
provenance_json: Mapped[str] = mapped_column(Text, default="{}")
governance_state: Mapped[str] = mapped_column(String(50), default="draft")
current_version: Mapped[int] = mapped_column(Integer, default=1)
is_published: Mapped[bool] = mapped_column(Boolean, default=False)
class LearnerORM(Base):
@ -70,16 +56,3 @@ 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="{}")

View File

@ -1,28 +1,10 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from sqlalchemy import select
from .db import SessionLocal
from .orm import UserORM, ServiceAccountORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM
from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent, DeploymentPolicyProfile
from .auth import verify_password, hash_password
from .config import load_settings
settings = load_settings()
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def deployment_policy_profile() -> DeploymentPolicyProfile:
return DeploymentPolicyProfile(
profile_name=settings.deployment_policy_profile,
default_personal_lane_enabled=True,
default_community_lane_enabled=True,
community_publish_requires_approval=True,
personal_publish_direct=True,
reviewer_assignment_required=False,
description="Deployment policy scaffold."
)
from .orm import UserORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM
from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent
from .auth import verify_password
def get_user_by_username(username: str):
with SessionLocal() as db:
@ -38,33 +20,6 @@ def authenticate_user(username: str, password: str):
return None
return user
def create_service_account(name: str, owner_user_id: int | None, description: str, scopes: list[str], secret: str):
with SessionLocal() as db:
sa = ServiceAccountORM(
name=name,
owner_user_id=owner_user_id,
description=description,
scopes_json=json.dumps(scopes),
secret_hash=hash_password(secret),
is_active=True,
)
db.add(sa)
db.commit()
db.refresh(sa)
return sa
def list_service_accounts():
with SessionLocal() as db:
rows = db.execute(select(ServiceAccountORM).order_by(ServiceAccountORM.id)).scalars().all()
return [{"id": r.id, "name": r.name, "owner_user_id": r.owner_user_id, "description": r.description, "scopes": json.loads(r.scopes_json or "[]"), "is_active": r.is_active} for r in rows]
def authenticate_service_account(name: str, secret: str):
with SessionLocal() as db:
sa = db.execute(select(ServiceAccountORM).where(ServiceAccountORM.name == name)).scalar_one_or_none()
if sa is None or not sa.is_active or not verify_password(secret, sa.secret_hash):
return None
return sa
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))
@ -105,9 +60,7 @@ def get_pack_row(pack_id: str):
with SessionLocal() as db:
return db.get(PackORM, pack_id)
def upsert_pack(pack: PackData, submitted_by_user_id: int, policy_lane: str = "personal", is_published: bool = False, change_summary: str = ""):
validation = {"ok": len(pack.concepts) > 0, "warnings": [] if len(pack.concepts) > 0 else ["Pack has no concepts."], "errors": []}
provenance = {"source_count": pack.compliance.sources, "restrictive_flags": list(pack.compliance.flags)}
def upsert_pack(pack: PackData, submitted_by_user_id: int, policy_lane: str = "personal", is_published: bool = False):
with SessionLocal() as db:
row = db.get(PackORM, pack.id)
payload = json.dumps(pack.model_dump())
@ -120,10 +73,6 @@ def upsert_pack(pack: PackData, submitted_by_user_id: int, policy_lane: str = "p
subtitle=pack.subtitle,
level=pack.level,
data_json=payload,
validation_json=json.dumps(validation),
provenance_json=json.dumps(provenance),
governance_state="personal_ready" if policy_lane == "personal" else "draft",
current_version=1,
is_published=is_published if policy_lane == "personal" else False,
)
db.add(row)
@ -134,9 +83,6 @@ def upsert_pack(pack: PackData, submitted_by_user_id: int, policy_lane: str = "p
row.subtitle = pack.subtitle
row.level = pack.level
row.data_json = payload
row.validation_json = json.dumps(validation)
row.provenance_json = json.dumps(provenance)
row.current_version += 1
if policy_lane == "personal":
row.is_published = is_published
db.commit()
@ -147,14 +93,6 @@ def create_learner(owner_user_id: int, learner_id: str, display_name: str = ""):
db.add(LearnerORM(id=learner_id, owner_user_id=owner_user_id, display_name=display_name))
db.commit()
def list_learners_for_user(user_id: int, is_admin: bool = False):
with SessionLocal() as db:
stmt = select(LearnerORM).order_by(LearnerORM.id)
if not is_admin:
stmt = stmt.where(LearnerORM.owner_user_id == user_id)
rows = db.execute(stmt).scalars().all()
return [{"learner_id": r.id, "display_name": r.display_name, "owner_user_id": r.owner_user_id} for r in rows]
def learner_owned_by_user(user_id: int, learner_id: str) -> bool:
with SessionLocal() as db:
learner = db.get(LearnerORM, learner_id)
@ -180,32 +118,3 @@ 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()

View File

@ -3,8 +3,8 @@ from sqlalchemy import select
from .db import Base, engine, SessionLocal
from .orm import UserORM
from .auth import hash_password
from .repository import upsert_pack
from .models import PackData, PackConcept, PackCompliance
from .repository import upsert_pack, create_learner
from .models import PackData, PackConcept
def main():
Base.metadata.create_all(bind=engine)
@ -12,18 +12,22 @@ def main():
if db.execute(select(UserORM).where(UserORM.username == "wesley")).scalar_one_or_none() is None:
db.add(UserORM(username="wesley", password_hash=hash_password("demo-pass"), role="admin", is_active=True))
db.commit()
create_learner(1, "wesley-learner", "Wesley learner")
upsert_pack(
PackData(
id="wesley-private-pack",
title="Wesley Private Pack",
subtitle="Personal pack example.",
level="novice-friendly",
concepts=[PackConcept(id="intro", title="Intro", prerequisites=[], masteryDimension="mastery", exerciseReward="Intro marker")],
onboarding={"headline":"Start privately","body":"Personal pack lane.","checklist":["Create pack","Use pack"]},
compliance=PackCompliance()
concepts=[
PackConcept(id="intro", title="Intro", prerequisites=[]),
PackConcept(id="second", title="Second concept", prerequisites=["intro"]),
PackConcept(id="third", title="Third concept", prerequisites=["second"]),
],
onboarding={"headline":"Start privately"},
compliance={}
),
submitted_by_user_id=1,
policy_lane="personal",
is_published=True,
change_summary="Initial personal pack"
)

View File

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

View File

@ -1,5 +1,5 @@
{
"name": "didactopus-agent-service-account-ui",
"name": "didactopus-animated-concept-graph-ui",
"private": true,
"version": "0.1.0",
"type": "module",

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { login, refresh, fetchDeploymentPolicy, fetchAgentCapabilities, listServiceAccounts, createServiceAccount } from "./api";
import React, { useEffect, useMemo, useState } from "react";
import { login, refresh, fetchPacks, fetchLearnerState, putLearnerState, fetchGraphAnimation } from "./api";
import { loadAuth, saveAuth, clearAuth } from "./authStore";
function LoginView({ onAuth }) {
@ -26,13 +26,57 @@ 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 }) {
if (!frame) return null;
const width = 760;
const height = 420;
const positions = {};
frame.nodes.forEach((node, idx) => {
positions[node.id] = { x: 120 + idx * 220, y: 120 + (idx % 2) * 150 };
});
return (
<svg viewBox={`0 0 ${width} ${height}`} className="graph">
{frame.edges.map((edge, idx) => {
const s = positions[edge.source];
const t = positions[edge.target];
if (!s || !t) return null;
return <line key={idx} x1={s.x} y1={s.y} x2={t.x} y2={t.y} stroke="#b8c2cf" strokeWidth="3" markerEnd="url(#arrow)" />;
})}
<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) => {
const p = positions[node.id];
return (
<g key={node.id}>
<circle cx={p.x} cy={p.y} r={node.size} fill={nodeColor(node.status)} opacity="0.9" />
<text x={p.x} y={p.y - 4} textAnchor="middle" className="svg-label">{node.title}</text>
<text x={p.x} y={p.y + 14} textAnchor="middle" className="svg-small">{node.score.toFixed(2)} · {node.status}</text>
</g>
);
})}
</svg>
);
}
export default function App() {
const [auth, setAuth] = useState(loadAuth());
const [policy, setPolicy] = useState(null);
const [caps, setCaps] = useState(null);
const [serviceAccounts, setServiceAccounts] = useState([]);
const [created, setCreated] = useState(null);
const [form, setForm] = useState({ name: "agent-learner-1", description: "AI learner account", scopes: ["packs:read","learners:read","learners:write","recommendations:read","evaluators:submit","evaluators:read"] });
const [packs, setPacks] = useState([]);
const [learnerId] = useState("wesley-learner");
const [packId, setPackId] = useState("");
const [graphData, setGraphData] = useState(null);
const [frameIndex, setFrameIndex] = useState(0);
const [playing, setPlaying] = useState(false);
const [message, setMessage] = useState("");
async function refreshAuthToken() {
if (!auth?.refresh_token) return null;
@ -57,22 +101,64 @@ export default function App() {
}
}
async function reload(pid) {
const data = await guarded((token) => fetchGraphAnimation(token, learnerId, pid));
setGraphData(data);
setFrameIndex(0);
}
useEffect(() => {
if (!auth) return;
async function load() {
setPolicy(await guarded((token) => fetchDeploymentPolicy(token)));
setCaps(await guarded((token) => fetchAgentCapabilities(token)));
if (auth.role === "admin") {
setServiceAccounts(await guarded((token) => listServiceAccounts(token)));
}
const p = await guarded((token) => fetchPacks(token));
setPacks(p);
const pid = p[0]?.id || "";
setPackId(pid);
if (pid) await reload(pid);
}
load();
}, [auth]);
async function createNow() {
const result = await guarded((token) => createServiceAccount(token, form));
setCreated(result);
setServiceAccounts(await guarded((token) => listServiceAccounts(token)));
useEffect(() => {
if (!playing || !graphData?.frames?.length) return;
const t = setInterval(() => {
setFrameIndex((idx) => idx >= graphData.frames.length - 1 ? 0 : idx + 1);
}, 900);
return () => clearInterval(t);
}, [playing, graphData]);
const frame = graphData?.frames?.[frameIndex];
async function generateDemo() {
let state = await guarded((token) => fetchLearnerState(token, learnerId));
const now1 = new Date().toISOString();
state.history.push({ concept_id: "intro", dimension: "mastery", score: 0.30, confidence_hint: 0.5, timestamp: now1, kind: "exercise", source_id: "demo-1" });
state.records = [{ concept_id: "intro", dimension: "mastery", score: 0.30, confidence: 0.30, evidence_count: 1, last_updated: now1 }];
await guarded((token) => putLearnerState(token, learnerId, state));
const now2 = new Date(Date.now() + 1000).toISOString();
state.history.push({ concept_id: "intro", dimension: "mastery", score: 0.78, confidence_hint: 0.7, timestamp: now2, kind: "review", source_id: "demo-2" });
state.records = [{ concept_id: "intro", dimension: "mastery", score: 0.78, confidence: 0.70, evidence_count: 2, last_updated: now2 }];
await guarded((token) => putLearnerState(token, learnerId, state));
const now3 = new Date(Date.now() + 2000).toISOString();
state.history.push({ concept_id: "second", dimension: "mastery", score: 0.42, confidence_hint: 0.5, timestamp: now3, kind: "exercise", source_id: "demo-3" });
state.records = [
{ concept_id: "intro", dimension: "mastery", score: 0.78, confidence: 0.70, evidence_count: 2, last_updated: now2 },
{ concept_id: "second", dimension: "mastery", score: 0.42, confidence: 0.40, evidence_count: 1, last_updated: now3 }
];
await guarded((token) => putLearnerState(token, learnerId, state));
const now4 = new Date(Date.now() + 3000).toISOString();
state.history.push({ concept_id: "second", dimension: "mastery", score: 0.72, confidence_hint: 0.7, timestamp: now4, kind: "review", source_id: "demo-4" });
state.records = [
{ concept_id: "intro", dimension: "mastery", score: 0.78, confidence: 0.70, evidence_count: 2, last_updated: now2 },
{ concept_id: "second", dimension: "mastery", score: 0.72, confidence: 0.65, evidence_count: 2, last_updated: now4 }
];
await guarded((token) => putLearnerState(token, learnerId, state));
await reload(packId);
setMessage("Demo graph animation frames generated.");
}
if (!auth) return <LoginView onAuth={setAuth} />;
@ -81,37 +167,36 @@ export default function App() {
<div className="page">
<header className="hero">
<div>
<h1>Didactopus agent service-account layer</h1>
<p>First-class machine identities with scoped API access for AI learners.</p>
<div className="muted">Signed in as {auth.username} ({auth.role})</div>
<h1>Didactopus animated concept graph</h1>
<p>Replay node-level mastery, unlocks, and prerequisite structure over time.</p>
<div className="muted">{message}</div>
</div>
<div className="controls">
<label>Pack
<select value={packId} onChange={async (e) => { setPackId(e.target.value); await reload(e.target.value); }}>
{packs.map((p) => <option key={p.id} value={p.id}>{p.title}</option>)}
</select>
</label>
<button onClick={() => setPlaying((x) => !x)}>{playing ? "Pause" : "Play"}</button>
<button onClick={generateDemo}>Generate demo</button>
<button onClick={() => { clearAuth(); setAuth(null); }}>Logout</button>
</div>
<button onClick={() => { clearAuth(); setAuth(null); }}>Logout</button>
</header>
<main className="layout twocol">
<section className="card">
<h2>Deployment policy</h2>
<pre className="prebox">{JSON.stringify(policy, null, 2)}</pre>
<h2>Agent capabilities</h2>
<pre className="prebox">{JSON.stringify(caps, null, 2)}</pre>
<h2>Graph animation</h2>
<div className="frame-meta">
<div><strong>Frame:</strong> {frameIndex + 1} / {graphData?.frames?.length || 0}</div>
<div><strong>Event:</strong> {frame?.event_kind || "-"}</div>
<div><strong>Focus:</strong> {frame?.focus_concept_id || "-"}</div>
<div><strong>Timestamp:</strong> {frame?.timestamp || "-"}</div>
</div>
<GraphView frame={frame} />
</section>
<section className="card">
<h2>Service accounts</h2>
{auth.role === "admin" ? (
<>
<label>Name<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></label>
<label>Description<input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></label>
<label>Scopes (comma-separated)<input value={form.scopes.join(", ")} onChange={(e) => setForm({ ...form, scopes: e.target.value.split(",").map(s => s.trim()).filter(Boolean) })} /></label>
<button className="primary" onClick={createNow}>Create service account</button>
<h3>Created credential</h3>
<pre className="prebox">{JSON.stringify(created, null, 2)}</pre>
<h3>Existing accounts</h3>
<pre className="prebox">{JSON.stringify(serviceAccounts, null, 2)}</pre>
</>
) : (
<div className="muted">Admin required.</div>
)}
<h2>Graph payload</h2>
<pre className="prebox">{JSON.stringify(graphData, null, 2)}</pre>
</section>
</main>
</div>

View File

@ -16,7 +16,7 @@ export async function refresh(refreshToken) {
if (!res.ok) throw new Error("refresh failed");
return await res.json();
}
export async function fetchDeploymentPolicy(token) { const res = await fetch(`${API}/deployment-policy`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchDeploymentPolicy failed"); return await res.json(); }
export async function fetchAgentCapabilities(token) { const res = await fetch(`${API}/agent/capabilities`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchAgentCapabilities failed"); return await res.json(); }
export async function listServiceAccounts(token) { const res = await fetch(`${API}/admin/service-accounts`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listServiceAccounts failed"); return await res.json(); }
export async function createServiceAccount(token, payload) { const res = await fetch(`${API}/admin/service-accounts`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("createServiceAccount failed"); return await res.json(); }
export async function 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(); }

View File

@ -3,18 +3,27 @@
}
* { box-sizing:border-box; }
body { margin:0; font-family:Arial, Helvetica, sans-serif; background:var(--bg); color:var(--text); }
.page { max-width:1400px; margin:0 auto; padding:24px; }
.page { max-width:1500px; margin:0 auto; padding:24px; }
.narrow-page { max-width:520px; }
.hero { background:var(--card); border:1px solid var(--border); border-radius:22px; padding:24px; display:flex; justify-content:space-between; gap:16px; }
label { display:block; font-weight:600; margin-bottom:10px; }
input { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; }
.hero { background:var(--card); border:1px solid var(--border); border-radius:22px; padding:24px; display:flex; justify-content:space-between; gap:16px; align-items:flex-start; }
.controls { display:flex; gap:10px; align-items:flex-end; flex-wrap:wrap; }
label { display:block; font-weight:600; }
input, select { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; }
button { border:1px solid var(--border); background:white; border-radius:12px; padding:10px 14px; cursor:pointer; }
.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:340px; }
.prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:460px; }
.muted { color:var(--muted); }
.error { color:#b42318; margin-top:10px; }
@media (max-width:1100px) { .twocol { grid-template-columns:1fr; } .hero { flex-direction:column; } }
.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; }
@media (max-width:1100px) {
.hero { flex-direction:column; }
.twocol { grid-template-columns:1fr; }
.frame-meta { grid-template-columns:1fr; }
}