관리자 증거와 계정 흐름을 정돈

This commit is contained in:
Yun Chan 2026-08-30 00:01:17 +09:00
parent 21bbe3f98d
commit 34aff65cb0
9 changed files with 1100 additions and 193 deletions

View file

@ -45,6 +45,15 @@ interface AdminUsersResponse {
users: AdminManagedUser[];
}
type UsageCostBasis =
| "provider_estimate"
| "provider_reported"
| "reference_rate"
| "reference_upper_bound"
| "partial"
| "partial_upper_bound"
| "unavailable";
interface AdminUsageBreakdown {
provider: string;
model: string;
@ -54,6 +63,11 @@ interface AdminUsageBreakdown {
tokens_in: number;
tokens_out: number;
cost_usd: number;
recorded_cost_usd?: number;
estimated_cost_usd?: number;
cost_basis?: UsageCostBasis;
rate_label?: string | null;
rate_source_url?: string | null;
}
interface AdminUsageDailyCost {
@ -62,13 +76,15 @@ interface AdminUsageDailyCost {
tokens_in: number;
tokens_out: number;
cost_usd: number;
cost_basis?: UsageCostBasis;
}
interface AdminUsageBudget {
limit_usd: number;
used_ratio: number;
remaining_usd: number | null;
status: "disabled" | "ok" | "warn" | "exceeded";
status: "disabled" | "ok" | "warn" | "exceeded" | "indeterminate";
cost_basis?: UsageCostBasis;
}
interface AdminUsageEvaluatorCache {
@ -94,6 +110,9 @@ interface AdminUsageResponse {
tokens_in: number;
tokens_out: number;
cost_usd: number;
recorded_cost_usd?: number;
estimated_cost_usd?: number;
cost_basis?: UsageCostBasis;
budget: AdminUsageBudget;
evaluator_cache?: AdminUsageEvaluatorCache;
by_provider: AdminUsageBreakdown[];
@ -927,20 +946,22 @@ test.describe("admin route guards", () => {
durable: true,
generated_at: 1_783_990_800,
window_days: 30,
total_turns: 37,
metered_turns: 37,
token_metered_turns: 35,
total_turns: 53,
metered_turns: 53,
token_metered_turns: 51,
token_unmetered_turns: 2,
tokens_in: 160_703,
tokens_out: 19_629,
cost_usd: 7.023222,
tokens_in: 240_950,
tokens_out: 22_907,
cost_usd: 7.064689,
recorded_cost_usd: 6.9612,
estimated_cost_usd: 0.062022,
estimated_cost_usd: 0.103489,
cost_basis: "reference_upper_bound",
budget: {
limit_usd: 20,
used_ratio: 0.3512,
remaining_usd: 12.976778,
used_ratio: 0.3532,
remaining_usd: 12.935311,
status: "ok",
cost_basis: "reference_upper_bound",
},
evaluator_cache: {
enabled: true,
@ -981,24 +1002,45 @@ test.describe("admin route guards", () => {
},
{
provider: "agy_cli",
model: "gemini-3.6-flash-high",
turns: 5,
token_metered_turns: 5,
model: "gemini-3.7-flash-high",
turns: 21,
token_metered_turns: 21,
token_unmetered_turns: 0,
tokens_in: 35_703,
tokens_out: 1_129,
cost_usd: 0.062022,
tokens_in: 115_950,
tokens_out: 4_407,
cost_usd: 0.103489,
recorded_cost_usd: 0,
estimated_cost_usd: 0.062022,
cost_basis: "reference_rate",
estimated_cost_usd: 0.103489,
cost_basis: "reference_upper_bound",
rate_label:
"Google Gemini 3.6 Flash 표준 단가 · 입력 $1.50/M · 캐시 $0.15/M · 출력 $7.50/M",
"Google Gemini 3.7 Flash 프로모션 표준 단가(2026-12-31까지) · 입력 $0.75/M · 캐시 $0.075/M · 출력 $3.75/M",
},
],
daily_cost: [
{ day: "2026-07-13", turns: 8, tokens_in: 32_000, tokens_out: 4_800, cost_usd: 1.42 },
{ day: "2026-07-14", turns: 10, tokens_in: 41_000, tokens_out: 6_100, cost_usd: 2.08 },
{ day: "2026-07-15", turns: 17, tokens_in: 87_703, tokens_out: 8_729, cost_usd: 3.183222 },
{
day: "2026-07-13",
turns: 8,
tokens_in: 32_000,
tokens_out: 4_800,
cost_usd: 1.42,
cost_basis: "provider_reported",
},
{
day: "2026-07-14",
turns: 10,
tokens_in: 41_000,
tokens_out: 6_100,
cost_usd: 2.08,
cost_basis: "provider_reported",
},
{
day: "2026-07-15",
turns: 35,
tokens_in: 167_950,
tokens_out: 12_007,
cost_usd: 3.564689,
cost_basis: "reference_upper_bound",
},
],
},
},
@ -1014,17 +1056,21 @@ test.describe("admin route guards", () => {
);
await expect(page.getByText("운영 DB 원장").first()).toBeVisible();
// 2026-07-27 D7: 합계 금액은 화면에 소수 2자리로 표시하고 원본 정밀도는 title 로 옮겼다.
await expect(page.locator(".aic-ledger")).toContainText("$7.02");
await expect(page.locator(".aic-ledger b").first()).toHaveAttribute("title", /7\.023222/);
await expect(page.locator(".aic-budget")).toContainText("94.6%");
await expect(page.locator(".aic-ledger")).toContainText("≤$7.06");
await expect(page.locator(".aic-ledger b").first()).toHaveAttribute("title", /≤\$7\.064689/);
await expect(page.locator(".aic-budget")).toContainText("잔여 ≥$12.94 · ≤35.3% 사용");
await expect(page.locator(".aic-table")).toContainText("gpt-5-mini");
await expect(page.locator(".aic-table")).toContainText("gemini-3.6-flash-high");
await expect(page.locator(".aic-table")).toContainText("참조단가");
await expect(page.locator(".aic-table")).toContainText("gemini-3.7-flash-high");
await expect(page.locator(".aic-table")).toContainText("참조 상한");
await expect(page.locator(".aic-table")).toContainText("≤$0.10");
await expect(page.locator(".aic-table")).toContainText("≤$0.0049");
await expect(page.locator(".aic-table")).toContainText("SDK 추정");
await expect(page.locator(".aic-table")).toContainText("미계량");
await expect(page.locator(".aic-table")).toContainText("$0.06");
await expect(page.locator(".aic-table")).toContainText("0.9%");
await expect(page.locator(".aic-ledger")).toContainText("기록 $6.96 · 참조 $0.06");
await expect(page.locator(".aic-table")).toContainText("$0.10");
await expect(page.locator(".aic-table")).toContainText("$0.0049");
await expect(page.locator(".aic-table tbody tr").first().locator("td").nth(6)).toHaveText("—");
await expect(page.locator(".aic-ledger")).toContainText("기록 $6.96 · 참조 ≤$0.10 · 참조 상한");
await expect(page.locator(".aic-chart__day").last()).toContainText("≤$3.56");
await expect(page.locator(".aic-cache-score")).toContainText("80%");
await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gateway-default");
await expect(page.getByLabel("AI 엔진 공급자").locator("option")).toHaveCount(6);
@ -1072,6 +1118,95 @@ test.describe("admin route guards", () => {
await expectNoHorizontalOverflow(page);
});
test("shows partial AI cost without inventing a per-call average", async ({ page }) => {
await mockAdminSession(
page,
{
user_id: "partial-cost-admin",
email: "partial-cost-admin@twentyoz.kr",
display_name: "Partial Cost Admin",
role: "admin",
admin_access: true,
super_admin: true,
onboarding_completed_at: 1_782_900_000,
},
{
adminUsage: {
source: "database",
durable: true,
generated_at: 1_783_990_800,
window_days: 30,
total_turns: 2,
metered_turns: 2,
token_metered_turns: 2,
token_unmetered_turns: 0,
tokens_in: 2_000_000,
tokens_out: 2_000_000,
cost_usd: 4.5,
recorded_cost_usd: 0,
estimated_cost_usd: 4.5,
cost_basis: "partial",
budget: {
limit_usd: 20,
used_ratio: 0.225,
remaining_usd: 15.5,
status: "indeterminate",
cost_basis: "partial",
},
evaluator_cache: {
enabled: false,
entries: 0,
hits: 0,
misses: 0,
stores: 0,
evictions: 0,
requests: 0,
hit_rate: 0,
},
by_provider: [
{
provider: "agy_cli",
model: "gemini-3.7-flash-high",
turns: 2,
token_metered_turns: 2,
token_unmetered_turns: 0,
tokens_in: 2_000_000,
tokens_out: 2_000_000,
cost_usd: 4.5,
recorded_cost_usd: 0,
estimated_cost_usd: 4.5,
cost_basis: "partial",
rate_label: "일부 호출 미산정 · 표시액은 산정 가능분 합계",
},
],
daily_cost: [
{
day: "2026-08-13",
turns: 2,
tokens_in: 2_000_000,
tokens_out: 2_000_000,
cost_usd: 4.5,
cost_basis: "partial",
},
],
},
},
);
await page.goto("/admin/ai");
const row = page.locator(".aic-table tbody tr").filter({ hasText: "gemini-3.7-flash-high" });
await expect(row).toContainText("$4.50+");
await expect(row).toContainText("일부 산정");
await expect(row.locator("td").nth(5)).toHaveText("—");
await expect(row.locator("td").nth(6)).toHaveText("—");
await expect(page.locator(".aic-ledger b").first()).toHaveText("$4.50+");
await expect(page.locator(".aic-ledger article").last().locator("b")).toHaveText("—");
await expect(page.locator(".aic-budget")).toContainText("예산 미확정");
await expect(page.locator(".aic-budget")).toContainText("잔여 ≤$15.50 · ≥22.5% 사용");
await expect(page.locator(".aic-chart__value")).toHaveText("$4.50+");
});
test("keeps the boot diagnostic overlay off a rendered AI operations page", async ({ page }) => {
await mockAdminSession(page, {
user_id: "ai-watchdog-admin",

View file

@ -6,6 +6,24 @@ 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";
type ApprovalDecision =
| "approve_content"
| "approve_promotion"
| "authorize_rollback"
| "keep_quarantine"
| "reject";
type ApprovalBody = {
submission_id: string;
approval_event_id: string;
effect_record_id: string;
target_kind: "content_qualification" | "model_change_gate" | "release_gate";
target_id: string;
decision: ApprovalDecision;
reason_code: string;
evidence_refs: string[];
};
function artifact(
recordId: string,
ownerKind: "model_change_gate" | "release_gate",
@ -210,18 +228,18 @@ async function installMock(page: Page, options: MockOptions = {}) {
}
if (request.method() === "POST" && path.endsWith("/continuous-improvement/approvals")) {
const body = request.postDataJSON() as Record<string, string>;
const body = request.postDataJSON() as ApprovalBody;
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_kind: body.target_kind,
target_id: body.target_id,
decision: body.decision as "approve_content" | "approve_promotion",
decision: body.decision,
reason_code: body.reason_code,
evidence_refs: body.evidence_refs as unknown as string[],
evidence_refs: body.evidence_refs,
created_at: CREATED_AT,
});
if (body.target_kind === "content_qualification") {
if (body.target_kind === "content_qualification" && body.decision === "approve_content") {
view.catalog_entries.unshift({
catalog_record_id: body.effect_record_id,
qualification_id: body.target_id,
@ -230,14 +248,17 @@ async function installMock(page: Page, options: MockOptions = {}) {
clinical_claim_allowed: false,
created_at: CREATED_AT,
});
} else {
} else if (
body.decision === "approve_promotion" ||
body.decision === "authorize_rollback"
) {
view.lifecycle_events.unshift({
lifecycle_event_id: body.effect_record_id,
target_kind: body.target_kind as "model_change_gate",
target_kind: body.target_kind as "model_change_gate" | "release_gate",
target_id: body.target_id,
event_type: "promotion",
event_status: "approved",
evidence_refs: body.evidence_refs as unknown as string[],
event_type: body.decision === "authorize_rollback" ? "rollback" : "promotion",
event_status: body.decision === "authorize_rollback" ? "requested" : "approved",
evidence_refs: body.evidence_refs,
created_at: CREATED_AT,
});
}
@ -287,11 +308,13 @@ test.describe("continuous improvement admin cockpit", () => {
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();
const contentReason = contentCandidate.getByLabel("콘텐츠 결정 사유");
await expect(contentCandidate.getByRole("button", { name: "결정 선택 필요" })).toBeDisabled();
await contentCandidate.getByRole("radio", { name: "승인", exact: true }).check();
await expect(contentCandidate.getByRole("button", { name: "승인 기록" })).toBeDisabled();
await contentReason.fill("합성 경계와 수련 목표를 직접 검수함");
await contentReason.press("Enter");
await expect(contentCandidate.getByText("카탈로그 승인 원장 기록됨")).toBeVisible();
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"),
@ -299,23 +322,36 @@ test.describe("continuous improvement admin cockpit", () => {
});
const missingGate = page.locator(`[data-gate-id="${RELEASE_GATE_ID}"]`);
await expect(missingGate.getByRole("button", { name: "증거 4종 미완료" })).toBeDisabled();
await expect(missingGate.getByText("필수 증거 4종이 모두 있어야 기록 가능")).toBeVisible();
await expect(missingGate.getByRole("radio", { name: "승인", exact: true })).toBeDisabled();
await expect(missingGate.getByRole("radio", { name: "보류", exact: true })).toBeEnabled();
await expect(missingGate.getByRole("radio", { name: "반려", exact: true })).toBeEnabled();
await expect(missingGate.getByText("필수 증거 4종 미완료로 승인 차단")).toBeVisible();
await missingGate.screenshot({
path: testInfo.outputPath("g8-human-decision-approval-blocked.png"),
animations: "disabled",
});
const modelGate = page.locator(`[data-gate-id="${MODEL_GATE_ID}"]`);
const approvalButton = modelGate.getByRole("button", { name: "사람 승인 기록" });
const approvalReason = modelGate.getByLabel("사람 승인 사유");
const approvalReason = modelGate.getByLabel("사람 결정 사유");
await expect(modelGate.getByRole("button", { name: "결정 선택 필요" })).toBeDisabled();
await expect(modelGate.getByText("승인·보류·반려 중 하나를 명시적으로 선택해야 함")).toBeVisible();
await modelGate.getByRole("radio", { name: "승인", exact: true }).check();
const approvalButton = modelGate.getByRole("button", { name: "승인 기록" });
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 expect(modelGate.getByText("승인 결정과 사유를 append-only 원장에 기록할 준비됨")).toBeVisible();
await modelGate.screenshot({
path: testInfo.outputPath("g8-human-decision-approval-ready.png"),
animations: "disabled",
});
await approvalButton.focus();
await expect(approvalButton).toBeFocused();
await approvalReason.focus();
await approvalReason.press("Enter");
await expect(modelGate.getByText("append-only 승인 원장 기록됨")).toBeVisible();
await expect(modelGate.getByText("승격 승인 원장 기록됨")).toBeVisible();
expect(mock.approvalBodies).toHaveLength(2);
expect(mock.approvalBodies[0]).toMatchObject({
target_kind: "content_qualification",
@ -332,6 +368,10 @@ test.describe("continuous improvement admin cockpit", () => {
expect(mock.approvalBodies[1].evidence_refs).toHaveLength(4);
await expectNoHorizontalOverflow(page);
// index.html watchdog은 3.5초와 8초에 관리자 표면의 canonical marker를 확인한다.
// 정상 렌더된 지속 개선 콕핏을 dev asset 이름 때문에 진단 화면으로 오인하면 안 된다.
await page.waitForTimeout(8_600);
await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0);
await page.screenshot({
path: testInfo.outputPath("g8-continuous-improvement-cockpit.png"),
fullPage: true,
@ -339,6 +379,60 @@ test.describe("continuous improvement admin cockpit", () => {
});
});
test("blocks only unmet approval and records hold, reject and approval as explicit decisions", async ({
page,
}) => {
const mock = await installMock(page);
await page.goto("/admin/continuous-improvement");
const releaseGate = page.locator(`[data-gate-id="${RELEASE_GATE_ID}"]`);
const releaseApprove = releaseGate.getByRole("radio", { name: "승인", exact: true });
await expect(releaseApprove).toBeDisabled();
await expect(releaseGate.getByText("필수 증거 4종 미완료로 승인 차단")).toBeVisible();
await expect(releaseGate.getByRole("radio", { name: "보류", exact: true })).toBeEnabled();
await expect(releaseGate.getByRole("radio", { name: "반려", exact: true })).toBeEnabled();
await releaseGate.getByRole("radio", { name: "보류", exact: true }).check();
await releaseGate.getByLabel("사람 결정 사유").fill("rollback 증거 보강 뒤 다시 검토");
await releaseGate.getByRole("button", { name: "보류 기록" }).click();
await expect(releaseGate.getByText("보류 원장 기록됨")).toBeVisible();
const contentCandidate = page.locator(
'[data-qualification-id="20000000-0000-4000-8000-000000000001"]',
);
await contentCandidate.getByRole("radio", { name: "반려", exact: true }).check();
await contentCandidate.getByLabel("콘텐츠 결정 사유").fill("검수 기준을 충족하지 못한 후보");
await contentCandidate.getByRole("button", { name: "반려 기록" }).click();
await expect(contentCandidate.getByText("반려 원장 기록됨")).toBeVisible();
const modelGate = page.locator(`[data-gate-id="${MODEL_GATE_ID}"]`);
await modelGate.getByRole("radio", { name: "승인", exact: true }).check();
await modelGate.getByLabel("사람 결정 사유").fill("독립 증거 4종과 승격 조건을 확인함");
await modelGate.getByRole("button", { name: "승인 기록" }).click();
await expect(modelGate.getByText("승격 승인 원장 기록됨")).toBeVisible();
expect(mock.approvalBodies).toHaveLength(3);
expect(mock.approvalBodies[0]).toMatchObject({
target_kind: "release_gate",
target_id: RELEASE_GATE_ID,
decision: "keep_quarantine",
reason_code: "rollback 증거 보강 뒤 다시 검토",
});
expect(mock.approvalBodies[1]).toMatchObject({
target_kind: "content_qualification",
target_id: "20000000-0000-4000-8000-000000000001",
decision: "reject",
reason_code: "검수 기준을 충족하지 못한 후보",
});
expect(mock.approvalBodies[2]).toMatchObject({
target_kind: "model_change_gate",
target_id: MODEL_GATE_ID,
decision: "approve_promotion",
reason_code: "독립 증거 4종과 승격 조건을 확인함",
});
await expectNoHorizontalOverflow(page);
});
test("shows empty and degraded states without opening an approval action", async ({ page }) => {
await installMock(page, { empty: true });
await page.goto("/admin/continuous-improvement");

View file

@ -3,6 +3,15 @@
// route fixture로 상태를 고정해 검증한다. 실제 AI 엔진 턴 생성은 하지 않는다.
import { expect, test, type Page } from "@playwright/test";
type UsageCostBasis =
| "provider_estimate"
| "provider_reported"
| "reference_rate"
| "reference_upper_bound"
| "partial"
| "partial_upper_bound"
| "unavailable";
interface AdminUsageBreakdown {
provider: string;
model: string;
@ -14,7 +23,7 @@ interface AdminUsageBreakdown {
estimated_cost_usd: number;
token_metered_turns: number;
token_unmetered_turns: number;
cost_basis: "provider_estimate" | "provider_reported" | "reference_rate" | "unavailable";
cost_basis: UsageCostBasis;
rate_label?: string | null;
rate_source_url?: string | null;
}
@ -25,6 +34,7 @@ interface AdminUsageDailyCost {
tokens_in: number;
tokens_out: number;
cost_usd: number;
cost_basis?: UsageCostBasis;
}
interface AdminUsageEvaluatorCache {
@ -52,11 +62,13 @@ interface AdminUsageResponse {
cost_usd: number;
recorded_cost_usd: number;
estimated_cost_usd: number;
cost_basis?: UsageCostBasis;
budget: {
limit_usd: number;
used_ratio: number;
remaining_usd: number | null;
status: "disabled" | "ok" | "warn" | "exceeded";
status: "disabled" | "ok" | "warn" | "exceeded" | "indeterminate";
cost_basis?: UsageCostBasis;
};
evaluator_cache?: AdminUsageEvaluatorCache;
by_provider: AdminUsageBreakdown[];
@ -90,10 +102,17 @@ function usageFixture(
tokens_in: 0,
tokens_out: 0,
cost_usd: 0,
cost_basis: "provider_reported",
// 실 API는 cost_usd = recorded + estimated 로 집계한다. 기본은 전액 기록값으로 둔다.
recorded_cost_usd: overrides.cost_usd ?? 0,
estimated_cost_usd: 0,
budget: { limit_usd: 0, used_ratio: 0, remaining_usd: null, status: "disabled" },
budget: {
limit_usd: 0,
used_ratio: 0,
remaining_usd: null,
status: "disabled",
cost_basis: "provider_reported",
},
evaluator_cache: {
enabled: false,
entries: 0,
@ -482,7 +501,7 @@ test.describe("full sweep: admin ai operations", () => {
await expect(chart.locator(".aic-chart__day")).toHaveCount(3);
await expect(chart.locator(".aic-chart__day").first()).toHaveAttribute(
"title",
"2026-07-13 $1.42",
"2026-07-13 $1.42 · Provider 보고",
);
await expect(chart.locator(".aic-chart__day").first()).toContainText("8턴");
@ -521,6 +540,70 @@ test.describe("full sweep: admin ai operations", () => {
await expect(page.locator(".aic-table")).toHaveCount(0);
});
test("does not present a partial upper bound as an exact cost", async ({ page }) => {
const state = createMockState({
usageForWindow: (days) =>
usageFixture(days, {
total_turns: 2,
metered_turns: 2,
token_metered_turns: 2,
tokens_in: 2_000_000,
tokens_out: 2_000_000,
cost_usd: 4.5,
recorded_cost_usd: 0,
estimated_cost_usd: 4.5,
cost_basis: "partial_upper_bound",
budget: {
limit_usd: 20,
used_ratio: 0.225,
remaining_usd: null,
status: "indeterminate",
cost_basis: "partial_upper_bound",
},
by_provider: [
{
provider: "agy_cli",
model: "gemini-3.7-flash-high",
turns: 2,
token_metered_turns: 2,
token_unmetered_turns: 0,
tokens_in: 2_000_000,
tokens_out: 2_000_000,
cost_usd: 4.5,
recorded_cost_usd: 0,
estimated_cost_usd: 4.5,
cost_basis: "partial_upper_bound",
rate_label: "일부 호출 미산정 · 산정된 부분도 상한 추정",
},
],
daily_cost: [
{
day: "2026-08-13",
turns: 2,
tokens_in: 2_000_000,
tokens_out: 2_000_000,
cost_usd: 4.5,
cost_basis: "partial_upper_bound",
},
],
}),
});
await mockAdminAiSession(page, state);
await page.goto("/admin/ai");
await expect(page.locator(".aic-ledger b").first()).toHaveText("미산정");
await expect(page.locator(".aic-ledger article").last().locator("b")).toHaveText("—");
await expect(page.locator(".aic-budget")).toContainText("예산 미확정");
await expect(page.locator(".aic-budget")).toContainText("잔여 미산정 · 사용률 미산정");
await expect(page.locator(".aic-chart__value")).toHaveText("미산정");
const row = page.locator(".aic-table tbody tr");
await expect(row.locator("td").nth(4)).toContainText("미산정");
await expect(row.locator("td").nth(4)).toContainText("일부 상한");
await expect(row.locator("td").nth(5)).toHaveText("—");
await expect(row.locator("td").nth(6)).toHaveText("—");
});
// checklist: admin-ai-engine-config-meta
test("shows engine config metadata for durable DB and runtime-only sources", async ({ page }) => {
const state = createMockState({

View file

@ -217,6 +217,38 @@ test.describe("uc auth-onboarding", () => {
await expect(submit).toBeDisabled();
});
// usecase: 다른 Google 계정을 잘못 선택한 사용자가 현재 이메일을 확인하고 로그인 화면으로 빠져나간다
test("온보딩 최상단에서 현재 Google 계정을 확인하고 다른 계정으로 로그인한다", async ({ page }) => {
await routeUnmockedApi(page);
await routeMe(page, () =>
makeMe({
email: "yunchan8804@gmail.com",
onboarding_completed_at: null,
nickname: "",
}),
);
await routeOnboardingData(page);
await routeAuthConfig(page);
let logoutRequested = false;
await page.route("**/api/auth/logout", (route) => {
logoutRequested = route.request().method() === "POST";
return route.fulfill(jsonRoute({ ok: true }));
});
await page.goto("/onboarding");
const account = page.getByLabel("현재 로그인 계정");
await expect(account).toBeVisible();
await expect(account.getByText("현재 Google 계정", { exact: true })).toBeVisible();
await expect(account.getByText("yunchan8804@gmail.com", { exact: true })).toBeVisible();
await account.getByRole("button", { name: "다른 계정으로 로그인" }).click();
await page.waitForURL(/\/login(?:$|[/?#])/);
expect(logoutRequested).toBe(true);
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
});
// usecase: 신규 사용자가 입력을 마쳤지만 약관·개인정보 동의를 빠뜨린 채 제출하려 한다
test("온보딩 필수 동의를 모두 체크해야 제출 버튼이 활성화된다", async ({ page }) => {
await setupFreshLearnerOnOnboarding(page);

View file

@ -3071,7 +3071,7 @@ export interface components {
* @default provider_reported
* @enum {string}
*/
cost_basis: "provider_estimate" | "provider_reported" | "reference_rate" | "unavailable";
cost_basis: "provider_estimate" | "provider_reported" | "reference_rate" | "reference_upper_bound" | "partial" | "partial_upper_bound" | "unavailable";
/** Cost Usd */
cost_usd: number;
/**
@ -3111,6 +3111,12 @@ export interface components {
};
/** AdminUsageBudget */
AdminUsageBudget: {
/**
* Cost Basis
* @default provider_reported
* @enum {string}
*/
cost_basis: "provider_estimate" | "provider_reported" | "reference_rate" | "reference_upper_bound" | "partial" | "partial_upper_bound" | "unavailable";
/** Limit Usd */
limit_usd: number;
/** Remaining Usd */
@ -3119,12 +3125,18 @@ export interface components {
* Status
* @enum {string}
*/
status: "disabled" | "ok" | "warn" | "exceeded";
status: "disabled" | "ok" | "warn" | "exceeded" | "indeterminate";
/** Used Ratio */
used_ratio: number;
};
/** AdminUsageDailyCost */
AdminUsageDailyCost: {
/**
* Cost Basis
* @default provider_reported
* @enum {string}
*/
cost_basis: "provider_estimate" | "provider_reported" | "reference_rate" | "reference_upper_bound" | "partial" | "partial_upper_bound" | "unavailable";
/** Cost Usd */
cost_usd: number;
/** Day */
@ -3160,6 +3172,12 @@ export interface components {
budget: components["schemas"]["AdminUsageBudget"];
/** By Provider */
by_provider: components["schemas"]["AdminUsageBreakdown"][];
/**
* Cost Basis
* @default provider_reported
* @enum {string}
*/
cost_basis: "provider_estimate" | "provider_reported" | "reference_rate" | "reference_upper_bound" | "partial" | "partial_upper_bound" | "unavailable";
/** Cost Usd */
cost_usd: number;
/** Daily Cost */

View file

@ -31,6 +31,21 @@ const ENGINE_MODE_LABEL: Record<string, string> = {
solar: "Solar",
};
type UsageCostBasis =
| "provider_estimate"
| "provider_reported"
| "reference_rate"
| "reference_upper_bound"
| "partial"
| "partial_upper_bound"
| "unavailable";
const EXACT_COST_BASES = new Set<UsageCostBasis>([
"provider_estimate",
"provider_reported",
"reference_rate",
]);
function countLabel(value: number): string {
return Number.isFinite(value) ? Math.max(0, Math.round(value)).toLocaleString("ko-KR") : "0";
}
@ -52,6 +67,65 @@ function costTitle(value: number): string {
return `$${exact === "0" ? value.toPrecision(3) : exact}`;
}
function readCostBasis(
value: unknown,
fallback: UsageCostBasis = "provider_reported",
): UsageCostBasis {
if (!value || typeof value !== "object" || !("cost_basis" in value)) return fallback;
const basis = (value as { cost_basis?: unknown }).cost_basis;
if (
basis === "provider_estimate" ||
basis === "provider_reported" ||
basis === "reference_rate" ||
basis === "reference_upper_bound" ||
basis === "partial" ||
basis === "partial_upper_bound" ||
basis === "unavailable"
) {
return basis;
}
return fallback;
}
function isExactCostBasis(value: UsageCostBasis): boolean {
return EXACT_COST_BASES.has(value);
}
function isUnknownCostBasis(value: UsageCostBasis): boolean {
return value === "unavailable" || value === "partial_upper_bound";
}
function costWithBasis(value: number, basis: UsageCostBasis, digits: 2 | 4 = 2): string {
if (isUnknownCostBasis(basis)) return "미산정";
if (basis === "reference_upper_bound") return `${costLabel(value, digits)}`;
if (basis === "partial") return `${costLabel(value, digits)}+`;
return costLabel(value, digits);
}
function costTitleWithBasis(value: number, basis: UsageCostBasis): string {
if (isUnknownCostBasis(basis)) return "미산정";
if (basis === "reference_upper_bound") return `${costTitle(value)}`;
if (basis === "partial") return `${costTitle(value)}+`;
return costTitle(value);
}
function budgetRemainingLabel(
value: number | null | undefined,
basis: UsageCostBasis,
): string {
if (isUnknownCostBasis(basis) || value === null || value === undefined) return "잔여 미산정";
if (basis === "reference_upper_bound") return `잔여 ≥${costLabel(value)}`;
if (basis === "partial") return `잔여 ≤${costLabel(value)}`;
return `잔여 ${costLabel(value)}`;
}
function budgetRateLabel(value: number, basis: UsageCostBasis): string {
if (isUnknownCostBasis(basis)) return "사용률 미산정";
if (basis === "reference_upper_bound") return `${rateLabel(value)} 사용`;
if (basis === "partial") return `${rateLabel(value)} 사용`;
return `${rateLabel(value)} 사용`;
}
function rateLabel(value: number): string {
if (!Number.isFinite(value) || value <= 0) return "0%";
return `${(value * 100).toFixed(1).replace(/\.0$/, "")}%`;
@ -61,6 +135,9 @@ function costBasisLabel(value?: string): string {
if (value === "provider_estimate") return "SDK 추정";
if (value === "provider_reported") return "Provider 보고";
if (value === "reference_rate") return "참조단가";
if (value === "reference_upper_bound") return "참조 상한";
if (value === "partial") return "일부 산정";
if (value === "partial_upper_bound") return "일부 상한";
if (value === "unavailable") return "미산정";
return "기록값";
}
@ -85,13 +162,18 @@ function sourceLabel(usage: AdminUsageResponse): string {
return usage.durable && usage.source === "database" ? "운영 DB 원장" : "서버 런타임 임시 집계";
}
function budgetStatusLabel(status: AdminUsageResponse["budget"]["status"]): string {
function budgetStatusLabel(status: string): string {
if (status === "exceeded") return "예산 초과";
if (status === "warn") return "예산 주의";
if (status === "ok") return "예산 정상";
if (status === "indeterminate") return "예산 미확정";
return "예산 경고 비활성";
}
function budgetStatusClass(status?: string): string {
return status === "indeterminate" ? "disabled" : status ?? "disabled";
}
function healthStatusLabel(status?: string): string {
if (status === "ok") return "정상";
if (status === "down") return "중단";
@ -229,8 +311,15 @@ export default function AdminAi() {
}
};
const usageCostBasis = readCostBasis(usage);
const budgetCostBasis = readCostBasis(usage?.budget, usageCostBasis);
const daily = usage?.daily_cost ?? [];
const maxDailyCost = Math.max(0, ...daily.map((item) => item.cost_usd));
const maxDailyCost = Math.max(
0,
...daily
.filter((item) => !isUnknownCostBasis(readCostBasis(item, usageCostBasis)))
.map((item) => item.cost_usd),
);
const totalTokens = (usage?.tokens_in ?? 0) + (usage?.tokens_out ?? 0);
const dbCoverage = usage && usage.total_turns > 0 ? usage.metered_turns / usage.total_turns : 0;
const unmeteredTurns = usage ? Math.max(0, usage.total_turns - usage.metered_turns) : 0;
@ -238,6 +327,12 @@ export default function AdminAi() {
const tokenUnmeteredTurns = usage?.token_unmetered_turns ?? 0;
const costPerTurn = usage && usage.metered_turns > 0 ? usage.cost_usd / usage.metered_turns : 0;
const costPerThousandTokens = usage && totalTokens > 0 ? (usage.cost_usd / totalTokens) * 1000 : 0;
const canShowCostAverage =
isExactCostBasis(usageCostBasis) || usageCostBasis === "reference_upper_bound";
const canShowCostShare = isExactCostBasis(usageCostBasis);
const budgetEnabled = Boolean(usage && usage.budget.status !== "disabled");
const budgetRate = usage?.budget.used_ratio ?? 0;
const budgetRateText = budgetRateLabel(budgetRate, budgetCostBasis);
const engineService = health?.services.find((service) => service.key === "engine");
const capabilityModels = capabilities?.models ?? [];
const selectedModel = capabilityModels.find((model) => model.id === engine?.model);
@ -316,12 +411,15 @@ export default function AdminAi() {
<article>
<span> </span>
{/* 합계 금액은 소수 2자리, 원본 정밀도는 title 로 남긴다. */}
<b title={usage ? costTitle(usage.cost_usd) : undefined}>
{usage ? costLabel(usage.cost_usd) : "—"}
<b title={usage ? costTitleWithBasis(usage.cost_usd, usageCostBasis) : undefined}>
{usage ? costWithBasis(usage.cost_usd, usageCostBasis) : "—"}
</b>
<small>
{usage
? `기록 ${costLabel(usage.recorded_cost_usd ?? usage.cost_usd)} · 참조 ${costLabel(usage.estimated_cost_usd ?? 0)}`
? `기록 ${costLabel(usage.recorded_cost_usd ?? usage.cost_usd)} · 참조 ${costWithBasis(
usage.estimated_cost_usd ?? 0,
usageCostBasis,
)} · ${costBasisLabel(usageCostBasis)}`
: `${windowDays}일 DB 집계`}
</small>
</article>
@ -343,11 +441,27 @@ export default function AdminAi() {
<article>
<span> </span>
{/* 단가는 2자리면 0이 되므로 4자리. 원본 정밀도는 title. */}
<b title={usage ? costTitle(costPerTurn) : undefined}>
{usage ? costLabel(costPerTurn, 4) : "—"}
<b
title={
usage && canShowCostAverage
? costTitleWithBasis(costPerTurn, usageCostBasis)
: undefined
}
>
{usage && canShowCostAverage ? costWithBasis(costPerTurn, usageCostBasis, 4) : "—"}
</b>
<small title={usage && totalTokens > 0 ? costTitle(costPerThousandTokens) : undefined}>
1 {usage ? (totalTokens > 0 && tokenUnmeteredTurns === 0 ? costLabel(costPerThousandTokens, 4) : "부분 계량") : "—"}
<small
title={
usage && totalTokens > 0 && tokenUnmeteredTurns === 0 && canShowCostAverage
? costTitleWithBasis(costPerThousandTokens, usageCostBasis)
: undefined
}
>
1 {usage
? totalTokens > 0 && tokenUnmeteredTurns === 0 && canShowCostAverage
? costWithBasis(costPerThousandTokens, usageCostBasis, 4)
: "—"
: "—"}
</small>
</article>
</section>
@ -361,7 +475,7 @@ export default function AdminAi() {
<span className="aic-eyebrow"> </span>
<h2> </h2>
</div>
<span className={`aic-status aic-status--${usage?.budget.status ?? "disabled"}`}>
<span className={`aic-status aic-status--${budgetStatusClass(usage?.budget.status)}`}>
{usage ? budgetStatusLabel(usage.budget.status) : "확인 중"}
</span>
</div>
@ -371,29 +485,40 @@ export default function AdminAi() {
<span> </span>
<b
title={
usage && usage.budget.status !== "disabled"
? `${costTitle(usage.cost_usd)} / ${costTitle(usage.budget.limit_usd)}`
budgetEnabled && usage
? `${costTitleWithBasis(usage.cost_usd, budgetCostBasis)} / ${costTitle(
usage.budget.limit_usd,
)}`
: undefined
}
>
{usage?.budget.status === "disabled"
? "한도 미설정"
: `${costLabel(usage?.cost_usd ?? 0)} / ${costLabel(usage?.budget.limit_usd ?? 0)}`}
: `${costWithBasis(usage?.cost_usd ?? 0, budgetCostBasis)} / ${costLabel(
usage?.budget.limit_usd ?? 0,
)}`}
</b>
</div>
<div className="aic-track" aria-label={`예산 사용률 ${rateLabel(usage?.budget.used_ratio ?? 0)}`}>
<span style={{ width: `${Math.min(100, (usage?.budget.used_ratio ?? 0) * 100)}%` }} />
<div
className="aic-track"
aria-label={budgetEnabled ? `예산 ${budgetRateText}` : "예산 사용률 0%"}
>
<span
style={{
width: `${isUnknownCostBasis(budgetCostBasis) ? 0 : Math.min(100, budgetRate * 100)}%`,
}}
/>
</div>
<p
title={
usage && usage.budget.status !== "disabled"
? `잔여 ${costTitle(usage.budget.remaining_usd ?? 0)}`
budgetEnabled && usage
? budgetRemainingLabel(usage.budget.remaining_usd, budgetCostBasis)
: undefined
}
>
{usage?.budget.status === "disabled"
? "서버의 ADMIN_USAGE_BUDGET_USD를 설정하면 80% 주의와 100% 초과 상태를 표시합니다."
: `잔여 ${costLabel(usage?.budget.remaining_usd ?? 0)} · ${rateLabel(usage?.budget.used_ratio ?? 0)} 사용`}
: `${budgetRemainingLabel(usage?.budget.remaining_usd, budgetCostBasis)} · ${budgetRateText}`}
</p>
</div>
<div>
@ -426,11 +551,21 @@ export default function AdminAi() {
{daily.length > 0 ? (
<div className="aic-chart" role="img" aria-label={`${windowDays}일 일별 AI 비용 막대 차트`}>
{daily.map((item) => {
const height = maxDailyCost > 0 ? Math.max(3, (item.cost_usd / maxDailyCost) * 100) : 3;
const itemCostBasis = readCostBasis(item, usageCostBasis);
const height =
maxDailyCost > 0 && !isUnknownCostBasis(itemCostBasis)
? Math.max(3, (item.cost_usd / maxDailyCost) * 100)
: 3;
return (
// 막대 위 라벨은 일별 합계라 소수 2자리, 원본 정밀도는 title 로 남긴다.
<div className="aic-chart__day" key={item.day} title={`${item.day} ${costTitle(item.cost_usd)}`}>
<span className="aic-chart__value">{costLabel(item.cost_usd)}</span>
<div
className="aic-chart__day"
key={item.day}
title={`${item.day} ${costTitleWithBasis(item.cost_usd, itemCostBasis)} · ${costBasisLabel(itemCostBasis)}`}
>
<span className="aic-chart__value">
{costWithBasis(item.cost_usd, itemCostBasis)}
</span>
<span className="aic-chart__rail">
<span className="aic-chart__bar" style={{ height: `${height}%` }} />
</span>
@ -468,7 +603,16 @@ export default function AdminAi() {
</tr>
</thead>
<tbody>
{sortedBreakdown.map((row) => (
{sortedBreakdown.map((row) => {
const rowCostBasis = readCostBasis(row, usageCostBasis);
const canShowRowAverage =
isExactCostBasis(rowCostBasis) || rowCostBasis === "reference_upper_bound";
const showRowShare = canShowCostShare && isExactCostBasis(rowCostBasis);
const costShare =
showRowShare && usage && usage.cost_usd > 0
? row.cost_usd / usage.cost_usd
: 0;
return (
<tr key={`${row.provider}:${row.model}`}>
<td>
<b>{row.model}</b>
@ -476,40 +620,66 @@ export default function AdminAi() {
</td>
<td>{countLabel(row.turns)}</td>
<td className="aic-token-cell">
<span>{row.token_metered_turns > 0 ? countLabel(row.tokens_in) : "미계량"}</span>
<span>
{row.token_metered_turns > 0
? countLabel(row.tokens_in)
: "미계량"}
</span>
{row.token_unmetered_turns > 0 && row.token_metered_turns > 0 ? (
<small>{countLabel(row.token_metered_turns)}/{countLabel(row.turns)}</small>
<small>
{countLabel(row.token_metered_turns)}/{countLabel(row.turns)}
</small>
) : null}
</td>
<td className="aic-token-cell">
<span>{row.token_metered_turns > 0 ? countLabel(row.tokens_out) : "미계량"}</span>
<span>
{row.token_metered_turns > 0
? countLabel(row.tokens_out)
: "미계량"}
</span>
{row.token_unmetered_turns > 0 && row.token_metered_turns > 0 ? (
<small>{countLabel(row.token_metered_turns)}/{countLabel(row.turns)}</small>
<small>
{countLabel(row.token_metered_turns)}/{countLabel(row.turns)}
</small>
) : null}
</td>
{/* 비용 미보고 모델은 공식 참조단가 추정임을 숫자와 함께 명시한다. */}
<td
className="aic-cost-cell"
title={row.rate_label ?? costTitle(row.cost_usd)}
title={
row.rate_label ?? costTitleWithBasis(row.cost_usd, rowCostBasis)
}
>
<span>
{row.cost_basis === "unavailable" ? "미산정" : costLabel(row.cost_usd)}
</span>
<small>{costBasisLabel(row.cost_basis)}</small>
<span>{costWithBasis(row.cost_usd, rowCostBasis)}</span>
<small>{costBasisLabel(rowCostBasis)}</small>
</td>
<td title={costTitle(row.turns > 0 ? row.cost_usd / row.turns : 0)}>
{row.cost_basis === "unavailable"
? "—"
: costLabel(row.turns > 0 ? row.cost_usd / row.turns : 0, 4)}
<td
title={
canShowRowAverage
? costTitleWithBasis(
row.turns > 0 ? row.cost_usd / row.turns : 0,
rowCostBasis,
)
: undefined
}
>
{canShowRowAverage
? costWithBasis(
row.turns > 0 ? row.cost_usd / row.turns : 0,
rowCostBasis,
4,
)
: "—"}
</td>
<td>
<span className="aic-share">
<span style={{ width: `${usage && usage.cost_usd > 0 ? (row.cost_usd / usage.cost_usd) * 100 : 0}%` }} />
<span style={{ width: `${costShare * 100}%` }} />
</span>
{rateLabel(usage && usage.cost_usd > 0 ? row.cost_usd / usage.cost_usd : 0)}
{showRowShare ? rateLabel(costShare) : "—"}
</td>
</tr>
))}
);
})}
</tbody>
</table>
</div>

View file

@ -52,6 +52,9 @@ type GateItem =
| { kind: "model_change_gate"; value: ModelChangeGateView }
| { kind: "release_gate"; value: ReleaseGateView };
type ApprovalDecision = HumanApprovalRequest["decision"];
type DecisionIntent = "approve" | "hold" | "reject";
function shortId(value: string, length = 10): string {
return value.length > length ? `${value.slice(0, length)}` : value;
}
@ -76,7 +79,7 @@ function gateTitle(item: GateItem): string {
return `릴리스 ${item.value.release_id}`;
}
function gateDecision(item: GateItem): HumanApprovalRequest["decision"] {
function computedGateDecision(item: GateItem): ApprovalDecision {
if (item.kind === "release_gate") {
return item.value.qualified ? "approve_promotion" : "reject";
}
@ -85,6 +88,43 @@ function gateDecision(item: GateItem): HumanApprovalRequest["decision"] {
return "keep_quarantine";
}
function gateApprovalDecision(item: GateItem): ApprovalDecision {
if (item.kind === "model_change_gate" && item.value.gate_decision === "rollback") {
return "authorize_rollback";
}
return "approve_promotion";
}
function gateApprovalCandidateReady(item: GateItem): boolean {
if (item.kind === "release_gate") return item.value.qualified;
return item.value.gate_decision !== "quarantine";
}
function decisionIntent(
decision: ApprovalDecision | undefined,
approvalDecision: ApprovalDecision,
): DecisionIntent | null {
if (decision === approvalDecision) return "approve";
if (decision === "keep_quarantine") return "hold";
if (decision === "reject") return "reject";
return null;
}
function decisionIntentLabel(intent: DecisionIntent | null): string {
if (intent === "approve") return "승인";
if (intent === "hold") return "보류";
if (intent === "reject") return "반려";
return "결정";
}
function recordedDecisionLabel(decision: ApprovalDecision): string {
if (decision === "approve_content") return "콘텐츠 승인 원장 기록됨";
if (decision === "approve_promotion") return "승격 승인 원장 기록됨";
if (decision === "authorize_rollback") return "롤백 승인 원장 기록됨";
if (decision === "keep_quarantine") return "보류 원장 기록됨";
return "반려 원장 기록됨";
}
function decisionLabel(item: GateItem): string {
if (item.kind === "release_gate") {
return item.value.qualified ? "승격 후보" : "게이트 미통과";
@ -94,12 +134,23 @@ function decisionLabel(item: GateItem): string {
return "격리 유지";
}
function actionLabel(item: GateItem): string {
const decision = gateDecision(item);
if (decision === "approve_promotion") return "사람 승인 기록";
if (decision === "authorize_rollback") return "롤백 승인 기록";
if (decision === "keep_quarantine") return "격리 결정 기록";
return "거부 결정 기록";
function submitLabel(intent: DecisionIntent | null, approvalDecision: ApprovalDecision): string {
if (intent === "approve" && approvalDecision === "authorize_rollback") return "롤백 승인 기록";
if (intent === "approve") return "승인 기록";
if (intent === "hold") return "보류 기록";
if (intent === "reject") return "반려 기록";
return "결정 선택 필요";
}
function submitVariant(
intent: DecisionIntent | null,
approvalDecision: ApprovalDecision,
): "primary" | "secondary" | "danger" {
if (intent === "reject" || (intent === "approve" && approvalDecision === "authorize_rollback")) {
return "danger";
}
if (intent === "hold") return "secondary";
return "primary";
}
function artifactsFor(
@ -174,19 +225,100 @@ function ArtifactRail({ artifacts }: { artifacts: GateArtifactView[] }) {
);
}
function DecisionSelector({
name,
decision,
approvalDecision,
approvalDescription,
approvalAllowed,
approvalBlockedReason,
onChange,
}: {
name: string;
decision: ApprovalDecision | undefined;
approvalDecision: ApprovalDecision;
approvalDescription: string;
approvalAllowed: boolean;
approvalBlockedReason: string;
onChange: (decision: ApprovalDecision) => void;
}) {
const options: Array<{
intent: DecisionIntent;
value: ApprovalDecision;
label: string;
description: string;
}> = [
{
intent: "approve",
value: approvalDecision,
label: "승인",
description: approvalAllowed ? approvalDescription : approvalBlockedReason,
},
{
intent: "hold",
value: "keep_quarantine",
label: "보류",
description: "격리 상태를 유지하고 재검토",
},
{
intent: "reject",
value: "reject",
label: "반려",
description: "후보를 반려하고 원장에 기록",
},
];
return (
<fieldset className="cic-decision-picker">
<legend> </legend>
<div className="cic-decision-options">
{options.map((option) => {
const disabled = option.intent === "approve" && !approvalAllowed;
const descriptionId = `${name}-${option.intent}-description`;
return (
<label
className={`cic-decision-option is-${option.intent} ${
decision === option.value ? "is-selected" : ""
} ${disabled ? "is-disabled" : ""}`}
key={option.intent}
>
<input
type="radio"
name={name}
value={option.value}
checked={decision === option.value}
disabled={disabled}
aria-label={option.label}
aria-describedby={descriptionId}
onChange={() => onChange(option.value)}
/>
<strong>{option.label}</strong>
<small id={descriptionId}>{option.description}</small>
</label>
);
})}
</div>
</fieldset>
);
}
function ContentQualificationCard({
item,
view,
decision,
reason,
onDecisionChange,
onReasonChange,
onApprove,
onSubmit,
approving,
}: {
item: ContentQualificationView;
view: ContinuousImprovementViewResponse;
decision: ApprovalDecision | undefined;
reason: string;
onDecisionChange: (decision: ApprovalDecision) => void;
onReasonChange: (value: string) => void;
onApprove: () => void;
onSubmit: () => void;
approving: boolean;
}) {
const payload = item.draft_payload;
@ -200,19 +332,33 @@ function ContentQualificationCard({
);
const boundarySafe = isBoundarySafe(view);
const evidenceReady = item.source_provenance_uris.length > 0;
const canApprove =
boundarySafe && Boolean(payload) && evidenceReady && !approval && !catalog && reason.trim().length > 0;
const approvalDecision: ApprovalDecision = "approve_content";
const approvalAllowed = boundarySafe && Boolean(payload) && evidenceReady && !catalog;
const intent = decisionIntent(decision, approvalDecision);
const canSubmit =
Boolean(intent) &&
!approval &&
!catalog &&
reason.trim().length > 0 &&
(intent !== "approve" || approvalAllowed);
const requirementId = `content-approval-requirement-${item.qualification_id}`;
const titleId = `content-qualification-title-${item.qualification_id}`;
const blockedReason = !boundarySafe
const approvalBlockedReason = !boundarySafe
? "데이터 경계 위반으로 승인 차단"
: !payload
? "검수 가능한 visible payload가 없어 승인 차단"
: !evidenceReady
? "repo provenance가 없어 승인 차단"
: "승인 조건 충족";
const requirementCopy = catalog
? "이미 카탈로그에 반영되어 추가 결정을 기록할 수 없음"
: !intent
? "승인·보류·반려 중 하나를 명시적으로 선택해야 함"
: intent === "approve" && !approvalAllowed
? approvalBlockedReason
: !reason.trim()
? "승인 사유를 먼저 입력해야 함"
: "visible payload와 repo provenance 검토 준비됨";
? `${decisionIntentLabel(intent)} 사유를 먼저 입력해야 함`
: `${decisionIntentLabel(intent)} 결정과 사유를 append-only 원장에 기록할 준비됨`;
return (
<article
@ -294,9 +440,12 @@ function ContentQualificationCard({
{approval ? (
<div className="cic-effect" role="status">
<Icon name={catalog ? "check" : "alert"} size={17} />
<Icon
name={approval.decision === "reject" ? "alert" : catalog ? "check" : "shield"}
size={17}
/>
<div>
<strong>{catalog ? "카탈로그 승인 원장 기록됨" : "후보 결정 원장 기록됨"}</strong>
<strong>{recordedDecisionLabel(approval.decision)}</strong>
<span>
{approval.decision} · {dateTimeLabel(approval.created_at)}
</span>
@ -307,14 +456,27 @@ function ContentQualificationCard({
className="cic-approval-form"
onSubmit={(event) => {
event.preventDefault();
if (canApprove && !approving) onApprove();
if (canSubmit && !approving) onSubmit();
}}
>
<label htmlFor={`content-reason-${item.qualification_id}`}>
<span> </span>
<DecisionSelector
name={`content-decision-${item.qualification_id}`}
decision={decision}
approvalDecision={approvalDecision}
approvalDescription="카탈로그 게시를 승인"
approvalAllowed={approvalAllowed}
approvalBlockedReason={approvalBlockedReason}
onChange={onDecisionChange}
/>
<label
className="cic-approval-reason"
htmlFor={`content-reason-${item.qualification_id}`}
>
<span> </span>
<input
type="text"
id={`content-reason-${item.qualification_id}`}
name={`content-approval-reason-${item.qualification_id}`}
name={`content-decision-reason-${item.qualification_id}`}
value={reason}
onChange={(event) => onReasonChange(event.target.value)}
aria-describedby={requirementId}
@ -325,18 +487,18 @@ function ContentQualificationCard({
</label>
<Button
size="sm"
variant="primary"
variant={submitVariant(intent, approvalDecision)}
type="submit"
disabled={!canApprove || approving}
disabled={!canSubmit || approving}
aria-describedby={requirementId}
>
{approving ? "승인 원장 기록 중…" : "카탈로그 승인"}
{approving ? "결정 기록 중…" : submitLabel(intent, approvalDecision)}
</Button>
<p
id={requirementId}
className={`cic-approval-requirement ${canApprove ? "is-ready" : !boundarySafe ? "is-blocked" : ""}`}
className={`cic-approval-requirement ${canSubmit ? "is-ready" : intent === "approve" && !approvalAllowed ? "is-blocked" : ""}`}
>
{blockedReason}
{requirementCopy}
</p>
</form>
)}
@ -346,15 +508,19 @@ function ContentQualificationCard({
function PipelineSection({
view,
decisions,
reasons,
setDecision,
setReason,
approve,
submit,
approvingId,
}: {
view: ContinuousImprovementViewResponse;
decisions: Partial<Record<string, ApprovalDecision>>;
reasons: Record<string, string>;
setDecision: (id: string, decision: ApprovalDecision) => void;
setReason: (id: string, value: string) => void;
approve: (item: ContentQualificationView) => void;
submit: (item: ContentQualificationView) => void;
approvingId: string | null;
}) {
const latest = view.content_qualifications.slice(0, 4);
@ -399,9 +565,11 @@ function PipelineSection({
key={item.qualification_id}
item={item}
view={view}
decision={decisions[item.qualification_id]}
reason={reasons[item.qualification_id] ?? ""}
onDecisionChange={(decision) => setDecision(item.qualification_id, decision)}
onReasonChange={(value) => setReason(item.qualification_id, value)}
onApprove={() => approve(item)}
onSubmit={() => submit(item)}
approving={approvingId === item.qualification_id}
/>
))}
@ -414,17 +582,21 @@ function PipelineSection({
function GateCard({
item,
view,
decision,
reason,
onDecisionChange,
onReasonChange,
onApprove,
onSubmit,
approving,
boundarySafe,
}: {
item: GateItem;
view: ContinuousImprovementViewResponse;
decision: ApprovalDecision | undefined;
reason: string;
onDecisionChange: (decision: ApprovalDecision) => void;
onReasonChange: (value: string) => void;
onApprove: () => void;
onSubmit: () => void;
approving: boolean;
boundarySafe: boolean;
}) {
@ -437,16 +609,32 @@ function GateCard({
const effect = view.lifecycle_events.find(
(event) => event.target_kind === item.kind && event.target_id === id,
);
const canSubmit = boundarySafe && complete && !approval && reason.trim().length > 0;
const blockedReason = !boundarySafe
const approvalDecision = gateApprovalDecision(item);
const approvalCandidateReady = gateApprovalCandidateReady(item);
const approvalAllowed = boundarySafe && complete && approvalCandidateReady;
const intent = decisionIntent(decision, approvalDecision);
const canSubmit =
Boolean(intent) &&
!approval &&
reason.trim().length > 0 &&
(intent !== "approve" || approvalAllowed);
const approvalBlockedReason = !boundarySafe
? "데이터 경계 위반으로 승인 차단"
: !complete
? "필수 증거 4종이 모두 있어야 기록 가능"
: !reason.trim()
? "승인 사유를 먼저 입력해야 함"
: undefined;
? "필수 증거 4종 미완료로 승인 차단"
: !approvalCandidateReady
? item.kind === "release_gate"
? "계산 게이트 미통과로 승격 승인 차단"
: "계산 결과가 격리 유지라 승격 승인 차단"
: "승인 조건 충족";
const requirementId = `approval-requirement-${id}`;
const requirementCopy = blockedReason ?? "증거 4종과 승인 사유가 준비됨";
const requirementCopy = !intent
? "승인·보류·반려 중 하나를 명시적으로 선택해야 함"
: intent === "approve" && !approvalAllowed
? approvalBlockedReason
: !reason.trim()
? `${decisionIntentLabel(intent)} 사유를 먼저 입력해야 함`
: `${decisionIntentLabel(intent)} 결정과 사유를 append-only 원장에 기록할 준비됨`;
return (
<article className="cic-gate-card" data-gate-id={id}>
@ -458,7 +646,7 @@ function GateCard({
<h3>{gateTitle(item)}</h3>
<code>{shortId(id, 14)}</code>
</div>
<span className={`cic-decision cic-decision--${gateDecision(item)}`}>
<span className={`cic-decision cic-decision--${computedGateDecision(item)}`}>
{decisionLabel(item)}
</span>
</header>
@ -475,9 +663,12 @@ function GateCard({
{approval ? (
<div className="cic-effect" role="status">
<Icon name="check" size={17} />
<Icon
name={approval.decision === "reject" ? "alert" : approval.decision === "keep_quarantine" ? "shield" : "check"}
size={17}
/>
<div>
<strong>append-only </strong>
<strong>{recordedDecisionLabel(approval.decision)}</strong>
<span>
{approval.decision} · {dateTimeLabel(approval.created_at)}
{effect ? ` · effect ${shortId(effect.lifecycle_event_id, 8)}` : ""}
@ -489,14 +680,26 @@ function GateCard({
className="cic-approval-form"
onSubmit={(event) => {
event.preventDefault();
if (canSubmit && !approving) onApprove();
if (canSubmit && !approving) onSubmit();
}}
>
<label htmlFor={`reason-${id}`}>
<span> </span>
<DecisionSelector
name={`gate-decision-${item.kind}-${id}`}
decision={decision}
approvalDecision={approvalDecision}
approvalDescription={
approvalDecision === "authorize_rollback" ? "롤백 실행을 승인" : "승격 효과를 승인"
}
approvalAllowed={approvalAllowed}
approvalBlockedReason={approvalBlockedReason}
onChange={onDecisionChange}
/>
<label className="cic-approval-reason" htmlFor={`reason-${id}`}>
<span> </span>
<input
type="text"
id={`reason-${id}`}
name={`approval-reason-${id}`}
name={`decision-reason-${id}`}
value={reason}
onChange={(event) => onReasonChange(event.target.value)}
aria-describedby={requirementId}
@ -507,16 +710,16 @@ function GateCard({
</label>
<Button
size="sm"
variant={gateDecision(item) === "authorize_rollback" ? "danger" : "primary"}
variant={submitVariant(intent, approvalDecision)}
type="submit"
disabled={!canSubmit || approving}
aria-describedby={requirementId}
>
{approving ? "원장 기록 중…" : complete ? actionLabel(item) : "증거 4종 미완료"}
{approving ? "결정 기록 중…" : submitLabel(intent, approvalDecision)}
</Button>
<p
id={requirementId}
className={`cic-approval-requirement ${!boundarySafe ? "is-blocked" : canSubmit ? "is-ready" : ""}`}
className={`cic-approval-requirement ${canSubmit ? "is-ready" : intent === "approve" && !approvalAllowed ? "is-blocked" : ""}`}
>
{requirementCopy}
</p>
@ -528,15 +731,19 @@ function GateCard({
function GateSection({
view,
decisions,
reasons,
setDecision,
setReason,
approve,
submit,
approvingId,
}: {
view: ContinuousImprovementViewResponse;
decisions: Partial<Record<string, ApprovalDecision>>;
reasons: Record<string, string>;
setDecision: (id: string, decision: ApprovalDecision) => void;
setReason: (id: string, value: string) => void;
approve: (item: GateItem) => void;
submit: (item: GateItem) => void;
approvingId: string | null;
}) {
const items: GateItem[] = [
@ -572,9 +779,11 @@ function GateSection({
key={`${item.kind}:${gateId(item)}`}
item={item}
view={view}
decision={decisions[gateId(item)]}
reason={reasons[gateId(item)] ?? ""}
onDecisionChange={(decision) => setDecision(gateId(item), decision)}
onReasonChange={(value) => setReason(gateId(item), value)}
onApprove={() => approve(item)}
onSubmit={() => submit(item)}
approving={approvingId === gateId(item)}
boundarySafe={boundarySafe}
/>
@ -704,6 +913,7 @@ export function ContinuousImprovementCockpit() {
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [approvingId, setApprovingId] = useState<string | null>(null);
const [decisions, setDecisions] = useState<Partial<Record<string, ApprovalDecision>>>({});
const [reasons, setReasons] = useState<Record<string, string>>({});
const load = useCallback(async (signal?: AbortSignal) => {
@ -726,49 +936,68 @@ export function ContinuousImprovementCockpit() {
return () => controller.abort();
}, [load]);
const approve = useCallback(
const recordGateDecision = useCallback(
async (item: GateItem) => {
if (!view) return;
const id = gateId(item);
const artifacts = artifactsFor(view, item.kind, id);
const approvalDecision = gateApprovalDecision(item);
const decision = decisions[id];
const intent = decisionIntent(decision, approvalDecision);
const reason = reasons[id]?.trim() ?? "";
const alreadyApproved = view.approvals.some(
(candidate) => candidate.target_kind === item.kind && candidate.target_id === id,
);
if (!isBoundarySafe(view) || !hasCompleteArtifacts(artifacts) || alreadyApproved || !reason) {
const approvalAllowed =
isBoundarySafe(view) && hasCompleteArtifacts(artifacts) && gateApprovalCandidateReady(item);
if (!decision || !intent || alreadyApproved || !reason || (intent === "approve" && !approvalAllowed)) {
return;
}
setApprovingId(id);
setError(null);
setNotice(null);
try {
const evidenceRefs = [...new Set(artifacts.map((artifact) => artifact.provenance_uri))];
const artifactEvidenceRefs = [
...new Set(artifacts.map((artifact) => artifact.provenance_uri)),
];
const evidenceRefs =
artifactEvidenceRefs.length > 0
? artifactEvidenceRefs
: [`audit://continuous-improvement/human-review/${id}`];
const result = await continuousImprovementApi.appendApproval({
submission_id: randomUuid(),
approval_event_id: randomUuid(),
effect_record_id: randomUuid(),
target_kind: item.kind,
target_id: id,
decision: gateDecision(item),
decision,
reason_code: reason,
evidence_refs: evidenceRefs,
});
setNotice(`append-only 승인과 효과 ${shortId(result.effect_record_id, 8)} 기록 완료`);
setNotice(
intent === "approve"
? `${decisionIntentLabel(intent)}과 append-only 효과 ${shortId(result.effect_record_id, 8)} 기록 완료`
: `${decisionIntentLabel(intent)} 결정을 append-only 원장에 기록 완료`,
);
setDecisions((current) => ({ ...current, [id]: undefined }));
setReasons((current) => ({ ...current, [id]: "" }));
await load();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "승인 원장을 기록하지 못했어.");
setError(cause instanceof Error ? cause.message : "사람 결정 원장을 기록하지 못했어.");
} finally {
setApprovingId(null);
}
},
[load, reasons, view],
[decisions, load, reasons, view],
);
const approveQualification = useCallback(
const recordContentDecision = useCallback(
async (item: ContentQualificationView) => {
if (!view) return;
const id = item.qualification_id;
const approvalDecision: ApprovalDecision = "approve_content";
const decision = decisions[id];
const intent = decisionIntent(decision, approvalDecision);
const reason = reasons[id]?.trim() ?? "";
const alreadyApproved = view.approvals.some(
(candidate) =>
@ -777,13 +1006,15 @@ export function ContinuousImprovementCockpit() {
const alreadyCataloged = view.catalog_entries.some(
(candidate) => candidate.qualification_id === id,
);
const approvalAllowed =
isBoundarySafe(view) && Boolean(item.draft_payload) && item.source_provenance_uris.length > 0;
if (
!isBoundarySafe(view) ||
!item.draft_payload ||
item.source_provenance_uris.length === 0 ||
!decision ||
!intent ||
alreadyApproved ||
alreadyCataloged ||
!reason
!reason ||
(intent === "approve" && !approvalAllowed)
) {
return;
}
@ -803,20 +1034,25 @@ export function ContinuousImprovementCockpit() {
effect_record_id: randomUuid(),
target_kind: "content_qualification",
target_id: id,
decision: "approve_content",
decision,
reason_code: reason,
evidence_refs: evidenceRefs,
});
setNotice(`카탈로그 승인과 append-only 효과 ${shortId(result.effect_record_id, 8)} 기록 완료`);
setNotice(
intent === "approve"
? `콘텐츠 승인과 append-only 효과 ${shortId(result.effect_record_id, 8)} 기록 완료`
: `${decisionIntentLabel(intent)} 결정을 append-only 원장에 기록 완료`,
);
setDecisions((current) => ({ ...current, [id]: undefined }));
setReasons((current) => ({ ...current, [id]: "" }));
await load();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "콘텐츠 승인 원장을 기록하지 못했어.");
setError(cause instanceof Error ? cause.message : "콘텐츠 결정 원장을 기록하지 못했어.");
} finally {
setApprovingId(null);
}
},
[load, reasons, view],
[decisions, load, reasons, view],
);
const orderedEvents = useMemo(
@ -853,7 +1089,12 @@ export function ContinuousImprovementCockpit() {
}
return (
<div className="cic" data-testid="continuous-improvement-cockpit" aria-busy={loading}>
<div
className="cic"
data-testid="continuous-improvement-cockpit"
data-vignette-admin-root="true"
aria-busy={loading}
>
<header className="cic-hero">
<div>
<Kicker> · CONTINUOUS IMPROVEMENT OS</Kicker>
@ -890,16 +1131,24 @@ export function ContinuousImprovementCockpit() {
<BoundaryStrip view={view} />
<PipelineSection
view={view}
decisions={decisions}
reasons={reasons}
setDecision={(id, decision) =>
setDecisions((current) => ({ ...current, [id]: decision }))
}
setReason={(id, value) => setReasons((current) => ({ ...current, [id]: value }))}
approve={(item) => void approveQualification(item)}
submit={(item) => void recordContentDecision(item)}
approvingId={approvingId}
/>
<GateSection
view={view}
decisions={decisions}
reasons={reasons}
setDecision={(id, decision) =>
setDecisions((current) => ({ ...current, [id]: decision }))
}
setReason={(id, value) => setReasons((current) => ({ ...current, [id]: value }))}
approve={(item) => void approve(item)}
submit={(item) => void recordGateDecision(item)}
approvingId={approvingId}
/>

View file

@ -596,19 +596,108 @@
gap: 10px;
}
.cic-approval-form label,
.cic-approval-form label > span {
display: block;
.cic-decision-picker {
grid-column: 1 / -1;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.cic-approval-form label > span {
.cic-decision-picker legend,
.cic-approval-form .cic-approval-reason > span {
margin-bottom: 6px;
color: var(--text-muted);
font-size: 10px;
font-weight: 700;
}
.cic-approval-form input {
.cic-decision-options {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.cic-decision-option {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
grid-template-rows: auto auto;
column-gap: 8px;
align-items: center;
min-width: 0;
min-height: 48px;
padding: 7px 9px;
border: 1px solid var(--border-strong);
border-radius: 7px;
background: var(--bg-surface-2);
cursor: pointer;
}
.cic-decision-option:focus-within {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--border-focus) 24%, transparent);
}
.cic-decision-option.is-selected.is-approve {
border-color: var(--accent-deep);
background: var(--accent-tint);
}
.cic-decision-option.is-selected.is-hold {
border-color: var(--warn-solid);
background: var(--warn-tint);
}
.cic-decision-option.is-selected.is-reject {
border-color: var(--crit-solid);
background: var(--crit-tint);
}
.cic-decision-option.is-disabled {
opacity: 0.68;
cursor: not-allowed;
}
.cic-decision-option input[type="radio"] {
grid-row: 1 / -1;
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--accent-deep);
}
.cic-decision-option.is-hold input[type="radio"] {
accent-color: var(--warn-solid);
}
.cic-decision-option.is-reject input[type="radio"] {
accent-color: var(--crit-solid);
}
.cic-decision-option strong,
.cic-decision-option small {
min-width: 0;
}
.cic-decision-option strong {
color: var(--text-strong);
font-size: 11px;
line-height: 1.35;
}
.cic-decision-option small {
color: var(--text-muted);
font-size: 9px;
line-height: 1.4;
overflow-wrap: anywhere;
}
.cic-approval-form .cic-approval-reason,
.cic-approval-form .cic-approval-reason > span {
display: block;
}
.cic-approval-form input[type="text"] {
width: 100%;
min-height: 34px;
padding: 7px 9px;
@ -621,7 +710,7 @@
font-size: 12px;
}
.cic-approval-form input:focus-visible {
.cic-approval-form input[type="text"]:focus-visible {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--border-focus) 24%, transparent);
}
@ -1094,6 +1183,10 @@
grid-template-columns: 1fr;
}
.cic-decision-options {
grid-template-columns: 1fr;
}
.cic-approval-form .vg-btn {
width: 100%;
}

View file

@ -12,6 +12,32 @@
gap:var(--sp-6);
padding:clamp(22px,4vw,44px);
}
.ob-account{
min-width:0;
display:flex;
align-items:center;
justify-content:space-between;
gap:var(--sp-3);
padding-bottom:var(--sp-4);
border-bottom:1px solid var(--border-subtle);
}
.ob-account__identity{
min-width:0;
display:grid;
gap:5px;
}
.ob-account__identity span{
color:var(--text-muted);
font-size:12px;
font-weight:730;
}
.ob-account__identity strong{
min-width:0;
color:var(--text-strong);
font-size:var(--fs-sm);
font-weight:760;
overflow-wrap:anywhere;
}
.ob-head{
display:grid;
gap:8px;
@ -252,6 +278,13 @@
.ob-page{
padding:14px;
}
.ob-account{
align-items:stretch;
flex-direction:column;
}
.ob-account .vg-btn{
width:100%;
}
.ob-fields{
grid-template-columns:1fr;
}