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:
@@ -22,19 +22,19 @@
|
||||
| Phase 1.1 | 프로젝트 생성 (OnboardingPage 폼 + 백엔드 `POST /projects/inline`, `GET /domains`) | `8681ac8` |
|
||||
| Phase 1.2 | 참고 소스 관리 (ConfigureSourcesPage CRUD + 백엔드 `POST/DELETE /projects/{name}/sources`) | `461ebc0` |
|
||||
| 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 1.5 | 엔티티/클레임 직접 입력 (`POST /projects/{n}/entities`, `/claims` 백엔드 추가 필요) | 독립 가능 |
|
||||
| Phase 2 | 그래프 시각화/편집 (Cytoscape 또는 react-flow 래퍼) | 데이터 일부 있어야 의미있음 |
|
||||
| Phase 3 | JSON Import/Export (대량 데이터 직접 입력) | 독립 가능 |
|
||||
| **Phase 2** | 그래프 시각화/편집 (Cytoscape 또는 react-flow 래퍼) | `GET /projects/{n}/graph/neighborhood` + `legacy/graph.js` 패턴 참고 |
|
||||
| Phase 3 | JSON Import/Export 전용 페이지 (Editor의 일괄 입력 탭 확장) | 독립 가능 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.site_crawler import SiteCrawler
|
||||
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.extractor.ai_provider import list_openai_compatible_models
|
||||
from crawler_platform.app.core.extractor.factory import extractor_for_domain
|
||||
@@ -148,6 +151,28 @@ class ResetProjectRequest(BaseModel):
|
||||
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):
|
||||
"""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
|
||||
]
|
||||
|
||||
@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")
|
||||
def project_claims(
|
||||
project_name: str,
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"sources": "Sources",
|
||||
"crawl": "Crawl",
|
||||
"research": "Research",
|
||||
"editor": "Editor",
|
||||
"review": "Review",
|
||||
"toggleSidebar": "Toggle sidebar"
|
||||
},
|
||||
@@ -92,6 +93,47 @@
|
||||
"historyEmpty": "No sessions yet",
|
||||
"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": {
|
||||
"title": "Seed Crawl",
|
||||
"formTitle": "Crawl Settings",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"sources": "참고 소스",
|
||||
"crawl": "크롤 진행",
|
||||
"research": "자율 연구",
|
||||
"editor": "온톨로지 편집",
|
||||
"review": "결과 검토",
|
||||
"toggleSidebar": "사이드바 토글"
|
||||
},
|
||||
@@ -92,6 +93,47 @@
|
||||
"historyEmpty": "아직 실행된 세션이 없습니다",
|
||||
"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": {
|
||||
"title": "시드 크롤",
|
||||
"formTitle": "크롤 설정",
|
||||
|
||||
@@ -4,6 +4,7 @@ import OnboardingPage from "@/pages/OnboardingPage";
|
||||
import ConfigureSourcesPage from "@/pages/ConfigureSourcesPage";
|
||||
import CrawlPage from "@/pages/CrawlPage";
|
||||
import ResearchPage from "@/pages/ResearchPage";
|
||||
import OntologyEditorPage from "@/pages/OntologyEditorPage";
|
||||
import ReviewPage from "@/pages/ReviewPage";
|
||||
import DashboardPage from "@/pages/DashboardPage";
|
||||
|
||||
@@ -16,6 +17,7 @@ function App() {
|
||||
<Route path="/sources/:projectId" element={<ConfigureSourcesPage />} />
|
||||
<Route path="/crawl/:projectId" element={<CrawlPage />} />
|
||||
<Route path="/research/:projectId" element={<ResearchPage />} />
|
||||
<Route path="/editor/:projectId" element={<OntologyEditorPage />} />
|
||||
<Route path="/review/:projectId" element={<ReviewPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { toggleSidebar } from "@/stores/slices/uiSlice";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -19,6 +19,7 @@ const navItems: NavItem[] = [
|
||||
{ to: "/sources/demo-project", labelKey: "nav.sources", icon: Settings2 },
|
||||
{ to: "/crawl/demo-project", labelKey: "nav.crawl", icon: Activity },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
|
||||
98
crawler_platform/app/web/frontend/src/components/ui/tabs.tsx
Normal file
98
crawler_platform/app/web/frontend/src/components/ui/tabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -31,4 +31,19 @@ export const queryKeys = {
|
||||
session: (jobId: string) =>
|
||||
[...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,
|
||||
},
|
||||
};
|
||||
|
||||
44
crawler_platform/app/web/frontend/src/hooks/useClaims.ts
Normal file
44
crawler_platform/app/web/frontend/src/hooks/useClaims.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
58
crawler_platform/app/web/frontend/src/hooks/useEntities.ts
Normal file
58
crawler_platform/app/web/frontend/src/hooks/useEntities.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
86
crawler_platform/app/web/frontend/src/lib/api/claims.ts
Normal file
86
crawler_platform/app/web/frontend/src/lib/api/claims.ts
Normal 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,
|
||||
),
|
||||
};
|
||||
72
crawler_platform/app/web/frontend/src/lib/api/entities.ts
Normal file
72
crawler_platform/app/web/frontend/src/lib/api/entities.ts
Normal 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,
|
||||
),
|
||||
};
|
||||
@@ -5,3 +5,5 @@ export * from "./ontology";
|
||||
export * from "./sources";
|
||||
export * from "./crawl";
|
||||
export * from "./research";
|
||||
export * from "./entities";
|
||||
export * from "./claims";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user