This commit is contained in:
parent
6ab40ff3f4
commit
29d5b6c50b
10 changed files with 1339 additions and 0 deletions
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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: "운영" },
|
||||
|
|
|
|||
|
|
@ -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?: {
|
||||
|
|
|
|||
|
|
@ -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">;
|
||||
|
|
|
|||
423
apps/web/src/pages/AdminAffect.tsx
Normal file
423
apps/web/src/pages/AdminAffect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
220
apps/web/src/pages/admin-affect/admin-affect.css
Normal file
220
apps/web/src/pages/admin-affect/admin-affect.css
Normal 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; }
|
||||
}
|
||||
57
apps/web/src/pages/admin-affect/affectMath.ts
Normal file
57
apps/web/src/pages/admin-affect/affectMath.ts
Normal 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" ? "변화 없음" : "데이터 부족";
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue