Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
369 changes: 177 additions & 192 deletions DESIGN.md

Large diffs are not rendered by default.

81 changes: 81 additions & 0 deletions apps/web/app/(pages)/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { api } from "@/lib/api";
import { Badge } from "@/components/shared/badge";
import { Card, CardHeader } from "@/components/shared/card";
import { JobControls } from "@/components/shared/job-controls";

export default async function AdminPage() {
const data = await api.admin();

return (
<div className="space-y-6">
<Card>
<CardHeader
eyebrow="Admin / Settings"
title="운영 설정과 승격 게이트"
description="소스 설정, 모델 라우팅, 비용 가드레일, 스케줄러, 수동 승격 승인 상태를 관리합니다."
/>
<div className="grid gap-6 xl:grid-cols-2">
<Card className="rounded-[18px] bg-[color:var(--bg-muted)] p-5">
<p className="text-[14px] font-semibold">활성 소스</p>
<ul className="mt-3 space-y-2 text-[13px] leading-6 text-[color:var(--text-muted)]">
{data.sources.map((source) => (
<li key={source.name}>
{source.name} · {source.kind} · 신뢰도 {source.reliabilityScore.toFixed(2)}
</li>
))}
</ul>
</Card>
<Card className="rounded-[18px] bg-[color:var(--bg-muted)] p-5">
<p className="text-[14px] font-semibold">역할별 모델 라우팅</p>
<ul className="mt-3 space-y-2 text-[13px] leading-6 text-[color:var(--text-muted)]">
{data.modelRouting.map((route) => (
<li key={route.role}>
{route.role} → {route.model}
</li>
))}
</ul>
</Card>
</div>
</Card>

<Card>
<CardHeader
eyebrow="Operations"
title="운영 작업 실행"
description="워커가 살아 있으면 여기서 수집, 분석, 전체 갱신 파이프라인을 즉시 실행할 수 있습니다."
/>
<JobControls compact />
</Card>

<div className="grid gap-6 xl:grid-cols-3">
<Card className="rounded-[18px] bg-[color:var(--bg-muted)] p-5">
<p className="text-[14px] font-semibold">비용 가드레일</p>
<p className="keep-korean mt-3 text-[13px] leading-6 text-[color:var(--text-muted)]">
일일 최대 ${data.costGuardrails.dailyBudgetUsd.toFixed(2)} / 작업당 최대 $
{data.costGuardrails.perJobBudgetUsd.toFixed(2)}
</p>
</Card>
<Card className="rounded-[18px] bg-[color:var(--bg-muted)] p-5">
<p className="text-[14px] font-semibold">스케줄러</p>
<p className="keep-korean mt-3 text-[13px] leading-6 text-[color:var(--text-muted)]">
프리마켓 {data.scheduler.premarketCron}
<br />
포스트마켓 {data.scheduler.postmarketCron}
</p>
</Card>
<Card className="rounded-[18px] bg-[color:var(--surface-white)] p-5 text-white">
<div className="flex items-center justify-between gap-4">
<p className="text-[14px] font-semibold">승격 승인</p>
<Badge variant={data.promotion.manualApprovalRequired ? "warn" : "ok"}>
{data.promotion.manualApprovalRequired ? "수동 승인 필요" : "자동 아님"}
</Badge>
</div>
<p className="keep-korean mt-3 text-[13px] leading-6 text-[color:var(--text-muted)]">
후보 승격은 평가 아티팩트 저장, 롤링 OOS 개선 확인, 사용자 수동 승인 전까지
실운영 라우팅에 반영되지 않습니다.
</p>
</Card>
</div>
</div>
);
}
195 changes: 195 additions & 0 deletions apps/web/app/(pages)/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { api } from "@/lib/api";
import { formatPct, formatScore } from "@/lib/format";
import { Badge } from "@/components/shared/badge";
import { Card, CardHeader } from "@/components/shared/card";
import { JobControls } from "@/components/shared/job-controls";
import { SectionList } from "@/components/shared/section-list";
import { MetricCard } from "@/components/dashboard/metric-card";

