관리자 증거와 계정 흐름을 정돈
This commit is contained in:
parent
21bbe3f98d
commit
34aff65cb0
9 changed files with 1100 additions and 193 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,24 @@ const RELEASE_GATE_ID = "10000000-0000-4000-8000-000000000002";
|
|||
const INCIDENT_ID = "10000000-0000-4000-8000-000000000003";
|
||||
const CREATED_AT = "2026-08-06T08:30:00Z";
|
||||
|
||||
type ApprovalDecision =
|
||||
| "approve_content"
|
||||
| "approve_promotion"
|
||||
| "authorize_rollback"
|
||||
| "keep_quarantine"
|
||||
| "reject";
|
||||
|
||||
type ApprovalBody = {
|
||||
submission_id: string;
|
||||
approval_event_id: string;
|
||||
effect_record_id: string;
|
||||
target_kind: "content_qualification" | "model_change_gate" | "release_gate";
|
||||
target_id: string;
|
||||
decision: ApprovalDecision;
|
||||
reason_code: string;
|
||||
evidence_refs: string[];
|
||||
};
|
||||
|
||||
function artifact(
|
||||
recordId: string,
|
||||
ownerKind: "model_change_gate" | "release_gate",
|
||||
|
|
@ -210,18 +228,18 @@ async function installMock(page: Page, options: MockOptions = {}) {
|
|||
}
|
||||
|
||||
if (request.method() === "POST" && path.endsWith("/continuous-improvement/approvals")) {
|
||||
const body = request.postDataJSON() as Record<string, string>;
|
||||
const body = request.postDataJSON() as ApprovalBody;
|
||||
approvalBodies.push(body);
|
||||
view.approvals.push({
|
||||
approval_event_id: body.approval_event_id,
|
||||
target_kind: body.target_kind as "content_qualification" | "model_change_gate",
|
||||
target_kind: body.target_kind,
|
||||
target_id: body.target_id,
|
||||
decision: body.decision as "approve_content" | "approve_promotion",
|
||||
decision: body.decision,
|
||||
reason_code: body.reason_code,
|
||||
evidence_refs: body.evidence_refs as unknown as string[],
|
||||
evidence_refs: body.evidence_refs,
|
||||
created_at: CREATED_AT,
|
||||
});
|
||||
if (body.target_kind === "content_qualification") {
|
||||
if (body.target_kind === "content_qualification" && body.decision === "approve_content") {
|
||||
view.catalog_entries.unshift({
|
||||
catalog_record_id: body.effect_record_id,
|
||||
qualification_id: body.target_id,
|
||||
|
|
@ -230,14 +248,17 @@ async function installMock(page: Page, options: MockOptions = {}) {
|
|||
clinical_claim_allowed: false,
|
||||
created_at: CREATED_AT,
|
||||
});
|
||||
} else {
|
||||
} else if (
|
||||
body.decision === "approve_promotion" ||
|
||||
body.decision === "authorize_rollback"
|
||||
) {
|
||||
view.lifecycle_events.unshift({
|
||||
lifecycle_event_id: body.effect_record_id,
|
||||
target_kind: body.target_kind as "model_change_gate",
|
||||
target_kind: body.target_kind as "model_change_gate" | "release_gate",
|
||||
target_id: body.target_id,
|
||||
event_type: "promotion",
|
||||
event_status: "approved",
|
||||
evidence_refs: body.evidence_refs as unknown as string[],
|
||||
event_type: body.decision === "authorize_rollback" ? "rollback" : "promotion",
|
||||
event_status: body.decision === "authorize_rollback" ? "requested" : "approved",
|
||||
evidence_refs: body.evidence_refs,
|
||||
created_at: CREATED_AT,
|
||||
});
|
||||
}
|
||||
|
|
@ -287,11 +308,13 @@ test.describe("continuous improvement admin cockpit", () => {
|
|||
await contentCandidate.getByText("검수 payload 펼쳐 보기").click();
|
||||
await expect(contentCandidate.getByText("영향을 방어하지 않고 인정한 뒤")).toBeVisible();
|
||||
await expect(contentCandidate).not.toContainText("hidden_answer");
|
||||
const contentReason = contentCandidate.getByLabel("콘텐츠 승인 사유");
|
||||
await expect(contentCandidate.getByRole("button", { name: "카탈로그 승인" })).toBeDisabled();
|
||||
const contentReason = contentCandidate.getByLabel("콘텐츠 결정 사유");
|
||||
await expect(contentCandidate.getByRole("button", { name: "결정 선택 필요" })).toBeDisabled();
|
||||
await contentCandidate.getByRole("radio", { name: "승인", exact: true }).check();
|
||||
await expect(contentCandidate.getByRole("button", { name: "승인 기록" })).toBeDisabled();
|
||||
await contentReason.fill("합성 경계와 수련 목표를 직접 검수함");
|
||||
await contentReason.press("Enter");
|
||||
await expect(contentCandidate.getByText("카탈로그 승인 원장 기록됨")).toBeVisible();
|
||||
await expect(contentCandidate.getByText("콘텐츠 승인 원장 기록됨")).toBeVisible();
|
||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||
await page.getByTestId("continuous-improvement-cockpit").screenshot({
|
||||
path: testInfo.outputPath("g8-continuous-improvement-before-approval.png"),
|
||||
|
|
@ -299,23 +322,36 @@ test.describe("continuous improvement admin cockpit", () => {
|
|||
});
|
||||
|
||||
const missingGate = page.locator(`[data-gate-id="${RELEASE_GATE_ID}"]`);
|
||||
await expect(missingGate.getByRole("button", { name: "증거 4종 미완료" })).toBeDisabled();
|
||||
await expect(missingGate.getByText("필수 증거 4종이 모두 있어야 기록 가능")).toBeVisible();
|
||||
await expect(missingGate.getByRole("radio", { name: "승인", exact: true })).toBeDisabled();
|
||||
await expect(missingGate.getByRole("radio", { name: "보류", exact: true })).toBeEnabled();
|
||||
await expect(missingGate.getByRole("radio", { name: "반려", exact: true })).toBeEnabled();
|
||||
await expect(missingGate.getByText("필수 증거 4종 미완료로 승인 차단")).toBeVisible();
|
||||
await missingGate.screenshot({
|
||||
path: testInfo.outputPath("g8-human-decision-approval-blocked.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
|
||||
const modelGate = page.locator(`[data-gate-id="${MODEL_GATE_ID}"]`);
|
||||
const approvalButton = modelGate.getByRole("button", { name: "사람 승인 기록" });
|
||||
const approvalReason = modelGate.getByLabel("사람 승인 사유");
|
||||
const approvalReason = modelGate.getByLabel("사람 결정 사유");
|
||||
await expect(modelGate.getByRole("button", { name: "결정 선택 필요" })).toBeDisabled();
|
||||
await expect(modelGate.getByText("승인·보류·반려 중 하나를 명시적으로 선택해야 함")).toBeVisible();
|
||||
await modelGate.getByRole("radio", { name: "승인", exact: true }).check();
|
||||
const approvalButton = modelGate.getByRole("button", { name: "승인 기록" });
|
||||
await expect(approvalButton).toBeDisabled();
|
||||
await expect(modelGate.getByText("승인 사유를 먼저 입력해야 함")).toBeVisible();
|
||||
await approvalReason.fill("기준선과 rollback runbook을 독립 검토함");
|
||||
await expect(approvalButton).toBeEnabled();
|
||||
await expect(modelGate.getByText("증거 4종과 승인 사유가 준비됨")).toBeVisible();
|
||||
await expect(modelGate.getByText("승인 결정과 사유를 append-only 원장에 기록할 준비됨")).toBeVisible();
|
||||
await modelGate.screenshot({
|
||||
path: testInfo.outputPath("g8-human-decision-approval-ready.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
await approvalButton.focus();
|
||||
await expect(approvalButton).toBeFocused();
|
||||
await approvalReason.focus();
|
||||
await approvalReason.press("Enter");
|
||||
|
||||
await expect(modelGate.getByText("append-only 승인 원장 기록됨")).toBeVisible();
|
||||
await expect(modelGate.getByText("승격 승인 원장 기록됨")).toBeVisible();
|
||||
expect(mock.approvalBodies).toHaveLength(2);
|
||||
expect(mock.approvalBodies[0]).toMatchObject({
|
||||
target_kind: "content_qualification",
|
||||
|
|
@ -332,6 +368,10 @@ test.describe("continuous improvement admin cockpit", () => {
|
|||
expect(mock.approvalBodies[1].evidence_refs).toHaveLength(4);
|
||||
|
||||
await expectNoHorizontalOverflow(page);
|
||||
// index.html watchdog은 3.5초와 8초에 관리자 표면의 canonical marker를 확인한다.
|
||||
// 정상 렌더된 지속 개선 콕핏을 dev asset 이름 때문에 진단 화면으로 오인하면 안 된다.
|
||||
await page.waitForTimeout(8_600);
|
||||
await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("g8-continuous-improvement-cockpit.png"),
|
||||
fullPage: true,
|
||||
|
|
@ -339,6 +379,60 @@ test.describe("continuous improvement admin cockpit", () => {
|
|||
});
|
||||
});
|
||||
|
||||
test("blocks only unmet approval and records hold, reject and approval as explicit decisions", async ({
|
||||
page,
|
||||
}) => {
|
||||
const mock = await installMock(page);
|
||||
await page.goto("/admin/continuous-improvement");
|
||||
|
||||
const releaseGate = page.locator(`[data-gate-id="${RELEASE_GATE_ID}"]`);
|
||||
const releaseApprove = releaseGate.getByRole("radio", { name: "승인", exact: true });
|
||||
await expect(releaseApprove).toBeDisabled();
|
||||
await expect(releaseGate.getByText("필수 증거 4종 미완료로 승인 차단")).toBeVisible();
|
||||
await expect(releaseGate.getByRole("radio", { name: "보류", exact: true })).toBeEnabled();
|
||||
await expect(releaseGate.getByRole("radio", { name: "반려", exact: true })).toBeEnabled();
|
||||
|
||||
await releaseGate.getByRole("radio", { name: "보류", exact: true }).check();
|
||||
await releaseGate.getByLabel("사람 결정 사유").fill("rollback 증거 보강 뒤 다시 검토");
|
||||
await releaseGate.getByRole("button", { name: "보류 기록" }).click();
|
||||
await expect(releaseGate.getByText("보류 원장 기록됨")).toBeVisible();
|
||||
|
||||
const contentCandidate = page.locator(
|
||||
'[data-qualification-id="20000000-0000-4000-8000-000000000001"]',
|
||||
);
|
||||
await contentCandidate.getByRole("radio", { name: "반려", exact: true }).check();
|
||||
await contentCandidate.getByLabel("콘텐츠 결정 사유").fill("검수 기준을 충족하지 못한 후보");
|
||||
await contentCandidate.getByRole("button", { name: "반려 기록" }).click();
|
||||
await expect(contentCandidate.getByText("반려 원장 기록됨")).toBeVisible();
|
||||
|
||||
const modelGate = page.locator(`[data-gate-id="${MODEL_GATE_ID}"]`);
|
||||
await modelGate.getByRole("radio", { name: "승인", exact: true }).check();
|
||||
await modelGate.getByLabel("사람 결정 사유").fill("독립 증거 4종과 승격 조건을 확인함");
|
||||
await modelGate.getByRole("button", { name: "승인 기록" }).click();
|
||||
await expect(modelGate.getByText("승격 승인 원장 기록됨")).toBeVisible();
|
||||
|
||||
expect(mock.approvalBodies).toHaveLength(3);
|
||||
expect(mock.approvalBodies[0]).toMatchObject({
|
||||
target_kind: "release_gate",
|
||||
target_id: RELEASE_GATE_ID,
|
||||
decision: "keep_quarantine",
|
||||
reason_code: "rollback 증거 보강 뒤 다시 검토",
|
||||
});
|
||||
expect(mock.approvalBodies[1]).toMatchObject({
|
||||
target_kind: "content_qualification",
|
||||
target_id: "20000000-0000-4000-8000-000000000001",
|
||||
decision: "reject",
|
||||
reason_code: "검수 기준을 충족하지 못한 후보",
|
||||
});
|
||||
expect(mock.approvalBodies[2]).toMatchObject({
|
||||
target_kind: "model_change_gate",
|
||||
target_id: MODEL_GATE_ID,
|
||||
decision: "approve_promotion",
|
||||
reason_code: "독립 증거 4종과 승격 조건을 확인함",
|
||||
});
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
test("shows empty and degraded states without opening an approval action", async ({ page }) => {
|
||||
await installMock(page, { empty: true });
|
||||
await page.goto("/admin/continuous-improvement");
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -31,6 +31,21 @@ const ENGINE_MODE_LABEL: Record<string, string> = {
|
|||
solar: "Solar",
|
||||
};
|
||||
|
||||
type UsageCostBasis =
|
||||
| "provider_estimate"
|
||||
| "provider_reported"
|
||||
| "reference_rate"
|
||||
| "reference_upper_bound"
|
||||
| "partial"
|
||||
| "partial_upper_bound"
|
||||
| "unavailable";
|
||||
|
||||
const EXACT_COST_BASES = new Set<UsageCostBasis>([
|
||||
"provider_estimate",
|
||||
"provider_reported",
|
||||
"reference_rate",
|
||||
]);
|
||||
|
||||
function countLabel(value: number): string {
|
||||
return Number.isFinite(value) ? Math.max(0, Math.round(value)).toLocaleString("ko-KR") : "0";
|
||||
}
|
||||
|
|
@ -52,6 +67,65 @@ function costTitle(value: number): string {
|
|||
return `$${exact === "0" ? value.toPrecision(3) : exact}`;
|
||||
}
|
||||
|
||||
function readCostBasis(
|
||||
value: unknown,
|
||||
fallback: UsageCostBasis = "provider_reported",
|
||||
): UsageCostBasis {
|
||||
if (!value || typeof value !== "object" || !("cost_basis" in value)) return fallback;
|
||||
const basis = (value as { cost_basis?: unknown }).cost_basis;
|
||||
if (
|
||||
basis === "provider_estimate" ||
|
||||
basis === "provider_reported" ||
|
||||
basis === "reference_rate" ||
|
||||
basis === "reference_upper_bound" ||
|
||||
basis === "partial" ||
|
||||
basis === "partial_upper_bound" ||
|
||||
basis === "unavailable"
|
||||
) {
|
||||
return basis;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function isExactCostBasis(value: UsageCostBasis): boolean {
|
||||
return EXACT_COST_BASES.has(value);
|
||||
}
|
||||
|
||||
function isUnknownCostBasis(value: UsageCostBasis): boolean {
|
||||
return value === "unavailable" || value === "partial_upper_bound";
|
||||
}
|
||||
|
||||
function costWithBasis(value: number, basis: UsageCostBasis, digits: 2 | 4 = 2): string {
|
||||
if (isUnknownCostBasis(basis)) return "미산정";
|
||||
if (basis === "reference_upper_bound") return `≤${costLabel(value, digits)}`;
|
||||
if (basis === "partial") return `${costLabel(value, digits)}+`;
|
||||
return costLabel(value, digits);
|
||||
}
|
||||
|
||||
function costTitleWithBasis(value: number, basis: UsageCostBasis): string {
|
||||
if (isUnknownCostBasis(basis)) return "미산정";
|
||||
if (basis === "reference_upper_bound") return `≤${costTitle(value)}`;
|
||||
if (basis === "partial") return `${costTitle(value)}+`;
|
||||
return costTitle(value);
|
||||
}
|
||||
|
||||
function budgetRemainingLabel(
|
||||
value: number | null | undefined,
|
||||
basis: UsageCostBasis,
|
||||
): string {
|
||||
if (isUnknownCostBasis(basis) || value === null || value === undefined) return "잔여 미산정";
|
||||
if (basis === "reference_upper_bound") return `잔여 ≥${costLabel(value)}`;
|
||||
if (basis === "partial") return `잔여 ≤${costLabel(value)}`;
|
||||
return `잔여 ${costLabel(value)}`;
|
||||
}
|
||||
|
||||
function budgetRateLabel(value: number, basis: UsageCostBasis): string {
|
||||
if (isUnknownCostBasis(basis)) return "사용률 미산정";
|
||||
if (basis === "reference_upper_bound") return `≤${rateLabel(value)} 사용`;
|
||||
if (basis === "partial") return `≥${rateLabel(value)} 사용`;
|
||||
return `${rateLabel(value)} 사용`;
|
||||
}
|
||||
|
||||
function rateLabel(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) return "0%";
|
||||
return `${(value * 100).toFixed(1).replace(/\.0$/, "")}%`;
|
||||
|
|
@ -61,6 +135,9 @@ function costBasisLabel(value?: string): string {
|
|||
if (value === "provider_estimate") return "SDK 추정";
|
||||
if (value === "provider_reported") return "Provider 보고";
|
||||
if (value === "reference_rate") return "참조단가";
|
||||
if (value === "reference_upper_bound") return "참조 상한";
|
||||
if (value === "partial") return "일부 산정";
|
||||
if (value === "partial_upper_bound") return "일부 상한";
|
||||
if (value === "unavailable") return "미산정";
|
||||
return "기록값";
|
||||
}
|
||||
|
|
@ -85,13 +162,18 @@ function sourceLabel(usage: AdminUsageResponse): string {
|
|||
return usage.durable && usage.source === "database" ? "운영 DB 원장" : "서버 런타임 임시 집계";
|
||||
}
|
||||
|
||||
function budgetStatusLabel(status: AdminUsageResponse["budget"]["status"]): string {
|
||||
function budgetStatusLabel(status: string): string {
|
||||
if (status === "exceeded") return "예산 초과";
|
||||
if (status === "warn") return "예산 주의";
|
||||
if (status === "ok") return "예산 정상";
|
||||
if (status === "indeterminate") return "예산 미확정";
|
||||
return "예산 경고 비활성";
|
||||
}
|
||||
|
||||
function budgetStatusClass(status?: string): string {
|
||||
return status === "indeterminate" ? "disabled" : status ?? "disabled";
|
||||
}
|
||||
|
||||
function healthStatusLabel(status?: string): string {
|
||||
if (status === "ok") return "정상";
|
||||
if (status === "down") return "중단";
|
||||
|
|
@ -229,8 +311,15 @@ export default function AdminAi() {
|
|||
}
|
||||
};
|
||||
|
||||
const usageCostBasis = readCostBasis(usage);
|
||||
const budgetCostBasis = readCostBasis(usage?.budget, usageCostBasis);
|
||||
const daily = usage?.daily_cost ?? [];
|
||||
const maxDailyCost = Math.max(0, ...daily.map((item) => item.cost_usd));
|
||||
const maxDailyCost = Math.max(
|
||||
0,
|
||||
...daily
|
||||
.filter((item) => !isUnknownCostBasis(readCostBasis(item, usageCostBasis)))
|
||||
.map((item) => item.cost_usd),
|
||||
);
|
||||
const totalTokens = (usage?.tokens_in ?? 0) + (usage?.tokens_out ?? 0);
|
||||
const dbCoverage = usage && usage.total_turns > 0 ? usage.metered_turns / usage.total_turns : 0;
|
||||
const unmeteredTurns = usage ? Math.max(0, usage.total_turns - usage.metered_turns) : 0;
|
||||
|
|
@ -238,6 +327,12 @@ export default function AdminAi() {
|
|||
const tokenUnmeteredTurns = usage?.token_unmetered_turns ?? 0;
|
||||
const costPerTurn = usage && usage.metered_turns > 0 ? usage.cost_usd / usage.metered_turns : 0;
|
||||
const costPerThousandTokens = usage && totalTokens > 0 ? (usage.cost_usd / totalTokens) * 1000 : 0;
|
||||
const canShowCostAverage =
|
||||
isExactCostBasis(usageCostBasis) || usageCostBasis === "reference_upper_bound";
|
||||
const canShowCostShare = isExactCostBasis(usageCostBasis);
|
||||
const budgetEnabled = Boolean(usage && usage.budget.status !== "disabled");
|
||||
const budgetRate = usage?.budget.used_ratio ?? 0;
|
||||
const budgetRateText = budgetRateLabel(budgetRate, budgetCostBasis);
|
||||
const engineService = health?.services.find((service) => service.key === "engine");
|
||||
const capabilityModels = capabilities?.models ?? [];
|
||||
const selectedModel = capabilityModels.find((model) => model.id === engine?.model);
|
||||
|
|
@ -316,12 +411,15 @@ export default function AdminAi() {
|
|||
<article>
|
||||
<span>누적 비용 추정</span>
|
||||
{/* 합계 금액은 소수 2자리, 원본 정밀도는 title 로 남긴다. */}
|
||||
<b title={usage ? costTitle(usage.cost_usd) : undefined}>
|
||||
{usage ? costLabel(usage.cost_usd) : "—"}
|
||||
<b title={usage ? costTitleWithBasis(usage.cost_usd, usageCostBasis) : undefined}>
|
||||
{usage ? costWithBasis(usage.cost_usd, usageCostBasis) : "—"}
|
||||
</b>
|
||||
<small>
|
||||
{usage
|
||||
? `기록 ${costLabel(usage.recorded_cost_usd ?? usage.cost_usd)} · 참조 ${costLabel(usage.estimated_cost_usd ?? 0)}`
|
||||
? `기록 ${costLabel(usage.recorded_cost_usd ?? usage.cost_usd)} · 참조 ${costWithBasis(
|
||||
usage.estimated_cost_usd ?? 0,
|
||||
usageCostBasis,
|
||||
)} · ${costBasisLabel(usageCostBasis)}`
|
||||
: `${windowDays}일 DB 집계`}
|
||||
</small>
|
||||
</article>
|
||||
|
|
@ -343,11 +441,27 @@ export default function AdminAi() {
|
|||
<article>
|
||||
<span>호출당 비용</span>
|
||||
{/* 단가는 2자리면 0이 되므로 4자리. 원본 정밀도는 title. */}
|
||||
<b title={usage ? costTitle(costPerTurn) : undefined}>
|
||||
{usage ? costLabel(costPerTurn, 4) : "—"}
|
||||
<b
|
||||
title={
|
||||
usage && canShowCostAverage
|
||||
? costTitleWithBasis(costPerTurn, usageCostBasis)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{usage && canShowCostAverage ? costWithBasis(costPerTurn, usageCostBasis, 4) : "—"}
|
||||
</b>
|
||||
<small title={usage && totalTokens > 0 ? costTitle(costPerThousandTokens) : undefined}>
|
||||
1천 토큰당 {usage ? (totalTokens > 0 && tokenUnmeteredTurns === 0 ? costLabel(costPerThousandTokens, 4) : "부분 계량") : "—"}
|
||||
<small
|
||||
title={
|
||||
usage && totalTokens > 0 && tokenUnmeteredTurns === 0 && canShowCostAverage
|
||||
? costTitleWithBasis(costPerThousandTokens, usageCostBasis)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
1천 토큰당 {usage
|
||||
? totalTokens > 0 && tokenUnmeteredTurns === 0 && canShowCostAverage
|
||||
? costWithBasis(costPerThousandTokens, usageCostBasis, 4)
|
||||
: "—"
|
||||
: "—"}
|
||||
</small>
|
||||
</article>
|
||||
</section>
|
||||
|
|
@ -361,7 +475,7 @@ export default function AdminAi() {
|
|||
<span className="aic-eyebrow">예산 관리</span>
|
||||
<h2>예산과 계량 커버리지</h2>
|
||||
</div>
|
||||
<span className={`aic-status aic-status--${usage?.budget.status ?? "disabled"}`}>
|
||||
<span className={`aic-status aic-status--${budgetStatusClass(usage?.budget.status)}`}>
|
||||
{usage ? budgetStatusLabel(usage.budget.status) : "확인 중"}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -371,29 +485,40 @@ export default function AdminAi() {
|
|||
<span>예산 사용</span>
|
||||
<b
|
||||
title={
|
||||
usage && usage.budget.status !== "disabled"
|
||||
? `${costTitle(usage.cost_usd)} / ${costTitle(usage.budget.limit_usd)}`
|
||||
budgetEnabled && usage
|
||||
? `${costTitleWithBasis(usage.cost_usd, budgetCostBasis)} / ${costTitle(
|
||||
usage.budget.limit_usd,
|
||||
)}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{usage?.budget.status === "disabled"
|
||||
? "한도 미설정"
|
||||
: `${costLabel(usage?.cost_usd ?? 0)} / ${costLabel(usage?.budget.limit_usd ?? 0)}`}
|
||||
: `${costWithBasis(usage?.cost_usd ?? 0, budgetCostBasis)} / ${costLabel(
|
||||
usage?.budget.limit_usd ?? 0,
|
||||
)}`}
|
||||
</b>
|
||||
</div>
|
||||
<div className="aic-track" aria-label={`예산 사용률 ${rateLabel(usage?.budget.used_ratio ?? 0)}`}>
|
||||
<span style={{ width: `${Math.min(100, (usage?.budget.used_ratio ?? 0) * 100)}%` }} />
|
||||
<div
|
||||
className="aic-track"
|
||||
aria-label={budgetEnabled ? `예산 ${budgetRateText}` : "예산 사용률 0%"}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: `${isUnknownCostBasis(budgetCostBasis) ? 0 : Math.min(100, budgetRate * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
title={
|
||||
usage && usage.budget.status !== "disabled"
|
||||
? `잔여 ${costTitle(usage.budget.remaining_usd ?? 0)}`
|
||||
budgetEnabled && usage
|
||||
? budgetRemainingLabel(usage.budget.remaining_usd, budgetCostBasis)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{usage?.budget.status === "disabled"
|
||||
? "서버의 ADMIN_USAGE_BUDGET_USD를 설정하면 80% 주의와 100% 초과 상태를 표시합니다."
|
||||
: `잔여 ${costLabel(usage?.budget.remaining_usd ?? 0)} · ${rateLabel(usage?.budget.used_ratio ?? 0)} 사용`}
|
||||
: `${budgetRemainingLabel(usage?.budget.remaining_usd, budgetCostBasis)} · ${budgetRateText}`}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -426,11 +551,21 @@ export default function AdminAi() {
|
|||
{daily.length > 0 ? (
|
||||
<div className="aic-chart" role="img" aria-label={`${windowDays}일 일별 AI 비용 막대 차트`}>
|
||||
{daily.map((item) => {
|
||||
const height = maxDailyCost > 0 ? Math.max(3, (item.cost_usd / maxDailyCost) * 100) : 3;
|
||||
const itemCostBasis = readCostBasis(item, usageCostBasis);
|
||||
const height =
|
||||
maxDailyCost > 0 && !isUnknownCostBasis(itemCostBasis)
|
||||
? Math.max(3, (item.cost_usd / maxDailyCost) * 100)
|
||||
: 3;
|
||||
return (
|
||||
// 막대 위 라벨은 일별 합계라 소수 2자리, 원본 정밀도는 title 로 남긴다.
|
||||
<div className="aic-chart__day" key={item.day} title={`${item.day} ${costTitle(item.cost_usd)}`}>
|
||||
<span className="aic-chart__value">{costLabel(item.cost_usd)}</span>
|
||||
<div
|
||||
className="aic-chart__day"
|
||||
key={item.day}
|
||||
title={`${item.day} ${costTitleWithBasis(item.cost_usd, itemCostBasis)} · ${costBasisLabel(itemCostBasis)}`}
|
||||
>
|
||||
<span className="aic-chart__value">
|
||||
{costWithBasis(item.cost_usd, itemCostBasis)}
|
||||
</span>
|
||||
<span className="aic-chart__rail">
|
||||
<span className="aic-chart__bar" style={{ height: `${height}%` }} />
|
||||
</span>
|
||||
|
|
@ -468,7 +603,16 @@ export default function AdminAi() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedBreakdown.map((row) => (
|
||||
{sortedBreakdown.map((row) => {
|
||||
const rowCostBasis = readCostBasis(row, usageCostBasis);
|
||||
const canShowRowAverage =
|
||||
isExactCostBasis(rowCostBasis) || rowCostBasis === "reference_upper_bound";
|
||||
const showRowShare = canShowCostShare && isExactCostBasis(rowCostBasis);
|
||||
const costShare =
|
||||
showRowShare && usage && usage.cost_usd > 0
|
||||
? row.cost_usd / usage.cost_usd
|
||||
: 0;
|
||||
return (
|
||||
<tr key={`${row.provider}:${row.model}`}>
|
||||
<td>
|
||||
<b>{row.model}</b>
|
||||
|
|
@ -476,40 +620,66 @@ export default function AdminAi() {
|
|||
</td>
|
||||
<td>{countLabel(row.turns)}</td>
|
||||
<td className="aic-token-cell">
|
||||
<span>{row.token_metered_turns > 0 ? countLabel(row.tokens_in) : "미계량"}</span>
|
||||
<span>
|
||||
{row.token_metered_turns > 0
|
||||
? countLabel(row.tokens_in)
|
||||
: "미계량"}
|
||||
</span>
|
||||
{row.token_unmetered_turns > 0 && row.token_metered_turns > 0 ? (
|
||||
<small>{countLabel(row.token_metered_turns)}/{countLabel(row.turns)}회</small>
|
||||
<small>
|
||||
{countLabel(row.token_metered_turns)}/{countLabel(row.turns)}회
|
||||
</small>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="aic-token-cell">
|
||||
<span>{row.token_metered_turns > 0 ? countLabel(row.tokens_out) : "미계량"}</span>
|
||||
<span>
|
||||
{row.token_metered_turns > 0
|
||||
? countLabel(row.tokens_out)
|
||||
: "미계량"}
|
||||
</span>
|
||||
{row.token_unmetered_turns > 0 && row.token_metered_turns > 0 ? (
|
||||
<small>{countLabel(row.token_metered_turns)}/{countLabel(row.turns)}회</small>
|
||||
<small>
|
||||
{countLabel(row.token_metered_turns)}/{countLabel(row.turns)}회
|
||||
</small>
|
||||
) : null}
|
||||
</td>
|
||||
{/* 비용 미보고 모델은 공식 참조단가 추정임을 숫자와 함께 명시한다. */}
|
||||
<td
|
||||
className="aic-cost-cell"
|
||||
title={row.rate_label ?? costTitle(row.cost_usd)}
|
||||
title={
|
||||
row.rate_label ?? costTitleWithBasis(row.cost_usd, rowCostBasis)
|
||||
}
|
||||
>
|
||||
<span>
|
||||
{row.cost_basis === "unavailable" ? "미산정" : costLabel(row.cost_usd)}
|
||||
</span>
|
||||
<small>{costBasisLabel(row.cost_basis)}</small>
|
||||
<span>{costWithBasis(row.cost_usd, rowCostBasis)}</span>
|
||||
<small>{costBasisLabel(rowCostBasis)}</small>
|
||||
</td>
|
||||
<td title={costTitle(row.turns > 0 ? row.cost_usd / row.turns : 0)}>
|
||||
{row.cost_basis === "unavailable"
|
||||
? "—"
|
||||
: costLabel(row.turns > 0 ? row.cost_usd / row.turns : 0, 4)}
|
||||
<td
|
||||
title={
|
||||
canShowRowAverage
|
||||
? costTitleWithBasis(
|
||||
row.turns > 0 ? row.cost_usd / row.turns : 0,
|
||||
rowCostBasis,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{canShowRowAverage
|
||||
? costWithBasis(
|
||||
row.turns > 0 ? row.cost_usd / row.turns : 0,
|
||||
rowCostBasis,
|
||||
4,
|
||||
)
|
||||
: "—"}
|
||||
</td>
|
||||
<td>
|
||||
<span className="aic-share">
|
||||
<span style={{ width: `${usage && usage.cost_usd > 0 ? (row.cost_usd / usage.cost_usd) * 100 : 0}%` }} />
|
||||
<span style={{ width: `${costShare * 100}%` }} />
|
||||
</span>
|
||||
{rateLabel(usage && usage.cost_usd > 0 ? row.cost_usd / usage.cost_usd : 0)}
|
||||
{showRowShare ? rateLabel(costShare) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ type GateItem =
|
|||
| { kind: "model_change_gate"; value: ModelChangeGateView }
|
||||
| { kind: "release_gate"; value: ReleaseGateView };
|
||||
|
||||
type ApprovalDecision = HumanApprovalRequest["decision"];
|
||||
type DecisionIntent = "approve" | "hold" | "reject";
|
||||
|
||||
function shortId(value: string, length = 10): string {
|
||||
return value.length > length ? `${value.slice(0, length)}…` : value;
|
||||
}
|
||||
|
|
@ -76,7 +79,7 @@ function gateTitle(item: GateItem): string {
|
|||
return `릴리스 ${item.value.release_id}`;
|
||||
}
|
||||
|
||||
function gateDecision(item: GateItem): HumanApprovalRequest["decision"] {
|
||||
function computedGateDecision(item: GateItem): ApprovalDecision {
|
||||
if (item.kind === "release_gate") {
|
||||
return item.value.qualified ? "approve_promotion" : "reject";
|
||||
}
|
||||
|
|
@ -85,6 +88,43 @@ function gateDecision(item: GateItem): HumanApprovalRequest["decision"] {
|
|||
return "keep_quarantine";
|
||||
}
|
||||
|
||||
function gateApprovalDecision(item: GateItem): ApprovalDecision {
|
||||
if (item.kind === "model_change_gate" && item.value.gate_decision === "rollback") {
|
||||
return "authorize_rollback";
|
||||
}
|
||||
return "approve_promotion";
|
||||
}
|
||||
|
||||
function gateApprovalCandidateReady(item: GateItem): boolean {
|
||||
if (item.kind === "release_gate") return item.value.qualified;
|
||||
return item.value.gate_decision !== "quarantine";
|
||||
}
|
||||
|
||||
function decisionIntent(
|
||||
decision: ApprovalDecision | undefined,
|
||||
approvalDecision: ApprovalDecision,
|
||||
): DecisionIntent | null {
|
||||
if (decision === approvalDecision) return "approve";
|
||||
if (decision === "keep_quarantine") return "hold";
|
||||
if (decision === "reject") return "reject";
|
||||
return null;
|
||||
}
|
||||
|
||||
function decisionIntentLabel(intent: DecisionIntent | null): string {
|
||||
if (intent === "approve") return "승인";
|
||||
if (intent === "hold") return "보류";
|
||||
if (intent === "reject") return "반려";
|
||||
return "결정";
|
||||
}
|
||||
|
||||
function recordedDecisionLabel(decision: ApprovalDecision): string {
|
||||
if (decision === "approve_content") return "콘텐츠 승인 원장 기록됨";
|
||||
if (decision === "approve_promotion") return "승격 승인 원장 기록됨";
|
||||
if (decision === "authorize_rollback") return "롤백 승인 원장 기록됨";
|
||||
if (decision === "keep_quarantine") return "보류 원장 기록됨";
|
||||
return "반려 원장 기록됨";
|
||||
}
|
||||
|
||||
function decisionLabel(item: GateItem): string {
|
||||
if (item.kind === "release_gate") {
|
||||
return item.value.qualified ? "승격 후보" : "게이트 미통과";
|
||||
|
|
@ -94,12 +134,23 @@ function decisionLabel(item: GateItem): string {
|
|||
return "격리 유지";
|
||||
}
|
||||
|
||||
function actionLabel(item: GateItem): string {
|
||||
const decision = gateDecision(item);
|
||||
if (decision === "approve_promotion") return "사람 승인 기록";
|
||||
if (decision === "authorize_rollback") return "롤백 승인 기록";
|
||||
if (decision === "keep_quarantine") return "격리 결정 기록";
|
||||
return "거부 결정 기록";
|
||||
function submitLabel(intent: DecisionIntent | null, approvalDecision: ApprovalDecision): string {
|
||||
if (intent === "approve" && approvalDecision === "authorize_rollback") return "롤백 승인 기록";
|
||||
if (intent === "approve") return "승인 기록";
|
||||
if (intent === "hold") return "보류 기록";
|
||||
if (intent === "reject") return "반려 기록";
|
||||
return "결정 선택 필요";
|
||||
}
|
||||
|
||||
function submitVariant(
|
||||
intent: DecisionIntent | null,
|
||||
approvalDecision: ApprovalDecision,
|
||||
): "primary" | "secondary" | "danger" {
|
||||
if (intent === "reject" || (intent === "approve" && approvalDecision === "authorize_rollback")) {
|
||||
return "danger";
|
||||
}
|
||||
if (intent === "hold") return "secondary";
|
||||
return "primary";
|
||||
}
|
||||
|
||||
function artifactsFor(
|
||||
|
|
@ -174,19 +225,100 @@ function ArtifactRail({ artifacts }: { artifacts: GateArtifactView[] }) {
|
|||
);
|
||||
}
|
||||
|
||||
function DecisionSelector({
|
||||
name,
|
||||
decision,
|
||||
approvalDecision,
|
||||
approvalDescription,
|
||||
approvalAllowed,
|
||||
approvalBlockedReason,
|
||||
onChange,
|
||||
}: {
|
||||
name: string;
|
||||
decision: ApprovalDecision | undefined;
|
||||
approvalDecision: ApprovalDecision;
|
||||
approvalDescription: string;
|
||||
approvalAllowed: boolean;
|
||||
approvalBlockedReason: string;
|
||||
onChange: (decision: ApprovalDecision) => void;
|
||||
}) {
|
||||
const options: Array<{
|
||||
intent: DecisionIntent;
|
||||
value: ApprovalDecision;
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
intent: "approve",
|
||||
value: approvalDecision,
|
||||
label: "승인",
|
||||
description: approvalAllowed ? approvalDescription : approvalBlockedReason,
|
||||
},
|
||||
{
|
||||
intent: "hold",
|
||||
value: "keep_quarantine",
|
||||
label: "보류",
|
||||
description: "격리 상태를 유지하고 재검토",
|
||||
},
|
||||
{
|
||||
intent: "reject",
|
||||
value: "reject",
|
||||
label: "반려",
|
||||
description: "후보를 반려하고 원장에 기록",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<fieldset className="cic-decision-picker">
|
||||
<legend>사람 결정</legend>
|
||||
<div className="cic-decision-options">
|
||||
{options.map((option) => {
|
||||
const disabled = option.intent === "approve" && !approvalAllowed;
|
||||
const descriptionId = `${name}-${option.intent}-description`;
|
||||
return (
|
||||
<label
|
||||
className={`cic-decision-option is-${option.intent} ${
|
||||
decision === option.value ? "is-selected" : ""
|
||||
} ${disabled ? "is-disabled" : ""}`}
|
||||
key={option.intent}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
value={option.value}
|
||||
checked={decision === option.value}
|
||||
disabled={disabled}
|
||||
aria-label={option.label}
|
||||
aria-describedby={descriptionId}
|
||||
onChange={() => onChange(option.value)}
|
||||
/>
|
||||
<strong>{option.label}</strong>
|
||||
<small id={descriptionId}>{option.description}</small>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentQualificationCard({
|
||||
item,
|
||||
view,
|
||||
decision,
|
||||
reason,
|
||||
onDecisionChange,
|
||||
onReasonChange,
|
||||
onApprove,
|
||||
onSubmit,
|
||||
approving,
|
||||
}: {
|
||||
item: ContentQualificationView;
|
||||
view: ContinuousImprovementViewResponse;
|
||||
decision: ApprovalDecision | undefined;
|
||||
reason: string;
|
||||
onDecisionChange: (decision: ApprovalDecision) => void;
|
||||
onReasonChange: (value: string) => void;
|
||||
onApprove: () => void;
|
||||
onSubmit: () => void;
|
||||
approving: boolean;
|
||||
}) {
|
||||
const payload = item.draft_payload;
|
||||
|
|
@ -200,19 +332,33 @@ function ContentQualificationCard({
|
|||
);
|
||||
const boundarySafe = isBoundarySafe(view);
|
||||
const evidenceReady = item.source_provenance_uris.length > 0;
|
||||
const canApprove =
|
||||
boundarySafe && Boolean(payload) && evidenceReady && !approval && !catalog && reason.trim().length > 0;
|
||||
const approvalDecision: ApprovalDecision = "approve_content";
|
||||
const approvalAllowed = boundarySafe && Boolean(payload) && evidenceReady && !catalog;
|
||||
const intent = decisionIntent(decision, approvalDecision);
|
||||
const canSubmit =
|
||||
Boolean(intent) &&
|
||||
!approval &&
|
||||
!catalog &&
|
||||
reason.trim().length > 0 &&
|
||||
(intent !== "approve" || approvalAllowed);
|
||||
const requirementId = `content-approval-requirement-${item.qualification_id}`;
|
||||
const titleId = `content-qualification-title-${item.qualification_id}`;
|
||||
const blockedReason = !boundarySafe
|
||||
const approvalBlockedReason = !boundarySafe
|
||||
? "데이터 경계 위반으로 승인 차단"
|
||||
: !payload
|
||||
? "검수 가능한 visible payload가 없어 승인 차단"
|
||||
: !evidenceReady
|
||||
? "repo provenance가 없어 승인 차단"
|
||||
: "승인 조건 충족";
|
||||
const requirementCopy = catalog
|
||||
? "이미 카탈로그에 반영되어 추가 결정을 기록할 수 없음"
|
||||
: !intent
|
||||
? "승인·보류·반려 중 하나를 명시적으로 선택해야 함"
|
||||
: intent === "approve" && !approvalAllowed
|
||||
? approvalBlockedReason
|
||||
: !reason.trim()
|
||||
? "승인 사유를 먼저 입력해야 함"
|
||||
: "visible payload와 repo provenance 검토 준비됨";
|
||||
? `${decisionIntentLabel(intent)} 사유를 먼저 입력해야 함`
|
||||
: `${decisionIntentLabel(intent)} 결정과 사유를 append-only 원장에 기록할 준비됨`;
|
||||
|
||||
return (
|
||||
<article
|
||||
|
|
@ -294,9 +440,12 @@ function ContentQualificationCard({
|
|||
|
||||
{approval ? (
|
||||
<div className="cic-effect" role="status">
|
||||
<Icon name={catalog ? "check" : "alert"} size={17} />
|
||||
<Icon
|
||||
name={approval.decision === "reject" ? "alert" : catalog ? "check" : "shield"}
|
||||
size={17}
|
||||
/>
|
||||
<div>
|
||||
<strong>{catalog ? "카탈로그 승인 원장 기록됨" : "후보 결정 원장 기록됨"}</strong>
|
||||
<strong>{recordedDecisionLabel(approval.decision)}</strong>
|
||||
<span>
|
||||
{approval.decision} · {dateTimeLabel(approval.created_at)}
|
||||
</span>
|
||||
|
|
@ -307,14 +456,27 @@ function ContentQualificationCard({
|
|||
className="cic-approval-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (canApprove && !approving) onApprove();
|
||||
if (canSubmit && !approving) onSubmit();
|
||||
}}
|
||||
>
|
||||
<label htmlFor={`content-reason-${item.qualification_id}`}>
|
||||
<span>콘텐츠 승인 사유</span>
|
||||
<DecisionSelector
|
||||
name={`content-decision-${item.qualification_id}`}
|
||||
decision={decision}
|
||||
approvalDecision={approvalDecision}
|
||||
approvalDescription="카탈로그 게시를 승인"
|
||||
approvalAllowed={approvalAllowed}
|
||||
approvalBlockedReason={approvalBlockedReason}
|
||||
onChange={onDecisionChange}
|
||||
/>
|
||||
<label
|
||||
className="cic-approval-reason"
|
||||
htmlFor={`content-reason-${item.qualification_id}`}
|
||||
>
|
||||
<span>콘텐츠 결정 사유</span>
|
||||
<input
|
||||
type="text"
|
||||
id={`content-reason-${item.qualification_id}`}
|
||||
name={`content-approval-reason-${item.qualification_id}`}
|
||||
name={`content-decision-reason-${item.qualification_id}`}
|
||||
value={reason}
|
||||
onChange={(event) => onReasonChange(event.target.value)}
|
||||
aria-describedby={requirementId}
|
||||
|
|
@ -325,18 +487,18 @@ function ContentQualificationCard({
|
|||
</label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
variant={submitVariant(intent, approvalDecision)}
|
||||
type="submit"
|
||||
disabled={!canApprove || approving}
|
||||
disabled={!canSubmit || approving}
|
||||
aria-describedby={requirementId}
|
||||
>
|
||||
{approving ? "승인 원장 기록 중…" : "카탈로그 승인"}
|
||||
{approving ? "결정 기록 중…" : submitLabel(intent, approvalDecision)}
|
||||
</Button>
|
||||
<p
|
||||
id={requirementId}
|
||||
className={`cic-approval-requirement ${canApprove ? "is-ready" : !boundarySafe ? "is-blocked" : ""}`}
|
||||
className={`cic-approval-requirement ${canSubmit ? "is-ready" : intent === "approve" && !approvalAllowed ? "is-blocked" : ""}`}
|
||||
>
|
||||
{blockedReason}
|
||||
{requirementCopy}
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
|
|
@ -346,15 +508,19 @@ function ContentQualificationCard({
|
|||
|
||||
function PipelineSection({
|
||||
view,
|
||||
decisions,
|
||||
reasons,
|
||||
setDecision,
|
||||
setReason,
|
||||
approve,
|
||||
submit,
|
||||
approvingId,
|
||||
}: {
|
||||
view: ContinuousImprovementViewResponse;
|
||||
decisions: Partial<Record<string, ApprovalDecision>>;
|
||||
reasons: Record<string, string>;
|
||||
setDecision: (id: string, decision: ApprovalDecision) => void;
|
||||
setReason: (id: string, value: string) => void;
|
||||
approve: (item: ContentQualificationView) => void;
|
||||
submit: (item: ContentQualificationView) => void;
|
||||
approvingId: string | null;
|
||||
}) {
|
||||
const latest = view.content_qualifications.slice(0, 4);
|
||||
|
|
@ -399,9 +565,11 @@ function PipelineSection({
|
|||
key={item.qualification_id}
|
||||
item={item}
|
||||
view={view}
|
||||
decision={decisions[item.qualification_id]}
|
||||
reason={reasons[item.qualification_id] ?? ""}
|
||||
onDecisionChange={(decision) => setDecision(item.qualification_id, decision)}
|
||||
onReasonChange={(value) => setReason(item.qualification_id, value)}
|
||||
onApprove={() => approve(item)}
|
||||
onSubmit={() => submit(item)}
|
||||
approving={approvingId === item.qualification_id}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -414,17 +582,21 @@ function PipelineSection({
|
|||
function GateCard({
|
||||
item,
|
||||
view,
|
||||
decision,
|
||||
reason,
|
||||
onDecisionChange,
|
||||
onReasonChange,
|
||||
onApprove,
|
||||
onSubmit,
|
||||
approving,
|
||||
boundarySafe,
|
||||
}: {
|
||||
item: GateItem;
|
||||
view: ContinuousImprovementViewResponse;
|
||||
decision: ApprovalDecision | undefined;
|
||||
reason: string;
|
||||
onDecisionChange: (decision: ApprovalDecision) => void;
|
||||
onReasonChange: (value: string) => void;
|
||||
onApprove: () => void;
|
||||
onSubmit: () => void;
|
||||
approving: boolean;
|
||||
boundarySafe: boolean;
|
||||
}) {
|
||||
|
|
@ -437,16 +609,32 @@ function GateCard({
|
|||
const effect = view.lifecycle_events.find(
|
||||
(event) => event.target_kind === item.kind && event.target_id === id,
|
||||
);
|
||||
const canSubmit = boundarySafe && complete && !approval && reason.trim().length > 0;
|
||||
const blockedReason = !boundarySafe
|
||||
const approvalDecision = gateApprovalDecision(item);
|
||||
const approvalCandidateReady = gateApprovalCandidateReady(item);
|
||||
const approvalAllowed = boundarySafe && complete && approvalCandidateReady;
|
||||
const intent = decisionIntent(decision, approvalDecision);
|
||||
const canSubmit =
|
||||
Boolean(intent) &&
|
||||
!approval &&
|
||||
reason.trim().length > 0 &&
|
||||
(intent !== "approve" || approvalAllowed);
|
||||
const approvalBlockedReason = !boundarySafe
|
||||
? "데이터 경계 위반으로 승인 차단"
|
||||
: !complete
|
||||
? "필수 증거 4종이 모두 있어야 기록 가능"
|
||||
: !reason.trim()
|
||||
? "승인 사유를 먼저 입력해야 함"
|
||||
: undefined;
|
||||
? "필수 증거 4종 미완료로 승인 차단"
|
||||
: !approvalCandidateReady
|
||||
? item.kind === "release_gate"
|
||||
? "계산 게이트 미통과로 승격 승인 차단"
|
||||
: "계산 결과가 격리 유지라 승격 승인 차단"
|
||||
: "승인 조건 충족";
|
||||
const requirementId = `approval-requirement-${id}`;
|
||||
const requirementCopy = blockedReason ?? "증거 4종과 승인 사유가 준비됨";
|
||||
const requirementCopy = !intent
|
||||
? "승인·보류·반려 중 하나를 명시적으로 선택해야 함"
|
||||
: intent === "approve" && !approvalAllowed
|
||||
? approvalBlockedReason
|
||||
: !reason.trim()
|
||||
? `${decisionIntentLabel(intent)} 사유를 먼저 입력해야 함`
|
||||
: `${decisionIntentLabel(intent)} 결정과 사유를 append-only 원장에 기록할 준비됨`;
|
||||
|
||||
return (
|
||||
<article className="cic-gate-card" data-gate-id={id}>
|
||||
|
|
@ -458,7 +646,7 @@ function GateCard({
|
|||
<h3>{gateTitle(item)}</h3>
|
||||
<code>{shortId(id, 14)}</code>
|
||||
</div>
|
||||
<span className={`cic-decision cic-decision--${gateDecision(item)}`}>
|
||||
<span className={`cic-decision cic-decision--${computedGateDecision(item)}`}>
|
||||
{decisionLabel(item)}
|
||||
</span>
|
||||
</header>
|
||||
|
|
@ -475,9 +663,12 @@ function GateCard({
|
|||
|
||||
{approval ? (
|
||||
<div className="cic-effect" role="status">
|
||||
<Icon name="check" size={17} />
|
||||
<Icon
|
||||
name={approval.decision === "reject" ? "alert" : approval.decision === "keep_quarantine" ? "shield" : "check"}
|
||||
size={17}
|
||||
/>
|
||||
<div>
|
||||
<strong>append-only 승인 원장 기록됨</strong>
|
||||
<strong>{recordedDecisionLabel(approval.decision)}</strong>
|
||||
<span>
|
||||
{approval.decision} · {dateTimeLabel(approval.created_at)}
|
||||
{effect ? ` · effect ${shortId(effect.lifecycle_event_id, 8)}` : ""}
|
||||
|
|
@ -489,14 +680,26 @@ function GateCard({
|
|||
className="cic-approval-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (canSubmit && !approving) onApprove();
|
||||
if (canSubmit && !approving) onSubmit();
|
||||
}}
|
||||
>
|
||||
<label htmlFor={`reason-${id}`}>
|
||||
<span>사람 승인 사유</span>
|
||||
<DecisionSelector
|
||||
name={`gate-decision-${item.kind}-${id}`}
|
||||
decision={decision}
|
||||
approvalDecision={approvalDecision}
|
||||
approvalDescription={
|
||||
approvalDecision === "authorize_rollback" ? "롤백 실행을 승인" : "승격 효과를 승인"
|
||||
}
|
||||
approvalAllowed={approvalAllowed}
|
||||
approvalBlockedReason={approvalBlockedReason}
|
||||
onChange={onDecisionChange}
|
||||
/>
|
||||
<label className="cic-approval-reason" htmlFor={`reason-${id}`}>
|
||||
<span>사람 결정 사유</span>
|
||||
<input
|
||||
type="text"
|
||||
id={`reason-${id}`}
|
||||
name={`approval-reason-${id}`}
|
||||
name={`decision-reason-${id}`}
|
||||
value={reason}
|
||||
onChange={(event) => onReasonChange(event.target.value)}
|
||||
aria-describedby={requirementId}
|
||||
|
|
@ -507,16 +710,16 @@ function GateCard({
|
|||
</label>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={gateDecision(item) === "authorize_rollback" ? "danger" : "primary"}
|
||||
variant={submitVariant(intent, approvalDecision)}
|
||||
type="submit"
|
||||
disabled={!canSubmit || approving}
|
||||
aria-describedby={requirementId}
|
||||
>
|
||||
{approving ? "원장 기록 중…" : complete ? actionLabel(item) : "증거 4종 미완료"}
|
||||
{approving ? "결정 기록 중…" : submitLabel(intent, approvalDecision)}
|
||||
</Button>
|
||||
<p
|
||||
id={requirementId}
|
||||
className={`cic-approval-requirement ${!boundarySafe ? "is-blocked" : canSubmit ? "is-ready" : ""}`}
|
||||
className={`cic-approval-requirement ${canSubmit ? "is-ready" : intent === "approve" && !approvalAllowed ? "is-blocked" : ""}`}
|
||||
>
|
||||
{requirementCopy}
|
||||
</p>
|
||||
|
|
@ -528,15 +731,19 @@ function GateCard({
|
|||
|
||||
function GateSection({
|
||||
view,
|
||||
decisions,
|
||||
reasons,
|
||||
setDecision,
|
||||
setReason,
|
||||
approve,
|
||||
submit,
|
||||
approvingId,
|
||||
}: {
|
||||
view: ContinuousImprovementViewResponse;
|
||||
decisions: Partial<Record<string, ApprovalDecision>>;
|
||||
reasons: Record<string, string>;
|
||||
setDecision: (id: string, decision: ApprovalDecision) => void;
|
||||
setReason: (id: string, value: string) => void;
|
||||
approve: (item: GateItem) => void;
|
||||
submit: (item: GateItem) => void;
|
||||
approvingId: string | null;
|
||||
}) {
|
||||
const items: GateItem[] = [
|
||||
|
|
@ -572,9 +779,11 @@ function GateSection({
|
|||
key={`${item.kind}:${gateId(item)}`}
|
||||
item={item}
|
||||
view={view}
|
||||
decision={decisions[gateId(item)]}
|
||||
reason={reasons[gateId(item)] ?? ""}
|
||||
onDecisionChange={(decision) => setDecision(gateId(item), decision)}
|
||||
onReasonChange={(value) => setReason(gateId(item), value)}
|
||||
onApprove={() => approve(item)}
|
||||
onSubmit={() => submit(item)}
|
||||
approving={approvingId === gateId(item)}
|
||||
boundarySafe={boundarySafe}
|
||||
/>
|
||||
|
|
@ -704,6 +913,7 @@ export function ContinuousImprovementCockpit() {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [approvingId, setApprovingId] = useState<string | null>(null);
|
||||
const [decisions, setDecisions] = useState<Partial<Record<string, ApprovalDecision>>>({});
|
||||
const [reasons, setReasons] = useState<Record<string, string>>({});
|
||||
|
||||
const load = useCallback(async (signal?: AbortSignal) => {
|
||||
|
|
@ -726,49 +936,68 @@ export function ContinuousImprovementCockpit() {
|
|||
return () => controller.abort();
|
||||
}, [load]);
|
||||
|
||||
const approve = useCallback(
|
||||
const recordGateDecision = useCallback(
|
||||
async (item: GateItem) => {
|
||||
if (!view) return;
|
||||
const id = gateId(item);
|
||||
const artifacts = artifactsFor(view, item.kind, id);
|
||||
const approvalDecision = gateApprovalDecision(item);
|
||||
const decision = decisions[id];
|
||||
const intent = decisionIntent(decision, approvalDecision);
|
||||
const reason = reasons[id]?.trim() ?? "";
|
||||
const alreadyApproved = view.approvals.some(
|
||||
(candidate) => candidate.target_kind === item.kind && candidate.target_id === id,
|
||||
);
|
||||
if (!isBoundarySafe(view) || !hasCompleteArtifacts(artifacts) || alreadyApproved || !reason) {
|
||||
const approvalAllowed =
|
||||
isBoundarySafe(view) && hasCompleteArtifacts(artifacts) && gateApprovalCandidateReady(item);
|
||||
if (!decision || !intent || alreadyApproved || !reason || (intent === "approve" && !approvalAllowed)) {
|
||||
return;
|
||||
}
|
||||
setApprovingId(id);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const evidenceRefs = [...new Set(artifacts.map((artifact) => artifact.provenance_uri))];
|
||||
const artifactEvidenceRefs = [
|
||||
...new Set(artifacts.map((artifact) => artifact.provenance_uri)),
|
||||
];
|
||||
const evidenceRefs =
|
||||
artifactEvidenceRefs.length > 0
|
||||
? artifactEvidenceRefs
|
||||
: [`audit://continuous-improvement/human-review/${id}`];
|
||||
const result = await continuousImprovementApi.appendApproval({
|
||||
submission_id: randomUuid(),
|
||||
approval_event_id: randomUuid(),
|
||||
effect_record_id: randomUuid(),
|
||||
target_kind: item.kind,
|
||||
target_id: id,
|
||||
decision: gateDecision(item),
|
||||
decision,
|
||||
reason_code: reason,
|
||||
evidence_refs: evidenceRefs,
|
||||
});
|
||||
setNotice(`append-only 승인과 효과 ${shortId(result.effect_record_id, 8)} 기록 완료`);
|
||||
setNotice(
|
||||
intent === "approve"
|
||||
? `${decisionIntentLabel(intent)}과 append-only 효과 ${shortId(result.effect_record_id, 8)} 기록 완료`
|
||||
: `${decisionIntentLabel(intent)} 결정을 append-only 원장에 기록 완료`,
|
||||
);
|
||||
setDecisions((current) => ({ ...current, [id]: undefined }));
|
||||
setReasons((current) => ({ ...current, [id]: "" }));
|
||||
await load();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "승인 원장을 기록하지 못했어.");
|
||||
setError(cause instanceof Error ? cause.message : "사람 결정 원장을 기록하지 못했어.");
|
||||
} finally {
|
||||
setApprovingId(null);
|
||||
}
|
||||
},
|
||||
[load, reasons, view],
|
||||
[decisions, load, reasons, view],
|
||||
);
|
||||
|
||||
const approveQualification = useCallback(
|
||||
const recordContentDecision = useCallback(
|
||||
async (item: ContentQualificationView) => {
|
||||
if (!view) return;
|
||||
const id = item.qualification_id;
|
||||
const approvalDecision: ApprovalDecision = "approve_content";
|
||||
const decision = decisions[id];
|
||||
const intent = decisionIntent(decision, approvalDecision);
|
||||
const reason = reasons[id]?.trim() ?? "";
|
||||
const alreadyApproved = view.approvals.some(
|
||||
(candidate) =>
|
||||
|
|
@ -777,13 +1006,15 @@ export function ContinuousImprovementCockpit() {
|
|||
const alreadyCataloged = view.catalog_entries.some(
|
||||
(candidate) => candidate.qualification_id === id,
|
||||
);
|
||||
const approvalAllowed =
|
||||
isBoundarySafe(view) && Boolean(item.draft_payload) && item.source_provenance_uris.length > 0;
|
||||
if (
|
||||
!isBoundarySafe(view) ||
|
||||
!item.draft_payload ||
|
||||
item.source_provenance_uris.length === 0 ||
|
||||
!decision ||
|
||||
!intent ||
|
||||
alreadyApproved ||
|
||||
alreadyCataloged ||
|
||||
!reason
|
||||
!reason ||
|
||||
(intent === "approve" && !approvalAllowed)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -803,20 +1034,25 @@ export function ContinuousImprovementCockpit() {
|
|||
effect_record_id: randomUuid(),
|
||||
target_kind: "content_qualification",
|
||||
target_id: id,
|
||||
decision: "approve_content",
|
||||
decision,
|
||||
reason_code: reason,
|
||||
evidence_refs: evidenceRefs,
|
||||
});
|
||||
setNotice(`카탈로그 승인과 append-only 효과 ${shortId(result.effect_record_id, 8)} 기록 완료`);
|
||||
setNotice(
|
||||
intent === "approve"
|
||||
? `콘텐츠 승인과 append-only 효과 ${shortId(result.effect_record_id, 8)} 기록 완료`
|
||||
: `${decisionIntentLabel(intent)} 결정을 append-only 원장에 기록 완료`,
|
||||
);
|
||||
setDecisions((current) => ({ ...current, [id]: undefined }));
|
||||
setReasons((current) => ({ ...current, [id]: "" }));
|
||||
await load();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "콘텐츠 승인 원장을 기록하지 못했어.");
|
||||
setError(cause instanceof Error ? cause.message : "콘텐츠 결정 원장을 기록하지 못했어.");
|
||||
} finally {
|
||||
setApprovingId(null);
|
||||
}
|
||||
},
|
||||
[load, reasons, view],
|
||||
[decisions, load, reasons, view],
|
||||
);
|
||||
|
||||
const orderedEvents = useMemo(
|
||||
|
|
@ -853,7 +1089,12 @@ export function ContinuousImprovementCockpit() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="cic" data-testid="continuous-improvement-cockpit" aria-busy={loading}>
|
||||
<div
|
||||
className="cic"
|
||||
data-testid="continuous-improvement-cockpit"
|
||||
data-vignette-admin-root="true"
|
||||
aria-busy={loading}
|
||||
>
|
||||
<header className="cic-hero">
|
||||
<div>
|
||||
<Kicker>관리자 · CONTINUOUS IMPROVEMENT OS</Kicker>
|
||||
|
|
@ -890,16 +1131,24 @@ export function ContinuousImprovementCockpit() {
|
|||
<BoundaryStrip view={view} />
|
||||
<PipelineSection
|
||||
view={view}
|
||||
decisions={decisions}
|
||||
reasons={reasons}
|
||||
setDecision={(id, decision) =>
|
||||
setDecisions((current) => ({ ...current, [id]: decision }))
|
||||
}
|
||||
setReason={(id, value) => setReasons((current) => ({ ...current, [id]: value }))}
|
||||
approve={(item) => void approveQualification(item)}
|
||||
submit={(item) => void recordContentDecision(item)}
|
||||
approvingId={approvingId}
|
||||
/>
|
||||
<GateSection
|
||||
view={view}
|
||||
decisions={decisions}
|
||||
reasons={reasons}
|
||||
setDecision={(id, decision) =>
|
||||
setDecisions((current) => ({ ...current, [id]: decision }))
|
||||
}
|
||||
setReason={(id, value) => setReasons((current) => ({ ...current, [id]: value }))}
|
||||
approve={(item) => void approve(item)}
|
||||
submit={(item) => void recordGateDecision(item)}
|
||||
approvingId={approvingId}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -596,19 +596,108 @@
|
|||
gap: 10px;
|
||||
}
|
||||
|
||||
.cic-approval-form label,
|
||||
.cic-approval-form label > span {
|
||||
display: block;
|
||||
.cic-decision-picker {
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.cic-approval-form label > span {
|
||||
.cic-decision-picker legend,
|
||||
.cic-approval-form .cic-approval-reason > span {
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cic-approval-form input {
|
||||
.cic-decision-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cic-decision-option {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
grid-template-rows: auto auto;
|
||||
column-gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
min-height: 48px;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 7px;
|
||||
background: var(--bg-surface-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cic-decision-option:focus-within {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--border-focus) 24%, transparent);
|
||||
}
|
||||
|
||||
.cic-decision-option.is-selected.is-approve {
|
||||
border-color: var(--accent-deep);
|
||||
background: var(--accent-tint);
|
||||
}
|
||||
|
||||
.cic-decision-option.is-selected.is-hold {
|
||||
border-color: var(--warn-solid);
|
||||
background: var(--warn-tint);
|
||||
}
|
||||
|
||||
.cic-decision-option.is-selected.is-reject {
|
||||
border-color: var(--crit-solid);
|
||||
background: var(--crit-tint);
|
||||
}
|
||||
|
||||
.cic-decision-option.is-disabled {
|
||||
opacity: 0.68;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cic-decision-option input[type="radio"] {
|
||||
grid-row: 1 / -1;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
accent-color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.cic-decision-option.is-hold input[type="radio"] {
|
||||
accent-color: var(--warn-solid);
|
||||
}
|
||||
|
||||
.cic-decision-option.is-reject input[type="radio"] {
|
||||
accent-color: var(--crit-solid);
|
||||
}
|
||||
|
||||
.cic-decision-option strong,
|
||||
.cic-decision-option small {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cic-decision-option strong {
|
||||
color: var(--text-strong);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.cic-decision-option small {
|
||||
color: var(--text-muted);
|
||||
font-size: 9px;
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cic-approval-form .cic-approval-reason,
|
||||
.cic-approval-form .cic-approval-reason > span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cic-approval-form input[type="text"] {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
padding: 7px 9px;
|
||||
|
|
@ -621,7 +710,7 @@
|
|||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cic-approval-form input:focus-visible {
|
||||
.cic-approval-form input[type="text"]:focus-visible {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--border-focus) 24%, transparent);
|
||||
}
|
||||
|
|
@ -1094,6 +1183,10 @@
|
|||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cic-decision-options {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.cic-approval-form .vg-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,32 @@
|
|||
gap:var(--sp-6);
|
||||
padding:clamp(22px,4vw,44px);
|
||||
}
|
||||
.ob-account{
|
||||
min-width:0;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:space-between;
|
||||
gap:var(--sp-3);
|
||||
padding-bottom:var(--sp-4);
|
||||
border-bottom:1px solid var(--border-subtle);
|
||||
}
|
||||
.ob-account__identity{
|
||||
min-width:0;
|
||||
display:grid;
|
||||
gap:5px;
|
||||
}
|
||||
.ob-account__identity span{
|
||||
color:var(--text-muted);
|
||||
font-size:12px;
|
||||
font-weight:730;
|
||||
}
|
||||
.ob-account__identity strong{
|
||||
min-width:0;
|
||||
color:var(--text-strong);
|
||||
font-size:var(--fs-sm);
|
||||
font-weight:760;
|
||||
overflow-wrap:anywhere;
|
||||
}
|
||||
.ob-head{
|
||||
display:grid;
|
||||
gap:8px;
|
||||
|
|
@ -252,6 +278,13 @@
|
|||
.ob-page{
|
||||
padding:14px;
|
||||
}
|
||||
.ob-account{
|
||||
align-items:stretch;
|
||||
flex-direction:column;
|
||||
}
|
||||
.ob-account .vg-btn{
|
||||
width:100%;
|
||||
}
|
||||
.ob-fields{
|
||||
grid-template-columns:1fr;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue