diff --git a/apps/web/e2e/admin.spec.ts b/apps/web/e2e/admin.spec.ts index 6f7eb36..21d58fa 100644 --- a/apps/web/e2e/admin.spec.ts +++ b/apps/web/e2e/admin.spec.ts @@ -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", diff --git a/apps/web/e2e/continuous-improvement-admin.spec.ts b/apps/web/e2e/continuous-improvement-admin.spec.ts index 3b4bc1e..087c00f 100644 --- a/apps/web/e2e/continuous-improvement-admin.spec.ts +++ b/apps/web/e2e/continuous-improvement-admin.spec.ts @@ -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; + 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"); diff --git a/apps/web/e2e/full-sweep-admin-ai.spec.ts b/apps/web/e2e/full-sweep-admin-ai.spec.ts index 490362b..376e458 100644 --- a/apps/web/e2e/full-sweep-admin-ai.spec.ts +++ b/apps/web/e2e/full-sweep-admin-ai.spec.ts @@ -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({ diff --git a/apps/web/e2e/uc-auth-onboarding.spec.ts b/apps/web/e2e/uc-auth-onboarding.spec.ts index 83860e0..203872f 100644 --- a/apps/web/e2e/uc-auth-onboarding.spec.ts +++ b/apps/web/e2e/uc-auth-onboarding.spec.ts @@ -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); diff --git a/apps/web/src/lib/api.gen.ts b/apps/web/src/lib/api.gen.ts index e5bdc9e..2779091 100644 --- a/apps/web/src/lib/api.gen.ts +++ b/apps/web/src/lib/api.gen.ts @@ -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 */ diff --git a/apps/web/src/pages/AdminAi.tsx b/apps/web/src/pages/AdminAi.tsx index 7f4b11a..192ec32 100644 --- a/apps/web/src/pages/AdminAi.tsx +++ b/apps/web/src/pages/AdminAi.tsx @@ -31,6 +31,21 @@ const ENGINE_MODE_LABEL: Record = { solar: "Solar", }; +type UsageCostBasis = + | "provider_estimate" + | "provider_reported" + | "reference_rate" + | "reference_upper_bound" + | "partial" + | "partial_upper_bound" + | "unavailable"; + +const EXACT_COST_BASES = new Set([ + "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() {
누적 비용 추정 {/* 합계 금액은 소수 2자리, 원본 정밀도는 title 로 남긴다. */} - - {usage ? costLabel(usage.cost_usd) : "—"} + + {usage ? costWithBasis(usage.cost_usd, usageCostBasis) : "—"} {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 집계`}
@@ -343,11 +441,27 @@ export default function AdminAi() {
호출당 비용 {/* 단가는 2자리면 0이 되므로 4자리. 원본 정밀도는 title. */} - - {usage ? costLabel(costPerTurn, 4) : "—"} + + {usage && canShowCostAverage ? costWithBasis(costPerTurn, usageCostBasis, 4) : "—"} - 0 ? costTitle(costPerThousandTokens) : undefined}> - 1천 토큰당 {usage ? (totalTokens > 0 && tokenUnmeteredTurns === 0 ? costLabel(costPerThousandTokens, 4) : "부분 계량") : "—"} + 0 && tokenUnmeteredTurns === 0 && canShowCostAverage + ? costTitleWithBasis(costPerThousandTokens, usageCostBasis) + : undefined + } + > + 1천 토큰당 {usage + ? totalTokens > 0 && tokenUnmeteredTurns === 0 && canShowCostAverage + ? costWithBasis(costPerThousandTokens, usageCostBasis, 4) + : "—" + : "—"}
@@ -361,7 +475,7 @@ export default function AdminAi() { 예산 관리

예산과 계량 커버리지

- + {usage ? budgetStatusLabel(usage.budget.status) : "확인 중"} @@ -371,29 +485,40 @@ export default function AdminAi() { 예산 사용 {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, + )}`} -
- +
+

{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}`}

@@ -426,11 +551,21 @@ export default function AdminAi() { {daily.length > 0 ? (
{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 로 남긴다. -
- {costLabel(item.cost_usd)} +
+ + {costWithBasis(item.cost_usd, itemCostBasis)} + @@ -468,48 +603,83 @@ export default function AdminAi() { - {sortedBreakdown.map((row) => ( - - - {row.model} - {row.provider} - - {countLabel(row.turns)} - - {row.token_metered_turns > 0 ? countLabel(row.tokens_in) : "미계량"} - {row.token_unmetered_turns > 0 && row.token_metered_turns > 0 ? ( - {countLabel(row.token_metered_turns)}/{countLabel(row.turns)}회 - ) : null} - - - {row.token_metered_turns > 0 ? countLabel(row.tokens_out) : "미계량"} - {row.token_unmetered_turns > 0 && row.token_metered_turns > 0 ? ( - {countLabel(row.token_metered_turns)}/{countLabel(row.turns)}회 - ) : null} - - {/* 비용 미보고 모델은 공식 참조단가 추정임을 숫자와 함께 명시한다. */} - - - {row.cost_basis === "unavailable" ? "미산정" : costLabel(row.cost_usd)} - - {costBasisLabel(row.cost_basis)} - - 0 ? row.cost_usd / row.turns : 0)}> - {row.cost_basis === "unavailable" - ? "—" - : costLabel(row.turns > 0 ? row.cost_usd / row.turns : 0, 4)} - - - - 0 ? (row.cost_usd / usage.cost_usd) * 100 : 0}%` }} /> - - {rateLabel(usage && usage.cost_usd > 0 ? row.cost_usd / usage.cost_usd : 0)} - - - ))} + {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 ( + + + {row.model} + {row.provider} + + {countLabel(row.turns)} + + + {row.token_metered_turns > 0 + ? countLabel(row.tokens_in) + : "미계량"} + + {row.token_unmetered_turns > 0 && row.token_metered_turns > 0 ? ( + + {countLabel(row.token_metered_turns)}/{countLabel(row.turns)}회 + + ) : null} + + + + {row.token_metered_turns > 0 + ? countLabel(row.tokens_out) + : "미계량"} + + {row.token_unmetered_turns > 0 && row.token_metered_turns > 0 ? ( + + {countLabel(row.token_metered_turns)}/{countLabel(row.turns)}회 + + ) : null} + + {/* 비용 미보고 모델은 공식 참조단가 추정임을 숫자와 함께 명시한다. */} + + {costWithBasis(row.cost_usd, rowCostBasis)} + {costBasisLabel(rowCostBasis)} + + 0 ? row.cost_usd / row.turns : 0, + rowCostBasis, + ) + : undefined + } + > + {canShowRowAverage + ? costWithBasis( + row.turns > 0 ? row.cost_usd / row.turns : 0, + rowCostBasis, + 4, + ) + : "—"} + + + + + + {showRowShare ? rateLabel(costShare) : "—"} + + + ); + })}
diff --git a/apps/web/src/pages/admin/ContinuousImprovementCockpit.tsx b/apps/web/src/pages/admin/ContinuousImprovementCockpit.tsx index 9656244..97a5a9a 100644 --- a/apps/web/src/pages/admin/ContinuousImprovementCockpit.tsx +++ b/apps/web/src/pages/admin/ContinuousImprovementCockpit.tsx @@ -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 ( +
+ 사람 결정 +
+ {options.map((option) => { + const disabled = option.intent === "approve" && !approvalAllowed; + const descriptionId = `${name}-${option.intent}-description`; + return ( + + ); + })} +
+
+ ); +} + 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 (
- +
- {catalog ? "카탈로그 승인 원장 기록됨" : "후보 결정 원장 기록됨"} + {recordedDecisionLabel(approval.decision)} {approval.decision} · {dateTimeLabel(approval.created_at)} @@ -307,14 +456,27 @@ function ContentQualificationCard({ className="cic-approval-form" onSubmit={(event) => { event.preventDefault(); - if (canApprove && !approving) onApprove(); + if (canSubmit && !approving) onSubmit(); }} > -
- + {decisionLabel(item)} @@ -475,9 +663,12 @@ function GateCard({ {approval ? (
- +
- append-only 승인 원장 기록됨 + {recordedDecisionLabel(approval.decision)} {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(); }} > -