export default async function DashboardPage() {
const data = await api.dashboard();

return (
<div className="space-y-6">
<Card className="overflow-hidden rounded-[24px] p-0">
<div className="grid gap-0 xl:grid-cols-[minmax(0,1.2fr)_360px]">
<div className="relative overflow-hidden px-6 py-7 lg:px-8 lg:py-8">
<div className="block-gradient absolute left-6 top-6 h-12 w-12 rounded-full opacity-85 blur-[1px]" />
<div className="absolute inset-x-0 top-0 h-1 bg-[linear-gradient(90deg,#1ed760_0%,rgba(30,215,96,0)_100%)]" />
<div className="absolute right-0 top-0 h-44 w-44 rounded-full bg-[rgba(30,215,96,0.12)] blur-3xl" />
<div className="relative space-y-5">
<p className="section-eyebrow">Premarket Briefing</p>
<div className="space-y-5">
<h2 className="display-title max-w-4xl text-[28px] md:text-[34px] lg:text-[40px]">
미국 반응을 검증해 내일 한국장 테마와 주도주 후보를 빠르게 압축합니다.
</h2>
<p className="keep-korean max-w-3xl text-[14px] leading-7 text-[color:var(--text-muted)] md:text-[15px]">
공식 원문, 실측 반응, 아날로그 검색, 역할별 에이전트 논증을 하나의
증거 사슬로 묶어 다음 영업일 한국장 해석을 빠르게 압축합니다.
</p>
</div>
<div className="flex flex-wrap gap-2">
<span className="ui-pill bg-[rgba(30,215,96,0.14)] px-3 py-2 text-[11px] font-semibold tracking-[0.08em] text-[#1ed760]">
U.S. Macro
</span>
<span className="ui-pill bg-[color:var(--bg-muted)] px-3 py-2 text-[11px] font-semibold tracking-[0.08em]">
Market Reaction
</span>
<span className="ui-pill bg-[color:var(--bg-muted)] px-3 py-2 text-[11px] font-semibold tracking-[0.08em]">
Korea Themes
</span>
</div>
<div className="flex flex-wrap gap-2">
{data.riskFlags.map((flag) => (
<Badge key={flag} variant="warn">
{flag}
</Badge>
))}
</div>
</div>
</div>
<div className="ink-panel flex flex-col justify-between px-5 py-5">
<div className="space-y-5">
<p className="section-eyebrow text-[rgba(255,255,255,0.64)]">오늘의 핵심 결론</p>
<div>
<p className="text-[13px] text-[rgba(255,255,255,0.68)]">우선 테마</p>
<p className="keep-korean mt-1 text-[24px] font-bold leading-[1.2] tracking-[-0.03em]">
{data.topTheme?.name ?? "데이터 대기"}
</p>
</div>
<div>
<p className="text-[13px] text-[rgba(255,255,255,0.68)]">주도주 후보</p>
<p className="keep-korean mt-1 text-[18px] font-semibold leading-[1.4]">
{data.topLeader?.ticker ?? "-"} {data.topLeader?.name ?? ""}
</p>
</div>
</div>
<div className="mt-8 grid grid-cols-2 gap-3">
<div className="rounded-[18px] bg-[rgba(255,255,255,0.06)] p-4">
<p className="text-[11px] uppercase tracking-[0.14em] text-[rgba(255,255,255,0.62)]">
종합 신뢰도
</p>
<p className="mt-3 text-[22px] font-bold tracking-[-0.04em]">
{data.topTheme ? formatScore(data.topTheme.confidence) : "-"}
</p>
</div>
<div className="rounded-[18px] bg-[rgba(255,255,255,0.06)] p-4">
<p className="text-[11px] uppercase tracking-[0.14em] text-[rgba(255,255,255,0.62)]">
미국 반응
</p>
<p className="mt-3 text-[22px] font-bold tracking-[-0.04em]">
{formatPct(data.usReactionSummary.totalMovePct)}
</p>
</div>
</div>
</div>
</div>
</Card>

<div className="metric-grid">
<MetricCard
label="수집 이벤트"
value={String(data.metrics.documentsIngested)}
helper="오늘 수집된 공식/뉴스 문서 수"
/>
<MetricCard
label="정규화 클러스터"
value={String(data.metrics.clusteredEvents)}
helper="중복 제거 후 남은 이벤트 묶음"
/>
<MetricCard
label="활성 테마"
value={String(data.metrics.activeThemes)}
helper="한국장으로 번역된 실행 후보"
/>
<MetricCard
label="최근 20일 히트율"
value={formatPct(data.metrics.recentThemeHitRate * 100)}
helper="롤링 OOS 기준 테마 적중률"
/>
</div>

<Card>
<CardHeader
eyebrow="Run Pipeline"
title="버튼으로 즉시 수집 / 분석 실행"
description="터미널 명령 대신 여기서 바로 큐 작업을 요청할 수 있습니다. 전체 파이프라인은 수집 후 분석을 순차 실행합니다."
/>
<JobControls />
</Card>

<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
<Card>
<CardHeader
eyebrow="U.S. Event Tape"
title="오늘의 미국 핵심 이벤트"
description="신뢰도 점수, 직접성, 서프라이즈, 시장 확인 여부를 종합해 중요한 클러스터부터 보여줍니다."
/>
<SectionList
items={data.keyEvents.map((event) => ({
title: `${event.categoryLabel} · ${event.title}`,
subtitle: `${event.sourceName} | 직접성 ${formatScore(event.directnessScore)} | 시장확인 ${formatScore(event.marketConfirmationScore)}`,
meta: event.publishedAtLabel
}))}
/>
</Card>
<Card>
<CardHeader
eyebrow="U.S. Reaction"
title="미국 반응 요약"
description="지수, 섹터 ETF, 관련주, 크로스애셋 확인 결과를 한 번에 봅니다."
/>
<div className="space-y-3">
{data.usReactionSummary.items.map((item) => (
<div
key={item.label}
className="rounded-[4px] border border-[color:var(--border)] bg-[color:var(--surface-white)] px-4 py-4"
>
<div className="flex items-center justify-between gap-4">
<p className="text-[18px] font-normal">{item.label}</p>
<Badge variant={item.movePct >= 0 ? "ok" : "danger"}>
{formatPct(item.movePct)}
</Badge>
</div>
<p className="mt-2 text-sm leading-6 text-[color:var(--text-muted)]">
{item.commentary}
</p>
</div>
))}
</div>
</Card>
</div>

<div className="grid gap-6 xl:grid-cols-2">
<Card>
<CardHeader
eyebrow="Korea Translation"
title="내일 한국장 예상 테마"
description="테마 적합도, 거래 용이성, 갭상승 후 밀림 위험, 선반영 위험을 함께 제시합니다."
/>
<SectionList
items={data.themeBoard.map((theme) => ({
title: `${theme.name} · ${formatScore(theme.confidence)}`,
subtitle: `${theme.rationale} | 거래용이성 ${formatScore(theme.tradabilityScore)} | 갭페이드 위험 ${formatScore(theme.gapFadeRisk)}`,
meta: theme.marketView
}))}
/>
</Card>
<Card>
<CardHeader
eyebrow="Leader Scan"
title="주도주 / 2등주 후보"
description="상위 후보는 주도 가능성과 리스크를 함께 기록합니다."
/>
<SectionList
items={data.leaderBoard.map((stock) => ({
title: `${stock.ticker} ${stock.name}`,
subtitle: `${stock.tierLabel} | ${stock.rationale} | 선반영 위험 ${formatScore(stock.pricedInRisk)}`,
meta: formatScore(stock.score)
}))}
/>
</Card>
</div>
</div>
);
}
91 changes: 91 additions & 0 deletions apps/web/app/(pages)/evaluation/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { api } from "@/lib/api";
import { Card, CardHeader } from "@/components/shared/card";
import { Badge } from "@/components/shared/badge";
import { formatPct, formatScore } from "@/lib/format";

