Apply ZIP update: 240-didactopus-review-governance-layer.zip [2026-03-14T13:20:58]

This commit is contained in:
welsberr 2026-03-14 13:29:56 -04:00
parent 664f959f34
commit 8738897da6
15 changed files with 685 additions and 132 deletions

View File

@ -19,7 +19,6 @@ dependencies = [
[project.scripts] [project.scripts]
didactopus-api = "didactopus.api:main" didactopus-api = "didactopus.api:main"
didactopus-worker = "didactopus.worker:main"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]

View File

@ -1,15 +1,12 @@
from __future__ import annotations from __future__ import annotations
import json
from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
import uvicorn import uvicorn
from .config import load_settings from .config import load_settings
from .db import Base, engine from .db import Base, engine
from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest, GovernanceAction, ReviewCommentCreate
from .repository import ( 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, set_governance_state, list_pack_versions, add_review_comment, list_review_comments, 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
authenticate_user, get_user_by_id, store_refresh_token, refresh_token_active, revoke_refresh_token,
list_packs, get_pack, upsert_pack, create_learner, learner_owned_by_user, load_learner_state,
save_learner_state, create_evaluator_job, get_evaluator_job, list_evaluator_jobs_for_learner
)
from .engine import apply_evidence, recommend_next from .engine import apply_evidence, recommend_next
from .auth import issue_access_token, issue_refresh_token, decode_token, new_token_id from .auth import issue_access_token, issue_refresh_token, decode_token, new_token_id
from .worker import process_job from .worker import process_job
@ -18,13 +15,7 @@ settings = load_settings()
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
app = FastAPI(title="Didactopus API Prototype") app = FastAPI(title="Didactopus API Prototype")
app.add_middleware( app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
def current_user(authorization: str = Header(default="")): def current_user(authorization: str = Header(default="")):
token = authorization.removeprefix("Bearer ").strip() token = authorization.removeprefix("Bearer ").strip()
@ -54,12 +45,7 @@ def login(payload: LoginRequest):
raise HTTPException(status_code=401, detail="Invalid credentials") raise HTTPException(status_code=401, detail="Invalid credentials")
token_id = new_token_id() token_id = new_token_id()
store_refresh_token(user.id, token_id) store_refresh_token(user.id, token_id)
return TokenPair( return TokenPair(access_token=issue_access_token(user.id, user.username, user.role), refresh_token=issue_refresh_token(user.id, user.username, user.role, token_id), username=user.username, role=user.role)
access_token=issue_access_token(user.id, user.username, user.role),
refresh_token=issue_refresh_token(user.id, user.username, user.role, token_id),
username=user.username,
role=user.role,
)
@app.post("/api/refresh", response_model=TokenPair) @app.post("/api/refresh", response_model=TokenPair)
def refresh(payload: RefreshRequest): def refresh(payload: RefreshRequest):
@ -75,35 +61,65 @@ def refresh(payload: RefreshRequest):
revoke_refresh_token(token_id) revoke_refresh_token(token_id)
new_jti = new_token_id() new_jti = new_token_id()
store_refresh_token(user.id, new_jti) store_refresh_token(user.id, new_jti)
return TokenPair( return TokenPair(access_token=issue_access_token(user.id, user.username, user.role), refresh_token=issue_refresh_token(user.id, user.username, user.role, new_jti), username=user.username, role=user.role)
access_token=issue_access_token(user.id, user.username, user.role),
refresh_token=issue_refresh_token(user.id, user.username, user.role, new_jti),
username=user.username,
role=user.role,
)
@app.get("/api/packs") @app.get("/api/packs")
def api_list_packs(user = Depends(current_user)): def api_list_packs(user = Depends(current_user)):
include_unpublished = user.role == "admin" return [p.model_dump() for p in list_packs(include_unpublished=(user.role == "admin"))]
return [p.model_dump() for p in list_packs(include_unpublished=include_unpublished)]
@app.get("/api/packs/{pack_id}") @app.get("/api/admin/packs")
def api_get_pack(pack_id: str, user = Depends(current_user)): def api_admin_list_packs(user = Depends(require_admin)):
pack = get_pack(pack_id) return list_pack_admin_rows()
if pack is None:
raise HTTPException(status_code=404, detail="Pack not found") @app.get("/api/admin/packs/{pack_id}/validation")
return pack.model_dump() 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.get("/api/admin/packs/{pack_id}/versions")
def api_admin_pack_versions(pack_id: str, user = Depends(require_admin)):
return list_pack_versions(pack_id)
@app.get("/api/admin/packs/{pack_id}/comments")
def api_admin_pack_comments(pack_id: str, user = Depends(require_admin)):
return list_review_comments(pack_id)
@app.post("/api/admin/packs") @app.post("/api/admin/packs")
def api_upsert_pack(payload: CreatePackRequest, user = Depends(require_admin)): def api_upsert_pack(payload: CreatePackRequest, user = Depends(require_admin)):
upsert_pack(payload.pack, is_published=payload.is_published) upsert_pack(payload.pack, submitted_by_user_id=user.id, is_published=payload.is_published, change_summary=payload.change_summary)
return {"ok": True, "pack_id": payload.pack.id} 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/admin/packs/{pack_id}/governance")
def api_governance_action(pack_id: str, payload: GovernanceAction, user = Depends(require_admin)):
ok = set_governance_state(pack_id, payload.status, payload.review_summary)
if not ok:
raise HTTPException(status_code=404, detail="Pack not found")
return {"ok": True, "pack_id": pack_id, "status": payload.status}
@app.post("/api/admin/packs/{pack_id}/comments")
def api_add_review_comment(pack_id: str, version_number: int, payload: ReviewCommentCreate, user = Depends(require_admin)):
add_review_comment(pack_id, version_number, user.id, payload.comment_text, payload.disposition)
return {"ok": True}
@app.post("/api/learners") @app.post("/api/learners")
def api_create_learner(payload: CreateLearnerRequest, user = Depends(current_user)): def api_create_learner(payload: CreateLearnerRequest, user = Depends(current_user)):
create_learner(user.id, payload.learner_id, payload.display_name) create_learner(user.id, payload.learner_id, payload.display_name)
return {"ok": True, "learner_id": payload.learner_id} return {"ok": True, "learner_id": payload.learner_id}
@app.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/learners/{learner_id}/state") @app.get("/api/learners/{learner_id}/state")
def api_get_learner_state(learner_id: str, user = Depends(current_user)): def api_get_learner_state(learner_id: str, user = Depends(current_user)):
ensure_learner_access(user, learner_id) ensure_learner_access(user, learner_id)
@ -147,6 +163,13 @@ def api_get_evaluator_job(job_id: int, user = Depends(current_user)):
raise HTTPException(status_code=404, detail="Job not found") 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) 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/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.get("/api/learners/{learner_id}/evaluator-history") @app.get("/api/learners/{learner_id}/evaluator-history")
def api_get_evaluator_history(learner_id: str, user = Depends(current_user)): def api_get_evaluator_history(learner_id: str, user = Depends(current_user)):
ensure_learner_access(user, learner_id) ensure_learner_access(user, learner_id)
@ -155,6 +178,3 @@ def api_get_evaluator_history(learner_id: str, user = Depends(current_user)):
def main(): def main():
uvicorn.run(app, host=settings.host, port=settings.port) uvicorn.run(app, host=settings.host, port=settings.port)
if __name__ == "__main__":
main()

