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.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 (대량 데이터 직접 입력) | 독립 가능 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "크롤 설정",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
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) =>
|
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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
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 "./sources";
|
||||||
export * from "./crawl";
|
export * from "./crawl";
|
||||||
export * from "./research";
|
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