Apply ZIP update: 180-didactopus-contribution-management-layer.zip [2026-03-14T13:20:17]

This commit is contained in:
welsberr 2026-03-14 13:29:55 -04:00
parent efafa2f289
commit 3fa217c5bc
18 changed files with 917 additions and 497 deletions

View File

@ -10,7 +10,11 @@ dependencies = [
"pydantic>=2.7",
"pyyaml>=6.0",
"fastapi>=0.115",
"uvicorn>=0.30"
"uvicorn>=0.30",
"sqlalchemy>=2.0",
"psycopg[binary]>=3.1",
"passlib[bcrypt]>=1.7",
"python-jose[cryptography]>=3.3"
]
[project.scripts]

View File

@ -1,62 +1,208 @@
from __future__ import annotations
from pathlib import Path
from fastapi import FastAPI, HTTPException
import json
from fastapi import FastAPI, HTTPException, Header, Depends, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
from .models import LearnerState, EvidenceEvent
from .storage import FileStorage
from .config import load_settings
from .db import Base, engine
from .models import LoginRequest, RefreshRequest, TokenPair, CreateLearnerRequest, LearnerState, EvidenceEvent, EvaluatorSubmission, EvaluatorJobStatus, CreatePackRequest, GovernanceAction, ReviewCommentCreate, ContributionSubmissionCreate
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, create_submission, list_submissions, get_submission_diff, get_submission_gates, list_review_tasks,
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
BASE_DIR = Path(__file__).resolve().parents[2] / "data"
storage = FileStorage(BASE_DIR)
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()
payload = decode_token(token) if token else None
if not payload or payload.get("kind") != "access":
raise HTTPException(status_code=401, detail="Unauthorized")
user = get_user_by_id(int(payload["sub"]))
if user is None or not user.is_active:
raise HTTPException(status_code=401, detail="Unauthorized")
return user
def require_admin(user = Depends(current_user)):
if user.role != "admin":
raise HTTPException(status_code=403, detail="Admin role required")
return user
def ensure_learner_access(user, learner_id: str):
if user.role == "admin":
return
if not learner_owned_by_user(user.id, learner_id):
raise HTTPException(status_code=403, detail="Learner not accessible by this user")
@app.post("/api/login", response_model=TokenPair)
def login(payload: LoginRequest):
user = authenticate_user(payload.username, payload.password)
if user is None:
raise HTTPException(status_code=401, detail="Invalid credentials")
token_id = new_token_id()
store_refresh_token(user.id, token_id)
return TokenPair(access_token=issue_access_token(user.id, user.username, user.role), refresh_token=issue_refresh_token(user.id, user.username, user.role, token_id), username=user.username, role=user.role)
@app.post("/api/refresh", response_model=TokenPair)
def refresh(payload: RefreshRequest):
data = decode_token(payload.refresh_token)
if not data or data.get("kind") != "refresh":
raise HTTPException(status_code=401, detail="Invalid refresh token")
token_id = data.get("jti")
if not token_id or not refresh_token_active(token_id):
raise HTTPException(status_code=401, detail="Refresh token inactive")
user = get_user_by_id(int(data["sub"]))
if user is None:
raise HTTPException(status_code=401, detail="User not found")
revoke_refresh_token(token_id)
new_jti = new_token_id()
store_refresh_token(user.id, new_jti)
return TokenPair(access_token=issue_access_token(user.id, user.username, user.role), refresh_token=issue_refresh_token(user.id, user.username, user.role, new_jti), username=user.username, role=user.role)
@app.get("/api/packs")
def list_packs():
return [p.model_dump() for p in storage.list_packs()]
def api_list_packs(user = Depends(current_user)):
return [p.model_dump() for p in list_packs(include_unpublished=(user.role == "admin"))]
@app.get("/api/packs/{pack_id}")
def get_pack(pack_id: str):
pack = storage.get_pack(pack_id)
if pack is None:
@app.post("/api/contributions")
def api_create_contribution(payload: ContributionSubmissionCreate, user = Depends(current_user)):
submission_id = create_submission(payload.pack, user.id, payload.submission_summary)
return {"ok": True, "submission_id": submission_id}
@app.get("/api/admin/submissions")
def api_admin_submissions(user = Depends(require_admin)):
return list_submissions()
@app.get("/api/admin/submissions/{submission_id}/diff")
def api_admin_submission_diff(submission_id: int, user = Depends(require_admin)):
return get_submission_diff(submission_id)
@app.get("/api/admin/submissions/{submission_id}/gates")
def api_admin_submission_gates(submission_id: int, user = Depends(require_admin)):
return get_submission_gates(submission_id)
@app.get("/api/admin/review-tasks")
def api_admin_review_tasks(user = Depends(require_admin)):
return list_review_tasks()
@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, 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 pack.model_dump()
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 get_learner_state(learner_id: str):
return storage.get_learner_state(learner_id).model_dump()
def api_get_learner_state(learner_id: str, user = Depends(current_user)):
ensure_learner_access(user, learner_id)
return load_learner_state(learner_id).model_dump()
@app.put("/api/learners/{learner_id}/state")
def put_learner_state(learner_id: str, state: LearnerState):
def api_put_learner_state(learner_id: str, state: LearnerState, user = Depends(current_user)):
ensure_learner_access(user, learner_id)
if learner_id != state.learner_id:
raise HTTPException(status_code=400, detail="Learner ID mismatch")
return storage.save_learner_state(state).model_dump()
return save_learner_state(state).model_dump()
@app.post("/api/learners/{learner_id}/evidence")
def post_evidence(learner_id: str, event: EvidenceEvent):
state = storage.get_learner_state(learner_id)
def api_post_evidence(learner_id: str, event: EvidenceEvent, user = Depends(current_user)):
ensure_learner_access(user, learner_id)
state = load_learner_state(learner_id)
state = apply_evidence(state, event)
storage.save_learner_state(state)
save_learner_state(state)
return state.model_dump()
@app.get("/api/learners/{learner_id}/recommendations/{pack_id}")
def get_recommendations(learner_id: str, pack_id: str):
state = storage.get_learner_state(learner_id)
pack = storage.get_pack(pack_id)
def api_get_recommendations(learner_id: str, pack_id: str, user = Depends(current_user)):
ensure_learner_access(user, learner_id)
state = load_learner_state(learner_id)
pack = get_pack(pack_id)
if pack is None:
raise HTTPException(status_code=404, detail="Pack not found")
return {"cards": recommend_next(state, pack)}
def main():
uvicorn.run(app, host="127.0.0.1", port=8011)
@app.post("/api/learners/{learner_id}/evaluator-jobs", response_model=EvaluatorJobStatus)
def api_submit_evaluator_job(learner_id: str, payload: EvaluatorSubmission, background_tasks: BackgroundTasks, user = Depends(current_user)):
ensure_learner_access(user, learner_id)
job_id = create_evaluator_job(learner_id, payload.pack_id, payload.concept_id, payload.submitted_text)
background_tasks.add_task(process_job, job_id)
return EvaluatorJobStatus(job_id=job_id, status="queued")
if __name__ == "__main__":
main()
@app.get("/api/evaluator-jobs/{job_id}", response_model=EvaluatorJobStatus)
def api_get_evaluator_job(job_id: int, user = Depends(current_user)):
job = get_evaluator_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job not found")
return EvaluatorJobStatus(job_id=job.id, status=job.status, result_score=job.result_score, result_confidence_hint=job.result_confidence_hint, result_notes=job.result_notes)
@app.get("/api/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)
jobs = list_evaluator_jobs_for_learner(learner_id)
return [{"job_id": j.id, "status": j.status, "concept_id": j.concept_id, "result_score": j.result_score, "result_confidence_hint": j.result_confidence_hint, "result_notes": j.result_notes} for j in jobs]
def main():
uvicorn.run(app, host=settings.host, port=settings.port)

View File

@ -1,8 +1,12 @@
from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from jose import jwt, JWTError
from passlib.context import CryptContext
import secrets
from .config import load_settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
settings = load_settings()
def hash_password(password: str) -> str:
return pwd_context.hash(password)
@ -10,5 +14,22 @@ def hash_password(password: str) -> str:
def verify_password(password: str, password_hash: str) -> bool:
return pwd_context.verify(password, password_hash)
def issue_token() -> str:
def _encode_token(payload: dict, expires_delta: timedelta) -> str:
to_encode = dict(payload)
to_encode["exp"] = datetime.now(timezone.utc) + expires_delta
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=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=14))
def decode_token(token: str) -> dict | None:
try:
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
except JWTError:
return None
def new_token_id() -> str:
return secrets.token_urlsafe(24)

