Phase 0: React environment setup + project initialization

구현 사항:
1. React + TypeScript 마이그레이션 시작
   - package.json: React, Redux Toolkit, TanStack Query, shadcn/ui dependencies 추가
   - vite.config.js: React 플러그인 + Phase 5/7 API 프록시 추가
   - tsconfig.json, tsconfig.node.json 추가

2. Redux 스토어 설정
   - stores/ontologySlice.ts: 온톨로지 상태 관리
   - stores/crawlSlice.ts: 크롤링 진행 상태
   - stores/uiSlice.ts: UI 상태

3. 리액트 진입점 및 기본 컴포넌트
   - src/main.tsx: React 앱 초기화
   - src/App.tsx: 라우팅 설정 (대시보드, 온보딩, 소스, 크롤, 검증)
   - 5개 페이지 컴포넌트 (placeholder)

4. 스타일 및 설정
   - tailwind.config.js: Tailwind 설정
   - src/styles/globals.css: 글로벌 스타일
   - postcss.config.js: PostCSS 설정
   - src/i18n.ts: i18next 다국어 설정
   - src/lib/queryClient.ts: React Query 설정

5. TypeScript 타입 정의
   - src/stores/slices: 각 Redux slice의 TypeScript 타입

프로젝트 구조:
crawler_platform/app/web/frontend/
├── src/
│   ├── main.tsx (React 진입점)
│   ├── App.tsx (라우팅)
│   ├── pages/ (4개 페이지 컴포넌트)
│   ├── stores/ (Redux 스토어 + slices)
│   ├── lib/ (유틸리티)
│   ├── styles/ (CSS)
│   └── i18n.ts (다국어 설정)
├── index.html (업데이트: React root)
├── package.json (의존성 추가)
├── vite.config.js (React 플러그인)
├── tsconfig.json (TypeScript 설정)
├── tailwind.config.js (Tailwind 설정)
└── postcss.config.js (PostCSS 설정)

다음 단계:
- npm install로 의존성 설치
- Phase 1: 온톨로지 업로더 구현
- Phase 1: 참고 사이트 매니저 구현

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
lasta
2026-05-14 13:52:45 +09:00
parent a71da5b3c2
commit 1be2c7d3a3
22 changed files with 817 additions and 7 deletions

View File

@@ -0,0 +1,25 @@
import { Routes, Route } from "react-router-dom";
import { useTranslation } from "react-i18next";
import OnboardingPage from "./pages/OnboardingPage";
import ConfigureSourcesPage from "./pages/ConfigureSourcesPage";
import CrawlPage from "./pages/CrawlPage";
import ReviewPage from "./pages/ReviewPage";
import DashboardPage from "./pages/DashboardPage";
function App() {
const { i18n } = useTranslation();
return (
<div className={i18n.language === "ko" ? "font-sans" : "font-sans"}>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/onboard" element={<OnboardingPage />} />
<Route path="/sources/:projectId" element={<ConfigureSourcesPage />} />
<Route path="/crawl/:projectId" element={<CrawlPage />} />
<Route path="/review/:projectId" element={<ReviewPage />} />
</Routes>
</div>
);
}
export default App;

View File

@@ -0,0 +1,25 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import HttpBackend from "i18next-http-backend";
i18n
.use(HttpBackend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: "en",
ns: ["common", "pages"],
defaultNS: "common",
backend: {
loadPath: "/static/locales/{{lng}}/{{ns}}.json",
},
interpolation: {
escapeValue: false,
},
react: {
useSuspense: false,
},
});
export default i18n;

View File

@@ -0,0 +1,14 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime)
retry: 1,
},
mutations: {
retry: 1,
},
},
});

View File

@@ -0,0 +1,24 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { Provider } from "react-redux";
import { BrowserRouter } from "react-router-dom";
import { QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "sonner";
import "./i18n";
import App from "./App";
import store from "./stores";
import { queryClient } from "./lib/queryClient";
import "./styles/globals.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<BrowserRouter basename="/static/">
<App />
<Toaster position="top-right" />
</BrowserRouter>
</QueryClientProvider>
</Provider>
</React.StrictMode>
);

View File

@@ -0,0 +1,40 @@
import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
export default function ConfigureSourcesPage() {
const navigate = useNavigate();
const { projectId } = useParams();
const { t } = useTranslation();
return (
<div className="min-h-screen bg-gray-50 p-4">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
{t("Configure Reference Sites")}
</h1>
<p className="text-gray-600 mb-6">
{t("Step 2 of 4: Add 5-20 reference sites for knowledge extraction")}
</p>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-gray-500">{t("No sites added yet")}</p>
</div>
<div className="flex gap-4 mt-6">
<button
onClick={() => navigate("/onboard")}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition"
>
{t("Back")}
</button>
<button
onClick={() => navigate(`/crawl/${projectId}`)}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
>
{t("Next")}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
export default function CrawlPage() {
const navigate = useNavigate();
const { projectId } = useParams();
const { t } = useTranslation();
return (
<div className="min-h-screen bg-gray-50 p-4">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
{t("Build Ontology")}
</h1>
<p className="text-gray-600 mb-6">
{t("Step 3 of 4: Auto-crawl and build ontology with Phase 5 + 7")}
</p>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-gray-500">{t("Crawl pipeline will appear here")}</p>
</div>
<div className="flex gap-4 mt-6">
<button
onClick={() => navigate(`/sources/${projectId}`)}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition"
>
{t("Back")}
</button>
<button
onClick={() => navigate(`/review/${projectId}`)}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
>
{t("Review Results")}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Plus, BarChart3, Settings } from "lucide-react";
export default function DashboardPage() {
const navigate = useNavigate();
const { t } = useTranslation();
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 py-12">
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold text-gray-900">
{t("Ontology Builder")}
</h1>
<p className="text-gray-600 mt-2">
{t("Build and manage domain ontologies with AI-powered extraction")}
</p>
</div>
<button
onClick={() => navigate("/onboard")}
className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition"
>
<Plus className="w-5 h-5" />
{t("New Project")}
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white rounded-lg shadow p-6">
<BarChart3 className="w-8 h-8 text-blue-600 mb-4" />
<h3 className="font-semibold text-lg mb-2">{t("Quick Start")}</h3>
<p className="text-gray-600 text-sm">
{t("Get started by uploading your domain ontology")}
</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<Settings className="w-8 h-8 text-green-600 mb-4" />
<h3 className="font-semibold text-lg mb-2">{t("Phase 5 GraphRAG")}</h3>
<p className="text-gray-600 text-sm">
{t("Intelligent entity resolution and deduplication")}
</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<Settings className="w-8 h-8 text-purple-600 mb-4" />
<h3 className="font-semibold text-lg mb-2">{t("Phase 7 LLM")}</h3>
<p className="text-gray-600 text-sm">
{t("AI-powered extraction and validation")}
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
export default function OnboardingPage() {
const navigate = useNavigate();
const { t } = useTranslation();
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-xl p-8 max-w-2xl w-full">
<h1 className="text-3xl font-bold text-gray-900 mb-4">
{t("Upload Ontology")}
</h1>
<p className="text-gray-600 mb-6">
{t("Step 1 of 4: Upload your domain ontology (YAML, JSON, or OWL)")}
</p>
<div className="border-2 border-dashed border-blue-300 rounded-lg p-8 text-center bg-blue-50 mb-6">
<p className="text-gray-600">
{t("Drag and drop your ontology file here, or click to browse")}
</p>
</div>
<div className="flex gap-4">
<button
onClick={() => navigate("/")}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition"
>
{t("Cancel")}
</button>
<button
onClick={() => navigate("/sources/demo-project")}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
>
{t("Next")}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
export default function ReviewPage() {
const navigate = useNavigate();
const { projectId } = useParams();
const { t } = useTranslation();
return (
<div className="min-h-screen bg-gray-50 p-4">
<div className="max-w-4xl mx-auto">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
{t("Review & Validate")}
</h1>
<p className="text-gray-600 mb-6">
{t("Step 4 of 4: Review extracted entities and relations")}
</p>
<div className="grid grid-cols-2 gap-6 mb-6">
<div className="bg-white rounded-lg shadow p-6">
<h3 className="font-semibold text-lg mb-2">
{t("Phase 5 Entity Merges")}
</h3>
<p className="text-gray-500">{t("Review merge suggestions")}</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="font-semibold text-lg mb-2">
{t("Phase 7 Extractions")}
</h3>
<p className="text-gray-500">{t("Validate extracted claims")}</p>
</div>
</div>
<div className="flex gap-4">
<button
onClick={() => navigate(`/crawl/${projectId}`)}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition"
>
{t("Back")}
</button>
<button
onClick={() => navigate("/")}
className="flex-1 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"
>
{t("Complete")}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { configureStore } from "@reduxjs/toolkit";
import ontologyReducer from "./slices/ontologySlice";
import crawlReducer from "./slices/crawlSlice";
import uiReducer from "./slices/uiSlice";
const store = configureStore({
reducer: {
ontology: ontologyReducer,
crawl: crawlReducer,
ui: uiReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export default store;

View File

@@ -0,0 +1,128 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
export interface CrawlStep {
name: string;
status: "pending" | "in_progress" | "completed" | "failed";
progress: number; // 0-100
message: string;
error?: string;
}
export interface CrawlState {
projectId: string | null;
isRunning: boolean;
steps: {
fetching: CrawlStep;
extracting: CrawlStep;
merging: CrawlStep;
validating: CrawlStep;
};
stats: {
pagesFound: number;
pagesCrawled: number;
entitiesExtracted: number;
entitiesMerged: number;
claimsValidated: number;
};
logs: Array<{
timestamp: string;
level: "info" | "warning" | "error";
message: string;
}>;
error: string | null;
}
const initialCrawlStep: CrawlStep = {
name: "",
status: "pending",
progress: 0,
message: "",
};
const initialState: CrawlState = {
projectId: null,
isRunning: false,
steps: {
fetching: { ...initialCrawlStep, name: "Fetching pages" },
extracting: { ...initialCrawlStep, name: "Extracting entities" },
merging: { ...initialCrawlStep, name: "Merging duplicates" },
validating: { ...initialCrawlStep, name: "Validating results" },
},
stats: {
pagesFound: 0,
pagesCrawled: 0,
entitiesExtracted: 0,
entitiesMerged: 0,
claimsValidated: 0,
},
logs: [],
error: null,
};
const crawlSlice = createSlice({
name: "crawl",
initialState,
reducers: {
startCrawl: (state, action: PayloadAction<string>) => {
state.projectId = action.payload;
state.isRunning = true;
state.error = null;
state.logs = [];
Object.values(state.steps).forEach((step) => {
step.status = "pending";
step.progress = 0;
step.error = undefined;
});
},
updateStep: (
state,
action: PayloadAction<{
step: keyof typeof state.steps;
status: CrawlStep["status"];
progress: number;
message: string;
error?: string;
}>
) => {
const { step, status, progress, message, error } = action.payload;
state.steps[step] = { ...state.steps[step], status, progress, message, error };
},
updateStats: (state, action: PayloadAction<Partial<typeof state.stats>>) => {
Object.assign(state.stats, action.payload);
},
addLog: (
state,
action: PayloadAction<{
level: "info" | "warning" | "error";
message: string;
}>
) => {
state.logs.push({
timestamp: new Date().toISOString(),
...action.payload,
});
},
setCrawlError: (state, action: PayloadAction<string>) => {
state.error = action.payload;
state.isRunning = false;
},
completeCrawl: (state) => {
state.isRunning = false;
},
resetCrawl: (state) => {
Object.assign(state, initialState);
},
},
});
export const {
startCrawl,
updateStep,
updateStats,
addLog,
setCrawlError,
completeCrawl,
resetCrawl,
} = crawlSlice.actions;
export default crawlSlice.reducer;

View File

@@ -0,0 +1,87 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
export interface OntologyEntity {
id: string;
label: string;
type: string;
description?: string;
}
export interface OntologyRelation {
id: string;
source: string;
target: string;
predicate: string;
}
export interface OntologyState {
id: string | null;
name: string;
domain: string;
format: "yaml" | "json" | "owl" | null;
entities: OntologyEntity[];
relations: OntologyRelation[];
isLoaded: boolean;
isSaving: boolean;
error: string | null;
}
const initialState: OntologyState = {
id: null,
name: "",
domain: "",
format: null,
entities: [],
relations: [],
isLoaded: false,
isSaving: false,
error: null,
};
const ontologySlice = createSlice({
name: "ontology",
initialState,
reducers: {
setOntology: (state, action: PayloadAction<Partial<OntologyState>>) => {
Object.assign(state, action.payload);
},
addEntity: (state, action: PayloadAction<OntologyEntity>) => {
state.entities.push(action.payload);
},
removeEntity: (state, action: PayloadAction<string>) => {
state.entities = state.entities.filter((e) => e.id !== action.payload);
},
addRelation: (state, action: PayloadAction<OntologyRelation>) => {
state.relations.push(action.payload);
},
removeRelation: (state, action: PayloadAction<string>) => {
state.relations = state.relations.filter((r) => r.id !== action.payload);
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.isLoaded = action.payload;
},
setSaving: (state, action: PayloadAction<boolean>) => {
state.isSaving = action.payload;
},
setError: (state, action: PayloadAction<string | null>) => {
state.error = action.payload;
},
resetOntology: (state) => {
Object.assign(state, initialState);
},
},
});
export const {
setOntology,
addEntity,
removeEntity,
addRelation,
removeRelation,
setLoading,
setSaving,
setError,
resetOntology,
} = ontologySlice.actions;
export default ontologySlice.reducer;

View File

@@ -0,0 +1,65 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
export interface UIState {
sidebarOpen: boolean;
currentStep: number; // 0: dashboard, 1: onboard, 2: sources, 3: crawl, 4: review
selectedProjectId: string | null;
isLoading: boolean;
notification: {
type: "success" | "error" | "info" | "warning" | null;
message: string;
};
}
const initialState: UIState = {
sidebarOpen: true,
currentStep: 0,
selectedProjectId: null,
isLoading: false,
notification: {
type: null,
message: "",
},
};
const uiSlice = createSlice({
name: "ui",
initialState,
reducers: {
toggleSidebar: (state) => {
state.sidebarOpen = !state.sidebarOpen;
},
setCurrentStep: (state, action: PayloadAction<number>) => {
state.currentStep = action.payload;
},
setSelectedProject: (state, action: PayloadAction<string | null>) => {
state.selectedProjectId = action.payload;
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload;
},
showNotification: (
state,
action: PayloadAction<{
type: "success" | "error" | "info" | "warning";
message: string;
}>
) => {
state.notification = action.payload;
},
clearNotification: (state) => {
state.notification = { type: null, message: "" };
},
},
});
export const {
toggleSidebar,
setCurrentStep,
setSelectedProject,
setLoading,
showNotification,
clearNotification,
} = uiSlice.actions;
export default uiSlice.reducer;

View File

@@ -0,0 +1,60 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--muted: 221.2 63.6% 97%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 221.2 83.2% 53.3%;
--accent-foreground: 210 40% 98%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
--primary: 222.2 47.6% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96%;
--secondary-foreground: 222.2 47.6% 11.2%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 91.2% 59.8%;
--accent-foreground: 222.2 47.6% 11.2%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.6% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}