Apply ZIP update: 180-didactopus-contribution-management-layer.zip [2026-03-14T13:20:17]
This commit is contained in:
parent
efafa2f289
commit
3fa217c5bc
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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="{}")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(); }
|
||||
|
|
|
|||
|
|
@ -2,5 +2,4 @@ import React from "react";
|
|||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")).render(<App />);
|
||||
|
|
|
|||
|
|
@ -1,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; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue