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]
didactopus-api = "didactopus.api:main"
didactopus-worker = "didactopus.worker:main"
[tool.setuptools.packages.find]
where = ["src"]

View File

@ -1,15 +1,12 @@
from __future__ import annotations
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, 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, 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 .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest, GovernanceAction, ReviewCommentCreate
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
from .engine import apply_evidence, recommend_next
from .auth import issue_access_token, issue_refresh_token, decode_token, new_token_id
from .worker import process_job
@ -18,13 +15,7 @@ settings = load_settings()
Base.metadata.create_all(bind=engine)
app = FastAPI(title="Didactopus API Prototype")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
def current_user(authorization: str = Header(default="")):
token = authorization.removeprefix("Bearer ").strip()
@ -54,12 +45,7 @@ def login(payload: LoginRequest):
raise HTTPException(status_code=401, detail="Invalid credentials")
token_id = new_token_id()
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,
)
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):
@ -75,35 +61,65 @@ def refresh(payload: RefreshRequest):
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,
)
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)):
include_unpublished = user.role == "admin"
return [p.model_dump() for p in list_packs(include_unpublished=include_unpublished)]
return [p.model_dump() for p in list_packs(include_unpublished=(user.role == "admin"))]
@app.get("/api/packs/{pack_id}")
def api_get_pack(pack_id: str, user = Depends(current_user)):
pack = get_pack(pack_id)
if pack is None:
raise HTTPException(status_code=404, detail="Pack not found")
return pack.model_dump()
@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.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")
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}
@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")
def api_create_learner(payload: CreateLearnerRequest, user = Depends(current_user)):
create_learner(user.id, payload.learner_id, payload.display_name)
return {"ok": True, "learner_id": payload.learner_id}
@app.get("/api/learners")
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")
def api_get_learner_state(learner_id: str, user = Depends(current_user)):
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")
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")
def api_get_evaluator_history(learner_id: str, user = Depends(current_user)):
ensure_learner_access(user, learner_id)
@ -155,6 +178,3 @@ def api_get_evaluator_history(learner_id: str, user = Depends(current_user)):
def main():
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)
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:
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:
try:

View File

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

View File

@ -41,6 +41,23 @@ class PackData(BaseModel):
onboarding: dict = Field(default_factory=dict)
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):
concept_id: str
dimension: str
@ -63,10 +80,6 @@ class LearnerState(BaseModel):
records: list[MasteryRecord] = Field(default_factory=list)
history: list[EvidenceEvent] = Field(default_factory=list)
class CreateLearnerRequest(BaseModel):
learner_id: str
display_name: str = ""
class EvaluatorSubmission(BaseModel):
pack_id: str
concept_id: str
@ -79,7 +92,3 @@ class EvaluatorJobStatus(BaseModel):
result_score: float | None = None
result_confidence_hint: float | None = None
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.orm import Mapped, mapped_column, relationship
from sqlalchemy.orm import Mapped, mapped_column
from .db import Base
class UserORM(Base):
@ -24,7 +24,33 @@ class PackORM(Base):
subtitle: Mapped[str] = mapped_column(Text, default="")
level: Mapped[str] = mapped_column(String(100), default="novice-friendly")
data_json: Mapped[str] = mapped_column(Text)
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):
__tablename__ = "learners"
@ -66,3 +92,4 @@ class EvaluatorJobORM(Base):
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="{}")

View File

@ -1,11 +1,15 @@
from __future__ import annotations
import json
from sqlalchemy import select
from datetime import datetime, timezone
from sqlalchemy import select, func
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 .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()
@ -37,7 +41,7 @@ def revoke_refresh_token(token_id: str):
row.is_revoked = True
db.commit()
def list_packs(include_unpublished: bool = False) -> list[PackData]:
def list_packs(include_unpublished: bool = False):
with SessionLocal() as db:
stmt = select(PackORM)
if not include_unpublished:
@ -45,24 +49,141 @@ def list_packs(include_unpublished: bool = False) -> list[PackData]:
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, "governance_state": r.governance_state, "current_version": r.current_version} for r in rows]
def get_pack(pack_id: str):
with SessionLocal() as db:
row = db.get(PackORM, pack_id)
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:
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, 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:
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
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()
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 = ""):
with SessionLocal() as db:
@ -70,12 +191,20 @@ 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 load_learner_state(learner_id: str) -> LearnerState:
def load_learner_state(learner_id: str):
with SessionLocal() as db:
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()
@ -96,15 +225,16 @@ def save_learner_state(state: LearnerState):
db.commit()
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:
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.commit()
db.refresh(job)
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:
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:
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:
job = db.get(EvaluatorJobORM, job_id)
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_confidence_hint = confidence_hint
job.result_notes = notes
if trace is not None:
job.trace_json = json.dumps(trace)
db.commit()

View File

@ -1,36 +1,32 @@
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
PACKS = [
{
"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"]}
}
]
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))
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()
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")
if __name__ == "__main__":
main()

View File

@ -8,27 +8,17 @@ def process_job(job_id: int):
job = get_evaluator_job(job_id)
if job is None:
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
confidence_hint = 0.72 if len(job.submitted_text.strip()) > 20 else 0.45
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 = 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}",
))
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 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:
time.sleep(60)
if __name__ == "__main__":
main()

View File

@ -3,6 +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/export_svg.py").exists()
assert Path("src/didactopus/render_bundle.py").exists()
assert Path("webui/src/App.jsx").exists()
assert Path("webui/src/api.js").exists()

View File

@ -3,10 +3,8 @@
<head>
<meta charset="UTF-8" />
<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>
</head>
<body>
<div id="root"></div>
</body>
<body><div id="root"></div></body>
</html>

View File

@ -1,17 +1,9 @@
{
"name": "didactopus-production-ui",
"name": "didactopus-governance-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" }
}

View File

@ -1,11 +1,354 @@
import React from "react";
export default function App() {
import React, { useEffect, useState } from "react";
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 (
<div className="page">
<div className="card">
<h1>Didactopus productionization scaffold</h1>
<p>This UI scaffold is intended for later connection to evaluator history, learner management, and admin pack management endpoints.</p>
</div>
<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")}>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>
);
}

View File

@ -11,13 +11,28 @@ export async function login(username, password) {
if (!res.ok) throw new Error("login failed");
return await res.json();
}
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 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 putLearnerState(token, learnerId, state) { const res = await fetch(`${API}/learners/${learnerId}/state`, { method: "PUT", headers: authHeaders(token), body: JSON.stringify(state) }); if (!res.ok) throw new Error("putLearnerState failed"); return await res.json(); }
export async function fetchGraphAnimation(token, learnerId, packId) { const res = await fetch(`${API}/learners/${learnerId}/graph-animation/${packId}`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchGraphAnimation failed"); return await res.json(); }
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 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(); }

View File

@ -1,3 +1,38 @@
body { margin:0; font-family:Arial, Helvetica, sans-serif; background:#f6f8fb; color:#1f2430; }
.page { max-width: 1100px; margin: 0 auto; padding: 24px; }
.card { background:white; border:1px solid #dbe1ea; border-radius:18px; padding:20px; }
:root {
--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: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; }
}