Apply ZIP update: 135-didactopus-admin-curation-layer.zip [2026-03-14T13:19:17]

This commit is contained in:
welsberr 2026-03-14 13:29:55 -04:00
parent c049fc4d86
commit 5b64420315
17 changed files with 776 additions and 974 deletions

View File

@ -5,18 +5,20 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "didactopus" name = "didactopus"
version = "0.1.0" version = "0.1.0"
description = "Didactopus: semantic QA layer for domain packs"
readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
license = {text = "MIT"} dependencies = [
authors = [{name = "Wesley R. Elsberry"}] "pydantic>=2.7",
dependencies = ["pydantic>=2.7", "pyyaml>=6.0"] "pyyaml>=6.0",
"fastapi>=0.115",
[project.optional-dependencies] "uvicorn>=0.30",
dev = ["pytest>=8.0", "ruff>=0.6"] "sqlalchemy>=2.0",
"psycopg[binary]>=3.1",
"passlib[bcrypt]>=1.7",
"python-jose[cryptography]>=3.3"
]
[project.scripts] [project.scripts]
didactopus-review-bridge = "didactopus.review_bridge_server:main" didactopus-api = "didactopus.api:main"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]

View File

@ -1 +1 @@
__version__ = "0.1.0" __version__ = '0.1.0'

View File

@ -1,29 +1,22 @@
from __future__ import annotations from __future__ import annotations
from fastapi import FastAPI, HTTPException, Header, Depends import json
from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
import uvicorn import uvicorn
from .config import load_settings
from .db import Base, engine from .db import Base, engine
from .models import ( from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest
LoginRequest, TokenPair, KnowledgeCandidateCreate, KnowledgeCandidateUpdate, 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
ReviewCreate, PromoteRequest, SynthesisRunRequest, SynthesisPromoteRequest, from .engine import apply_evidence, recommend_next
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 .auth import issue_access_token, issue_refresh_token, decode_token, new_token_id 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) 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=["*"]) app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
_refresh_tokens = {}
def current_user(authorization: str = Header(default="")): def current_user(authorization: str = Header(default="")):
token = authorization.removeprefix("Bearer ").strip() token = authorization.removeprefix("Bearer ").strip()
payload = decode_token(token) if token else None payload = decode_token(token) if token else None
@ -34,117 +27,134 @@ def current_user(authorization: str = Header(default="")):
raise HTTPException(status_code=401, detail="Unauthorized") raise HTTPException(status_code=401, detail="Unauthorized")
return user return user
def require_reviewer(user = Depends(current_user)): def require_admin(user = Depends(current_user)):
if user.role not in {"admin", "reviewer"}: if user.role != "admin":
raise HTTPException(status_code=403, detail="Reviewer role required") raise HTTPException(status_code=403, detail="Admin role required")
return user 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) @app.post("/api/login", response_model=TokenPair)
def login(payload: LoginRequest): def login(payload: LoginRequest):
user = authenticate_user(payload.username, payload.password) user = authenticate_user(payload.username, payload.password)
if user is None: if user is None:
raise HTTPException(status_code=401, detail="Invalid credentials") raise HTTPException(status_code=401, detail="Invalid credentials")
token_id = new_token_id() token_id = new_token_id()
_refresh_tokens[token_id] = user.id store_refresh_token(user.id, token_id)
return TokenPair( 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)
access_token=issue_access_token(user.id, user.username, user.role),
refresh_token=issue_refresh_token(user.id, user.username, user.role, token_id), @app.post("/api/refresh", response_model=TokenPair)
username=user.username, def refresh(payload: RefreshRequest):
role=user.role 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") @app.post("/api/learners")
def api_create_learner(payload: CreateLearnerRequest, user = Depends(current_user)): def api_create_learner(payload: CreateLearnerRequest, user = Depends(current_user)):
create_learner(user.id, payload.learner_id, payload.display_name) create_learner(user.id, payload.learner_id, payload.display_name)
return {"ok": True, "learner_id": payload.learner_id} return {"ok": True, "learner_id": payload.learner_id}
@app.post("/api/knowledge-candidates") @app.get("/api/learners")
def api_create_candidate(payload: KnowledgeCandidateCreate, reviewer = Depends(require_reviewer)): def api_list_learners(user = Depends(current_user)):
candidate_id = create_candidate(payload) return list_learners_for_user(user.id, is_admin=(user.role == "admin"))
return {"candidate_id": candidate_id}
@app.get("/api/knowledge-candidates") @app.get("/api/learners/{learner_id}/state")
def api_list_candidates(reviewer = Depends(require_reviewer)): def api_get_learner_state(learner_id: str, user = Depends(current_user)):
return list_candidates() ensure_learner_access(user, learner_id)
return load_learner_state(learner_id).model_dump()
@app.get("/api/knowledge-candidates/{candidate_id}") @app.put("/api/learners/{learner_id}/state")
def api_get_candidate(candidate_id: int, reviewer = Depends(require_reviewer)): def api_put_learner_state(learner_id: str, state: LearnerState, user = Depends(current_user)):
row = get_candidate(candidate_id) ensure_learner_access(user, learner_id)
if row is None: if learner_id != state.learner_id:
raise HTTPException(status_code=404, detail="Candidate not found") raise HTTPException(status_code=400, detail="Learner ID mismatch")
return row return save_learner_state(state).model_dump()
@app.post("/api/knowledge-candidates/{candidate_id}/update") @app.post("/api/learners/{learner_id}/evidence")
def api_update_candidate(candidate_id: int, payload: KnowledgeCandidateUpdate, reviewer = Depends(require_reviewer)): def api_post_evidence(learner_id: str, event: EvidenceEvent, user = Depends(current_user)):
row = update_candidate(candidate_id, triage_lane=payload.triage_lane, current_status=payload.current_status) ensure_learner_access(user, learner_id)
if row is None: state = load_learner_state(learner_id)
raise HTTPException(status_code=404, detail="Candidate not found") state = apply_evidence(state, event)
return {"candidate_id": row.id, "triage_lane": row.triage_lane, "current_status": row.current_status} save_learner_state(state)
return state.model_dump()
@app.post("/api/knowledge-candidates/{candidate_id}/reviews") @app.get("/api/learners/{learner_id}/recommendations/{pack_id}")
def api_create_review(candidate_id: int, payload: ReviewCreate, reviewer = Depends(require_reviewer)): def api_get_recommendations(learner_id: str, pack_id: str, user = Depends(current_user)):
if get_candidate(candidate_id) is None: ensure_learner_access(user, learner_id)
raise HTTPException(status_code=404, detail="Candidate not found") state = load_learner_state(learner_id)
review_id = create_review(candidate_id, reviewer.id, payload) pack = get_pack(pack_id)
return {"review_id": review_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") @app.post("/api/learners/{learner_id}/evaluator-jobs", response_model=EvaluatorJobStatus)
def api_list_reviews(candidate_id: int, reviewer = Depends(require_reviewer)): def api_submit_evaluator_job(learner_id: str, payload: EvaluatorSubmission, background_tasks: BackgroundTasks, user = Depends(current_user)):
return list_reviews(candidate_id) 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") @app.get("/api/evaluator-jobs/{job_id}", response_model=EvaluatorJobStatus)
def api_promote_candidate(candidate_id: int, payload: PromoteRequest, reviewer = Depends(require_reviewer)): def api_get_evaluator_job(job_id: int, user = Depends(current_user)):
if get_candidate(candidate_id) is None: job = get_evaluator_job(job_id)
raise HTTPException(status_code=404, detail="Candidate not found") if job is None:
promotion_id = create_promotion(candidate_id, reviewer.id, payload) raise HTTPException(status_code=404, detail="Job not found")
return {"promotion_id": promotion_id} 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") @app.get("/api/evaluator-jobs/{job_id}/trace")
def api_list_promotions(reviewer = Depends(require_reviewer)): def api_get_evaluator_job_trace(job_id: int, user = Depends(current_user)):
return list_promotions() 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") @app.get("/api/learners/{learner_id}/evaluator-history")
def api_run_synthesis(payload: SynthesisRunRequest, reviewer = Depends(require_reviewer)): def api_get_evaluator_history(learner_id: str, user = Depends(current_user)):
created = generate_synthesis_candidates(payload.source_pack_id, payload.target_pack_id, payload.limit) ensure_learner_access(user, learner_id)
return {"created_count": len(created), "synthesis_ids": created} 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]
@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}
def main(): def main():
uvicorn.run(app, host="127.0.0.1", port=8011) uvicorn.run(app, host=settings.host, port=settings.port)

