From 5b64420315f57c6425ba604d2a4e0d6e401e06a1 Mon Sep 17 00:00:00 2001 From: welsberr Date: Sat, 14 Mar 2026 13:29:55 -0400 Subject: [PATCH] Apply ZIP update: 135-didactopus-admin-curation-layer.zip [2026-03-14T13:19:17] --- pyproject.toml | 20 +- src/didactopus/__init__.py | 2 +- src/didactopus/api.py | 222 +++++++-------- src/didactopus/config.py | 31 +-- src/didactopus/engine.py | 145 ++++------ src/didactopus/models.py | 90 ++++--- src/didactopus/orm.py | 88 +++--- src/didactopus/repository.py | 368 +++++++++---------------- src/didactopus/seed.py | 58 ++-- src/didactopus/worker.py | 61 ++--- tests/test_scaffold_files.py | 2 +- webui/index.html | 6 +- webui/package.json | 16 +- webui/src/App.jsx | 510 +++++++++++++++++++---------------- webui/src/api.js | 57 ++-- webui/src/main.jsx | 1 - webui/src/styles.css | 73 +++-- 17 files changed, 776 insertions(+), 974 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ff216ab..17c47da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,18 +5,20 @@ build-backend = "setuptools.build_meta" [project] name = "didactopus" version = "0.1.0" -description = "Didactopus: semantic QA layer for domain packs" -readme = "README.md" requires-python = ">=3.10" -license = {text = "MIT"} -authors = [{name = "Wesley R. Elsberry"}] -dependencies = ["pydantic>=2.7", "pyyaml>=6.0"] - -[project.optional-dependencies] -dev = ["pytest>=8.0", "ruff>=0.6"] +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" +] [project.scripts] -didactopus-review-bridge = "didactopus.review_bridge_server:main" +didactopus-api = "didactopus.api:main" [tool.setuptools.packages.find] where = ["src"] diff --git a/src/didactopus/__init__.py b/src/didactopus/__init__.py index 3dc1f76..b794fd4 100644 --- a/src/didactopus/__init__.py +++ b/src/didactopus/__init__.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = '0.1.0' diff --git a/src/didactopus/api.py b/src/didactopus/api.py index 1798efa..332873c 100644 --- a/src/didactopus/api.py +++ b/src/didactopus/api.py @@ -1,29 +1,22 @@ from __future__ import annotations -from fastapi import FastAPI, HTTPException, Header, Depends +import json +from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks from fastapi.middleware.cors import CORSMiddleware import uvicorn +from .config import load_settings from .db import Base, engine -from .models import ( - LoginRequest, TokenPair, KnowledgeCandidateCreate, KnowledgeCandidateUpdate, - ReviewCreate, PromoteRequest, SynthesisRunRequest, SynthesisPromoteRequest, - CreateLearnerRequest -) -from .repository import ( - authenticate_user, get_user_by_id, create_learner, learner_owned_by_user, - create_candidate, list_candidates, get_candidate, update_candidate, - create_review, list_reviews, create_promotion, list_promotions, - list_synthesis_candidates, get_synthesis_candidate -) +from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest +from .repository import authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token, list_packs, list_pack_admin_rows, get_pack, get_pack_validation, get_pack_provenance, upsert_pack, set_pack_publication, create_learner, list_learners_for_user, 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, decode_token, new_token_id -from .synthesis import generate_synthesis_candidates +from .worker import process_job +settings = load_settings() Base.metadata.create_all(bind=engine) -app = FastAPI(title="Didactopus Review Workbench API") +app = FastAPI(title="Didactopus API Prototype") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"]) -_refresh_tokens = {} - def current_user(authorization: str = Header(default="")): token = authorization.removeprefix("Bearer ").strip() payload = decode_token(token) if token else None @@ -34,117 +27,134 @@ def current_user(authorization: str = Header(default="")): raise HTTPException(status_code=401, detail="Unauthorized") return user -def require_reviewer(user = Depends(current_user)): - if user.role not in {"admin", "reviewer"}: - raise HTTPException(status_code=403, detail="Reviewer role required") +def require_admin(user = Depends(current_user)): + if user.role != "admin": + raise HTTPException(status_code=403, detail="Admin role required") return 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 user") + @app.post("/api/login", response_model=TokenPair) def login(payload: LoginRequest): user = authenticate_user(payload.username, payload.password) if user is None: raise HTTPException(status_code=401, detail="Invalid credentials") token_id = new_token_id() - _refresh_tokens[token_id] = user.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 - ) + 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/refresh", response_model=TokenPair) +def refresh(payload: RefreshRequest): + data = decode_token(payload.refresh_token) + if not data or data.get("kind") != "refresh": + raise HTTPException(status_code=401, detail="Invalid refresh token") + token_id = data.get("jti") + if not token_id or not refresh_token_active(token_id): + raise HTTPException(status_code=401, detail="Refresh token inactive") + user = get_user_by_id(int(data["sub"])) + if user is None: + raise HTTPException(status_code=401, detail="User not found") + revoke_refresh_token(token_id) + new_jti = new_token_id() + 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/packs") +def api_list_packs(user = Depends(current_user)): + return [p.model_dump() for p in list_packs(include_unpublished=(user.role == "admin"))] + +@app.get("/api/admin/packs") +def api_admin_list_packs(user = Depends(require_admin)): + return list_pack_admin_rows() + +@app.get("/api/admin/packs/{pack_id}/validation") +def api_admin_pack_validation(pack_id: str, user = Depends(require_admin)): + return get_pack_validation(pack_id) + +@app.get("/api/admin/packs/{pack_id}/provenance") +def api_admin_pack_provenance(pack_id: str, user = Depends(require_admin)): + return get_pack_provenance(pack_id) + +@app.post("/api/admin/packs") +def api_upsert_pack(payload: CreatePackRequest, user = Depends(require_admin)): + upsert_pack(payload.pack, is_published=payload.is_published) + return {"ok": True, "pack_id": payload.pack.id} + +@app.post("/api/admin/packs/{pack_id}/publish") +def api_publish_pack(pack_id: str, is_published: bool, user = Depends(require_admin)): + ok = set_pack_publication(pack_id, is_published) + if not ok: + raise HTTPException(status_code=404, detail="Pack not found") + return {"ok": True, "pack_id": pack_id, "is_published": is_published} @app.post("/api/learners") 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.post("/api/knowledge-candidates") -def api_create_candidate(payload: KnowledgeCandidateCreate, reviewer = Depends(require_reviewer)): - candidate_id = create_candidate(payload) - return {"candidate_id": candidate_id} +@app.get("/api/learners") +def api_list_learners(user = Depends(current_user)): + return list_learners_for_user(user.id, is_admin=(user.role == "admin")) -@app.get("/api/knowledge-candidates") -def api_list_candidates(reviewer = Depends(require_reviewer)): - return list_candidates() +@app.get("/api/learners/{learner_id}/state") +def api_get_learner_state(learner_id: str, user = Depends(current_user)): + ensure_learner_access(user, learner_id) + return load_learner_state(learner_id).model_dump() -@app.get("/api/knowledge-candidates/{candidate_id}") -def api_get_candidate(candidate_id: int, reviewer = Depends(require_reviewer)): - row = get_candidate(candidate_id) - if row is None: - raise HTTPException(status_code=404, detail="Candidate not found") - return row +@app.put("/api/learners/{learner_id}/state") +def api_put_learner_state(learner_id: str, state: LearnerState, user = Depends(current_user)): + ensure_learner_access(user, learner_id) + 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/knowledge-candidates/{candidate_id}/update") -def api_update_candidate(candidate_id: int, payload: KnowledgeCandidateUpdate, reviewer = Depends(require_reviewer)): - row = update_candidate(candidate_id, triage_lane=payload.triage_lane, current_status=payload.current_status) - if row is None: - raise HTTPException(status_code=404, detail="Candidate not found") - return {"candidate_id": row.id, "triage_lane": row.triage_lane, "current_status": row.current_status} +@app.post("/api/learners/{learner_id}/evidence") +def api_post_evidence(learner_id: str, event: EvidenceEvent, user = Depends(current_user)): + ensure_learner_access(user, learner_id) + state = load_learner_state(learner_id) + state = apply_evidence(state, event) + save_learner_state(state) + return state.model_dump() -@app.post("/api/knowledge-candidates/{candidate_id}/reviews") -def api_create_review(candidate_id: int, payload: ReviewCreate, reviewer = Depends(require_reviewer)): - if get_candidate(candidate_id) is None: - raise HTTPException(status_code=404, detail="Candidate not found") - review_id = create_review(candidate_id, reviewer.id, payload) - return {"review_id": review_id} +@app.get("/api/learners/{learner_id}/recommendations/{pack_id}") +def api_get_recommendations(learner_id: str, pack_id: str, user = Depends(current_user)): + ensure_learner_access(user, learner_id) + state = load_learner_state(learner_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.get("/api/knowledge-candidates/{candidate_id}/reviews") -def api_list_reviews(candidate_id: int, reviewer = Depends(require_reviewer)): - return list_reviews(candidate_id) +@app.post("/api/learners/{learner_id}/evaluator-jobs", response_model=EvaluatorJobStatus) +def api_submit_evaluator_job(learner_id: str, payload: EvaluatorSubmission, background_tasks: BackgroundTasks, user = Depends(current_user)): + ensure_learner_access(user, learner_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.post("/api/knowledge-candidates/{candidate_id}/promote") -def api_promote_candidate(candidate_id: int, payload: PromoteRequest, reviewer = Depends(require_reviewer)): - if get_candidate(candidate_id) is None: - raise HTTPException(status_code=404, detail="Candidate not found") - promotion_id = create_promotion(candidate_id, reviewer.id, payload) - return {"promotion_id": promotion_id} +@app.get("/api/evaluator-jobs/{job_id}", response_model=EvaluatorJobStatus) +def api_get_evaluator_job(job_id: int, user = Depends(current_user)): + 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/promotions") -def api_list_promotions(reviewer = Depends(require_reviewer)): - return list_promotions() +@app.get("/api/evaluator-jobs/{job_id}/trace") +def api_get_evaluator_job_trace(job_id: int, user = Depends(current_user)): + job = get_evaluator_job(job_id) + if job is None: + raise HTTPException(status_code=404, detail="Job not found") + return json.loads(job.trace_json or "{}") -@app.post("/api/synthesis/run") -def api_run_synthesis(payload: SynthesisRunRequest, reviewer = Depends(require_reviewer)): - created = generate_synthesis_candidates(payload.source_pack_id, payload.target_pack_id, payload.limit) - return {"created_count": len(created), "synthesis_ids": created} - -@app.get("/api/synthesis/candidates") -def api_list_synthesis(reviewer = Depends(require_reviewer)): - return list_synthesis_candidates() - -@app.get("/api/synthesis/candidates/{synthesis_id}") -def api_get_synthesis(synthesis_id: int, reviewer = Depends(require_reviewer)): - row = get_synthesis_candidate(synthesis_id) - if row is None: - raise HTTPException(status_code=404, detail="Synthesis candidate not found") - return row - -@app.post("/api/synthesis/candidates/{synthesis_id}/promote") -def api_promote_synthesis(synthesis_id: int, payload: SynthesisPromoteRequest, reviewer = Depends(require_reviewer)): - syn = get_synthesis_candidate(synthesis_id) - if syn is None: - raise HTTPException(status_code=404, detail="Synthesis candidate not found") - candidate_id = create_candidate(KnowledgeCandidateCreate( - source_type="synthesis_engine", - source_artifact_id=None, - learner_id="system", - pack_id=syn["source_pack_id"], - candidate_kind="synthesis_proposal", - title=f"Synthesis: {syn['source_concept_id']} ↔ {syn['target_concept_id']}", - summary=syn["explanation"], - structured_payload=syn, - evidence_summary="Promoted from synthesis engine candidate", - confidence_hint=syn["score_total"], - novelty_score=syn["evidence"].get("novelty", 0.0), - synthesis_score=syn["score_total"], - triage_lane=payload.promotion_target, - )) - promotion_id = create_promotion(candidate_id, reviewer.id, PromoteRequest( - promotion_target=payload.promotion_target, - target_object_id="", - promotion_status="approved", - )) - return {"candidate_id": candidate_id, "promotion_id": promotion_id} +@app.get("/api/learners/{learner_id}/evaluator-history") +def api_get_evaluator_history(learner_id: str, user = Depends(current_user)): + ensure_learner_access(user, 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] def main(): - uvicorn.run(app, host="127.0.0.1", port=8011) + uvicorn.run(app, host=settings.host, port=settings.port) diff --git a/src/didactopus/config.py b/src/didactopus/config.py index 77b988d..6db28e1 100644 --- a/src/didactopus/config.py +++ b/src/didactopus/config.py @@ -1,22 +1,13 @@ -from pathlib import Path -from pydantic import BaseModel, Field -import yaml +from __future__ import annotations +import os +from pydantic import BaseModel -class ReviewConfig(BaseModel): - default_reviewer: str = "Unknown Reviewer" - write_promoted_pack: bool = True +class Settings(BaseModel): + database_url: str = os.getenv("DIDACTOPUS_DATABASE_URL", "postgresql+psycopg://didactopus:didactopus-dev-password@localhost:5432/didactopus") + host: str = os.getenv("DIDACTOPUS_HOST", "127.0.0.1") + port: int = int(os.getenv("DIDACTOPUS_PORT", "8011")) + jwt_secret: str = os.getenv("DIDACTOPUS_JWT_SECRET", "change-me") + jwt_algorithm: str = "HS256" -class BridgeConfig(BaseModel): - host: str = "127.0.0.1" - port: int = 8765 - registry_path: str = "workspace_registry.json" - default_workspace_root: str = "workspaces" - -class AppConfig(BaseModel): - review: ReviewConfig = Field(default_factory=ReviewConfig) - bridge: BridgeConfig = Field(default_factory=BridgeConfig) - -def load_config(path: str | Path) -> AppConfig: - with open(path, "r", encoding="utf-8") as handle: - data = yaml.safe_load(handle) or {} - return AppConfig.model_validate(data) +def load_settings() -> Settings: + return Settings() diff --git a/src/didactopus/engine.py b/src/didactopus/engine.py index 6b7c236..c1e52bf 100644 --- a/src/didactopus/engine.py +++ b/src/didactopus/engine.py @@ -1,110 +1,59 @@ from __future__ import annotations -from collections import defaultdict -from .models import LearnerState, PackData +from .models import LearnerState, EvidenceEvent, MasteryRecord, PackData -def concept_depths(pack: PackData) -> dict[str, int]: - concept_map = {c.id: c for c in pack.concepts} - memo = {} - def depth(cid: str) -> int: - if cid in memo: - return memo[cid] - c = concept_map[cid] - if not c.prerequisites: - memo[cid] = 0 - else: - memo[cid] = 1 + max(depth(pid) for pid in c.prerequisites if pid in concept_map) - return memo[cid] - for cid in concept_map: - depth(cid) - return memo +def get_record(state: LearnerState, concept_id: str, dimension: str = "mastery") -> MasteryRecord | None: + for rec in state.records: + if rec.concept_id == concept_id and rec.dimension == dimension: + return rec + return None -def stable_layout(pack: PackData, width: int = 900, height: int = 520): - depths = concept_depths(pack) - layers = defaultdict(list) - for c in pack.concepts: - layers[depths.get(c.id, 0)].append(c) - positions = {} - max_depth = max(layers.keys()) if layers else 0 - for d in sorted(layers): - nodes = sorted(layers[d], key=lambda c: c.id) - y = 90 + d * ((height - 160) / max(1, max_depth)) - for idx, node in enumerate(nodes): - if node.position is not None: - positions[node.id] = {"x": node.position.x, "y": node.position.y, "source": "pack_authored"} - else: - spacing = width / (len(nodes) + 1) - x = spacing * (idx + 1) - positions[node.id] = {"x": x, "y": y, "source": "auto_layered"} - return positions +def apply_evidence(state: LearnerState, event: EvidenceEvent, decay: float = 0.05, reinforcement: float = 0.25) -> LearnerState: + rec = get_record(state, event.concept_id, event.dimension) + if rec is None: + rec = MasteryRecord(concept_id=event.concept_id, dimension=event.dimension, score=0.0, confidence=0.0, evidence_count=0, last_updated=event.timestamp) + state.records.append(rec) + weight = max(0.05, min(1.0, event.confidence_hint)) + rec.score = ((rec.score * rec.evidence_count) + (event.score * weight)) / max(1, rec.evidence_count + 1) + rec.confidence = min(1.0, max(0.0, rec.confidence * (1.0 - decay) + reinforcement * weight + 0.10 * max(0.0, min(1.0, event.score)))) + rec.evidence_count += 1 + rec.last_updated = event.timestamp + state.history.append(event) + return state -def prereqs_satisfied(scores: dict[str, float], concept, min_score: float = 0.65) -> bool: +def prereqs_satisfied(state: LearnerState, concept, min_score: float = 0.65, min_confidence: float = 0.45) -> bool: for pid in concept.prerequisites: - if scores.get(pid, 0.0) < min_score: + rec = get_record(state, pid, concept.masteryDimension) + if rec is None or rec.score < min_score or rec.confidence < min_confidence: return False return True -def concept_status(scores: dict[str, float], concept, min_score: float = 0.65) -> str: - score = scores.get(concept.id, 0.0) - if score >= min_score: +def concept_status(state: LearnerState, concept, min_score: float = 0.65, min_confidence: float = 0.45) -> str: + rec = get_record(state, concept.id, concept.masteryDimension) + if rec and rec.score >= min_score and rec.confidence >= min_confidence: return "mastered" - if prereqs_satisfied(scores, concept, min_score): - return "active" if score > 0 else "available" + if prereqs_satisfied(state, concept, min_score, min_confidence): + return "active" if rec else "available" return "locked" -def build_graph_frames(state: LearnerState, pack: PackData): - concepts = {c.id: c for c in pack.concepts} - layout = stable_layout(pack) - scores = {c.id: 0.0 for c in pack.concepts} - frames = [] - history = sorted(state.history, key=lambda x: x.timestamp) - static_edges = [{"source": pre, "target": c.id, "kind": "prerequisite"} for c in pack.concepts for pre in c.prerequisites] - static_cross = [{ - "source": c.id, - "target_pack_id": link.target_pack_id, - "target_concept_id": link.target_concept_id, - "relationship": link.relationship, - "kind": "cross_pack" - } for c in pack.concepts for link in c.cross_pack_links] - for idx, ev in enumerate(history): - if ev.concept_id in scores: - scores[ev.concept_id] = ev.score - nodes = [] - for cid, concept in concepts.items(): - score = scores.get(cid, 0.0) - status = concept_status(scores, concept) - pos = layout[cid] - nodes.append({ - "id": cid, - "title": concept.title, - "score": score, - "status": status, - "size": 20 + int(score * 30), - "x": pos["x"], - "y": pos["y"], - "layout_source": pos["source"], +def recommend_next(state: LearnerState, pack: PackData) -> list[dict]: + cards = [] + for concept in pack.concepts: + status = concept_status(state, concept) + rec = get_record(state, concept.id, concept.masteryDimension) + if status in {"available", "active"}: + cards.append({ + "id": concept.id, + "title": f"Work on {concept.title}", + "minutes": 15 if status == "available" else 10, + "reason": "Prerequisites are satisfied, so this is the best next unlock." if status == "available" else "You have started this concept, but mastery is not yet secure.", + "why": [ + "Prerequisite check passed", + f"Current score: {rec.score:.2f}" if rec else "No evidence recorded yet", + f"Current confidence: {rec.confidence:.2f}" if rec else "Confidence starts after your first exercise", + ], + "reward": concept.exerciseReward or f"{concept.title} progress recorded", + "conceptId": concept.id, + "scoreHint": 0.82 if status == "available" else 0.76, + "confidenceHint": 0.72 if status == "available" else 0.55, }) - frames.append({ - "index": idx, - "timestamp": ev.timestamp, - "event_kind": ev.kind, - "focus_concept_id": ev.concept_id, - "nodes": nodes, - "edges": static_edges, - "cross_pack_links": static_cross, - }) - if not frames: - nodes = [] - for c in pack.concepts: - pos = layout[c.id] - nodes.append({ - "id": c.id, - "title": c.title, - "score": 0.0, - "status": "available" if not c.prerequisites else "locked", - "size": 20, - "x": pos["x"], - "y": pos["y"], - "layout_source": pos["source"], - }) - frames.append({"index": 0, "timestamp": "", "event_kind": "empty", "focus_concept_id": "", "nodes": nodes, "edges": static_edges, "cross_pack_links": static_cross}) - return frames + return cards[:4] diff --git a/src/didactopus/models.py b/src/didactopus/models.py index 16f12be..9456ad7 100644 --- a/src/didactopus/models.py +++ b/src/didactopus/models.py @@ -1,9 +1,8 @@ from __future__ import annotations from pydantic import BaseModel, Field +from typing import Literal -class LoginRequest(BaseModel): - username: str - password: str +EvidenceKind = Literal["checkpoint", "project", "exercise", "review"] class TokenPair(BaseModel): access_token: str @@ -12,43 +11,39 @@ class TokenPair(BaseModel): username: str role: str -class KnowledgeCandidateCreate(BaseModel): - source_type: str = "learner_export" - source_artifact_id: int | None = None - learner_id: str - pack_id: str - candidate_kind: str +class LoginRequest(BaseModel): + username: str + password: str + +class RefreshRequest(BaseModel): + refresh_token: str + +class PackConcept(BaseModel): + id: str title: str - summary: str = "" - structured_payload: dict = Field(default_factory=dict) - evidence_summary: str = "" - confidence_hint: float = 0.0 - novelty_score: float = 0.0 - synthesis_score: float = 0.0 - triage_lane: str = "archive" + prerequisites: list[str] = Field(default_factory=list) + masteryDimension: str = "mastery" + exerciseReward: str = "" -class KnowledgeCandidateUpdate(BaseModel): - triage_lane: str | None = None - current_status: str | None = None +class PackCompliance(BaseModel): + sources: int = 0 + attributionRequired: bool = False + shareAlikeRequired: bool = False + noncommercialOnly: bool = False + flags: list[str] = Field(default_factory=list) -class ReviewCreate(BaseModel): - review_kind: str = "human_review" - verdict: str - rationale: str = "" - requested_changes: str = "" +class PackData(BaseModel): + id: str + title: str + subtitle: str = "" + level: str = "novice-friendly" + concepts: list[PackConcept] = Field(default_factory=list) + onboarding: dict = Field(default_factory=dict) + compliance: PackCompliance = Field(default_factory=PackCompliance) -class PromoteRequest(BaseModel): - promotion_target: str - target_object_id: str = "" - promotion_status: str = "approved" - -class SynthesisRunRequest(BaseModel): - source_pack_id: str | None = None - target_pack_id: str | None = None - limit: int = 20 - -class SynthesisPromoteRequest(BaseModel): - promotion_target: str = "pack_improvement" +class CreatePackRequest(BaseModel): + pack: PackData + is_published: bool = True class CreateLearnerRequest(BaseModel): learner_id: str @@ -62,6 +57,29 @@ class MasteryRecord(BaseModel): evidence_count: int = 0 last_updated: str = "" +class EvidenceEvent(BaseModel): + concept_id: str + dimension: str + score: float + confidence_hint: float = 0.5 + timestamp: str + kind: EvidenceKind = "exercise" + source_id: str = "" + class LearnerState(BaseModel): learner_id: str records: list[MasteryRecord] = Field(default_factory=list) + history: list[EvidenceEvent] = Field(default_factory=list) + +class EvaluatorSubmission(BaseModel): + pack_id: str + concept_id: str + submitted_text: str + kind: str = "checkpoint" + +class EvaluatorJobStatus(BaseModel): + job_id: int + status: str + result_score: float | None = None + result_confidence_hint: float | None = None + result_notes: str = "" diff --git a/src/didactopus/orm.py b/src/didactopus/orm.py index 668611f..1382d61 100644 --- a/src/didactopus/orm.py +++ b/src/didactopus/orm.py @@ -10,16 +10,23 @@ class UserORM(Base): role: Mapped[str] = mapped_column(String(50), default="learner") is_active: Mapped[bool] = mapped_column(Boolean, default=True) +class RefreshTokenORM(Base): + __tablename__ = "refresh_tokens" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) + token_id: Mapped[str] = mapped_column(String(255), unique=True, index=True) + is_revoked: Mapped[bool] = mapped_column(Boolean, default=False) + class PackORM(Base): __tablename__ = "packs" id: Mapped[str] = mapped_column(String(100), primary_key=True) - owner_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) - policy_lane: Mapped[str] = mapped_column(String(50), default="personal") title: Mapped[str] = mapped_column(String(255)) subtitle: Mapped[str] = mapped_column(Text, default="") level: Mapped[str] = mapped_column(String(100), default="novice-friendly") data_json: Mapped[str] = mapped_column(Text) - is_published: Mapped[bool] = mapped_column(Boolean, default=False) + validation_json: Mapped[str] = mapped_column(Text, default="{}") + provenance_json: Mapped[str] = mapped_column(Text, default="{}") + is_published: Mapped[bool] = mapped_column(Boolean, default=True) class LearnerORM(Base): __tablename__ = "learners" @@ -38,60 +45,27 @@ class MasteryRecordORM(Base): evidence_count: Mapped[int] = mapped_column(Integer, default=0) last_updated: Mapped[str] = mapped_column(String(100), default="") -class KnowledgeCandidateORM(Base): - __tablename__ = "knowledge_candidates" +class EvidenceEventORM(Base): + __tablename__ = "evidence_events" id: Mapped[int] = mapped_column(Integer, primary_key=True) - source_type: Mapped[str] = mapped_column(String(50), default="learner_export") - source_artifact_id: Mapped[int | None] = mapped_column(Integer, nullable=True) - learner_id: Mapped[str] = mapped_column(String(100), index=True) - pack_id: Mapped[str] = mapped_column(String(100), index=True) - candidate_kind: Mapped[str] = mapped_column(String(100), index=True) - title: Mapped[str] = mapped_column(String(255)) - summary: Mapped[str] = mapped_column(Text, default="") - structured_payload_json: Mapped[str] = mapped_column(Text, default="{}") - evidence_summary: Mapped[str] = mapped_column(Text, default="") - confidence_hint: Mapped[float] = mapped_column(Float, default=0.0) - novelty_score: Mapped[float] = mapped_column(Float, default=0.0) - synthesis_score: Mapped[float] = mapped_column(Float, default=0.0) - triage_lane: Mapped[str] = mapped_column(String(50), default="archive") - current_status: Mapped[str] = mapped_column(String(50), default="captured") - created_at: Mapped[str] = mapped_column(String(100), default="") + learner_id: Mapped[str] = mapped_column(ForeignKey("learners.id"), index=True) + concept_id: Mapped[str] = mapped_column(String(100), index=True) + dimension: Mapped[str] = mapped_column(String(100), default="mastery") + score: Mapped[float] = mapped_column(Float, default=0.0) + confidence_hint: Mapped[float] = mapped_column(Float, default=0.5) + 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 ReviewRecordORM(Base): - __tablename__ = "review_records" +class EvaluatorJobORM(Base): + __tablename__ = "evaluator_jobs" id: Mapped[int] = mapped_column(Integer, primary_key=True) - candidate_id: Mapped[int] = mapped_column(ForeignKey("knowledge_candidates.id"), index=True) - reviewer_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) - review_kind: Mapped[str] = mapped_column(String(50), default="human_review") - verdict: Mapped[str] = mapped_column(String(100), default="") - rationale: Mapped[str] = mapped_column(Text, default="") - requested_changes: Mapped[str] = mapped_column(Text, default="") - created_at: Mapped[str] = mapped_column(String(100), default="") - -class PromotionRecordORM(Base): - __tablename__ = "promotion_records" - id: Mapped[int] = mapped_column(Integer, primary_key=True) - candidate_id: Mapped[int] = mapped_column(ForeignKey("knowledge_candidates.id"), index=True) - promotion_target: Mapped[str] = mapped_column(String(50), index=True) - target_object_id: Mapped[str] = mapped_column(String(100), default="") - promotion_status: Mapped[str] = mapped_column(String(50), default="draft") - promoted_by: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) - created_at: Mapped[str] = mapped_column(String(100), default="") - -class SynthesisCandidateORM(Base): - __tablename__ = "synthesis_candidates" - id: Mapped[int] = mapped_column(Integer, primary_key=True) - source_concept_id: Mapped[str] = mapped_column(String(100), index=True) - target_concept_id: Mapped[str] = mapped_column(String(100), index=True) - source_pack_id: Mapped[str] = mapped_column(String(100), index=True) - target_pack_id: Mapped[str] = mapped_column(String(100), index=True) - synthesis_kind: Mapped[str] = mapped_column(String(100), default="cross_pack_similarity") - score_total: Mapped[float] = mapped_column(Float, default=0.0) - score_semantic: Mapped[float] = mapped_column(Float, default=0.0) - score_structural: Mapped[float] = mapped_column(Float, default=0.0) - score_trajectory: Mapped[float] = mapped_column(Float, default=0.0) - score_review_history: Mapped[float] = mapped_column(Float, default=0.0) - explanation: Mapped[str] = mapped_column(Text, default="") - evidence_json: Mapped[str] = mapped_column(Text, default="{}") - current_status: Mapped[str] = mapped_column(String(50), default="proposed") - created_at: Mapped[str] = mapped_column(String(100), default="") + 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 c1f454b..0c7ce34 100644 --- a/src/didactopus/repository.py +++ b/src/didactopus/repository.py @@ -1,17 +1,11 @@ from __future__ import annotations import json -from datetime import datetime, timezone from sqlalchemy import select from .db import SessionLocal -from .orm import ( - UserORM, PackORM, LearnerORM, MasteryRecordORM, - KnowledgeCandidateORM, ReviewRecordORM, PromotionRecordORM, SynthesisCandidateORM -) +from .orm import UserORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM +from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent from .auth import verify_password -def now_iso() -> str: - return datetime.now(timezone.utc).isoformat() - def get_user_by_username(username: str): with SessionLocal() as db: return db.execute(select(UserORM).where(UserORM.username == username)).scalar_one_or_none() @@ -26,13 +20,89 @@ def authenticate_user(username: str, password: str): return None return user -def list_packs(): +def store_refresh_token(user_id: int, token_id: str): with SessionLocal() as db: - return db.execute(select(PackORM).order_by(PackORM.id)).scalars().all() + db.add(RefreshTokenORM(user_id=user_id, token_id=token_id, is_revoked=False)) + db.commit() + +def refresh_token_active(token_id: str) -> bool: + with SessionLocal() as db: + row = db.execute(select(RefreshTokenORM).where(RefreshTokenORM.token_id == token_id)).scalar_one_or_none() + return row is not None and not row.is_revoked + +def revoke_refresh_token(token_id: str): + with SessionLocal() as db: + row = db.execute(select(RefreshTokenORM).where(RefreshTokenORM.token_id == token_id)).scalar_one_or_none() + if row: + row.is_revoked = True + db.commit() + +def list_packs(include_unpublished: bool = False): + with SessionLocal() as db: + stmt = select(PackORM) + if not include_unpublished: + stmt = stmt.where(PackORM.is_published == True) + rows = db.execute(stmt).scalars().all() + return [PackData.model_validate(json.loads(r.data_json)) for r in rows] + +def list_pack_admin_rows(): + with SessionLocal() as db: + rows = db.execute(select(PackORM).order_by(PackORM.id)).scalars().all() + return [{"id": r.id, "title": r.title, "is_published": r.is_published, "subtitle": r.subtitle} for r in rows] def get_pack(pack_id: str): with SessionLocal() as db: - return db.get(PackORM, pack_id) + row = db.get(PackORM, pack_id) + return None if row is None else PackData.model_validate(json.loads(row.data_json)) + +def get_pack_validation(pack_id: str): + with SessionLocal() as db: + row = db.get(PackORM, pack_id) + return {} if row is None else json.loads(row.validation_json or "{}") + +def get_pack_provenance(pack_id: str): + with SessionLocal() as db: + row = db.get(PackORM, pack_id) + return {} if row is None else json.loads(row.provenance_json or "{}") + +def upsert_pack(pack: PackData, is_published: bool = True): + validation = { + "ok": len(pack.concepts) > 0, + "warnings": [] if len(pack.concepts) > 0 else ["Pack has no concepts."], + "errors": [], + "summary": {"concept_count": len(pack.concepts), "has_onboarding": bool(pack.onboarding)} + } + provenance = { + "source_count": pack.compliance.sources, + "licenses_present": ["CC BY-NC-SA 4.0"] if pack.compliance.shareAlikeRequired or pack.compliance.noncommercialOnly else [], + "restrictive_flags": list(pack.compliance.flags), + "sources": [ + {"source_id": "sample-source-1", "title": f"Provenance placeholder for {pack.title}", "license_id": "CC BY-NC-SA 4.0" if pack.compliance.shareAlikeRequired or pack.compliance.noncommercialOnly else "unspecified", "attribution_text": "Sample attribution text placeholder"} + ] if pack.compliance.sources else [] + } + with SessionLocal() as db: + row = db.get(PackORM, pack.id) + payload = json.dumps(pack.model_dump()) + if row is None: + db.add(PackORM(id=pack.id, title=pack.title, subtitle=pack.subtitle, level=pack.level, data_json=payload, validation_json=json.dumps(validation), provenance_json=json.dumps(provenance), is_published=is_published)) + else: + row.title = pack.title + 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.is_published = is_published + db.commit() + +def set_pack_publication(pack_id: str, is_published: bool): + with SessionLocal() as db: + row = db.get(PackORM, pack_id) + if row is None: + return False + row.is_published = is_published + db.commit() + return True def create_learner(owner_user_id: int, learner_id: str, display_name: str = ""): with SessionLocal() as db: @@ -40,244 +110,66 @@ 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) return learner is not None and learner.owner_user_id == user_id -def list_mastery_records(learner_id: str): +def load_learner_state(learner_id: str): with SessionLocal() as db: - rows = db.execute(select(MasteryRecordORM).where(MasteryRecordORM.learner_id == learner_id)).scalars().all() - return [{ - "concept_id": r.concept_id, - "dimension": r.dimension, - "score": r.score, - "confidence": r.confidence, - "evidence_count": r.evidence_count, - "last_updated": r.last_updated, - } for r in rows] - -def create_candidate(payload): - with SessionLocal() as db: - row = KnowledgeCandidateORM( - source_type=payload.source_type, - source_artifact_id=payload.source_artifact_id, - learner_id=payload.learner_id, - pack_id=payload.pack_id, - candidate_kind=payload.candidate_kind, - title=payload.title, - summary=payload.summary, - structured_payload_json=json.dumps(payload.structured_payload), - evidence_summary=payload.evidence_summary, - confidence_hint=payload.confidence_hint, - novelty_score=payload.novelty_score, - synthesis_score=payload.synthesis_score, - triage_lane=payload.triage_lane, - current_status="triaged", - created_at=now_iso(), + records = db.execute(select(MasteryRecordORM).where(MasteryRecordORM.learner_id == learner_id)).scalars().all() + history = db.execute(select(EvidenceEventORM).where(EvidenceEventORM.learner_id == learner_id)).scalars().all() + return LearnerState( + learner_id=learner_id, + records=[MasteryRecord(concept_id=r.concept_id, dimension=r.dimension, score=r.score, confidence=r.confidence, evidence_count=r.evidence_count, last_updated=r.last_updated) for r in records], + history=[EvidenceEvent(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) for h in history], ) - db.add(row) + +def save_learner_state(state: LearnerState): + with SessionLocal() as db: + db.query(MasteryRecordORM).filter(MasteryRecordORM.learner_id == state.learner_id).delete() + db.query(EvidenceEventORM).filter(EvidenceEventORM.learner_id == state.learner_id).delete() + for r in state.records: + db.add(MasteryRecordORM(learner_id=state.learner_id, concept_id=r.concept_id, dimension=r.dimension, score=r.score, confidence=r.confidence, evidence_count=r.evidence_count, last_updated=r.last_updated)) + for h in state.history: + db.add(EvidenceEventORM(learner_id=state.learner_id, concept_id=h.concept_id, dimension=h.dimension, score=h.score, confidence_hint=h.confidence_hint, timestamp=h.timestamp, kind=h.kind, source_id=h.source_id)) db.commit() - db.refresh(row) - return row.id + return state -def list_candidates(): +def create_evaluator_job(learner_id: str, pack_id: str, concept_id: str, submitted_text: str) -> int: with SessionLocal() as db: - rows = db.execute(select(KnowledgeCandidateORM).order_by(KnowledgeCandidateORM.id.desc())).scalars().all() - return [{ - "candidate_id": r.id, - "source_type": r.source_type, - "source_artifact_id": r.source_artifact_id, - "learner_id": r.learner_id, - "pack_id": r.pack_id, - "candidate_kind": r.candidate_kind, - "title": r.title, - "summary": r.summary, - "structured_payload": json.loads(r.structured_payload_json or "{}"), - "evidence_summary": r.evidence_summary, - "confidence_hint": r.confidence_hint, - "novelty_score": r.novelty_score, - "synthesis_score": r.synthesis_score, - "triage_lane": r.triage_lane, - "current_status": r.current_status, - "created_at": r.created_at, - } for r in rows] - -def get_candidate(candidate_id: int): - with SessionLocal() as db: - r = db.get(KnowledgeCandidateORM, candidate_id) - if r is None: - return None - return { - "candidate_id": r.id, - "source_type": r.source_type, - "source_artifact_id": r.source_artifact_id, - "learner_id": r.learner_id, - "pack_id": r.pack_id, - "candidate_kind": r.candidate_kind, - "title": r.title, - "summary": r.summary, - "structured_payload": json.loads(r.structured_payload_json or "{}"), - "evidence_summary": r.evidence_summary, - "confidence_hint": r.confidence_hint, - "novelty_score": r.novelty_score, - "synthesis_score": r.synthesis_score, - "triage_lane": r.triage_lane, - "current_status": r.current_status, - "created_at": r.created_at, - } - -def update_candidate(candidate_id: int, triage_lane=None, current_status=None): - with SessionLocal() as db: - row = db.get(KnowledgeCandidateORM, candidate_id) - if row is None: - return None - if triage_lane is not None: - row.triage_lane = triage_lane - if current_status is not None: - row.current_status = current_status + trace = {"rubric_dimension_scores": [{"dimension": "mastery", "score": 0.0}], "notes": ["Job queued", "Awaiting evaluator"], "token_count_estimate": len(submitted_text.split())} + job = EvaluatorJobORM(learner_id=learner_id, pack_id=pack_id, concept_id=concept_id, submitted_text=submitted_text, status="queued", trace_json=json.dumps(trace)) + db.add(job) db.commit() - db.refresh(row) - return row + db.refresh(job) + return job.id -def create_review(candidate_id: int, reviewer_id: int, payload): +def list_evaluator_jobs_for_learner(learner_id: str): with SessionLocal() as db: - row = ReviewRecordORM( - candidate_id=candidate_id, - reviewer_id=reviewer_id, - review_kind=payload.review_kind, - verdict=payload.verdict, - rationale=payload.rationale, - requested_changes=payload.requested_changes, - created_at=now_iso(), - ) - db.add(row) + 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() - db.refresh(row) - return row.id - -def list_reviews(candidate_id: int): - with SessionLocal() as db: - rows = db.execute(select(ReviewRecordORM).where(ReviewRecordORM.candidate_id == candidate_id).order_by(ReviewRecordORM.id.desc())).scalars().all() - return [{ - "review_id": r.id, - "candidate_id": r.candidate_id, - "reviewer_id": r.reviewer_id, - "review_kind": r.review_kind, - "verdict": r.verdict, - "rationale": r.rationale, - "requested_changes": r.requested_changes, - "created_at": r.created_at, - } for r in rows] - -def create_promotion(candidate_id: int, promoted_by: int, payload): - with SessionLocal() as db: - row = PromotionRecordORM( - candidate_id=candidate_id, - promotion_target=payload.promotion_target, - target_object_id=payload.target_object_id, - promotion_status=payload.promotion_status, - promoted_by=promoted_by, - created_at=now_iso(), - ) - db.add(row) - candidate = db.get(KnowledgeCandidateORM, candidate_id) - if candidate: - candidate.current_status = "promoted" - candidate.triage_lane = payload.promotion_target - db.commit() - db.refresh(row) - return row.id - -def list_promotions(): - with SessionLocal() as db: - rows = db.execute(select(PromotionRecordORM).order_by(PromotionRecordORM.id.desc())).scalars().all() - return [{ - "promotion_id": r.id, - "candidate_id": r.candidate_id, - "promotion_target": r.promotion_target, - "target_object_id": r.target_object_id, - "promotion_status": r.promotion_status, - "promoted_by": r.promoted_by, - "created_at": r.created_at, - } for r in rows] - -def create_synthesis_candidate( - source_concept_id: str, - target_concept_id: str, - source_pack_id: str, - target_pack_id: str, - synthesis_kind: str, - score_semantic: float, - score_structural: float, - score_trajectory: float, - score_review_history: float, - explanation: str, - evidence: dict -): - score_total = 0.35 * score_semantic + 0.25 * score_structural + 0.20 * score_trajectory + 0.10 * score_review_history + 0.10 * evidence.get("novelty", 0.0) - with SessionLocal() as db: - row = SynthesisCandidateORM( - source_concept_id=source_concept_id, - target_concept_id=target_concept_id, - source_pack_id=source_pack_id, - target_pack_id=target_pack_id, - synthesis_kind=synthesis_kind, - score_total=score_total, - score_semantic=score_semantic, - score_structural=score_structural, - score_trajectory=score_trajectory, - score_review_history=score_review_history, - explanation=explanation, - evidence_json=json.dumps(evidence), - current_status="proposed", - created_at=now_iso(), - ) - db.add(row) - db.commit() - db.refresh(row) - return row.id - -def list_synthesis_candidates(): - with SessionLocal() as db: - rows = db.execute(select(SynthesisCandidateORM).order_by(SynthesisCandidateORM.score_total.desc(), SynthesisCandidateORM.id.desc())).scalars().all() - return [{ - "synthesis_id": r.id, - "source_concept_id": r.source_concept_id, - "target_concept_id": r.target_concept_id, - "source_pack_id": r.source_pack_id, - "target_pack_id": r.target_pack_id, - "synthesis_kind": r.synthesis_kind, - "score_total": r.score_total, - "score_semantic": r.score_semantic, - "score_structural": r.score_structural, - "score_trajectory": r.score_trajectory, - "score_review_history": r.score_review_history, - "explanation": r.explanation, - "evidence": json.loads(r.evidence_json or "{}"), - "current_status": r.current_status, - "created_at": r.created_at, - } for r in rows] - -def get_synthesis_candidate(synthesis_id: int): - with SessionLocal() as db: - r = db.get(SynthesisCandidateORM, synthesis_id) - if r is None: - return None - return { - "synthesis_id": r.id, - "source_concept_id": r.source_concept_id, - "target_concept_id": r.target_concept_id, - "source_pack_id": r.source_pack_id, - "target_pack_id": r.target_pack_id, - "synthesis_kind": r.synthesis_kind, - "score_total": r.score_total, - "score_semantic": r.score_semantic, - "score_structural": r.score_structural, - "score_trajectory": r.score_trajectory, - "score_review_history": r.score_review_history, - "explanation": r.explanation, - "evidence": json.loads(r.evidence_json or "{}"), - "current_status": r.current_status, - "created_at": r.created_at, - } diff --git a/src/didactopus/seed.py b/src/didactopus/seed.py index 7541e63..abffe81 100644 --- a/src/didactopus/seed.py +++ b/src/didactopus/seed.py @@ -1,53 +1,27 @@ from __future__ import annotations -import json from sqlalchemy import select from .db import Base, engine, SessionLocal -from .orm import UserORM, PackORM +from .orm import UserORM from .auth import hash_password +from .repository import upsert_pack +from .models import PackData, PackConcept, PackCompliance def main(): Base.metadata.create_all(bind=engine) with SessionLocal() as db: 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)) - if db.execute(select(UserORM).where(UserORM.username == "reviewer")).scalar_one_or_none() is None: - db.add(UserORM(username="reviewer", password_hash=hash_password("demo-pass"), role="reviewer", is_active=True)) - if db.get(PackORM, "biology-pack") is None: - db.add(PackORM( - id="biology-pack", - owner_user_id=1, - policy_lane="personal", - title="Biology Pack", - subtitle="Core biology concepts", - level="novice-friendly", - is_published=True, - data_json=json.dumps({ - "id": "biology-pack", - "title": "Biology Pack", - "concepts": [ - {"id": "selection", "title": "Natural Selection", "prerequisites": ["variation"]}, - {"id": "variation", "title": "Variation", "prerequisites": []}, - {"id": "drift", "title": "Genetic Drift", "prerequisites": ["variation"]} - ] - }) - )) - if db.get(PackORM, "math-pack") is None: - db.add(PackORM( - id="math-pack", - owner_user_id=1, - policy_lane="personal", - title="Math Pack", - subtitle="Core math concepts", - level="novice-friendly", - is_published=True, - data_json=json.dumps({ - "id": "math-pack", - "title": "Math Pack", - "concepts": [ - {"id": "random_walk", "title": "Random Walk", "prerequisites": ["variation"]}, - {"id": "variation", "title": "Variation in Models", "prerequisites": []}, - {"id": "optimization", "title": "Optimization", "prerequisites": []} - ] - }) - )) db.commit() + upsert_pack(PackData( + id="bayes-pack", + title="Bayesian Reasoning", + subtitle="Probability, evidence, updating, and model criticism.", + level="novice-friendly", + concepts=[ + PackConcept(id="prior", title="Prior", prerequisites=[], masteryDimension="mastery", exerciseReward="Prior badge earned"), + PackConcept(id="posterior", title="Posterior", prerequisites=["prior"], masteryDimension="mastery", exerciseReward="Posterior path opened"), + ], + onboarding={"headline":"Start with a fast visible win","body":"Read one short orientation, answer one guided question, and leave with your first mastery marker.","checklist":["Read the one-screen topic orientation","Answer one guided exercise","Write one explanation in your own words"]}, + compliance=PackCompliance(sources=2, attributionRequired=True, shareAlikeRequired=True, noncommercialOnly=True, flags=["share-alike","noncommercial","excluded-third-party-content"]) + ), is_published=True) + print("Seeded database. Demo user: wesley / demo-pass") diff --git a/src/didactopus/worker.py b/src/didactopus/worker.py index 6202bb3..6d28da5 100644 --- a/src/didactopus/worker.py +++ b/src/didactopus/worker.py @@ -1,43 +1,24 @@ from __future__ import annotations -import json, tempfile -from pathlib import Path -from datetime import datetime, timedelta, timezone -from .repository import update_render_job, register_artifact -from .render_bundle import make_render_bundle +from .repository import get_evaluator_job, update_evaluator_job, load_learner_state, save_learner_state +from .engine import apply_evidence +from .models import EvidenceEvent +import time -def future_iso(days: int) -> str: - return (datetime.now(timezone.utc) + timedelta(days=days)).isoformat() +def process_job(job_id: int): + job = get_evaluator_job(job_id) + if job is None: + return + update_evaluator_job(job_id, "running", trace={"rubric_dimension_scores": [{"dimension": "mastery", "score": 0.35}], "notes": ["Job running", "Prototype trace generated"], "token_count_estimate": len(job.submitted_text.split())}) + score = 0.78 if len(job.submitted_text.strip()) > 20 else 0.62 + confidence_hint = 0.72 if len(job.submitted_text.strip()) > 20 else 0.45 + notes = "Prototype evaluator: longer responses scored somewhat higher." + trace = {"rubric_dimension_scores": [{"dimension": "mastery", "score": score}], "notes": ["Prototype evaluator completed", notes], "token_count_estimate": len(job.submitted_text.split()), "decision_basis": ["response length heuristic", "single-dimension mastery proxy"]} + update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes, trace=trace) + state = load_learner_state(job.learner_id) + state = apply_evidence(state, EvidenceEvent(concept_id=job.concept_id, dimension="mastery", score=score, confidence_hint=confidence_hint, timestamp="2026-03-13T12:00:00+00:00", kind="review", source_id=f"evaluator-job-{job_id}")) + save_learner_state(state) -def process_render_job(job_id: int, learner_id: str, pack_id: str, fmt: str, fps: int, theme: str, retention_class: str, retention_days: int, animation_payload: dict): - update_render_job(job_id, status="running") - try: - base = Path(tempfile.mkdtemp(prefix=f"didactopus_job_{job_id}_")) - payload_json = base / "animation_payload.json" - payload_json.write_text(json.dumps(animation_payload, indent=2), encoding="utf-8") - out_dir = base / "bundle" - make_render_bundle(str(payload_json), str(out_dir), fps=fps, fmt=fmt) - manifest_path = out_dir / "render_manifest.json" - script_path = out_dir / "render.sh" - update_render_job( - job_id, - status="completed", - bundle_dir=str(out_dir), - payload_json=str(payload_json), - manifest_path=str(manifest_path), - script_path=str(script_path), - error_text="", - ) - register_artifact( - render_job_id=job_id, - learner_id=learner_id, - pack_id=pack_id, - artifact_type="render_bundle", - fmt=fmt, - title=f"{pack_id} animation bundle", - path=str(out_dir), - metadata={"fps": fps, "theme": theme, "manifest_path": str(manifest_path), "script_path": str(script_path)}, - retention_class=retention_class, - expires_at=future_iso(retention_days), - ) - except Exception as e: - update_render_job(job_id, status="failed", error_text=str(e)) +def main(): + print("Didactopus worker scaffold running. Replace this with a real queue worker.") + while True: + time.sleep(60) diff --git a/tests/test_scaffold_files.py b/tests/test_scaffold_files.py index 9b11e28..2e9b522 100644 --- a/tests/test_scaffold_files.py +++ b/tests/test_scaffold_files.py @@ -3,5 +3,5 @@ from pathlib import Path def test_scaffold_files_exist(): assert Path("src/didactopus/api.py").exists() assert Path("src/didactopus/repository.py").exists() - assert Path("src/didactopus/synthesis.py").exists() assert Path("webui/src/App.jsx").exists() + assert Path("webui/src/api.js").exists() diff --git a/webui/index.html b/webui/index.html index 4ecb1aa..5a90d2b 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3,10 +3,8 @@ - Didactopus Review UI + Didactopus Admin Curation Layer - -
- +
diff --git a/webui/package.json b/webui/package.json index 3da8e81..fc49c73 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,17 +1,9 @@ { - "name": "didactopus-review-ui", + "name": "didactopus-admin-curation-ui", "private": true, "version": "0.1.0", "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build" - }, - "dependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1" - }, - "devDependencies": { - "vite": "^5.4.0" - } + "scripts": { "dev": "vite", "build": "vite build" }, + "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" }, + "devDependencies": { "vite": "^5.4.0" } } diff --git a/webui/src/App.jsx b/webui/src/App.jsx index 9909b6a..93a331a 100644 --- a/webui/src/App.jsx +++ b/webui/src/App.jsx @@ -1,269 +1,317 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useState } from "react"; +import { login, refresh, fetchPacks, fetchAdminPacks, fetchPackValidation, fetchPackProvenance, upsertPack, publishPack, listLearners, createLearner, fetchLearnerState, fetchRecommendations, postEvidence, submitEvaluatorJob, fetchEvaluatorHistory, fetchEvaluatorTrace } from "./api"; +import { loadAuth, saveAuth, clearAuth } from "./authStore"; -const API = "http://127.0.0.1:8765"; -const statuses = ["needs_review", "trusted", "provisional", "rejected"]; +function LoginView({ onAuth }) { + const [username, setUsername] = useState("wesley"); + const [password, setPassword] = useState("demo-pass"); + const [error, setError] = useState(""); + async function doLogin() { + try { + const result = await login(username, password); + saveAuth(result); + onAuth(result); + } catch { setError("Login failed"); } + } + return ( +
+
+

Didactopus login

+ + + + {error ?
{error}
: null} +
+
+ ); +} + +function NavTabs({ tab, setTab, role }) { + return ( +
+ + + + {role === "admin" ? <> + + + : null} +
+ ); +} + +function PackAuthorForm({ value, onChange, onSave }) { + function setField(field, val) { onChange({ ...value, [field]: val }); } + function setCompliance(field, val) { onChange({ ...value, compliance: { ...value.compliance, [field]: val } }); } + return ( +
+ + + + + + +