View File

@ -1,10 +1,13 @@
from pathlib import Path
from __future__ import annotations
import os
from pydantic import BaseModel
class Settings(BaseModel):
database_url: str = "sqlite:///./didactopus.db"
host: str = "127.0.0.1"
port: int = 8011
database_url: str = os.getenv("DIDACTOPUS_DATABASE_URL", "postgresql+psycopg://didactopus:didactopus-dev-password@localhost:5432/didactopus")
host: str = os.getenv("DIDACTOPUS_HOST", "127.0.0.1")
port: int = int(os.getenv("DIDACTOPUS_PORT", "8011"))
jwt_secret: str = os.getenv("DIDACTOPUS_JWT_SECRET", "change-me")
jwt_algorithm: str = "HS256"
def load_settings() -> Settings:
return Settings()

View File

@ -3,7 +3,6 @@ from sqlalchemy.orm import declarative_base, sessionmaker
from .config import load_settings
settings = load_settings()
connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {}
engine = create_engine(settings.database_url, future=True, connect_args=connect_args)
engine = create_engine(settings.database_url, future=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
Base = declarative_base()

View File

@ -7,30 +7,14 @@ def get_record(state: LearnerState, concept_id: str, dimension: str = "mastery")
return rec
return None
def apply_evidence(
state: LearnerState,
event: EvidenceEvent,
decay: float = 0.05,
reinforcement: float = 0.25,
) -> LearnerState:
def apply_evidence(state: LearnerState, event: EvidenceEvent, decay: float = 0.05, reinforcement: float = 0.25) -> LearnerState:
rec = get_record(state, event.concept_id, event.dimension)
if rec is None:
rec = MasteryRecord(
concept_id=event.concept_id,
dimension=event.dimension,
score=0.0,
confidence=0.0,
evidence_count=0,
last_updated=event.timestamp,
)
rec = MasteryRecord(concept_id=event.concept_id, dimension=event.dimension, score=0.0, confidence=0.0, evidence_count=0, last_updated=event.timestamp)
state.records.append(rec)
weight = max(0.05, min(1.0, event.confidence_hint))
rec.score = ((rec.score * rec.evidence_count) + (event.score * weight)) / max(1, rec.evidence_count + 1)
rec.confidence = min(
1.0,
max(0.0, rec.confidence * (1.0 - decay) + reinforcement * weight + 0.10 * max(0.0, min(1.0, event.score))),
)
rec.confidence = min(1.0, max(0.0, rec.confidence * (1.0 - decay) + reinforcement * weight + 0.10 * max(0.0, min(1.0, event.score))))
rec.evidence_count += 1
rec.last_updated = event.timestamp
state.history.append(event)
@ -61,11 +45,7 @@ def recommend_next(state: LearnerState, pack: PackData) -> list[dict]:
"id": concept.id,
"title": f"Work on {concept.title}",
"minutes": 15 if status == "available" else 10,
"reason": (
"Prerequisites are satisfied, so this is the best next unlock."
if status == "available"
else "You have started this concept, but mastery is not yet secure."
),
"reason": "Prerequisites are satisfied, so this is the best next unlock." if status == "available" else "You have started this concept, but mastery is not yet secure.",
"why": [
"Prerequisite check passed",
f"Current score: {rec.score:.2f}" if rec else "No evidence recorded yet",
@ -76,22 +56,4 @@ def recommend_next(state: LearnerState, pack: PackData) -> list[dict]:
"scoreHint": 0.82 if status == "available" else 0.76,
"confidenceHint": 0.72 if status == "available" else 0.55,
})
for rec in state.records:
if rec.dimension == "mastery" and rec.confidence < 0.40:
concept = next((c for c in pack.concepts if c.id == rec.concept_id), None)
if concept:
cards.append({
"id": f"{concept.id}-reinforce",
"title": f"Reinforce {concept.title}",
"minutes": 8,
"reason": "Your score is promising, but confidence is still thin.",
"why": [
f"Confidence {rec.confidence:.2f} is below reinforcement threshold",
"A small fresh exercise can stabilize recall",
],
"reward": "Confidence ring grows",
"conceptId": concept.id,
"scoreHint": max(0.60, rec.score),
"confidenceHint": 0.30,
})
return cards[:4]

View File

@ -4,6 +4,20 @@ from typing import Literal
EvidenceKind = Literal["checkpoint", "project", "exercise", "review"]
class TokenPair(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
username: str
role: str
class LoginRequest(BaseModel):
username: str
password: str
class RefreshRequest(BaseModel):
refresh_token: str
class PackConcept(BaseModel):
id: str
title: str
@ -27,6 +41,27 @@ 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 ContributionSubmissionCreate(BaseModel):
pack: PackData
submission_summary: str = ""
class CreateLearnerRequest(BaseModel):
learner_id: str
display_name: str = ""
class MasteryRecord(BaseModel):
concept_id: str
dimension: str
@ -48,3 +83,16 @@ class LearnerState(BaseModel):
learner_id: str
records: list[MasteryRecord] = Field(default_factory=list)
history: list[EvidenceEvent] = Field(default_factory=list)
class EvaluatorSubmission(BaseModel):
pack_id: str
concept_id: str
submitted_text: str
kind: str = "checkpoint"
class EvaluatorJobStatus(BaseModel):
job_id: int
status: str
result_score: float | None = None
result_confidence_hint: float | None = None
result_notes: str = ""

View File

@ -1,5 +1,5 @@
from sqlalchemy import String, Integer, Float, Boolean, ForeignKey, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import String, Integer, Float, ForeignKey, Text, Boolean
from sqlalchemy.orm import Mapped, mapped_column
from .db import Base
class UserORM(Base):
@ -7,7 +7,15 @@ class UserORM(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(100), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
token: Mapped[str] = mapped_column(String(255), unique=True, index=True)
role: Mapped[str] = mapped_column(String(50), default="learner")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
class RefreshTokenORM(Base):
__tablename__ = "refresh_tokens"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
token_id: Mapped[str] = mapped_column(String(255), unique=True, index=True)
is_revoked: Mapped[bool] = mapped_column(Boolean, default=False)
class PackORM(Base):
__tablename__ = "packs"
@ -16,13 +24,61 @@ 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)
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 ContributionSubmissionORM(Base):
__tablename__ = "contribution_submissions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
pack_id: Mapped[str] = mapped_column(String(100), index=True)
proposed_version_number: Mapped[int] = mapped_column(Integer, default=1)
contributor_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
status: Mapped[str] = mapped_column(String(50), default="submitted")
submission_summary: Mapped[str] = mapped_column(Text, default="")
proposed_data_json: Mapped[str] = mapped_column(Text, default="{}")
diff_json: Mapped[str] = mapped_column(Text, default="{}")
gate_json: Mapped[str] = mapped_column(Text, default="{}")
created_at: Mapped[str] = mapped_column(String(100), default="")
class ReviewTaskORM(Base):
__tablename__ = "review_tasks"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
submission_id: Mapped[int] = mapped_column(ForeignKey("contribution_submissions.id"), index=True)
reviewer_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True)
task_status: Mapped[str] = mapped_column(String(50), default="open")
task_note: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[str] = mapped_column(String(100), default="")
class LearnerORM(Base):
__tablename__ = "learners"
id: Mapped[str] = mapped_column(String(100), primary_key=True)
owner_user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
display_name: Mapped[str] = mapped_column(String(255), default="")
owner = relationship("UserORM")
class MasteryRecordORM(Base):
__tablename__ = "mastery_records"
@ -34,7 +90,6 @@ class MasteryRecordORM(Base):
confidence: Mapped[float] = mapped_column(Float, default=0.0)
evidence_count: Mapped[int] = mapped_column(Integer, default=0)
last_updated: Mapped[str] = mapped_column(String(100), default="")
learner = relationship("LearnerORM")
class EvidenceEventORM(Base):
__tablename__ = "evidence_events"
@ -47,7 +102,6 @@ class EvidenceEventORM(Base):
timestamp: Mapped[str] = mapped_column(String(100), default="")
kind: Mapped[str] = mapped_column(String(50), default="exercise")
source_id: Mapped[str] = mapped_column(String(255), default="")
learner = relationship("LearnerORM")
class EvaluatorJobORM(Base):
__tablename__ = "evaluator_jobs"
@ -60,5 +114,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="")
learner = relationship("LearnerORM")
pack = relationship("PackORM")
trace_json: Mapped[str] = mapped_column(Text, default="{}")

View File

