Phase 1.5: 엔티티/클레임 직접 입력 — OntologyEditorPage + 3탭 (엔티티/클레임/JSON 일괄)

백엔드 (crawler_platform/app/api/routes.py):
- POST /projects/{n}/entities: 단일 엔티티 직접 생성 (upsert)
  - CreateEntityRequest (entity_type, name, metadata)
- POST /projects/{n}/entities/bulk: 다수 엔티티 일괄 생성
  - BulkCreateEntitiesRequest, 응답 created 수 + entities
- DELETE /projects/{n}/entities/{id}: 단일 엔티티 삭제
- POST /projects/{n}/claims: 단일 클레임 직접 생성
  - CreateClaimRequest (source_name, subject_entity_id, predicate,
    object_entity_id|object_value, confidence, confidence_reason, evidence_text)
  - claim_hash로 중복 검출 → 있으면 confidence/메타 갱신
  - status="validated_claim", extraction_method="manual"
  - evidence_text 있으면 Evidence 자동 생성
- DELETE /projects/{n}/claims/{id}: 단일 클레임 삭제

프론트엔드 API (src/lib/api/):
- entities.ts: list/create/bulkCreate/delete + Zod 스키마
- claims.ts: list/create/delete + Zod 스키마 (passthrough)

TanStack Query 훅 (src/hooks/):
- useEntities.ts: useEntities, useCreateEntity, useBulkCreateEntities, useDeleteEntity
- useClaims.ts: useClaims, useCreateClaim, useDeleteClaim
- queryKeys에 entities.list, claims.list 키 팩토리

UI 프리미티브 (src/components/ui/):
- tabs.tsx: Tabs, TabsList, TabsTrigger, TabsContent (Context API 기반)

OntologyEditorPage 신규 (src/pages/):
- 3개 탭 구조:
  * 엔티티 탭: 도메인의 entity_types에서 타입 선택 + 이름 입력 → 추가
    + 엔티티 목록 (max-h scroll, 타입 배지, 삭제 버튼)
  * 클레임 탭: 소스/주어/술어/목적어(엔티티 or 리터럴)/신뢰도 입력
    + 클레임 목록 (S-P-O 시각화, 신뢰도, status 배지)
  * JSON 일괄 탭: textarea에 { entities: [...] } 붙여넣기 → 파싱 → bulkCreate
- react-hook-form + zod 검증
- useOntology(domain)으로 entity_types/predicates 자동 로드
- 삭제 confirm 대화상자, sonner 토스트

라우팅 & Sidebar:
- App.tsx: /editor/:projectId 라우트 추가
- AppShell: 사이드바에 "온톨로지 편집" 메뉴 (Network 아이콘)

i18n: editor.*, nav.editor 키 (한/영)

UI_REBUILD_PLAN.md 업데이트:
- Phase 1.4 `00786a4` 커밋 기록
- Phase 1.5 완료 표시 + 대기 보드 Phase 2/3 재정렬

다음 단계: Phase 2 — 그래프 시각화/편집 (Cytoscape React 래퍼)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
lasta
2026-05-14 19:10:37 +09:00
parent 00786a4fea
commit 39097d0240
14 changed files with 1347 additions and 7 deletions

View File

@@ -22,19 +22,19 @@
| Phase 1.1 | 프로젝트 생성 (OnboardingPage 폼 + 백엔드 `POST /projects/inline`, `GET /domains`) | `8681ac8` | | Phase 1.1 | 프로젝트 생성 (OnboardingPage 폼 + 백엔드 `POST /projects/inline`, `GET /domains`) | `8681ac8` |
| Phase 1.2 | 참고 소스 관리 (ConfigureSourcesPage CRUD + 백엔드 `POST/DELETE /projects/{name}/sources`) | `461ebc0` | | Phase 1.2 | 참고 소스 관리 (ConfigureSourcesPage CRUD + 백엔드 `POST/DELETE /projects/{name}/sources`) | `461ebc0` |
| Phase 1.3 | 시드 크롤 (CrawlPage + 폴링 + 취소, 백엔드 `POST /crawl-site/by-project`) | `aaaaa05` | | Phase 1.3 | 시드 크롤 (CrawlPage + 폴링 + 취소, 백엔드 `POST /crawl-site/by-project`) | `aaaaa05` |
| Phase 1.4 | 자율 연구 (ResearchPage + 세션 이력, 백엔드 `POST /research/run/by-project`) | (이번 커밋) | | Phase 1.4 | 자율 연구 (ResearchPage + 세션 이력, 백엔드 `POST /research/run/by-project`) | `00786a4` |
| Phase 1.5 | 엔티티/클레임 직접 입력 (OntologyEditorPage 3 탭: 엔티티/클레임/JSON 일괄) | (이번 커밋) |
### 🚧 진행 중 ### 🚧 진행 중
(없음 — Phase 1.5 시작 전) (없음 — Phase 2 시작 전)
### ⏳ 대기 ### ⏳ 대기
| Phase | 내용 | 다음 액션 | | Phase | 내용 | 다음 액션 |
|---|---|---| |---|---|---|
| Phase 1.5 | 엔티티/클레임 직접 입력 (`POST /projects/{n}/entities`, `/claims` 백엔드 추가 필요) | 독립 가능 | | **Phase 2** | 그래프 시각화/편집 (Cytoscape 또는 react-flow 래퍼) | `GET /projects/{n}/graph/neighborhood` + `legacy/graph.js` 패턴 참고 |
| Phase 2 | 그래프 시각화/편집 (Cytoscape 또는 react-flow 래퍼) | 데이터 일부 있어야 의미있음 | | Phase 3 | JSON Import/Export 전용 페이지 (Editor의 일괄 입력 탭 확장) | 독립 가능 |
| Phase 3 | JSON Import/Export (대량 데이터 직접 입력) | 독립 가능 |
--- ---

