feat: P1 풀빌드 — React 프론트 7화면 + 백엔드 상담루프·평가·음성·RAG

web (Vite+React19+TS, Cloudflare Pages 배포):
- 디자인토큰(세이지틸/테라코타 SSOT), 앱셸, 공통 UI 프리미티브
- 7화면: 로그인/학습자홈/상담세션/회기리뷰/교수자/관리자/설정
- ClientAvatar: SVG 반구상 흉상 4상태 + RMS 립싱크 + 6파라미터 정서
- 회기리뷰는 외부 레퍼런스 디자인을 Vignette 토큰으로 리스킨

api (FastAPI):
- 게이트웨이 /v1/generate·/v1/stream 어댑터(상주풀/EngineSession 보존)
- services: 페르소나 L0~L6 빌더 / 결정론 상태머신 / 가드레일 /
  턴 오케스트레이터 / 회기간 메모리 / 평가AI / 음성 / RAG
- store: DB off 폴백(in-memory), sessions 실구현

검증:
- web: node22 tsc+vite build 통과(node23 segfault 회피), Pages 배포 200
- api: app.main import 통과
- 핫픽스: Topbar initials undefined-safe (undefined.trim 크래시)
- E2E: 서연(P1) 상담 1턴 — 좋은/나쁜 상담에 차등 반응 실증
This commit is contained in:
Yun Chan 2026-06-25 23:37:22 +09:00
parent 859ab26314
commit 24b1b7a6e1
84 changed files with 19645 additions and 107 deletions

View file

