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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user