From 39097d02407acf58b714cbf120e1e56e960fa445 Mon Sep 17 00:00:00 2001 From: lasta Date: Thu, 14 May 2026 19:10:37 +0900 Subject: [PATCH] =?UTF-8?q?Phase=201.5:=20=EC=97=94=ED=8B=B0=ED=8B=B0/?= =?UTF-8?q?=ED=81=B4=EB=A0=88=EC=9E=84=20=EC=A7=81=EC=A0=91=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=E2=80=94=20OntologyEditorPage=20+=203=ED=83=AD=20(?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0/=ED=81=B4=EB=A0=88=EC=9E=84/JSON=20?= =?UTF-8?q?=EC=9D=BC=EA=B4=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 백엔드 (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 --- UI_REBUILD_PLAN.md | 10 +- crawler_platform/app/api/routes.py | 192 ++++- .../frontend/public/locales/en/common.json | 42 ++ .../frontend/public/locales/ko/common.json | 42 ++ crawler_platform/app/web/frontend/src/App.tsx | 2 + .../src/components/layout/AppShell.tsx | 3 +- .../web/frontend/src/components/ui/tabs.tsx | 98 +++ .../app/web/frontend/src/hooks/queryKeys.ts | 15 + .../app/web/frontend/src/hooks/useClaims.ts | 44 ++ .../app/web/frontend/src/hooks/useEntities.ts | 58 ++ .../app/web/frontend/src/lib/api/claims.ts | 86 +++ .../app/web/frontend/src/lib/api/entities.ts | 72 ++ .../app/web/frontend/src/lib/api/index.ts | 2 + .../frontend/src/pages/OntologyEditorPage.tsx | 688 ++++++++++++++++++ 14 files changed, 1347 insertions(+), 7 deletions(-) create mode 100644 crawler_platform/app/web/frontend/src/components/ui/tabs.tsx create mode 100644 crawler_platform/app/web/frontend/src/hooks/useClaims.ts create mode 100644 crawler_platform/app/web/frontend/src/hooks/useEntities.ts create mode 100644 crawler_platform/app/web/frontend/src/lib/api/claims.ts create mode 100644 crawler_platform/app/web/frontend/src/lib/api/entities.ts create mode 100644 crawler_platform/app/web/frontend/src/pages/OntologyEditorPage.tsx diff --git a/UI_REBUILD_PLAN.md b/UI_REBUILD_PLAN.md index 0cf239e..7619423 100644 --- a/UI_REBUILD_PLAN.md +++ b/UI_REBUILD_PLAN.md @@ -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의 일괄 입력 탭 확장) | 독립 가능 | --- diff --git a/crawler_platform/app/api/routes.py b/crawler_platform/app/api/routes.py index 86b6b67..4cbc112 100644 --- a/crawler_platform/app/api/routes.py +++ b/crawler_platform/app/api/routes.py @@ -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, diff --git a/crawler_platform/app/web/frontend/public/locales/en/common.json b/crawler_platform/app/web/frontend/public/locales/en/common.json index dda9a6b..7e7fa28 100644 --- a/crawler_platform/app/web/frontend/public/locales/en/common.json +++ b/crawler_platform/app/web/frontend/public/locales/en/common.json @@ -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", diff --git a/crawler_platform/app/web/frontend/public/locales/ko/common.json b/crawler_platform/app/web/frontend/public/locales/ko/common.json index 7cbdb19..5e698b7 100644 --- a/crawler_platform/app/web/frontend/public/locales/ko/common.json +++ b/crawler_platform/app/web/frontend/public/locales/ko/common.json @@ -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": "크롤 설정", diff --git a/crawler_platform/app/web/frontend/src/App.tsx b/crawler_platform/app/web/frontend/src/App.tsx index 14a71c0..da7afcd 100644 --- a/crawler_platform/app/web/frontend/src/App.tsx +++ b/crawler_platform/app/web/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/crawler_platform/app/web/frontend/src/components/layout/AppShell.tsx b/crawler_platform/app/web/frontend/src/components/layout/AppShell.tsx index 54dee3e..98182e9 100644 --- a/crawler_platform/app/web/frontend/src/components/layout/AppShell.tsx +++ b/crawler_platform/app/web/frontend/src/components/layout/AppShell.tsx @@ -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 }, ]; diff --git a/crawler_platform/app/web/frontend/src/components/ui/tabs.tsx b/crawler_platform/app/web/frontend/src/components/ui/tabs.tsx new file mode 100644 index 0000000..0521a67 --- /dev/null +++ b/crawler_platform/app/web/frontend/src/components/ui/tabs.tsx @@ -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(null); + +function useTabs() { + const ctx = React.useContext(TabsContext); + if (!ctx) throw new Error("Tabs primitives must be used within "); + return ctx; +} + +interface TabsProps { + value: string; + onValueChange: (value: string) => void; + className?: string; + children: React.ReactNode; +} + +export function Tabs({ value, onValueChange, className, children }: TabsProps) { + return ( + +
{children}
+
+ ); +} + +export function TabsList({ + className, + children, +}: { + className?: string; + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +export function TabsTrigger({ + value, + children, + className, +}: { + value: string; + children: React.ReactNode; + className?: string; +}) { + const ctx = useTabs(); + const active = ctx.value === value; + return ( + + ); +} + +export function TabsContent({ + value, + children, + className, +}: { + value: string; + children: React.ReactNode; + className?: string; +}) { + const ctx = useTabs(); + if (ctx.value !== value) return null; + return ( +
+ {children} +
+ ); +} diff --git a/crawler_platform/app/web/frontend/src/hooks/queryKeys.ts b/crawler_platform/app/web/frontend/src/hooks/queryKeys.ts index ec0c863..106088b 100644 --- a/crawler_platform/app/web/frontend/src/hooks/queryKeys.ts +++ b/crawler_platform/app/web/frontend/src/hooks/queryKeys.ts @@ -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, + }, }; diff --git a/crawler_platform/app/web/frontend/src/hooks/useClaims.ts b/crawler_platform/app/web/frontend/src/hooks/useClaims.ts new file mode 100644 index 0000000..2408563 --- /dev/null +++ b/crawler_platform/app/web/frontend/src/hooks/useClaims.ts @@ -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({ + 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, + }); + }, + }); +} diff --git a/crawler_platform/app/web/frontend/src/hooks/useEntities.ts b/crawler_platform/app/web/frontend/src/hooks/useEntities.ts new file mode 100644 index 0000000..66baa33 --- /dev/null +++ b/crawler_platform/app/web/frontend/src/hooks/useEntities.ts @@ -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({ + 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, + }); + }, + }); +} diff --git a/crawler_platform/app/web/frontend/src/lib/api/claims.ts b/crawler_platform/app/web/frontend/src/lib/api/claims.ts new file mode 100644 index 0000000..61b89fb --- /dev/null +++ b/crawler_platform/app/web/frontend/src/lib/api/claims.ts @@ -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; + +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; +} + +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, + ), +}; diff --git a/crawler_platform/app/web/frontend/src/lib/api/entities.ts b/crawler_platform/app/web/frontend/src/lib/api/entities.ts new file mode 100644 index 0000000..d53974c --- /dev/null +++ b/crawler_platform/app/web/frontend/src/lib/api/entities.ts @@ -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; + +export interface CreateEntityRequest { + entity_type: string; + name: string; + metadata?: Record; +} + +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, + ), +}; diff --git a/crawler_platform/app/web/frontend/src/lib/api/index.ts b/crawler_platform/app/web/frontend/src/lib/api/index.ts index 43219d9..8b57d42 100644 --- a/crawler_platform/app/web/frontend/src/lib/api/index.ts +++ b/crawler_platform/app/web/frontend/src/lib/api/index.ts @@ -5,3 +5,5 @@ export * from "./ontology"; export * from "./sources"; export * from "./crawl"; export * from "./research"; +export * from "./entities"; +export * from "./claims"; diff --git a/crawler_platform/app/web/frontend/src/pages/OntologyEditorPage.tsx b/crawler_platform/app/web/frontend/src/pages/OntologyEditorPage.tsx new file mode 100644 index 0000000..146abb9 --- /dev/null +++ b/crawler_platform/app/web/frontend/src/pages/OntologyEditorPage.tsx @@ -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; + +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; + +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({ + 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({ + 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(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, 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) + : {}, + }; + }); + 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 ( +
+
+ +
+

+ + {t("editor.title", "온톨로지 직접 편집")} +

+ {project && ( +

+ {project.name} + + {" "} + · {project.domain} + +

+ )} +
+
+ + {isError && ( + + +
+ + {(error as Error).message} +
+ +
+
+ )} + + setTab(v as typeof tab)}> + + + {t("editor.entitiesTab", "엔티티")} ({entities.data?.length ?? 0}) + + + + {t("editor.claimsTab", "클레임")} ({claims.data?.length ?? 0}) + + + + {t("editor.bulkTab", "JSON 일괄 입력")} + + + + {/* ── Entities Tab ─────────────────────────────────────── */} + +
+ + + {t("editor.addEntity", "엔티티 추가")} + + {t( + "editor.addEntityDesc", + "온톨로지 도메인의 entity_types 중에서 선택", + )} + + + +
+
+ + + {entityForm.formState.errors.entity_type && ( +

+ {entityForm.formState.errors.entity_type.message} +

+ )} +
+
+ + + {entityForm.formState.errors.name && ( +

+ {entityForm.formState.errors.name.message} +

+ )} +
+ +
+
+
+ + + + + {t("editor.entitiesList", "엔티티 목록")} + + + {entities.data + ? t("editor.entityCount", "{{count}}개", { + count: entities.data.length, + }) + : t("dashboard.loadFailed", "")} + + + + {entities.isLoading && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ )} + {entities.data && entities.data.length === 0 && ( +

+ {t("editor.entitiesEmpty", "아직 등록된 엔티티가 없습니다")} +

+ )} + {entities.data && entities.data.length > 0 && ( +
    + {entities.data.map((e) => ( +
  • +
    +
    + {e.type} + + {e.name} + +
    +
    + +
  • + ))} +
+ )} +
+
+
+
+ + {/* ── Claims Tab ───────────────────────────────────────── */} + +
+ + + {t("editor.addClaim", "클레임 추가")} + + {t( + "editor.addClaimDesc", + "주어-술어-목적어 형태로 직접 입력", + )} + + + +
+
+ + + {claimForm.formState.errors.source_name && ( +

+ {claimForm.formState.errors.source_name.message} +

+ )} +
+ +
+ + + {claimForm.formState.errors.subject_entity_id && ( +

+ {claimForm.formState.errors.subject_entity_id.message} +

+ )} +
+ +
+ + +
+ +
+ + +
+ + {objectKind === "entity" ? ( +
+ + +
+ ) : ( +
+ + +
+ )} + +
+ + +
+ + +
+
+
+ + + + {t("editor.claimsList", "클레임 목록")} + + {claims.data && + t("editor.claimCount", "{{count}}개", { + count: claims.data.length, + })} + + + + {claims.isLoading && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ )} + {claims.data && claims.data.length === 0 && ( +

+ {t("editor.claimsEmpty", "아직 등록된 클레임이 없습니다")} +

+ )} + {claims.data && claims.data.length > 0 && ( +
    + {claims.data.map((c) => ( +
  • +
    +
    + + {c.subject ?? "?"} + + {c.predicate} + + {c.object ?? String(c.object_value ?? "—")} + +
    +
    + + {t("editor.source", "소스")}: {c.source ?? "—"} + + {typeof c.confidence === "number" && ( + + {t("editor.confidence", "신뢰도")}:{" "} + {c.confidence.toFixed(2)} + + )} + {c.status && ( + {c.status} + )} +
    +
    + +
  • + ))} +
+ )} +
+
+
+
+ + {/* ── Bulk Tab ─────────────────────────────────────────── */} + + + + {t("editor.bulkTitle", "JSON 일괄 입력")} + + {t( + "editor.bulkDesc", + "{ entities: [{ entity_type, name, metadata? }] } 형태의 JSON", + )} + + + +