View File

@@ -18,7 +18,10 @@ from crawler_platform.app.core.crawler.fetchers import RobotsPolicy, make_fetche
from crawler_platform.app.core.crawler.pipeline import CrawlPipeline from crawler_platform.app.core.crawler.pipeline import CrawlPipeline
from crawler_platform.app.core.crawler.site_crawler import SiteCrawler from crawler_platform.app.core.crawler.site_crawler import SiteCrawler
from crawler_platform.app.core.database import models from crawler_platform.app.core.database import models
from crawler_platform.app.core.database.repository import KnowledgeRepository from crawler_platform.app.core.database.repository import (
KnowledgeRepository,
make_claim_hash,
)
from crawler_platform.app.core.database.session import session_scope from crawler_platform.app.core.database.session import session_scope
from crawler_platform.app.core.extractor.ai_provider import list_openai_compatible_models from crawler_platform.app.core.extractor.ai_provider import list_openai_compatible_models
from crawler_platform.app.core.extractor.factory import extractor_for_domain from crawler_platform.app.core.extractor.factory import extractor_for_domain
@@ -148,6 +151,28 @@ class ResetProjectRequest(BaseModel):
project_name: str | None = None project_name: str | None = None
class CreateEntityRequest(BaseModel):
entity_type: str
name: str
metadata: dict[str, Any] = Field(default_factory=dict)
class BulkCreateEntitiesRequest(BaseModel):
entities: list[CreateEntityRequest]
class CreateClaimRequest(BaseModel):
source_name: str
subject_entity_id: int
predicate: str
object_entity_id: int | None = None
object_value: Any = None
confidence: float = 1.0
confidence_reason: str | None = None
evidence_text: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class ResearchRunByProjectRequest(BaseModel): class ResearchRunByProjectRequest(BaseModel):
"""Run autonomous research against an existing DB project (no config_path).""" """Run autonomous research against an existing DB project (no config_path)."""
@@ -932,6 +957,171 @@ def register_routes(app, database_url: str) -> None:
for entity in entities for entity in entities
] ]
@app.post("/projects/{project_name}/entities")
def create_entity(project_name: str, request: CreateEntityRequest):
"""Create or update a single entity directly (no extraction)."""
with session_scope(database_url) as session:
repo = KnowledgeRepository(session)
project = repo.get_project(project_name)
entity = repo.upsert_entity(
project_id=project.id,
entity_type=request.entity_type,
name=request.name,
metadata={
**request.metadata,
"input_method": request.metadata.get("input_method", "manual"),
},
)
return {
"id": entity.id,
"type": entity.entity_type,
"name": entity.name,
"metadata": entity.metadata_json,
}
@app.post("/projects/{project_name}/entities/bulk")
def bulk_create_entities(project_name: str, request: BulkCreateEntitiesRequest):
"""Create multiple entities in one call."""
with session_scope(database_url) as session:
repo = KnowledgeRepository(session)
project = repo.get_project(project_name)
created: list[dict[str, Any]] = []
for item in request.entities:
entity = repo.upsert_entity(
project_id=project.id,
entity_type=item.entity_type,
name=item.name,
metadata={
**item.metadata,
"input_method": item.metadata.get("input_method", "manual"),
},
)
created.append(
{
"id": entity.id,
"type": entity.entity_type,
"name": entity.name,
}
)
return {"created": len(created), "entities": created}
@app.delete("/projects/{project_name}/entities/{entity_id}")
def delete_entity(project_name: str, entity_id: int):
with session_scope(database_url) as session:
repo = KnowledgeRepository(session)
project = repo.get_project(project_name)
entity = session.get(models.Entity, entity_id)
if entity is None or entity.project_id != project.id:
raise HTTPException(
status_code=404,
detail=f"Entity {entity_id} not found in project '{project_name}'",
)
session.delete(entity)
return {"ok": True, "deleted": entity_id}
@app.post("/projects/{project_name}/claims")
def create_claim(project_name: str, request: CreateClaimRequest):
"""Create a single claim directly (manual input)."""
with session_scope(database_url) as session:
repo = KnowledgeRepository(session)
project = repo.get_project(project_name)
try:
source = repo.get_source(project.id, request.source_name)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
subject = session.get(models.Entity, request.subject_entity_id)
if subject is None or subject.project_id != project.id:
raise HTTPException(
status_code=404,
detail=f"Subject entity {request.subject_entity_id} not found",
)
object_entity: models.Entity | None = None
if request.object_entity_id is not None:
object_entity = session.get(models.Entity, request.object_entity_id)
if object_entity is None or object_entity.project_id != project.id:
raise HTTPException(
status_code=404,
detail=f"Object entity {request.object_entity_id} not found",
)
claim_hash = make_claim_hash(
project_id=project.id,
source_id=source.id,
subject_entity_id=subject.id,
predicate=request.predicate,
object_entity_id=object_entity.id if object_entity else None,
object_value=request.object_value,
)
existing = session.scalar(
select(models.Claim).where(
models.Claim.project_id == project.id,
models.Claim.claim_hash == claim_hash,
)
)
if existing is not None:
existing.confidence = max(existing.confidence, request.confidence)
existing.last_seen_at = models.utcnow()
if request.confidence_reason:
existing.confidence_reason = request.confidence_reason
existing.metadata_json = {
**(existing.metadata_json or {}),
**request.metadata,
"input_method": "manual",
}
claim = existing
else:
claim = models.Claim(
project_id=project.id,
source_id=source.id,
page_id=None,
subject_entity_id=subject.id,
predicate=request.predicate,
object_entity_id=object_entity.id if object_entity else None,
object_value=request.object_value,
value_type="entity" if object_entity else "literal",
claim_hash=claim_hash,
confidence=max(0.0, min(1.0, request.confidence)),
confidence_reason=request.confidence_reason,
extraction_method="manual",
status="validated_claim",
metadata_json={**request.metadata, "input_method": "manual"},
)
session.add(claim)
session.flush()
if request.evidence_text:
session.add(
models.Evidence(
project_id=project.id,
claim_id=claim.id,
page_id=None,
evidence_text=request.evidence_text,
)
)
return {
"id": claim.id,
"subject_entity_id": claim.subject_entity_id,
"predicate": claim.predicate,
"object_entity_id": claim.object_entity_id,
"object_value": claim.object_value,
"confidence": claim.confidence,
"status": claim.status,
}
@app.delete("/projects/{project_name}/claims/{claim_id}")
def delete_claim(project_name: str, claim_id: int):
with session_scope(database_url) as session:
repo = KnowledgeRepository(session)
project = repo.get_project(project_name)
claim = session.get(models.Claim, claim_id)
if claim is None or claim.project_id != project.id:
raise HTTPException(
status_code=404,
detail=f"Claim {claim_id} not found in project '{project_name}'",
)
session.delete(claim)
return {"ok": True, "deleted": claim_id}
@app.get("/projects/{project_name}/claims") @app.get("/projects/{project_name}/claims")
def project_claims( def project_claims(
project_name: str, project_name: str,

View File

@@ -9,6 +9,7 @@
"sources": "Sources", "sources": "Sources",
"crawl": "Crawl", "crawl": "Crawl",
"research": "Research", "research": "Research",
"editor": "Editor",
"review": "Review", "review": "Review",
"toggleSidebar": "Toggle sidebar" "toggleSidebar": "Toggle sidebar"
}, },
@@ -92,6 +93,47 @@
"historyEmpty": "No sessions yet", "historyEmpty": "No sessions yet",
"pages": "pages" "pages": "pages"
}, },
"editor": {
"title": "Ontology Editor",
"entitiesTab": "Entities",
"claimsTab": "Claims",
"bulkTab": "JSON Bulk",
"addEntity": "Add Entity",
"addEntityDesc": "Pick from the domain's entity_types",
"entityType": "Type",
"pickType": "Pick type...",
"entityName": "Name",
"add": "Add",
"entitiesList": "Entities",
"entityCount": "{{count}} total",
"entitiesEmpty": "No entities yet",
"entityAdded": "Entity added: {{name}}",
"entityAddFailed": "Add failed: {{msg}}",
"confirmDeleteEntity": "Delete entity '{{name}}'?",
"addClaim": "Add Claim",
"addClaimDesc": "Subject-Predicate-Object form",
"source": "Source",
"pickSource": "Pick source...",
"subject": "Subject",
"pickSubject": "Pick entity...",
"predicate": "Predicate",
"pickPredicate": "Pick predicate...",
"objectKind": "Object kind",
"objectEntity": "Other entity",
"objectValue": "Literal value",
"pickObject": "Pick entity...",
"confidence": "Confidence",
"claimsList": "Claims",
"claimCount": "{{count}} total",
"claimsEmpty": "No claims yet",
"claimAdded": "Claim added",
"claimAddFailed": "Add failed: {{msg}}",
"confirmDeleteClaim": "Delete this claim?",
"bulkTitle": "JSON Bulk Input",
"bulkDesc": "JSON of shape { entities: [{ entity_type, name, metadata? }] }",
"bulkSubmit": "Bulk Add",
"bulkAdded": "{{count}} entities added"
},
"crawl": { "crawl": {
"title": "Seed Crawl", "title": "Seed Crawl",
"formTitle": "Crawl Settings", "formTitle": "Crawl Settings",

View File

@@ -9,6 +9,7 @@
"sources": "참고 소스", "sources": "참고 소스",
"crawl": "크롤 진행", "crawl": "크롤 진행",
"research": "자율 연구", "research": "자율 연구",
"editor": "온톨로지 편집",
"review": "결과 검토", "review": "결과 검토",
"toggleSidebar": "사이드바 토글" "toggleSidebar": "사이드바 토글"
}, },
@@ -92,6 +93,47 @@
"historyEmpty": "아직 실행된 세션이 없습니다", "historyEmpty": "아직 실행된 세션이 없습니다",
"pages": "페이지" "pages": "페이지"
}, },
"editor": {
"title": "온톨로지 직접 편집",
"entitiesTab": "엔티티",
"claimsTab": "클레임",
"bulkTab": "JSON 일괄 입력",
"addEntity": "엔티티 추가",
"addEntityDesc": "온톨로지 도메인의 entity_types 중에서 선택",
"entityType": "타입",
"pickType": "타입 선택...",
"entityName": "이름",
"add": "추가",
"entitiesList": "엔티티 목록",
"entityCount": "{{count}}개",
"entitiesEmpty": "아직 등록된 엔티티가 없습니다",
"entityAdded": "엔티티가 추가되었습니다: {{name}}",
"entityAddFailed": "추가 실패: {{msg}}",
"confirmDeleteEntity": "엔티티 '{{name}}'을 삭제하시겠습니까?",
"addClaim": "클레임 추가",
"addClaimDesc": "주어-술어-목적어 형태로 직접 입력",
"source": "소스",
"pickSource": "소스 선택...",
"subject": "주어 (Subject)",
"pickSubject": "엔티티 선택...",
"predicate": "술어 (Predicate)",
"pickPredicate": "술어 선택...",
"objectKind": "목적어 유형",
"objectEntity": "다른 엔티티",
"objectValue": "리터럴 값",
"pickObject": "엔티티 선택...",
"confidence": "신뢰도",
"claimsList": "클레임 목록",
"claimCount": "{{count}}개",
"claimsEmpty": "아직 등록된 클레임이 없습니다",
"claimAdded": "클레임이 추가되었습니다",
"claimAddFailed": "추가 실패: {{msg}}",
"confirmDeleteClaim": "클레임을 삭제하시겠습니까?",
"bulkTitle": "JSON 일괄 입력",
"bulkDesc": "{ entities: [{ entity_type, name, metadata? }] } 형태의 JSON",
"bulkSubmit": "일괄 추가",
"bulkAdded": "{{count}}개 엔티티가 추가되었습니다"
},
"crawl": { "crawl": {
"title": "시드 크롤", "title": "시드 크롤",
"formTitle": "크롤 설정", "formTitle": "크롤 설정",

View File

@@ -4,6 +4,7 @@ import OnboardingPage from "@/pages/OnboardingPage";
import ConfigureSourcesPage from "@/pages/ConfigureSourcesPage"; import ConfigureSourcesPage from "@/pages/ConfigureSourcesPage";
import CrawlPage from "@/pages/CrawlPage"; import CrawlPage from "@/pages/CrawlPage";
import ResearchPage from "@/pages/ResearchPage"; import ResearchPage from "@/pages/ResearchPage";
import OntologyEditorPage from "@/pages/OntologyEditorPage";
import ReviewPage from "@/pages/ReviewPage"; import ReviewPage from "@/pages/ReviewPage";
import DashboardPage from "@/pages/DashboardPage"; import DashboardPage from "@/pages/DashboardPage";
@@ -16,6 +17,7 @@ function App() {
<Route path="/sources/:projectId" element={<ConfigureSourcesPage />} /> <Route path="/sources/:projectId" element={<ConfigureSourcesPage />} />
<Route path="/crawl/:projectId" element={<CrawlPage />} /> <Route path="/crawl/:projectId" element={<CrawlPage />} />
<Route path="/research/:projectId" element={<ResearchPage />} /> <Route path="/research/:projectId" element={<ResearchPage />} />
<Route path="/editor/:projectId" element={<OntologyEditorPage />} />
<Route path="/review/:projectId" element={<ReviewPage />} /> <Route path="/review/:projectId" element={<ReviewPage />} />
</Route> </Route>
</Routes> </Routes>

View File

@@ -1,7 +1,7 @@
import { NavLink, Outlet } from "react-router-dom"; import { NavLink, Outlet } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useSelector, useDispatch } from "react-redux"; import { useSelector, useDispatch } from "react-redux";
import { LayoutDashboard, UploadCloud, Settings2, Activity, Brain, ListChecks, Menu } from "lucide-react"; import { LayoutDashboard, UploadCloud, Settings2, Activity, Brain, Network, ListChecks, Menu } from "lucide-react";
import { RootState } from "@/stores"; import { RootState } from "@/stores";
import { toggleSidebar } from "@/stores/slices/uiSlice"; import { toggleSidebar } from "@/stores/slices/uiSlice";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -19,6 +19,7 @@ const navItems: NavItem[] = [
{ to: "/sources/demo-project", labelKey: "nav.sources", icon: Settings2 }, { to: "/sources/demo-project", labelKey: "nav.sources", icon: Settings2 },
{ to: "/crawl/demo-project", labelKey: "nav.crawl", icon: Activity }, { to: "/crawl/demo-project", labelKey: "nav.crawl", icon: Activity },
{ to: "/research/demo-project", labelKey: "nav.research", icon: Brain }, { to: "/research/demo-project", labelKey: "nav.research", icon: Brain },
{ to: "/editor/demo-project", labelKey: "nav.editor", icon: Network },
{ to: "/review/demo-project", labelKey: "nav.review", icon: ListChecks }, { to: "/review/demo-project", labelKey: "nav.review", icon: ListChecks },
]; ];

View File

@@ -0,0 +1,98 @@
import * as React from "react";
import { cn } from "@/lib/utils";
interface TabsContextValue {
value: string;
onChange: (value: string) => void;
}
const TabsContext = React.createContext<TabsContextValue | null>(null);
function useTabs() {
const ctx = React.useContext(TabsContext);
if (!ctx) throw new Error("Tabs primitives must be used within <Tabs>");
return ctx;
}
interface TabsProps {
value: string;
onValueChange: (value: string) => void;
className?: string;
children: React.ReactNode;
}
export function Tabs({ value, onValueChange, className, children }: TabsProps) {
return (
<TabsContext.Provider value={{ value, onChange: onValueChange }}>
<div className={cn("flex flex-col gap-3", className)}>{children}</div>
</TabsContext.Provider>
);
}
export function TabsList({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
return (
<div
role="tablist"
className={cn(
"inline-flex h-10 items-center justify-start gap-1 rounded-md bg-muted p-1 text-muted-foreground",
className,
)}
>
{children}
</div>
);
}
export function TabsTrigger({
value,
children,
className,
}: {
value: string;
children: React.ReactNode;
className?: string;
}) {
const ctx = useTabs();
const active = ctx.value === value;
return (
<button
role="tab"
type="button"
aria-selected={active}
onClick={() => ctx.onChange(value)}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
active
? "bg-background text-foreground shadow-sm"
: "hover:text-foreground",
className,
)}
>
{children}
</button>
);
}
export function TabsContent({
value,
children,
className,
}: {
value: string;
children: React.ReactNode;
className?: string;
}) {
const ctx = useTabs();
if (ctx.value !== value) return null;
return (
<div role="tabpanel" className={cn("focus-visible:outline-none", className)}>
{children}
</div>
);
}

View File

@@ -31,4 +31,19 @@ export const queryKeys = {
session: (jobId: string) => session: (jobId: string) =>
[...queryKeys.research.all, "session", jobId] as const, [...queryKeys.research.all, "session", jobId] as const,
}, },
entities: {
all: ["entities"] as const,
list: (projectName: string, entityType?: string) =>
[
...queryKeys.entities.all,
"list",
projectName,
entityType ?? "*",
] as const,
},
claims: {
all: ["claims"] as const,
list: (projectName: string, status?: string) =>
[...queryKeys.claims.all, "list", projectName, status ?? "*"] as const,
},
}; };

View File

@@ -0,0 +1,44 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Claim, CreateClaimRequest, claimsApi } from "@/lib/api/claims";
import { queryKeys } from "./queryKeys";
export function useClaims(
projectName: string,
options: { status?: string; includeCandidates?: boolean } = {},
) {
return useQuery<Claim[]>({
queryKey: queryKeys.claims.list(projectName, options.status),
queryFn: () =>
claimsApi.list(projectName, {
status: options.status,
includeCandidates: options.includeCandidates,
}),
enabled: Boolean(projectName),
});
}
export function useCreateClaim(projectName: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: CreateClaimRequest) =>
claimsApi.create(projectName, body),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.claims.all,
});
},
});
}
export function useDeleteClaim(projectName: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (claimId: string | number) =>
claimsApi.delete(projectName, claimId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.claims.all,
});
},
});
}

