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

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[]; users: AdminManagedUser[];
} }
type UsageCostBasis =
| "provider_estimate"
| "provider_reported"
| "reference_rate"
| "reference_upper_bound"
| "partial"
| "partial_upper_bound"
| "unavailable";
interface AdminUsageBreakdown { interface AdminUsageBreakdown {
provider: string; provider: string;
model: string; model: string;
@ -54,6 +63,11 @@ interface AdminUsageBreakdown {
tokens_in: number; tokens_in: number;
tokens_out: number; tokens_out: number;
cost_usd: 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 { interface AdminUsageDailyCost {
@ -62,13 +76,15 @@ interface AdminUsageDailyCost {
tokens_in: number; tokens_in: number;
tokens_out: number; tokens_out: number;
cost_usd: number; cost_usd: number;
cost_basis?: UsageCostBasis;
} }
interface AdminUsageBudget { interface AdminUsageBudget {
limit_usd: number; limit_usd: number;
used_ratio: number; used_ratio: number;
remaining_usd: number | null; remaining_usd: number | null;
status: "disabled" | "ok" | "warn" | "exceeded"; status: "disabled" | "ok" | "warn" | "exceeded" | "indeterminate";
cost_basis?: UsageCostBasis;
} }
interface AdminUsageEvaluatorCache { interface AdminUsageEvaluatorCache {
@ -94,6 +110,9 @@ interface AdminUsageResponse {
tokens_in: number; tokens_in: number;
tokens_out: number; tokens_out: number;
cost_usd: number; cost_usd: number;
recorded_cost_usd?: number;
estimated_cost_usd?: number;
cost_basis?: UsageCostBasis;
budget: AdminUsageBudget; budget: AdminUsageBudget;
evaluator_cache?: AdminUsageEvaluatorCache; evaluator_cache?: AdminUsageEvaluatorCache;
by_provider: AdminUsageBreakdown[]; by_provider: AdminUsageBreakdown[];
@ -927,20 +946,22 @@ test.describe("admin route guards", () => {
durable: true, durable: true,
generated_at: 1_783_990_800, generated_at: 1_783_990_800,
window_days: 30, window_days: 30,
total_turns: 37, total_turns: 53,
metered_turns: 37, metered_turns: 53,
token_metered_turns: 35, token_metered_turns: 51,
token_unmetered_turns: 2, token_unmetered_turns: 2,
tokens_in: 160_703, tokens_in: 240_950,
tokens_out: 19_629, tokens_out: 22_907,
cost_usd: 7.023222, cost_usd: 7.064689,
recorded_cost_usd: 6.9612, recorded_cost_usd: 6.9612,
estimated_cost_usd: 0.062022, estimated_cost_usd: 0.103489,
cost_basis: "reference_upper_bound",
budget: { budget: {
limit_usd: 20, limit_usd: 20,
used_ratio: 0.3512, used_ratio: 0.3532,
remaining_usd: 12.976778, remaining_usd: 12.935311,
status: "ok", status: "ok",
cost_basis: "reference_upper_bound",
}, },
evaluator_cache: { evaluator_cache: {
enabled: true, enabled: true,
@ -981,24 +1002,45 @@ test.describe("admin route guards", () => {
}, },
{ {
provider: "agy_cli", provider: "agy_cli",
model: "gemini-3.6-flash-high", model: "gemini-3.7-flash-high",
turns: 5, turns: 21,
token_metered_turns: 5, token_metered_turns: 21,
token_unmetered_turns: 0, token_unmetered_turns: 0,
tokens_in: 35_703, tokens_in: 115_950,
tokens_out: 1_129, tokens_out: 4_407,
cost_usd: 0.062022, cost_usd: 0.103489,
recorded_cost_usd: 0, recorded_cost_usd: 0,
estimated_cost_usd: 0.062022, estimated_cost_usd: 0.103489,
cost_basis: "reference_rate", cost_basis: "reference_upper_bound",
rate_label: 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: [ 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-13",
{ day: "2026-07-15", turns: 17, tokens_in: 87_703, tokens_out: 8_729, cost_usd: 3.183222 }, 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(); await expect(page.getByText("운영 DB 원장").first()).toBeVisible();
// 2026-07-27 D7: 합계 금액은 화면에 소수 2자리로 표시하고 원본 정밀도는 title 로 옮겼다. // 2026-07-27 D7: 합계 금액은 화면에 소수 2자리로 표시하고 원본 정밀도는 title 로 옮겼다.
await expect(page.locator(".aic-ledger")).toContainText("$7.02"); await expect(page.locator(".aic-ledger")).toContainText("≤$7.06");
await expect(page.locator(".aic-ledger b").first()).toHaveAttribute("title", /7\.023222/); await expect(page.locator(".aic-ledger b").first()).toHaveAttribute("title", /≤\$7\.064689/);
await expect(page.locator(".aic-budget")).toContainText("94.6%"); 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("gpt-5-mini");
await expect(page.locator(".aic-table")).toContainText("gemini-3.6-flash-high"); 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("참조 상한");
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("SDK 추정");
await expect(page.locator(".aic-table")).toContainText("미계량"); await expect(page.locator(".aic-table")).toContainText("미계량");
await expect(page.locator(".aic-table")).toContainText("$0.06"); await expect(page.locator(".aic-table")).toContainText("$0.10");
await expect(page.locator(".aic-table")).toContainText("0.9%"); await expect(page.locator(".aic-table")).toContainText("$0.0049");
await expect(page.locator(".aic-ledger")).toContainText("기록 $6.96 · 참조 $0.06"); 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.locator(".aic-cache-score")).toContainText("80%");
await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gateway-default"); await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gateway-default");
await expect(page.getByLabel("AI 엔진 공급자").locator("option")).toHaveCount(6); await expect(page.getByLabel("AI 엔진 공급자").locator("option")).toHaveCount(6);
@ -1072,6 +1118,95 @@ test.describe("admin route guards", () => {
await expectNoHorizontalOverflow(page); 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 }) => { test("keeps the boot diagnostic overlay off a rendered AI operations page", async ({ page }) => {
await mockAdminSession(page, { await mockAdminSession(page, {
user_id: "ai-watchdog-admin", 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 INCIDENT_ID = "10000000-0000-4000-8000-000000000003";
const CREATED_AT = "2026-08-06T08:30:00Z"; 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( function artifact(
recordId: string, recordId: string,
ownerKind: "model_change_gate" | "release_gate", 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")) { 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); approvalBodies.push(body);
view.approvals.push({ view.approvals.push({
approval_event_id: body.approval_event_id, 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, target_id: body.target_id,
decision: body.decision as "approve_content" | "approve_promotion", decision: body.decision,
reason_code: body.reason_code, reason_code: body.reason_code,
evidence_refs: body.evidence_refs as unknown as string[], evidence_refs: body.evidence_refs,
created_at: CREATED_AT, created_at: CREATED_AT,
}); });
if (body.target_kind === "content_qualification") { if (body.target_kind === "content_qualification" && body.decision === "approve_content") {
view.catalog_entries.unshift({ view.catalog_entries.unshift({
catalog_record_id: body.effect_record_id, catalog_record_id: body.effect_record_id,
qualification_id: body.target_id, qualification_id: body.target_id,
@ -230,14 +248,17 @@ async function installMock(page: Page, options: MockOptions = {}) {
clinical_claim_allowed: false, clinical_claim_allowed: false,
created_at: CREATED_AT, created_at: CREATED_AT,
}); });
} else { } else if (
body.decision === "approve_promotion" ||
body.decision === "authorize_rollback"
) {
view.lifecycle_events.unshift({ view.lifecycle_events.unshift({
lifecycle_event_id: body.effect_record_id, 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, target_id: body.target_id,
event_type: "promotion", event_type: body.decision === "authorize_rollback" ? "rollback" : "promotion",
event_status: "approved", event_status: body.decision === "authorize_rollback" ? "requested" : "approved",
evidence_refs: body.evidence_refs as unknown as string[], evidence_refs: body.evidence_refs,
created_at: CREATED_AT, created_at: CREATED_AT,
}); });
} }
@ -287,11 +308,13 @@ test.describe("continuous improvement admin cockpit", () => {
await contentCandidate.getByText("검수 payload 펼쳐 보기").click(); await contentCandidate.getByText("검수 payload 펼쳐 보기").click();
await expect(contentCandidate.getByText("영향을 방어하지 않고 인정한 뒤")).toBeVisible(); await expect(contentCandidate.getByText("영향을 방어하지 않고 인정한 뒤")).toBeVisible();
await expect(contentCandidate).not.toContainText("hidden_answer"); await expect(contentCandidate).not.toContainText("hidden_answer");
const contentReason = contentCandidate.getByLabel("콘텐츠 승인 사유"); const contentReason = contentCandidate.getByLabel("콘텐츠 결정 사유");
await expect(contentCandidate.getByRole("button", { name: "카탈로그 승인" })).toBeDisabled(); 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.fill("합성 경계와 수련 목표를 직접 검수함");
await contentReason.press("Enter"); await contentReason.press("Enter");
await expect(contentCandidate.getByText("카탈로그 승인 원장 기록됨")).toBeVisible(); await expect(contentCandidate.getByText("콘텐츠 승인 원장 기록됨")).toBeVisible();
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await page.getByTestId("continuous-improvement-cockpit").screenshot({ await page.getByTestId("continuous-improvement-cockpit").screenshot({
path: testInfo.outputPath("g8-continuous-improvement-before-approval.png"), 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}"]`); const missingGate = page.locator(`[data-gate-id="${RELEASE_GATE_ID}"]`);
await expect(missingGate.getByRole("button", { name: "증거 4종 미완료" })).toBeDisabled(); await expect(missingGate.getByRole("radio", { name: "승인", exact: true })).toBeDisabled();
await expect(missingGate.getByText("필수 증거 4종이 모두 있어야 기록 가능")).toBeVisible(); 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 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(approvalButton).toBeDisabled();
await expect(modelGate.getByText("승인 사유를 먼저 입력해야 함")).toBeVisible(); await expect(modelGate.getByText("승인 사유를 먼저 입력해야 함")).toBeVisible();
await approvalReason.fill("기준선과 rollback runbook을 독립 검토함"); await approvalReason.fill("기준선과 rollback runbook을 독립 검토함");
await expect(approvalButton).toBeEnabled(); 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 approvalButton.focus();
await expect(approvalButton).toBeFocused(); await expect(approvalButton).toBeFocused();
await approvalReason.focus(); await approvalReason.focus();
await approvalReason.press("Enter"); await approvalReason.press("Enter");
await expect(modelGate.getByText("append-only 승인 원장 기록됨")).toBeVisible(); await expect(modelGate.getByText("승격 승인 원장 기록됨")).toBeVisible();
expect(mock.approvalBodies).toHaveLength(2); expect(mock.approvalBodies).toHaveLength(2);
expect(mock.approvalBodies[0]).toMatchObject({ expect(mock.approvalBodies[0]).toMatchObject({
target_kind: "content_qualification", target_kind: "content_qualification",
@ -332,6 +368,10 @@ test.describe("continuous improvement admin cockpit", () => {
expect(mock.approvalBodies[1].evidence_refs).toHaveLength(4); expect(mock.approvalBodies[1].evidence_refs).toHaveLength(4);
await expectNoHorizontalOverflow(page); 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({ await page.screenshot({
path: testInfo.outputPath("g8-continuous-improvement-cockpit.png"), path: testInfo.outputPath("g8-continuous-improvement-cockpit.png"),
fullPage: true, 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 }) => { test("shows empty and degraded states without opening an approval action", async ({ page }) => {
await installMock(page, { empty: true }); await installMock(page, { empty: true });
await page.goto("/admin/continuous-improvement"); await page.goto("/admin/continuous-improvement");

View file

@ -3,6 +3,15 @@
// route fixture로 상태를 고정해 검증한다. 실제 AI 엔진 턴 생성은 하지 않는다. // route fixture로 상태를 고정해 검증한다. 실제 AI 엔진 턴 생성은 하지 않는다.
import { expect, test, type Page } from "@playwright/test"; 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 { interface AdminUsageBreakdown {
provider: string; provider: string;
model: string; model: string;
@ -14,7 +23,7 @@ interface AdminUsageBreakdown {
estimated_cost_usd: number; estimated_cost_usd: number;
token_metered_turns: number; token_metered_turns: number;
token_unmetered_turns: number; token_unmetered_turns: number;
cost_basis: "provider_estimate" | "provider_reported" | "reference_rate" | "unavailable"; cost_basis: UsageCostBasis;
rate_label?: string | null; rate_label?: string | null;
rate_source_url?: string | null; rate_source_url?: string | null;
} }
@ -25,6 +34,7 @@ interface AdminUsageDailyCost {
tokens_in: number; tokens_in: number;
tokens_out: number; tokens_out: number;
cost_usd: number; cost_usd: number;
cost_basis?: UsageCostBasis;
} }
interface AdminUsageEvaluatorCache { interface AdminUsageEvaluatorCache {
@ -52,11 +62,13 @@ interface AdminUsageResponse {
cost_usd: number; cost_usd: number;
recorded_cost_usd: number; recorded_cost_usd: number;
estimated_cost_usd: number; estimated_cost_usd: number;
cost_basis?: UsageCostBasis;
budget: { budget: {
limit_usd: number; limit_usd: number;
used_ratio: number; used_ratio: number;
remaining_usd: number | null; remaining_usd: number | null;
status: "disabled" | "ok" | "warn" | "exceeded"; status: "disabled" | "ok" | "warn" | "exceeded" | "indeterminate";
cost_basis?: UsageCostBasis;
}; };
evaluator_cache?: AdminUsageEvaluatorCache; evaluator_cache?: AdminUsageEvaluatorCache;
by_provider: AdminUsageBreakdown[]; by_provider: AdminUsageBreakdown[];
@ -90,10 +102,17 @@ function usageFixture(
tokens_in: 0, tokens_in: 0,
tokens_out: 0, tokens_out: 0,
cost_usd: 0, cost_usd: 0,
cost_basis: "provider_reported",
// 실 API는 cost_usd = recorded + estimated 로 집계한다. 기본은 전액 기록값으로 둔다. // 실 API는 cost_usd = recorded + estimated 로 집계한다. 기본은 전액 기록값으로 둔다.
recorded_cost_usd: overrides.cost_usd ?? 0, recorded_cost_usd: overrides.cost_usd ?? 0,
estimated_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: { evaluator_cache: {
enabled: false, enabled: false,
entries: 0, 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")).toHaveCount(3);
await expect(chart.locator(".aic-chart__day").first()).toHaveAttribute( await expect(chart.locator(".aic-chart__day").first()).toHaveAttribute(
"title", "title",
"2026-07-13 $1.42", "2026-07-13 $1.42 · Provider 보고",
); );
await expect(chart.locator(".aic-chart__day").first()).toContainText("8턴"); 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); 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 // checklist: admin-ai-engine-config-meta
test("shows engine config metadata for durable DB and runtime-only sources", async ({ page }) => { test("shows engine config metadata for durable DB and runtime-only sources", async ({ page }) => {
const state = createMockState({ const state = createMockState({

View file

@ -217,6 +217,38 @@ test.describe("uc auth-onboarding", () => {
await expect(submit).toBeDisabled(); 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: 신규 사용자가 입력을 마쳤지만 약관·개인정보 동의를 빠뜨린 채 제출하려 한다 // usecase: 신규 사용자가 입력을 마쳤지만 약관·개인정보 동의를 빠뜨린 채 제출하려 한다
test("온보딩 필수 동의를 모두 체크해야 제출 버튼이 활성화된다", async ({ page }) => { test("온보딩 필수 동의를 모두 체크해야 제출 버튼이 활성화된다", async ({ page }) => {
await setupFreshLearnerOnOnboarding(page); await setupFreshLearnerOnOnboarding(page);

View file

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

View file

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

View file

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

View file

@ -596,19 +596,108 @@
gap: 10px; gap: 10px;
} }
.cic-approval-form label, .cic-decision-picker {
.cic-approval-form label > span { grid-column: 1 / -1;
display: block; 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; margin-bottom: 6px;
color: var(--text-muted); color: var(--text-muted);
font-size: 10px; font-size: 10px;
font-weight: 700; 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%; width: 100%;
min-height: 34px; min-height: 34px;
padding: 7px 9px; padding: 7px 9px;
@ -621,7 +710,7 @@
font-size: 12px; font-size: 12px;
} }
.cic-approval-form input:focus-visible { .cic-approval-form input[type="text"]:focus-visible {
border-color: var(--border-focus); border-color: var(--border-focus);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--border-focus) 24%, transparent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--border-focus) 24%, transparent);
} }
@ -1094,6 +1183,10 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.cic-decision-options {
grid-template-columns: 1fr;
}
.cic-approval-form .vg-btn { .cic-approval-form .vg-btn {
width: 100%; width: 100%;
} }

View file

@ -12,6 +12,32 @@
gap:var(--sp-6); gap:var(--sp-6);
padding:clamp(22px,4vw,44px); 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{ .ob-head{
display:grid; display:grid;
gap:8px; gap:8px;
@ -252,6 +278,13 @@
.ob-page{ .ob-page{
padding:14px; padding:14px;
} }
.ob-account{
align-items:stretch;
flex-direction:column;
}
.ob-account .vg-btn{
width:100%;
}
.ob-fields{ .ob-fields{
grid-template-columns:1fr; grid-template-columns:1fr;
} }