@ -1,124 +1,341 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from sqlalchemy import select
from .db import SessionLocal
from .orm import UserORM, PackORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM
from .orm import UserORM, RefreshTokenORM, PackORM, PackVersionORM, ReviewCommentORM, ContributionSubmissionORM, ReviewTaskORM, LearnerORM, MasteryRecordORM, EvidenceEventORM, EvaluatorJobORM
from .models import PackData, LearnerState, MasteryRecord, EvidenceEvent
from .auth import verify_password
def get_user_by_token(token: str) -> UserORM | None:
with SessionLocal() as db:
return db.execute(select(UserORM).where(UserORM.token == token)).scalar_one_or_none()
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def authenticate_user(username: str, password: str) -> UserORM | None:
def pack_diff(old_pack: dict | None, new_pack: dict) -> dict:
old_pack = old_pack or {}
old_concepts = {c.get("id"): c for c in old_pack.get("concepts", [])}
new_concepts = {c.get("id"): c for c in new_pack.get("concepts", [])}
added = sorted([cid for cid in new_concepts if cid not in old_concepts])
removed = sorted([cid for cid in old_concepts if cid not in new_concepts])
changed = sorted([cid for cid in new_concepts if cid in old_concepts and new_concepts[cid] != old_concepts[cid]])
return {
"title_changed": old_pack.get("title") != new_pack.get("title"),
"subtitle_changed": old_pack.get("subtitle") != new_pack.get("subtitle"),
"concepts_added": added,
"concepts_removed": removed,
"concepts_changed": changed,
"onboarding_changed": old_pack.get("onboarding") != new_pack.get("onboarding"),
"compliance_changed": old_pack.get("compliance") != new_pack.get("compliance"),
}
def gate_summary(validation: dict, provenance: dict) -> dict:
warnings = list(validation.get("warnings", []) or [])
errors = list(validation.get("errors", []) or [])
restrictive_flags = list(provenance.get("restrictive_flags", []) or [])
qa_ok = validation.get("ok", False) and len(errors) == 0
provenance_ok = provenance.get("source_count", 0) >= 0
ready_for_review = qa_ok and provenance_ok
return {
"qa_ok": qa_ok,
"provenance_ok": provenance_ok,
"ready_for_review": ready_for_review,
"warnings": warnings,
"errors": errors,
"restrictive_flags": restrictive_flags,
}
def get_user_by_username(username: str):
with SessionLocal() as db:
user = db.execute(select(UserORM).where(UserORM.username == username)).scalar_one_or_none()
if user is None:
return None
if not verify_password(password, user.password_hash):
return db.execute(select(UserORM).where(UserORM.username == username)).scalar_one_or_none()
def get_user_by_id(user_id: int):
with SessionLocal() as db:
return db.get(UserORM, user_id)
def authenticate_user(username: str, password: str):
user = get_user_by_username(username)
if user is None or not verify_password(password, user.password_hash) or not user.is_active:
return None
return user
def list_packs() -> list[PackData]:
def store_refresh_token(user_id: int, token_id: str):
with SessionLocal() as db:
rows = db.execute(select(PackORM)).scalars().all()
db.add(RefreshTokenORM(user_id=user_id, token_id=token_id, is_revoked=False))
db.commit()
def refresh_token_active(token_id: str) -> bool:
with SessionLocal() as db:
row = db.execute(select(RefreshTokenORM).where(RefreshTokenORM.token_id == token_id)).scalar_one_or_none()
return row is not None and not row.is_revoked
def revoke_refresh_token(token_id: str):
with SessionLocal() as db:
row = db.execute(select(RefreshTokenORM).where(RefreshTokenORM.token_id == token_id)).scalar_one_or_none()
if row:
row.is_revoked = True
db.commit()
def list_packs(include_unpublished: bool = False):
with SessionLocal() as db:
stmt = select(PackORM)
if not include_unpublished:
stmt = stmt.where(PackORM.is_published == True)
rows = db.execute(stmt).scalars().all()
return [PackData.model_validate(json.loads(r.data_json)) for r in rows]
def get_pack(pack_id: str) -> PackData | None:
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 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 validation_and_provenance_for_pack(pack: PackData):
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 []
}
return validation, provenance
def upsert_pack(pack: PackData, submitted_by_user_id: int, is_published: bool = False, change_summary: str = ""):
validation, provenance = validation_and_provenance_for_pack(pack)
with SessionLocal() as db:
row = db.get(PackORM, pack.id)
payload = json.dumps(pack.model_dump())
if row is None:
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 create_submission(pack: PackData, contributor_user_id: int, submission_summary: str):
validation, provenance = validation_and_provenance_for_pack(pack)
with SessionLocal() as db:
current_pack = db.get(PackORM, pack.id)
current_payload = json.loads(current_pack.data_json) if current_pack is not None else None
current_version = current_pack.current_version if current_pack is not None else 0
proposed_version = current_version + 1
proposed_payload = pack.model_dump()
diff = pack_diff(current_payload, proposed_payload)
gates = gate_summary(validation, provenance)
sub = ContributionSubmissionORM(
pack_id=pack.id,
proposed_version_number=proposed_version,
contributor_user_id=contributor_user_id,
status="submitted",
submission_summary=submission_summary,
proposed_data_json=json.dumps(proposed_payload),
diff_json=json.dumps(diff),
gate_json=json.dumps(gates),
created_at=now_iso(),
)
db.add(sub)
db.flush()
db.add(ReviewTaskORM(
submission_id=sub.id,
reviewer_user_id=None,
task_status="open",
task_note="Submission awaiting reviewer attention",
created_at=now_iso(),
))
db.commit()
return sub.id
def list_submissions():
with SessionLocal() as db:
rows = db.execute(select(ContributionSubmissionORM).order_by(ContributionSubmissionORM.id.desc())).scalars().all()
return [{
"submission_id": r.id,
"pack_id": r.pack_id,
"proposed_version_number": r.proposed_version_number,
"contributor_user_id": r.contributor_user_id,
"status": r.status,
"submission_summary": r.submission_summary,
"created_at": r.created_at,
} for r in rows]
def get_submission(submission_id: int):
with SessionLocal() as db:
return db.get(ContributionSubmissionORM, submission_id)
def get_submission_diff(submission_id: int):
with SessionLocal() as db:
row = db.get(ContributionSubmissionORM, submission_id)
return {} if row is None else json.loads(row.diff_json or "{}")
def get_submission_gates(submission_id: int):
with SessionLocal() as db:
row = db.get(ContributionSubmissionORM, submission_id)
return {} if row is None else json.loads(row.gate_json or "{}")
def list_review_tasks():
with SessionLocal() as db:
rows = db.execute(select(ReviewTaskORM).order_by(ReviewTaskORM.id.desc())).scalars().all()
return [{
"task_id": r.id,
"submission_id": r.submission_id,
"reviewer_user_id": r.reviewer_user_id,
"task_status": r.task_status,
"task_note": r.task_note,
"created_at": r.created_at,
} for r in rows]
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 None
return PackData.model_validate(json.loads(row.data_json))
return False
row.is_published = is_published
db.commit()
return True
def create_learner(owner_user_id: int, learner_id: str, display_name: str = "") -> None:
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:
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.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()
return LearnerState(
learner_id=learner_id,
records=[
MasteryRecord(
concept_id=r.concept_id,
dimension=r.dimension,
score=r.score,
confidence=r.confidence,
evidence_count=r.evidence_count,
last_updated=r.last_updated,
) for r in records
],
history=[
EvidenceEvent(
concept_id=h.concept_id,
dimension=h.dimension,
score=h.score,
confidence_hint=h.confidence_hint,
timestamp=h.timestamp,
kind=h.kind,
source_id=h.source_id,
) for h in history
]
records=[MasteryRecord(concept_id=r.concept_id, dimension=r.dimension, score=r.score, confidence=r.confidence, evidence_count=r.evidence_count, last_updated=r.last_updated) for r in records],
history=[EvidenceEvent(concept_id=h.concept_id, dimension=h.dimension, score=h.score, confidence_hint=h.confidence_hint, timestamp=h.timestamp, kind=h.kind, source_id=h.source_id) for h in history],
)
def save_learner_state(state: LearnerState) -> LearnerState:
def save_learner_state(state: LearnerState):
with SessionLocal() as db:
db.execute(select(LearnerORM).where(LearnerORM.id == state.learner_id))
db.query(MasteryRecordORM).filter(MasteryRecordORM.learner_id == state.learner_id).delete()
db.query(EvidenceEventORM).filter(EvidenceEventORM.learner_id == state.learner_id).delete()
for r in state.records:
db.add(MasteryRecordORM(
learner_id=state.learner_id,
concept_id=r.concept_id,
dimension=r.dimension,
score=r.score,
confidence=r.confidence,
evidence_count=r.evidence_count,
last_updated=r.last_updated,
))
db.add(MasteryRecordORM(learner_id=state.learner_id, concept_id=r.concept_id, dimension=r.dimension, score=r.score, confidence=r.confidence, evidence_count=r.evidence_count, last_updated=r.last_updated))
for h in state.history:
db.add(EvidenceEventORM(
learner_id=state.learner_id,
concept_id=h.concept_id,
dimension=h.dimension,
score=h.score,
confidence_hint=h.confidence_hint,
timestamp=h.timestamp,
kind=h.kind,
source_id=h.source_id,
))
db.add(EvidenceEventORM(learner_id=state.learner_id, concept_id=h.concept_id, dimension=h.dimension, score=h.score, confidence_hint=h.confidence_hint, timestamp=h.timestamp, kind=h.kind, source_id=h.source_id))
db.commit()
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 get_evaluator_job(job_id: int) -> EvaluatorJobORM | None:
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()
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 = "") -> None:
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:
@ -127,4 +344,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,79 +1,34 @@
from __future__ import annotations
import json
from .db import Base, engine, SessionLocal
from .orm import UserORM, PackORM
from .auth import hash_password, issue_token
from sqlalchemy import select
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"]
}
},
{
"id": "stats-pack",
"title": "Introductory Statistics",
"subtitle": "Descriptive statistics, sampling, and inference.",
"level": "novice-friendly",
"concepts": [
{"id": "descriptive", "title": "Descriptive Statistics", "prerequisites": [], "masteryDimension": "mastery", "exerciseReward": "Descriptive tools unlocked"},
{"id": "sampling", "title": "Sampling", "prerequisites": ["descriptive"], "masteryDimension": "mastery", "exerciseReward": "Sampling pathway opened"},
{"id": "inference", "title": "Inference", "prerequisites": ["sampling"], "masteryDimension": "mastery", "exerciseReward": "Inference challenge unlocked"}
],
"onboarding": {
"headline": "Build your first useful data skill",
"body": "You will learn one concept that immediately helps you summarize real data.",
"checklist": [
"See one worked example",
"Compute one short example yourself",
"Explain what the result means"
]
},
"compliance": {
"sources": 1,
"attributionRequired": True,
"shareAlikeRequired": False,
"noncommercialOnly": False,
"flags": []
}
}
]
from .db import Base, engine, SessionLocal
from .orm import UserORM
from .auth import hash_password
from .repository import upsert_pack
from .models import PackData, PackConcept, PackCompliance
def main():
Base.metadata.create_all(bind=engine)
with SessionLocal() as db:
existing = db.execute(select(UserORM).where(UserORM.username == "wesley")).scalar_one_or_none()
if existing is None:
db.add(UserORM(username="wesley", password_hash=hash_password("demo-pass"), token=issue_token()))
for pack in PACKS:
row = db.get(PackORM, pack["id"])
if row is None:
db.add(PackORM(id=pack["id"], title=pack["title"], subtitle=pack["subtitle"], level=pack["level"], data_json=json.dumps(pack)))
if db.execute(select(UserORM).where(UserORM.username == "wesley")).scalar_one_or_none() is None:
db.add(UserORM(username="wesley", password_hash=hash_password("demo-pass"), role="admin", is_active=True))
if db.execute(select(UserORM).where(UserORM.username == "contrib")).scalar_one_or_none() is None:
db.add(UserORM(username="contrib", password_hash=hash_password("demo-pass"), role="learner", is_active=True))
db.commit()
print("Seeded database. Demo user: wesley / demo-pass")
if __name__ == "__main__":
main()
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 users: wesley/demo-pass and contrib/demo-pass")

