diff --git a/apps/web/e2e/admin-affect.spec.ts b/apps/web/e2e/admin-affect.spec.ts new file mode 100644 index 0000000..4156263 --- /dev/null +++ b/apps/web/e2e/admin-affect.spec.ts @@ -0,0 +1,312 @@ +import { expect, test, type Page } from "@playwright/test"; +import { pearsonCorrelation, type ChangeObservation } from "../src/pages/admin-affect/affectMath"; + +const DIMENSIONS = [ + "anxiety", + "sadness", + "anger", + "shame", + "guilt", + "loneliness", + "relief", + "hope", + "trust", +] as const; + +type Dimension = (typeof DIMENSIONS)[number]; + +type AffectTrace = { + schema_version: 1; + provider: string; + model: string; + latency_ms: number; + input_tokens: number; + output_tokens: number; + cost_usd: number | null; + turn_seq: number; + policy: { + version: "jev-affect-v1"; + min_confidence: number; + accepted_alpha: number; + accepted_cap: number; + tentative_alpha: number; + tentative_cap: number; + tentative_confidence_floor: number; + adjacent_probability_threshold: number; + }; + context: { stage: string; resistance: number; effective_openness: number; rapport_credit: number }; + dimensions: Array<{ + key: Dimension; + before: number; + target: number | null; + after: number; + confidence: number | null; + probabilities: number[] | null; + decision: "accepted" | "tentative" | "held"; + }>; +}; + +type AffectDetail = { + session_id: string; + persona_code: string; + current_emotions: Record; + traces: Array<{ turn_id: string; seq: number; created_at: string; trace: AffectTrace }>; + total_traces: number; + has_more: boolean; +}; + +function trace(seq: number, round: number, anxietyChange: number, sadnessChange: number): AffectDetail["traces"][number] { + return { + turn_id: `turn-${seq}`, + seq, + created_at: `2026-09-23T0${Math.min(round, 9)}:00:00Z`, + trace: { + schema_version: 1, + provider: "openai", + model: "jev-test-model", + latency_ms: 124, + input_tokens: 302, + output_tokens: 71, + cost_usd: 0.0012, + turn_seq: round, + policy: { + version: "jev-affect-v1", + min_confidence: 0.35, + accepted_alpha: 0.35, + accepted_cap: 0.15, + tentative_alpha: 0.15, + tentative_cap: 0.075, + tentative_confidence_floor: 0.35, + adjacent_probability_threshold: 0.8, + }, + context: { stage: "working", resistance: 0.2, effective_openness: 0.7, rapport_credit: 0.4 }, + dimensions: DIMENSIONS.map((key) => { + const change = key === "anxiety" ? anxietyChange : key === "sadness" ? sadnessChange : 0; + return { + key, + before: 0.5, + target: key === "trust" ? null : 0.5 + change, + after: 0.5 + change, + confidence: key === "trust" ? null : 0.72, + probabilities: key === "trust" ? null : [0.02, 0.08, 0.6, 0.25, 0.05], + decision: key === "trust" ? "held" : key === "sadness" ? "tentative" : "accepted", + }; + }), + }, + }; +} + +function detail(sessionId: string, personaCode: string, traces: AffectDetail["traces"][]): AffectDetail { + return { + session_id: sessionId, + persona_code: personaCode, + current_emotions: Object.fromEntries(DIMENSIONS.map((key) => [key, key === "trust" ? null : 0.5])) as Record, + traces, + total_traces: traces.length, + has_more: false, + }; +} + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +async function mockAffectAdmin(page: Page, options: { + detailBySession?: Record; + olderDetailBySession?: Record; + failDetails?: boolean; + detailGate?: (sessionId: string) => Promise | undefined; +} = {}) { + const firstTraces = [ + trace(2, 1, 0.01, -0.01), + trace(4, 2, 0.02, -0.02), + trace(6, 3, 0.03, -0.03), + trace(8, 4, 0.04, -0.04), + trace(12, 6, 0.05, -0.05), + trace(14, 7, 0.06, -0.06), + ]; + const details = options.detailBySession ?? { + "session-alpha": detail("session-alpha", "P1", firstTraces), + "session-beta": detail("session-beta", "P2", []), + }; + await page.route("**/api/**", async (route) => { + const request = route.request(); + const url = new URL(request.url()); + const fulfill = (body: unknown, status = 200) => route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); + if (request.method() === "GET" && url.pathname.endsWith("/auth/me")) { + await fulfill({ user_id: "affect-admin", email: "admin@example.invalid", display_name: "관리자", role: "admin", admin_access: true, super_admin: true, account_status: "approved", approval_required: false, cohort_ids: [], consent_at: null, onboarding_completed_at: 1, nickname: "", self_introduction: "", avatar_url: "" }); + return; + } + if (request.method() === "GET" && url.pathname.endsWith("/admin/affect/sessions")) { + await fulfill({ runtime: { enabled: true, configured: true, provider: "openai", model: "runtime-model" }, sessions: [ + { session_id: "session-alpha", persona_code: "P1", started_at: "2026-09-23T01:00:00Z", ended: true, trace_count: firstTraces.length }, + { session_id: "session-beta", persona_code: "P2", started_at: "2026-09-22T01:00:00Z", ended: false, trace_count: 0 }, + ], total: 2, limit: 30, offset: 0 }); + return; + } + if (request.method() === "GET" && url.pathname.includes("/admin/affect/sessions/")) { + const sessionId = url.pathname.split("/").at(-1) ?? ""; + const gate = options.detailGate?.(sessionId); + if (gate) await gate; + if (options.failDetails) { + await fulfill({ detail: "감정 기록 저장소를 읽을 수 없습니다." }, 503); + return; + } + const response = url.searchParams.has("before_seq") + ? options.olderDetailBySession?.[sessionId] + : details[sessionId]; + await fulfill(response ?? { detail: "회기가 없습니다." }, response ? 200 : 404); + return; + } + await fulfill({ detail: `unmocked API: ${request.method()} ${url.pathname}` }, 404); + }); +} + +test.describe("관리자 감정 관측", () => { + test("9축 추이와 누락된 왕복 간격, 판단 상세와 관계를 표시한다", async ({ page }) => { + await mockAffectAdmin(page); + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/admin/emotions"); + + await expect(page.getByRole("heading", { name: "가상 내담자 감정 관측" })).toBeVisible(); + await expect(page.getByText("시뮬레이션 상태이며 실제 사람의 감정 측정이나 진단이 아닙니다.")).toBeVisible(); + await expect(page.locator("[data-vignette-admin-root]")).toBeVisible(); + await page.waitForTimeout(3600); + await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0); + await page.waitForTimeout(4500); + await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0); + await expect(page.getByLabel("관측 회기 선택")).toHaveValue("session-alpha"); + await expect(page.getByText("왕복 1").first()).toBeVisible(); + await expect(page.getByText("왕복 7").first()).toBeVisible(); + await expect(page.locator(".aac-chart path").first()).toHaveAttribute("d", /M.*M/); + await expect(page.getByRole("cell", { name: "r -1.00" }).first()).toBeVisible(); + await expect(page.getByText("변화 없음").first()).toBeVisible(); + + await page.getByLabel("상세 턴 선택").selectOption("12"); + await expect(page).toHaveURL(/turn=12/); + await expect(page.getByText("잠정반영").first()).toBeVisible(); + await expect(page.getByText("정답 확률이 아닙니다")).toBeVisible(); + await page.getByText("제공자 · 모델 · 정책 · 당시 상태 · 사용량 모두 보기").click(); + await expect(page.getByText("jev-test-model")).toBeVisible(); + await page.screenshot({ path: "node_modules/.tmp/admin-affect-focused-1440.png", fullPage: true }); + + await page.keyboard.press("2"); + await expect(page.getByRole("table").filter({ has: page.getByRole("columnheader", { name: "불안" }) }).first()).toBeVisible(); + await page.keyboard.press("1"); + await expect(page.locator(".aac-charts")).toBeVisible(); + + await page.locator(".aac-grid").scrollIntoViewIfNeeded(); + await page.screenshot({ path: "node_modules/.tmp/admin-affect-1440-overview.png" }); + await page.locator(".aac-charts").scrollIntoViewIfNeeded(); + await page.screenshot({ path: "node_modules/.tmp/admin-affect-1440-trends.png" }); + await page.locator(".aac-correlation__matrix").scrollIntoViewIfNeeded(); + await page.screenshot({ path: "node_modules/.tmp/admin-affect-1440-correlation.png" }); + + await page.getByRole("button", { name: "다크 모드로" }).click(); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark"); + await page.locator(".aac-correlation__matrix").scrollIntoViewIfNeeded(); + await page.screenshot({ path: "node_modules/.tmp/admin-affect-1440-dark-correlation.png" }); + + await page.emulateMedia({ media: "print", reducedMotion: "reduce" }); + await expect.poll(() => page.evaluate(() => matchMedia("(prefers-reduced-motion: reduce)").matches)).toBe(true); + await page.locator(".aac-charts").scrollIntoViewIfNeeded(); + await page.screenshot({ path: "node_modules/.tmp/admin-affect-1440-print-trends.png" }); + }); + + test("판단 기록이 없는 기존 회기를 역산하지 않고 명시한다", async ({ page }) => { + await mockAffectAdmin(page); + await page.goto("/admin/emotions?session=session-beta"); + + await expect(page.getByText("이 회기에는 새 판단 기록이 없습니다. 기존 감정 상태는 과거 턴별 판단으로 역산하지 않습니다.")).toBeVisible(); + await expect(page.getByText("기록 없음").first()).toBeVisible(); + }); + + test("저장소 오류를 alert로 보이고 다시 조회한다", async ({ page }) => { + const state = { fail: true }; + await mockAffectAdmin(page, { get failDetails() { return state.fail; } }); + await page.goto("/admin/emotions"); + + await expect(page.getByRole("alert")).toContainText("감정 기록 저장소를 읽을 수 없습니다."); + state.fail = false; + await page.getByRole("button", { name: "새로고침" }).click(); + await expect(page.getByRole("alert")).toHaveCount(0); + await expect(page.getByText("턴별 추이")).toBeVisible(); + }); + + test("늦은 이전 회기 응답이 새 회기 화면을 덮지 않는다", async ({ page }) => { + const alphaGate = deferred(); + await mockAffectAdmin(page, { detailGate: (sessionId) => sessionId === "session-alpha" ? alphaGate.promise : undefined }); + await page.goto("/admin/emotions"); + await expect(page.getByLabel("관측 회기 선택")).toHaveValue("session-alpha"); + await page.getByRole("button", { name: "다음 회기" }).click(); + await expect(page.getByLabel("관측 회기 선택")).toHaveValue("session-beta"); + await expect(page.getByText("이 회기에는 새 판단 기록이 없습니다. 기존 감정 상태는 과거 턴별 판단으로 역산하지 않습니다.")).toBeVisible(); + alphaGate.resolve(); + await expect(page.getByLabel("관측 회기 선택")).toHaveValue("session-beta"); + await expect(page.getByText("턴별 추이")).toHaveCount(1); + }); + + test("이전 판단 기록을 불러와 현재 목록 앞에 합친다", async ({ page }) => { + const newest = detail("session-alpha", "P1", [ + trace(4, 2, 0.02, -0.02), + trace(6, 3, 0.03, -0.03), + ]); + newest.total_traces = 3; + newest.has_more = true; + const older = detail("session-alpha", "P1", [trace(2, 1, 0.01, -0.01)]); + older.total_traces = 3; + await mockAffectAdmin(page, { + detailBySession: { "session-alpha": newest, "session-beta": detail("session-beta", "P2", []) }, + olderDetailBySession: { "session-alpha": older }, + }); + await page.goto("/admin/emotions"); + + await page.getByRole("button", { name: "이전 기록 더 불러오기" }).click(); + await expect(page.getByLabel("상세 턴 선택")).toHaveText(/턴 2/); + await expect(page.getByRole("button", { name: "이전 기록 더 불러오기" })).toHaveCount(0); + }); + + test("390px에서 회기 선택 초점과 표 보기 키보드 조작을 유지한다", async ({ page }) => { + await mockAffectAdmin(page); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/admin/emotions"); + + await expect(page.getByLabel("관측 회기 선택")).toBeVisible(); + await page.getByLabel("관측 회기 선택").focus(); + await expect.poll(() => page.evaluate(() => document.activeElement?.getAttribute("aria-label"))).toBe("관측 회기 선택"); + await page.getByRole("button", { name: /표로 보기/ }).focus(); + await page.keyboard.press("Enter"); + await expect(page.getByRole("table").filter({ has: page.getByRole("columnheader", { name: "불안" }) }).first()).toBeVisible(); + }); + + test("320px에서 문서 폭과 44px 조작 높이를 유지한다", async ({ page }) => { + await mockAffectAdmin(page); + await page.setViewportSize({ width: 320, height: 740 }); + await page.goto("/admin/emotions"); + + await expect(page.getByLabel("관측 회기 선택")).toBeVisible(); + await expect(page.getByRole("button", { name: /차트로 보기/ })).toHaveCSS("min-height", "44px"); + await expect.poll(() => page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + }))).toEqual({ clientWidth: 320, scrollWidth: 320 }); + await expect.poll(() => page.locator(".vg-main").evaluate((element) => element.scrollWidth === element.clientWidth)).toBe(true); + await page.locator(".aac-charts").scrollIntoViewIfNeeded(); + await page.screenshot({ path: "node_modules/.tmp/admin-affect-320-trends.png" }); + await page.screenshot({ path: "node_modules/.tmp/admin-affect-focused-320.png", fullPage: true }); + }); + + test("관계 계산은 양음 상관, 변화 없음, 표본 부족을 정직하게 표시한다", () => { + const observations: ChangeObservation[] = [1, 2, 3, 4, 5].map((seq) => ({ + seq, + values: { anxiety: seq, sadness: -seq, anger: 1 }, + })); + + expect(pearsonCorrelation(observations, "anxiety", "anxiety")).toMatchObject({ count: 5, value: 1, reason: null }); + expect(pearsonCorrelation(observations, "anxiety", "sadness")).toMatchObject({ count: 5, value: -1, reason: null }); + expect(pearsonCorrelation(observations, "anxiety", "anger")).toMatchObject({ count: 5, value: null, reason: "zero_variance" }); + expect(pearsonCorrelation(observations.slice(0, 4), "anxiety", "sadness")).toMatchObject({ count: 4, value: null, reason: "insufficient" }); + }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 92eb02c..c9a8f8a 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -57,6 +57,7 @@ const SupervisionResearch = lazy(() => import("./pages/SupervisionResearch")); const PersonaStudio = lazy(() => import("./pages/PersonaStudio")); const Admin = lazy(() => import("./pages/Admin")); const AdminAi = lazy(() => import("./pages/AdminAi")); +const AdminAffect = lazy(() => import("./pages/AdminAffect")); const AdminContinuousImprovement = lazy( () => import("./pages/AdminContinuousImprovement"), ); @@ -601,6 +602,14 @@ function AppRoutes() { } /> + + + + } + /> = { admin: [ { to: "/admin", label: "운영 홈", icon: "shield", end: true, group: "운영" }, { to: "/admin/ai", label: "AI 운영", icon: "session", group: "운영" }, + { to: "/admin/emotions", label: "감정 관측", icon: "review", group: "운영" }, { to: "/admin/continuous-improvement", label: "지속 개선", icon: "refresh", group: "운영" }, { to: "/admin/users", label: "사용자 관리", icon: "users", group: "운영" }, { to: "/admin/access", label: "접근 관리", icon: "settings", group: "운영" }, diff --git a/apps/web/src/lib/api.gen.ts b/apps/web/src/lib/api.gen.ts index e03d002..7aa6b76 100644 --- a/apps/web/src/lib/api.gen.ts +++ b/apps/web/src/lib/api.gen.ts @@ -4,6 +4,46 @@ */ export interface paths { + "/admin/affect/sessions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Admin Affect Sessions + * @description 관리자 전용 감정 관측 회기 목록을 반환한다. + */ + get: operations["list_admin_affect_sessions_admin_affect_sessions_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/admin/affect/sessions/{session_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Admin Affect Session Detail + * @description 저장된 Jev trace와 현재 감정 snapshot을 반환한다. + */ + get: operations["get_admin_affect_session_detail_admin_affect_sessions__session_id__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/admin/engine-capabilities": { parameters: { query?: never; @@ -2776,6 +2816,75 @@ export interface components { /** Idempotent Replay */ idempotent_replay: boolean; }; + /** AdminAffectRuntimeResponse */ + AdminAffectRuntimeResponse: { + /** Configured */ + configured: boolean; + /** Enabled */ + enabled: boolean; + /** Model */ + model: string; + /** Provider */ + provider: string; + }; + /** AdminAffectSessionDetailResponse */ + AdminAffectSessionDetailResponse: { + /** Current Emotions */ + current_emotions: { + [key: string]: number | null; + }; + /** Has More */ + has_more: boolean; + /** Persona Code */ + persona_code: string; + /** Session Id */ + session_id: string; + /** Total Traces */ + total_traces: number; + /** Traces */ + traces: components["schemas"]["AdminAffectTraceRecord"][]; + }; + /** AdminAffectSessionListResponse */ + AdminAffectSessionListResponse: { + /** Limit */ + limit: number; + /** Offset */ + offset: number; + runtime: components["schemas"]["AdminAffectRuntimeResponse"]; + /** Sessions */ + sessions: components["schemas"]["AdminAffectSessionSummary"][]; + /** Total */ + total: number; + }; + /** AdminAffectSessionSummary */ + AdminAffectSessionSummary: { + /** Ended */ + ended: boolean; + /** Persona Code */ + persona_code: string; + /** Session Id */ + session_id: string; + /** + * Started At + * Format: date-time + */ + started_at: string; + /** Trace Count */ + trace_count: number; + }; + /** AdminAffectTraceRecord */ + AdminAffectTraceRecord: { + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Seq */ + seq: number; + trace: components["schemas"]["ClientAffectTraceV1"]; + /** Turn Id */ + turn_id: string; + }; /** AdminEngineConfigPatch */ AdminEngineConfigPatch: { /** Engine Mode */ @@ -4482,6 +4591,93 @@ export interface components { /** Source Id */ source_id?: string | null; }; + /** ClientAffectContextV1 */ + ClientAffectContextV1: { + /** Effective Openness */ + effective_openness: number; + /** Rapport Credit */ + rapport_credit: number; + /** Resistance */ + resistance: number; + /** Stage */ + stage: string; + }; + /** ClientAffectDimensionTraceV1 */ + ClientAffectDimensionTraceV1: { + /** After */ + after: number; + /** Before */ + before: number; + /** Confidence */ + confidence?: number | null; + /** + * Decision + * @enum {string} + */ + decision: "accepted" | "tentative" | "held"; + /** Key */ + key: string; + /** Probabilities */ + probabilities?: [ + number, + number, + number, + number, + number + ] | null; + /** Target */ + target?: number | null; + }; + /** ClientAffectPolicyV1 */ + ClientAffectPolicyV1: { + /** Accepted Alpha */ + accepted_alpha: number; + /** Accepted Cap */ + accepted_cap: number; + /** Adjacent Probability Threshold */ + adjacent_probability_threshold: number; + /** Min Confidence */ + min_confidence: number; + /** Tentative Alpha */ + tentative_alpha: number; + /** Tentative Cap */ + tentative_cap: number; + /** Tentative Confidence Floor */ + tentative_confidence_floor: number; + /** + * Version + * @constant + * @enum {string} + */ + version: "jev-affect-v1"; + }; + /** ClientAffectTraceV1 */ + ClientAffectTraceV1: { + context: components["schemas"]["ClientAffectContextV1"]; + /** Cost Usd */ + cost_usd?: number | null; + /** Dimensions */ + dimensions: components["schemas"]["ClientAffectDimensionTraceV1"][]; + /** Input Tokens */ + input_tokens: number; + /** Latency Ms */ + latency_ms: number; + /** Model */ + model: string; + /** Output Tokens */ + output_tokens: number; + policy: components["schemas"]["ClientAffectPolicyV1"]; + /** Provider */ + provider: string; + /** + * Schema Version + * @constant + * @enum {integer} + */ + schema_version: 1; + /** Turn Seq */ + turn_seq: number; + }; /** ClientDiagnosticError */ ClientDiagnosticError: { /** At */ @@ -11687,6 +11883,78 @@ export interface components { } export type $defs = Record; export interface operations { + list_admin_affect_sessions_admin_affect_sessions_get: { + parameters: { + query?: { + limit?: number; + offset?: number; + }; + header?: never; + path?: never; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminAffectSessionListResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_admin_affect_session_detail_admin_affect_sessions__session_id__get: { + parameters: { + query?: { + limit?: number; + before_seq?: number | null; + }; + header?: never; + path: { + session_id: string; + }; + cookie?: { + "__Host-vignette_sid"?: string | null; + vignette_sid?: string | null; + }; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminAffectSessionDetailResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_engine_capabilities_admin_engine_capabilities_get: { parameters: { query?: { diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index ce34b6b..b1f7b14 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -610,6 +610,8 @@ export type AdminHealthResponse = ApiSchema<"AdminHealthResponse">; export type AdminUsageBreakdown = ApiSchema<"AdminUsageBreakdown">; export type AdminUsageBudget = ApiSchema<"AdminUsageBudget">; export type AdminUsageResponse = ApiSchema<"AdminUsageResponse">; +export type AdminAffectSessionListResponse = ApiSchema<"AdminAffectSessionListResponse">; +export type AdminAffectSessionDetailResponse = ApiSchema<"AdminAffectSessionDetailResponse">; export type AdminHealthEvent = ApiSchema<"AdminHealthEvent">; export type AdminUptimeServiceSummary = ApiSchema<"AdminUptimeServiceSummary">; export type AdminUptimeResponse = ApiSchema<"AdminUptimeResponse">; @@ -652,6 +654,27 @@ export const adminApi = { }), }; +export const adminAffectApi = { + sessions: (limit = 30, offset = 0, options?: AdminReadOptions) => + api.get( + `/admin/affect/sessions?limit=${limit}&offset=${offset}`, + options, + ), + session: ( + sessionId: string, + limit = 100, + beforeSeq?: number, + options?: AdminReadOptions, + ) => { + const params = new URLSearchParams({ limit: String(limit) }); + if (beforeSeq !== undefined) params.set("before_seq", String(beforeSeq)); + return api.get( + `/admin/affect/sessions/${encodeURIComponent(sessionId)}?${params.toString()}`, + options, + ); + }, +}; + export type AdminManagedUser = ApiSchema<"AdminUserResponse">; export type AdminUsersResponse = ApiSchema<"AdminUsersResponse">; export type AdminUserCreateRequest = ApiSchema<"AdminUserCreate">; diff --git a/apps/web/src/pages/AdminAffect.tsx b/apps/web/src/pages/AdminAffect.tsx new file mode 100644 index 0000000..3b78b31 --- /dev/null +++ b/apps/web/src/pages/AdminAffect.tsx @@ -0,0 +1,423 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; +import { useSearchParams } from "react-router-dom"; +import { AppShell } from "../components/shell/AppShell"; +import { + adminAffectApi, + type AdminAffectSessionDetailResponse, + type AdminAffectSessionListResponse, +} from "../lib/api"; +import { + AFFECT_DIMENSIONS, + affectLabel, + correlationLabel, + pearsonCorrelation, + type AffectDimensionKey, + type ChangeObservation, +} from "./admin-affect/affectMath"; +import "./admin-affect/admin-affect.css"; + +type AffectTrace = AdminAffectSessionDetailResponse["traces"][number]["trace"]; +type AffectDimension = AffectTrace["dimensions"][number]; + +const PROBABILITY_LABELS = ["없음", "약함", "보통", "강함", "매우강함"]; + +function numberLabel(value: number | null | undefined, digits = 0): string { + return typeof value === "number" && Number.isFinite(value) ? value.toFixed(digits) : "기록 없음"; +} + +function pointNumberLabel(points: number, digits: number): string { + return Number(points.toFixed(digits)).toString(); +} + +function intensityLabel(value: number | null | undefined, digits = 0): string { + return typeof value === "number" && Number.isFinite(value) ? `${pointNumberLabel(value * 100, digits)}점` : "기록 없음"; +} + +function changeLabel(value: number | null): string { + if (value === null || !Number.isFinite(value)) return "기록 없음"; + const points = value * 100; + return `${points >= 0 ? "+" : ""}${pointNumberLabel(points, 1)}점`; +} + +function percentLabel(value: number | null | undefined): string { + return typeof value === "number" && Number.isFinite(value) ? `${Math.round(value * 100)}%` : "기록 없음"; +} + +function dateTimeLabel(value: string): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return new Intl.DateTimeFormat("ko-KR", { + month: "numeric", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(date); +} + +function decisionLabel(value: AffectDimension["decision"]): string { + if (value === "accepted") return "반영"; + if (value === "tentative") return "잠정반영"; + return "보류"; +} + +function traceDimension(trace: AffectTrace, key: AffectDimensionKey): AffectDimension | undefined { + return trace.dimensions.find((dimension) => dimension.key === key); +} + +function traceChanges(detail: AdminAffectSessionDetailResponse | null): ChangeObservation[] { + if (!detail) return []; + return detail.traces.map((item) => ({ + seq: item.seq, + values: Object.fromEntries( + AFFECT_DIMENSIONS.flatMap(([key]) => { + const dimension = traceDimension(item.trace, key); + if (!dimension || !Number.isFinite(dimension.before) || !Number.isFinite(dimension.after)) return []; + return [[key, dimension.after - dimension.before]]; + }), + ) as ChangeObservation["values"], + })); +} + +function TrendChart({ + label, + values, + selectedSeq, + onSelect, +}: { + label: string; + values: Array<{ seq: number; round: number; value: number }>; + selectedSeq: number | null; + onSelect: (seq: number) => void; +}) { + if (!values.length) { + return ( +
+

{label}

+

기록 없음

+
+ ); + } + const minRound = values[0].round; + const maxRound = values[values.length - 1].round; + const span = Math.max(1, maxRound - minRound); + const pointFor = ({ round, value }: { round: number; value: number }) => ({ + x: 20 + ((round - minRound) / span) * 170, + y: 9 + ((100 - value) / 100) * 78, + }); + const path = values.reduce((result, value, index) => { + const point = pointFor(value); + const previous = values[index - 1]; + return `${result}${index > 0 && previous && value.round - previous.round === 1 ? "L" : "M"}${point.x} ${point.y} `; + }, ""); + const latest = values[values.length - 1]; + + return ( +
+

{label}

+ + {[9, 48, 87].map((y) => )} + 100 + 50 + 0 + + {values.map((value) => { + const point = pointFor(value); + return ( + onSelect(value.seq)} + /> + ); + })} + 왕복 {minRound} + 왕복 {maxRound} + +
+ ); +} + +export default function AdminAffect() { + const [searchParams, setSearchParams] = useSearchParams(); + const [list, setList] = useState(null); + const [detail, setDetail] = useState(null); + const [listLoading, setListLoading] = useState(true); + const [detailLoading, setDetailLoading] = useState(false); + const [olderLoading, setOlderLoading] = useState(false); + const [error, setError] = useState(null); + const [view, setView] = useState<"chart" | "table">("chart"); + const [pageOffset, setPageOffset] = useState(0); + const [pair, setPair] = useState<[AffectDimensionKey, AffectDimensionKey]>(["anxiety", "sadness"]); + const listController = useRef(null); + const detailController = useRef(null); + const olderController = useRef(null); + const listRequest = useRef(0); + const detailRequest = useRef(0); + const selectedSessionId = searchParams.get("session"); + const selectedSeqValue = Number(searchParams.get("turn")); + const selectedSeq = Number.isInteger(selectedSeqValue) && selectedSeqValue > 0 ? selectedSeqValue : null; + + const setRouteSelection = useCallback((sessionId: string, seq?: number | null) => { + setSearchParams((current) => { + const next = new URLSearchParams(current); + next.set("session", sessionId); + if (seq && Number.isInteger(seq)) next.set("turn", String(seq)); + else next.delete("turn"); + return next; + }, { replace: true }); + }, [setSearchParams]); + + const loadSessions = useCallback(async () => { + listController.current?.abort(); + const controller = new AbortController(); + const request = ++listRequest.current; + listController.current = controller; + setListLoading(true); + setError(null); + try { + const response = await adminAffectApi.sessions(30, pageOffset, { signal: controller.signal }); + if (controller.signal.aborted || request !== listRequest.current) return; + setList(response); + if (!selectedSessionId && response.sessions[0]) setRouteSelection(response.sessions[0].session_id); + } catch (cause) { + if (controller.signal.aborted || request !== listRequest.current) return; + setError(cause instanceof Error ? cause.message : "회기 목록을 불러오지 못했습니다."); + } finally { + if (!controller.signal.aborted && request === listRequest.current) setListLoading(false); + } + }, [pageOffset, selectedSessionId, setRouteSelection]); + + const loadDetail = useCallback(async (sessionId: string) => { + detailController.current?.abort(); + olderController.current?.abort(); + setOlderLoading(false); + const controller = new AbortController(); + const request = ++detailRequest.current; + detailController.current = controller; + setDetailLoading(true); + setError(null); + try { + const response = await adminAffectApi.session(sessionId, 100, undefined, { signal: controller.signal }); + if (controller.signal.aborted || request !== detailRequest.current) return; + setDetail(response); + } catch (cause) { + if (controller.signal.aborted || request !== detailRequest.current) return; + setDetail(null); + setError(cause instanceof Error ? cause.message : "감정 기록을 불러오지 못했습니다."); + } finally { + if (!controller.signal.aborted && request === detailRequest.current) setDetailLoading(false); + } + }, []); + + const loadOlder = useCallback(async () => { + if (!selectedSessionId || !detail?.traces.length || !detail.has_more) return; + olderController.current?.abort(); + const controller = new AbortController(); + olderController.current = controller; + const beforeSeq = detail.traces[0].seq; + setOlderLoading(true); + try { + const response = await adminAffectApi.session(selectedSessionId, 100, beforeSeq, { signal: controller.signal }); + if (controller.signal.aborted || response.session_id !== selectedSessionId) return; + setDetail((current) => { + if (!current || current.session_id !== selectedSessionId) return current; + const known = new Set(current.traces.map((trace) => trace.turn_id)); + const older = response.traces.filter((trace) => !known.has(trace.turn_id)); + return { ...response, traces: [...older, ...current.traces] }; + }); + } catch (cause) { + if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : "이전 기록을 불러오지 못했습니다."); + } finally { + if (!controller.signal.aborted) setOlderLoading(false); + } + }, [detail, selectedSessionId]); + + useEffect(() => { + void loadSessions(); + return () => listController.current?.abort(); + }, [loadSessions]); + + useEffect(() => { + if (!selectedSessionId) { + detailController.current?.abort(); + olderController.current?.abort(); + setDetail(null); + return; + } + void loadDetail(selectedSessionId); + return () => { + detailController.current?.abort(); + olderController.current?.abort(); + setOlderLoading(false); + }; + }, [loadDetail, selectedSessionId]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const target = event.target; + if (target instanceof HTMLInputElement || target instanceof HTMLSelectElement || target instanceof HTMLTextAreaElement) return; + if (event.key === "1") setView("chart"); + if (event.key === "2") setView("table"); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, []); + + const displayDetail = detail?.session_id === selectedSessionId ? detail : null; + const activeTrace = useMemo(() => { + if (!displayDetail?.traces.length) return null; + return displayDetail.traces.find((item) => item.seq === selectedSeq) ?? displayDetail.traces[displayDetail.traces.length - 1]; + }, [displayDetail, selectedSeq]); + + useEffect(() => { + if (activeTrace && activeTrace.seq !== selectedSeq && selectedSessionId) { + setRouteSelection(selectedSessionId, activeTrace.seq); + } + }, [activeTrace, selectedSeq, selectedSessionId, setRouteSelection]); + + const changes = useMemo(() => traceChanges(displayDetail), [displayDetail]); + const trendRows = useMemo(() => displayDetail?.traces ?? [], [displayDetail]); + const activeSessionIndex = list?.sessions.findIndex((session) => session.session_id === selectedSessionId) ?? -1; + const selectedTrace = activeTrace?.trace ?? null; + const listedSession = list?.sessions.find((session) => session.session_id === selectedSessionId) ?? null; + const selectedSession = listedSession ?? (displayDetail ? { + session_id: displayDetail.session_id, + persona_code: displayDetail.persona_code, + ended: null, + } : null); + const loading = listLoading || detailLoading || olderLoading; + + const goAdjacent = (direction: -1 | 1) => { + if (!list || activeSessionIndex < 0) return; + const next = list.sessions[activeSessionIndex + direction]; + if (next) setRouteSelection(next.session_id); + }; + + const changePage = (offset: number) => { + setSearchParams((current) => { + const next = new URLSearchParams(current); + next.delete("session"); + next.delete("turn"); + return next; + }, { replace: true }); + setPageOffset(offset); + }; + + const runtimeState = list + ? (list.runtime.enabled ? "감정 엔진 켜짐" : "감정 엔진 꺼짐") + : listLoading ? "감정 엔진 확인 중" : error ? "감정 엔진 확인 불가" : "감정 엔진 확인 중"; + const keyState = list ? (list.runtime.configured ? "서버 키 설정" : "서버 키 미설정") : listLoading ? "서버 키 확인 중" : "서버 키 확인 불가"; + + return ( + +
+
+
+ 관리자 · 읽기 전용 관측 +

가상 내담자 감정 관측

+

시뮬레이션 상태이며 실제 사람의 감정 측정이나 진단이 아닙니다.

+
+
+ + {runtimeState} + + {keyState} + {list ? `${list.runtime.provider} · ${list.runtime.model}` : "제공자 · 모델 확인 중"} +
+
+ + {error ?
{error}
: null} + +
+
+ +
+ + + +
+
+

회기 ID, 개인 이름, 이메일과 상담 원문은 이 화면에 표시하지 않습니다.

+ {list ?
+ + {pageOffset + 1}–{Math.min(pageOffset + list.sessions.length, list.total)} / {list.total} + +
: null} +
+ + {!listLoading && !error && !list?.sessions.length ?
관측 가능한 회기가 없습니다.
: null} + {selectedSession && displayDetail ? ( + <> +
+
+

현재 상태

저장된 감정 상태의 강도입니다. 정확도나 확률이 아닙니다.

{selectedSession.ended === true ? "종료 회기" : selectedSession.ended === false ? "진행 중" : "상태 미상"}
+
+ {AFFECT_DIMENSIONS.map(([key, label]) => { + const value = displayDetail.current_emotions[key]; + const numeric = typeof value === "number" && Number.isFinite(value) ? value : null; + return
{label}
{intensityLabel(numeric)}
; + })} +
+
+
+

기록 범위

현재 불러온 판단 기록만 관계와 추이에 사용합니다.

+
+
회기 코드
{displayDetail.persona_code}
+
판단 기록
{displayDetail.total_traces}개
+
불러온 범위
{displayDetail.traces.length ? `턴 ${displayDetail.traces[0].seq}–${displayDetail.traces[displayDetail.traces.length - 1].seq}` : "기록 없음"}
+
+
+
+ +
+
+

턴별 추이

실제 상담 왕복 번호 간격을 유지하며, 누락된 기록은 선으로 잇지 않습니다.

+
+
+ {view === "chart" ?
+ {AFFECT_DIMENSIONS.map(([key, label]) => selectedSessionId && setRouteSelection(selectedSessionId, seq)} values={trendRows.flatMap((item) => { const dimension = traceDimension(item.trace, key); return dimension && Number.isFinite(dimension.after) ? [{ seq: item.seq, round: item.trace.turn_seq, value: dimension.after * 100 }] : []; })} />)} +
:
{AFFECT_DIMENSIONS.map(([, label]) => )}{trendRows.map((item) => {AFFECT_DIMENSIONS.map(([key]) => )})}
턴{label}
{intensityLabel(traceDimension(item.trace, key)?.after)}
} + {displayDetail.has_more ?
: null} +
+ +
+

턴 상세

Jev 판단 확신도는 정답 확률이 아닙니다.

+ {!selectedTrace ?
이 회기에는 새 판단 기록이 없습니다. 기존 감정 상태는 과거 턴별 판단으로 역산하지 않습니다.
: <> +
{AFFECT_DIMENSIONS.map(([key, label]) => { const dimension = traceDimension(selectedTrace, key); const delta = dimension && Number.isFinite(dimension.before) && Number.isFinite(dimension.after) ? dimension.after - dimension.before : null; return ; })}
감정 축전Jev 목표후변화량반영판단 확신도
{label}{intensityLabel(dimension?.before, 1)}{intensityLabel(dimension?.target, 1)}{intensityLabel(dimension?.after, 1)}{changeLabel(delta)}{dimension ? decisionLabel(dimension.decision) : "기록 없음"}{percentLabel(dimension?.confidence)}
+

5단계 강도 분포

+ {AFFECT_DIMENSIONS.map(([key, label]) => { const dimension = traceDimension(selectedTrace, key); return

{label}

{(dimension?.probabilities ?? Array(5).fill(null)).map((probability, index) =>
{PROBABILITY_LABELS[index]}{percentLabel(probability)}
)}
; })} +
제공자 · 모델 · 정책 · 당시 상태 · 사용량 모두 보기
제공자
{selectedTrace.provider}
모델
{selectedTrace.model}
응답 지연
{numberLabel(selectedTrace.latency_ms)}ms
입력 사용량
{numberLabel(selectedTrace.input_tokens)} token
출력 사용량
{numberLabel(selectedTrace.output_tokens)} token
비용
{selectedTrace.cost_usd === null ? "기록 없음" : `$${selectedTrace.cost_usd}`}
정책 버전
{selectedTrace.policy.version}
최소 판단 확신도
{percentLabel(selectedTrace.policy.min_confidence)}
반영 비율 · 변화량 상한
{percentLabel(selectedTrace.policy.accepted_alpha)} · {intensityLabel(selectedTrace.policy.accepted_cap, 1)}
잠정반영 최소 판단 확신도
{percentLabel(selectedTrace.policy.tentative_confidence_floor)}
잠정반영 비율 · 변화량 상한
{percentLabel(selectedTrace.policy.tentative_alpha)} · {intensityLabel(selectedTrace.policy.tentative_cap, 1)}
인접 강도 기준
{percentLabel(selectedTrace.policy.adjacent_probability_threshold)}
당시 단계
{selectedTrace.context.stage}
당시 저항
{numberLabel(selectedTrace.context.resistance, 2)}
유효 개방성
{numberLabel(selectedTrace.context.effective_openness, 2)}
라포 잔여값
{numberLabel(selectedTrace.context.rapport_credit, 2)}
+ } +
+ +
+

상태 변화의 동행 · 인과관계 아님

각 판단 기록의 변화량을 비교한다. 공통 유효 관측 5개 미만 또는 어느 축의 변화 분산이 0이면 값을 만들지 않는다.

+
{AFFECT_DIMENSIONS.map(([key, label]) => )}{AFFECT_DIMENSIONS.map(([rowKey, rowLabel]) => {AFFECT_DIMENSIONS.map(([columnKey]) => { const correlation = pearsonCorrelation(changes, rowKey, columnKey); const selected = pair[0] === rowKey && pair[1] === columnKey; const tone = correlation.value === null ? "empty" : correlation.value >= 0 ? "positive" : "negative"; return ; })})}
r{label}
{rowLabel}
+ +
+
+ + ) : null} +
+
+ ); +} diff --git a/apps/web/src/pages/AdminAi.tsx b/apps/web/src/pages/AdminAi.tsx index 60f34a4..3e5e048 100644 --- a/apps/web/src/pages/AdminAi.tsx +++ b/apps/web/src/pages/AdminAi.tsx @@ -545,8 +545,12 @@ export default function AdminAi() { 관리자 · AI 운영 원장

AI 운영과 DB 계량

상담 응답 엔진의 적용값과 실제 DB에 기록된 호출·토큰·비용을 한 화면에서 확인합니다.

+

생성 응답 엔진의 설정·비용은 여기서, 턴별 Jev 감정 판단 기록은 감정 관측에서 분리해 확인합니다.

+ + 감정 관측 + 최근 집계 {dateTimeLabel(usage?.generated_at)} diff --git a/apps/web/src/pages/admin-affect/admin-affect.css b/apps/web/src/pages/admin-affect/admin-affect.css new file mode 100644 index 0000000..9cc302c --- /dev/null +++ b/apps/web/src/pages/admin-affect/admin-affect.css @@ -0,0 +1,220 @@ +.aac { + display: grid; + width: 100%; + gap: var(--sp-4); + color: var(--text-body); +} + +.aac-head, +.aac-toolbar, +.aac-panel__head, +.aac-detail-controls, +.aac-view-toggle, +.aac-runtime { + display: flex; + align-items: center; + gap: var(--sp-3); +} + +.aac-head, +.aac-toolbar, +.aac-panel__head { + justify-content: space-between; +} + +.aac-head { + align-items: flex-end; + padding: 2px; +} + +.aac-head h1 { + margin: 6px 0 0; + color: var(--text-strong); + font-size: clamp(1.65rem, 2.4vw, 2.2rem); + line-height: 1.12; + letter-spacing: -0.035em; +} + +.aac-head p { + max-width: 720px; + margin: var(--sp-2) 0 0; + font-size: var(--fs-sm); + line-height: 1.6; +} + +.aac-kicker { + display: block; + color: var(--text-accent); + font-size: var(--fs-xs); + font-weight: 750; +} + +.aac-runtime { + flex-wrap: wrap; + justify-content: flex-end; + color: var(--text-muted); + font: 600 var(--fs-xs)/1.4 var(--font-num); + text-align: right; +} + +.aac-runtime b { color: var(--text-strong); } +.aac-runtime__state { color: var(--text-accent); } +.aac-runtime__state.is-off { color: var(--warn-text); } + +.aac-panel { + min-width: 0; + padding: var(--sp-4); + border: 1px solid var(--border-subtle); + border-radius: var(--radius); + background: var(--bg-surface); + box-shadow: var(--shadow-sm); +} + +.aac-panel h2 { + margin: 0; + color: var(--text-strong); + font-size: var(--fs-body); + line-height: 1.35; +} + +.aac-panel__head { align-items: flex-start; margin-bottom: var(--sp-3); } +.aac-panel__head p, +.aac-note { margin: 4px 0 0; color: var(--text-muted); font-size: var(--fs-xs); line-height: 1.55; } + +.aac-btn, +.aac-select { + min-height: 44px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--bg-surface-2); + color: var(--text-body); + font: 650 var(--fs-sm)/1 var(--font-sans); +} + +.aac-btn { padding: 0 12px; cursor: pointer; } +.aac-btn:hover { border-color: var(--accent); color: var(--text-strong); } +.aac-btn:focus-visible, +.aac-select:focus-visible, +.aac-correlation button:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; } +.aac-btn:disabled { cursor: not-allowed; opacity: .62; } +.aac-select { min-width: 0; padding: 0 10px; } + +.aac-toolbar { + align-items: flex-end; + flex-wrap: wrap; +} + +.aac-field { display: grid; min-width: min(100%, 310px); gap: 6px; color: var(--text-muted); font-size: var(--fs-xs); font-weight: 700; } +.aac-field .aac-select { width: 100%; color: var(--text-strong); } +.aac-toolbar__actions { display: flex; flex-wrap: wrap; gap: var(--sp-2); } + +.aac-status { + padding: var(--sp-3) var(--sp-4); + border: 1px solid var(--border-subtle); + border-radius: var(--radius); + background: var(--bg-surface-2); + color: var(--text-muted); + font-size: var(--fs-sm); + line-height: 1.5; +} +.aac-status[role="alert"] { border-color: color-mix(in srgb, var(--crit-solid) 40%, var(--border-subtle)); background: var(--crit-tint); color: var(--crit-text); } + +.aac-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(280px, .65fr); gap: var(--sp-4); } +.aac-current { display: grid; gap: 10px; } +.aac-meter { display: grid; grid-template-columns: 76px minmax(0, 1fr) 44px; align-items: center; gap: 10px; font-size: var(--fs-xs); } +.aac-meter b { color: var(--text-body); font-weight: 650; } +.aac-meter span:last-child { color: var(--text-muted); font-family: var(--font-num); text-align: right; } +.aac-track { height: 8px; overflow: hidden; border-radius: 999px; background: var(--neutral-100); } +.aac-track > span { display: block; height: 100%; background: var(--accent-bright); } +.aac-track.is-empty > span { background: transparent; } + +.aac-view-toggle { gap: 0; overflow: hidden; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); } +.aac-view-toggle button { min-height: 44px; padding: 0 10px; border: 0; border-left: 1px solid var(--border-subtle); background: var(--bg-surface-2); color: var(--text-muted); font: 650 var(--fs-xs)/1 var(--font-sans); cursor: pointer; } +.aac-view-toggle button:first-child { border-left: 0; } +.aac-view-toggle button[aria-pressed="true"] { background: var(--accent); color: var(--text-on-accent); } + +.aac-charts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--sp-3); } +.aac-chart { min-width: 0; padding: 10px; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: var(--bg-surface-2); } +.aac-chart h3 { margin: 0 0 6px; color: var(--text-body); font-size: var(--fs-xs); } +.aac-chart svg { display: block; width: 100%; height: 108px; overflow: visible; } +.aac-chart text { fill: var(--text-muted); font: 10px var(--font-num); } +.aac-chart line { stroke: var(--border-subtle); stroke-dasharray: 2 3; } +.aac-chart path { fill: none; stroke: var(--ink-2); stroke-width: 1.5; vector-effect: non-scaling-stroke; } +.aac-chart circle { fill: var(--accent); } + +.aac-table-wrap { overflow-x: auto; } +.aac-table { width: 100%; min-width: 720px; border-collapse: collapse; font-size: var(--fs-xs); } +.aac-table th, +.aac-table td { padding: 9px 10px; border-bottom: 1px solid var(--border-subtle); text-align: left; vertical-align: top; } +.aac-table th { color: var(--text-muted); font-weight: 750; white-space: nowrap; } +.aac-table td { color: var(--text-body); font-family: var(--font-num); } +.aac-table tr:last-child td { border-bottom: 0; } + +.aac-detail-controls { flex-wrap: wrap; } +.aac-detail-controls label { display: grid; gap: 6px; color: var(--text-muted); font-size: var(--fs-xs); font-weight: 700; } +.aac-detail-controls .aac-select { min-width: 180px; } +.aac-dimension-table { width: 100%; border-collapse: collapse; font-size: var(--fs-sm); } +.aac-dimension-table th, +.aac-dimension-table td { padding: 10px 7px; border-bottom: 1px solid var(--border-subtle); text-align: right; font-family: var(--font-num); } +.aac-dimension-table th:first-child, +.aac-dimension-table td:first-child { text-align: left; font-family: var(--font-sans); } +.aac-dimension-table th { color: var(--text-muted); font-size: var(--fs-xs); font-weight: 700; } +.aac-dimension-table td { color: var(--text-body); } +.aac-decision { font-family: var(--font-sans) !important; font-size: var(--fs-xs); } +.aac-decision.is-held { color: var(--warn-text); } +.aac-decision.is-accepted { color: var(--pos-text); } + +.aac-distribution-group { margin-top: var(--sp-3); } +.aac-distribution-group h4 { margin: 0; color: var(--text-body); font-size: var(--fs-xs); } +.aac-distribution { display: grid; grid-template-columns: repeat(5, minmax(72px, 1fr)); gap: 6px; margin-top: var(--sp-2); } +.aac-distribution > div { display: grid; gap: 5px; min-width: 0; color: var(--text-muted); font: 600 var(--fs-xs)/1.2 var(--font-num); text-align: center; } +.aac-distribution span { display: flex; height: 46px; align-items: end; border-bottom: 1px solid var(--border-strong); background: repeating-linear-gradient(to top, transparent 0 11px, color-mix(in srgb, var(--border-subtle) 60%, transparent) 11px 12px); } +.aac-distribution i { display: block; width: 100%; background: var(--accent-bright); } +.aac-distribution i.is-missing { background: transparent; } + +.aac-trace-meta { margin-top: var(--sp-3); } +.aac-trace-meta summary { cursor: pointer; color: var(--text-accent); font-size: var(--fs-sm); font-weight: 700; } +.aac-trace-meta dl { display: grid; grid-template-columns: 150px minmax(0, 1fr); margin: var(--sp-3) 0 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); overflow: hidden; } +.aac-trace-meta dt, +.aac-trace-meta dd { min-width: 0; margin: 0; padding: 9px 10px; border-bottom: 1px solid var(--border-subtle); font-size: var(--fs-xs); overflow-wrap: anywhere; } +.aac-trace-meta dt { background: var(--bg-surface-2); color: var(--text-muted); font-weight: 700; } +.aac-trace-meta dd { color: var(--text-body); font-family: var(--font-num); } +.aac-trace-meta dt:nth-last-of-type(1), .aac-trace-meta dd:nth-last-of-type(1) { border-bottom: 0; } + +.aac-correlation { display: grid; grid-template-columns: minmax(0, 1fr) minmax(250px, .7fr); gap: var(--sp-4); } +.aac-correlation__matrix { overflow-x: auto; } +.aac-correlation table { min-width: 620px; width: 100%; border-collapse: collapse; font-size: var(--fs-xs); } +.aac-correlation th, +.aac-correlation td { min-width: 55px; padding: 6px; border: 1px solid var(--border-subtle); text-align: center; } +.aac-correlation th { color: var(--text-muted); font-weight: 700; } +.aac-correlation button { width: 100%; min-height: 44px; border: 0; border-radius: 3px; background: transparent; color: var(--text-body); font: 650 var(--fs-xs)/1 var(--font-num); cursor: pointer; } +.aac-correlation button[data-correlation="positive"] { background: color-mix(in srgb, var(--pos-solid) calc(var(--aac-correlation-strength) * 42%), var(--bg-surface)); color: var(--pos-text); } +.aac-correlation button[data-correlation="negative"] { background: color-mix(in srgb, var(--warn-solid) calc(var(--aac-correlation-strength) * 42%), var(--bg-surface)); color: var(--warn-text); } +.aac-correlation button[aria-pressed="true"] { outline: 2px solid var(--accent); outline-offset: -2px; } +.aac-pair { display: grid; align-content: start; gap: var(--sp-3); } +.aac-pair h3 { margin: 0; color: var(--text-strong); font-size: var(--fs-sm); } +.aac-pair__selects { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-2); } +.aac-pair__values { max-height: 260px; overflow: auto; } + +@media (max-width: 900px) { + .aac-grid, .aac-correlation { grid-template-columns: 1fr; } + .aac-charts { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 620px) { + .aac-head, .aac-panel__head { align-items: flex-start; flex-direction: column; } + .aac-runtime { justify-content: flex-start; text-align: left; } + .aac-toolbar__actions, .aac-toolbar__actions .aac-btn { width: 100%; } + .aac-toolbar__actions .aac-btn { flex: 1 1 0; } + .aac-charts { grid-template-columns: 1fr; } + .aac-distribution { grid-template-columns: repeat(5, minmax(58px, 1fr)); overflow-x: auto; } + .aac-trace-meta dl { grid-template-columns: 100px minmax(0, 1fr); } +} + +@media print { + .vg-topbar, .vg-sidebar, .vg-bottom-nav, .aac-toolbar, .aac-view-toggle, .aac-detail-controls { display: none !important; } + .aac, .aac-panel { color: #17231f; background: #fff; } + .aac-panel { break-inside: avoid; box-shadow: none; } + .aac-charts { grid-template-columns: repeat(3, minmax(0, 1fr)); } + * { print-color-adjust: exact; -webkit-print-color-adjust: exact; } +} diff --git a/apps/web/src/pages/admin-affect/affectMath.ts b/apps/web/src/pages/admin-affect/affectMath.ts new file mode 100644 index 0000000..8441c6d --- /dev/null +++ b/apps/web/src/pages/admin-affect/affectMath.ts @@ -0,0 +1,57 @@ +export const AFFECT_DIMENSIONS = [ + ["anxiety", "불안"], + ["sadness", "슬픔"], + ["anger", "분노"], + ["shame", "수치심"], + ["guilt", "죄책감"], + ["loneliness", "외로움"], + ["relief", "안도"], + ["hope", "희망"], + ["trust", "신뢰"], +] as const; + +export type AffectDimensionKey = (typeof AFFECT_DIMENSIONS)[number][0]; + +export function affectLabel(key: string): string { + return AFFECT_DIMENSIONS.find(([dimension]) => dimension === key)?.[1] ?? key; +} + +export type ChangeObservation = { + seq: number; + values: Partial>; +}; + +export type Correlation = { + count: number; + value: number | null; + reason: "insufficient" | "zero_variance" | null; +}; + +export function pearsonCorrelation( + observations: ChangeObservation[], + first: AffectDimensionKey, + second: AffectDimensionKey, +): Correlation { + const pairs = observations.flatMap((observation) => { + const x = observation.values[first]; + const y = observation.values[second]; + return Number.isFinite(x) && Number.isFinite(y) ? [[x as number, y as number] as const] : []; + }); + if (pairs.length < 5) return { count: pairs.length, value: null, reason: "insufficient" }; + + const meanX = pairs.reduce((sum, [x]) => sum + x, 0) / pairs.length; + const meanY = pairs.reduce((sum, [, y]) => sum + y, 0) / pairs.length; + const numerator = pairs.reduce((sum, [x, y]) => sum + (x - meanX) * (y - meanY), 0); + const xVariance = pairs.reduce((sum, [x]) => sum + (x - meanX) ** 2, 0); + const yVariance = pairs.reduce((sum, [, y]) => sum + (y - meanY) ** 2, 0); + if (xVariance === 0 || yVariance === 0) { + return { count: pairs.length, value: null, reason: "zero_variance" }; + } + + return { count: pairs.length, value: numerator / Math.sqrt(xVariance * yVariance), reason: null }; +} + +export function correlationLabel(correlation: Correlation): string { + if (correlation.value !== null) return `r ${correlation.value.toFixed(2)}`; + return correlation.reason === "zero_variance" ? "변화 없음" : "데이터 부족"; +} diff --git a/apps/web/src/pages/admin/admin-ai.css b/apps/web/src/pages/admin/admin-ai.css index 994f131..98740d6 100644 --- a/apps/web/src/pages/admin/admin-ai.css +++ b/apps/web/src/pages/admin/admin-ai.css @@ -29,12 +29,34 @@ line-height: 1.6; } +.aic-head .aic-affect-note { + margin-top: 4px; + color: var(--text-muted); + font-size: var(--fs-xs); +} + .aic-head__actions { display: flex; align-items: center; gap: 12px; } +.aic-affect-link { + display: inline-flex; + min-height: 44px; + align-items: center; + padding: 0 11px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + color: var(--text-accent); + font-size: var(--fs-xs); + font-weight: 750; + text-decoration: none; +} + +.aic-affect-link:hover { border-color: var(--accent); } +.aic-affect-link:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; } + .aic-head__stamp { color: var(--text-muted); font-size: 12px;