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

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

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

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

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

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

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

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

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

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

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

View File

@@ -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,