G0~G8 성과·동맹 측정 OS 작업 일괄 고정
8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본 폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을 git 이력으로 고정하는 것이 목적이다. - contracts/routes/services: measurement, outcome_trajectory, rupture_repair, deliberate_practice, calibration_transfer, supervision_research, multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트 - infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행) - apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가 - docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG - scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트 engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
This commit is contained in:
parent
93dd8f82d7
commit
16e791e044
390 changed files with 243188 additions and 499 deletions
366
apps/web/e2e/continuous-improvement-admin.spec.ts
Normal file
366
apps/web/e2e/continuous-improvement-admin.spec.ts
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { expectNoHorizontalOverflow } from "./support";
|
||||
|
||||
const MODEL_GATE_ID = "10000000-0000-4000-8000-000000000001";
|
||||
const RELEASE_GATE_ID = "10000000-0000-4000-8000-000000000002";
|
||||
const INCIDENT_ID = "10000000-0000-4000-8000-000000000003";
|
||||
const CREATED_AT = "2026-08-06T08:30:00Z";
|
||||
|
||||
function artifact(
|
||||
recordId: string,
|
||||
ownerKind: "model_change_gate" | "release_gate",
|
||||
ownerId: string,
|
||||
kind: "baseline" | "threshold" | "provenance" | "rollback",
|
||||
) {
|
||||
return {
|
||||
artifact_record_id: recordId,
|
||||
owner_kind: ownerKind,
|
||||
owner_id: ownerId,
|
||||
artifact_kind: kind,
|
||||
artifact_id: `oas-g8-${kind}-${ownerId.slice(-4)}`,
|
||||
content_sha256: kind.slice(0, 1).repeat(64),
|
||||
provenance_uri: `audit://synthetic/g8/${ownerId}/${kind}`,
|
||||
created_at: CREATED_AT,
|
||||
};
|
||||
}
|
||||
|
||||
function baseView() {
|
||||
return {
|
||||
content_qualifications: [
|
||||
{
|
||||
qualification_id: "20000000-0000-4000-8000-000000000001",
|
||||
pipeline_id: "20000000-0000-4000-8000-000000000002",
|
||||
catalog_entry_id: "oas-g8-catalog-synthetic-rupture",
|
||||
payload_sha256: "a".repeat(64),
|
||||
content_kind: "rupture" as const,
|
||||
difficulty_level: 3,
|
||||
synthetic_identity_id: "synthetic-identity-synthetic-rupture",
|
||||
source_count: 1,
|
||||
red_team_review_count: 2,
|
||||
benchmark_variant_count: 3,
|
||||
benchmark_pass_rate: 1,
|
||||
source_provenance_uris: [
|
||||
"repo://apps/api/app/data/continuous_improvement/synthetic_source_pack.v2.json",
|
||||
],
|
||||
draft_payload: {
|
||||
title: "합성 관계 균열 수선 연습",
|
||||
synthetic_profile: "실존 인물과 무관한 합성 내담자",
|
||||
scenario: "상담자가 주제를 너무 빨리 바꿔 합성 내담자가 서두른다고 느낀 상황",
|
||||
rupture_or_challenge: "상호작용을 명명하고 내담자의 정정을 초대한다.",
|
||||
learner_task: "영향을 방어하지 않고 인정한 뒤 다음 초점을 공동 결정한다.",
|
||||
success_criteria: ["상호작용 명명", "정정 초대"],
|
||||
source_refs: ["oas-g8-source-repo-synthetic-rupture-v2"],
|
||||
grounded_claims: [
|
||||
{
|
||||
claim: "합성 수련 시나리오",
|
||||
source_ref: "oas-g8-source-repo-synthetic-rupture-v2",
|
||||
},
|
||||
],
|
||||
},
|
||||
gate_state: "pending_human_approval",
|
||||
created_at: CREATED_AT,
|
||||
},
|
||||
],
|
||||
model_change_gates: [
|
||||
{
|
||||
gate_id: MODEL_GATE_ID,
|
||||
gate_decision: "promote",
|
||||
reasons: ["synthetic benchmark threshold passed", "coverage drift stable"],
|
||||
state: "pending_human_approval",
|
||||
created_at: CREATED_AT,
|
||||
},
|
||||
],
|
||||
release_gates: [
|
||||
{
|
||||
gate_id: RELEASE_GATE_ID,
|
||||
release_id: "oas-g8-release-2026-08-06",
|
||||
qualified: true,
|
||||
state: "pending_human_approval",
|
||||
created_at: CREATED_AT,
|
||||
},
|
||||
],
|
||||
gate_artifacts: [
|
||||
artifact("30000000-0000-4000-8000-000000000001", "model_change_gate", MODEL_GATE_ID, "baseline"),
|
||||
artifact("30000000-0000-4000-8000-000000000002", "model_change_gate", MODEL_GATE_ID, "threshold"),
|
||||
artifact("30000000-0000-4000-8000-000000000003", "model_change_gate", MODEL_GATE_ID, "provenance"),
|
||||
artifact("30000000-0000-4000-8000-000000000004", "model_change_gate", MODEL_GATE_ID, "rollback"),
|
||||
artifact("30000000-0000-4000-8000-000000000005", "release_gate", RELEASE_GATE_ID, "baseline"),
|
||||
artifact("30000000-0000-4000-8000-000000000006", "release_gate", RELEASE_GATE_ID, "threshold"),
|
||||
artifact("30000000-0000-4000-8000-000000000007", "release_gate", RELEASE_GATE_ID, "provenance"),
|
||||
],
|
||||
approvals: [],
|
||||
catalog_entries: [],
|
||||
lifecycle_events: [
|
||||
{
|
||||
lifecycle_event_id: "40000000-0000-4000-8000-000000000003",
|
||||
target_kind: "model_change_gate",
|
||||
target_id: "40000000-0000-4000-8000-000000000004",
|
||||
event_type: "rollback",
|
||||
event_status: "requested",
|
||||
evidence_refs: ["audit://synthetic/g8/rollback-requested"],
|
||||
created_at: "2026-08-06T08:20:00Z",
|
||||
},
|
||||
{
|
||||
lifecycle_event_id: "40000000-0000-4000-8000-000000000005",
|
||||
target_kind: "model_change_gate",
|
||||
target_id: "40000000-0000-4000-8000-000000000006",
|
||||
event_type: "rollback",
|
||||
event_status: "failed",
|
||||
evidence_refs: ["audit://synthetic/g8/rollback-failed"],
|
||||
created_at: "2026-08-06T08:10:00Z",
|
||||
},
|
||||
{
|
||||
lifecycle_event_id: "40000000-0000-4000-8000-000000000001",
|
||||
target_kind: "model_change_gate",
|
||||
target_id: "40000000-0000-4000-8000-000000000002",
|
||||
event_type: "rollback",
|
||||
event_status: "executed",
|
||||
evidence_refs: ["audit://synthetic/g8/rollback"],
|
||||
created_at: "2026-08-06T08:00:00Z",
|
||||
},
|
||||
],
|
||||
incidents: [
|
||||
{
|
||||
incident_record_id: INCIDENT_ID,
|
||||
incident_id: "oas-g8-incident-provider-timeout",
|
||||
error_fingerprint: "e".repeat(64),
|
||||
affected_contract: "synthetic.replay.provider-timeout",
|
||||
evidence_refs: ["audit://synthetic/g8/incident"],
|
||||
pii_included: false,
|
||||
created_at: CREATED_AT,
|
||||
},
|
||||
],
|
||||
regression_dag_nodes: [
|
||||
["01", "reproduction_test", "passed"],
|
||||
["02", "implementation", "passed"],
|
||||
["03", "e2e", "pending"],
|
||||
["04", "runtime_proof", "pending"],
|
||||
].map(([suffix, nodeType, nodeStatus], index, all) => ({
|
||||
node_record_id: `50000000-0000-4000-8000-0000000000${suffix}`,
|
||||
incident_record_id: INCIDENT_ID,
|
||||
node_id: `oas-g8-node-provider-timeout-${nodeType}`,
|
||||
node_type: nodeType,
|
||||
depends_on_record_ids:
|
||||
index === 0 ? [] : [`50000000-0000-4000-8000-0000000000${all[index - 1][0]}`],
|
||||
evidence_ref: nodeStatus === "passed" ? `audit://synthetic/g8/${nodeType}` : null,
|
||||
node_status: nodeStatus,
|
||||
created_at: CREATED_AT,
|
||||
})),
|
||||
data_classification: "synthetic_replay_red_team_coverage_drift",
|
||||
silent_auto_promotion_allowed: false,
|
||||
raw_transcript_included: false,
|
||||
pii_included: false,
|
||||
clinical_claim_allowed: false,
|
||||
};
|
||||
}
|
||||
|
||||
type MockOptions = {
|
||||
role?: "admin" | "teacher" | "learner";
|
||||
status?: number;
|
||||
empty?: boolean;
|
||||
};
|
||||
|
||||
async function installMock(page: Page, options: MockOptions = {}) {
|
||||
const view = baseView();
|
||||
if (options.empty) {
|
||||
view.content_qualifications = [];
|
||||
view.model_change_gates = [];
|
||||
view.release_gates = [];
|
||||
view.gate_artifacts = [];
|
||||
view.incidents = [];
|
||||
view.regression_dag_nodes = [];
|
||||
view.lifecycle_events = [];
|
||||
}
|
||||
const approvalBodies: Record<string, unknown>[] = [];
|
||||
|
||||
await page.route("**/api/**", async (route) => {
|
||||
const request = route.request();
|
||||
const path = new URL(request.url()).pathname;
|
||||
const fulfill = (body: unknown, status = 200) =>
|
||||
route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) });
|
||||
|
||||
if (request.method() === "GET" && path.endsWith("/auth/me")) {
|
||||
const role = options.role ?? "admin";
|
||||
await fulfill({
|
||||
user_id: "60000000-0000-4000-8000-000000000001",
|
||||
email: `${role}@twentyoz.kr`,
|
||||
display_name: `CI ${role}`,
|
||||
role,
|
||||
admin_access: false,
|
||||
super_admin: false,
|
||||
account_status: "approved",
|
||||
approval_required: false,
|
||||
cohort_ids: [],
|
||||
consent_at: 1_754_378_000,
|
||||
onboarding_completed_at: 1_754_378_000,
|
||||
nickname: "",
|
||||
self_introduction: "",
|
||||
avatar_url: "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === "GET" && path.endsWith("/continuous-improvement")) {
|
||||
if (options.status && options.status !== 200) {
|
||||
await fulfill({ detail: "continuous improvement read model unavailable" }, options.status);
|
||||
return;
|
||||
}
|
||||
await fulfill(view);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method() === "POST" && path.endsWith("/continuous-improvement/approvals")) {
|
||||
const body = request.postDataJSON() as Record<string, string>;
|
||||
approvalBodies.push(body);
|
||||
view.approvals.push({
|
||||
approval_event_id: body.approval_event_id,
|
||||
target_kind: body.target_kind as "content_qualification" | "model_change_gate",
|
||||
target_id: body.target_id,
|
||||
decision: body.decision as "approve_content" | "approve_promotion",
|
||||
reason_code: body.reason_code,
|
||||
evidence_refs: body.evidence_refs as unknown as string[],
|
||||
created_at: CREATED_AT,
|
||||
});
|
||||
if (body.target_kind === "content_qualification") {
|
||||
view.catalog_entries.unshift({
|
||||
catalog_record_id: body.effect_record_id,
|
||||
qualification_id: body.target_id,
|
||||
catalog_entry_id: "oas-g8-catalog-synthetic-rupture",
|
||||
status: "approved",
|
||||
clinical_claim_allowed: false,
|
||||
created_at: CREATED_AT,
|
||||
});
|
||||
} else {
|
||||
view.lifecycle_events.unshift({
|
||||
lifecycle_event_id: body.effect_record_id,
|
||||
target_kind: body.target_kind as "model_change_gate",
|
||||
target_id: body.target_id,
|
||||
event_type: "promotion",
|
||||
event_status: "approved",
|
||||
evidence_refs: body.evidence_refs as unknown as string[],
|
||||
created_at: CREATED_AT,
|
||||
});
|
||||
}
|
||||
await fulfill(
|
||||
{
|
||||
submission_id: body.submission_id,
|
||||
approval_event_id: body.approval_event_id,
|
||||
target_kind: body.target_kind,
|
||||
target_id: body.target_id,
|
||||
decision: body.decision,
|
||||
effect_record_id: body.effect_record_id,
|
||||
idempotent_replay: false,
|
||||
},
|
||||
201,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfill({ detail: `unmocked ${request.method()} ${path}` }, 404);
|
||||
});
|
||||
|
||||
return { view, approvalBodies };
|
||||
}
|
||||
|
||||
test.describe("continuous improvement admin cockpit", () => {
|
||||
test("renders the generated-content, gate, incident and rollback ledgers fail-closed", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.emulateMedia({ colorScheme: "dark", reducedMotion: "reduce" });
|
||||
await page.addInitScript(() => localStorage.setItem("vignette.theme", "dark"));
|
||||
const mock = await installMock(page);
|
||||
|
||||
await page.goto("/admin/continuous-improvement");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "승격보다 근거를 먼저 본다" })).toBeVisible();
|
||||
await expect(page.getByText("합성 콘텐츠 검증 파이프라인")).toBeVisible();
|
||||
await expect(page.getByText("모델 변경 · 릴리스 게이트")).toBeVisible();
|
||||
await expect(page.getByText("사건 회귀 DAG")).toBeVisible();
|
||||
await expect(page.getByText("모니터 · 롤백 원장")).toBeVisible();
|
||||
await expect(page.getByText("롤백 실행 대기 · 실행기 미구성", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("롤백 실행 실패", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("롤백은 실행됐지만 verification 이벤트가 아직 없어")).toBeVisible();
|
||||
const contentCandidate = page.locator(
|
||||
'[data-qualification-id="20000000-0000-4000-8000-000000000001"]',
|
||||
);
|
||||
await expect(contentCandidate.getByText("합성 관계 균열 수선 연습")).toBeVisible();
|
||||
await contentCandidate.getByText("검수 payload 펼쳐 보기").click();
|
||||
await expect(contentCandidate.getByText("영향을 방어하지 않고 인정한 뒤")).toBeVisible();
|
||||
await expect(contentCandidate).not.toContainText("hidden_answer");
|
||||
const contentReason = contentCandidate.getByLabel("콘텐츠 승인 사유");
|
||||
await expect(contentCandidate.getByRole("button", { name: "카탈로그 승인" })).toBeDisabled();
|
||||
await contentReason.fill("합성 경계와 수련 목표를 직접 검수함");
|
||||
await contentReason.press("Enter");
|
||||
await expect(contentCandidate.getByText("카탈로그 승인 원장 기록됨")).toBeVisible();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
await page.getByTestId("continuous-improvement-cockpit").screenshot({
|
||||
path: testInfo.outputPath("g8-continuous-improvement-before-approval.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
|
||||
const missingGate = page.locator(`[data-gate-id="${RELEASE_GATE_ID}"]`);
|
||||
await expect(missingGate.getByRole("button", { name: "증거 4종 미완료" })).toBeDisabled();
|
||||
await expect(missingGate.getByText("필수 증거 4종이 모두 있어야 기록 가능")).toBeVisible();
|
||||
|
||||
const modelGate = page.locator(`[data-gate-id="${MODEL_GATE_ID}"]`);
|
||||
const approvalButton = modelGate.getByRole("button", { name: "사람 승인 기록" });
|
||||
const approvalReason = modelGate.getByLabel("사람 승인 사유");
|
||||
await expect(approvalButton).toBeDisabled();
|
||||
await expect(modelGate.getByText("승인 사유를 먼저 입력해야 함")).toBeVisible();
|
||||
await approvalReason.fill("기준선과 rollback runbook을 독립 검토함");
|
||||
await expect(approvalButton).toBeEnabled();
|
||||
await expect(modelGate.getByText("증거 4종과 승인 사유가 준비됨")).toBeVisible();
|
||||
await approvalButton.focus();
|
||||
await expect(approvalButton).toBeFocused();
|
||||
await approvalReason.focus();
|
||||
await approvalReason.press("Enter");
|
||||
|
||||
await expect(modelGate.getByText("append-only 승인 원장 기록됨")).toBeVisible();
|
||||
expect(mock.approvalBodies).toHaveLength(2);
|
||||
expect(mock.approvalBodies[0]).toMatchObject({
|
||||
target_kind: "content_qualification",
|
||||
target_id: "20000000-0000-4000-8000-000000000001",
|
||||
decision: "approve_content",
|
||||
reason_code: "합성 경계와 수련 목표를 직접 검수함",
|
||||
});
|
||||
expect(mock.approvalBodies[1]).toMatchObject({
|
||||
target_kind: "model_change_gate",
|
||||
target_id: MODEL_GATE_ID,
|
||||
decision: "approve_promotion",
|
||||
reason_code: "기준선과 rollback runbook을 독립 검토함",
|
||||
});
|
||||
expect(mock.approvalBodies[1].evidence_refs).toHaveLength(4);
|
||||
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("g8-continuous-improvement-cockpit.png"),
|
||||
fullPage: true,
|
||||
animations: "disabled",
|
||||
});
|
||||
});
|
||||
|
||||
test("shows empty and degraded states without opening an approval action", async ({ page }) => {
|
||||
await installMock(page, { empty: true });
|
||||
await page.goto("/admin/continuous-improvement");
|
||||
await expect(page.getByText("검토할 콘텐츠 후보가 없어")).toBeVisible();
|
||||
await expect(page.getByText("대기 중인 게이트가 없어")).toBeVisible();
|
||||
await expect(page.getByText("등록된 운영 사건이 없어")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /승인 기록|결정 기록/ })).toHaveCount(0);
|
||||
|
||||
await page.unroute("**/api/**");
|
||||
await installMock(page, { status: 503 });
|
||||
await page.reload();
|
||||
await expect(page.getByRole("heading", { name: "개선 원장을 확인할 수 없어" })).toBeVisible();
|
||||
await expect(page.getByText(/승인 동작은 닫힌 상태/)).toBeVisible();
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
for (const role of ["teacher", "learner"] as const) {
|
||||
test(`does not expose the admin cockpit to ${role}`, async ({ page }) => {
|
||||
await installMock(page, { role });
|
||||
await page.goto("/admin/continuous-improvement");
|
||||
await expect(page).toHaveURL(role === "teacher" ? /\/teach$/ : /\/learn$/);
|
||||
await expect(page.getByTestId("continuous-improvement-cockpit")).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue