관리자 워크스페이스 접근 확장과 소유자 결정 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 return True
if self.super_admin: if self.super_admin:
return True return True
if self.role == Role.ADMIN:
return True
if role == Role.ADMIN: if role == Role.ADMIN:
return self.admin_access return self.admin_access
return False return False
@ -131,9 +133,11 @@ def require_role(*allowed: Role):
) -> Principal: ) -> Principal:
if principal.role in allowed: if principal.role in allowed:
return principal return principal
if principal.super_admin: if principal.super_admin and Role.ADMIN in allowed:
effective = Role.ADMIN if Role.ADMIN in allowed else allowed[0] return principal.with_role(Role.ADMIN)
return principal.with_role(effective) for role in allowed:
if principal.can_access_role(role):
return principal.with_role(role)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail=f"role {principal.role.value} not permitted", detail=f"role {principal.role.value} not permitted",

View file

@ -1065,7 +1065,7 @@ async def accept_consent(
principal: CurrentPrincipal, principal: CurrentPrincipal,
) -> ConsentResponse: ) -> ConsentResponse:
"""Record the current learner's practice-session consent receipt.""" """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) principal = principal.with_role(Role.LEARNER)
if principal.role != Role.LEARNER: if principal.role != Role.LEARNER:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="learner consent only") raise HTTPException(status.HTTP_403_FORBIDDEN, detail="learner consent only")
@ -1081,7 +1081,7 @@ async def accept_consent(
@router.delete("/consent", response_model=ConsentResponse) @router.delete("/consent", response_model=ConsentResponse)
async def withdraw_consent(principal: CurrentPrincipal) -> ConsentResponse: async def withdraw_consent(principal: CurrentPrincipal) -> ConsentResponse:
"""Withdraw practice-session consent until the learner accepts again.""" """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) principal = principal.with_role(Role.LEARNER)
if principal.role != Role.LEARNER: if principal.role != Role.LEARNER:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="learner consent only") 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_send_json(websocket, {"type": "error", "detail": "not authenticated"})
await _safe_close(websocket, WS_CLOSE_UNAUTHORIZED) await _safe_close(websocket, WS_CLOSE_UNAUTHORIZED)
return 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) principal = principal.with_role(Role.LEARNER)
if principal.role != Role.LEARNER: if principal.role != Role.LEARNER:
await _safe_send_json(websocket, {"type": "error", "detail": "only learners can use voice"}) 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 {} approvals = manifest.get("approvals") or {}
if pii_scan.get("status") != "pass": if pii_scan.get("status") != "pass":
errors.append("PII scan must pass") errors.append("PII scan must pass")
if (agreement.get("kappa") or 0) < 0.60: if (agreement.get("kappa") or 0) < 0.70:
errors.append("kappa must be >= 0.60") errors.append("kappa must be >= 0.70")
if (agreement.get("icc") or 0) < 0.75: if (agreement.get("icc") or 0) < 0.75:
errors.append("ICC must be >= 0.75") errors.append("ICC must be >= 0.75")
for key in ("data_steward", "legal_or_privacy_reviewer", "technical_operator", "approved_at"): 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", "direct_identifier_policy": "blocked",
}, },
"pii_scan": {"status": "pass"}, "pii_scan": {"status": "pass"},
"agreement": {"kappa": 0.60, "icc": 0.75}, "agreement": {"kappa": 0.70, "icc": 0.75},
"files": [ "files": [
{ {
"path": "03-export/anonymized_dataset.jsonl", "path": "03-export/anonymized_dataset.jsonl",
@ -167,7 +167,7 @@ class Phase3ArtifactCheckerTests(unittest.TestCase):
manifest_path = root / "03-export" / "export_manifest.json" manifest_path = root / "03-export" / "export_manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8")) manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["pii_scan"]["status"] = "pending" manifest["pii_scan"]["status"] = "pending"
manifest["agreement"]["kappa"] = 0.59 manifest["agreement"]["kappa"] = 0.69
manifest["agreement"]["icc"] = 0.74 manifest["agreement"]["icc"] = 0.74
manifest["selection_criteria"]["include_withdrawn"] = True manifest["selection_criteria"]["include_withdrawn"] = True
manifest["consent_scope"]["allowed_uses"] = ["education_quality_review"] manifest["consent_scope"]["allowed_uses"] = ["education_quality_review"]
@ -177,7 +177,7 @@ class Phase3ArtifactCheckerTests(unittest.TestCase):
errors = "\n".join(report.errors) errors = "\n".join(report.errors)
self.assertIn("pii_scan.status='pass'", 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("agreement.icc >= 0.75", errors)
self.assertIn("include_withdrawn=false", errors) self.assertIn("include_withdrawn=false", errors)
self.assertIn("recursive_learning_seed consent scope", errors) self.assertIn("recursive_learning_seed consent scope", errors)

View file

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

View file

@ -554,6 +554,33 @@ test.describe("admin route", () => {
}); });
}); });
test("shows every workspace entry in the admin navigation", async ({ page }) => {
test.setTimeout(60_000);
await signInAsAdmin(page);
await page.goto("/admin");
const nav = page.locator(".vg-nav");
await expect(nav.getByRole("link", { name: "운영 홈" })).toBeVisible();
await expect(nav.getByRole("link", { name: "사용자" })).toBeVisible();
await expect(nav.getByRole("link", { name: "권한" })).toBeVisible();
await expect(nav.getByRole("link", { name: "티켓" })).toBeVisible();
await expect(nav.getByRole("link", { name: "교수 콘솔" })).toBeVisible();
await expect(nav.getByRole("link", { name: "페르소나" })).toBeVisible();
await expect(nav.getByRole("link", { name: "학습자 홈" })).toBeVisible();
await expect(nav.getByRole("link", { name: "학습", exact: true })).toBeVisible();
await expect(nav.getByRole("link", { name: "기록" })).toBeVisible();
await nav.getByRole("link", { name: "교수 콘솔" }).click();
await expect(page).toHaveURL(/\/teach$/);
await expect(page.locator(".pf-root")).toBeVisible();
await page.goto("/admin");
await page.locator(".vg-nav").getByRole("link", { name: "학습자 홈" }).click();
await expect(page).toHaveURL(/\/learn$/);
await expect(page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." })).toBeVisible();
await expectNoHorizontalOverflow(page);
});
test("allows an admin to manage real server-known users", async ({ page }, testInfo) => { test("allows an admin to manage real server-known users", async ({ page }, testInfo) => {
test.setTimeout(60_000); test.setTimeout(60_000);
await signInAsAdmin(page); await signInAsAdmin(page);

View file

@ -27,11 +27,15 @@ const NAV_BY_ROLE: Record<Role, NavItem[]> = {
{ to: "/settings", label: "설정", icon: "settings" }, { to: "/settings", label: "설정", icon: "settings" },
], ],
admin: [ admin: [
{ to: "/admin", label: "운영", icon: "shield", end: true }, { to: "/admin", label: "운영", icon: "shield", end: true },
{ to: "/admin/users", label: "사용자", icon: "users" }, { to: "/admin/users", label: "사용자", icon: "users" },
{ to: "/admin/access", label: "권한", icon: "settings" }, { to: "/admin/access", label: "권한", icon: "settings" },
{ to: "/admin/tickets", label: "티켓", icon: "review" }, { to: "/admin/tickets", label: "티켓", icon: "review" },
{ to: "/teach", label: "교수 콘솔", icon: "users", end: true },
{ to: "/teach/personas", label: "페르소나", icon: "review" }, { to: "/teach/personas", label: "페르소나", icon: "review" },
{ to: "/learn", label: "학습자 홈", icon: "home", end: true },
{ to: "/learn/practice", label: "학습", icon: "session" },
{ to: "/learn/history", label: "기록", icon: "review" },
{ to: "/settings", label: "설정", icon: "settings" }, { to: "/settings", label: "설정", icon: "settings" },
], ],
}; };

View file

@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
import { Icon } from "../ui/Icon"; import { Icon } from "../ui/Icon";
import { canAccessRole, roleHomePath, roleLabel, useAuth, type Role } from "../../lib/auth"; import { accessibleRolesFor, canAccessRole, roleHomePath, roleLabel, useAuth, type Role } from "../../lib/auth";
import { applyTheme, readInitialTheme, type AppTheme } from "../../lib/theme"; import { applyTheme, readInitialTheme, type AppTheme } from "../../lib/theme";
/** Vignette 워드마크 — 비네트(조리개) inline SVG. dev_dashboard 마크 계승. */ /** Vignette 워드마크 — 비네트(조리개) inline SVG. dev_dashboard 마크 계승. */
@ -49,11 +49,7 @@ export function Topbar({ contextLabel }: TopbarProps) {
const [dark, toggleTheme] = useTheme(); const [dark, toggleTheme] = useTheme();
const label = contextLabel ?? (user ? roleLabel(user.role) : null); const label = contextLabel ?? (user ? roleLabel(user.role) : null);
const switchRoles: Role[] = user?.superAdmin const switchRoles: Role[] = user ? accessibleRolesFor(user) : [];
? ["learner", "teacher", "admin"]
: user?.adminAccess
? ["admin"]
: [];
const onLogout = async () => { const onLogout = async () => {
await logout(); await logout();

View file

@ -70,9 +70,16 @@ export function roleHomePath(role: Role): string {
export function canAccessRole(user: AuthUser, role: Role): boolean { export function canAccessRole(user: AuthUser, role: Role): boolean {
if (user.role === role) return true; if (user.role === role) return true;
if (user.superAdmin) return true; if (user.superAdmin) return true;
if (user.role === "admin") return true;
return role === "admin" && user.adminAccess; return role === "admin" && user.adminAccess;
} }
export function accessibleRolesFor(user: AuthUser): Role[] {
if (user.superAdmin || user.role === "admin") return ["learner", "teacher", "admin"];
if (user.adminAccess) return ["admin"];
return [];
}
const DEV_EMAIL_BY_ROLE: Record<Role, string> = { const DEV_EMAIL_BY_ROLE: Record<Role, string> = {
learner: "learner@hs.ac.kr", learner: "learner@hs.ac.kr",
teacher: "teacher@hs.ac.kr", teacher: "teacher@hs.ac.kr",

View file

@ -299,7 +299,7 @@
<a class="kpi k-done" href="#board"><span class="lab"><span class="dot"></span>완료</span><span class="num" id="kpi-done">0</span><span class="hint" id="kpi-pct">전체의 0%</span></a> <a class="kpi k-done" href="#board"><span class="lab"><span class="dot"></span>완료</span><span class="num" id="kpi-done">0</span><span class="hint" id="kpi-pct">전체의 0%</span></a>
<a class="kpi k-doing" href="#board"><span class="lab"><span class="dot"></span>진행 중</span><span class="num" id="kpi-doing">0</span><span class="hint">검증/배선 진행</span></a> <a class="kpi k-doing" href="#board"><span class="lab"><span class="dot"></span>진행 중</span><span class="num" id="kpi-doing">0</span><span class="hint">검증/배선 진행</span></a>
<a class="kpi k-plan" href="#board"><span class="kpi-badge" id="kpi-critbadge" aria-hidden="true"></span><span class="lab"><span class="dot"></span>계획 카드</span><span class="num" id="kpi-plan">0</span><span class="hint" id="kpi-crit">외부 gate는 내 차례에 분리</span></a> <a class="kpi k-plan" href="#board"><span class="kpi-badge" id="kpi-critbadge" aria-hidden="true"></span><span class="lab"><span class="dot"></span>계획 카드</span><span class="num" id="kpi-plan">0</span><span class="hint" id="kpi-crit">외부 gate는 내 차례에 분리</span></a>
<a class="kpi k-owner" href="#owner"><span class="lab"><span class="dot"></span>내 차례</span><span class="num" id="kpi-owner">0</span><span class="hint">블로커 2 · 결정 7 · 외부조율 2</span></a> <a class="kpi k-owner" href="#owner"><span class="lab"><span class="dot"></span>내 차례</span><span class="num" id="kpi-owner">0</span><span class="hint">블로커 2 · 결정 0 · 외부조율 2</span></a>
</div> </div>
</section> </section>
@ -323,14 +323,14 @@
<div class="ocol-h">🧭 소유자 결정 (DECIDE) <span class="ct" id="ct-decide">0</span></div> <div class="ocol-h">🧭 소유자 결정 (DECIDE) <span class="ct" id="ct-decide">0</span></div>
<div class="ostack" data-owner-col="decide"> <div class="ostack" data-owner-col="decide">
<article class="ocard" data-owner="0"><div class="otop"><span class="otag decide">결정됨</span><span class="oblocks">아키텍처·일정</span></div><h3>백엔드 언어 방향</h3><p class="ask">소유자 결정: 장기 교체 대상과 신규 분리 서비스는 Node.js 우선. 단, 현재 FastAPI 전면 재작성은 일정 리스크라 계약 우선 strangler 방식으로 간다.</p><p class="why">영향: 팀 역량은 Node로 맞추되, 납품 핵심 경로는 기존 Python 동작을 보존하며 교체 가능한 경계부터 만든다.</p></article> <article class="ocard" data-owner="0"><div class="otop"><span class="otag decide">결정됨</span><span class="oblocks">아키텍처·일정</span></div><h3>백엔드 언어 방향</h3><p class="ask">소유자 결정: 장기 교체 대상과 신규 분리 서비스는 Node.js 우선. 단, 현재 FastAPI 전면 재작성은 일정 리스크라 계약 우선 strangler 방식으로 간다.</p><p class="why">영향: 팀 역량은 Node로 맞추되, 납품 핵심 경로는 기존 Python 동작을 보존하며 교체 가능한 경계부터 만든다.</p></article>
<article class="ocard" data-owner="1"><div class="otop"><span class="otag decide">결정</span><span class="oblocks">Phase 3 측정</span></div><h3>평가설계 (H1: 실험/통제군·3척도·κ/ICC)</h3><p class="ask">doc4가 20명 실험/통제군·단회기 50분·3척도 pre-post를 명시. κ/ICC·환각률 목표는 doc4 미명시 — 확정 필요.</p><p class="why">영향: 평가 타당도, Phase 3 측정력. 평가설계는 소유자 결정.</p></article> <article class="ocard" data-owner="0"><div class="otop"><span class="otag decide">결정</span><span class="oblocks">Phase 3 측정</span></div><h3>평가설계 (H1: 실험/통제군·3척도·κ/ICC)</h3><p class="ask">소유자 결정(2026-06-30): 평가자 신뢰도 목표 κ≥0.70 & ICC≥0.75, 환각률 ≤3%, 통계검정은 독립표본 t-검정(α=0.05), 20명을 실험/통제군으로 무작위 배정, 단회기 50분·3척도 pre-post는 doc4대로 유지. 설계 파라미터는 확정됐고, 실제 20명 파일럿 evidence·통계해석은 별도 GATE로 남는다.</p><p class="why">영향: 평가 타당도, Phase 3 측정력. 설계는 확정, 효과성 판정은 파일럿 데이터 이후.</p></article>
<article class="ocard" data-owner="1"><div class="otop"><span class="otag decide">결정</span><span class="oblocks">음성 아키텍처</span></div><h3>s2s 2차 PoC 채택 여부</h3><p class="ask">캐스케이드 1차는 확정. <code>voice-s2s-poc.md</code> 기준표로 비교 후 유지/폐기.</p><p class="opt">캐스케이드만 / s2s 2차 PoC 유지</p></article> <article class="ocard" data-owner="0"><div class="otop"><span class="otag decide">결정</span><span class="oblocks">음성 아키텍처</span></div><h3>s2s 2차 PoC 채택 여부</h3><p class="ask">소유자 결정(2026-06-30): 캐스케이드 1차를 유지하면서 s2s 2차 PoC를 계속 연다. <code>voice-s2s-poc.md</code> 기준표(transcript·safety·audit·latency·failure·privacy)로 비교 리포트를 만든 뒤 최종 채택/폐기를 다시 판단한다.</p><p class="opt">방향: s2s 2차 PoC 유지 → 비교 PoC 구현·실측이 후속</p></article>
<article class="ocard" data-owner="1"><div class="otop"><span class="otag decide">결정</span><span class="oblocks">페르소나 음성</span></div><h3>서비스용 음성 모델·권리 정책</h3><p class="ask">Higgs급 무참조 synthetic P1 샘플은 생성 완료, dev-only sample TTS provider 연결 완료. 실존 인물/성우 ref 사용 금지 확정. 서비스 배포 전 상업 라이선스·동의 ref·대체 provider 중 결정 필요.</p><p class="opt">Higgs 라이선스 / 동의 ref / 대체 TTS</p></article> <article class="ocard" data-owner="0"><div class="otop"><span class="otag decide">결정</span><span class="oblocks">페르소나 음성</span></div><h3>서비스용 음성 모델·권리 정책</h3><p class="ask">소유자 결정(2026-06-30): 서비스 배포 음성은 상업 라이선스가 명확한 대체 TTS provider로 간다. Higgs 무참조 synthetic 샘플과 dev-only sample provider는 개발 플래그로만 유지하고, 실존 인물/성우 ref 사용 금지를 유지한다.</p><p class="opt">방향: 대체 TTS provider 채택 → provider 선정·통합이 후속</p></article>
<article class="ocard" data-owner="1"><div class="otop"><span class="otag decide">결정</span><span class="oblocks">데이터주권·레이턴시</span></div><h3>fast-loop 외부 API 허용 여부</h3><p class="ask">로컬 상주 분류기 기본 + 외부 Haiku/Solar 폴백 허용 여부.</p><p class="opt">로컬 전용 / 외부 폴백 허용</p></article> <article class="ocard" data-owner="0"><div class="otop"><span class="otag decide">결정</span><span class="oblocks">데이터주권·레이턴시</span></div><h3>fast-loop 외부 API 허용 여부</h3><p class="ask">소유자 결정(2026-06-30): 로컬 상주 분류기를 기본으로 하되 외부 Haiku/Solar 폴백을 허용한다. 외부 전송 시 데이터주권·로그 보존 정책과 동의 범위를 함께 적용한다.</p><p class="opt">방향: 외부 폴백 허용 → 폴백 경로·전송 가드 구현이 후속</p></article>
<article class="ocard" data-owner="1"><div class="otop"><span class="otag decide">결정</span><span class="oblocks">운영 감사·장애 대응</span></div><h3>운영 보존기간·에스컬레이션 정책</h3><p class="ask">헬스 raw/rollup 보존 기간, 담당 그룹 자동 배정, 우선순위 escalation, SLA 표현 여부를 정해야 한다. 현재 값은 SLA가 아니라 관측 샘플 이력이다.</p><p class="opt">정책 승인 전 자동 처리 금지</p></article> <article class="ocard" data-owner="0"><div class="otop"><span class="otag decide">결정</span><span class="oblocks">운영 감사·장애 대응</span></div><h3>운영 보존기간·에스컬레이션 정책</h3><p class="ask">소유자 결정(2026-06-30): 헬스 raw 이벤트 90일, 일별 rollup 365일 보존. 우선순위 escalation은 운영자 수동 승인, SLA는 미표기(현 값은 관측 샘플 이력으로 유지), 담당 그룹 자동 배정은 도입하지 않는다.</p><p class="opt">방향: raw 90일/rollup 365일 · 수동 escalation · SLA 미표기 → retention 기본값 적용이 후속</p></article>
<article class="ocard" data-owner="1"><div class="otop"><span class="otag decide">결정</span><span class="oblocks">동의서·데이터셋</span></div><h3>재귀학습 fine-tuning 범위</h3><p class="ask">1차 few-shot 자동갱신만 vs fine-tuning 포함.</p><p class="opt">few-shot only / fine-tuning 포함</p></article> <article class="ocard" data-owner="0"><div class="otop"><span class="otag decide">결정</span><span class="oblocks">동의서·데이터셋</span></div><h3>재귀학습 fine-tuning 범위</h3><p class="ask">소유자 결정(2026-06-30): few-shot 자동갱신에 더해 fine-tuning까지 범위에 포함한다. 단 실제 fine-tuning 데이터셋은 동의 범위·축어록 수급 결정이 닫힌 뒤에 구성한다.</p><p class="opt">방향: fine-tuning 포함 → 동의/데이터셋 게이트 충족 후 파이프라인 구축</p></article>
<article class="ocard" data-owner="0"><div class="otop"><span class="otag decide">결정됨</span><span class="oblocks">안전 정책</span></div><h3>자유연습 기본값</h3><p class="ask">결정: 기본 ON + 피드백 권장 배지 + 교수자 토글. 미동의·미성년 개인정보 gate만 차단한다.</p><p class="opt">근거: MASTERPLAN_REVISIONS F-10 승인</p></article> <article class="ocard" data-owner="0"><div class="otop"><span class="otag decide">결정됨</span><span class="oblocks">안전 정책</span></div><h3>자유연습 기본값</h3><p class="ask">결정: 기본 ON + 피드백 권장 배지 + 교수자 토글. 미동의·미성년 개인정보 gate만 차단한다.</p><p class="opt">근거: MASTERPLAN_REVISIONS F-10 승인</p></article>
<article class="ocard" data-owner="1"><div class="otop"><span class="otag decide">결정</span><span class="oblocks">평가 타당도·다양성</span></div><h3>추가 축어록 수급 규모/일정</h3><p class="ask">n=1 시드 한계 해소를 위한 추가 사례 수급 규모와 일정을 정해야 한다.</p><p class="why">영향: 평가 타당도, 페르소나 다양성, Phase 3 측정력.</p></article> <article class="ocard" data-owner="0"><div class="otop"><span class="otag decide">결정</span><span class="oblocks">평가 타당도·다양성</span></div><h3>추가 축어록 수급 규모/일정</h3><p class="ask">소유자 결정(2026-06-30): 추가 사례 20건을 1개월 내 수급한다. n=1 시드 한계를 해소하고 페르소나 다양성·Phase 3 측정력을 확보하기 위한 목표다.</p><p class="why">영향: 평가 타당도, 페르소나 다양성, Phase 3 측정력. 실제 수급·동의·정제는 임상/거버넌스 게이트와 연계.</p></article>
</div> </div>
</div> </div>
<div class="ocol"> <div class="ocol">
@ -533,6 +533,9 @@
<p class="dg-note">97차 적용(2026-06-29): <code>SESSION_DIGEST_WORKER_ENABLED=false</code> 기본값을 두고, opt-in일 때만 세션 종료 성공 뒤 digest worker를 background task로 예약한다. scheduler는 persisted fallback row를 load한 뒤 DB connection을 놓고 engine을 호출하며, accepted plan만 짧은 apply transaction으로 반영한다. 이미 <code>compressed_by</code>가 있는 세션은 loader에서 제외하고, apply도 <code>compressed_by IS NULL</code> CAS로 보호하며, fallback summary 재작성 시 compression metadata를 초기화한다. 이는 bounded/default-off scheduler 골격이며, 실 provider 장시간 운영·임상 골든셋 품질평가·재압축 정책 완료가 아니다. 검증: <code>py_compile</code>, runner <code>--help</code>, M2 focused 30 passed.</p> <p class="dg-note">97차 적용(2026-06-29): <code>SESSION_DIGEST_WORKER_ENABLED=false</code> 기본값을 두고, opt-in일 때만 세션 종료 성공 뒤 digest worker를 background task로 예약한다. scheduler는 persisted fallback row를 load한 뒤 DB connection을 놓고 engine을 호출하며, accepted plan만 짧은 apply transaction으로 반영한다. 이미 <code>compressed_by</code>가 있는 세션은 loader에서 제외하고, apply도 <code>compressed_by IS NULL</code> CAS로 보호하며, fallback summary 재작성 시 compression metadata를 초기화한다. 이는 bounded/default-off scheduler 골격이며, 실 provider 장시간 운영·임상 골든셋 품질평가·재압축 정책 완료가 아니다. 검증: <code>py_compile</code>, runner <code>--help</code>, M2 focused 30 passed.</p>
<p class="dg-note">99차 적용(2026-06-29): 대시보드 SSOT drift gate를 보강했다. <code>scripts/check-dev-dashboard-ssot.py</code>는 현재 카드 상태(<code>done=25</code>, <code>planned=0</code>, <code>doing=0</code>), CHECK 잔여 0, owner board counts(<code>block=2</code>, <code>decide=7</code>, <code>ext=2</code>), 외부 <code>GATE</code> 상세 6행, 결정 탭 7행, 자유연습 F-10 결정 문구, M2 30/87 검증 수치, default-off scheduler/CAS 문구, 실 provider·임상 골든셋·재압축 GATE 보존, stale 82/25/26 passed 및 낡은 결정 8건 문구 제거를 함께 확인한다. 검증: <code>py_compile</code>, <code>py -3.11 -X utf8 scripts\check-dev-dashboard-ssot.py --json</code> PASS. 아래 적용 노트 일부는 historical snapshot이라 당시 통과 수치를 그대로 보존한다.</p> <p class="dg-note">99차 적용(2026-06-29): 대시보드 SSOT drift gate를 보강했다. <code>scripts/check-dev-dashboard-ssot.py</code>는 현재 카드 상태(<code>done=25</code>, <code>planned=0</code>, <code>doing=0</code>), CHECK 잔여 0, owner board counts(<code>block=2</code>, <code>decide=7</code>, <code>ext=2</code>), 외부 <code>GATE</code> 상세 6행, 결정 탭 7행, 자유연습 F-10 결정 문구, M2 30/87 검증 수치, default-off scheduler/CAS 문구, 실 provider·임상 골든셋·재압축 GATE 보존, stale 82/25/26 passed 및 낡은 결정 8건 문구 제거를 함께 확인한다. 검증: <code>py_compile</code>, <code>py -3.11 -X utf8 scripts\check-dev-dashboard-ssot.py --json</code> PASS. 아래 적용 노트 일부는 historical snapshot이라 당시 통과 수치를 그대로 보존한다.</p>
<p class="dg-note">100차 적용(2026-06-29): 관리자·교수자 메일링 시스템 1차를 추가했다. 신규 외부 로그인 pending 계정은 <code>account_pending_approval:{user_id}</code>, 회기 종료 후 평가/error record 저장 완료 세션은 <code>session_review_ready:{session_id}</code>, 명시 테스트 메일은 <code>admin_test_email:{uuid}</code> idempotency key로 <code>app.notification_event</code>/<code>app.notification_delivery</code>에 큐잉된다. 테스트 메일은 대량 오발송을 피하기 위해 <code>AUTH_SUPER_ADMIN_EMAILS</code> 대상에게만 큐잉한다. 메일 HTML은 Vignette 톤앤매너(종이 배경, 세이지 CTA, 8px 카드)를 inline style로 유지하고, 본문에는 축어록·평가 전문을 넣지 않고 <code>/admin/users</code>, <code>/teach/session/:sessionId/review</code>, <code>/admin</code> 딥링크만 제공한다. <code>NOTIFICATION_EMAIL_PROVIDER=smtp</code><code>SMTP_*</code>가 설정된 경우 실제 발송하며, <code>GET /admin/notifications</code>, <code>POST /admin/notifications/process</code>, <code>POST /admin/notifications/test</code>, <code>scripts/run-notification-worker.py</code>로 delivery 상태와 재시도를 운영한다.</p> <p class="dg-note">100차 적용(2026-06-29): 관리자·교수자 메일링 시스템 1차를 추가했다. 신규 외부 로그인 pending 계정은 <code>account_pending_approval:{user_id}</code>, 회기 종료 후 평가/error record 저장 완료 세션은 <code>session_review_ready:{session_id}</code>, 명시 테스트 메일은 <code>admin_test_email:{uuid}</code> idempotency key로 <code>app.notification_event</code>/<code>app.notification_delivery</code>에 큐잉된다. 테스트 메일은 대량 오발송을 피하기 위해 <code>AUTH_SUPER_ADMIN_EMAILS</code> 대상에게만 큐잉한다. 메일 HTML은 Vignette 톤앤매너(종이 배경, 세이지 CTA, 8px 카드)를 inline style로 유지하고, 본문에는 축어록·평가 전문을 넣지 않고 <code>/admin/users</code>, <code>/teach/session/:sessionId/review</code>, <code>/admin</code> 딥링크만 제공한다. <code>NOTIFICATION_EMAIL_PROVIDER=smtp</code><code>SMTP_*</code>가 설정된 경우 실제 발송하며, <code>GET /admin/notifications</code>, <code>POST /admin/notifications/process</code>, <code>POST /admin/notifications/test</code>, <code>scripts/run-notification-worker.py</code>로 delivery 상태와 재시도를 운영한다.</p>
<p class="dg-note">101차 적용(2026-06-30): 관리자(<code>role=admin</code>)가 학습자·교수자·관리자 워크스페이스를 모두 직접 오갈 수 있도록 권한 경계를 정리했다. 백엔드는 <code>Principal.can_access_role()</code><code>role=admin</code>이면 모든 역할 접근을 허용하고, <code>require_role()</code>은 super_admin뿐 아니라 admin도 허용 역할로 승격한다. 학습자 전용 경로(<code>/consent</code> accept/withdraw, <code>/voice/ws</code>)의 super_admin-only 승격 분기도 <code>can_access_role(Role.LEARNER)</code> 기준으로 바꿔 admin이 동일하게 통과한다. 프론트는 <code>auth.tsx</code><code>canAccessRole()</code>에 admin 전체 접근을 반영하고, 역할 전환 메뉴는 <code>accessibleRolesFor()</code> 단일 헬퍼가 소유하며, <code>Sidebar</code> admin 내비에 <code>운영 홈</code>·<code>교수 콘솔</code>·<code>학습자 홈</code>·<code>학습</code>·<code>기록</code> 진입을 추가했다. 외부 API 계약·DB schema·승인 게이트(<code>account_status</code>)·super_admin 권한 부여/회수 동작은 바꾸지 않았다. 검증: <code>npm run typecheck</code>, <code>npm run check:api-types</code>, <code>py -3.11 -X utf8 -B -m pytest app/ -q</code> 290 passed(RBAC/runtime 30 포함), admin 워크스페이스 내비 E2E 1 passed, layout 시각게이트 9/9, session-layout 4 passed.</p>
<p class="dg-note">102차 적용(2026-06-30): 소유자(윤찬) 결정 4건이 확정되어 owner 보드 결정 카드 7→3으로 줄었다. (1) <b>s2s 2차 PoC 유지</b> — 캐스케이드 1차를 운영 기준으로 두면서 <code>voice-s2s-poc.md</code> 비교 PoC를 계속 연다(최종 채택은 비교 리포트 이후). (2) <b>fast-loop 외부 폴백 허용</b> — 로컬 상주 분류기 기본 + 외부 Haiku/Solar 폴백 허용, 전송 시 데이터주권·보존·동의 가드 적용. (3) <b>재귀학습 fine-tuning 포함</b> — few-shot 자동갱신에 더해 fine-tuning까지 범위에 포함하되, 데이터셋은 동의·추가 축어록 수급 게이트가 닫힌 뒤 구성. (4) <b>서비스 음성 = 대체 TTS provider</b> — 상업 라이선스가 명확한 대체 provider 채택, Higgs synthetic·dev sample provider는 개발 플래그로만 유지, 실존 인물/성우 ref 금지 유지. 네 결정 모두 방향 확정이며 구현/실측은 후속 build 항목으로 남는다. 남은 owner 결정은 평가설계(H1 κ/ICC), 운영 보존·에스컬레이션, 추가 축어록 수급 3건이다. SSOT drift 게이트(<code>check-dev-dashboard-ssot.py</code>)의 기대 카운트도 결정 3·decision panel 3행으로 동기화했다.</p>
<p class="dg-note">103차 적용(2026-06-30): 남은 소유자 결정 3건이 확정되어 owner 결정 7건이 전건 확정(결정 필요 3→0)됐다. (1) <b>평가설계 H1</b> — 평가자 신뢰도 κ≥0.70 & ICC≥0.75, 환각률 ≤3%, 독립표본 t-검정(α=0.05), 20명 실험/통제군 무작위 배정, 단회기 50분·3척도 pre-post 유지. (2) <b>운영 보존·에스컬레이션</b> — 헬스 raw 90일·rollup 365일, escalation 운영자 수동 승인, SLA 미표기(관측 샘플 이력 유지), 담당 그룹 자동 배정 미도입. (3) <b>추가 축어록</b> — 20건을 1개월 내 수급. 이 결정들은 설계·정책 확정이며, KPI threshold 반영·retention 기본값 적용·실제 수급/파일럿 evidence·통계해석은 후속 build/GATE로 남는다. 즉 owner 보드의 남은 항목은 모두 외부 증거·기관 의존(공개 OAuth 실 <code>/turn</code>, vnet DNS, 한신대 거버넌스, 임상팀 콘텐츠, 20명 파일럿·법무·물리 실측)이며, 운영 원칙상 E2E/외부 증거 없이는 DONE으로 체크하지 않는다. SSOT drift 게이트의 기대 카운트도 결정 0·decision panel 0행으로 동기화했다.</p>
<p class="dg-note">20차 적용(2026-06-28): 신규 Google/SAML 사용자는 <code>account_status=pending</code>으로 시작하고 승인 전에는 <code>/pending</code> 안내 화면만 본다. <code>yunchan@twentyoz.kr</code>는 슈퍼 관리자 allowlist로 admin+approved를 받으며, <code>/admin/users</code>는 가입 승인 탭에서 pending 계정을 승인 또는 보류 처리한다.</p> <p class="dg-note">20차 적용(2026-06-28): 신규 Google/SAML 사용자는 <code>account_status=pending</code>으로 시작하고 승인 전에는 <code>/pending</code> 안내 화면만 본다. <code>yunchan@twentyoz.kr</code>는 슈퍼 관리자 allowlist로 admin+approved를 받으며, <code>/admin/users</code>는 가입 승인 탭에서 pending 계정을 승인 또는 보류 처리한다.</p>
<p class="dg-note">21차 적용(2026-06-28): 관리자 페이지 진입권을 기본 역할과 분리해 <code>app_user.admin_access</code>로 저장한다. <code>AUTH_SUPER_ADMIN_EMAILS</code> 기본값은 <code>yunchan@twentyoz.kr</code>, <code>hoonjungkoo@hs.ac.kr</code>이며, 슈퍼 관리자는 학습자·교수자·관리자 공간 전환과 관리자 권한 부여/회수를 할 수 있다. 학생·교수 계정도 <code>admin_access=true</code>면 우측 상단 관리자 진입이 노출된다. 구성 슈퍼 관리자의 권한 회수와 계정 비활성화는 차단한다.</p> <p class="dg-note">21차 적용(2026-06-28): 관리자 페이지 진입권을 기본 역할과 분리해 <code>app_user.admin_access</code>로 저장한다. <code>AUTH_SUPER_ADMIN_EMAILS</code> 기본값은 <code>yunchan@twentyoz.kr</code>, <code>hoonjungkoo@hs.ac.kr</code>이며, 슈퍼 관리자는 학습자·교수자·관리자 공간 전환과 관리자 권한 부여/회수를 할 수 있다. 학생·교수 계정도 <code>admin_access=true</code>면 우측 상단 관리자 진입이 노출된다. 구성 슈퍼 관리자의 권한 회수와 계정 비활성화는 차단한다.</p>
<p class="dg-note">25차 적용(2026-06-28): <code>/admin/users</code>에서 허용 도메인 밖 이메일도 정확한 계정 단위로 강제 등록할 수 있다. Google/SAML/dev-login은 미리 등록된 이메일만 도메인 게이트 예외로 통과시키고, provider 로그인 시 기존 관리 row의 역할·코호트·승인 상태를 이어받는다. 미등록 외부 도메인 로그인은 계속 차단한다.</p> <p class="dg-note">25차 적용(2026-06-28): <code>/admin/users</code>에서 허용 도메인 밖 이메일도 정확한 계정 단위로 강제 등록할 수 있다. Google/SAML/dev-login은 미리 등록된 이메일만 도메인 게이트 예외로 통과시키고, provider 로그인 시 기존 관리 row의 역할·코호트·승인 상태를 이어받는다. 미등록 외부 도메인 로그인은 계속 차단한다.</p>
@ -1011,19 +1014,19 @@ $env:E2E_PUBLIC_STORAGE_STATE=".\node_modules\.tmp\public-auth.json"
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\start-public-runtime.ps1</pre> powershell -NoProfile -ExecutionPolicy Bypass -File scripts\start-public-runtime.ps1</pre>
</div> </div>
<div class="source-note"><b>닫은 항목(live 증거):</b> ① engine config 운영값(<code>claude_cli / 9099 / gateway-default</code>, durable, db). ② 상주 엔진풀 probe — 단일 session_id 2회 reused stream, TTFT 16674199ms, cost 누적 0.068→0.123. ③ 상주 엔진풀 RSS — gateway session 1개에서 <code>claude -p</code> child RSS 363.3MB, gateway RSS 16.2MB. ④ Postgres RLS/audit smoke 5 checks PASS. ⑤ turn cost telemetry — <code>app.turns</code> client_ai 13행에 provider/model/cost_usd 실적재(합 $1.22). ⑥ 레이아웃 시각 게이트 최신 9/9 + 2026-06-28 적대적 재검수 7/7 accept.</div> <div class="source-note"><b>닫은 항목(live 증거):</b> ① engine config 운영값(<code>claude_cli / 9099 / gateway-default</code>, durable, db). ② 상주 엔진풀 probe — 단일 session_id 2회 reused stream, TTFT 16674199ms, cost 누적 0.068→0.123. ③ 상주 엔진풀 RSS — gateway session 1개에서 <code>claude -p</code> child RSS 363.3MB, gateway RSS 16.2MB. ④ Postgres RLS/audit smoke 5 checks PASS. ⑤ turn cost telemetry — <code>app.turns</code> client_ai 13행에 provider/model/cost_usd 실적재(합 $1.22). ⑥ 레이아웃 시각 게이트 최신 9/9 + 2026-06-28 적대적 재검수 7/7 accept.</div>
<div class="source-note"><b>환경 제약으로 이 세션에서 못 닫는 항목(정직 표기):</b> 공개 Google OAuth 실제 <code>/turn</code>(OAuth start는 2026-06-29 재확인 완료, 실제 계정 storageState 필요) · 실배포 <code>infra/.env</code> owner-secret fill-in(<code>APP_DB_PASSWORD</code>, <code>OAUTH_GOOGLE_CLIENT_ID</code>, <code>OAUTH_GOOGLE_CLIENT_SECRET</code>, <code>OPENAI_API_KEY</code>, <code>SESSION_SECRET</code>) · <code>vnet.18ka.net</code>/<code>api-vnet.18ka.net</code> 공개 런타임(DNS A record 0, Cloudflare 18ka.net zone 권한 불일치, Google OAuth redirect URI 등록 필요) · 한신대 데이터/SSO 거버넌스(owner/기관 written evidence 필요) · 음성 실제 Deepgram interim/final WSS·물리 마이크·공개 WSS·50분 양방향 장시간 실측 · claude_cli↔Messages API 폴백 동일성 · 재부팅 후 watchdog smoke · Phase 3 파일럿 게이트 · decisions 7건(owner 결정). 운영 원칙상 가짜 증거로 DONE 표기하지 않는다. 전체 추적: <code>docs/ops/backlog-2026-06-26.md</code>(B1 구현 후속 · B2 환경제약 · B3 소유자결정 · B4 거버넌스).</div> <div class="source-note"><b>환경 제약으로 이 세션에서 못 닫는 항목(정직 표기):</b> 공개 Google OAuth 실제 <code>/turn</code>(OAuth start는 2026-06-29 재확인 완료, 실제 계정 storageState 필요) · 실배포 <code>infra/.env</code> owner-secret fill-in(<code>APP_DB_PASSWORD</code>, <code>OAUTH_GOOGLE_CLIENT_ID</code>, <code>OAUTH_GOOGLE_CLIENT_SECRET</code>, <code>OPENAI_API_KEY</code>, <code>SESSION_SECRET</code>) · <code>vnet.18ka.net</code>/<code>api-vnet.18ka.net</code> 공개 런타임(DNS A record 0, Cloudflare 18ka.net zone 권한 불일치, Google OAuth redirect URI 등록 필요) · 한신대 데이터/SSO 거버넌스(owner/기관 written evidence 필요) · 음성 실제 Deepgram interim/final WSS·물리 마이크·공개 WSS·50분 양방향 장시간 실측 · claude_cli↔Messages API 폴백 동일성 · 재부팅 후 watchdog smoke · Phase 3 파일럿 게이트. owner 결정 7건은 2026-06-30 전건 확정됨. 운영 원칙상 가짜 증거로 DONE 표기하지 않는다. 전체 추적: <code>docs/ops/backlog-2026-06-26.md</code>(B1 구현 후속 · B2 환경제약 · B3 소유자결정 · B4 거버넌스).</div>
</div> </div>
<div class="tab-panel" id="panel-decisions" role="tabpanel" aria-labelledby="tab-decisions" hidden> <div class="tab-panel" id="panel-decisions" role="tabpanel" aria-labelledby="tab-decisions" hidden>
<div class="detail-head"><div><h3>윤찬 결정 필요 상세</h3><p>람다가 단독으로 정하면 월권인 항목이다. 결정되기 전에는 "완료"가 아니라 조건부로 둔다.</p></div><div class="mini-metrics"><span>결정 7개</span><span>조건부 GO 사유</span></div></div> <div class="detail-head"><div><h3>윤찬 결정 상세 (전건 확정)</h3><p>람다가 단독으로 정하면 월권인 항목이었다. 2026-06-30 owner 결정으로 전건 확정됐고, 각 결정의 구현·실측은 후속 build/GATE로 남는다.</p></div><div class="mini-metrics"><span>결정 0개(전건 확정)</span><span>조건부 GO 사유</span></div></div>
<div class="task-list"> <div class="task-list">
<div class="task-row r3"><div><span class="task-status s-risk">DECIDE</span></div><div><b>평가설계 (H1: 실험/통제군·3척도·κ/ICC)</b><p>doc4가 20명 실험/통제군·단회기 50분·3척도 pre-post를 요구하지만 κ/ICC·환각률 목표, 통계검정, 결측 처리, 공식 효과성 판정 기준은 아직 확정되지 않았다.</p></div><div><b>영향</b><p>평가 타당도, Phase 3 측정력, 파일럿 evidence 해석. 람다 단독 결정 금지.</p></div></div> <div class="task-row"><div><span class="task-status s-done">DECIDED</span></div><div><b>평가설계 (H1: 실험/통제군·3척도·κ/ICC)</b><p>소유자 결정(2026-06-30): 평가자 신뢰도 κ≥0.70 & ICC≥0.75, 환각률 ≤3%, 독립표본 t-검정(α=0.05), 20명 실험/통제군 무작위 배정, 단회기 50분·3척도 pre-post 유지. 설계 파라미터 확정.</p></div><div><b>후속</b><p>KPI 측정계획·report threshold에 κ/ICC/환각률 반영. 실제 20명 파일럿 evidence·통계해석·공식 효과성 판정은 별도 GATE.</p></div></div>
<div class="task-row r3"><div><span class="task-status s-risk">DECIDE</span></div><div><b>s2s 2차 PoC 채택 여부</b><p>캐스케이드 1차는 확정. <code>voice-s2s-poc.md</code> 기준표에 따라 비교 리포트가 나온 뒤 유지/폐기를 정한다. 선택지: 캐스케이드만 / s2s 2차 PoC 유지.</p></div><div><b>영향</b><p>음성 아키텍처, 전송 비용, 로그 보존 방식. 람다 단독 결정 금지.</p></div></div> <div class="task-row"><div><span class="task-status s-done">DECIDED</span></div><div><b>s2s 2차 PoC 채택 여부</b><p>소유자 결정(2026-06-30): s2s 2차 PoC를 유지한다. 캐스케이드 1차를 운영 기준으로 두면서 <code>voice-s2s-poc.md</code> 기준표로 비교 리포트를 만든 뒤 최종 채택/폐기를 재판단한다.</p></div><div><b>후속</b><p>s2s 비교 PoC 구현·레이턴시/안전/감사 실측. 최종 채택 판단은 비교 리포트 이후.</p></div></div>
<div class="task-row r3"><div><span class="task-status s-risk">DECIDE</span></div><div><b>서비스용 페르소나 음성 모델·권리 정책</b><p>Higgs v3 무참조 synthetic seed 방식으로 P1 샘플은 만들었고, 개발 플래그로 P1 sample TTS provider를 서비스에 연결했다. 하지만 실서비스는 모델 상업 라이선스와 reference voice 권리정책이 닫히기 전까지 탑재 금지다. 선택지: Higgs 라이선스 확보 / 동의 받은 ref만 사용 / 대체 TTS provider.</p></div><div><b>영향</b><p>법적 리스크, 음성 품질, 페르소나 확장 비용, 운영 GPU 요구량.</p></div></div> <div class="task-row"><div><span class="task-status s-done">DECIDED</span></div><div><b>서비스용 페르소나 음성 모델·권리 정책</b><p>소유자 결정(2026-06-30): 실서비스 음성은 상업 라이선스가 명확한 대체 TTS provider로 간다. Higgs 무참조 synthetic 샘플과 dev-only sample provider는 개발 플래그로만 유지하고, 실존 인물/성우 ref 사용 금지를 유지한다.</p></div><div><b>후속</b><p>대체 TTS provider 선정·통합, dev 플래그와 서비스 경로 분리 유지.</p></div></div>
<div class="task-row r3"><div><span class="task-status s-risk">DECIDE</span></div><div><b>fast-loop 외부 API 허용 여부</b><p>로컬 상주 분류기를 기본으로 하되, 외부 Haiku/Solar API 폴백을 허용할지 정한다. 선택지: 로컬 전용 / 외부 폴백 허용.</p></div><div><b>영향</b><p>데이터주권, 레이턴시, 장애 대응</p></div></div> <div class="task-row"><div><span class="task-status s-done">DECIDED</span></div><div><b>fast-loop 외부 API 허용 여부</b><p>소유자 결정(2026-06-30): 로컬 상주 분류기를 기본으로 하되 외부 Haiku/Solar 폴백을 허용한다. 외부 전송 시 데이터주권·로그 보존·동의 범위 가드를 함께 적용한다.</p></div><div><b>후속</b><p>외부 폴백 경로·전송 가드·관측 구현. 로컬 우선/외부 폴백 라우팅 정책 명시.</p></div></div>
<div class="task-row r3"><div><span class="task-status s-risk">DECIDE</span></div><div><b>운영 보존기간·에스컬레이션 정책</b><p>헬스 raw/rollup 보존 기간, SLA 표현 여부, 운영 티켓 담당 그룹 자동 배정, 우선순위 escalation 조건을 정해야 한다. 현재 구현은 명시 인자 기반 dry-run/apply 경로까지만 제공한다.</p></div><div><b>영향</b><p>운영 감사, 장애 대응, 개인정보·로그 보존 정책</p></div></div> <div class="task-row"><div><span class="task-status s-done">DECIDED</span></div><div><b>운영 보존기간·에스컬레이션 정책</b><p>소유자 결정(2026-06-30): 헬스 raw 90일·rollup 365일 보존, 우선순위 escalation은 운영자 수동 승인, SLA 미표기(관측 샘플 이력 유지), 담당 그룹 자동 배정 미도입.</p></div><div><b>후속</b><p>retention 기본값(raw 90/rollup 365) 적용·검증. escalation/그룹 자동화는 미도입으로 고정.</p></div></div>
<div class="task-row r3"><div><span class="task-status s-risk">DECIDE</span></div><div><b>재귀학습 fine-tuning 범위</b><p>1차는 few-shot 자동갱신만 할지, fine-tuning까지 포함할지 결정한다. 선택지: few-shot only / fine-tuning 포함.</p></div><div><b>영향</b><p>동의서, 데이터셋 품질, 운영 리스크</p></div></div> <div class="task-row"><div><span class="task-status s-done">DECIDED</span></div><div><b>재귀학습 fine-tuning 범위</b><p>소유자 결정(2026-06-30): few-shot 자동갱신에 더해 fine-tuning까지 범위에 포함한다. 단 실제 fine-tuning 데이터셋은 동의 범위·추가 축어록 수급 결정이 닫힌 뒤에 구성한다.</p></div><div><b>후속</b><p>동의/데이터셋 게이트 충족 후 fine-tuning 파이프라인 구축. few-shot 자동갱신은 선행 가능.</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>자유연습 기본값</b><p>기본 ON + 피드백 권장 배지 + 교수자 토글로 결정했다. 미동의·미성년 개인정보 gate만 차단한다.</p></div><div><b>근거</b><p><code>docs/redteam/MASTERPLAN_REVISIONS.md</code> F-10 승인.</p></div></div> <div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>자유연습 기본값</b><p>기본 ON + 피드백 권장 배지 + 교수자 토글로 결정했다. 미동의·미성년 개인정보 gate만 차단한다.</p></div><div><b>근거</b><p><code>docs/redteam/MASTERPLAN_REVISIONS.md</code> F-10 승인.</p></div></div>
<div class="task-row r3"><div><span class="task-status s-risk">DECIDE</span></div><div><b>추가 축어록 수급 규모/일정</b><p>n=1 시드의 한계를 해소하려면 추가 사례 수급 규모와 일정을 정해야 한다. 선택지: N건 목표/일정 확정.</p></div><div><b>영향</b><p>평가 타당도, 페르소나 다양성, Phase 3 측정력</p></div></div> <div class="task-row"><div><span class="task-status s-done">DECIDED</span></div><div><b>추가 축어록 수급 규모/일정</b><p>소유자 결정(2026-06-30): 추가 사례 20건을 1개월 내 수급한다. n=1 시드 한계 해소와 페르소나 다양성 확보가 목표다.</p></div><div><b>후속</b><p>실제 수급·동의·정제는 임상/한신대 거버넌스 게이트와 연계. 목표=20건/1개월.</p></div></div>
</div> </div>
<div class="detail-blocks"> <div class="detail-blocks">
<div class="detail-block"><h4>완료라고 말하는 기준</h4><ul><li>실제 로그인 기반 E2E 증거가 있어야 한다.</li><li>DB 쿼리나 로그로 저장/권한/비용이 확인되어야 한다.</li><li>시각 확인만으로 기능 완료를 선언하지 않는다.</li></ul></div> <div class="detail-block"><h4>완료라고 말하는 기준</h4><ul><li>실제 로그인 기반 E2E 증거가 있어야 한다.</li><li>DB 쿼리나 로그로 저장/권한/비용이 확인되어야 한다.</li><li>시각 확인만으로 기능 완료를 선언하지 않는다.</li></ul></div>

View file

@ -57,14 +57,14 @@
> 결정되면 대시보드 `결정 필요` 탭과 관련 Phase 항목을 갱신한다. > 결정되면 대시보드 `결정 필요` 탭과 관련 Phase 항목을 갱신한다.
- [ ] **운영 티켓·가용성 정책 결정** — 1차 DB 큐, 사용자별 조회 UI, synthetic sampler, retention/rollup 실행 경로는 연결됐지만 raw/rollup 보존 기간의 운영 기본값, 담당 그룹 자동 배정, 우선순위 escalation은 아직 운영 정책 결정이 필요하다. `/admin/uptime`과 sampler 값은 계속 SLA가 아니라 관측 샘플 이력이다. - [x] **운영 티켓·가용성 정책 결정** — 결정됨(2026-06-30): 헬스 raw 90일·rollup 365일 보존, 우선순위 escalation은 운영자 수동 승인, SLA 미표기(관측 샘플 이력 유지), 담당 그룹 자동 배정 미도입. 후속: retention 기본값(raw 90/rollup 365) 적용·검증. escalation/그룹 자동화는 미도입으로 고정.
- [ ] **평가설계 (H1: 실험/통제군·3척도·κ/ICC)** — doc4가 20명 실험/통제군·단회기 50분·3척도 pre-post를 명시하지만 κ/ICC·환각률 목표, 통계검정 종류, alpha, 결측 처리, 공식 효과성 판정 기준은 미확정이다. 영향: 평가 타당도·Phase 3 측정력·파일럿 evidence 해석. - [x] **평가설계 (H1: 실험/통제군·3척도·κ/ICC)** — 결정됨(2026-06-30): 평가자 신뢰도 κ≥0.70 & ICC≥0.75, 환각률 ≤3%, 독립표본 t-검정(α=0.05), 20명 실험/통제군 무작위 배정, 단회기 50분·3척도 pre-post 유지. 후속: KPI 측정계획·report threshold에 κ/ICC/환각률 반영. 실제 20명 파일럿 evidence·통계해석·공식 효과성 판정은 별도 GATE.
- [ ] **s2s 2차 PoC 채택 여부** — 캐스케이드만 / s2s 2차 PoC 유지. 영향: 음성 아키텍처·전송 비용·로그 보존. 기준표 `docs/decisions/voice-s2s-poc.md`. - [x] **s2s 2차 PoC 채택 여부** — 결정됨(2026-06-30): s2s 2차 PoC 유지. 캐스케이드 1차를 운영 기준으로 두고 `docs/decisions/voice-s2s-poc.md` 비교 PoC를 계속 연다. 최종 채택/폐기는 비교 리포트 이후. 후속: s2s 비교 PoC 구현·레이턴시/안전/감사 실측.
- [ ] **서비스용 페르소나 음성 모델·권리 정책** — (2026-06-27) P1 서연 음성 아트 PoC는 Higgs v3 무참조 synthetic seed 방식으로 생성(`docs/voice-art/p1-seoyeon-higgs-v3-20260627/`). 실존 인물/성우 reference 사용 금지 확정. `VIGNETTE_VOICE_POC_SAMPLE_TTS=true` 개발 플래그로 `/voice/ws` P1 sample TTS provider에 연결했지만, 서비스 배포 전 선택 필요: Higgs 상업 라이선스 확보 / 동의 받은 reference voice만 사용 / 배포 가능한 대체 TTS provider 채택. - [x] **서비스용 페르소나 음성 모델·권리 정책** — 결정됨(2026-06-30): 상업 라이선스가 명확한 대체 TTS provider 채택. Higgs v3 무참조 synthetic 샘플과 `VIGNETTE_VOICE_POC_SAMPLE_TTS=true` dev sample provider는 개발 플래그로만 유지하고, 실존 인물/성우 reference 사용 금지를 유지한다. 후속: 대체 TTS provider 선정·통합, dev/서비스 경로 분리 유지.
- [ ] **fast-loop 외부 API 허용** — 로컬 전용 / 외부 Haiku·Solar 폴백 허용. 영향: 데이터주권·레이턴시·장애 대응. - [x] **fast-loop 외부 API 허용** — 결정됨(2026-06-30): 외부 폴백 허용. 로컬 상주 분류기 기본 + 외부 Haiku·Solar 폴백, 외부 전송 시 데이터주권·로그 보존·동의 가드 적용. 후속: 폴백 경로·전송 가드·관측 구현, 로컬 우선/외부 폴백 라우팅 정책 명시.
- [ ] **재귀학습 fine-tuning 범위** — few-shot 자동갱신만 / fine-tuning 포함. 영향: 동의서·데이터셋 품질·운영 리스크. - [x] **재귀학습 fine-tuning 범위** — 결정됨(2026-06-30): fine-tuning 포함. few-shot 자동갱신에 더해 fine-tuning까지 범위에 두되, 데이터셋은 동의 범위·추가 축어록 수급 결정이 닫힌 뒤 구성한다. few-shot 자동갱신은 선행 가능.
- [x] **자유연습 기본값** — 결정됨: 기본 ON(+피드백 권장 배지+교수자 토글). 미동의·미성년 개인정보 gate만 차단한다. 근거: `docs/redteam/MASTERPLAN_REVISIONS.md` F-10 승인. - [x] **자유연습 기본값** — 결정됨: 기본 ON(+피드백 권장 배지+교수자 토글). 미동의·미성년 개인정보 gate만 차단한다. 근거: `docs/redteam/MASTERPLAN_REVISIONS.md` F-10 승인.
- [ ] **추가 축어록 수급 규모·일정** — 목표 N건/일정 확정. 영향: 평가 타당도·페르소나 다양성·Phase 3 측정력. - [x] **추가 축어록 수급 규모·일정** — 결정됨(2026-06-30): 20건을 1개월 내 수급. 후속: 실제 수급·동의·정제는 임상/한신대 거버넌스 게이트와 연계.
- [x] **백엔드 언어 방향** — (2026-06-28 결정, 2026-06-29 보강) Node.js를 장기 교체 대상과 신규 분리 서비스 우선 스택으로 삼되, 현재 FastAPI 전면 재작성은 납품 일정 리스크라 보류한다. 전환은 계약 우선 strangler 방식으로 진행한다. 고정된 내부 경계는 `app/contracts/engine_gateway.py`의 gateway request/response/SSE 계약과 `gateway-default` sentinel, `structured_payload_from_response()`, `scripts/check-engine-gateway-contract.mjs` schema-driven Node conformance runner, `GatewayPromptParts`, `app/session_read_model.py`, `app/persona_read_model.py`, `app/persona_generation_contract.py`, `SessionEvaluationWrite.from_result()/from_error()`다. FastAPI route는 auth/RLS DB read/persistence, teacher/admin gate, RAG/LLM side effect, session lifecycle, provenance/error mapping을 계속 소유한다. 2026-06-29 후속으로 Python gateway의 `provider/model` 응답 메타 규칙을 helper로 모으고, Node runner가 `gateway-default` request sentinel과 resolved response model 분리를 검증하며, current-turn prompt split과 missing-user 400 경계를 테스트로 고정했다. 결정 기록: `docs/decisions/backend-node-transition.md`. **검증: `node scripts/check-engine-gateway-contract.mjs --json`, gateway contract 27 passed, persona contract+review 39 passed, backend focused 120 passed, `npm run check:api-types`, `npm run typecheck`.** - [x] **백엔드 언어 방향** — (2026-06-28 결정, 2026-06-29 보강) Node.js를 장기 교체 대상과 신규 분리 서비스 우선 스택으로 삼되, 현재 FastAPI 전면 재작성은 납품 일정 리스크라 보류한다. 전환은 계약 우선 strangler 방식으로 진행한다. 고정된 내부 경계는 `app/contracts/engine_gateway.py`의 gateway request/response/SSE 계약과 `gateway-default` sentinel, `structured_payload_from_response()`, `scripts/check-engine-gateway-contract.mjs` schema-driven Node conformance runner, `GatewayPromptParts`, `app/session_read_model.py`, `app/persona_read_model.py`, `app/persona_generation_contract.py`, `SessionEvaluationWrite.from_result()/from_error()`다. FastAPI route는 auth/RLS DB read/persistence, teacher/admin gate, RAG/LLM side effect, session lifecycle, provenance/error mapping을 계속 소유한다. 2026-06-29 후속으로 Python gateway의 `provider/model` 응답 메타 규칙을 helper로 모으고, Node runner가 `gateway-default` request sentinel과 resolved response model 분리를 검증하며, current-turn prompt split과 missing-user 400 경계를 테스트로 고정했다. 결정 기록: `docs/decisions/backend-node-transition.md`. **검증: `node scripts/check-engine-gateway-contract.mjs --json`, gateway contract 27 passed, persona contract+review 39 passed, backend focused 120 passed, `npm run check:api-types`, `npm run typecheck`.**
- [x] **Node 전환 경계 보강 — 페르소나 생성 계약 + 평가 저장 packet** — (2026-06-29 처리) `app/persona_generation_contract.py`가 페르소나 draft structured schema, prompt bundle id/version/hash, `GenerateResponse` payload extraction, generated draft coercion/default fallback을 소유한다. `SessionEvaluationWrite.from_result()/from_error()``app.session_evaluation` 저장 packet(`status/source/scope/stage/payload/error`) 생성을 소유한다. Node conformance runner는 stream event 목록과 payload 검증을 `engine_gateway_schema.v1.json``StreamPacket.oneOf`, payload `$ref`, `x-engine-gateway-sse-events`에서 읽는다. `routes/personas.py`는 auth, source 등록·조회, RAG evidence, engine 호출, provenance 조립, HTTP error mapping을 유지하고, `routes/sessions.py`/`routes/eval.py`는 factory로 named packet을 만들어 저장한다. **검증: `py -3.11 -X utf8 -B -m py_compile app\persona_generation_contract.py app\routes\personas.py app\persona_read_model.py app\test_persona_generation_contract.py app\test_persona_review.py engine_gateway\test_gateway_model.py app\session_persistence.py app\routes\sessions.py app\routes\eval.py app\services\evaluator.py app\services\live_coach.py`, `py -3.11 -X utf8 -B -m pytest -p no:cacheprovider app\test_persona_generation_contract.py app\test_persona_review.py -q` 39 passed, backend focused 120 passed, `node scripts\check-engine-gateway-contract.mjs --json`, `npm run check:api-types`, `npm run typecheck`.** - [x] **Node 전환 경계 보강 — 페르소나 생성 계약 + 평가 저장 packet** — (2026-06-29 처리) `app/persona_generation_contract.py`가 페르소나 draft structured schema, prompt bundle id/version/hash, `GenerateResponse` payload extraction, generated draft coercion/default fallback을 소유한다. `SessionEvaluationWrite.from_result()/from_error()``app.session_evaluation` 저장 packet(`status/source/scope/stage/payload/error`) 생성을 소유한다. Node conformance runner는 stream event 목록과 payload 검증을 `engine_gateway_schema.v1.json``StreamPacket.oneOf`, payload `$ref`, `x-engine-gateway-sse-events`에서 읽는다. `routes/personas.py`는 auth, source 등록·조회, RAG evidence, engine 호출, provenance 조립, HTTP error mapping을 유지하고, `routes/sessions.py`/`routes/eval.py`는 factory로 named packet을 만들어 저장한다. **검증: `py -3.11 -X utf8 -B -m py_compile app\persona_generation_contract.py app\routes\personas.py app\persona_read_model.py app\test_persona_generation_contract.py app\test_persona_review.py engine_gateway\test_gateway_model.py app\session_persistence.py app\routes\sessions.py app\routes\eval.py app\services\evaluator.py app\services\live_coach.py`, `py -3.11 -X utf8 -B -m pytest -p no:cacheprovider app\test_persona_generation_contract.py app\test_persona_review.py -q` 39 passed, backend focused 120 passed, `node scripts\check-engine-gateway-contract.mjs --json`, `npm run check:api-types`, `npm run typecheck`.**

View file

@ -17,7 +17,7 @@ The export may be marked `approved_for_recursive_learning_seed` only when:
- Direct identifiers and raw identity mapping are absent from exported files. - Direct identifiers and raw identity mapping are absent from exported files.
- Free text is masked or transformed according to the manifest. - Free text is masked or transformed according to the manifest.
- PII scan is pass, or every finding has a reviewer disposition. - PII scan is pass, or every finding has a reviewer disposition.
- IAA gates pass for gold exports: kappa >=0.60 and ICC >=0.75. - IAA gates pass for gold exports: kappa >=0.70 and ICC >=0.75. (owner-confirmed 2026-06-30)
- Legal/data-steward review is complete. - Legal/data-steward review is complete.
If any gate is missing, set `export_status` to `technical_dry_run` or `blocked`, not If any gate is missing, set `export_status` to `technical_dry_run` or `blocked`, not

View file

@ -7,6 +7,14 @@ time. These metrics are pilot evidence, not claims of validated clinical effecti
Primary evidence file: `02-measures/kpi_report.json` Primary evidence file: `02-measures/kpi_report.json`
## Evaluation Design (owner-confirmed 2026-06-30)
- **표본·배정**: 20명을 실험군/통제군으로 **무작위 배정(randomized)**. 단회기 50분, 3척도(self-efficacy / skill proficiency / training satisfaction) pre-post.
- **통계검정**: 군 간 비교는 **독립표본 t-검정**, 유의수준 **α=0.05**. 정규성 위반 시 Mann-Whitney U로 대체 가능하나 기본은 t-검정.
- **신뢰도 목표**: 평가자 간 **κ≥0.70**(Cohen's kappa), 수치 평정 **ICC≥0.75**.
- **환각률 목표**: 상담 응답 환각률 **≤0.03**.
- 위 파라미터는 설계 확정값이며, 실제 효과성 판정·통계해석은 20명 파일럿 evidence 수집 후 수행한다(파일럿 게이트).
## Report Shape ## Report Shape
```json ```json
@ -60,10 +68,10 @@ Each metric object must include:
| Pilot completion | >=20 participants | Count active participants with consent, required sessions, pre/post, SUS, and supervisor review. | `pilot_roster.csv`, `session_completion.csv`, `prepost_measures.csv`, `sus_responses.csv` | | Pilot completion | >=20 participants | Count active participants with consent, required sessions, pre/post, SUS, and supervisor review. | `pilot_roster.csv`, `session_completion.csv`, `prepost_measures.csv`, `sus_responses.csv` |
| Session completion | >=20 participants | Count participants with at least two `completed` sessions and non-empty `supervisor_reviewed_at`. | `session_completion.csv` | | Session completion | >=20 participants | Count participants with at least two `completed` sessions and non-empty `supervisor_reviewed_at`. | `session_completion.csv` |
| Embedding consistency | >=0.79 | Mean cosine similarity for persona invariant checks across masked session turns. Report model, embedding version, mean, p10, and count. | DB query export, `kpi_report.json` | | Embedding consistency | >=0.79 | Mean cosine similarity for persona invariant checks across masked session turns. Report model, embedding version, mean, p10, and count. | DB query export, `kpi_report.json` |
| Hallucination rate | <=0.05, target <=0.01 | Unsupported or contradicted factual claims divided by reviewed eligible evaluator/client-response claims. | supervisor review export, `kpi_report.json` | | Hallucination rate | <=0.03 (owner 2026-06-30) | Unsupported or contradicted factual claims divided by reviewed eligible evaluator/client-response claims. | supervisor review export, `kpi_report.json` |
| Top-1 retrieval | >=0.80 | Top-1 evidence match rate for golden feedback or RAG evaluation queries. | golden evaluation run, `kpi_report.json` | | Top-1 retrieval | >=0.80 | Top-1 evidence match rate for golden feedback or RAG evaluation queries. | golden evaluation run, `kpi_report.json` |
| SUS | >=80 | Standard 10-item SUS score transformed to a 0-100 score; report mean, median, and response count. | `sus_responses.csv` | | SUS | >=80 | Standard 10-item SUS score transformed to a 0-100 score; report mean, median, and response count. | `sus_responses.csv` |
| Cohen kappa | >=0.60 | Agreement between human reviewers or human-vs-AI labels for categorical tags used in dataset export. | annotation export, `kpi_report.json` | | Cohen kappa | >=0.70 (owner 2026-06-30) | Agreement between human reviewers or human-vs-AI labels for categorical tags used in dataset export. | annotation export, `kpi_report.json` |
| ICC | >=0.75 | Intraclass correlation for numeric ratings such as feedback score dimensions. | annotation export, `kpi_report.json` | | ICC | >=0.75 | Intraclass correlation for numeric ratings such as feedback score dimensions. | annotation export, `kpi_report.json` |
| Self-efficacy pre/post | Report and review | Paired pre/post change for the approved self-efficacy measure. Report effect direction, test used, p-value, and missingness. | `prepost_measures.csv` | | Self-efficacy pre/post | Report and review | Paired pre/post change for the approved self-efficacy measure. Report effect direction, test used, p-value, and missingness. | `prepost_measures.csv` |

View file

@ -25,10 +25,10 @@ EXPECTED_STATUS_COUNTS = {
} }
EXPECTED_OWNER_COLUMN_COUNTS = { EXPECTED_OWNER_COLUMN_COUNTS = {
"block": 2, "block": 2,
"decide": 7, "decide": 0,
"ext": 2, "ext": 2,
} }
EXPECTED_DECISION_DETAIL_ROWS = 7 EXPECTED_DECISION_DETAIL_ROWS = 0
EXPECTED_EXTERNAL_GATE_ROWS = 6 EXPECTED_EXTERNAL_GATE_ROWS = 6
REQUIRED_SHARED_PHRASES = ( REQUIRED_SHARED_PHRASES = (
@ -38,9 +38,9 @@ REQUIRED_SHARED_PHRASES = (
"실 provider 장시간 운영", "실 provider 장시간 운영",
"임상 골든셋", "임상 골든셋",
"재압축", "재압축",
"블로커 2 · 결정 7 · 외부조율 2", "블로커 2 · 결정 0 · 외부조율 2",
"decisions 7건(owner 결정)", "owner 결정 7건은 2026-06-30 전건 확정",
"결정 7개", "결정 0개(전건 확정)",
"MASTERPLAN_REVISIONS F-10 승인", "MASTERPLAN_REVISIONS F-10 승인",
) )
@ -59,6 +59,12 @@ FORBIDDEN_STALE_PHRASES = (
"자유연습 기본값</b><p>수정안", "자유연습 기본값</b><p>수정안",
"decisions 8건(owner 결정)", "decisions 8건(owner 결정)",
"결정 8개", "결정 8개",
"블로커 2 · 결정 7 · 외부조율 2",
"decisions 7건(owner 결정)",
"결정 7개",
"블로커 2 · 결정 3 · 외부조율 2",
"decisions 3건(owner 결정)",
"결정 3개",
) )

View file

@ -435,8 +435,8 @@ def validate_manifest(root: Path, report: Report) -> None:
else: else:
kappa = numeric_value(agreement.get("kappa")) kappa = numeric_value(agreement.get("kappa"))
icc = numeric_value(agreement.get("icc")) icc = numeric_value(agreement.get("icc"))
if kappa is None or kappa < 0.60: if kappa is None or kappa < 0.70:
report.error(f"{rel_path}: approved export requires agreement.kappa >= 0.60") report.error(f"{rel_path}: approved export requires agreement.kappa >= 0.70")
if icc is None or icc < 0.75: if icc is None or icc < 0.75:
report.error(f"{rel_path}: approved export requires agreement.icc >= 0.75") report.error(f"{rel_path}: approved export requires agreement.icc >= 0.75")
selection_criteria = data.get("selection_criteria") selection_criteria = data.get("selection_criteria")

View file

@ -170,7 +170,7 @@ async def fetch_agreement(conn: asyncpg.Connection, dataset_id: int | None, args
rows = [row_to_dict(row) for row in await conn.fetch(ANNOTATION_QUERY, dataset_id)] rows = [row_to_dict(row) for row in await conn.fetch(ANNOTATION_QUERY, dataset_id)]
kappa = cohen_kappa(rows, args.kappa_label) kappa = cohen_kappa(rows, args.kappa_label)
icc = intraclass_correlation(rows, args.icc_label) icc = intraclass_correlation(rows, args.icc_label)
gold_status = "gold_candidate" if kappa is not None and kappa >= 0.60 and icc is not None and icc >= 0.75 else "not_gold" gold_status = "gold_candidate" if kappa is not None and kappa >= 0.70 and icc is not None and icc >= 0.75 else "not_gold"
return {"kappa": kappa, "icc": icc, "gold_status": gold_status} return {"kappa": kappa, "icc": icc, "gold_status": gold_status}