@ -0,0 +1,495 @@
/* =====================================================================
Admin (/admin). data-role=admin accent (§6.2).
§6.6: "시스템이 건강한가 · 누가 무엇을 했는가."
- KPI 5 ( , )
-
- · ( / )
- ·AI + (claude -p / API , )
-
- + audit log( geo )
- (· )
[] mock(data.ts). .
===================================================================== */
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { AppShell } from "../components/shell/AppShell";
import {
Badge,
Button,
Dot,
Icon,
Kicker,
ProgressBar,
} from "../components/ui";
import { clamp01, formatPercent } from "../lib/format";
import { AdminIcon, type LocalIconName } from "./admin/AdminIcons";
import { AuditTable } from "./admin/AuditTable";
import { TrendChart } from "./admin/TrendChart";
import {
AUDIT_LOG,
DISTRIB,
KPIS,
MOCK,
PROF_AUDIT,
ROLES,
SERVICES,
TREND_7D,
USAGE,
type AuditAction,
type HealthStatus,
} from "./admin/data";
import "./admin/admin.css";
const PAGE_SIZE = 5;
/** 헬스 상태 → 게이지 fill 클래스 보조 */
function healthFillClass(status: HealthStatus): string {
if (status === "warn") return "vgad-hrow__fill vgad-hrow__fill--warn";
if (status === "crit") return "vgad-hrow__fill vgad-hrow__fill--crit";
return "vgad-hrow__fill";
}
/** 헬스 상태 → 배지 톤 */
function healthBadgeTone(status: HealthStatus): "pos" | "warn" | "crit" {
if (status === "warn") return "warn";
if (status === "crit") return "crit";
return "pos";
}
/** 관리 진입 항목 — 라우터에 실재하는 목적지만 to, 나머지는 안내 콜아웃 */
interface MgmtEntry {
icon: LocalIconName;
title: string;
desc: string;
/** 실 라우트(있으면 Link) */
to?: string;
/** 신규 배지 수 */
badge?: number;
}
const MGMT: MgmtEntry[] = [
{
icon: "persona",
title: "페르소나 관리",
desc: "가상 내담자 풀, 정서 파라미터, 미성년 라벨 검수.",
badge: 3,
},
{
icon: "sliders",
title: "엔진 설정",
desc: "추론 모델·가드레일 임계치·추론 지역(KR/US) 정책.",
to: "/settings",
},
{
icon: "database",
title: "데이터 관리",
desc: "세션 기록·평가 데이터 보존, 익명화, 내보내기 정책.",
},
{
icon: "megaphone",
title: "공지 관리",
desc: "학기 일정, 점검 안내, 역할별 배너 공지 발행.",
},
];
export default function Admin() {
// ── audit log 인터랙션 state ──
const [filter, setFilter] = useState<AuditAction | "all">("all");
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
// 필터 + 검색 적용 (mock 클라이언트 필터링)
const matched = useMemo(() => {
const q = query.trim().toLowerCase();
return AUDIT_LOG.filter((r) => {
if (filter !== "all" && r.action !== filter) return false;
if (!q) return true;
return (
r.actorName.toLowerCase().includes(q) ||
r.target.toLowerCase().includes(q) ||
r.targetMeta.toLowerCase().includes(q) ||
r.id.toLowerCase().includes(q)
);
});
}, [filter, query]);
const pageCount = Math.max(1, Math.ceil(matched.length / PAGE_SIZE));
const safePage = Math.min(page, pageCount);
const rangeFrom = matched.length === 0 ? 0 : (safePage - 1) * PAGE_SIZE + 1;
const rangeTo = Math.min(safePage * PAGE_SIZE, matched.length);
const rows = matched.slice(rangeFrom === 0 ? 0 : rangeFrom - 1, rangeTo);
// 필터/검색 변경 시 1페이지로 (state 동기화는 핸들러에서)
const changeFilter = (key: AuditAction | "all") => {
setFilter(key);
setPage(1);
};
const changeQuery = (q: string) => {
setQuery(q);
setPage(1);
};
const pulseClass =
MOCK.statusLevel === "warn"
? "vgad-statusline__pulse vgad-statusline__pulse--warn"
: MOCK.statusLevel === "crit"
? "vgad-statusline__pulse vgad-statusline__pulse--crit"
: "vgad-statusline__pulse";
return (
<AppShell>
{/* ── 페이지 헤드 ── */}
<div className="vgad-head">
<div>
<Kicker> </Kicker>
<h1 className="vgad-head__title"> .</h1>
<p className="vgad-head__lead">
· .
, .
</p>
</div>
<div className="vgad-head__when">
<b className="tabular">{MOCK.syncedAt}</b>
{MOCK.syncAgo}
</div>
</div>
{/* ── 한 줄 상태 요약 ── */}
<div className="vgad-statusline" role="status">
<span className={pulseClass} aria-hidden="true" />
<span className="vgad-statusline__msg">
<b> </b> .{" "}
<span className="vgad-statusline__detail">{MOCK.statusDetail}</span>
</span>
<span className="vgad-statusline__ts tabular">uptime {MOCK.uptime}</span>
</div>
{/* ── 운영 KPI ── */}
<div className="vgad-kpis">
{KPIS.map((k) => {
const numCls =
"vgad-kpi__n" +
(k.tone === "warn"
? " vgad-kpi__n--warn"
: k.tone === "crit"
? " vgad-kpi__n--crit"
: "");
const dCls =
"vgad-kpi__d" + (k.noteTone ? ` vgad-kpi__d--${k.noteTone}` : "");
// note 의 첫 토큰(부호/퍼센트)만 강조, 나머지는 무채
const [head, ...tail] = (k.note ?? "").split(" ");
return (
<div className="vgad-kpi" key={k.label}>
<div className="vgad-kpi__l">{k.label}</div>
<div className={numCls}>
{k.value}
{k.unit ? <span className="vgad-kpi__u">{k.unit}</span> : null}
</div>
{k.note ? (
<div className={dCls}>
{k.noteTone ? <b>{head}</b> : head}
{tail.length ? " " + tail.join(" ") : ""}
</div>
) : null}
</div>
);
})}
</div>
{/* ── 서비스 헬스 ── */}
<section className="vgad-section">
<div className="vgad-sechead">
<h2 className="vgad-sechead__title"> </h2>
<span className="vgad-sechead__meta"> · · DB · </span>
</div>
<div className="vgad-health">
{SERVICES.map((s) => (
<div className="vgad-hrow" key={s.name}>
<div className="vgad-hrow__svc">
<Dot tone={healthBadgeTone(s.status)} />
<span>
<span className="vgad-hrow__nm">{s.name}</span>
<span className="vgad-hrow__sub">{s.sub}</span>
</span>
</div>
<div className="vgad-hrow__gauge">
<span
className={healthFillClass(s.status)}
style={{ width: `${Math.round(clamp01(s.load) * 100)}%` }}
/>
</div>
<span className="vgad-hrow__num tabular">{s.metric}</span>
<span className="vgad-hrow__stat">
<Badge tone={healthBadgeTone(s.status)}>{s.statusLabel}</Badge>
</span>
</div>
))}
</div>
</section>
{/* ── 사용·세션 수집 ── */}
<section className="vgad-section">
<div className="vgad-sechead">
<h2 className="vgad-sechead__title">· </h2>
<span className="vgad-sechead__meta"> 7 </span>
</div>
<div className="vgad-collect">
{/* 시나리오 분포 */}
<div className="vg-panel vg-panel--flat">
<div className="vgad-panel__ph"> </div>
<div className="vgad-panel__psub">
1,284 ·
</div>
<div className="vgad-distrib">
{DISTRIB.map((d) => (
<div className="vgad-dist" key={d.label}>
<span className="vgad-dist__l">{d.label}</span>
<ProgressBar
value={d.ratio}
tone={d.tone === "clay" ? "clay" : d.tone === "warn" ? "warn" : "accent"}
slim
label={`${d.label} ${d.count}회기`}
/>
<span className="vgad-dist__v">{d.count}</span>
</div>
))}
</div>
</div>
{/* 역할별 사용 */}
<div className="vg-panel vg-panel--flat">
<div className="vgad-panel__ph"> </div>
<div className="vgad-panel__psub"> </div>
<div className="vgad-rolelist">
{ROLES.map((r) => (
<div className="vgad-rr" key={r.label}>
<div className="vgad-rr__l">
<span
className="vgad-rr__sq"
style={{ background: r.color }}
aria-hidden="true"
/>
<span className="vgad-rr__nm">
{r.label}
<span className="vgad-rr__sub">{r.sub}</span>
</span>
</div>
<span className="vgad-rr__v tabular">
{r.active}
{r.delta != null ? (
<span className="vgad-rr__delta">+{r.delta}</span>
) : null}
</span>
</div>
))}
</div>
</div>
</div>
</section>
{/* ── 음성·AI 사용 + 비용 ── */}
<section className="vgad-section">
<div className="vgad-sechead">
<h2 className="vgad-sechead__title">·AI </h2>
<span className="vgad-sechead__meta">claude -p · API </span>
</div>
<div className="vg-panel vg-panel--flat">
<div className="vgad-usage">
{USAGE.map((u) => {
const over = u.ratio >= 0.8; // 80% 초과 시 warn
const pct = formatPercent(u.ratio);
return (
<div className="vgad-meter" key={u.label}>
<div className="vgad-meter__top">
<span>
<span className="vgad-meter__nm">{u.label}</span>
<span className="vgad-meter__sub">{u.sub}</span>
</span>
<span
className={"vgad-meter__v" + (over ? " vgad-meter__v--warn" : "")}
>
{u.value}
</span>
</div>
<ProgressBar
value={u.ratio}
tone={over ? "warn" : "accent"}
slim
label={`${u.label} ${pct} 사용`}
/>
<div className="vgad-meter__foot">
<span className="vgad-meter__budget">{u.budget}</span>
<span
className={
"vgad-meter__pct" + (over ? " vgad-meter__pct--warn" : "")
}
>
{pct}
</span>
</div>
</div>
);
})}
</div>
</div>
</section>
{/* ── 세션 추이 ── */}
<section className="vgad-section">
<div className="vgad-sechead">
<h2 className="vgad-sechead__title"> </h2>
<span className="vgad-sechead__meta"> 7 · </span>
</div>
<div className="vg-panel vg-panel--flat">
<TrendChart data={TREND_7D} label="일별 완료 세션 수" />
</div>
</section>
{/* ── 교수 활동 감사 요약 ── */}
<section className="vgad-section">
<div className="vgad-sechead">
<h2 className="vgad-sechead__title"> </h2>
<span className="vgad-sechead__meta"> · · </span>
</div>
<div className="vgad-tablewrap">
<table className="vgad-table">
<thead>
<tr>
<th></th>
<th className="vgad-hide-sm"> </th>
<th className="is-num" style={{ width: 160 }}>
</th>
<th className="is-num" style={{ width: 90 }}>
</th>
<th style={{ width: 140 }}> </th>
</tr>
</thead>
<tbody>
{PROF_AUDIT.map((p) => {
const low = p.reviewRate < 0.6;
return (
<tr key={p.name}>
<td>
<div className="vgad-actor">
<span className="vgad-actor__av" aria-hidden="true">
{p.initial}
</span>
<span>
<span className="vgad-actor__nm">{p.name}</span>
<span className="vgad-actor__sub"></span>
</span>
</div>
</td>
<td className="vgad-hide-sm">{p.cohort}</td>
<td className="is-num">
<span className="vgad-ratebar">
<span className="vgad-ratebar__track">
<span
className={
"vgad-ratebar__fill" +
(low ? " vgad-ratebar__fill--warn" : "")
}
style={{ width: `${Math.round(p.reviewRate * 100)}%` }}
/>
</span>
<span className="vgad-ratebar__pct">
{Math.round(p.reviewRate * 100)}%
</span>
</span>
</td>
<td className="is-num">{p.published}</td>
<td>
{p.needsAttention ? (
<span className="vgad-attn">
<Icon name="alert" size={14} strokeWidth={2} />
· {p.lastActive}
</span>
) : (
<span className="vgad-td-time">{p.lastActive}</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
{/* ── audit log ── */}
<section className="vgad-section">
<div className="vgad-sechead">
<h2 className="vgad-sechead__title"> </h2>
<Button variant="ghost" size="sm" leading={<AdminIcon name="download" size={14} strokeWidth={2} />}>
</Button>
</div>
<AuditTable
rows={rows}
filter={filter}
onFilter={changeFilter}
query={query}
onQuery={changeQuery}
page={safePage}
pageCount={pageCount}
onPage={setPage}
total={matched.length}
rangeFrom={rangeFrom}
rangeTo={rangeTo}
/>
</section>
{/* ── 관리 진입 ── */}
<section className="vgad-section">
<div className="vgad-sechead">
<h2 className="vgad-sechead__title"></h2>
<span className="vgad-sechead__meta">
· · ·
</span>
</div>
<div className="vgad-mgmt">
{MGMT.map((m) => {
const inner = (
<>
<div className="vgad-entry__head">
<span className="vgad-entry__ico">
<AdminIcon name={m.icon} size={18} strokeWidth={1.8} />
</span>
{m.badge ? <Badge tone="warn"> {m.badge}</Badge> : null}
</div>
<div className="vgad-entry__t">{m.title}</div>
<div className="vgad-entry__d">{m.desc}</div>
<div className="vgad-entry__go">
{m.to ? "바로가기" : "준비 중"}
<Icon name="chevron-right" size={13} strokeWidth={2} />
</div>
</>
);
return m.to ? (
<Link className="vgad-entry" to={m.to} key={m.title}>
{inner}
</Link>
) : (
// 라우트 미구현 영역: 권한 차단이 아니므로 회색 disabled 처리 금지(§6.3).
// 살아있는 진입처럼 보이되 "준비 중" 라벨로만 상태를 알린다.
<button className="vgad-entry" type="button" key={m.title}>
{inner}
</button>
);
})}
</div>
</section>
{/* ── 페이지 푸터 ── */}
<div className="vgad-foot">
<span>
Vignette · AI ·
</span>
<span className="tabular"> · v2.4.1</span>
</div>
</AppShell>
);
}