View File

@ -1,37 +1,24 @@
from __future__ import annotations
import json, tempfile
from pathlib import Path
from .repository import update_render_job, register_artifact
from .render_bundle import make_render_bundle
from .repository import get_evaluator_job, update_evaluator_job, load_learner_state, save_learner_state
from .engine import apply_evidence
from .models import EvidenceEvent
import time
def process_render_job(job_id: int, learner_id: str, pack_id: str, fmt: str, fps: int, theme: str, animation_payload: dict):
update_render_job(job_id, status="running")
try:
base = Path(tempfile.mkdtemp(prefix=f"didactopus_job_{job_id}_"))
payload_json = base / "animation_payload.json"
payload_json.write_text(json.dumps(animation_payload, indent=2), encoding="utf-8")
out_dir = base / "bundle"
make_render_bundle(str(payload_json), str(out_dir), fps=fps, fmt=fmt)
manifest_path = out_dir / "render_manifest.json"
script_path = out_dir / "render.sh"
update_render_job(
job_id,
status="completed",
bundle_dir=str(out_dir),
payload_json=str(payload_json),
manifest_path=str(manifest_path),
script_path=str(script_path),
error_text="",
)
register_artifact(
render_job_id=job_id,
learner_id=learner_id,
pack_id=pack_id,
artifact_type="render_bundle",
fmt=fmt,
title=f"{pack_id} animation bundle",
path=str(out_dir),
metadata={"fps": fps, "theme": theme, "manifest_path": str(manifest_path), "script_path": str(script_path)},
)
except Exception as e:
update_render_job(job_id, status="failed", error_text=str(e))
def process_job(job_id: int):
job = get_evaluator_job(job_id)
if job is None:
return
update_evaluator_job(job_id, "running", trace={"rubric_dimension_scores": [{"dimension": "mastery", "score": 0.35}], "notes": ["Job running", "Prototype trace generated"], "token_count_estimate": len(job.submitted_text.split())})
score = 0.78 if len(job.submitted_text.strip()) > 20 else 0.62
confidence_hint = 0.72 if len(job.submitted_text.strip()) > 20 else 0.45
notes = "Prototype evaluator: longer responses scored somewhat higher."
trace = {"rubric_dimension_scores": [{"dimension": "mastery", "score": score}], "notes": ["Prototype evaluator completed", notes], "token_count_estimate": len(job.submitted_text.split()), "decision_basis": ["response length heuristic", "single-dimension mastery proxy"]}
update_evaluator_job(job_id, "completed", score=score, confidence_hint=confidence_hint, notes=notes, trace=trace)
state = load_learner_state(job.learner_id)
state = apply_evidence(state, EvidenceEvent(concept_id=job.concept_id, dimension="mastery", score=score, confidence_hint=confidence_hint, timestamp="2026-03-13T12:00:00+00:00", kind="review", source_id=f"evaluator-job-{job_id}"))
save_learner_state(state)
def main():
print("Didactopus worker scaffold running. Replace this with a real queue worker.")
while True:
time.sleep(60)

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/worker.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 API UI</title>
<title>Didactopus Contribution Management 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-api-ui",
"name": "didactopus-contribution-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,194 +1,243 @@
import React, { useEffect, useMemo, useState } from "react";
import { fetchPacks, fetchLearnerState, fetchRecommendations, postEvidence } from "./api";
import { buildMasteryMap, progressPercent, milestoneMessages, claimReadiness } from "./localEngine";
import React, { useEffect, useState } from "react";
import { login, refresh, fetchPacks, createContribution, fetchAdminPacks, fetchPackValidation, fetchPackProvenance, fetchPackVersions, fetchPackComments, fetchSubmissions, fetchSubmissionDiff, fetchSubmissionGates, fetchReviewTasks, upsertPack, publishPack, governanceAction, addReviewComment } from "./api";
import { loadAuth, saveAuth, clearAuth } from "./authStore";
function DomainCard({ domain, selected, onSelect }) {
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 (
<button className={`domain-card ${selected ? "selected" : ""}`} onClick={() => onSelect(domain.id)}>
<div className="domain-title">{domain.title}</div>
<div className="domain-subtitle">{domain.subtitle}</div>
<div className="domain-meta">
<span>{domain.level}</span>
<span>{domain.concepts.length} concepts</span>
<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>
</button>
);
}
function NextStepCard({ step, onSimulate }) {
function NavTabs({ tab, setTab, role }) {
return (
<div className="step-card">
<div className="step-header">
<div>
<h3>{step.title}</h3>
<div className="muted">{step.minutes} minutes</div>
</div>
<div className="reward-pill">{step.reward}</div>
</div>
<p>{step.reason}</p>
<details>
<summary>Why this is recommended</summary>
<ul>{step.why.map((item, idx) => <li key={idx}>{item}</li>)}</ul>
</details>
<button className="primary" onClick={() => onSimulate(step)}>Simulate completing this step</button>
<div className="tab-row">
<button className={tab==="contribute" ? "active-tab" : ""} onClick={() => setTab("contribute")}>Contribute</button>
{role === "admin" ? <>
<button className={tab==="submissions" ? "active-tab" : ""} onClick={() => setTab("submissions")}>Submissions</button>
<button className={tab==="review" ? "active-tab" : ""} onClick={() => setTab("review")}>Governance</button>
</> : null}
</div>
);
}
export default function App() {
const [auth, setAuth] = useState(loadAuth());
const [tab, setTab] = useState("contribute");
const [packs, setPacks] = useState([]);
const [selectedDomainId, setSelectedDomainId] = useState("");
const [learnerName, setLearnerName] = useState("Wesley");
const [learnerState, setLearnerState] = useState(null);
const [cards, setCards] = useState([]);
const [lastReward, setLastReward] = useState("");
const learnerId = "wesley-demo";
const [adminPacks, setAdminPacks] = useState([]);
const [selectedPackId, setSelectedPackId] = useState("");
const [validation, setValidation] = useState(null);
const [provenance, setProvenance] = useState(null);
const [versions, setVersions] = useState([]);
const [comments, setComments] = useState([]);
const [submissions, setSubmissions] = useState([]);
const [selectedSubmissionId, setSelectedSubmissionId] = useState(null);
const [submissionDiff, setSubmissionDiff] = useState(null);
const [submissionGates, setSubmissionGates] = useState(null);
const [reviewTasks, setReviewTasks] = useState([]);
const [commentText, setCommentText] = useState("Looks structurally plausible.");
const [reviewSummary, setReviewSummary] = useState("Reviewed and ready for next stage.");
const [message, setMessage] = useState("");
const [contribPack, setContribPack] = useState({
id: "bayes-pack",
title: "Bayesian Reasoning",
subtitle: "Contributor revision scaffold",
level: "novice-friendly",
concepts: [{ id: "prior", title: "Prior", prerequisites: [], masteryDimension: "mastery", exerciseReward: "Prior badge earned" }],
onboarding: { headline: "Start here", body: "Begin", checklist: [] },
compliance: { sources: 1, attributionRequired: true, shareAlikeRequired: false, noncommercialOnly: false, flags: [] }
});
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 loadedPacks = await fetchPacks();
setPacks(loadedPacks);
const first = loadedPacks[0]?.id || "";
setSelectedDomainId(first);
const state = await fetchLearnerState(learnerId);
setLearnerState(state);
const p = await guarded((token) => fetchPacks(token));
setPacks(p);
setSelectedPackId((prev) => prev || p[0]?.id || "");
if (auth.role === "admin") {
setAdminPacks(await guarded((token) => fetchAdminPacks(token)));
setSubmissions(await guarded((token) => fetchSubmissions(token)));
setReviewTasks(await guarded((token) => fetchReviewTasks(token)));
}
}
load();
}, []);
}, [auth]);
useEffect(() => {
async function loadCards() {
if (!selectedDomainId) return;
const data = await fetchRecommendations(learnerId, selectedDomainId);
setCards(data.cards || []);
if (!auth?.role || auth.role !== "admin" || !selectedPackId) return;
async function loadPackReview() {
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)));
}
if (selectedDomainId) loadCards();
}, [selectedDomainId, learnerState]);
loadPackReview();
}, [auth, selectedPackId]);
const domain = useMemo(() => packs.find((d) => d.id === selectedDomainId) || null, [packs, selectedDomainId]);
const masteryMap = useMemo(() => domain && learnerState ? buildMasteryMap(learnerState, domain) : [], [learnerState, domain]);
const progress = useMemo(() => domain && learnerState ? progressPercent(learnerState, domain) : 0, [learnerState, domain]);
const milestones = useMemo(() => domain && learnerState ? milestoneMessages(learnerState, domain) : [], [learnerState, domain]);
const readiness = useMemo(() => domain && learnerState ? claimReadiness(learnerState, domain) : {ready:false, mastered:0, avgScore:0, avgConfidence:0}, [learnerState, domain]);
useEffect(() => {
if (!auth?.role || auth.role !== "admin" || !selectedSubmissionId) return;
async function loadSubmission() {
setSubmissionDiff(await guarded((token) => fetchSubmissionDiff(token, selectedSubmissionId)));
setSubmissionGates(await guarded((token) => fetchSubmissionGates(token, selectedSubmissionId)));
}
loadSubmission()
}, [auth, selectedSubmissionId]);
async function simulateStep(step) {
const nextState = await postEvidence(learnerId, {
concept_id: step.conceptId,
dimension: "mastery",
score: step.scoreHint,
confidence_hint: step.confidenceHint,
timestamp: new Date().toISOString(),
kind: "checkpoint",
source_id: `ui-${step.id}`
});
setLearnerState(nextState);
setLastReward(step.reward);
async function submitContribution() {
const result = await guarded((token) => createContribution(token, { pack: contribPack, submission_summary: "Contributor-submitted revision from UI scaffold" }));
setMessage(`Submission created: ${result.submission_id}`);
}
if (!domain || !learnerState) {
return <div className="page"><div className="card">Loading backend data...</div></div>;
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");
}
if (!auth) return <LoginView onAuth={setAuth} />;
return (
<div className="page">
<header className="hero">
<div>
<h1>Didactopus learner prototype</h1>
<p>Backend-driven pack registry, learner-state persistence, and evaluator-style evidence ingestion.</p>
<h1>Didactopus contribution management layer</h1>
<p>Contributor submissions, diffs, QA/provenance gates, and reviewer task queue scaffolding.</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 name
<input value={learnerName} onChange={(e) => setLearnerName(e.target.value)} />
</label>
{auth.role === "admin" ? (
<label>Pack<select value={selectedPackId} onChange={(e) => setSelectedPackId(e.target.value)}>{adminPacks.map((p) => <option key={p.id} value={p.id}>{p.title}</option>)}</select></label>
) : (
<label>Base pack<select value={contribPack.id} onChange={(e) => setContribPack({ ...contribPack, id: e.target.value })}>{packs.map((p) => <option key={p.id} value={p.id}>{p.title}</option>)}</select></label>
)}
<button onClick={() => { clearAuth(); setAuth(null); }}>Logout</button>
</div>
</header>
<section className="domain-grid">
{packs.map((d) => <DomainCard key={d.id} domain={d} selected={d.id === domain.id} onSelect={setSelectedDomainId} />)}
</section>
<NavTabs tab={tab} setTab={setTab} role={auth.role} />
<main className="layout">
<div className="left-col">
{tab === "contribute" && (
<main className="layout onecol">
<section className="card">
<h2>First-session onboarding</h2>
<h3>{domain.onboarding.headline}</h3>
<p>{domain.onboarding.body}</p>
<p className="muted">Learner: {learnerName}</p>
<ul>{(domain.onboarding.checklist || []).map((item, idx) => <li key={idx}>{item}</li>)}</ul>
<h2>Contributor submission</h2>
<label>Pack title<input value={contribPack.title} onChange={(e) => setContribPack({ ...contribPack, title: e.target.value })} /></label>
<label>Subtitle<input value={contribPack.subtitle} onChange={(e) => setContribPack({ ...contribPack, subtitle: e.target.value })} /></label>
<label>Onboarding headline<input value={contribPack.onboarding.headline} onChange={(e) => setContribPack({ ...contribPack, onboarding: { ...contribPack.onboarding, headline: e.target.value } })} /></label>
<label>Onboarding body<textarea value={contribPack.onboarding.body} onChange={(e) => setContribPack({ ...contribPack, onboarding: { ...contribPack.onboarding, body: e.target.value } })} /></label>
<button className="primary" onClick={submitContribution}>Submit contribution</button>
<pre className="prebox">{JSON.stringify(contribPack, null, 2)}</pre>
</section>
<section className="card">
<h2>Visible mastery map</h2>
<div className="map-grid">
{masteryMap.map((node) => (
<div key={node.id} className={`map-node ${node.status}`}>
<div className="node-label">{node.label}</div>
<div className="node-status">{node.status}</div>
</div>
))}
</div>
</section>
<section className="card">
<h2>Evidence log</h2>
{learnerState.history.length === 0 ? <div className="muted">No evidence recorded yet.</div> : (
<ul>
{learnerState.history.slice().reverse().map((item, idx) => (
<li key={idx}>{item.concept_id} · score {item.score.toFixed(2)} · confidence hint {item.confidence_hint.toFixed(2)}</li>
))}
</ul>
)}
</section>
</div>
<div className="center-col">
<section className="card">
<h2>What should I do next?</h2>
{cards.length === 0 ? <div className="muted">No immediate recommendation available.</div> : (
<div className="steps-stack">
{cards.map((step) => <NextStepCard key={step.id} step={step} onSimulate={simulateStep} />)}
</div>
)}
</section>
</div>
<div className="right-col">
<section className="card">
<h2>Progress</h2>
<div className="progress-wrap">
<div className="progress-label">Mastery progress</div>
<div className="progress-bar"><div className="progress-fill" style={{ width: `${progress}%` }} /></div>
<div className="muted">{progress}%</div>
</div>
<div className={`readiness-box ${readiness.ready ? "ready" : ""}`}>
<strong>{readiness.ready ? "Usable expertise threshold met" : "Still building toward usable expertise"}</strong>
<div className="muted">Mastered concepts: {readiness.mastered}</div>
<div className="muted">Average score: {readiness.avgScore.toFixed(2)}</div>
<div className="muted">Average confidence: {readiness.avgConfidence.toFixed(2)}</div>
</div>
</section>
<section className="card">
<h2>Milestones and rewards</h2>
{lastReward ? <div className="reward-banner">{lastReward}</div> : null}
<ul>{milestones.map((m, idx) => <li key={idx}>{m}</li>)}</ul>
</section>
<section className="card">
<h2>Source attribution and compliance</h2>
<div className="compliance-grid">
<div><strong>Sources</strong><br />{domain.compliance.sources}</div>
<div><strong>Attribution</strong><br />{domain.compliance.attributionRequired ? "required" : "not required"}</div>
<div><strong>Share-alike</strong><br />{domain.compliance.shareAlikeRequired ? "yes" : "no"}</div>
<div><strong>Noncommercial</strong><br />{domain.compliance.noncommercialOnly ? "yes" : "no"}</div>
</div>
<div className="flag-row">
{domain.compliance.flags.length ? domain.compliance.flags.map((f) => <span className="flag" key={f}>{f}</span>) : <span className="muted">No extra flags</span>}
</div>
</section>
</div>
</main>
)}
{tab === "submissions" && auth.role === "admin" && (
<main className="layout twocol">
<section className="card">
<h2>Submission queue</h2>
<table className="table">
<thead><tr><th>ID</th><th>Pack</th><th>Version</th><th>Status</th><th>Select</th></tr></thead>
<tbody>
{submissions.map((s) => (
<tr key={s.submission_id}>
<td>{s.submission_id}</td>
<td>{s.pack_id}</td>
<td>{s.proposed_version_number}</td>
<td>{s.status}</td>
<td><button onClick={() => setSelectedSubmissionId(s.submission_id)}>Inspect</button></td>
</tr>
))}
</tbody>
</table>
<h3>Review tasks</h3>
<pre className="prebox">{JSON.stringify(reviewTasks, null, 2)}</pre>
</section>
<section className="card">
<h2>Submission diff and gates</h2>
<h3>Diff summary</h3>
<pre className="prebox">{JSON.stringify(submissionDiff, null, 2)}</pre>
<h3>Gate summary</h3>
<pre className="prebox">{JSON.stringify(submissionGates, null, 2)}</pre>
</section>
</main>
)}
{tab === "review" && auth.role === "admin" && (
<main className="layout twocol">
<section className="card">
<h2>Governance and approval gates</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>
<button onClick={() => publishPack(auth.access_token, selectedPackId, true)}>Publish directly</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>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>Comments</h3>
<pre className="prebox">{JSON.stringify(comments, null, 2)}</pre>
</section>
</main>
)}
</div>
);
}

View File

@ -1,25 +1,33 @@
const API = "http://127.0.0.1:8011/api";
export async function fetchPacks() {
const res = await fetch(`${API}/packs`);
return await res.json();
function authHeaders(token, json=true) {
const h = { Authorization: `Bearer ${token}` };
if (json) h["Content-Type"] = "application/json";
return h;
}
export async function fetchLearnerState(learnerId) {
const res = await fetch(`${API}/learners/${learnerId}/state`);
export async function login(username, password) {
const res = await fetch(`${API}/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }) });
if (!res.ok) throw new Error("login failed");
return await res.json();
}
export async function postEvidence(learnerId, event) {
const res = await fetch(`${API}/learners/${learnerId}/evidence`, {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(event)
});
return await res.json();
}
export async function fetchRecommendations(learnerId, packId) {
const res = await fetch(`${API}/learners/${learnerId}/recommendations/${packId}`);
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 createContribution(token, payload) { const res = await fetch(`${API}/contributions`, { method: "POST", headers: authHeaders(token), body: JSON.stringify(payload) }); if (!res.ok) throw new Error("createContribution 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 fetchSubmissions(token) { const res = await fetch(`${API}/admin/submissions`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchSubmissions failed"); return await res.json(); }
export async function fetchSubmissionDiff(token, submissionId) { const res = await fetch(`${API}/admin/submissions/${submissionId}/diff`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchSubmissionDiff failed"); return await res.json(); }
export async function fetchSubmissionGates(token, submissionId) { const res = await fetch(`${API}/admin/submissions/${submissionId}/gates`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchSubmissionGates failed"); return await res.json(); }
export async function fetchReviewTasks(token) { const res = await fetch(`${API}/admin/review-tasks`, { headers: authHeaders(token, false) }); if (!res.ok) throw new Error("fetchReviewTasks 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(); }

View File

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

View File

@ -1,52 +1,30 @@
:root {
--bg: #f6f8fb;
--card: #ffffff;
--text: #1f2430;
--muted: #60697a;
--border: #dbe1ea;
--accent: #2d6cdf;
--soft: #eef4ff;
--bg:#f6f8fb; --card:#ffffff; --text:#1f2430; --muted:#60697a; --border:#dbe1ea; --accent:#2d6cdf; --soft:#eef4ff;
}
* { box-sizing:border-box; }
body { margin:0; font-family:Arial, Helvetica, sans-serif; background:var(--bg); color:var(--text); }
.page { max-width: 1500px; margin: 0 auto; padding: 20px; }
.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: 260px; }
.hero-controls input { width: 100%; margin-top: 6px; border: 1px solid var(--border); border-radius: 10px; padding: 10px; font: inherit; }
.domain-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; margin-top: 16px; }
.domain-card { border: 1px solid var(--border); background: var(--card); border-radius: 18px; padding: 16px; text-align: left; cursor: pointer; }
.domain-card.selected { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(45,108,223,0.12); }
.domain-title { font-size: 20px; font-weight: 700; }
.domain-subtitle { margin-top: 6px; color: var(--muted); }
.domain-meta { margin-top: 10px; display: flex; gap: 12px; color: var(--muted); font-size: 14px; }
.layout { display: grid; grid-template-columns: 1fr 1.25fr 0.95fr; gap: 16px; margin-top: 16px; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 20px; padding: 18px; }
.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; }
.muted { color:var(--muted); }
.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; }
button { border: 1px solid var(--border); background: white; border-radius: 12px; padding: 10px 14px; cursor: pointer; }
.primary { margin-top: 10px; background: var(--accent); color: white; border: none; }
.map-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
.map-node { border: 1px solid var(--border); border-radius: 16px; padding: 14px; }
.map-node.mastered { background: #eef9f0; }
.map-node.active, .map-node.available { background: #eef4ff; }
.map-node.locked { background: #f6f7fa; }
.node-label { font-weight: 700; }
.node-status { margin-top: 6px; color: var(--muted); text-transform: capitalize; }
.progress-wrap { margin-bottom: 14px; }
.progress-bar { width: 100%; height: 12px; border-radius: 999px; background: #e9edf4; overflow: hidden; margin: 8px 0; }
.progress-fill { height: 100%; background: var(--accent); }
.reward-banner { background: #fff7dd; border: 1px solid #ecdca2; border-radius: 14px; padding: 12px; margin-bottom: 12px; font-weight: 700; }
.readiness-box { border: 1px solid var(--border); background: #fbfcfe; border-radius: 14px; padding: 12px; }
.readiness-box.ready { background: #eef9f0; }
.compliance-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.flag-row { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 8px; }
.flag { border: 1px solid var(--border); background: #f4f7fc; border-radius: 999px; padding: 6px 10px; font-size: 12px; }
details summary { cursor: pointer; color: var(--accent); }
.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; }
.button-row { display:flex; gap:10px; flex-wrap:wrap; margin-bottom:12px; }
@media (max-width:1100px) {
.layout { grid-template-columns: 1fr; }
.domain-grid { grid-template-columns: 1fr; }
.hero { flex-direction:column; }
.twocol { grid-template-columns:1fr; }
}