관리자 감정 추이와 관계 그래프 추가
Some checks failed
API contract / OpenAPI type drift (push) Failing after 43s

This commit is contained in:
Yun Chan 2026-09-23 04:53:42 +09:00
parent 6ab40ff3f4
commit 29d5b6c50b
10 changed files with 1339 additions and 0 deletions

View file

@ -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<Dimension, number | null>;
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<Dimension, number | null>,
traces,
total_traces: traces.length,
has_more: false,
};
}
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((done) => { resolve = done; });
return { promise, resolve };
}
async function mockAffectAdmin(page: Page, options: {
detailBySession?: Record<string, AffectDetail>;
olderDetailBySession?: Record<string, AffectDetail>;
failDetails?: boolean;
detailGate?: (sessionId: string) => Promise<void> | 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" });
});
});

View file

@ -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() {
</RequireAuth>
}
/>
<Route
path="/admin/emotions"
element={
<RequireAuth roles={["admin"]}>
<AdminAffect />
</RequireAuth>
}
/>
<Route
path="/admin/continuous-improvement"
element={

View file

@ -34,6 +34,7 @@ const NAV_BY_ROLE: Record<Role, NavItem[]> = {
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: "운영" },

View file

@ -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<string, never>;
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?: {

View file

@ -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<AdminAffectSessionListResponse>(
`/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<AdminAffectSessionDetailResponse>(
`/admin/affect/sessions/${encodeURIComponent(sessionId)}?${params.toString()}`,
options,
);
},
};
export type AdminManagedUser = ApiSchema<"AdminUserResponse">;
export type AdminUsersResponse = ApiSchema<"AdminUsersResponse">;
export type AdminUserCreateRequest = ApiSchema<"AdminUserCreate">;

View file

@ -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 (
<article className="aac-chart">
<h3>{label}</h3>
<p className="aac-note">기록 없음</p>
</article>
);
}
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 (
<article className="aac-chart">
<h3>{label}</h3>
<svg viewBox="0 0 210 108" role="img" aria-label={`${label} 턴별 강도 추이`}>
{[9, 48, 87].map((y) => <line key={y} x1="20" x2="190" y1={y} y2={y} />)}
<text x="0" y="12">100</text>
<text x="5" y="51">50</text>
<text x="10" y="91">0</text>
<path d={path} />
{values.map((value) => {
const point = pointFor(value);
return (
<circle
key={value.seq}
cx={point.x}
cy={point.y}
r={value.seq === selectedSeq ? 4 : value.seq === latest.seq ? 3 : 2.5}
onClick={() => onSelect(value.seq)}
/>
);
})}
<text x="20" y="105">왕복 {minRound}</text>
<text x="142" y="105">왕복 {maxRound}</text>
</svg>
</article>
);
}
export default function AdminAffect() {
const [searchParams, setSearchParams] = useSearchParams();
const [list, setList] = useState<AdminAffectSessionListResponse | null>(null);
const [detail, setDetail] = useState<AdminAffectSessionDetailResponse | null>(null);
const [listLoading, setListLoading] = useState(true);
const [detailLoading, setDetailLoading] = useState(false);
const [olderLoading, setOlderLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [view, setView] = useState<"chart" | "table">("chart");
const [pageOffset, setPageOffset] = useState(0);
const [pair, setPair] = useState<[AffectDimensionKey, AffectDimensionKey]>(["anxiety", "sadness"]);
const listController = useRef<AbortController | null>(null);
const detailController = useRef<AbortController | null>(null);
const olderController = useRef<AbortController | null>(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 (
<AppShell contextLabel="감정 관측" navRole="admin" wide>
<main className="aac" data-testid="admin-affect-page" data-vignette-admin-root aria-busy={loading}>
<header className="aac-head">
<div>
<span className="aac-kicker">관리자 · 읽기 전용 관측</span>
<h1>가상 내담자 감정 관측</h1>
<p>시뮬레이션 상태이며 실제 사람의 감정 측정이나 진단이 아닙니다.</p>
</div>
<div className="aac-runtime" aria-label="감정 엔진 런타임 상태">
<span className={`aac-runtime__state ${list && !list.runtime.enabled ? "is-off" : ""}`}>
{runtimeState}
</span>
<span>{keyState}</span>
<b>{list ? `${list.runtime.provider} · ${list.runtime.model}` : "제공자 · 모델 확인 중"}</b>
</div>
</header>
{error ? <div className="aac-status" role="alert">{error}</div> : null}
<section className="aac-panel" aria-label="회기 선택">
<div className="aac-toolbar">
<label className="aac-field">
회기
<select
className="aac-select"
aria-label="관측 회기 선택"
value={selectedSessionId ?? ""}
onChange={(event) => setRouteSelection(event.target.value)}
disabled={!list?.sessions.length && !selectedSession}
>
{!list?.sessions.length ? <option value="">회기 기록 없음</option> : null}
{selectedSession && !listedSession ? <option value={selectedSession.session_id}>{selectedSession.persona_code} · 목록에 없는 회기</option> : null}
{list?.sessions.map((session) => (
<option key={session.session_id} value={session.session_id}>
{session.persona_code} · {dateTimeLabel(session.started_at)} · 기록 {session.trace_count}
</option>
))}
</select>
</label>
<div className="aac-toolbar__actions">
<button className="aac-btn" type="button" onClick={() => goAdjacent(-1)} disabled={activeSessionIndex <= 0}>이전 회기</button>
<button className="aac-btn" type="button" onClick={() => goAdjacent(1)} disabled={!list || activeSessionIndex < 0 || activeSessionIndex >= list.sessions.length - 1}>다음 회기</button>
<button className="aac-btn" type="button" onClick={() => { void loadSessions(); if (selectedSessionId) void loadDetail(selectedSessionId); }} disabled={loading}>새로고침</button>
</div>
</div>
<p className="aac-note">회기 ID, 개인 이름, 이메일과 상담 원문은 이 화면에 표시하지 않습니다.</p>
{list ? <div className="aac-toolbar__actions">
<button className="aac-btn" type="button" onClick={() => changePage(Math.max(0, pageOffset - list.limit))} disabled={pageOffset === 0 || loading}>이전 목록</button>
<span className="aac-note">{pageOffset + 1}–{Math.min(pageOffset + list.sessions.length, list.total)} / {list.total}</span>
<button className="aac-btn" type="button" onClick={() => changePage(pageOffset + list.limit)} disabled={pageOffset + list.limit >= list.total || loading}>다음 목록</button>
</div> : null}
</section>
{!listLoading && !error && !list?.sessions.length ? <div className="aac-status">관측 가능한 회기가 없습니다.</div> : null}
{selectedSession && displayDetail ? (
<>
<section className="aac-grid" aria-label="현재 감정 상태와 추이">
<section className="aac-panel">
<div className="aac-panel__head"><div><h2>현재 상태</h2><p>저장된 감정 상태의 강도입니다. 정확도나 확률이 아닙니다.</p></div><span className="aac-note">{selectedSession.ended === true ? "종료 회기" : selectedSession.ended === false ? "진행 중" : "상태 미상"}</span></div>
<div className="aac-current">
{AFFECT_DIMENSIONS.map(([key, label]) => {
const value = displayDetail.current_emotions[key];
const numeric = typeof value === "number" && Number.isFinite(value) ? value : null;
return <div className="aac-meter" key={key}><b>{label}</b><div className={`aac-track ${numeric === null ? "is-empty" : ""}`}><span style={{ width: `${(numeric ?? 0) * 100}%` }} /></div><span>{intensityLabel(numeric)}</span></div>;
})}
</div>
</section>
<section className="aac-panel">
<div className="aac-panel__head"><div><h2>기록 범위</h2><p>현재 불러온 판단 기록만 관계와 추이에 사용합니다.</p></div></div>
<dl className="aac-trace-meta">
<dt>회기 코드</dt><dd>{displayDetail.persona_code}</dd>
<dt>판단 기록</dt><dd>{displayDetail.total_traces}개</dd>
<dt>불러온 범위</dt><dd>{displayDetail.traces.length ? `턴 ${displayDetail.traces[0].seq}–${displayDetail.traces[displayDetail.traces.length - 1].seq}` : "기록 없음"}</dd>
</dl>
</section>
</section>
<section className="aac-panel">
<div className="aac-panel__head">
<div><h2>턴별 추이</h2><p>실제 상담 왕복 번호 간격을 유지하며, 누락된 기록은 선으로 잇지 않습니다.</p></div>
<div className="aac-view-toggle" aria-label="추이 표시 방식"><button type="button" aria-pressed={view === "chart"} onClick={() => setView("chart")}>차트로 보기 <kbd>1</kbd></button><button type="button" aria-pressed={view === "table"} onClick={() => setView("table")}>표로 보기 <kbd>2</kbd></button></div>
</div>
{view === "chart" ? <div className="aac-charts">
{AFFECT_DIMENSIONS.map(([key, label]) => <TrendChart key={key} label={label} selectedSeq={activeTrace?.seq ?? null} onSelect={(seq) => 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 }] : []; })} />)}
</div> : <div className="aac-table-wrap"><table className="aac-table"><thead><tr><th>턴</th>{AFFECT_DIMENSIONS.map(([, label]) => <th key={label}>{label}</th>)}</tr></thead><tbody>{trendRows.map((item) => <tr key={item.turn_id}><td><button className="aac-btn" type="button" onClick={() => selectedSessionId && setRouteSelection(selectedSessionId, item.seq)}>턴 {item.seq}</button></td>{AFFECT_DIMENSIONS.map(([key]) => <td key={key}>{intensityLabel(traceDimension(item.trace, key)?.after)}</td>)}</tr>)}</tbody></table></div>}
{displayDetail.has_more ? <div className="aac-toolbar__actions"><button className="aac-btn" type="button" onClick={() => void loadOlder()} disabled={olderLoading}>이전 기록 더 불러오기</button></div> : null}
</section>
<section className="aac-panel" aria-label="선택된 턴 상세">
<div className="aac-panel__head"><div><h2>턴 상세</h2><p>Jev 판단 확신도는 정답 확률이 아닙니다.</p></div><div className="aac-detail-controls"><label>턴<select className="aac-select" aria-label="상세 턴 선택" value={activeTrace?.seq ?? ""} onChange={(event) => selectedSessionId && setRouteSelection(selectedSessionId, Number(event.target.value))}>{displayDetail.traces.map((item) => <option key={item.turn_id} value={item.seq}>턴 {item.seq} · {dateTimeLabel(item.created_at)}</option>)}</select></label></div></div>
{!selectedTrace ? <div className="aac-status">이 회기에는 새 판단 기록이 없습니다. 기존 감정 상태는 과거 턴별 판단으로 역산하지 않습니다.</div> : <>
<div className="aac-table-wrap"><table className="aac-dimension-table"><thead><tr><th>감정 축</th><th>전</th><th>Jev 목표</th><th>후</th><th>변화량</th><th>반영</th><th>판단 확신도</th></tr></thead><tbody>{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 <tr key={key}><td>{label}</td><td>{intensityLabel(dimension?.before, 1)}</td><td>{intensityLabel(dimension?.target, 1)}</td><td>{intensityLabel(dimension?.after, 1)}</td><td>{changeLabel(delta)}</td><td className={`aac-decision is-${dimension?.decision ?? "held"}`}>{dimension ? decisionLabel(dimension.decision) : "기록 없음"}</td><td>{percentLabel(dimension?.confidence)}</td></tr>; })}</tbody></table></div>
<h3 className="aac-note">5단계 강도 분포</h3>
{AFFECT_DIMENSIONS.map(([key, label]) => { const dimension = traceDimension(selectedTrace, key); return <section key={key} className="aac-distribution-group"><h4>{label}</h4><div className="aac-distribution" aria-label={`${label} 5단계 강도 분포`}>{(dimension?.probabilities ?? Array<number | null>(5).fill(null)).map((probability, index) => <div key={PROBABILITY_LABELS[index]}><span><i className={probability === null ? "is-missing" : ""} style={{ height: `${typeof probability === "number" ? probability * 100 : 0}%` }} /></span>{PROBABILITY_LABELS[index]}<b>{percentLabel(probability)}</b></div>)}</div></section>; })}
<details className="aac-trace-meta"><summary>제공자 · 모델 · 정책 · 당시 상태 · 사용량 모두 보기</summary><dl><dt>제공자</dt><dd>{selectedTrace.provider}</dd><dt>모델</dt><dd>{selectedTrace.model}</dd><dt>응답 지연</dt><dd>{numberLabel(selectedTrace.latency_ms)}ms</dd><dt>입력 사용량</dt><dd>{numberLabel(selectedTrace.input_tokens)} token</dd><dt>출력 사용량</dt><dd>{numberLabel(selectedTrace.output_tokens)} token</dd><dt>비용</dt><dd>{selectedTrace.cost_usd === null ? "기록 없음" : `$${selectedTrace.cost_usd}`}</dd><dt>정책 버전</dt><dd>{selectedTrace.policy.version}</dd><dt>최소 판단 확신도</dt><dd>{percentLabel(selectedTrace.policy.min_confidence)}</dd><dt>반영 비율 · 변화량 상한</dt><dd>{percentLabel(selectedTrace.policy.accepted_alpha)} · {intensityLabel(selectedTrace.policy.accepted_cap, 1)}</dd><dt>잠정반영 최소 판단 확신도</dt><dd>{percentLabel(selectedTrace.policy.tentative_confidence_floor)}</dd><dt>잠정반영 비율 · 변화량 상한</dt><dd>{percentLabel(selectedTrace.policy.tentative_alpha)} · {intensityLabel(selectedTrace.policy.tentative_cap, 1)}</dd><dt>인접 강도 기준</dt><dd>{percentLabel(selectedTrace.policy.adjacent_probability_threshold)}</dd><dt>당시 단계</dt><dd>{selectedTrace.context.stage}</dd><dt>당시 저항</dt><dd>{numberLabel(selectedTrace.context.resistance, 2)}</dd><dt>유효 개방성</dt><dd>{numberLabel(selectedTrace.context.effective_openness, 2)}</dd><dt>라포 잔여값</dt><dd>{numberLabel(selectedTrace.context.rapport_credit, 2)}</dd></dl></details>
</>}
</section>
<section className="aac-panel">
<div className="aac-panel__head"><div><h2>상태 변화의 동행 · 인과관계 아님</h2><p>각 판단 기록의 변화량을 비교한다. 공통 유효 관측 5개 미만 또는 어느 축의 변화 분산이 0이면 값을 만들지 않는다.</p></div></div>
<div className="aac-correlation"><div className="aac-correlation__matrix"><table><thead><tr><th>r</th>{AFFECT_DIMENSIONS.map(([key, label]) => <th key={key}>{label}</th>)}</tr></thead><tbody>{AFFECT_DIMENSIONS.map(([rowKey, rowLabel]) => <tr key={rowKey}><th>{rowLabel}</th>{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 <td key={columnKey}><button type="button" aria-pressed={selected} data-correlation={tone} style={{ "--aac-correlation-strength": Math.abs(correlation.value ?? 0) } as CSSProperties} onClick={() => setPair([rowKey, columnKey])} title={`공통 유효 관측 ${correlation.count}개`}>{correlationLabel(correlation)}</button></td>; })}</tr>)}</tbody></table></div>
<aside className="aac-pair"><h3>{affectLabel(pair[0])} · {affectLabel(pair[1])}</h3><div className="aac-pair__selects"><select className="aac-select" aria-label="첫 번째 관계 축" value={pair[0]} onChange={(event) => setPair([event.target.value as AffectDimensionKey, pair[1]])}>{AFFECT_DIMENSIONS.map(([key, label]) => <option key={key} value={key}>{label}</option>)}</select><select className="aac-select" aria-label="두 번째 관계 축" value={pair[1]} onChange={(event) => setPair([pair[0], event.target.value as AffectDimensionKey])}>{AFFECT_DIMENSIONS.map(([key, label]) => <option key={key} value={key}>{label}</option>)}</select></div><p className="aac-note">표본 5개는 표시용 최소값이며 통계적 유의성 기준이 아닙니다. 현재 조회 범위: {changes.length}개 판단 기록.</p><div className="aac-table-wrap aac-pair__values"><table className="aac-table"><thead><tr><th>턴</th><th>{affectLabel(pair[0])} 변화량</th><th>{affectLabel(pair[1])} 변화량</th></tr></thead><tbody>{changes.map((observation) => <tr key={observation.seq}><td>턴 {observation.seq}</td><td>{changeLabel(observation.values[pair[0]] ?? null)}</td><td>{changeLabel(observation.values[pair[1]] ?? null)}</td></tr>)}</tbody></table></div></aside>
</div>
</section>
</>
) : null}
</main>
</AppShell>
);
}

View file

@ -545,8 +545,12 @@ export default function AdminAi() {
<Kicker>관리자 · AI 운영 원장</Kicker>
<h1>AI 운영과 DB 계량</h1>
<p>상담 응답 엔진의 적용값과 실제 DB에 기록된 호출·토큰·비용을 한 화면에서 확인합니다.</p>
<p className="aic-affect-note">생성 응답 엔진의 설정·비용은 여기서, 턴별 Jev 감정 판단 기록은 감정 관측에서 분리해 확인합니다.</p>
</div>
<div className="aic-head__actions">
<a className="aic-affect-link" href="/admin/emotions">
감정 관측
</a>
<span className="aic-head__stamp">
최근 집계 <b>{dateTimeLabel(usage?.generated_at)}</b>
</span>

View file

@ -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; }
}

View file

@ -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<Record<AffectDimensionKey, number>>;
};
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" ? "변화 없음" : "데이터 부족";
}

View file

@ -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;