View File

@@ -0,0 +1,58 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
BulkCreateEntitiesRequest,
CreateEntityRequest,
entitiesApi,
Entity,
} from "@/lib/api/entities";
import { queryKeys } from "./queryKeys";
export function useEntities(projectName: string, entityType?: string) {
return useQuery<Entity[]>({
queryKey: queryKeys.entities.list(projectName, entityType),
queryFn: () => entitiesApi.list(projectName, entityType),
enabled: Boolean(projectName),
});
}
export function useCreateEntity(projectName: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: CreateEntityRequest) =>
entitiesApi.create(projectName, body),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.entities.all,
});
},
});
}
export function useBulkCreateEntities(projectName: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: BulkCreateEntitiesRequest) =>
entitiesApi.bulkCreate(projectName, body),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.entities.all,
});
},
});
}
export function useDeleteEntity(projectName: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (entityId: string | number) =>
entitiesApi.delete(projectName, entityId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: queryKeys.entities.all,
});
queryClient.invalidateQueries({
queryKey: queryKeys.claims.all,
});
},
});
}

View File

@@ -0,0 +1,86 @@
import { z } from "zod";
import { apiClient } from "./client";
import { deleteResponseSchema } from "./entities";
const idLike = z.union([z.string(), z.number()]).transform(String);
export const claimSchema = z
.object({
id: idLike,
subject: z.string().optional(),
subject_type: z.string().optional(),
predicate: z.string(),
object: z.string().nullable().optional(),
object_value: z.unknown().nullable().optional(),
source: z.string().nullable().optional(),
page_url: z.string().nullable().optional(),
confidence: z.number().optional(),
confidence_reason: z.string().nullable().optional(),
status: z.string().optional(),
evidence_text: z.string().nullable().optional(),
evidence_summary: z.string().nullable().optional(),
last_seen_at: z.string().optional(),
})
.passthrough();
export const claimListSchema = z.array(claimSchema);
export const createClaimResponseSchema = z.object({
id: idLike,
subject_entity_id: z.union([z.string(), z.number()]).transform(String),
predicate: z.string(),
object_entity_id: z
.union([z.string(), z.number(), z.null()])
.nullable()
.optional(),
object_value: z.unknown().nullable().optional(),
confidence: z.number(),
status: z.string(),
});
export type Claim = z.infer<typeof claimSchema>;
export interface CreateClaimRequest {
source_name: string;
subject_entity_id: number;
predicate: string;
object_entity_id?: number | null;
object_value?: unknown;
confidence?: number;
confidence_reason?: string;
evidence_text?: string;
metadata?: Record<string, unknown>;
}
export const claimsApi = {
list: (
projectName: string,
options: {
limit?: number;
includeCandidates?: boolean;
status?: string;
} = {},
) =>
apiClient.get(
`/projects/${encodeURIComponent(projectName)}/claims`,
claimListSchema,
{
query: {
limit: options.limit ?? 100,
include_candidates: options.includeCandidates,
status: options.status,
},
},
),
create: (projectName: string, body: CreateClaimRequest) =>
apiClient.post(
`/projects/${encodeURIComponent(projectName)}/claims`,
createClaimResponseSchema,
body,
),
delete: (projectName: string, claimId: string | number) =>
apiClient.delete(
`/projects/${encodeURIComponent(projectName)}/claims/${claimId}`,
deleteResponseSchema,
),
};

View File

@@ -0,0 +1,72 @@
import { z } from "zod";
import { apiClient } from "./client";
const idLike = z.union([z.string(), z.number()]).transform(String);
export const entitySchema = z.object({
id: idLike,
type: z.string(),
name: z.string(),
metadata: z.record(z.string(), z.unknown()).default({}),
});
export const entityListSchema = z.array(entitySchema);
export const bulkCreateEntitiesResponseSchema = z.object({
created: z.number(),
entities: z.array(
z.object({
id: idLike,
type: z.string(),
name: z.string(),
}),
),
});
export const deleteResponseSchema = z.object({
ok: z.boolean(),
deleted: z.union([z.string(), z.number()]),
});
export type Entity = z.infer<typeof entitySchema>;
export interface CreateEntityRequest {
entity_type: string;
name: string;
metadata?: Record<string, unknown>;
}
export interface BulkCreateEntitiesRequest {
entities: CreateEntityRequest[];
}
export const entitiesApi = {
list: (projectName: string, entityType?: string, limit = 200) =>
apiClient.get(
`/projects/${encodeURIComponent(projectName)}/entities`,
entityListSchema,
{
query: {
limit,
...(entityType ? { entity_type: entityType } : {}),
},
},
),
create: (projectName: string, body: CreateEntityRequest) =>
apiClient.post(
`/projects/${encodeURIComponent(projectName)}/entities`,
entitySchema,
body,
),
bulkCreate: (projectName: string, body: BulkCreateEntitiesRequest) =>
apiClient.post(
`/projects/${encodeURIComponent(projectName)}/entities/bulk`,
bulkCreateEntitiesResponseSchema,
body,
),
delete: (projectName: string, entityId: string | number) =>
apiClient.delete(
`/projects/${encodeURIComponent(projectName)}/entities/${entityId}`,
deleteResponseSchema,
),
};

View File

@@ -5,3 +5,5 @@ export * from "./ontology";
export * from "./sources"; export * from "./sources";
export * from "./crawl"; export * from "./crawl";
export * from "./research"; export * from "./research";
export * from "./entities";
export * from "./claims";

View File

@@ -0,0 +1,688 @@
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { toast } from "sonner";
import {
AlertCircle,
ArrowLeft,
FileJson,
Link2,
Loader2,
Network,
Plus,
Trash2,
} from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useProject } from "@/hooks/useProjects";
import { useOntology } from "@/hooks/useDomains";
import {
useBulkCreateEntities,
useCreateEntity,
useDeleteEntity,
useEntities,
} from "@/hooks/useEntities";
import {
useClaims,
useCreateClaim,
useDeleteClaim,
} from "@/hooks/useClaims";
const entitySchema = z.object({
entity_type: z.string().min(1, "타입을 선택하세요"),
name: z.string().min(1, "이름을 입력하세요").max(240),
});
type EntityFormValues = z.infer<typeof entitySchema>;
const claimSchema = z.object({
source_name: z.string().min(1, "소스를 선택하세요"),
subject_entity_id: z.number().int().min(1, "주어 엔티티 선택"),
predicate: z.string().min(1, "술어를 선택하세요"),
object_kind: z.enum(["entity", "value"]),
object_entity_id: z.number().int().nullable().optional(),
object_value: z.string().optional(),
confidence: z.number().min(0).max(1),
});
type ClaimFormValues = z.infer<typeof claimSchema>;
export default function OntologyEditorPage() {
const navigate = useNavigate();
const { projectId } = useParams<{ projectId: string }>();
const { t } = useTranslation();
const projectName = projectId ?? "";
const [tab, setTab] = useState<"entities" | "claims" | "bulk">("entities");
const { data: project, isLoading: projectLoading, isError, error, refetch } =
useProject(projectName);
const { data: ontology } = useOntology(project?.domain);
const entities = useEntities(projectName);
const claims = useClaims(projectName, { includeCandidates: true });
const createEntity = useCreateEntity(projectName);
const bulkCreate = useBulkCreateEntities(projectName);
const deleteEntity = useDeleteEntity(projectName);
const createClaim = useCreateClaim(projectName);
const deleteClaim = useDeleteClaim(projectName);
const sources = project?.sources ?? [];
const entityTypeOptions = useMemo(() => {
if (ontology?.entity_types?.length) return ontology.entity_types;
return ["Entity", "Concept", "Attribute"];
}, [ontology]);
const predicateOptions = useMemo(() => {
if (ontology?.predicates?.length) return ontology.predicates;
return ["hasAttribute", "relatedTo", "sameAs"];
}, [ontology]);
// ── Entity form ───────────────────────────────────────────────
const entityForm = useForm<EntityFormValues>({
resolver: zodResolver(entitySchema),
defaultValues: { entity_type: "", name: "" },
});
const onCreateEntity = async (values: EntityFormValues) => {
try {
await createEntity.mutateAsync({
entity_type: values.entity_type,
name: values.name,
});
toast.success(
t("editor.entityAdded", "엔티티가 추가되었습니다: {{name}}", {
name: values.name,
}),
);
entityForm.reset({ entity_type: values.entity_type, name: "" });
} catch (e) {
toast.error(
t("editor.entityAddFailed", "추가 실패: {{msg}}", {
msg: (e as Error).message,
}),
);
}
};
// ── Claim form ────────────────────────────────────────────────
const claimForm = useForm<ClaimFormValues>({
resolver: zodResolver(claimSchema),
defaultValues: {
source_name: "",
subject_entity_id: 0,
predicate: "",
object_kind: "entity",
object_entity_id: null,
object_value: "",
confidence: 1.0,
},
});
const objectKind = claimForm.watch("object_kind");
const onCreateClaim = async (values: ClaimFormValues) => {
try {
await createClaim.mutateAsync({
source_name: values.source_name,
subject_entity_id: values.subject_entity_id,
predicate: values.predicate,
object_entity_id:
values.object_kind === "entity" ? values.object_entity_id : null,
object_value:
values.object_kind === "value" ? values.object_value : undefined,
confidence: values.confidence,
});
toast.success(t("editor.claimAdded", "클레임이 추가되었습니다"));
claimForm.reset({
...claimForm.getValues(),
object_entity_id: null,
object_value: "",
});
} catch (e) {
toast.error(
t("editor.claimAddFailed", "추가 실패: {{msg}}", {
msg: (e as Error).message,
}),
);
}
};
// ── Bulk JSON form ────────────────────────────────────────────
const [bulkText, setBulkText] = useState(
JSON.stringify(
{
entities: [
{ entity_type: "Entity", name: "Example A" },
{ entity_type: "Entity", name: "Example B" },
],
},
null,
2,
),
);
const [bulkError, setBulkError] = useState<string | null>(null);
const onBulkSubmit = async () => {
setBulkError(null);
try {
const parsed = JSON.parse(bulkText);
const list = Array.isArray(parsed.entities)
? parsed.entities
: Array.isArray(parsed)
? parsed
: null;
if (!list || list.length === 0) {
throw new Error("최상위에 entities 배열이 있어야 합니다");
}
const normalized = list.map((item: Record<string, unknown>, idx: number) => {
if (
typeof item !== "object" ||
item === null ||
typeof item.entity_type !== "string" ||
typeof item.name !== "string"
) {
throw new Error(
`항목 [${idx}]에 entity_type/name 문자열이 필요합니다`,
);
}
return {
entity_type: item.entity_type,
name: item.name,
metadata:
typeof item.metadata === "object" && item.metadata !== null
? (item.metadata as Record<string, unknown>)
: {},
};
});
const res = await bulkCreate.mutateAsync({ entities: normalized });
toast.success(
t("editor.bulkAdded", "{{count}}개 엔티티가 추가되었습니다", {
count: res.created,
}),
);
} catch (e) {
setBulkError((e as Error).message);
}
};
// ── Render ────────────────────────────────────────────────────
return (
<div className="mx-auto max-w-7xl px-6 py-10">
<div className="mb-6 flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/sources/${projectName}`)}
aria-label={t("common.back", "이전")}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex-1">
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Network className="h-6 w-6 text-primary" />
{t("editor.title", "온톨로지 직접 편집")}
</h1>
{project && (
<p className="text-sm text-muted-foreground">
{project.name}
<span className="capitalize text-muted-foreground/70">
{" "}
· {project.domain}
</span>
</p>
)}
</div>
</div>
{isError && (
<Card className="mb-6 border-destructive">
<CardContent className="flex items-center justify-between gap-3 py-4">
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
<span>{(error as Error).message}</span>
</div>
<Button
variant="outline"
size="sm"
type="button"
onClick={() => refetch()}
>
{t("common.retry", "다시 시도")}
</Button>
</CardContent>
</Card>
)}
<Tabs value={tab} onValueChange={(v) => setTab(v as typeof tab)}>
<TabsList>
<TabsTrigger value="entities">
{t("editor.entitiesTab", "엔티티")} ({entities.data?.length ?? 0})
</TabsTrigger>
<TabsTrigger value="claims">
<Link2 className="mr-1 h-4 w-4" />
{t("editor.claimsTab", "클레임")} ({claims.data?.length ?? 0})
</TabsTrigger>
<TabsTrigger value="bulk">
<FileJson className="mr-1 h-4 w-4" />
{t("editor.bulkTab", "JSON 일괄 입력")}
</TabsTrigger>
</TabsList>
{/* ── Entities Tab ─────────────────────────────────────── */}
<TabsContent value="entities">
<div className="grid gap-6 lg:grid-cols-[380px_1fr]">
<Card>
<CardHeader>
<CardTitle>{t("editor.addEntity", "엔티티 추가")}</CardTitle>
<CardDescription>
{t(
"editor.addEntityDesc",
"온톨로지 도메인의 entity_types 중에서 선택",
)}
</CardDescription>
</CardHeader>
<CardContent>
<form
onSubmit={entityForm.handleSubmit(onCreateEntity)}
className="space-y-3"
noValidate
>
<div className="space-y-1.5">
<Label>{t("editor.entityType", "타입")}</Label>
<Select {...entityForm.register("entity_type")}>
<option value="">
{t("editor.pickType", "타입 선택...")}
</option>
{entityTypeOptions.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</Select>
{entityForm.formState.errors.entity_type && (
<p className="text-xs text-destructive">
{entityForm.formState.errors.entity_type.message}
</p>
)}
</div>
<div className="space-y-1.5">
<Label>{t("editor.entityName", "이름")}</Label>
<Input
placeholder="Chanel No.5"
{...entityForm.register("name")}
/>
{entityForm.formState.errors.name && (
<p className="text-xs text-destructive">
{entityForm.formState.errors.name.message}
</p>
)}
</div>
<Button
type="submit"
className="w-full"
disabled={createEntity.isPending}
>
{createEntity.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
{t("editor.add", "추가")}
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>
{t("editor.entitiesList", "엔티티 목록")}
</CardTitle>
<CardDescription>
{entities.data
? t("editor.entityCount", "{{count}}개", {
count: entities.data.length,
})
: t("dashboard.loadFailed", "")}
</CardDescription>
</CardHeader>
<CardContent>
{entities.isLoading && (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10" />
))}
</div>
)}
{entities.data && entities.data.length === 0 && (
<p className="py-6 text-center text-sm text-muted-foreground">
{t("editor.entitiesEmpty", "아직 등록된 엔티티가 없습니다")}
</p>
)}
{entities.data && entities.data.length > 0 && (
<ul className="max-h-[600px] divide-y overflow-y-auto">
{entities.data.map((e) => (
<li
key={e.id}
className="flex items-center justify-between gap-3 py-2 text-sm"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Badge variant="outline">{e.type}</Badge>
<span className="truncate font-medium">
{e.name}
</span>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => {
if (
confirm(
t(
"editor.confirmDeleteEntity",
"엔티티 '{{name}}'을 삭제하시겠습니까?",
{ name: e.name },
),
)
) {
deleteEntity.mutate(e.id);
}
}}
disabled={deleteEntity.isPending}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* ── Claims Tab ───────────────────────────────────────── */}
<TabsContent value="claims">
<div className="grid gap-6 lg:grid-cols-[420px_1fr]">
<Card>
<CardHeader>
<CardTitle>{t("editor.addClaim", "클레임 추가")}</CardTitle>
<CardDescription>
{t(
"editor.addClaimDesc",
"주어-술어-목적어 형태로 직접 입력",
)}
</CardDescription>
</CardHeader>
<CardContent>
<form
onSubmit={claimForm.handleSubmit(onCreateClaim)}
className="space-y-3"
noValidate
>
<div className="space-y-1.5">
<Label>{t("editor.source", "소스")}</Label>
<Select {...claimForm.register("source_name")}>
<option value="">
{t("editor.pickSource", "소스 선택...")}
</option>
{sources.map((s) => (
<option key={s.id} value={s.name}>
{s.name}
</option>
))}
</Select>
{claimForm.formState.errors.source_name && (
<p className="text-xs text-destructive">
{claimForm.formState.errors.source_name.message}
</p>
)}
</div>
<div className="space-y-1.5">
<Label>{t("editor.subject", "주어 (Subject)")}</Label>
<Select
{...claimForm.register("subject_entity_id", {
valueAsNumber: true,
})}
>
<option value={0}>
{t("editor.pickSubject", "엔티티 선택...")}
</option>
{entities.data?.map((e) => (
<option key={e.id} value={e.id}>
[{e.type}] {e.name}
</option>
))}
</Select>
{claimForm.formState.errors.subject_entity_id && (
<p className="text-xs text-destructive">
{claimForm.formState.errors.subject_entity_id.message}
</p>
)}
</div>
<div className="space-y-1.5">
<Label>{t("editor.predicate", "술어 (Predicate)")}</Label>
<Select {...claimForm.register("predicate")}>
<option value="">
{t("editor.pickPredicate", "술어 선택...")}
</option>
{predicateOptions.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</Select>
</div>
<div className="space-y-1.5">
<Label>{t("editor.objectKind", "목적어 유형")}</Label>
<Select {...claimForm.register("object_kind")}>
<option value="entity">
{t("editor.objectEntity", "다른 엔티티")}
</option>
<option value="value">
{t("editor.objectValue", "리터럴 값")}
</option>
</Select>
</div>
{objectKind === "entity" ? (
<div className="space-y-1.5">
<Label>{t("editor.objectEntity", "목적 엔티티")}</Label>
<Select
{...claimForm.register("object_entity_id", {
valueAsNumber: true,
setValueAs: (v) =>
v === "" || v === null || v === undefined
? null
: Number(v),
})}
>
<option value="">
{t("editor.pickObject", "엔티티 선택...")}
</option>
{entities.data?.map((e) => (
<option key={e.id} value={e.id}>
[{e.type}] {e.name}
</option>
))}
</Select>
</div>
) : (
<div className="space-y-1.5">
<Label>{t("editor.objectValue", "값")}</Label>
<Input
placeholder="2024-01-15 또는 임의 문자열"
{...claimForm.register("object_value")}
/>
</div>
)}
<div className="space-y-1.5">
<Label>{t("editor.confidence", "신뢰도")}</Label>
<Input
type="number"
step="0.05"
min={0}
max={1}
{...claimForm.register("confidence", {
valueAsNumber: true,
})}
/>
</div>
<Button
type="submit"
className="w-full"
disabled={createClaim.isPending}
>
{createClaim.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
{t("editor.add", "추가")}
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{t("editor.claimsList", "클레임 목록")}</CardTitle>
<CardDescription>
{claims.data &&
t("editor.claimCount", "{{count}}개", {
count: claims.data.length,
})}
</CardDescription>
</CardHeader>
<CardContent>
{claims.isLoading && (
<div className="space-y-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-12" />
))}
</div>
)}
{claims.data && claims.data.length === 0 && (
<p className="py-6 text-center text-sm text-muted-foreground">
{t("editor.claimsEmpty", "아직 등록된 클레임이 없습니다")}
</p>
)}
{claims.data && claims.data.length > 0 && (
<ul className="max-h-[600px] divide-y overflow-y-auto">
{claims.data.map((c) => (
<li
key={c.id}
className="flex items-start justify-between gap-3 py-3 text-sm"
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="font-medium">
{c.subject ?? "?"}
</span>
<Badge variant="secondary">{c.predicate}</Badge>
<span className="font-medium">
{c.object ?? String(c.object_value ?? "—")}
</span>
</div>
<div className="mt-1 flex flex-wrap gap-2 text-xs text-muted-foreground">
<span>
{t("editor.source", "소스")}: {c.source ?? "—"}
</span>
{typeof c.confidence === "number" && (
<span>
{t("editor.confidence", "신뢰도")}:{" "}
{c.confidence.toFixed(2)}
</span>
)}
{c.status && (
<Badge variant="outline">{c.status}</Badge>
)}
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => {
if (
confirm(
t(
"editor.confirmDeleteClaim",
"클레임을 삭제하시겠습니까?",
),
)
) {
deleteClaim.mutate(c.id);
}
}}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* ── Bulk Tab ─────────────────────────────────────────── */}
<TabsContent value="bulk">
<Card>
<CardHeader>
<CardTitle>{t("editor.bulkTitle", "JSON 일괄 입력")}</CardTitle>
<CardDescription>
{t(
"editor.bulkDesc",
"{ entities: [{ entity_type, name, metadata? }] } 형태의 JSON",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Textarea
value={bulkText}
onChange={(e) => setBulkText(e.target.value)}
rows={12}
className="font-mono text-xs"
/>
{bulkError && (
<p className="text-sm text-destructive">{bulkError}</p>
)}
<Button
onClick={onBulkSubmit}
disabled={bulkCreate.isPending || projectLoading}
>
{bulkCreate.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<FileJson className="h-4 w-4" />
)}
{t("editor.bulkSubmit", "일괄 추가")}
</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}