Apply ZIP update: 135-didactopus-admin-curation-layer.zip [2026-03-14T13:19:17]
This commit is contained in:
parent
c049fc4d86
commit
5b64420315
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
__version__ = "0.1.0"
|
||||
__version__ = '0.1.0'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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="{}")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -3,10 +3,8 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
<body><div id="root"></div></body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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() {
|
||||
const [registry, setRegistry] = useState({ workspaces: [], recent_workspace_ids: [] });
|
||||
const [workspaceId, setWorkspaceId] = useState("");
|
||||
const [workspaceTitle, setWorkspaceTitle] = useState("");
|
||||
const [importSource, setImportSource] = useState("");
|
||||
const [importPreview, setImportPreview] = useState(null);
|
||||
const [allowOverwrite, setAllowOverwrite] = useState(false);
|
||||
const [session, setSession] = useState(null);
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [pendingActions, setPendingActions] = useState([]);
|
||||
const [message, setMessage] = useState("Connecting to local Didactopus bridge...");
|
||||
const [auth, setAuth] = useState(loadAuth());
|
||||
const [tab, setTab] = useState("learner");
|
||||
const [packs, setPacks] = useState([]);
|
||||
const [adminPacks, setAdminPacks] = useState([]);
|
||||
const [learners, setLearners] = useState([]);
|
||||
const [selectedLearnerId, setSelectedLearnerId] = useState("wesley-learner");
|
||||
const [selectedPackId, setSelectedPackId] = useState("");
|
||||
const [learnerState, setLearnerState] = useState(null);
|
||||
const [cards, setCards] = useState([]);
|
||||
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() {
|
||||
const res = await fetch(`${API}/api/workspaces`);
|
||||
const data = await res.json();
|
||||
setRegistry(data);
|
||||
if (!session) setMessage("Choose, create, preview, or import a workspace.");
|
||||
async function refreshAuthToken() {
|
||||
if (!auth?.refresh_token) return null;
|
||||
try {
|
||||
const result = await refresh(auth.refresh_token);
|
||||
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(() => {
|
||||
loadRegistry().catch(() => setMessage("Could not connect to local review bridge. Start the Python bridge service first."));
|
||||
}, []);
|
||||
|
||||
async function createWorkspace() {
|
||||
if (!workspaceId || !workspaceTitle) return;
|
||||
await fetch(`${API}/api/workspaces/create`, {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({ workspace_id: workspaceId, title: workspaceTitle })
|
||||
});
|
||||
await loadRegistry();
|
||||
await openWorkspace(workspaceId);
|
||||
}
|
||||
|
||||
async function previewImport() {
|
||||
if (!workspaceId || !importSource) return;
|
||||
const res = await fetch(`${API}/api/workspaces/import-preview`, {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({ workspace_id: workspaceId, source_dir: importSource })
|
||||
});
|
||||
const data = await res.json();
|
||||
setImportPreview(data);
|
||||
setMessage(data.ok ? "Import preview ready." : "Import preview found blocking errors.");
|
||||
}
|
||||
|
||||
async function importWorkspace() {
|
||||
if (!workspaceId || !importSource) return;
|
||||
const res = await fetch(`${API}/api/workspaces/import`, {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
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;
|
||||
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);
|
||||
}
|
||||
}
|
||||
await loadRegistry();
|
||||
await openWorkspace(workspaceId);
|
||||
setMessage(`Imported draft pack from ${importSource} into workspace ${workspaceId}.`);
|
||||
}
|
||||
load();
|
||||
}, [auth]);
|
||||
|
||||
async function openWorkspace(id) {
|
||||
const res = await fetch(`${API}/api/workspaces/open`, {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({ workspace_id: id })
|
||||
});
|
||||
const opened = await res.json();
|
||||
if (!opened.ok) {
|
||||
setMessage("Could not open workspace.");
|
||||
return;
|
||||
useEffect(() => {
|
||||
if (!auth || !selectedLearnerId || !selectedPackId) return;
|
||||
async function loadLearnerStuff() {
|
||||
setLearnerState(await guarded((token) => fetchLearnerState(token, selectedLearnerId)));
|
||||
const recs = await guarded((token) => fetchRecommendations(token, selectedLearnerId, selectedPackId));
|
||||
setCards(recs.cards || []);
|
||||
setHistory(await guarded((token) => fetchEvaluatorHistory(token, selectedLearnerId)));
|
||||
if (auth.role === "admin") {
|
||||
setValidation(await guarded((token) => fetchPackValidation(token, selectedPackId)));
|
||||
setProvenance(await guarded((token) => fetchPackProvenance(token, selectedPackId)));
|
||||
}
|
||||
}
|
||||
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();
|
||||
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);
|
||||
}
|
||||
|
||||
const selected = useMemo(() => {
|
||||
if (!session) return null;
|
||||
return session.draft_pack.concepts.find((c) => c.concept_id === selectedId) || null;
|
||||
}, [session, selectedId]);
|
||||
|
||||
function queueAction(action) {
|
||||
setPendingActions((prev) => [...prev, action]);
|
||||
async function runEvaluator() {
|
||||
const conceptId = packs.find((p) => p.id === selectedPackId)?.concepts?.[0]?.id || "prior";
|
||||
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" }));
|
||||
setTimeout(async () => {
|
||||
setHistory(await guarded((token) => fetchEvaluatorHistory(token, selectedLearnerId)));
|
||||
setLearnerState(await guarded((token) => fetchLearnerState(token, selectedLearnerId)));
|
||||
const recs = await guarded((token) => fetchRecommendations(token, selectedLearnerId, selectedPackId));
|
||||
setCards(recs.cards || []);
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function patchConcept(conceptId, patch, rationale) {
|
||||
if (!session) return;
|
||||
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 });
|
||||
async function createLearnerNow() {
|
||||
await guarded((token) => createLearner(token, newLearnerId, newLearnerId));
|
||||
const ls = await guarded((token) => listLearners(token));
|
||||
setLearners(ls);
|
||||
setSelectedLearnerId(newLearnerId);
|
||||
}
|
||||
|
||||
function resolveConflict(conflict) {
|
||||
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 savePack() {
|
||||
await guarded((token) => upsertPack(token, { pack: formPack, is_published: false }));
|
||||
setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
|
||||
setPacks(await guarded((token) => fetchPacks(token)));
|
||||
setMessage("Pack saved");
|
||||
}
|
||||
|
||||
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 togglePublish(packId, isPublished) {
|
||||
await guarded((token) => publishPack(token, packId, isPublished));
|
||||
setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
|
||||
setPacks(await guarded((token) => fetchPacks(token)));
|
||||
}
|
||||
|
||||
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}`);
|
||||
async function loadTrace(jobId) {
|
||||
setSelectedTrace(await guarded((token) => fetchEvaluatorTrace(token, jobId)));
|
||||
}
|
||||
|
||||
if (!auth) return <LoginView onAuth={setAuth} />;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="hero">
|
||||
<div>
|
||||
<h1>Didactopus Semantic QA</h1>
|
||||
<p>
|
||||
Reduce the activation-energy hump from generated draft packs to curated review workspaces
|
||||
by surfacing semantic curation issues before import.
|
||||
</p>
|
||||
<div className="small">{message}</div>
|
||||
<h1>Didactopus admin curation layer</h1>
|
||||
<p>Pack validation review, provenance inspection, evaluator traces, and form-driven pack authoring.</p>
|
||||
<div className="muted">Signed in as {auth.username} ({auth.role})</div>
|
||||
{message ? <div className="message">{message}</div> : null}
|
||||
</div>
|
||||
<div className="hero-actions">
|
||||
<button onClick={saveChanges}>Save Review State</button>
|
||||
<button onClick={exportPromoted} disabled={!session}>Export Promoted Pack</button>
|
||||
<div className="hero-controls">
|
||||
<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>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<section className="summary-grid">
|
||||
<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>
|
||||
<NavTabs tab={tab} setTab={setTab} role={auth.role} />
|
||||
|
||||
{importPreview && (
|
||||
<section className="preview-grid">
|
||||
<div className="card">
|
||||
<h2>Import Preview</h2>
|
||||
<div><strong>OK:</strong> {String(importPreview.ok)}</div>
|
||||
<div><strong>Overwrite Required:</strong> {String(importPreview.overwrite_required)}</div>
|
||||
<div><strong>Pack:</strong> {importPreview.summary?.display_name || importPreview.summary?.pack_name || "-"}</div>
|
||||
<div><strong>Version:</strong> {importPreview.summary?.version || "-"}</div>
|
||||
<div><strong>Concepts:</strong> {importPreview.summary?.concept_count ?? "-"}</div>
|
||||
<div><strong>Roadmap Stages:</strong> {importPreview.summary?.roadmap_stage_count ?? "-"}</div>
|
||||
<div><strong>Projects:</strong> {importPreview.summary?.project_count ?? "-"}</div>
|
||||
<div><strong>Rubrics:</strong> {importPreview.summary?.rubric_count ?? "-"}</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h2>Validation Errors</h2>
|
||||
<ul>{(importPreview.errors || []).length ? importPreview.errors.map((x, i) => <li key={i}>{x}</li>) : <li>none</li>}</ul>
|
||||
</div>
|
||||
<div className="card">
|
||||
<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>
|
||||
</section>
|
||||
{tab === "learner" && (
|
||||
<main className="layout onecol">
|
||||
<section className="card">
|
||||
<h2>Learner dashboard</h2>
|
||||
<button onClick={runEvaluator}>Submit demo evaluator job</button>
|
||||
<div className="steps-stack">
|
||||
{cards.length ? cards.map((card) => (
|
||||
<div key={card.id} className="step-card">
|
||||
<div className="step-header">
|
||||
<div><h4>{card.title}</h4><div className="muted">{card.minutes} minutes</div></div>
|
||||
<div className="reward-pill">{card.reward}</div>
|
||||
</div>
|
||||
<p>{card.reason}</p>
|
||||
<details><summary>Why this is recommended</summary><ul>{card.why.map((w, idx) => <li key={idx}>{w}</li>)}</ul></details>
|
||||
<button className="primary" onClick={() => simulateCard(card)}>Simulate step</button>
|
||||
</div>
|
||||
)) : <div className="muted">No recommendations available.</div>}
|
||||
</div>
|
||||
<h3>Learner state snapshot</h3>
|
||||
<pre className="prebox">{JSON.stringify(learnerState, null, 2)}</pre>
|
||||
</section>
|
||||
</main>
|
||||
)}
|
||||
|
||||
{session && (
|
||||
<main className="layout">
|
||||
<aside className="sidebar">
|
||||
<h2>Concepts</h2>
|
||||
{session.draft_pack.concepts.map((c) => (
|
||||
<button key={c.concept_id} className={`concept-btn ${c.concept_id === selectedId ? "active" : ""}`} onClick={() => setSelectedId(c.concept_id)}>
|
||||
<span>{c.title}</span>
|
||||
<span className={`status-pill status-${c.status}`}>{c.status}</span>
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
|
||||
<section className="content">
|
||||
{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>
|
||||
)}
|
||||
{tab === "history" && (
|
||||
<main className="layout twocol">
|
||||
<section className="card">
|
||||
<h2>Evaluator history</h2>
|
||||
{history.length ? (
|
||||
<table className="table">
|
||||
<thead><tr><th>Job</th><th>Status</th><th>Concept</th><th>Score</th><th>Trace</th></tr></thead>
|
||||
<tbody>
|
||||
{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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : <div className="muted">No evaluator jobs yet.</div>}
|
||||
</section>
|
||||
<section className="card">
|
||||
<h2>Evaluator trace</h2>
|
||||
<pre className="prebox">{JSON.stringify(selectedTrace, null, 2)}</pre>
|
||||
</section>
|
||||
</main>
|
||||
)}
|
||||
|
||||
<section className="rightbar">
|
||||
<div className="card">
|
||||
<h2>Conflicts</h2>
|
||||
{session.draft_pack.conflicts.length ? session.draft_pack.conflicts.map((conflict, idx) => (
|
||||
<div key={idx} className="conflict">
|
||||
<div>{conflict}</div>
|
||||
<button onClick={() => resolveConflict(conflict)}>Resolve</button>
|
||||
</div>
|
||||
)) : <div className="small">No remaining conflicts.</div>}
|
||||
</div>
|
||||
<div className="card">
|
||||
<h2>Review Flags</h2>
|
||||
<ul>{session.draft_pack.review_flags.map((flag, idx) => <li key={idx}>{flag}</li>)}</ul>
|
||||
</div>
|
||||
{tab === "manage" && (
|
||||
<main className="layout twocol">
|
||||
<section className="card">
|
||||
<h2>Learner management</h2>
|
||||
<label>New learner ID<input value={newLearnerId} onChange={(e) => setNewLearnerId(e.target.value)} /></label>
|
||||
<button className="primary" onClick={createLearnerNow}>Create learner</button>
|
||||
</section>
|
||||
<section className="card">
|
||||
<h2>Existing learners</h2>
|
||||
<table className="table">
|
||||
<thead><tr><th>Learner ID</th><th>Display name</th><th>Owner</th></tr></thead>
|
||||
<tbody>
|
||||
{learners.map((row) => (
|
||||
<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>
|
||||
</main>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -7,49 +7,28 @@ function authHeaders(token, json=true) {
|
|||
}
|
||||
|
||||
export async function login(username, password) {
|
||||
const res = await fetch(`${API}/login`, { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ username, password })});
|
||||
const res = await fetch(`${API}/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }) });
|
||||
if (!res.ok) throw new Error("login failed");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function listCandidates(token) {
|
||||
const res = await fetch(`${API}/knowledge-candidates`, { headers: authHeaders(token, false) });
|
||||
if (!res.ok) throw new Error("listCandidates failed");
|
||||
export async function refresh(refreshToken) {
|
||||
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("refresh failed");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function createCandidate(token, payload) {
|
||||
const res = await fetch(`${API}/knowledge-candidates`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error("createCandidate failed");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function createReview(token, candidateId, payload) {
|
||||
const res = await fetch(`${API}/knowledge-candidates/${candidateId}/reviews`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error("createReview failed");
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
export async function promoteCandidate(token, candidateId, payload) {
|
||||
const res = await fetch(`${API}/knowledge-candidates/${candidateId}/promote`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) });
|
||||
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();
|
||||
}
|
||||
export async function fetchPacks(token) { const res = await fetch(`${API}/packs`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPacks failed"); return await res.json(); }
|
||||
export async function 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(); }
|
||||
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(); }
|
||||
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 listLearners(token) { const res = await fetch(`${API}/learners`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("listLearners failed"); return await res.json(); }
|
||||
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(); }
|
||||
export async function fetchLearnerState(token, learnerId) { const res = await fetch(`${API}/learners/${learnerId}/state`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchLearnerState failed"); return await res.json(); }
|
||||
export async function 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 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(); }
|
||||
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(); }
|
||||
|
|
|
|||
|
|
@ -2,5 +2,4 @@ import React from "react";
|
|||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")).render(<App />);
|
||||
|
|
|
|||
|
|
@ -1,42 +1,37 @@
|
|||
:root {
|
||||
--bg: #f7f8fb;
|
||||
--card: #ffffff;
|
||||
--text: #1f2430;
|
||||
--muted: #5d6678;
|
||||
--border: #d7dce5;
|
||||
--accent: #2d6cdf;
|
||||
--bg:#f6f8fb; --card:#ffffff; --text:#1f2430; --muted:#60697a; --border:#dbe1ea; --accent:#2d6cdf; --soft:#eef4ff;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: Arial, Helvetica, sans-serif; background: var(--bg); color: var(--text); }
|
||||
.page { max-width: 1500px; margin: 0 auto; padding: 20px; }
|
||||
.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; }
|
||||
.hero h1 { margin-top: 0; }
|
||||
.hero-actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
button { border: 1px solid var(--border); background: white; border-radius: 12px; padding: 10px 14px; cursor: pointer; }
|
||||
button:hover { border-color: var(--accent); }
|
||||
.summary-grid { margin-top: 16px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
|
||||
.preview-grid { margin-top: 16px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
|
||||
.layout { margin-top: 16px; display: grid; grid-template-columns: 290px 1fr 360px; gap: 16px; }
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: 18px; padding: 16px; }
|
||||
.semantic-card { grid-column: span 4; }
|
||||
.sidebar, .content, .rightbar { display: flex; flex-direction: column; gap: 16px; }
|
||||
.concept-btn { width: 100%; text-align: left; display: flex; justify-content: space-between; gap: 8px; margin-bottom: 10px; }
|
||||
.concept-btn.active { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(45,108,223,0.08); }
|
||||
.status-pill { font-size: 12px; padding: 4px 8px; border-radius: 999px; border: 1px solid var(--border); white-space: nowrap; }
|
||||
.status-trusted { background: #e7f7ec; }
|
||||
.status-provisional { background: #fff6df; }
|
||||
.status-rejected { background: #fde9e9; }
|
||||
.status-needs_review { background: #eef2f7; }
|
||||
label { display: block; font-weight: 600; margin-bottom: 12px; }
|
||||
input, textarea, select { width: 100%; margin-top: 6px; border: 1px solid var(--border); border-radius: 10px; padding: 10px; font: inherit; background: white; }
|
||||
.small { color: var(--muted); }
|
||||
.conflict { border-top: 1px solid var(--border); padding-top: 12px; margin-top: 12px; }
|
||||
ul { padding-left: 18px; }
|
||||
.button-row { display: flex; gap: 10px; }
|
||||
.checkline { display: flex; gap: 10px; align-items: center; }
|
||||
.checkline input { width: auto; margin-top: 0; }
|
||||
@media (max-width: 1100px) {
|
||||
.summary-grid, .preview-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.semantic-card { grid-column: span 2; }
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
* { box-sizing:border-box; }
|
||||
body { margin:0; font-family:Arial, Helvetica, sans-serif; background:var(--bg); color:var(--text); }
|
||||
.page { max-width:1500px; margin:0 auto; padding:24px; }
|
||||
.narrow-page { max-width:520px; }
|
||||
.hero { background:var(--card); border:1px solid var(--border); border-radius:22px; padding:24px; display:flex; justify-content:space-between; gap:16px; }
|
||||
.hero-controls { min-width:280px; display:flex; flex-direction:column; gap:12px; }
|
||||
label { display:block; font-weight:600; }
|
||||
input, select, textarea { width:100%; margin-top:6px; border:1px solid var(--border); border-radius:10px; padding:10px; font:inherit; background:white; }
|
||||
.card { background:var(--card); border:1px solid var(--border); border-radius:18px; padding:18px; }
|
||||
.narrow { margin-top:60px; }
|
||||
.tab-row { display:flex; gap:10px; margin:16px 0; flex-wrap:wrap; }
|
||||
.tab-row button, button { border:1px solid var(--border); background:white; border-radius:12px; padding:10px 14px; cursor:pointer; }
|
||||
.active-tab, .primary { background:var(--accent); color:white; border-color:var(--accent); }
|
||||
.layout { display:grid; gap:16px; }
|
||||
.twocol { grid-template-columns:1fr 1fr; }
|
||||
.onecol { grid-template-columns:1fr; }
|
||||
.steps-stack { display:grid; gap:14px; }
|
||||
.step-card { border:1px solid var(--border); border-radius:16px; padding:14px; background:#fcfdff; }
|
||||
.step-header { display:flex; justify-content:space-between; gap:12px; align-items:start; }
|
||||
.reward-pill { background:var(--soft); border:1px solid var(--border); border-radius:999px; padding:8px 10px; font-size:12px; }
|
||||
.muted { color:var(--muted); }
|
||||
.error { color:#b42318; margin-top:10px; }
|
||||
.message { margin-top:10px; padding:10px 12px; border:1px solid var(--border); background:#fff7dd; border-radius:12px; }
|
||||
.table { width:100%; border-collapse:collapse; }
|
||||
.table th, .table td { border-bottom:1px solid var(--border); text-align:left; padding:8px; vertical-align:top; }
|
||||
.prebox { background:#f7f8fa; border:1px solid var(--border); border-radius:12px; padding:12px; overflow:auto; max-height:420px; }
|
||||
.form-grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
||||
.full { grid-column:1 / -1; }
|
||||
.checkrow { display:flex; gap:16px; flex-wrap:wrap; align-items:center; }
|
||||
details summary { cursor:pointer; color:var(--accent); }
|
||||
@media (max-width:1100px) {
|
||||
.hero { flex-direction:column; }
|
||||
.twocol, .form-grid { grid-template-columns:1fr; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue