관리자 워크스페이스 접근 확장과 소유자 결정 7건 확정 반영

- 관리자(role=admin)가 학습자·교수자·관리자 워크스페이스를 모두 접근하도록
  can_access_role/require_role와 프론트 auth 헬퍼·Sidebar 내비를 정리하고
  admin 워크스페이스 내비 E2E를 추가.
- 소유자 결정 7건 전건 확정(2026-06-30)을 SSOT 대시보드·백로그에 반영하고
  결정 필요 7→0으로 동기화. SSOT drift 게이트 기대 카운트도 갱신.
- 확정된 H1 평가설계(κ≥0.70·ICC≥0.75·환각률≤0.03·t-검정 α=0.05·무작위 배정)를
  approved-export κ 게이트(checker·dataset_export·recursive export)와
  KPI 측정계획·export manifest 문서에 반영.

검증: npm run typecheck, npm run check:api-types, 백엔드 pytest 290 passed,
admin 내비 E2E 1 passed, 레이아웃 시각게이트 9/9, session-layout 4 passed,
SSOT drift 게이트 PASS.
This commit is contained in:
Yun Chan 2026-06-30 10:57:46 +09:00
parent 1274ba9ccc
commit 4e6b0045e3
17 changed files with 110 additions and 53 deletions

View file

@ -58,6 +58,8 @@ class Principal:
return True
if self.super_admin:
return True
if self.role == Role.ADMIN:
return True
if role == Role.ADMIN:
return self.admin_access
return False
@ -131,9 +133,11 @@ def require_role(*allowed: Role):
) -> Principal:
if principal.role in allowed:
return principal
if principal.super_admin:
effective = Role.ADMIN if Role.ADMIN in allowed else allowed[0]
return principal.with_role(effective)
if principal.super_admin and Role.ADMIN in allowed:
return principal.with_role(Role.ADMIN)
for role in allowed:
if principal.can_access_role(role):
return principal.with_role(role)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"role {principal.role.value} not permitted",

View file

@ -1065,7 +1065,7 @@ async def accept_consent(
principal: CurrentPrincipal,
) -> ConsentResponse:
"""Record the current learner's practice-session consent receipt."""
if principal.role != Role.LEARNER and principal.super_admin:
if principal.role != Role.LEARNER and principal.can_access_role(Role.LEARNER):
principal = principal.with_role(Role.LEARNER)
if principal.role != Role.LEARNER:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="learner consent only")
@ -1081,7 +1081,7 @@ async def accept_consent(
@router.delete("/consent", response_model=ConsentResponse)
async def withdraw_consent(principal: CurrentPrincipal) -> ConsentResponse:
"""Withdraw practice-session consent until the learner accepts again."""
if principal.role != Role.LEARNER and principal.super_admin:
if principal.role != Role.LEARNER and principal.can_access_role(Role.LEARNER):
principal = principal.with_role(Role.LEARNER)
if principal.role != Role.LEARNER:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="learner consent only")

View file

@ -127,7 +127,7 @@ async def voice_ws(websocket: WebSocket) -> None:
await _safe_send_json(websocket, {"type": "error", "detail": "not authenticated"})
await _safe_close(websocket, WS_CLOSE_UNAUTHORIZED)
return
if principal.role != Role.LEARNER and principal.super_admin:
if principal.role != Role.LEARNER and principal.can_access_role(Role.LEARNER):
principal = principal.with_role(Role.LEARNER)
if principal.role != Role.LEARNER:
await _safe_send_json(websocket, {"type": "error", "detail": "only learners can use voice"})

View file

@ -389,8 +389,8 @@ def validate_manifest_gate(manifest: Mapping[str, Any]) -> None:
approvals = manifest.get("approvals") or {}
if pii_scan.get("status") != "pass":
errors.append("PII scan must pass")
if (agreement.get("kappa") or 0) < 0.60:
errors.append("kappa must be >= 0.60")
if (agreement.get("kappa") or 0) < 0.70:
errors.append("kappa must be >= 0.70")
if (agreement.get("icc") or 0) < 0.75:
errors.append("ICC must be >= 0.75")
for key in ("data_steward", "legal_or_privacy_reviewer", "technical_operator", "approved_at"):

View file

@ -134,7 +134,7 @@ class Phase3ArtifactCheckerTests(unittest.TestCase):
"direct_identifier_policy": "blocked",
},
"pii_scan": {"status": "pass"},
"agreement": {"kappa": 0.60, "icc": 0.75},
"agreement": {"kappa": 0.70, "icc": 0.75},
"files": [
{
"path": "03-export/anonymized_dataset.jsonl",
@ -167,7 +167,7 @@ class Phase3ArtifactCheckerTests(unittest.TestCase):
manifest_path = root / "03-export" / "export_manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["pii_scan"]["status"] = "pending"
manifest["agreement"]["kappa"] = 0.59
manifest["agreement"]["kappa"] = 0.69
manifest["agreement"]["icc"] = 0.74
manifest["selection_criteria"]["include_withdrawn"] = True
manifest["consent_scope"]["allowed_uses"] = ["education_quality_review"]
@ -177,7 +177,7 @@ class Phase3ArtifactCheckerTests(unittest.TestCase):
errors = "\n".join(report.errors)
self.assertIn("pii_scan.status='pass'", errors)
self.assertIn("agreement.kappa >= 0.60", errors)
self.assertIn("agreement.kappa >= 0.70", errors)
self.assertIn("agreement.icc >= 0.75", errors)
self.assertIn("include_withdrawn=false", errors)
self.assertIn("recursive_learning_seed consent scope", errors)

View file

@ -146,6 +146,8 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
"has_engine_config": False,
"has_session_columns": False,
"has_state_columns": False,
"has_turn_provider_events": False,
"has_session_review_worksheet_columns": False,
"has_stage_defs": False,
"has_admin_health_event": False,
"has_admin_health_daily_rollup": False,