diff --git a/pyproject.toml b/pyproject.toml index 17c47da..53c7bda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" ] diff --git a/src/didactopus/api.py b/src/didactopus/api.py index ceca6ff..38663d9 100644 --- a/src/didactopus/api.py +++ b/src/didactopus/api.py @@ -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) diff --git a/src/didactopus/auth.py b/src/didactopus/auth.py index c7b3117..54745ac 100644 --- a/src/didactopus/auth.py +++ b/src/didactopus/auth.py @@ -25,9 +25,6 @@ def issue_access_token(user_id: int, username: str, role: str) -> str: def issue_refresh_token(user_id: int, username: str, role: str, token_id: str) -> str: return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "refresh", "jti": token_id}, timedelta(days=14)) -def issue_service_access_token(service_account_id: int, name: str, scopes: list[str]) -> str: - return _encode_token({"sub": str(service_account_id), "service_account_name": name, "kind": "service", "scopes": scopes}, timedelta(hours=8)) - def decode_token(token: str) -> dict | None: try: return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]) @@ -36,6 +33,3 @@ def decode_token(token: str) -> dict | None: def new_token_id() -> str: return secrets.token_urlsafe(24) - -def new_secret() -> str: - return secrets.token_urlsafe(24) diff --git a/src/didactopus/config.py b/src/didactopus/config.py index 88e5551..1f71733 100644 --- a/src/didactopus/config.py +++ b/src/didactopus/config.py @@ -8,7 +8,6 @@ class Settings(BaseModel): port: int = int(os.getenv("DIDACTOPUS_PORT", "8011")) jwt_secret: str = os.getenv("DIDACTOPUS_JWT_SECRET", "change-me") jwt_algorithm: str = "HS256" - deployment_policy_profile: str = os.getenv("DIDACTOPUS_POLICY_PROFILE", "single_user") def load_settings() -> Settings: return Settings() diff --git a/src/didactopus/engine.py b/src/didactopus/engine.py index c1e52bf..f9032e0 100644 --- a/src/didactopus/engine.py +++ b/src/didactopus/engine.py @@ -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 diff --git a/src/didactopus/models.py b/src/didactopus/models.py index 87b1f84..b1db4f5 100644 --- a/src/didactopus/models.py +++ b/src/didactopus/models.py @@ -1,9 +1,5 @@ from __future__ import annotations from pydantic import BaseModel, Field -from typing import Literal - -EvidenceKind = Literal["checkpoint", "project", "exercise", "review"] -PolicyLane = Literal["personal", "community"] class TokenPair(BaseModel): access_token: str @@ -12,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) diff --git a/src/didactopus/orm.py b/src/didactopus/orm.py index b95271e..49c1d57 100644 --- a/src/didactopus/orm.py +++ b/src/didactopus/orm.py @@ -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="{}") diff --git a/src/didactopus/repository.py b/src/didactopus/repository.py index 18d011c..bc8e397 100644 --- a/src/didactopus/repository.py +++ b/src/didactopus/repository.py @@ -1,28 +1,10 @@ from __future__ import annotations import json -from datetime import datetime, timezone from sqlalchemy import select from .db import SessionLocal -from .orm import UserORM, ServiceAccountORM, 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() diff --git a/src/didactopus/seed.py b/src/didactopus/seed.py index 4b29cbd..a3723f5 100644 --- a/src/didactopus/seed.py +++ b/src/didactopus/seed.py @@ -3,8 +3,8 @@ from sqlalchemy import select from .db import Base, engine, SessionLocal from .orm import UserORM from .auth import hash_password -from .repository import upsert_pack -from .models import PackData, PackConcept, PackCompliance +from .repository import upsert_pack, create_learner +from .models import PackData, PackConcept 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" ) diff --git a/webui/index.html b/webui/index.html index 1356138..0214f55 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3,7 +3,7 @@ - Didactopus Agent Service Accounts + Didactopus Animated Concept Graph
diff --git a/webui/package.json b/webui/package.json index 325b296..f9f85ae 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,5 +1,5 @@ { - "name": "didactopus-agent-service-account-ui", + "name": "didactopus-animated-concept-graph-ui", "private": true, "version": "0.1.0", "type": "module", diff --git a/webui/src/App.jsx b/webui/src/App.jsx index 22a5e17..d16151d 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -1,5 +1,5 @@ -import React, { useEffect, useState } from "react"; -import { login, refresh, fetchDeploymentPolicy, fetchAgentCapabilities, listServiceAccounts, createServiceAccount } 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 ( + + {frame.edges.map((edge, idx) => { + const s = positions[edge.source]; + const t = positions[edge.target]; + if (!s || !t) return null; + return ; + })} + + + + + + {frame.nodes.map((node) => { + const p = positions[node.id]; + return ( + + + {node.title} + {node.score.toFixed(2)} ยท {node.status} + + ); + })} + + ); +} + 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 ; @@ -81,37 +167,36 @@ export default function App() {
-

Didactopus agent service-account layer

-

First-class machine identities with scoped API access for AI learners.

-
Signed in as {auth.username} ({auth.role})
+

Didactopus animated concept graph

+

Replay node-level mastery, unlocks, and prerequisite structure over time.

+
{message}
+
+
+ + + +
-
-

Deployment policy

-
{JSON.stringify(policy, null, 2)}
-

Agent capabilities

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

Graph animation

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

Service accounts

- {auth.role === "admin" ? ( - <> - - - - -

Created credential

-
{JSON.stringify(created, null, 2)}
-

Existing accounts

-
{JSON.stringify(serviceAccounts, null, 2)}
- - ) : ( -
Admin required.
- )} +

Graph payload

+
{JSON.stringify(graphData, null, 2)}
diff --git a/webui/src/api.js b/webui/src/api.js index bef781c..975a975 100644 --- a/webui/src/api.js +++ b/webui/src/api.js @@ -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(); } diff --git a/webui/src/styles.css b/webui/src/styles.css index db154e4..516cd27 100644 --- a/webui/src/styles.css +++ b/webui/src/styles.css @@ -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; } +}