export default async function EvaluationPage() {
const data = await api.evaluation();

return (
<div className="space-y-6">
<Card>
<CardHeader
eyebrow="Evaluation Center"
title="롤링 OOS 성과와 프롬프트 리더보드"
description="테마 적중률, 주도주 적중률, 랭킹 품질, 오탐 비율, 갭페이드 실패를 체계적으로 추적하고 승격 후보를 심사합니다."
/>
<div className="metric-grid">
<Card className="rounded-[18px] bg-[color:var(--bg-deep)] p-4">
<p className="text-[14px] font-semibold">테마 적중률</p>
<p className="mt-3 text-[24px] font-bold">{formatPct(data.rollingMetrics.themeHitRate * 100)}</p>
</Card>
<Card className="rounded-[18px] bg-[color:var(--bg-deep)] p-4">
<p className="text-[14px] font-semibold">주도주 적중률</p>
<p className="mt-3 text-[24px] font-bold">{formatPct(data.rollingMetrics.leaderHitRate * 100)}</p>
</Card>
<Card className="rounded-[18px] bg-[color:var(--bg-deep)] p-4">
<p className="text-[14px] font-semibold">오탐 비율</p>
<p className="mt-3 text-[24px] font-bold">{formatPct(data.rollingMetrics.falsePositiveRate * 100)}</p>
</Card>
<Card className="rounded-[18px] bg-[color:var(--bg-deep)] p-4">
<p className="text-[14px] font-semibold">갭페이드 실패</p>
<p className="mt-3 text-[24px] font-bold">{formatPct(data.rollingMetrics.gapFadeMissRate * 100)}</p>
</Card>
</div>
</Card>

<div className="grid gap-6 xl:grid-cols-2">
<Card>
<CardHeader eyebrow="Prompt Leaderboard" title="프롬프트 / 워크플로우 버전" />
<div className="space-y-3">
{data.promptLeaderboard.map((item) => (
<div
key={item.version}
className="rounded-[18px] border border-[color:var(--border)] bg-[color:var(--surface-white)] px-4 py-4"
>
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-[15px] font-semibold">{item.version}</p>
<p className="keep-korean text-[13px] leading-6 text-[color:var(--text-muted)]">
{item.description}
</p>
</div>
<Badge variant={item.promotable ? "ok" : "warn"}>
{item.promotable ? "승격 가능" : "보류"}
</Badge>
</div>
<div className="mt-3 grid gap-2 text-[13px] text-[color:var(--text-muted)]">
<p>종합 점수 {formatScore(item.score)}</p>
<p>테마 적중률 {formatPct(item.themeHitRate * 100)}</p>
<p>주도주 적중률 {formatPct(item.leaderHitRate * 100)}</p>
</div>
</div>
))}
</div>
</Card>
<Card>
<CardHeader eyebrow="Model Roles" title="역할별 모델 라우팅 성능" />
<div className="space-y-3">
{data.modelRoleLeaderboard.map((item) => (
<div
key={`${item.role}-${item.model}`}
className="rounded-[18px] border border-[color:var(--border)] bg-[color:var(--surface-white)] px-4 py-4"
>
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-[15px] font-semibold">{item.role}</p>
<p className="text-[13px] text-[color:var(--text-muted)]">{item.model}</p>
</div>
<Badge>{formatScore(item.score)}</Badge>
</div>
<p className="keep-korean mt-2 text-[13px] leading-6 text-[color:var(--text-muted)]">
평균 지연 {item.avgLatencyMs}ms | 평균 비용 ${item.avgCostUsd.toFixed(4)}
</p>
</div>
))}
</div>
</Card>
</div>
</div>
);
}
Loading