Skip to content
Merged
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
14 changes: 10 additions & 4 deletions frontend/src/app/chat/KnowledgePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,9 +148,15 @@ export default function KnowledgePanel() {

{/* 파일 첨부 → 임베딩 */}
<div className="border-t border-line pt-4">
<h3 className="mb-2 text-xs font-medium uppercase tracking-[0.14em] text-muted">
파일 추가
</h3>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-xs font-medium uppercase tracking-[0.14em] text-muted">
파일 추가 (첨부)
</h3>
<span className="text-[10px] text-muted">PDF, TXT, MD</span>
</div>
<p className="mb-2 text-[11px] text-muted leading-relaxed">
세무 서류, 청약 가이드, 금융사기 예방 매뉴얼 등을 첨부하면 임베딩되어 챗봇 답변의 근거로 활용됩니다.
</p>
<input
type="file"
accept=".pdf,.txt,.md,.jsonl"
Expand Down Expand Up @@ -191,7 +197,7 @@ export default function KnowledgePanel() {
</p>
<p className="mt-1.5 px-1 text-[10px] leading-relaxed text-warning">
⚠ 엔티티 추출(LLM)로 <span className="font-medium">수 분 소요</span>되며, 그동안 같은 LLM 자원을 공유하는
챗 응답이 느려질 수 있습니다. 시연·발표 중에는 실행하지 마세요.
챗 응답이 느려질 수 있습니다. 시연이나 발표 중에는 실행하지 마세요.
</p>
{buildJobId && (
<div className="mt-3">
Expand Down
28 changes: 21 additions & 7 deletions frontend/src/app/chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
CheckCircle,
Copy,
Check,
Paperclip,
} from "@phosphor-icons/react";
import {
apiDelete,
Expand Down Expand Up @@ -95,7 +96,7 @@ function AssistantAnswer({ content }: { content: string }) {
{/* 5겹째 — 라벨이 "5겹"인데 4줄만 있으면 세어 보는 사람에게 바로 걸린다. */}
<div className="flex items-center gap-2 text-muted">
<CheckCircle weight="fill" size={16} className="text-positive shrink-0" />
<span>외곽 경계: <span className="text-fg font-medium">fetch 허용목록 · JWT 세션</span></span>
<span>외곽 경계: <span className="text-fg font-medium">fetch 허용목록, JWT 세션</span></span>
</div>
</div>
</div>
Expand Down Expand Up @@ -392,7 +393,7 @@ function ChatClient() {
>
<div className="truncate text-fg">{s.title}</div>
<div className="truncate text-[10px] text-muted">
{fmtDate(s.updated_at)} · {s.message_count}개 메시지
{fmtDate(s.updated_at)} / {s.message_count}개 메시지
</div>
</button>
<button
Expand Down Expand Up @@ -421,7 +422,7 @@ function ChatClient() {
<h1 className="font-display text-lg sm:text-xl text-fg shrink-0 font-bold">에이전트 챗봇</h1>
<span className="eyebrow hidden sm:inline-flex">AI Advisor</span>
<span className="hidden font-mono-spec text-[10px] uppercase tracking-widest text-muted md:inline truncate">
MidasAdviser · 멀티턴 · 실시간 스트리밍
MidasAdviser / 멀티턴 / 실시간 스트리밍
</span>
</div>
<button
Expand Down Expand Up @@ -501,12 +502,12 @@ function ChatClient() {
</div>
<h3 className="text-sm font-semibold text-fg">무엇이든 질문해보세요</h3>
<p className="text-xs text-muted mt-1">
사기 문자 검증, 세법 근거·계산, 청약 자격까지 — 근거와 함께 정리해 드립니다.
사기 문자 검증, 세법 근거계산, 청약 자격까지 — 근거와 함께 정리해 드립니다.
</p>
</div>

{/* 예시 질문은 첫 화면의 실질적 헤드라인이다 — 공모 주제('AI 금융 보안 비서')의
핵심 기능(사기 검증·결정론 계산)을 앞세우고 청약·자산은 균형으로 둔다. */}
핵심 기능(사기 검증, 결정론 계산)을 앞세우고 청약자산은 균형으로 둔다. */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-left max-w-xl mx-auto pt-2">
{[
{
Expand Down Expand Up @@ -594,7 +595,20 @@ function ChatClient() {
</div>
))}
</div>
<div className="flex gap-2 border-t border-line p-3">
<div className="flex items-center gap-2 border-t border-line p-3">
<button
type="button"
onClick={() => {
setTab("kb");
setMobileSidebarOpen(true);
toast("지식베이스 패널에서 PDF, TXT, MD 문서를 첨부하고 임베딩할 수 있습니다.", "info");
}}
aria-label="지식베이스 파일 첨부"
title="문서 첨부 및 지식베이스 열기"
className="btn-ghost flex h-10 w-10 shrink-0 items-center justify-center rounded-xl text-muted hover:text-accent hover:border-accent/40 transition"
>
<Paperclip size={18} />
</button>
<input
ref={inputRef}
value={input}
Expand All @@ -605,7 +619,7 @@ function ChatClient() {
send();
}
}}
placeholder="메시지를 입력하세요…"
placeholder="메시지를 입력하세요… (서류 첨부는 좌측 클립 아이콘)"
className="field flex-1 px-4 py-2.5 text-sm"
/>
<button
Expand Down
10 changes: 5 additions & 5 deletions frontend/src/app/graph/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,10 @@ export default function GraphPage() {
</div>
<div className="text-sm sm:text-[15px] leading-relaxed text-muted/95 break-keep space-y-1">
<p>
신규 세법 문서 업로드와 임베딩·그래프 구축은 <strong className="text-accent font-semibold">챗봇 화면의 지식베이스 패널</strong>에서 한 번에 처리됩니다.
신규 세법 문서 업로드와 임베딩그래프 구축은 <strong className="text-accent font-semibold">챗봇 화면의 지식베이스 패널</strong>에서 한 번에 처리됩니다.
</p>
<p>
이 페이지는 그렇게 구축된 Neo4j 지식그래프를 직접 시각화하고 탐색·질의하는 <strong className="text-fg font-bold">근거 추적 전용 콘솔</strong>입니다.
이 페이지는 그렇게 구축된 Neo4j 지식그래프를 직접 시각화하고 탐색질의하는 <strong className="text-fg font-bold">근거 추적 전용 콘솔</strong>입니다.
</p>
</div>
</div>
Expand All @@ -138,8 +138,8 @@ export default function GraphPage() {
) : (
<>
<p className="mb-3 text-xs sm:text-sm font-medium text-muted">
노드 {snapshot.nodes.length}개 · 관계 {snapshot.links.length}개
{highlight.length > 0 && " · 흰 테두리 = RAG 근거 노드"}
노드 {snapshot.nodes.length}개 / 관계 {snapshot.links.length}개
{highlight.length > 0 && " (흰색 노드: GraphRAG 질의 근거)"}
</p>
<GraphView data={snapshot} highlight={highlight} />
</>
Expand All @@ -154,7 +154,7 @@ export default function GraphPage() {
)}
<div className="text-center sm:text-left">
<span className="font-mono-spec text-xs font-bold uppercase tracking-[0.2em] text-accent">
Standing by · 스캔 대기
Standing by / 스캔 대기
</span>
<p className="mt-2 text-sm sm:text-[15px] font-medium text-muted">
버튼을 눌러 Neo4j 그래프 스냅샷을 시각화하세요.
Expand Down
117 changes: 104 additions & 13 deletions frontend/src/components/GraphView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,78 @@ const ForceGraph2D = dynamic(() => import("react-force-graph-2d"), {
loading: () => <p className="text-sm text-muted">그래프 로딩 중…</p>,
});

// 폴백색 — 실제로는 렌더 시 토큰(--accent 등)을 실측해 쓴다. 인디고/앰버 듀오 유지.
const NODE_COLOR = "#6c5cff";
const NODE_HI = "#faad13";
// 5대 핵심 도메인 시맨틱 팔레트 (Neon Ledger 디자인 시스템 연동)
export interface NodeCategory {
id: string;
label: string;
color: string;
}

export const NODE_CATEGORIES: NodeCategory[] = [
{ id: "tax", label: "세법 / 세율 규정", color: "#6c5cff" },
{ id: "legal", label: "법령 조문 / 근거", color: "#faad13" },
{ id: "asset", label: "부동산 / 자산 유형", color: "#05c168" },
{ id: "condition", label: "감면 / 공제 / 요건", color: "#06b6d4" },
{ id: "household", label: "가구 / 기간 요건", color: "#ff4d67" },
{ id: "etc", label: "기타 엔티티", color: "#9094a8" },
];

export function getNodeCategory(rawGroup?: string): NodeCategory {
if (!rawGroup) return NODE_CATEGORIES[5];
const g = rawGroup.toUpperCase();
if (
g.includes("TAX") ||
g.includes("RATE") ||
g.includes("BRACKET") ||
g.includes("AMOUNT") ||
g.includes("BASE") ||
g.includes("LIMIT") ||
g.includes("보유기간")
) {
if (g.includes("EXEMPT")) return NODE_CATEGORIES[3];
return NODE_CATEGORIES[0];
}
if (
g.includes("LEGAL") ||
g.includes("LAW") ||
g.includes("ARTICLE") ||
g.includes("POLICY") ||
g.includes("CLAUSE")
) {
return NODE_CATEGORIES[1];
}
if (
g.includes("HOUSING") ||
g.includes("ASSET") ||
g.includes("PORTFOLIO") ||
g.includes("PROJECT") ||
g.includes("REGION") ||
g.includes("STOCK") ||
g.includes("PROPERTY")
) {
return NODE_CATEGORIES[2];
}
if (
g.includes("CONDITION") ||
g.includes("EXEMPT") ||
g.includes("DEDUCTION") ||
g.includes("INCOME") ||
g.includes("REQUIREMENT")
) {
return NODE_CATEGORIES[3];
}
if (
g.includes("HOUSEHOLD") ||
g.includes("FAMILY") ||
g.includes("DATE") ||
g.includes("TIMEFRAME") ||
g.includes("COMPOSITE") ||
g.includes("PERSON")
) {
return NODE_CATEGORIES[4];
}
return NODE_CATEGORIES[5];
}

// canvas fillStyle 은 var() 를 파싱하지 못하므로 렌더 시점에 토큰 값을 실측해 치환한다.
function tokenColor(varName: string, fallback: string): string {
Expand All @@ -33,9 +102,6 @@ export default function GraphView({
const [width, setWidth] = useState(800);
const [selected, setSelected] = useState<GraphNode | null>(null);
const hi = useMemo(() => new Set(highlight), [highlight]);
// 표면/헤어라인은 --ink-1/--line, 강조(하이라이트 노드)만 --accent.
const accentColor = tokenColor("--accent", NODE_COLOR);
const surfaceColor = tokenColor("--ink-1", NODE_HI);
const lineColor = tokenColor("--line", "#2a2d35");

const nodeById = useMemo(() => {
Expand All @@ -56,6 +122,26 @@ export default function GraphView({
return (
<div ref={wrapRef} className="w-full">
<div className="relative overflow-hidden rounded-lg border border-line bg-[var(--ink-2)]">
{/* 상단 5대 시맨틱 카테고리 범례 바 */}
<div className="flex flex-wrap items-center gap-x-3.5 gap-y-1.5 border-b border-line bg-[var(--ink-1)] px-3.5 py-2 text-xs">
<span className="text-[11px] font-bold text-muted shrink-0">도메인 범례:</span>
{NODE_CATEGORIES.slice(0, 5).map((cat) => (
<div key={cat.id} className="flex items-center gap-1.5">
<span
className="h-2.5 w-2.5 rounded-full shrink-0 shadow-sm"
style={{ backgroundColor: cat.color }}
/>
<span className="text-[11px] font-medium text-muted">{cat.label}</span>
</div>
))}
{hi.size > 0 && (
<div className="flex items-center gap-1.5 sm:ml-auto">
<span className="h-2.5 w-2.5 rounded-full shrink-0 bg-white ring-2 ring-accent" />
<span className="text-[11px] font-bold text-accent">RAG 근거 노드 ({hi.size})</span>
</div>
)}
</div>

<ForceGraph2D
graphData={data}
width={width}
Expand All @@ -66,10 +152,12 @@ export default function GraphView({
warmupTicks={20}
// 기본 렌더(커스텀 캔버스 X)라 정지 후 재페인트가 없어 가볍다.
nodeRelSize={4}
nodeColor={(n: NodeObject) =>
hi.has(String(n.id)) ? surfaceColor : accentColor
}
nodeVal={(n: NodeObject) => (hi.has(String(n.id)) ? 3 : 1)}
nodeColor={(n: NodeObject) => {
if (hi.has(String(n.id))) return "#ffffff";
const group = (n as { group?: string }).group;
return getNodeCategory(group).color;
}}
nodeVal={(n: NodeObject) => (hi.has(String(n.id)) ? 3.5 : 1.2)}
nodeLabel={(n: NodeObject) =>
`${n.id} (${(n as { group?: string }).group ?? ""})`
}
Expand All @@ -84,9 +172,12 @@ export default function GraphView({
/>

{selected && (
<div className="absolute right-3 top-3 max-h-[480px] w-64 overflow-auto rounded-lg border border-line bg-[var(--ink-1)] p-3 text-xs">
<div className="absolute right-3 top-12 max-h-[440px] w-64 overflow-auto rounded-lg border border-line bg-[var(--ink-1)] p-3 text-xs shadow-lg">
<div className="mb-2 flex items-start justify-between gap-2">
<span className="text-[10px] uppercase tracking-wider text-muted">
<span
className="rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-white"
style={{ backgroundColor: getNodeCategory(selected.group).color }}
>
{selected.group}
</span>
<button
Expand Down Expand Up @@ -116,7 +207,7 @@ export default function GraphView({
)}
</div>
<p className="mt-1.5 text-[11px] text-muted">
노드에 마우스를 올리면 이름·관계명, 클릭하면 상세정보가 보입니다.
노드에 마우스를 올리면 이름과 관계명, 클릭하면 상세정보가 보입니다.
</p>
</div>
);
Expand Down
Loading