View File

@ -20,10 +20,10 @@ def _encode_token(payload: dict, expires_delta: timedelta) -> str:
return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm) return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def issue_access_token(user_id: int, username: str, role: str) -> str: def issue_access_token(user_id: int, username: str, role: str) -> str:
return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "access"}, timedelta(minutes=settings.access_token_minutes)) return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "access"}, timedelta(minutes=30))
def issue_refresh_token(user_id: int, username: str, role: str, token_id: str) -> str: def issue_refresh_token(user_id: int, username: str, role: str, token_id: str) -> str:
return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "refresh", "jti": token_id}, timedelta(days=settings.refresh_token_days)) return _encode_token({"sub": str(user_id), "username": username, "role": role, "kind": "refresh", "jti": token_id}, timedelta(days=14))
def decode_token(token: str) -> dict | None: def decode_token(token: str) -> dict | None:
try: try:

View File

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

View File

@ -41,6 +41,23 @@ class PackData(BaseModel):
onboarding: dict = Field(default_factory=dict) onboarding: dict = Field(default_factory=dict)
compliance: PackCompliance = Field(default_factory=PackCompliance) compliance: PackCompliance = Field(default_factory=PackCompliance)
class CreatePackRequest(BaseModel):
pack: PackData
is_published: bool = False
change_summary: str = ""
class GovernanceAction(BaseModel):
status: str
review_summary: str = ""
class ReviewCommentCreate(BaseModel):
comment_text: str
disposition: str = "comment"
class CreateLearnerRequest(BaseModel):
learner_id: str
display_name: str = ""
class MasteryRecord(BaseModel): class MasteryRecord(BaseModel):
concept_id: str concept_id: str
dimension: str dimension: str
@ -63,10 +80,6 @@ class LearnerState(BaseModel):
records: list[MasteryRecord] = Field(default_factory=list) records: list[MasteryRecord] = Field(default_factory=list)
history: list[EvidenceEvent] = Field(default_factory=list) history: list[EvidenceEvent] = Field(default_factory=list)
class CreateLearnerRequest(BaseModel):
learner_id: str
display_name: str = ""
class EvaluatorSubmission(BaseModel): class EvaluatorSubmission(BaseModel):
pack_id: str pack_id: str
concept_id: str concept_id: str
@ -79,7 +92,3 @@ class EvaluatorJobStatus(BaseModel):
result_score: float | None = None result_score: float | None = None
result_confidence_hint: float | None = None result_confidence_hint: float | None = None
result_notes: str = "" result_notes: str = ""
class CreatePackRequest(BaseModel):
pack: PackData
is_published: bool = True

View File

@ -1,5 +1,5 @@
from sqlalchemy import String, Integer, Float, ForeignKey, Text, Boolean from sqlalchemy import String, Integer, Float, ForeignKey, Text, Boolean
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column
from .db import Base from .db import Base
class UserORM(Base): class UserORM(Base):
@ -24,7 +24,33 @@ class PackORM(Base):
subtitle: Mapped[str] = mapped_column(Text, default="") subtitle: Mapped[str] = mapped_column(Text, default="")
level: Mapped[str] = mapped_column(String(100), default="novice-friendly") level: Mapped[str] = mapped_column(String(100), default="novice-friendly")
data_json: Mapped[str] = mapped_column(Text) data_json: Mapped[str] = mapped_column(Text)
is_published: Mapped[bool] = mapped_column(Boolean, default=True) validation_json: Mapped[str] = mapped_column(Text, default="{}")
provenance_json: Mapped[str] = mapped_column(Text, default="{}")
governance_state: Mapped[str] = mapped_column(String(50), default="draft")
current_version: Mapped[int] = mapped_column(Integer, default=1)
is_published: Mapped[bool] = mapped_column(Boolean, default=False)
class PackVersionORM(Base):
__tablename__ = "pack_versions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
pack_id: Mapped[str] = mapped_column(ForeignKey("packs.id"), index=True)
version_number: Mapped[int] = mapped_column(Integer)
submitted_by_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
status: Mapped[str] = mapped_column(String(50), default="draft")
data_json: Mapped[str] = mapped_column(Text)
change_summary: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[str] = mapped_column(String(100), default="")
review_summary: Mapped[str] = mapped_column(Text, default="")
class ReviewCommentORM(Base):
__tablename__ = "review_comments"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
pack_id: Mapped[str] = mapped_column(ForeignKey("packs.id"), index=True)
version_number: Mapped[int] = mapped_column(Integer)
reviewer_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
comment_text: Mapped[str] = mapped_column(Text, default="")
disposition: Mapped[str] = mapped_column(String(50), default="comment")
created_at: Mapped[str] = mapped_column(String(100), default="")
class LearnerORM(Base): class LearnerORM(Base):
__tablename__ = "learners" __tablename__ = "learners"
@ -66,3 +92,4 @@ class EvaluatorJobORM(Base):
result_score: Mapped[float | None] = mapped_column(Float, nullable=True) result_score: Mapped[float | None] = mapped_column(Float, nullable=True)
result_confidence_hint: 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="") result_notes: Mapped[str] = mapped_column(Text, default="")
trace_json: Mapped[str] = mapped_column(Text, default="{}")

View File

@ -1,11 +1,15 @@
from __future__ import annotations from __future__ import annotations
import json import json
from sqlalchemy import select from datetime import datetime, timezone
from sqlalchemy import select, func
from .db import SessionLocal from .db import SessionLocal
from .orm import UserORM, RefreshTokenORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM from .orm import UserORM, RefreshTokenORM, PackORM, PackVersionORM, ReviewCommentORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM
from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent
from .auth import verify_password from .auth import verify_password
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def get_user_by_username(username: str): def get_user_by_username(username: str):
with SessionLocal() as db: with SessionLocal() as db:
return db.execute(select(UserORM).where(UserORM.username == username)).scalar_one_or_none() return db.execute(select(UserORM).where(UserORM.username == username)).scalar_one_or_none()
@ -37,7 +41,7 @@ def revoke_refresh_token(token_id: str):
row.is_revoked = True row.is_revoked = True
db.commit() db.commit()
def list_packs(include_unpublished: bool = False) -> list[PackData]: def list_packs(include_unpublished: bool = False):
with SessionLocal() as db: with SessionLocal() as db:
stmt = select(PackORM) stmt = select(PackORM)
if not include_unpublished: if not include_unpublished:
@ -45,37 +49,162 @@ def list_packs(include_unpublished: bool = False) -> list[PackData]:
rows = db.execute(stmt).scalars().all() rows = db.execute(stmt).scalars().all()
return [PackData.model_validate(json.loads(r.data_json)) for r in rows] 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, "governance_state": r.governance_state, "current_version": r.current_version} for r in rows]
def get_pack(pack_id: str): def get_pack(pack_id: str):
with SessionLocal() as db: with SessionLocal() as db:
row = 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)) return None if row is None else PackData.model_validate(json.loads(row.data_json))
def upsert_pack(pack: PackData, is_published: bool = True): 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, submitted_by_user_id: int, is_published: bool = False, change_summary: str = ""):
validation = {
"ok": len(pack.concepts) > 0,
"warnings": [] if len(pack.concepts) > 0 else ["Pack has no concepts."],
"errors": [],
"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: with SessionLocal() as db:
row = db.get(PackORM, pack.id) row = db.get(PackORM, pack.id)
payload = json.dumps(pack.model_dump()) payload = json.dumps(pack.model_dump())
if row is None: if row is None:
db.add(PackORM(id=pack.id, title=pack.title, subtitle=pack.subtitle, level=pack.level, data_json=payload, is_published=is_published)) row = 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),
governance_state="draft", current_version=1, is_published=is_published
)
db.add(row)
version_number = 1
else: else:
row.title = pack.title row.title = pack.title
row.subtitle = pack.subtitle row.subtitle = pack.subtitle
row.level = pack.level row.level = pack.level
row.data_json = payload row.data_json = payload
row.validation_json = json.dumps(validation)
row.provenance_json = json.dumps(provenance)
row.is_published = is_published row.is_published = is_published
row.current_version += 1
row.governance_state = "draft"
version_number = row.current_version
db.flush()
db.add(PackVersionORM(
pack_id=pack.id,
version_number=version_number,
submitted_by_user_id=submitted_by_user_id,
status="draft",
data_json=payload,
change_summary=change_summary,
created_at=now_iso(),
review_summary=""
))
db.commit() 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 set_governance_state(pack_id: str, status: str, review_summary: str):
with SessionLocal() as db:
row = db.get(PackORM, pack_id)
if row is None:
return False
row.governance_state = status
version = db.execute(
select(PackVersionORM).where(
PackVersionORM.pack_id == pack_id,
PackVersionORM.version_number == row.current_version
)
).scalar_one_or_none()
if version is not None:
version.status = status
version.review_summary = review_summary
db.commit()
return True
def list_pack_versions(pack_id: str):
with SessionLocal() as db:
rows = db.execute(
select(PackVersionORM).where(PackVersionORM.pack_id == pack_id).order_by(PackVersionORM.version_number.desc())
).scalars().all()
return [{
"version_number": r.version_number,
"status": r.status,
"change_summary": r.change_summary,
"created_at": r.created_at,
"review_summary": r.review_summary,
"submitted_by_user_id": r.submitted_by_user_id
} for r in rows]
def add_review_comment(pack_id: str, version_number: int, reviewer_user_id: int, comment_text: str, disposition: str):
with SessionLocal() as db:
db.add(ReviewCommentORM(
pack_id=pack_id,
version_number=version_number,
reviewer_user_id=reviewer_user_id,
comment_text=comment_text,
disposition=disposition,
created_at=now_iso()
))
db.commit()
def list_review_comments(pack_id: str):
with SessionLocal() as db:
rows = db.execute(
select(ReviewCommentORM).where(ReviewCommentORM.pack_id == pack_id).order_by(ReviewCommentORM.id.desc())
).scalars().all()
return [{
"version_number": r.version_number,
"reviewer_user_id": r.reviewer_user_id,
"comment_text": r.comment_text,
"disposition": r.disposition,
"created_at": r.created_at
} for r in rows]
def create_learner(owner_user_id: int, learner_id: str, display_name: str = ""): def create_learner(owner_user_id: int, learner_id: str, display_name: str = ""):
with SessionLocal() as db: with SessionLocal() as db:
if db.get(LearnerORM, learner_id) is None: if db.get(LearnerORM, learner_id) is None:
db.add(LearnerORM(id=learner_id, owner_user_id=owner_user_id, display_name=display_name)) db.add(LearnerORM(id=learner_id, owner_user_id=owner_user_id, display_name=display_name))
db.commit() db.commit()
def list_learners_for_user(user_id: int, is_admin: bool = False):
with SessionLocal() as db:
stmt = select(LearnerORM).order_by(LearnerORM.id)
if not is_admin:
stmt = stmt.where(LearnerORM.owner_user_id == user_id)
rows = db.execute(stmt).scalars().all()
return [{"learner_id": r.id, "display_name": r.display_name, "owner_user_id": r.owner_user_id} for r in rows]
def learner_owned_by_user(user_id: int, learner_id: str) -> bool: def learner_owned_by_user(user_id: int, learner_id: str) -> bool:
with SessionLocal() as db: with SessionLocal() as db:
learner = db.get(LearnerORM, learner_id) learner = db.get(LearnerORM, learner_id)
return learner is not None and learner.owner_user_id == user_id return learner is not None and learner.owner_user_id == user_id
def load_learner_state(learner_id: str) -> LearnerState: def load_learner_state(learner_id: str):
with SessionLocal() as db: with SessionLocal() as db:
records = db.execute(select(MasteryRecordORM).where(MasteryRecordORM.learner_id == learner_id)).scalars().all() records = db.execute(select(MasteryRecordORM).where(MasteryRecordORM.learner_id == learner_id)).scalars().all()
history = db.execute(select(EvidenceEventORM).where(EvidenceEventORM.learner_id == learner_id)).scalars().all() history = db.execute(select(EvidenceEventORM).where(EvidenceEventORM.learner_id == learner_id)).scalars().all()
@ -96,15 +225,16 @@ def save_learner_state(state: LearnerState):
db.commit() db.commit()
return state return state
def create_evaluator_job(learner_id: str, pack_id: str, concept_id: str, submitted_text: str) -> int: def create_evaluator_job(learner_id: str, pack_id: str, concept_id: str, submitted_text: str):
with SessionLocal() as db: with SessionLocal() as db:
job = EvaluatorJobORM(learner_id=learner_id, pack_id=pack_id, concept_id=concept_id, submitted_text=submitted_text, status="queued") trace = {"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.add(job)
db.commit() db.commit()
db.refresh(job) db.refresh(job)
return job.id return job.id
def list_evaluator_jobs_for_learner(learner_id: str) -> list[EvaluatorJobORM]: def list_evaluator_jobs_for_learner(learner_id: str):
with SessionLocal() as db: with SessionLocal() as db:
return db.execute(select(EvaluatorJobORM).where(EvaluatorJobORM.learner_id == learner_id).order_by(EvaluatorJobORM.id.desc())).scalars().all() return db.execute(select(EvaluatorJobORM).where(EvaluatorJobORM.learner_id == learner_id).order_by(EvaluatorJobORM.id.desc())).scalars().all()
@ -112,7 +242,7 @@ def get_evaluator_job(job_id: int):
with SessionLocal() as db: with SessionLocal() as db:
return db.get(EvaluatorJobORM, job_id) 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 = ""): 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: with SessionLocal() as db:
job = db.get(EvaluatorJobORM, job_id) job = db.get(EvaluatorJobORM, job_id)
if job is None: if job is None:
@ -121,4 +251,6 @@ def update_evaluator_job(job_id: int, status: str, score: float | None = None, c
job.result_score = score job.result_score = score
job.result_confidence_hint = confidence_hint job.result_confidence_hint = confidence_hint
job.result_notes = notes job.result_notes = notes
if trace is not None:
job.trace_json = json.dumps(trace)
db.commit() db.commit()

View File

@ -1,36 +1,32 @@
from __future__ import annotations from __future__ import annotations
import json
from sqlalchemy import select from sqlalchemy import select
from .db import Base, engine, SessionLocal from .db import Base, engine, SessionLocal
from .orm import UserORM, PackORM from .orm import UserORM
from .auth import hash_password from .auth import hash_password
from .repository import upsert_pack
PACKS = [ from .models import PackData, PackConcept, PackCompliance
{
"id": "bayes-pack",
"title": "Bayesian Reasoning",
"subtitle": "Probability, evidence, updating, and model criticism.",
"level": "novice-friendly",
"concepts": [
{"id": "prior", "title": "Prior", "prerequisites": [], "masteryDimension": "mastery", "exerciseReward": "Prior badge earned"},
{"id": "posterior", "title": "Posterior", "prerequisites": ["prior"], "masteryDimension": "mastery", "exerciseReward": "Posterior path opened"},
{"id": "model-checking", "title": "Model Checking", "prerequisites": ["posterior"], "masteryDimension": "mastery", "exerciseReward": "Model-checking unlocked"}
],
"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": {"sources": 2, "attributionRequired": True, "shareAlikeRequired": True, "noncommercialOnly": True, "flags": ["share-alike", "noncommercial", "excluded-third-party-content"]}
}
]
def main(): def main():
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
with SessionLocal() as db: with SessionLocal() as db:
if db.execute(select(UserORM).where(UserORM.username == "wesley")).scalar_one_or_none() is None: if db.execute(select(UserORM).where(UserORM.username == "wesley")).scalar_one_or_none() is None:
db.add(UserORM(username="wesley", password_hash=hash_password("demo-pass"), role="admin", is_active=True)) db.add(UserORM(username="wesley", password_hash=hash_password("demo-pass"), role="admin", is_active=True))
for pack in PACKS:
if db.get(PackORM, pack["id"]) is None:
db.add(PackORM(id=pack["id"], title=pack["title"], subtitle=pack["subtitle"], level=pack["level"], data_json=json.dumps(pack), is_published=True))
db.commit() db.commit()
upsert_pack(
PackData(
id="bayes-pack",
title="Bayesian Reasoning",
subtitle="Probability, evidence, updating, and model criticism.",
level="novice-friendly",
concepts=[
PackConcept(id="prior", title="Prior", prerequisites=[], masteryDimension="mastery", exerciseReward="Prior badge earned"),
PackConcept(id="posterior", title="Posterior", prerequisites=["prior"], masteryDimension="mastery", exerciseReward="Posterior path opened"),
],
onboarding={"headline":"Start with a fast visible win","body":"Read one short orientation, answer one guided question, and leave with your first mastery marker.","checklist":["Read the one-screen topic orientation","Answer one guided exercise","Write one explanation in your own words"]},
compliance=PackCompliance(sources=2, attributionRequired=True, shareAlikeRequired=True, noncommercialOnly=True, flags=["share-alike","noncommercial","excluded-third-party-content"])
),
submitted_by_user_id=1,
is_published=True,
change_summary="Initial seed version"
)
print("Seeded database. Demo user: wesley / demo-pass") print("Seeded database. Demo user: wesley / demo-pass")
if __name__ == "__main__":
main()

View File

@ -8,27 +8,17 @@ def process_job(job_id: int):
job = get_evaluator_job(job_id) job = get_evaluator_job(job_id)
if job is None: if job is None:
return return
update_evaluator_job(job_id, "running") 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 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 confidence_hint = 0.72 if len(job.submitted_text.strip()) > 20 else 0.45
notes = "Prototype evaluator: longer responses scored somewhat higher." notes = "Prototype evaluator: longer responses scored somewhat higher."
update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes) 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 = load_learner_state(job.learner_id)
state = apply_evidence(state, EvidenceEvent( 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}"))
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) save_learner_state(state)
def main(): def main():
print("Didactopus worker scaffold running. In a real deployment this would poll a queue.") print("Didactopus worker scaffold running. Replace this with a real queue worker.")
while True: while True:
time.sleep(60) time.sleep(60)
if __name__ == "__main__":
main()

View File

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

View File

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

View File

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

View File

@ -1,11 +1,354 @@
import React from "react"; import React, { useEffect, useState } from "react";
export default function App() { import { login, refresh, fetchPacks, fetchAdminPacks, fetchPackValidation, fetchPackProvenance, fetchPackVersions, fetchPackComments, upsertPack, publishPack, governanceAction, addReviewComment, listLearners, createLearner, fetchLearnerState, fetchRecommendations, postEvidence, submitEvaluatorJob, fetchEvaluatorHistory, fetchEvaluatorTrace } from "./api";
import { loadAuth, saveAuth, clearAuth } from "./authStore";
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 ( return (
<div className="page"> <div className="page narrow-page">
<div className="card"> <section className="card narrow">
<h1>Didactopus productionization scaffold</h1> <h1>Didactopus login</h1>
<p>This UI scaffold is intended for later connection to evaluator history, learner management, and admin pack management endpoints.</p> <label>Username<input value={username} onChange={(e) => setUsername(e.target.value)} /></label>
</div> <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")}>Governance 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 draft</button></div>
</div>
);
}
export default function App() {
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 [versions, setVersions] = useState([]);
const [comments, setComments] = useState([]);
const [commentText, setCommentText] = useState("Looks structurally plausible.");
const [reviewSummary, setReviewSummary] = useState("Reviewed and ready for next stage.");
const [newLearnerId, setNewLearnerId] = useState("wesley-learner");
const [formPack, setFormPack] = useState({ id: "new-pack", title: "New Pack", subtitle: "Editable governance 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 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(() => {
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") {
setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
}
}
load();
}, [auth]);
useEffect(() => {
if (!auth || !selectedLearnerId || !selectedPackId) return;
async function loadStuff() {
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)));
setVersions(await guarded((token) => fetchPackVersions(token, selectedPackId)));
setComments(await guarded((token) => fetchPackComments(token, selectedPackId)));
}
}
loadStuff();
}, [auth, selectedLearnerId, selectedPackId]);
async function simulateCard(card) {
await guarded((token) => postEvidence(token, selectedLearnerId, { concept_id: card.conceptId, dimension: "mastery", score: card.scoreHint, confidence_hint: card.confidenceHint, timestamp: new Date().toISOString(), kind: "checkpoint", source_id: `ui-${card.id}` }));
setLearnerState(await guarded((token) => fetchLearnerState(token, selectedLearnerId)));
const recs = await guarded((token) => fetchRecommendations(token, selectedLearnerId, selectedPackId));
setCards(recs.cards || []);
setMessage(card.reward);
}
async function 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);
}
async function createLearnerNow() {
await guarded((token) => createLearner(token, newLearnerId, newLearnerId));
const ls = await guarded((token) => listLearners(token));
setLearners(ls);
setSelectedLearnerId(newLearnerId);
}
async function savePack() {
await guarded((token) => upsertPack(token, { pack: formPack, is_published: false, change_summary: "Submitted from form editor" }));
setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
setPacks(await guarded((token) => fetchPacks(token)));
setMessage("Draft saved");
}
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 doGovernance(status) {
await guarded((token) => governanceAction(token, selectedPackId, { status, review_summary: reviewSummary }));
setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
setVersions(await guarded((token) => fetchPackVersions(token, selectedPackId)));
setMessage(`Pack moved to ${status}`);
}
async function addCommentNow() {
const versionNumber = versions[0]?.version_number || 1;
await guarded((token) => addReviewComment(token, selectedPackId, versionNumber, { comment_text: commentText, disposition: "comment" }));
setComments(await guarded((token) => fetchPackComments(token, selectedPackId)));
setMessage("Review comment added");
}
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 review governance layer</h1>
<p>Versioning, review comments, governance states, evaluator traces, and curator-facing pack workflows.</p>
<div className="muted">Signed in as {auth.username} ({auth.role})</div>
{message ? <div className="message">{message}</div> : null}
</div>
<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>
<NavTabs tab={tab} setTab={setTab} role={auth.role} />
{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>
)}
{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>
)}
{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>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>Version</th><th>State</th><th>Published</th><th>Action</th></tr></thead>
<tbody>
{adminPacks.map((row) => (
<tr key={row.id}>
<td>{row.id}</td>
<td>{row.current_version}</td>
<td>{row.governance_state}</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>Governance review</h2>
<div className="button-row">
<button onClick={() => doGovernance("in_review")}>Move to in_review</button>
<button onClick={() => doGovernance("approved")}>Approve</button>
<button onClick={() => doGovernance("rejected")}>Reject</button>
</div>
<label>Review summary<textarea value={reviewSummary} onChange={(e) => setReviewSummary(e.target.value)} /></label>
<h3>Validation</h3>
<pre className="prebox">{JSON.stringify(validation, null, 2)}</pre>
<h3>Provenance</h3>
<pre className="prebox">{JSON.stringify(provenance, null, 2)}</pre>
</section>
<section className="card">
<h2>Versions and comments</h2>
<h3>Pack versions</h3>
<pre className="prebox">{JSON.stringify(versions, null, 2)}</pre>
<label>Reviewer comment<textarea value={commentText} onChange={(e) => setCommentText(e.target.value)} /></label>
<button className="primary" onClick={addCommentNow}>Add comment</button>
<h3>Review comments</h3>
<pre className="prebox">{JSON.stringify(comments, null, 2)}</pre>
</section>
</main>
)}
</div> </div>
); );
} }

View File

@ -11,13 +11,28 @@ export async function login(username, password) {
if (!res.ok) throw new Error("login failed"); if (!res.ok) throw new Error("login failed");
return await res.json(); return await res.json();
} }
export async function refresh(refreshToken) { export async function refresh(refreshToken) {
const res = await fetch(`${API}/refresh`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: 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"); if (!res.ok) throw new Error("refresh failed");
return await res.json(); 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 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 fetchPackVersions(token, packId) { const res = await fetch(`${API}/admin/packs/${packId}/versions`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPackVersions failed"); return await res.json(); }
export async function fetchPackComments(token, packId) { const res = await fetch(`${API}/admin/packs/${packId}/comments`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchPackComments 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 governanceAction(token, packId, payload) { const res = await fetch(`${API}/admin/packs/${packId}/governance`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("governanceAction failed"); return await res.json(); }
export async function addReviewComment(token, packId, versionNumber, payload) { const res = await fetch(`${API}/admin/packs/${packId}/comments?version_number=${versionNumber}`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("addReviewComment 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 fetchLearnerState(token, learnerId) { const res = await fetch(`${API}/learners/${learnerId}/state`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchLearnerState failed"); return await res.json(); }
export async function putLearnerState(token, learnerId, state) { const res = await fetch(`${API}/learners/${learnerId}/state`, { method: "PUT", headers: authHeaders(token), body: JSON.stringify(state) }); if (!res.ok) throw new Error("putLearnerState failed"); return await res.json(); } export async function 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 fetchGraphAnimation(token, learnerId, packId) { const res = await fetch(`${API}/learners/${learnerId}/graph-animation/${packId}`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchGraphAnimation failed"); return await res.json(); } 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 createRenderBundle(token, learnerId, packId, payload) { const res = await fetch(`${API}/learners/${learnerId}/render-bundle/${packId}`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("createRenderBundle 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(); }

View File

@ -1,3 +1,38 @@
body { margin:0; font-family:Arial, Helvetica, sans-serif; background:#f6f8fb; color:#1f2430; } :root {
.page { max-width: 1100px; margin: 0 auto; padding: 24px; } --bg:#f6f8fb; --card:#ffffff; --text:#1f2430; --muted:#60697a; --border:#dbe1ea; --accent:#2d6cdf; --soft:#eef4ff;
.card { background:white; border:1px solid #dbe1ea; border-radius:18px; padding:20px; } }
* { 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:320px; }
.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; }
.button-row { display:flex; gap:10px; flex-wrap:wrap; margin-bottom:12px; }
details summary { cursor:pointer; color:var(--accent); }
@media (max-width:1100px) {
.hero { flex-direction:column; }
.twocol, .form-grid { grid-template-columns:1fr; }
}