View File

@ -1,22 +1,13 @@
from pathlib import Path from __future__ import annotations
from pydantic import BaseModel, Field import os
import yaml from pydantic import BaseModel
class ReviewConfig(BaseModel): class Settings(BaseModel):
default_reviewer: str = "Unknown Reviewer" database_url: str = os.getenv("DIDACTOPUS_DATABASE_URL", "postgresql+psycopg://didactopus:didactopus-dev-password@localhost:5432/didactopus")
write_promoted_pack: bool = True 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): def load_settings() -> Settings:
host: str = "127.0.0.1" return Settings()
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)

View File

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

View File

@ -1,9 +1,8 @@
from __future__ import annotations from __future__ import annotations
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Literal
class LoginRequest(BaseModel): EvidenceKind = Literal["checkpoint", "project", "exercise", "review"]
username: str
password: str
class TokenPair(BaseModel): class TokenPair(BaseModel):
access_token: str access_token: str
@ -12,43 +11,39 @@ class TokenPair(BaseModel):
username: str username: str
role: str role: str
class KnowledgeCandidateCreate(BaseModel): class LoginRequest(BaseModel):
source_type: str = "learner_export" username: str
source_artifact_id: int | None = None password: str
learner_id: str
pack_id: str class RefreshRequest(BaseModel):
candidate_kind: str refresh_token: str
class PackConcept(BaseModel):
id: str
title: str title: str
summary: str = "" prerequisites: list[str] = Field(default_factory=list)
structured_payload: dict = Field(default_factory=dict) masteryDimension: str = "mastery"
evidence_summary: str = "" exerciseReward: str = ""
confidence_hint: float = 0.0
novelty_score: float = 0.0
synthesis_score: float = 0.0
triage_lane: str = "archive"
class KnowledgeCandidateUpdate(BaseModel): class PackCompliance(BaseModel):
triage_lane: str | None = None sources: int = 0
current_status: str | None = None attributionRequired: bool = False
shareAlikeRequired: bool = False
noncommercialOnly: bool = False
flags: list[str] = Field(default_factory=list)
class ReviewCreate(BaseModel): class PackData(BaseModel):
review_kind: str = "human_review" id: str
verdict: str title: str
rationale: str = "" subtitle: str = ""
requested_changes: 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): class CreatePackRequest(BaseModel):
promotion_target: str pack: PackData
target_object_id: str = "" is_published: bool = True
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 CreateLearnerRequest(BaseModel): class CreateLearnerRequest(BaseModel):
learner_id: str learner_id: str
@ -62,6 +57,29 @@ class MasteryRecord(BaseModel):
evidence_count: int = 0 evidence_count: int = 0
last_updated: str = "" 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): class LearnerState(BaseModel):
learner_id: str learner_id: str
records: list[MasteryRecord] = Field(default_factory=list) records: list[MasteryRecord] = Field(default_factory=list)
history: list[EvidenceEvent] = Field(default_factory=list)
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 = ""

View File

@ -10,16 +10,23 @@ class UserORM(Base):
role: Mapped[str] = mapped_column(String(50), default="learner") role: Mapped[str] = mapped_column(String(50), default="learner")
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
class 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): class PackORM(Base):
__tablename__ = "packs" __tablename__ = "packs"
id: Mapped[str] = mapped_column(String(100), primary_key=True) 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)) title: Mapped[str] = mapped_column(String(255))
subtitle: Mapped[str] = mapped_column(Text, default="") subtitle: Mapped[str] = mapped_column(Text, default="")
level: Mapped[str] = mapped_column(String(100), default="novice-friendly") level: Mapped[str] = mapped_column(String(100), default="novice-friendly")
data_json: Mapped[str] = mapped_column(Text) data_json: Mapped[str] = mapped_column(Text)
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): class LearnerORM(Base):
__tablename__ = "learners" __tablename__ = "learners"
@ -38,60 +45,27 @@ class MasteryRecordORM(Base):
evidence_count: Mapped[int] = mapped_column(Integer, default=0) evidence_count: Mapped[int] = mapped_column(Integer, default=0)
last_updated: Mapped[str] = mapped_column(String(100), default="") last_updated: Mapped[str] = mapped_column(String(100), default="")
class KnowledgeCandidateORM(Base): class EvidenceEventORM(Base):
__tablename__ = "knowledge_candidates" __tablename__ = "evidence_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
source_type: Mapped[str] = mapped_column(String(50), default="learner_export") learner_id: Mapped[str] = mapped_column(ForeignKey("learners.id"), index=True)
source_artifact_id: Mapped[int | None] = mapped_column(Integer, nullable=True) concept_id: Mapped[str] = mapped_column(String(100), index=True)
learner_id: Mapped[str] = mapped_column(String(100), index=True) dimension: Mapped[str] = mapped_column(String(100), default="mastery")
pack_id: Mapped[str] = mapped_column(String(100), index=True) score: Mapped[float] = mapped_column(Float, default=0.0)
candidate_kind: Mapped[str] = mapped_column(String(100), index=True) confidence_hint: Mapped[float] = mapped_column(Float, default=0.5)
title: Mapped[str] = mapped_column(String(255)) timestamp: Mapped[str] = mapped_column(String(100), default="")
summary: Mapped[str] = mapped_column(Text, default="") kind: Mapped[str] = mapped_column(String(50), default="exercise")
structured_payload_json: Mapped[str] = mapped_column(Text, default="{}") source_id: Mapped[str] = mapped_column(String(255), 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="")
class ReviewRecordORM(Base): class EvaluatorJobORM(Base):
__tablename__ = "review_records" __tablename__ = "evaluator_jobs"
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
candidate_id: Mapped[int] = mapped_column(ForeignKey("knowledge_candidates.id"), index=True) learner_id: Mapped[str] = mapped_column(ForeignKey("learners.id"), index=True)
reviewer_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) pack_id: Mapped[str] = mapped_column(ForeignKey("packs.id"), index=True)
review_kind: Mapped[str] = mapped_column(String(50), default="human_review") concept_id: Mapped[str] = mapped_column(String(100), index=True)
verdict: Mapped[str] = mapped_column(String(100), default="") submitted_text: Mapped[str] = mapped_column(Text, default="")
rationale: Mapped[str] = mapped_column(Text, default="") status: Mapped[str] = mapped_column(String(50), default="queued")
requested_changes: Mapped[str] = mapped_column(Text, default="") result_score: Mapped[float | None] = mapped_column(Float, nullable=True)
created_at: Mapped[str] = mapped_column(String(100), default="") result_confidence_hint: Mapped[float | None] = mapped_column(Float, nullable=True)
result_notes: Mapped[str] = mapped_column(Text, default="")
class PromotionRecordORM(Base): trace_json: Mapped[str] = mapped_column(Text, default="{}")
__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="")

View File

@ -1,17 +1,11 @@
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from .db import SessionLocal from .db import SessionLocal
from .orm import ( from .orm import UserORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM
UserORM, PackORM, LearnerORM, MasteryRecordORM, from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent
KnowledgeCandidateORM, ReviewRecordORM, PromotionRecordORM, SynthesisCandidateORM
)
from .auth import verify_password from .auth import verify_password
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def get_user_by_username(username: str): def get_user_by_username(username: str):
with SessionLocal() as db: with SessionLocal() as db:
return db.execute(select(UserORM).where(UserORM.username == username)).scalar_one_or_none() 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 None
return user return user
def list_packs(): def store_refresh_token(user_id: int, token_id: str):
with SessionLocal() as db: 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): def get_pack(pack_id: str):
with SessionLocal() as db: 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 = ""): def create_learner(owner_user_id: int, learner_id: str, display_name: str = ""):
with SessionLocal() as db: 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.add(LearnerORM(id=learner_id, owner_user_id=owner_user_id, display_name=display_name))
db.commit() 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: def learner_owned_by_user(user_id: int, learner_id: str) -> bool:
with SessionLocal() as db: with SessionLocal() as db:
learner = db.get(LearnerORM, learner_id) learner = db.get(LearnerORM, learner_id)
return learner is not None and learner.owner_user_id == user_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: with SessionLocal() as db:
rows = db.execute(select(MasteryRecordORM).where(MasteryRecordORM.learner_id == learner_id)).scalars().all() records = db.execute(select(MasteryRecordORM).where(MasteryRecordORM.learner_id == learner_id)).scalars().all()
return [{ history = db.execute(select(EvidenceEventORM).where(EvidenceEventORM.learner_id == learner_id)).scalars().all()
"concept_id": r.concept_id, return LearnerState(
"dimension": r.dimension, learner_id=learner_id,
"score": r.score, 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],
"confidence": r.confidence, 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],
"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(),
) )
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.commit()
db.refresh(row) return state
return row.id
def list_candidates(): def create_evaluator_job(learner_id: str, pack_id: str, concept_id: str, submitted_text: str) -> int:
with SessionLocal() as db: with SessionLocal() as db:
rows = db.execute(select(KnowledgeCandidateORM).order_by(KnowledgeCandidateORM.id.desc())).scalars().all() trace = {"rubric_dimension_scores": [{"dimension": "mastery", "score": 0.0}], "notes": ["Job queued", "Awaiting evaluator"], "token_count_estimate": len(submitted_text.split())}
return [{ 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))
"candidate_id": r.id, db.add(job)
"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
db.commit() db.commit()
db.refresh(row) db.refresh(job)
return row 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: with SessionLocal() as db:
row = ReviewRecordORM( return db.execute(select(EvaluatorJobORM).where(EvaluatorJobORM.learner_id == learner_id).order_by(EvaluatorJobORM.id.desc())).scalars().all()
candidate_id=candidate_id,
reviewer_id=reviewer_id, def get_evaluator_job(job_id: int):
review_kind=payload.review_kind, with SessionLocal() as db:
verdict=payload.verdict, return db.get(EvaluatorJobORM, job_id)
rationale=payload.rationale,
requested_changes=payload.requested_changes, def update_evaluator_job(job_id: int, status: str, score: float | None = None, confidence_hint: float | None = None, notes: str = "", trace: dict | None = None):
created_at=now_iso(), with SessionLocal() as db:
) job = db.get(EvaluatorJobORM, job_id)
db.add(row) 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.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,
}

View File

@ -1,53 +1,27 @@
from __future__ import annotations from __future__ import annotations
import json
from sqlalchemy import select from sqlalchemy import select
from .db import Base, engine, SessionLocal from .db import Base, engine, SessionLocal
from .orm import UserORM, PackORM from .orm import UserORM
from .auth import hash_password from .auth import hash_password
from .repository import upsert_pack
from .models import PackData, PackConcept, PackCompliance
def main(): def main():
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
with SessionLocal() as db: with SessionLocal() as db:
if db.execute(select(UserORM).where(UserORM.username == "wesley")).scalar_one_or_none() is None: 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.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() 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")

View File

@ -1,43 +1,24 @@
from __future__ import annotations from __future__ import annotations
import json, tempfile from .repository import get_evaluator_job, update_evaluator_job, load_learner_state, save_learner_state
from pathlib import Path from .engine import apply_evidence
from datetime import datetime, timedelta, timezone from .models import EvidenceEvent
from .repository import update_render_job, register_artifact import time
from .render_bundle import make_render_bundle
def future_iso(days: int) -> str: def process_job(job_id: int):
return (datetime.now(timezone.utc) + timedelta(days=days)).isoformat() 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): def main():
update_render_job(job_id, status="running") print("Didactopus worker scaffold running. Replace this with a real queue worker.")
try: while True:
base = Path(tempfile.mkdtemp(prefix=f"didactopus_job_{job_id}_")) time.sleep(60)
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))

View File

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

View File

@ -3,10 +3,8 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Didactopus Review UI</title> <title>Didactopus Admin Curation Layer</title>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</head> </head>
<body> <body><div id="root"></div></body>
<div id="root"></div>
</body>
</html> </html>

View File

@ -1,17 +1,9 @@
{ {
"name": "didactopus-review-ui", "name": "didactopus-admin-curation-ui",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": { "dev": "vite", "build": "vite build" },
"dev": "vite", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1" },
"build": "vite build" "devDependencies": { "vite": "^5.4.0" }
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"vite": "^5.4.0"
}
} }

View File

@ -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"; function LoginView({ onAuth }) {
const statuses = ["needs_review", "trusted", "provisional", "rejected"]; 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 (
<div className="page narrow-page">
<section className="card narrow">
<h1>Didactopus login</h1>
<label>Username<input value={username} onChange={(e) => setUsername(e.target.value)} /></label>
<label>Password<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} /></label>
<button className="primary" onClick={doLogin}>Login</button>
{error ? <div className="error">{error}</div> : null}
</section>
</div>
);
}
function NavTabs({ tab, setTab, role }) {
return (
<div className="tab-row">
<button className={tab==="learner" ? "active-tab" : ""} onClick={() => setTab("learner")}>Learner</button>
<button className={tab==="history" ? "active-tab" : ""} onClick={() => setTab("history")}>Evaluator history</button>
<button className={tab==="manage" ? "active-tab" : ""} onClick={() => setTab("manage")}>Learners</button>
{role === "admin" ? <>
<button className={tab==="admin" ? "active-tab" : ""} onClick={() => setTab("admin")}>Pack admin</button>
<button className={tab==="review" ? "active-tab" : ""} onClick={() => setTab("review")}>Curation review</button>
</> : null}
</div>
);
}
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 (
<div className="form-grid">
<label>Pack ID<input value={value.id} onChange={(e) => setField("id", e.target.value)} /></label>
<label>Title<input value={value.title} onChange={(e) => setField("title", e.target.value)} /></label>
<label className="full">Subtitle<input value={value.subtitle} onChange={(e) => setField("subtitle", e.target.value)} /></label>
<label>Level<input value={value.level} onChange={(e) => setField("level", e.target.value)} /></label>
<label>Source count<input type="number" value={value.compliance.sources} onChange={(e) => setCompliance("sources", Number(e.target.value))} /></label>
<label className="full">Onboarding headline<input value={value.onboarding.headline} onChange={(e) => onChange({ ...value, onboarding: { ...value.onboarding, headline: e.target.value } })} /></label>
<label className="full">Onboarding body<textarea value={value.onboarding.body} onChange={(e) => onChange({ ...value, onboarding: { ...value.onboarding, body: e.target.value } })} /></label>
<div className="checkrow full">
<label><input type="checkbox" checked={value.compliance.attributionRequired} onChange={(e) => setCompliance("attributionRequired", e.target.checked)} /> Attribution required</label>
<label><input type="checkbox" checked={value.compliance.shareAlikeRequired} onChange={(e) => setCompliance("shareAlikeRequired", e.target.checked)} /> Share-alike</label>
<label><input type="checkbox" checked={value.compliance.noncommercialOnly} onChange={(e) => setCompliance("noncommercialOnly", e.target.checked)} /> Noncommercial only</label>
</div>
<div className="full"><button className="primary" onClick={onSave}>Save pack</button></div>
</div>
);
}
export default function App() { export default function App() {
const [registry, setRegistry] = useState({ workspaces: [], recent_workspace_ids: [] }); const [auth, setAuth] = useState(loadAuth());
const [workspaceId, setWorkspaceId] = useState(""); const [tab, setTab] = useState("learner");
const [workspaceTitle, setWorkspaceTitle] = useState(""); const [packs, setPacks] = useState([]);
const [importSource, setImportSource] = useState(""); const [adminPacks, setAdminPacks] = useState([]);
const [importPreview, setImportPreview] = useState(null); const [learners, setLearners] = useState([]);
const [allowOverwrite, setAllowOverwrite] = useState(false); const [selectedLearnerId, setSelectedLearnerId] = useState("wesley-learner");
const [session, setSession] = useState(null); const [selectedPackId, setSelectedPackId] = useState("");
const [selectedId, setSelectedId] = useState(""); const [learnerState, setLearnerState] = useState(null);
const [pendingActions, setPendingActions] = useState([]); const [cards, setCards] = useState([]);
const [message, setMessage] = useState("Connecting to local Didactopus bridge..."); const [history, setHistory] = useState([]);
const [selectedTrace, setSelectedTrace] = useState(null);
const [validation, setValidation] = useState(null);
const [provenance, setProvenance] = useState(null);
const [newLearnerId, setNewLearnerId] = useState("wesley-learner");
const [formPack, setFormPack] = useState({ id: "new-pack", title: "New Pack", subtitle: "Editable admin pack scaffold", level: "novice-friendly", concepts: [], onboarding: { headline: "Start here", body: "Begin", checklist: [] }, compliance: { sources: 0, attributionRequired: false, shareAlikeRequired: false, noncommercialOnly: false, flags: [] } });
const [message, setMessage] = useState("");
async function loadRegistry() { async function refreshAuthToken() {
const res = await fetch(`${API}/api/workspaces`); if (!auth?.refresh_token) return null;
const data = await res.json(); try {
setRegistry(data); const result = await refresh(auth.refresh_token);
if (!session) setMessage("Choose, create, preview, or import a workspace."); saveAuth(result);
setAuth(result);
return result;
} catch {
clearAuth();
setAuth(null);
return null;
}
}
async function guarded(fn) {
try { return await fn(auth.access_token); }
catch {
const next = await refreshAuthToken();
if (!next) throw new Error("auth failed");
return await fn(next.access_token);
}
} }
useEffect(() => { useEffect(() => {
loadRegistry().catch(() => setMessage("Could not connect to local review bridge. Start the Python bridge service first.")); if (!auth) return;
}, []); async function load() {
const p = await guarded((token) => fetchPacks(token));
setPacks(p);
setSelectedPackId((prev) => prev || p[0]?.id || "");
let ls = await guarded((token) => listLearners(token));
if (ls.length === 0) {
await guarded((token) => createLearner(token, selectedLearnerId, selectedLearnerId));
ls = await guarded((token) => listLearners(token));
}
setLearners(ls);
if (auth.role === "admin") {
const ap = await guarded((token) => fetchAdminPacks(token));
setAdminPacks(ap);
}
}
load();
}, [auth]);
async function createWorkspace() { useEffect(() => {
if (!workspaceId || !workspaceTitle) return; if (!auth || !selectedLearnerId || !selectedPackId) return;
await fetch(`${API}/api/workspaces/create`, { async function loadLearnerStuff() {
method: "POST", setLearnerState(await guarded((token) => fetchLearnerState(token, selectedLearnerId)));
headers: {"Content-Type": "application/json"}, const recs = await guarded((token) => fetchRecommendations(token, selectedLearnerId, selectedPackId));
body: JSON.stringify({ workspace_id: workspaceId, title: workspaceTitle }) setCards(recs.cards || []);
}); setHistory(await guarded((token) => fetchEvaluatorHistory(token, selectedLearnerId)));
await loadRegistry(); if (auth.role === "admin") {
await openWorkspace(workspaceId); setValidation(await guarded((token) => fetchPackValidation(token, selectedPackId)));
setProvenance(await guarded((token) => fetchPackProvenance(token, selectedPackId)));
}
}
loadLearnerStuff();
}, [auth, selectedLearnerId, selectedPackId]);
async function simulateCard(card) {
await guarded((token) => postEvidence(token, selectedLearnerId, { concept_id: card.conceptId, dimension: "mastery", score: card.scoreHint, confidence_hint: card.confidenceHint, timestamp: new Date().toISOString(), kind: "checkpoint", source_id: `ui-${card.id}` }));
setLearnerState(await guarded((token) => fetchLearnerState(token, selectedLearnerId)));
const recs = await guarded((token) => fetchRecommendations(token, selectedLearnerId, selectedPackId));
setCards(recs.cards || []);
setMessage(card.reward);
} }
async function previewImport() { async function runEvaluator() {
if (!workspaceId || !importSource) return; const conceptId = packs.find((p) => p.id === selectedPackId)?.concepts?.[0]?.id || "prior";
const res = await fetch(`${API}/api/workspaces/import-preview`, { await guarded((token) => submitEvaluatorJob(token, selectedLearnerId, { pack_id: selectedPackId, concept_id: conceptId, submitted_text: "This is a moderately detailed learner response intended to trigger a somewhat better prototype evaluator score.", kind: "checkpoint" }));
method: "POST", setTimeout(async () => {
headers: {"Content-Type": "application/json"}, setHistory(await guarded((token) => fetchEvaluatorHistory(token, selectedLearnerId)));
body: JSON.stringify({ workspace_id: workspaceId, source_dir: importSource }) setLearnerState(await guarded((token) => fetchLearnerState(token, selectedLearnerId)));
}); const recs = await guarded((token) => fetchRecommendations(token, selectedLearnerId, selectedPackId));
const data = await res.json(); setCards(recs.cards || []);
setImportPreview(data); }, 1200);
setMessage(data.ok ? "Import preview ready." : "Import preview found blocking errors.");
} }
async function importWorkspace() { async function createLearnerNow() {
if (!workspaceId || !importSource) return; await guarded((token) => createLearner(token, newLearnerId, newLearnerId));
const res = await fetch(`${API}/api/workspaces/import`, { const ls = await guarded((token) => listLearners(token));
method: "POST", setLearners(ls);
headers: {"Content-Type": "application/json"}, setSelectedLearnerId(newLearnerId);
body: JSON.stringify({
workspace_id: workspaceId,
title: workspaceTitle || workspaceId,
source_dir: importSource,
allow_overwrite: allowOverwrite
})
});
const data = await res.json();
if (!data.ok) {
setMessage(data.error || "Import failed.");
return;
}
await loadRegistry();
await openWorkspace(workspaceId);
setMessage(`Imported draft pack from ${importSource} into workspace ${workspaceId}.`);
} }
async function openWorkspace(id) { async function savePack() {
const res = await fetch(`${API}/api/workspaces/open`, { await guarded((token) => upsertPack(token, { pack: formPack, is_published: false }));
method: "POST", setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
headers: {"Content-Type": "application/json"}, setPacks(await guarded((token) => fetchPacks(token)));
body: JSON.stringify({ workspace_id: id }) setMessage("Pack saved");
});
const opened = await res.json();
if (!opened.ok) {
setMessage("Could not open workspace.");
return;
}
const sessionRes = await fetch(`${API}/api/load`);
const sessionData = await sessionRes.json();
setSession(sessionData.session);
setSelectedId(sessionData.session?.draft_pack?.concepts?.[0]?.concept_id || "");
setPendingActions([]);
setMessage(`Opened workspace ${id}.`);
await loadRegistry();
} }
const selected = useMemo(() => { async function togglePublish(packId, isPublished) {
if (!session) return null; await guarded((token) => publishPack(token, packId, isPublished));
return session.draft_pack.concepts.find((c) => c.concept_id === selectedId) || null; setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
}, [session, selectedId]); setPacks(await guarded((token) => fetchPacks(token)));
function queueAction(action) {
setPendingActions((prev) => [...prev, action]);
} }
function patchConcept(conceptId, patch, rationale) { async function loadTrace(jobId) {
if (!session) return; setSelectedTrace(await guarded((token) => fetchEvaluatorTrace(token, jobId)));
const concepts = session.draft_pack.concepts.map((c) =>
c.concept_id === conceptId ? { ...c, ...patch } : c
);
setSession({ ...session, draft_pack: { ...session.draft_pack, concepts } });
if (patch.status !== undefined) queueAction({ action_type: "set_status", target: conceptId, payload: { status: patch.status }, rationale });
if (patch.title !== undefined) queueAction({ action_type: "edit_title", target: conceptId, payload: { title: patch.title }, rationale });
if (patch.description !== undefined) queueAction({ action_type: "edit_description", target: conceptId, payload: { description: patch.description }, rationale });
if (patch.prerequisites !== undefined) queueAction({ action_type: "edit_prerequisites", target: conceptId, payload: { prerequisites: patch.prerequisites }, rationale });
if (patch.notes !== undefined) queueAction({ action_type: "edit_notes", target: conceptId, payload: { notes: patch.notes }, rationale });
} }
function resolveConflict(conflict) { if (!auth) return <LoginView onAuth={setAuth} />;
if (!session) return;
setSession({
...session,
draft_pack: { ...session.draft_pack, conflicts: session.draft_pack.conflicts.filter((c) => c !== conflict) }
});
queueAction({ action_type: "resolve_conflict", target: "", payload: { conflict }, rationale: "Resolved in UI" });
}
async function saveChanges() {
if (!pendingActions.length) {
setMessage("No pending changes to save.");
return;
}
const res = await fetch(`${API}/api/save`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ actions: pendingActions })
});
const data = await res.json();
setSession(data.session);
setPendingActions([]);
setMessage("Saved review state.");
}
async function exportPromoted() {
const res = await fetch(`${API}/api/export`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({})
});
const data = await res.json();
setMessage(`Exported promoted pack to ${data.promoted_pack_dir}`);
}
return ( return (
<div className="page"> <div className="page">
<header className="hero"> <header className="hero">
<div> <div>
<h1>Didactopus Semantic QA</h1> <h1>Didactopus admin curation layer</h1>
<p> <p>Pack validation review, provenance inspection, evaluator traces, and form-driven pack authoring.</p>
Reduce the activation-energy hump from generated draft packs to curated review workspaces <div className="muted">Signed in as {auth.username} ({auth.role})</div>
by surfacing semantic curation issues before import. {message ? <div className="message">{message}</div> : null}
</p>
<div className="small">{message}</div>
</div> </div>
<div className="hero-actions"> <div className="hero-controls">
<button onClick={saveChanges}>Save Review State</button> <label>Learner<select value={selectedLearnerId} onChange={(e) => setSelectedLearnerId(e.target.value)}>{learners.map((l) => <option key={l.learner_id} value={l.learner_id}>{l.display_name || l.learner_id}</option>)}</select></label>
<button onClick={exportPromoted} disabled={!session}>Export Promoted Pack</button> <label>Pack<select value={selectedPackId} onChange={(e) => setSelectedPackId(e.target.value)}>{packs.map((p) => <option key={p.id} value={p.id}>{p.title}</option>)}</select></label>
<button onClick={() => { clearAuth(); setAuth(null); }}>Logout</button>
</div> </div>
</header> </header>
<section className="summary-grid"> <NavTabs tab={tab} setTab={setTab} role={auth.role} />
<div className="card">
<h2>Create Workspace</h2>
<label>Workspace ID<input value={workspaceId} onChange={(e) => setWorkspaceId(e.target.value)} /></label>
<label>Title<input value={workspaceTitle} onChange={(e) => setWorkspaceTitle(e.target.value)} /></label>
<button onClick={createWorkspace}>Create</button>
</div>
<div className="card">
<h2>Preview / Import Draft Pack</h2>
<label>Workspace ID<input value={workspaceId} onChange={(e) => setWorkspaceId(e.target.value)} /></label>
<label>Draft Pack Source Directory<input value={importSource} onChange={(e) => setImportSource(e.target.value)} placeholder="e.g. generated-pack" /></label>
<label className="checkline"><input type="checkbox" checked={allowOverwrite} onChange={(e) => setAllowOverwrite(e.target.checked)} /> Allow overwrite of existing workspace draft_pack</label>
<div className="button-row">
<button onClick={previewImport}>Preview</button>
<button onClick={importWorkspace}>Import</button>
</div>
</div>
<div className="card">
<h2>Recent</h2>
<ul>{registry.recent_workspace_ids.map((id) => <li key={id}><button onClick={() => openWorkspace(id)}>{id}</button></li>)}</ul>
</div>
<div className="card">
<h2>All Workspaces</h2>
<ul>{registry.workspaces.map((ws) => <li key={ws.workspace_id}><button onClick={() => openWorkspace(ws.workspace_id)}>{ws.title} ({ws.workspace_id})</button></li>)}</ul>
</div>
</section>
{importPreview && ( {tab === "learner" && (
<section className="preview-grid"> <main className="layout onecol">
<div className="card"> <section className="card">
<h2>Import Preview</h2> <h2>Learner dashboard</h2>
<div><strong>OK:</strong> {String(importPreview.ok)}</div> <button onClick={runEvaluator}>Submit demo evaluator job</button>
<div><strong>Overwrite Required:</strong> {String(importPreview.overwrite_required)}</div> <div className="steps-stack">
<div><strong>Pack:</strong> {importPreview.summary?.display_name || importPreview.summary?.pack_name || "-"}</div> {cards.length ? cards.map((card) => (
<div><strong>Version:</strong> {importPreview.summary?.version || "-"}</div> <div key={card.id} className="step-card">
<div><strong>Concepts:</strong> {importPreview.summary?.concept_count ?? "-"}</div> <div className="step-header">
<div><strong>Roadmap Stages:</strong> {importPreview.summary?.roadmap_stage_count ?? "-"}</div> <div><h4>{card.title}</h4><div className="muted">{card.minutes} minutes</div></div>
<div><strong>Projects:</strong> {importPreview.summary?.project_count ?? "-"}</div> <div className="reward-pill">{card.reward}</div>
<div><strong>Rubrics:</strong> {importPreview.summary?.rubric_count ?? "-"}</div>
</div> </div>
<div className="card"> <p>{card.reason}</p>
<h2>Validation Errors</h2> <details><summary>Why this is recommended</summary><ul>{card.why.map((w, idx) => <li key={idx}>{w}</li>)}</ul></details>
<ul>{(importPreview.errors || []).length ? importPreview.errors.map((x, i) => <li key={i}>{x}</li>) : <li>none</li>}</ul> <button className="primary" onClick={() => simulateCard(card)}>Simulate step</button>
</div> </div>
<div className="card"> )) : <div className="muted">No recommendations available.</div>}
<h2>Validation Warnings</h2>
<ul>{(importPreview.warnings || []).length ? importPreview.warnings.map((x, i) => <li key={i}>{x}</li>) : <li>none</li>}</ul>
</div>
<div className="card semantic-card">
<h2>Semantic QA Warnings</h2>
<ul>{(importPreview.semantic_warnings || []).length ? importPreview.semantic_warnings.map((x, i) => <li key={i}>{x}</li>) : <li>none</li>}</ul>
</div> </div>
<h3>Learner state snapshot</h3>
<pre className="prebox">{JSON.stringify(learnerState, null, 2)}</pre>
</section> </section>
</main>
)} )}
{session && ( {tab === "history" && (
<main className="layout"> <main className="layout twocol">
<aside className="sidebar"> <section className="card">
<h2>Concepts</h2> <h2>Evaluator history</h2>
{session.draft_pack.concepts.map((c) => ( {history.length ? (
<button key={c.concept_id} className={`concept-btn ${c.concept_id === selectedId ? "active" : ""}`} onClick={() => setSelectedId(c.concept_id)}> <table className="table">
<span>{c.title}</span> <thead><tr><th>Job</th><th>Status</th><th>Concept</th><th>Score</th><th>Trace</th></tr></thead>
<span className={`status-pill status-${c.status}`}>{c.status}</span> <tbody>
</button> {history.map((row) => (
<tr key={row.job_id}>
<td>{row.job_id}</td>
<td>{row.status}</td>
<td>{row.concept_id}</td>
<td>{row.result_score ?? "-"}</td>
<td><button onClick={() => loadTrace(row.job_id)}>Inspect trace</button></td>
</tr>
))} ))}
</aside> </tbody>
</table>
<section className="content"> ) : <div className="muted">No evaluator jobs yet.</div>}
{selected && (
<div className="card">
<h2>Concept Editor</h2>
<label>Title<input value={selected.title} onChange={(e) => patchConcept(selected.concept_id, { title: e.target.value }, "Edited title")} /></label>
<label>Status
<select value={selected.status} onChange={(e) => patchConcept(selected.concept_id, { status: e.target.value }, "Changed trust status")}>
{statuses.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</label>
<label>Description<textarea rows="6" value={selected.description} onChange={(e) => patchConcept(selected.concept_id, { description: e.target.value }, "Edited description")} /></label>
<label>Prerequisites (comma-separated ids)<input value={(selected.prerequisites || []).join(", ")} onChange={(e) => patchConcept(selected.concept_id, { prerequisites: e.target.value.split(",").map((x) => x.trim()).filter(Boolean) }, "Edited prerequisites")} /></label>
<label>Notes (one per line)<textarea rows="4" value={(selected.notes || []).join("\n")} onChange={(e) => patchConcept(selected.concept_id, { notes: e.target.value.split("\n").filter(Boolean) }, "Edited notes")} /></label>
</div>
)}
</section> </section>
<section className="card">
<h2>Evaluator trace</h2>
<pre className="prebox">{JSON.stringify(selectedTrace, null, 2)}</pre>
</section>
</main>
)}
<section className="rightbar"> {tab === "manage" && (
<div className="card"> <main className="layout twocol">
<h2>Conflicts</h2> <section className="card">
{session.draft_pack.conflicts.length ? session.draft_pack.conflicts.map((conflict, idx) => ( <h2>Learner management</h2>
<div key={idx} className="conflict"> <label>New learner ID<input value={newLearnerId} onChange={(e) => setNewLearnerId(e.target.value)} /></label>
<div>{conflict}</div> <button className="primary" onClick={createLearnerNow}>Create learner</button>
<button onClick={() => resolveConflict(conflict)}>Resolve</button> </section>
</div> <section className="card">
)) : <div className="small">No remaining conflicts.</div>} <h2>Existing learners</h2>
</div> <table className="table">
<div className="card"> <thead><tr><th>Learner ID</th><th>Display name</th><th>Owner</th></tr></thead>
<h2>Review Flags</h2> <tbody>
<ul>{session.draft_pack.review_flags.map((flag, idx) => <li key={idx}>{flag}</li>)}</ul> {learners.map((row) => (
</div> <tr key={row.learner_id}>
<td>{row.learner_id}</td>
<td>{row.display_name}</td>
<td>{row.owner_user_id}</td>
</tr>
))}
</tbody>
</table>
</section>
</main>
)}
{tab === "admin" && auth.role === "admin" && (
<main className="layout twocol">
<section className="card">
<h2>Richer pack authoring</h2>
<PackAuthorForm value={formPack} onChange={setFormPack} onSave={savePack} />
</section>
<section className="card">
<h2>Pack administration</h2>
<table className="table">
<thead><tr><th>ID</th><th>Title</th><th>Published</th><th>Action</th></tr></thead>
<tbody>
{adminPacks.map((row) => (
<tr key={row.id}>
<td>{row.id}</td>
<td>{row.title}</td>
<td>{String(row.is_published)}</td>
<td><button onClick={() => togglePublish(row.id, !row.is_published)}>{row.is_published ? "Unpublish" : "Publish"}</button></td>
</tr>
))}
</tbody>
</table>
</section>
</main>
)}
{tab === "review" && auth.role === "admin" && (
<main className="layout twocol">
<section className="card">
<h2>Pack validation review</h2>
<pre className="prebox">{JSON.stringify(validation, null, 2)}</pre>
</section>
<section className="card">
<h2>Attribution / provenance inspection</h2>
<pre className="prebox">{JSON.stringify(provenance, null, 2)}</pre>
</section> </section>
</main> </main>
)} )}

View File

@ -12,44 +12,23 @@ export async function login(username, password) {
return await res.json(); return await res.json();
} }
export async function listCandidates(token) { export async function refresh(refreshToken) {
const res = await fetch(`${API}/knowledge-candidates`, { headers: authHeaders(token, false) }); const res = await fetch(`${API}/refresh`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: refreshToken }) });
if (!res.ok) throw new Error("listCandidates failed"); if (!res.ok) throw new Error("refresh failed");
return await res.json(); return await res.json();
} }
export async function createCandidate(token, payload) { 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(); }
const res = await fetch(`${API}/knowledge-candidates`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); export async function fetchAdminPacks(token) { const res = await fetch(`${API}/admin/packs`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchAdminPacks failed"); return await res.json(); }
if (!res.ok) throw new Error("createCandidate failed"); export async function fetchPackValidation(token, packId) { const res = await fetch(`${API}/admin/packs/${packId}/validation`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPackValidation failed"); return await res.json(); }
return await res.json(); export async function fetchPackProvenance(token, packId) { const res = await fetch(`${API}/admin/packs/${packId}/provenance`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPackProvenance failed"); return await res.json(); }
} export async function upsertPack(token, payload) { const res = await fetch(`${API}/admin/packs`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("upsertPack failed"); return await res.json(); }
export async function publishPack(token, packId, isPublished) { const res = await fetch(`${API}/admin/packs/${packId}/publish?is_published=${isPublished}`, { method: "POST", headers: authHeaders(token, false) }); if (!res.ok) throw new Error("publishPack failed"); return await res.json(); }
export async function createReview(token, candidateId, payload) { export async function listLearners(token) { const res = await fetch(`${API}/learners`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listLearners failed"); return await res.json(); }
const res = await fetch(`${API}/knowledge-candidates/${candidateId}/reviews`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); export async function createLearner(token, learnerId, displayName) { const res = await fetch(`${API}/learners`, { method: "POST", headers: authHeaders(token), body: JSON.stringify({ learner_id: learnerId, display_name: displayName }) }); if (!res.ok) throw new Error("createLearner failed"); return await res.json(); }
if (!res.ok) throw new Error("createReview failed"); 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(); }
return await res.json(); export async function fetchRecommendations(token, learnerId, packId) { const res = await fetch(`${API}/learners/${learnerId}/recommendations/${packId}`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchRecommendations failed"); return await res.json(); }
} export async function postEvidence(token, learnerId, event) { const res = await fetch(`${API}/learners/${learnerId}/evidence`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(event) }); if (!res.ok) throw new Error("postEvidence failed"); return await res.json(); }
export async function submitEvaluatorJob(token, learnerId, payload) { const res = await fetch(`${API}/learners/${learnerId}/evaluator-jobs`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("submitEvaluatorJob failed"); return await res.json(); }
export async function promoteCandidate(token, candidateId, payload) { export async function fetchEvaluatorHistory(token, learnerId) { const res = await fetch(`${API}/learners/${learnerId}/evaluator-history`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchEvaluatorHistory failed"); return await res.json(); }
const res = await fetch(`${API}/knowledge-candidates/${candidateId}/promote`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); export async function fetchEvaluatorTrace(token, jobId) { const res = await fetch(`${API}/evaluator-jobs/${jobId}/trace`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchEvaluatorTrace failed"); return await res.json(); }
if (!res.ok) throw new Error("promoteCandidate failed");
return await res.json();
}
export async function runSynthesis(token, payload) {
const res = await fetch(`${API}/synthesis/run`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) });
if (!res.ok) throw new Error("runSynthesis failed");
return await res.json();
}
export async function listSynthesisCandidates(token) {
const res = await fetch(`${API}/synthesis/candidates`, { headers: authHeaders(token, false) });
if (!res.ok) throw new Error("listSynthesisCandidates failed");
return await res.json();
}
export async function promoteSynthesis(token, synthesisId, payload) {
const res = await fetch(`${API}/synthesis/candidates/${synthesisId}/promote`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) });
if (!res.ok) throw new Error("promoteSynthesis failed");
return await res.json();
}

View File

@ -2,5 +2,4 @@ import React from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import App from "./App"; import App from "./App";
import "./styles.css"; import "./styles.css";
createRoot(document.getElementById("root")).render(<App />); createRoot(document.getElementById("root")).render(<App />);

View File

@ -1,42 +1,37 @@
:root { :root {
--bg: #f7f8fb; --bg:#f6f8fb; --card:#ffffff; --text:#1f2430; --muted:#60697a; --border:#dbe1ea; --accent:#2d6cdf; --soft:#eef4ff;
--card: #ffffff;
--text: #1f2430;
--muted: #5d6678;
--border: #d7dce5;
--accent: #2d6cdf;
} }
* { box-sizing:border-box; } * { box-sizing:border-box; }
body { margin:0; font-family:Arial, Helvetica, sans-serif; background:var(--bg); color:var(--text); } body { margin:0; font-family:Arial, Helvetica, sans-serif; background:var(--bg); color:var(--text); }
.page { max-width: 1500px; margin: 0 auto; padding: 20px; } .page { max-width:1500px; margin:0 auto; padding:24px; }
.hero { background: var(--card); border: 1px solid var(--border); border-radius: 20px; padding: 20px; display: flex; justify-content: space-between; gap: 20px; align-items: flex-start; } .narrow-page { max-width:520px; }
.hero h1 { margin-top: 0; } .hero { background:var(--card); border:1px solid var(--border); border-radius:22px; padding:24px; display:flex; justify-content:space-between; gap:16px; }
.hero-actions { display: flex; gap: 10px; flex-wrap: wrap; } .hero-controls { min-width:280px; display:flex; flex-direction:column; gap:12px; }
button { border: 1px solid var(--border); background: white; border-radius: 12px; padding: 10px 14px; cursor: pointer; } label { display:block; font-weight:600; }
button:hover { border-color: var(--accent); } input, select, textarea { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; }
.summary-grid { margin-top: 16px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; } .card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; }
.preview-grid { margin-top: 16px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; } .narrow { margin-top:60px; }
.layout { margin-top: 16px; display: grid; grid-template-columns: 290px 1fr 360px; gap: 16px; } .tab-row { display:flex; gap:10px; margin:16px 0; flex-wrap:wrap; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 18px; padding: 16px; } .tab-row button, button { border:1px solid var(--border); background:white; border-radius:12px; padding:10px 14px; cursor:pointer; }
.semantic-card { grid-column: span 4; } .active-tab, .primary { background:var(--accent); color:white; border-color:var(--accent); }
.sidebar, .content, .rightbar { display: flex; flex-direction: column; gap: 16px; } .layout { display:grid; gap:16px; }
.concept-btn { width: 100%; text-align: left; display: flex; justify-content: space-between; gap: 8px; margin-bottom: 10px; } .twocol { grid-template-columns:1fr 1fr; }
.concept-btn.active { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(45,108,223,0.08); } .onecol { grid-template-columns:1fr; }
.status-pill { font-size: 12px; padding: 4px 8px; border-radius: 999px; border: 1px solid var(--border); white-space: nowrap; } .steps-stack { display:grid; gap:14px; }
.status-trusted { background: #e7f7ec; } .step-card { border:1px solid var(--border); border-radius:16px; padding:14px; background:#fcfdff; }
.status-provisional { background: #fff6df; } .step-header { display:flex; justify-content:space-between; gap:12px; align-items:start; }
.status-rejected { background: #fde9e9; } .reward-pill { background:var(--soft); border:1px solid var(--border); border-radius:999px; padding:8px 10px; font-size:12px; }
.status-needs_review { background: #eef2f7; } .muted { color:var(--muted); }
label { display: block; font-weight: 600; margin-bottom: 12px; } .error { color:#b42318; margin-top:10px; }
input, textarea, select { width: 100%; margin-top: 6px; border: 1px solid var(--border); border-radius: 10px; padding: 10px; font: inherit; background: white; } .message { margin-top:10px; padding:10px 12px; border:1px solid var(--border); background:#fff7dd; border-radius:12px; }
.small { color: var(--muted); } .table { width:100%; border-collapse:collapse; }
.conflict { border-top: 1px solid var(--border); padding-top: 12px; margin-top: 12px; } .table th, .table td { border-bottom:1px solid var(--border); text-align:left; padding:8px; vertical-align:top; }
ul { padding-left: 18px; } .prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:420px; }
.button-row { display: flex; gap: 10px; } .form-grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.checkline { display: flex; gap: 10px; align-items: center; } .full { grid-column:1 / -1; }
.checkline input { width: auto; margin-top: 0; } .checkrow { display:flex; gap:16px; flex-wrap:wrap; align-items:center; }
details summary { cursor:pointer; color:var(--accent); }
@media (max-width:1100px) { @media (max-width:1100px) {
.summary-grid, .preview-grid { grid-template-columns: repeat(2, 1fr); } .hero { flex-direction:column; }
.semantic-card { grid-column: span 2; } .twocol, .form-grid { grid-template-columns:1fr; }
.layout { grid-template-columns: 1fr; }
} }