2026-05-08 17:41:15 +09:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-05-19 20:31:52 +09:00
|
|
|
import csv
|
|
|
|
|
import io
|
|
|
|
|
import json
|
2026-05-20 20:59:40 +09:00
|
|
|
import re
|
2026-05-22 00:22:03 +09:00
|
|
|
import time
|
2026-05-08 17:41:15 +09:00
|
|
|
from dataclasses import asdict
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
2026-05-19 20:31:52 +09:00
|
|
|
from fastapi import BackgroundTasks, HTTPException, Response
|
2026-05-08 17:41:15 +09:00
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
from sqlalchemy import select
|
2026-05-22 00:22:03 +09:00
|
|
|
from sqlalchemy.exc import OperationalError
|
2026-05-08 17:41:15 +09:00
|
|
|
|
Phase 1.3: 시드 크롤 — by-project 엔드포인트 + CrawlPage 폼/폴링/취소
세션 영구화: UI_REBUILD_PLAN.md 신설
- 사용자 비전 5가지 흐름 + Phase 보드 + 작업 재개 가이드
- 세션이 끊겨도 이 파일 + git log로 어디까지 했는지 즉시 복원
백엔드 (crawler_platform/app/api/):
- routes.py: POST /crawl-site/by-project 추가 — config_path 의존 제거
- SiteCrawlByProjectRequest: project_name 기반, DB project.config 재구성
- run_site_crawl_job이 __config_dict 메타키 인식하도록 최소 변경
- config/loader.py: project_config_from_dict() 헬퍼 추출 (DB dict ↔ ProjectConfig)
프론트엔드 API (src/lib/api/):
- crawl.ts: crawlApi.startByProject/getJob/cancel + Zod 스키마
- 종료 상태 판정 헬퍼 isCrawlTerminal()
TanStack Query 훅 (src/hooks/):
- useCrawl.ts: useStartSiteCrawl, useCrawlJob (2초 폴링, 종료 시 자동 중단), useCancelCrawl
- queryKeys에 crawl.job 키
UI 프리미티브 (src/components/ui/):
- select.tsx, progress.tsx, badge.tsx (success/warning/destructive variants 포함)
CrawlPage 완전 교체 (src/pages/):
- 2열 레이아웃: 좌측 크롤 설정 폼 + 우측 진행 상태
- 폼: 소스 선택 (드롭다운, 비어있으면 소스 추가 안내) + 시드 URL + max_depth/max_pages + same_domain_only
- react-hook-form + zod 검증
- 진행 상태: 상태 배지, 진행률 바, visited/queued/analyzed 카운터, 최근 페이지, 에러 details
- 크롤 종료시 폴링 자동 중단, 완료시 "결과 검토" 버튼
- 취소 버튼 → /crawl-site/jobs/{id}/cancel
- sonner 토스트
i18n: crawl.* 키 (한/영)
다음 단계: Phase 1.4 — 자율 연구 (POST /research/run by-project 마이그레이션 + ResearchPage)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:04:41 +09:00
|
|
|
from crawler_platform.app.config.loader import (
|
|
|
|
|
ProjectConfig,
|
|
|
|
|
SourceConfig,
|
|
|
|
|
load_project_config,
|
|
|
|
|
project_config_from_dict,
|
|
|
|
|
)
|
2026-05-08 17:41:15 +09:00
|
|
|
from crawler_platform.app.core.crawler.discovery import discover_links
|
|
|
|
|
from crawler_platform.app.core.crawler.fetchers import RobotsPolicy, make_fetcher
|
|
|
|
|
from crawler_platform.app.core.crawler.pipeline import CrawlPipeline
|
2026-05-11 13:02:11 +09:00
|
|
|
from crawler_platform.app.core.crawler.site_crawler import SiteCrawler
|
2026-05-08 17:41:15 +09:00
|
|
|
from crawler_platform.app.core.database import models
|
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>
2026-05-14 19:10:37 +09:00
|
|
|
from crawler_platform.app.core.database.repository import (
|
|
|
|
|
KnowledgeRepository,
|
|
|
|
|
make_claim_hash,
|
|
|
|
|
)
|
2026-05-08 17:41:15 +09:00
|
|
|
from crawler_platform.app.core.database.session import session_scope
|
2026-05-22 20:16:28 +09:00
|
|
|
from crawler_platform.app.core.extractor.ai_provider import (
|
|
|
|
|
list_lmstudio_loaded_models,
|
|
|
|
|
list_openai_compatible_models,
|
|
|
|
|
)
|
2026-05-08 17:41:15 +09:00
|
|
|
from crawler_platform.app.core.extractor.factory import extractor_for_domain
|
2026-05-20 20:59:40 +09:00
|
|
|
from crawler_platform.app.core.ontology.definitions import DOMAIN_ONTOLOGIES, Ontology, ontology_for_domain
|
|
|
|
|
from crawler_platform.app.core.ontology.domain_discovery import DomainDiscoveryService
|
2026-05-12 19:40:31 +09:00
|
|
|
from crawler_platform.app.core.ontology.gap_detector import KnowledgeGapDetector
|
2026-05-08 17:41:15 +09:00
|
|
|
from crawler_platform.app.core.ontology.mapper import ontology_to_dict
|
2026-05-12 19:40:31 +09:00
|
|
|
from crawler_platform.app.core.ontology.registry import OntologyRegistry
|
2026-05-08 17:41:15 +09:00
|
|
|
from crawler_platform.app.core.recommendation.scorer import PreferenceInput, RuleBasedRecommender
|
2026-05-12 19:40:31 +09:00
|
|
|
from crawler_platform.app.core.research.graph_query import SemanticGraphQuery
|
|
|
|
|
from crawler_platform.app.core.research.graph_research_loop import GraphResearchLoop
|
|
|
|
|
from crawler_platform.app.core.research.memory_store import ResearchMemoryStore, research_session_payload
|
2026-05-08 17:41:15 +09:00
|
|
|
|
|
|
|
|
|
2026-05-20 20:59:40 +09:00
|
|
|
DOMAIN_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{1,79}$")
|
2026-05-22 00:22:03 +09:00
|
|
|
SITE_CRAWL_CANCEL_REQUESTS: set[int] = set()
|
2026-05-22 19:07:07 +09:00
|
|
|
COMPARISON_KEYS = ("both_agree", "rule_only", "llm_only", "conflict", "rejected_by_validation")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def extraction_log_summary(raw_output: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
|
candidate_claims = raw_output.get("candidate_claims")
|
|
|
|
|
if not isinstance(candidate_claims, list):
|
|
|
|
|
candidate_claims = []
|
|
|
|
|
|
|
|
|
|
raw_comparison = raw_output.get("comparison")
|
|
|
|
|
if not isinstance(raw_comparison, dict):
|
|
|
|
|
raw_comparison = {}
|
|
|
|
|
comparison = {key: int(raw_comparison.get(key) or 0) for key in COMPARISON_KEYS}
|
|
|
|
|
if not any(comparison.values()):
|
|
|
|
|
comparison.update(comparison_from_candidate_claims(candidate_claims))
|
|
|
|
|
validation = raw_output.get("validation")
|
|
|
|
|
if isinstance(validation, dict) and comparison["rejected_by_validation"] == 0:
|
|
|
|
|
comparison["rejected_by_validation"] = number_or_default(validation.get("rejected_claim_count"), 0)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"candidate_count": len(candidate_claims),
|
|
|
|
|
"comparison": comparison,
|
|
|
|
|
"rule_entity_count": number_or_none(raw_output.get("rule_entity_count")),
|
|
|
|
|
"rule_claim_count": number_or_derived(
|
|
|
|
|
raw_output.get("rule_claim_count"),
|
|
|
|
|
candidate_claims,
|
|
|
|
|
source="rule",
|
|
|
|
|
),
|
|
|
|
|
"llm_entity_count": number_or_none(raw_output.get("llm_entity_count")),
|
|
|
|
|
"llm_claim_count": number_or_derived(
|
|
|
|
|
raw_output.get("llm_claim_count"),
|
|
|
|
|
candidate_claims,
|
|
|
|
|
source="llm",
|
|
|
|
|
),
|
|
|
|
|
"agreement_claim_count": number_or_default(raw_output.get("agreement_claim_count"), comparison["both_agree"]),
|
|
|
|
|
"rule_only_claim_count": number_or_default(raw_output.get("rule_only_claim_count"), comparison["rule_only"]),
|
|
|
|
|
"llm_only_claim_count": number_or_default(raw_output.get("llm_only_claim_count"), comparison["llm_only"]),
|
|
|
|
|
"conflict_claim_count": number_or_default(raw_output.get("conflict_claim_count"), comparison["conflict"]),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def comparison_from_candidate_claims(candidate_claims: list[Any]) -> dict[str, int]:
|
|
|
|
|
comparison = {key: 0 for key in COMPARISON_KEYS}
|
|
|
|
|
for claim in candidate_claims:
|
|
|
|
|
metadata = claim_metadata(claim)
|
|
|
|
|
agreement = str(metadata.get("agreement") or metadata.get("claim_kind") or "").lower()
|
|
|
|
|
if agreement == "rule_and_llm":
|
|
|
|
|
comparison["both_agree"] += 1
|
|
|
|
|
elif agreement == "rule_only":
|
|
|
|
|
comparison["rule_only"] += 1
|
|
|
|
|
elif agreement == "llm_only":
|
|
|
|
|
comparison["llm_only"] += 1
|
|
|
|
|
elif agreement == "conflict":
|
|
|
|
|
comparison["conflict"] += 1
|
|
|
|
|
return comparison
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def claim_metadata(claim: Any) -> dict[str, Any]:
|
|
|
|
|
if not isinstance(claim, dict):
|
|
|
|
|
return {}
|
|
|
|
|
metadata = claim.get("metadata")
|
|
|
|
|
return metadata if isinstance(metadata, dict) else {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def number_or_none(value: Any) -> int | None:
|
|
|
|
|
if isinstance(value, bool):
|
|
|
|
|
return None
|
|
|
|
|
if isinstance(value, (int, float)):
|
|
|
|
|
return int(value)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def number_or_default(value: Any, default: int) -> int:
|
|
|
|
|
parsed = number_or_none(value)
|
|
|
|
|
return default if parsed is None else parsed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def number_or_derived(value: Any, candidate_claims: list[Any], *, source: str) -> int:
|
|
|
|
|
parsed = number_or_none(value)
|
|
|
|
|
if parsed is not None:
|
|
|
|
|
return parsed
|
|
|
|
|
return sum(1 for claim in candidate_claims if claim_matches_source(claim, source))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def claim_matches_source(claim: Any, source: str) -> bool:
|
|
|
|
|
metadata = claim_metadata(claim)
|
|
|
|
|
extraction_source = str(metadata.get("extraction_source") or "").lower()
|
|
|
|
|
agreement = str(metadata.get("agreement") or "").lower()
|
|
|
|
|
if extraction_source == source:
|
|
|
|
|
return True
|
|
|
|
|
if source == "rule":
|
|
|
|
|
return agreement in {"rule_only", "rule_and_llm"}
|
|
|
|
|
if source == "llm":
|
|
|
|
|
return agreement in {"llm_only", "rule_and_llm"}
|
|
|
|
|
return False
|
2026-05-20 20:59:40 +09:00
|
|
|
|
|
|
|
|
|
2026-05-08 17:41:15 +09:00
|
|
|
class CrawlRequest(BaseModel):
|
|
|
|
|
config_path: str
|
|
|
|
|
source_name: str
|
|
|
|
|
url: str
|
2026-05-22 00:22:03 +09:00
|
|
|
extraction_mode: str = "hybrid"
|
2026-05-12 19:40:31 +09:00
|
|
|
extractor_provider: str = "lm_studio"
|
2026-05-08 17:41:15 +09:00
|
|
|
extractor_model: str | None = None
|
2026-05-12 19:40:31 +09:00
|
|
|
extractor_base_url: str | None = "http://localhost:1234/v1"
|
2026-05-22 00:22:03 +09:00
|
|
|
fallback_to_rules: bool = True
|
2026-05-13 19:57:34 +09:00
|
|
|
check_robots_txt: bool = False
|
|
|
|
|
respect_robots_txt: bool | None = None
|
2026-05-08 17:41:15 +09:00
|
|
|
|
|
|
|
|
|
2026-05-11 13:02:11 +09:00
|
|
|
class SiteCrawlRequest(CrawlRequest):
|
|
|
|
|
max_depth: int = 2
|
|
|
|
|
max_pages: int = 50
|
|
|
|
|
same_domain_only: bool = True
|
2026-05-12 19:40:31 +09:00
|
|
|
analyze_page_types: list[str] = Field(default_factory=lambda: ["ProductPage", "BrandStoryPage", "ReviewPage"])
|
2026-05-11 13:02:11 +09:00
|
|
|
|
|
|
|
|
|
Phase 1.3: 시드 크롤 — by-project 엔드포인트 + CrawlPage 폼/폴링/취소
세션 영구화: UI_REBUILD_PLAN.md 신설
- 사용자 비전 5가지 흐름 + Phase 보드 + 작업 재개 가이드
- 세션이 끊겨도 이 파일 + git log로 어디까지 했는지 즉시 복원
백엔드 (crawler_platform/app/api/):
- routes.py: POST /crawl-site/by-project 추가 — config_path 의존 제거
- SiteCrawlByProjectRequest: project_name 기반, DB project.config 재구성
- run_site_crawl_job이 __config_dict 메타키 인식하도록 최소 변경
- config/loader.py: project_config_from_dict() 헬퍼 추출 (DB dict ↔ ProjectConfig)
프론트엔드 API (src/lib/api/):
- crawl.ts: crawlApi.startByProject/getJob/cancel + Zod 스키마
- 종료 상태 판정 헬퍼 isCrawlTerminal()
TanStack Query 훅 (src/hooks/):
- useCrawl.ts: useStartSiteCrawl, useCrawlJob (2초 폴링, 종료 시 자동 중단), useCancelCrawl
- queryKeys에 crawl.job 키
UI 프리미티브 (src/components/ui/):
- select.tsx, progress.tsx, badge.tsx (success/warning/destructive variants 포함)
CrawlPage 완전 교체 (src/pages/):
- 2열 레이아웃: 좌측 크롤 설정 폼 + 우측 진행 상태
- 폼: 소스 선택 (드롭다운, 비어있으면 소스 추가 안내) + 시드 URL + max_depth/max_pages + same_domain_only
- react-hook-form + zod 검증
- 진행 상태: 상태 배지, 진행률 바, visited/queued/analyzed 카운터, 최근 페이지, 에러 details
- 크롤 종료시 폴링 자동 중단, 완료시 "결과 검토" 버튼
- 취소 버튼 → /crawl-site/jobs/{id}/cancel
- sonner 토스트
i18n: crawl.* 키 (한/영)
다음 단계: Phase 1.4 — 자율 연구 (POST /research/run by-project 마이그레이션 + ResearchPage)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:04:41 +09:00
|
|
|
class SiteCrawlByProjectRequest(BaseModel):
|
|
|
|
|
"""Site crawl invoked against an existing project (no filesystem config_path)."""
|
|
|
|
|
|
|
|
|
|
project_name: str
|
|
|
|
|
source_name: str
|
|
|
|
|
url: str
|
2026-05-22 00:22:03 +09:00
|
|
|
extraction_mode: str = "hybrid"
|
Phase 1.3: 시드 크롤 — by-project 엔드포인트 + CrawlPage 폼/폴링/취소
세션 영구화: UI_REBUILD_PLAN.md 신설
- 사용자 비전 5가지 흐름 + Phase 보드 + 작업 재개 가이드
- 세션이 끊겨도 이 파일 + git log로 어디까지 했는지 즉시 복원
백엔드 (crawler_platform/app/api/):
- routes.py: POST /crawl-site/by-project 추가 — config_path 의존 제거
- SiteCrawlByProjectRequest: project_name 기반, DB project.config 재구성
- run_site_crawl_job이 __config_dict 메타키 인식하도록 최소 변경
- config/loader.py: project_config_from_dict() 헬퍼 추출 (DB dict ↔ ProjectConfig)
프론트엔드 API (src/lib/api/):
- crawl.ts: crawlApi.startByProject/getJob/cancel + Zod 스키마
- 종료 상태 판정 헬퍼 isCrawlTerminal()
TanStack Query 훅 (src/hooks/):
- useCrawl.ts: useStartSiteCrawl, useCrawlJob (2초 폴링, 종료 시 자동 중단), useCancelCrawl
- queryKeys에 crawl.job 키
UI 프리미티브 (src/components/ui/):
- select.tsx, progress.tsx, badge.tsx (success/warning/destructive variants 포함)
CrawlPage 완전 교체 (src/pages/):
- 2열 레이아웃: 좌측 크롤 설정 폼 + 우측 진행 상태
- 폼: 소스 선택 (드롭다운, 비어있으면 소스 추가 안내) + 시드 URL + max_depth/max_pages + same_domain_only
- react-hook-form + zod 검증
- 진행 상태: 상태 배지, 진행률 바, visited/queued/analyzed 카운터, 최근 페이지, 에러 details
- 크롤 종료시 폴링 자동 중단, 완료시 "결과 검토" 버튼
- 취소 버튼 → /crawl-site/jobs/{id}/cancel
- sonner 토스트
i18n: crawl.* 키 (한/영)
다음 단계: Phase 1.4 — 자율 연구 (POST /research/run by-project 마이그레이션 + ResearchPage)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:04:41 +09:00
|
|
|
extractor_provider: str = "lm_studio"
|
|
|
|
|
extractor_model: str | None = None
|
|
|
|
|
extractor_base_url: str | None = "http://localhost:1234/v1"
|
2026-05-22 00:22:03 +09:00
|
|
|
fallback_to_rules: bool = True
|
Phase 1.3: 시드 크롤 — by-project 엔드포인트 + CrawlPage 폼/폴링/취소
세션 영구화: UI_REBUILD_PLAN.md 신설
- 사용자 비전 5가지 흐름 + Phase 보드 + 작업 재개 가이드
- 세션이 끊겨도 이 파일 + git log로 어디까지 했는지 즉시 복원
백엔드 (crawler_platform/app/api/):
- routes.py: POST /crawl-site/by-project 추가 — config_path 의존 제거
- SiteCrawlByProjectRequest: project_name 기반, DB project.config 재구성
- run_site_crawl_job이 __config_dict 메타키 인식하도록 최소 변경
- config/loader.py: project_config_from_dict() 헬퍼 추출 (DB dict ↔ ProjectConfig)
프론트엔드 API (src/lib/api/):
- crawl.ts: crawlApi.startByProject/getJob/cancel + Zod 스키마
- 종료 상태 판정 헬퍼 isCrawlTerminal()
TanStack Query 훅 (src/hooks/):
- useCrawl.ts: useStartSiteCrawl, useCrawlJob (2초 폴링, 종료 시 자동 중단), useCancelCrawl
- queryKeys에 crawl.job 키
UI 프리미티브 (src/components/ui/):
- select.tsx, progress.tsx, badge.tsx (success/warning/destructive variants 포함)
CrawlPage 완전 교체 (src/pages/):
- 2열 레이아웃: 좌측 크롤 설정 폼 + 우측 진행 상태
- 폼: 소스 선택 (드롭다운, 비어있으면 소스 추가 안내) + 시드 URL + max_depth/max_pages + same_domain_only
- react-hook-form + zod 검증
- 진행 상태: 상태 배지, 진행률 바, visited/queued/analyzed 카운터, 최근 페이지, 에러 details
- 크롤 종료시 폴링 자동 중단, 완료시 "결과 검토" 버튼
- 취소 버튼 → /crawl-site/jobs/{id}/cancel
- sonner 토스트
i18n: crawl.* 키 (한/영)
다음 단계: Phase 1.4 — 자율 연구 (POST /research/run by-project 마이그레이션 + ResearchPage)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:04:41 +09:00
|
|
|
check_robots_txt: bool = False
|
|
|
|
|
respect_robots_txt: bool | None = None
|
|
|
|
|
max_depth: int = 2
|
|
|
|
|
max_pages: int = 50
|
|
|
|
|
same_domain_only: bool = True
|
|
|
|
|
analyze_page_types: list[str] = Field(
|
|
|
|
|
default_factory=lambda: ["ProductPage", "BrandStoryPage", "ReviewPage"]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def to_site_crawl_request(self, config_path_placeholder: str = "") -> "SiteCrawlRequest":
|
|
|
|
|
"""For internal handoff to existing crawl pipeline (config_path is not used)."""
|
|
|
|
|
return SiteCrawlRequest(
|
|
|
|
|
config_path=config_path_placeholder,
|
|
|
|
|
source_name=self.source_name,
|
|
|
|
|
url=self.url,
|
2026-05-22 00:22:03 +09:00
|
|
|
extraction_mode=self.extraction_mode,
|
Phase 1.3: 시드 크롤 — by-project 엔드포인트 + CrawlPage 폼/폴링/취소
세션 영구화: UI_REBUILD_PLAN.md 신설
- 사용자 비전 5가지 흐름 + Phase 보드 + 작업 재개 가이드
- 세션이 끊겨도 이 파일 + git log로 어디까지 했는지 즉시 복원
백엔드 (crawler_platform/app/api/):
- routes.py: POST /crawl-site/by-project 추가 — config_path 의존 제거
- SiteCrawlByProjectRequest: project_name 기반, DB project.config 재구성
- run_site_crawl_job이 __config_dict 메타키 인식하도록 최소 변경
- config/loader.py: project_config_from_dict() 헬퍼 추출 (DB dict ↔ ProjectConfig)
프론트엔드 API (src/lib/api/):
- crawl.ts: crawlApi.startByProject/getJob/cancel + Zod 스키마
- 종료 상태 판정 헬퍼 isCrawlTerminal()
TanStack Query 훅 (src/hooks/):
- useCrawl.ts: useStartSiteCrawl, useCrawlJob (2초 폴링, 종료 시 자동 중단), useCancelCrawl
- queryKeys에 crawl.job 키
UI 프리미티브 (src/components/ui/):
- select.tsx, progress.tsx, badge.tsx (success/warning/destructive variants 포함)
CrawlPage 완전 교체 (src/pages/):
- 2열 레이아웃: 좌측 크롤 설정 폼 + 우측 진행 상태
- 폼: 소스 선택 (드롭다운, 비어있으면 소스 추가 안내) + 시드 URL + max_depth/max_pages + same_domain_only
- react-hook-form + zod 검증
- 진행 상태: 상태 배지, 진행률 바, visited/queued/analyzed 카운터, 최근 페이지, 에러 details
- 크롤 종료시 폴링 자동 중단, 완료시 "결과 검토" 버튼
- 취소 버튼 → /crawl-site/jobs/{id}/cancel
- sonner 토스트
i18n: crawl.* 키 (한/영)
다음 단계: Phase 1.4 — 자율 연구 (POST /research/run by-project 마이그레이션 + ResearchPage)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:04:41 +09:00
|
|
|
extractor_provider=self.extractor_provider,
|
|
|
|
|
extractor_model=self.extractor_model,
|
|
|
|
|
extractor_base_url=self.extractor_base_url,
|
2026-05-22 00:22:03 +09:00
|
|
|
fallback_to_rules=self.fallback_to_rules,
|
Phase 1.3: 시드 크롤 — by-project 엔드포인트 + CrawlPage 폼/폴링/취소
세션 영구화: UI_REBUILD_PLAN.md 신설
- 사용자 비전 5가지 흐름 + Phase 보드 + 작업 재개 가이드
- 세션이 끊겨도 이 파일 + git log로 어디까지 했는지 즉시 복원
백엔드 (crawler_platform/app/api/):
- routes.py: POST /crawl-site/by-project 추가 — config_path 의존 제거
- SiteCrawlByProjectRequest: project_name 기반, DB project.config 재구성
- run_site_crawl_job이 __config_dict 메타키 인식하도록 최소 변경
- config/loader.py: project_config_from_dict() 헬퍼 추출 (DB dict ↔ ProjectConfig)
프론트엔드 API (src/lib/api/):
- crawl.ts: crawlApi.startByProject/getJob/cancel + Zod 스키마
- 종료 상태 판정 헬퍼 isCrawlTerminal()
TanStack Query 훅 (src/hooks/):
- useCrawl.ts: useStartSiteCrawl, useCrawlJob (2초 폴링, 종료 시 자동 중단), useCancelCrawl
- queryKeys에 crawl.job 키
UI 프리미티브 (src/components/ui/):
- select.tsx, progress.tsx, badge.tsx (success/warning/destructive variants 포함)
CrawlPage 완전 교체 (src/pages/):
- 2열 레이아웃: 좌측 크롤 설정 폼 + 우측 진행 상태
- 폼: 소스 선택 (드롭다운, 비어있으면 소스 추가 안내) + 시드 URL + max_depth/max_pages + same_domain_only
- react-hook-form + zod 검증
- 진행 상태: 상태 배지, 진행률 바, visited/queued/analyzed 카운터, 최근 페이지, 에러 details
- 크롤 종료시 폴링 자동 중단, 완료시 "결과 검토" 버튼
- 취소 버튼 → /crawl-site/jobs/{id}/cancel
- sonner 토스트
i18n: crawl.* 키 (한/영)
다음 단계: Phase 1.4 — 자율 연구 (POST /research/run by-project 마이그레이션 + ResearchPage)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:04:41 +09:00
|
|
|
check_robots_txt=self.check_robots_txt,
|
|
|
|
|
respect_robots_txt=self.respect_robots_txt,
|
|
|
|
|
max_depth=self.max_depth,
|
|
|
|
|
max_pages=self.max_pages,
|
|
|
|
|
same_domain_only=self.same_domain_only,
|
|
|
|
|
analyze_page_types=list(self.analyze_page_types),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-08 17:41:15 +09:00
|
|
|
class DiscoverRequest(BaseModel):
|
|
|
|
|
config_path: str
|
|
|
|
|
source_name: str
|
|
|
|
|
url: str
|
|
|
|
|
limit: int = 30
|
2026-05-13 19:57:34 +09:00
|
|
|
check_robots_txt: bool = False
|
|
|
|
|
respect_robots_txt: bool | None = None
|
2026-05-08 17:41:15 +09:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class RecommendRequest(BaseModel):
|
|
|
|
|
project_name: str
|
|
|
|
|
target_entity_type: str = "Perfume"
|
|
|
|
|
preferences: dict = Field(default_factory=dict)
|
|
|
|
|
limit: int = 10
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CreateProjectRequest(BaseModel):
|
|
|
|
|
config_path: str
|
|
|
|
|
|
|
|
|
|
|
Phase 1.1: 프로젝트 생성 — 폼 기반 도메인 선택 + 인라인 JSON 생성
백엔드 (crawler_platform/app/api/routes.py):
- POST /projects/inline 추가: 파일 시스템 의존 없이 JSON 본문으로 ProjectConfig 인라인 생성
- CreateProjectInlineRequest + InlineSourceConfig Pydantic 모델
- GET /domains 추가: 미리 정의된 도메인 목록 (perfume, tea, coffee, candle, supplement, gift)
- 각 도메인의 entity_types, predicates, attribute_count 반환
프론트엔드 API 레이어 (src/lib/api/):
- domains.ts: domainsApi.list + Zod 스키마
- ontology.ts: ontologyApi.get(domain) + Zod 스키마
- projects.ts: createInline() 메서드 + CreateProjectInlineRequest 타입
TanStack Query 훅 (src/hooks/):
- useDomains: 도메인 목록 (30분 staleTime)
- useOntology(domain): 도메인 상세
- useCreateProjectInline: 인라인 생성 mutation + projects 캐시 무효화
- queryKeys에 domains, ontology 키 팩토리 추가
UI 프리미티브 (src/components/ui/):
- input.tsx, label.tsx, textarea.tsx
OnboardingPage 완전 교체 (src/pages/OnboardingPage.tsx):
- react-hook-form + zod 검증 (project_name 정규식, 도메인 필수)
- 도메인 카드 그리드 선택 (entity/predicate 개수 미리보기)
- 백엔드 /projects/inline POST → 성공 시 sonner 토스트 + /sources/{name}으로 이동
- 로딩/에러/재시도 UI
기타:
- Vite proxy에 /domains prefix 추가
- i18n locale 키 onboarding.* 추가 (한/영)
다음 단계: Phase 1.2 — 온톨로지 엔티티/클레임 직접 입력 + URL 추출
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 14:46:03 +09:00
|
|
|
class InlineSourceConfig(BaseModel):
|
|
|
|
|
name: str
|
|
|
|
|
type: str = "unknown"
|
|
|
|
|
trust_level: float = 0.5
|
|
|
|
|
base_url: str | None = None
|
|
|
|
|
allowed_paths: list[str] = Field(default_factory=list)
|
|
|
|
|
parser: str = "generic"
|
|
|
|
|
fetcher: str = "requests"
|
|
|
|
|
rate_limit_per_minute: int = 30
|
|
|
|
|
respect_robots_txt: bool = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CreateProjectInlineRequest(BaseModel):
|
|
|
|
|
"""Create a project from inline JSON config (no filesystem dependency)."""
|
|
|
|
|
|
|
|
|
|
project_name: str
|
|
|
|
|
domain: str
|
|
|
|
|
target_entities: list[str] = Field(default_factory=list)
|
|
|
|
|
fields: list[str] = Field(default_factory=list)
|
|
|
|
|
sources: list[InlineSourceConfig] = Field(default_factory=list)
|
|
|
|
|
ontology: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
recommendation: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
update_policy: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
def to_project_config(self) -> ProjectConfig:
|
|
|
|
|
return ProjectConfig(
|
|
|
|
|
project_name=self.project_name,
|
|
|
|
|
domain=self.domain,
|
|
|
|
|
target_entities=list(self.target_entities),
|
|
|
|
|
fields=list(self.fields),
|
|
|
|
|
sources=[SourceConfig(**s.model_dump()) for s in self.sources],
|
|
|
|
|
ontology=dict(self.ontology),
|
|
|
|
|
recommendation=dict(self.recommendation),
|
|
|
|
|
update_policy=dict(self.update_policy),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 20:59:40 +09:00
|
|
|
class DomainDefinitionRequest(BaseModel):
|
|
|
|
|
domain: str
|
|
|
|
|
description: str | None = None
|
|
|
|
|
entity_types: list[str] = Field(default_factory=list)
|
|
|
|
|
predicates: list[str] = Field(default_factory=list)
|
|
|
|
|
attributes: list[str] = Field(default_factory=list)
|
|
|
|
|
aliases: dict[str, str] = Field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DomainDefinitionUpdateRequest(BaseModel):
|
|
|
|
|
description: str | None = None
|
|
|
|
|
entity_types: list[str] = Field(default_factory=list)
|
|
|
|
|
predicates: list[str] = Field(default_factory=list)
|
|
|
|
|
attributes: list[str] = Field(default_factory=list)
|
|
|
|
|
aliases: dict[str, str] = Field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DomainDiscoveryRequest(BaseModel):
|
|
|
|
|
seed_urls: list[str] = Field(default_factory=list)
|
|
|
|
|
max_pages: int = 20
|
|
|
|
|
max_depth: int = 1
|
|
|
|
|
same_domain_only: bool = True
|
|
|
|
|
fetcher: str = "requests"
|
|
|
|
|
respect_robots_txt: bool = False
|
|
|
|
|
force_recrawl: bool = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DomainCandidateApplyRequest(BaseModel):
|
|
|
|
|
candidate_ids: list[int] = Field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class DomainCandidateStatusRequest(BaseModel):
|
|
|
|
|
status: str
|
|
|
|
|
|
|
|
|
|
|
2026-05-11 22:55:01 +09:00
|
|
|
class ResetProjectRequest(BaseModel):
|
|
|
|
|
config_path: str
|
|
|
|
|
project_name: str | None = None
|
|
|
|
|
|
|
|
|
|
|
2026-05-14 23:37:21 +09:00
|
|
|
def source_model_to_config(source: models.Source) -> SourceConfig:
|
|
|
|
|
return SourceConfig(
|
|
|
|
|
name=source.name,
|
|
|
|
|
type=source.type,
|
|
|
|
|
trust_level=source.trust_level,
|
|
|
|
|
base_url=source.base_url,
|
|
|
|
|
rate_limit_per_minute=source.rate_limit_per_minute,
|
|
|
|
|
respect_robots_txt=source.respect_robots_txt,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 20:59:40 +09:00
|
|
|
def normalize_domain_name(domain: str) -> str:
|
|
|
|
|
normalized = domain.strip().lower()
|
|
|
|
|
if not DOMAIN_NAME_PATTERN.match(normalized):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail="Domain must be 2-80 chars using lowercase letters, numbers, underscore, or hyphen.",
|
|
|
|
|
)
|
|
|
|
|
return normalized
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def clean_string_list(values: list[str]) -> list[str]:
|
|
|
|
|
cleaned: list[str] = []
|
|
|
|
|
seen: set[str] = set()
|
|
|
|
|
for value in values:
|
|
|
|
|
item = value.strip()
|
|
|
|
|
if not item or item in seen:
|
|
|
|
|
continue
|
|
|
|
|
cleaned.append(item)
|
|
|
|
|
seen.add(item)
|
|
|
|
|
return cleaned
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def clean_aliases(values: dict[str, str]) -> dict[str, str]:
|
|
|
|
|
return {
|
|
|
|
|
key.strip(): value.strip()
|
|
|
|
|
for key, value in values.items()
|
|
|
|
|
if key.strip() and value.strip()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def domain_row_to_ontology(row: models.DomainDefinition):
|
|
|
|
|
return Ontology(
|
|
|
|
|
domain=row.domain,
|
|
|
|
|
entity_types=clean_string_list(list(row.entity_types or [])),
|
|
|
|
|
predicates=clean_string_list(list(row.predicates or [])),
|
|
|
|
|
attributes=clean_string_list(list(row.attributes or [])),
|
|
|
|
|
aliases=clean_aliases(dict(row.aliases or {})),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def domain_summary_payload(ont, *, is_builtin: bool, is_custom: bool, description: str | None = None):
|
|
|
|
|
return {
|
|
|
|
|
"domain": ont.domain,
|
|
|
|
|
"description": description,
|
|
|
|
|
"entity_types": list(ont.entity_types),
|
|
|
|
|
"predicates": list(ont.predicates),
|
|
|
|
|
"attribute_count": len(ont.attributes),
|
|
|
|
|
"attributes": list(ont.attributes),
|
|
|
|
|
"aliases": dict(ont.aliases),
|
|
|
|
|
"is_builtin": is_builtin,
|
|
|
|
|
"is_custom": is_custom,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def upsert_domain_definition(
|
|
|
|
|
session,
|
|
|
|
|
domain: str,
|
|
|
|
|
payload: DomainDefinitionUpdateRequest,
|
|
|
|
|
) -> models.DomainDefinition:
|
|
|
|
|
normalized_domain = normalize_domain_name(domain)
|
|
|
|
|
row = session.scalar(
|
|
|
|
|
select(models.DomainDefinition).where(models.DomainDefinition.domain == normalized_domain)
|
|
|
|
|
)
|
|
|
|
|
if row is None:
|
|
|
|
|
row = models.DomainDefinition(domain=normalized_domain)
|
|
|
|
|
session.add(row)
|
|
|
|
|
session.flush()
|
|
|
|
|
row.description = payload.description.strip() if payload.description else None
|
|
|
|
|
row.entity_types = clean_string_list(payload.entity_types)
|
|
|
|
|
row.predicates = clean_string_list(payload.predicates)
|
|
|
|
|
row.attributes = clean_string_list(payload.attributes)
|
|
|
|
|
row.aliases = clean_aliases(payload.aliases)
|
|
|
|
|
row.is_builtin_override = normalized_domain in DOMAIN_ONTOLOGIES
|
|
|
|
|
row.updated_at = models.utcnow()
|
|
|
|
|
return row
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ensure_domain_definition(session, domain: str) -> models.DomainDefinition:
|
|
|
|
|
normalized_domain = normalize_domain_name(domain)
|
|
|
|
|
row = session.scalar(
|
|
|
|
|
select(models.DomainDefinition).where(models.DomainDefinition.domain == normalized_domain)
|
|
|
|
|
)
|
|
|
|
|
if row is not None:
|
|
|
|
|
return row
|
|
|
|
|
|
|
|
|
|
base = DOMAIN_ONTOLOGIES.get(normalized_domain)
|
|
|
|
|
row = models.DomainDefinition(
|
|
|
|
|
domain=normalized_domain,
|
|
|
|
|
entity_types=list(base.entity_types) if base else [],
|
|
|
|
|
predicates=list(base.predicates) if base else [],
|
|
|
|
|
attributes=list(base.attributes) if base else [],
|
|
|
|
|
aliases=dict(base.aliases) if base else {},
|
|
|
|
|
is_builtin_override=normalized_domain in DOMAIN_ONTOLOGIES,
|
|
|
|
|
)
|
|
|
|
|
session.add(row)
|
|
|
|
|
session.flush()
|
|
|
|
|
return row
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def ontology_for_domain_from_db(session, domain: str):
|
|
|
|
|
normalized_domain = normalize_domain_name(domain)
|
|
|
|
|
row = session.scalar(
|
|
|
|
|
select(models.DomainDefinition).where(models.DomainDefinition.domain == normalized_domain)
|
|
|
|
|
)
|
|
|
|
|
if row is not None:
|
|
|
|
|
return domain_row_to_ontology(row)
|
|
|
|
|
return ontology_for_domain(normalized_domain)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def clean_seed_urls(values: list[str]) -> list[str]:
|
|
|
|
|
cleaned: list[str] = []
|
|
|
|
|
seen: set[str] = set()
|
|
|
|
|
for value in values:
|
|
|
|
|
url = value.strip()
|
|
|
|
|
if not url or url in seen:
|
|
|
|
|
continue
|
|
|
|
|
if not (url.startswith("http://") or url.startswith("https://") or url.startswith("file://")):
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Unsupported URL: {url}")
|
|
|
|
|
cleaned.append(url)
|
|
|
|
|
seen.add(url)
|
|
|
|
|
if not cleaned:
|
|
|
|
|
raise HTTPException(status_code=400, detail="At least one reference URL is required.")
|
|
|
|
|
return cleaned
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def domain_discovery_job_payload(job: models.DomainDiscoveryJob) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"job_id": job.id,
|
|
|
|
|
"domain": job.domain,
|
|
|
|
|
"status": job.status,
|
|
|
|
|
"seed_urls": list(job.seed_urls or []),
|
|
|
|
|
"max_pages": job.max_pages,
|
|
|
|
|
"max_depth": job.max_depth,
|
|
|
|
|
"same_domain_only": job.same_domain_only,
|
|
|
|
|
"fetcher": job.fetcher,
|
|
|
|
|
"respect_robots_txt": job.respect_robots_txt,
|
|
|
|
|
"error": job.error,
|
|
|
|
|
"progress": job.progress or {},
|
|
|
|
|
"result_summary": job.result_summary or {},
|
|
|
|
|
"created_at": job.created_at.isoformat() if job.created_at else None,
|
|
|
|
|
"started_at": job.started_at.isoformat() if job.started_at else None,
|
|
|
|
|
"finished_at": job.finished_at.isoformat() if job.finished_at else None,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def domain_reference_source_payload(row: models.DomainReferenceSource) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"id": row.id,
|
|
|
|
|
"domain": row.domain,
|
|
|
|
|
"url": row.url,
|
|
|
|
|
"label": row.label,
|
|
|
|
|
"status": row.status,
|
|
|
|
|
"last_crawled_at": row.last_crawled_at.isoformat() if row.last_crawled_at else None,
|
|
|
|
|
"metadata": row.metadata_json or {},
|
|
|
|
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
|
|
|
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def already_crawled_reference_urls(session, domain: str, seed_urls: list[str]) -> set[str]:
|
|
|
|
|
if not seed_urls:
|
|
|
|
|
return set()
|
|
|
|
|
rows = session.scalars(
|
|
|
|
|
select(models.DomainReferenceSource).where(
|
|
|
|
|
models.DomainReferenceSource.domain == domain,
|
|
|
|
|
models.DomainReferenceSource.url.in_(seed_urls),
|
|
|
|
|
models.DomainReferenceSource.status == "active",
|
|
|
|
|
models.DomainReferenceSource.last_crawled_at.is_not(None),
|
|
|
|
|
)
|
|
|
|
|
).all()
|
|
|
|
|
return {row.url for row in rows}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def domain_candidate_payload(row: models.DomainSchemaCandidate, evidence_rows: list[models.DomainCandidateEvidence]) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"id": row.id,
|
|
|
|
|
"domain": row.domain,
|
|
|
|
|
"job_id": row.job_id,
|
|
|
|
|
"candidate_type": row.candidate_type,
|
|
|
|
|
"name": row.name,
|
|
|
|
|
"description": row.description,
|
|
|
|
|
"confidence": row.confidence,
|
|
|
|
|
"occurrence_count": row.occurrence_count,
|
|
|
|
|
"status": row.status,
|
|
|
|
|
"metadata": row.metadata_json or {},
|
|
|
|
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
|
|
|
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
|
|
|
|
"evidence": [
|
|
|
|
|
{
|
|
|
|
|
"id": evidence.id,
|
|
|
|
|
"job_id": evidence.job_id,
|
|
|
|
|
"url": evidence.url,
|
|
|
|
|
"title": evidence.title,
|
|
|
|
|
"snippet": evidence.snippet,
|
|
|
|
|
"metadata": evidence.metadata_json or {},
|
|
|
|
|
"created_at": evidence.created_at.isoformat() if evidence.created_at else None,
|
|
|
|
|
}
|
|
|
|
|
for evidence in evidence_rows
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def run_domain_discovery_job(database_url: str, job_id: int) -> None:
|
|
|
|
|
try:
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
DomainDiscoveryService(session).run_job(job_id)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
job = session.get(models.DomainDiscoveryJob, job_id)
|
|
|
|
|
if job is not None:
|
|
|
|
|
job.status = "failed"
|
|
|
|
|
job.error = str(exc)
|
|
|
|
|
job.finished_at = models.utcnow()
|
|
|
|
|
progress = dict(job.progress or {})
|
|
|
|
|
progress["errors"] = [*progress.get("errors", []), str(exc)]
|
|
|
|
|
job.progress = progress
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def recover_interrupted_domain_discovery_jobs(database_url: str) -> None:
|
|
|
|
|
"""Mark domain discovery jobs that cannot survive a server restart."""
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
rows = session.scalars(
|
|
|
|
|
select(models.DomainDiscoveryJob).where(
|
|
|
|
|
models.DomainDiscoveryJob.status.in_(
|
|
|
|
|
["pending", "running", "cancel_requested"]
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
).all()
|
|
|
|
|
for job in rows:
|
|
|
|
|
progress = dict(job.progress or {})
|
|
|
|
|
errors = list(progress.get("errors") or [])
|
|
|
|
|
if job.status == "cancel_requested":
|
|
|
|
|
job.status = "canceled"
|
|
|
|
|
progress["completion_reason"] = "canceled"
|
|
|
|
|
else:
|
|
|
|
|
job.status = "failed"
|
|
|
|
|
job.error = "Server restarted before this background analysis finished."
|
|
|
|
|
progress["completion_reason"] = "interrupted"
|
|
|
|
|
errors.append(job.error)
|
|
|
|
|
progress["errors"] = errors[-10:]
|
|
|
|
|
job.progress = progress
|
|
|
|
|
job.finished_at = models.utcnow()
|
|
|
|
|
|
|
|
|
|
|
2026-05-14 23:37:21 +09:00
|
|
|
def project_config_from_project_row(session, project: models.Project) -> ProjectConfig:
|
|
|
|
|
config_dict = dict(project.config or {})
|
|
|
|
|
if not config_dict:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail=f"Project '{project.name}' has no stored config",
|
|
|
|
|
)
|
|
|
|
|
config = project_config_from_dict(config_dict)
|
|
|
|
|
config.sources = [
|
|
|
|
|
source_model_to_config(source)
|
|
|
|
|
for source in session.scalars(
|
|
|
|
|
select(models.Source).where(models.Source.project_id == project.id)
|
|
|
|
|
).all()
|
|
|
|
|
]
|
|
|
|
|
return config
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def project_config_to_dict(config: ProjectConfig) -> dict[str, Any]:
|
|
|
|
|
return {
|
|
|
|
|
"project_name": config.project_name,
|
|
|
|
|
"domain": config.domain,
|
|
|
|
|
"target_entities": list(config.target_entities),
|
|
|
|
|
"fields": list(config.fields),
|
|
|
|
|
"sources": [asdict(source) for source in config.sources],
|
|
|
|
|
"ontology": dict(config.ontology),
|
|
|
|
|
"recommendation": dict(config.recommendation),
|
|
|
|
|
"update_policy": dict(config.update_policy),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-05-20 20:59:40 +09:00
|
|
|
def delete_project_data(session, project_id: int, include_project: bool = False) -> dict[str, int]:
|
|
|
|
|
repo = KnowledgeRepository(session)
|
|
|
|
|
deleted = repo.reset_project_runtime_data(project_id)
|
|
|
|
|
for table_model in [
|
|
|
|
|
models.OntologyProposal,
|
|
|
|
|
models.OntologyTriple,
|
|
|
|
|
models.OntologyRelationType,
|
|
|
|
|
models.OntologyEntityType,
|
|
|
|
|
models.Source,
|
|
|
|
|
]:
|
|
|
|
|
count = (
|
|
|
|
|
session.query(table_model)
|
|
|
|
|
.filter(table_model.project_id == project_id)
|
|
|
|
|
.delete(synchronize_session=False)
|
|
|
|
|
)
|
|
|
|
|
deleted[table_model.__tablename__] = deleted.get(table_model.__tablename__, 0) + int(count or 0)
|
|
|
|
|
if include_project:
|
|
|
|
|
project_count = (
|
|
|
|
|
session.query(models.Project)
|
|
|
|
|
.filter(models.Project.id == project_id)
|
|
|
|
|
.delete(synchronize_session=False)
|
|
|
|
|
)
|
|
|
|
|
deleted[models.Project.__tablename__] = int(project_count or 0)
|
|
|
|
|
return deleted
|
|
|
|
|
|
|
|
|
|
|
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>
2026-05-14 19:10:37 +09:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
Phase 1.4: 자율 연구 — by-project 엔드포인트 + ResearchPage + 세션 이력
백엔드 (crawler_platform/app/api/routes.py):
- POST /research/run/by-project 추가 — config_path 의존 제거
- ResearchRunByProjectRequest: project_name 기반, DB project.config 재구성
- GraphResearchLoop 동기 실행 (장시간 가능), asdict(result) 반환
- 기존 /projects/{n}/research/sessions, /research/sessions/{job_id}는 변경 없음
프론트엔드 API (src/lib/api/):
- research.ts: researchApi.startByProject/listSessions/getSession + Zod 스키마
- 결과 페이로드는 passthrough() (백엔드 dataclass 그대로 노출)
TanStack Query 훅 (src/hooks/):
- useResearch.ts: useStartResearch, useResearchSessions, useResearchSession
- queryKeys에 research.sessions, research.session 키
ResearchPage 신규 (src/pages/ResearchPage.tsx):
- 2열 레이아웃: 좌측 연구 설정 폼 + 우측 결과/이력
- 폼: 소스 선택, goal 텍스트(필수, 3~500자), 시드 URL(선택),
max_steps/max_branch/max_depth/min_relevance, same_domain_only
react-hook-form + zod 검증
- 결과 카드: 단계/페이지/엔티티/클레임 4종 stats + raw JSON details
- 세션 이력 카드: GET /projects/{n}/research/sessions 결과 리스트
- 실행 중 안내 (장시간 가능), sonner 토스트
라우팅 & Sidebar:
- App.tsx: /research/:projectId 라우트 추가
- AppShell: 사이드바에 "자율 연구" 메뉴 (Brain 아이콘)
i18n: research.*, nav.research 키 (한/영)
다음 단계: Phase 1.5 — 엔티티/클레임 직접 입력
(백엔드 POST /projects/{n}/entities, /claims 신규 필요)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 19:01:52 +09:00
|
|
|
class ResearchRunByProjectRequest(BaseModel):
|
|
|
|
|
"""Run autonomous research against an existing DB project (no config_path)."""
|
|
|
|
|
|
|
|
|
|
project_name: str
|
|
|
|
|
source_name: str
|
|
|
|
|
url: str | None = None
|
|
|
|
|
seed_entity_id: int | None = None
|
|
|
|
|
goal: str = "Semantic ontology exploration"
|
2026-05-22 00:22:03 +09:00
|
|
|
extraction_mode: str = "hybrid"
|
Phase 1.4: 자율 연구 — by-project 엔드포인트 + ResearchPage + 세션 이력
백엔드 (crawler_platform/app/api/routes.py):
- POST /research/run/by-project 추가 — config_path 의존 제거
- ResearchRunByProjectRequest: project_name 기반, DB project.config 재구성
- GraphResearchLoop 동기 실행 (장시간 가능), asdict(result) 반환
- 기존 /projects/{n}/research/sessions, /research/sessions/{job_id}는 변경 없음
프론트엔드 API (src/lib/api/):
- research.ts: researchApi.startByProject/listSessions/getSession + Zod 스키마
- 결과 페이로드는 passthrough() (백엔드 dataclass 그대로 노출)
TanStack Query 훅 (src/hooks/):
- useResearch.ts: useStartResearch, useResearchSessions, useResearchSession
- queryKeys에 research.sessions, research.session 키
ResearchPage 신규 (src/pages/ResearchPage.tsx):
- 2열 레이아웃: 좌측 연구 설정 폼 + 우측 결과/이력
- 폼: 소스 선택, goal 텍스트(필수, 3~500자), 시드 URL(선택),
max_steps/max_branch/max_depth/min_relevance, same_domain_only
react-hook-form + zod 검증
- 결과 카드: 단계/페이지/엔티티/클레임 4종 stats + raw JSON details
- 세션 이력 카드: GET /projects/{n}/research/sessions 결과 리스트
- 실행 중 안내 (장시간 가능), sonner 토스트
라우팅 & Sidebar:
- App.tsx: /research/:projectId 라우트 추가
- AppShell: 사이드바에 "자율 연구" 메뉴 (Brain 아이콘)
i18n: research.*, nav.research 키 (한/영)
다음 단계: Phase 1.5 — 엔티티/클레임 직접 입력
(백엔드 POST /projects/{n}/entities, /claims 신규 필요)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 19:01:52 +09:00
|
|
|
extractor_provider: str = "lm_studio"
|
|
|
|
|
extractor_model: str | None = None
|
|
|
|
|
extractor_base_url: str | None = "http://localhost:1234/v1"
|
2026-05-22 00:22:03 +09:00
|
|
|
fallback_to_rules: bool = True
|
Phase 1.4: 자율 연구 — by-project 엔드포인트 + ResearchPage + 세션 이력
백엔드 (crawler_platform/app/api/routes.py):
- POST /research/run/by-project 추가 — config_path 의존 제거
- ResearchRunByProjectRequest: project_name 기반, DB project.config 재구성
- GraphResearchLoop 동기 실행 (장시간 가능), asdict(result) 반환
- 기존 /projects/{n}/research/sessions, /research/sessions/{job_id}는 변경 없음
프론트엔드 API (src/lib/api/):
- research.ts: researchApi.startByProject/listSessions/getSession + Zod 스키마
- 결과 페이로드는 passthrough() (백엔드 dataclass 그대로 노출)
TanStack Query 훅 (src/hooks/):
- useResearch.ts: useStartResearch, useResearchSessions, useResearchSession
- queryKeys에 research.sessions, research.session 키
ResearchPage 신규 (src/pages/ResearchPage.tsx):
- 2열 레이아웃: 좌측 연구 설정 폼 + 우측 결과/이력
- 폼: 소스 선택, goal 텍스트(필수, 3~500자), 시드 URL(선택),
max_steps/max_branch/max_depth/min_relevance, same_domain_only
react-hook-form + zod 검증
- 결과 카드: 단계/페이지/엔티티/클레임 4종 stats + raw JSON details
- 세션 이력 카드: GET /projects/{n}/research/sessions 결과 리스트
- 실행 중 안내 (장시간 가능), sonner 토스트
라우팅 & Sidebar:
- App.tsx: /research/:projectId 라우트 추가
- AppShell: 사이드바에 "자율 연구" 메뉴 (Brain 아이콘)
i18n: research.*, nav.research 키 (한/영)
다음 단계: Phase 1.5 — 엔티티/클레임 직접 입력
(백엔드 POST /projects/{n}/entities, /claims 신규 필요)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 19:01:52 +09:00
|
|
|
check_robots_txt: bool = False
|
|
|
|
|
respect_robots_txt: bool | None = None
|
|
|
|
|
max_depth: int = 2
|
|
|
|
|
max_steps: int = 12
|
|
|
|
|
max_branch: int = 8
|
|
|
|
|
min_relevance: float = 0.35
|
|
|
|
|
same_domain_only: bool = True
|
|
|
|
|
analyze_page_types: list[str] = Field(
|
|
|
|
|
default_factory=lambda: ["ProductPage", "BrandStoryPage", "ReviewPage"]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-05-08 17:41:15 +09:00
|
|
|
class UpdateClaimConfidenceRequest(BaseModel):
|
|
|
|
|
confidence: float
|
|
|
|
|
reason: str | None = None
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 19:40:31 +09:00
|
|
|
class UpdateClaimStatusRequest(BaseModel):
|
|
|
|
|
status: str
|
|
|
|
|
reason: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BulkClaimStatusRequest(BaseModel):
|
|
|
|
|
claim_ids: list[int]
|
|
|
|
|
status: str
|
|
|
|
|
reason: str | None = None
|
|
|
|
|
|
|
|
|
|
|
2026-05-19 20:31:52 +09:00
|
|
|
class SchemaEntityTypeRequest(BaseModel):
|
|
|
|
|
name: str
|
|
|
|
|
domain: str = "generic"
|
|
|
|
|
description: str | None = None
|
|
|
|
|
status: str = "active"
|
|
|
|
|
confidence: float = 1.0
|
|
|
|
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SchemaRelationTypeRequest(BaseModel):
|
|
|
|
|
name: str
|
|
|
|
|
domain: str = "generic"
|
|
|
|
|
description: str | None = None
|
|
|
|
|
allowed_subject_types: list[str] = Field(default_factory=list)
|
|
|
|
|
allowed_object_types: list[str] = Field(default_factory=list)
|
|
|
|
|
allowed_page_types: list[str] = Field(default_factory=list)
|
|
|
|
|
allowed_source_zones: list[str] = Field(default_factory=list)
|
|
|
|
|
semantic_constraints: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
confidence_rules: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
min_confidence: float | None = None
|
|
|
|
|
status: str = "active"
|
|
|
|
|
confidence: float = 1.0
|
|
|
|
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class PipelineRerunRequest(BaseModel):
|
|
|
|
|
stage: str
|
|
|
|
|
|
|
|
|
|
|
2026-05-08 17:41:15 +09:00
|
|
|
class MergeEntitiesRequest(BaseModel):
|
|
|
|
|
project_name: str
|
|
|
|
|
source_entity_id: int
|
|
|
|
|
target_entity_id: int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ExtractorModelsRequest(BaseModel):
|
|
|
|
|
provider: str
|
|
|
|
|
base_url: str | None = None
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 19:40:31 +09:00
|
|
|
class ResearchRunRequest(CrawlRequest):
|
|
|
|
|
project_name: str | None = None
|
|
|
|
|
seed_entity_id: int | None = None
|
|
|
|
|
goal: str = "Semantic ontology exploration"
|
|
|
|
|
max_depth: int = 2
|
|
|
|
|
max_steps: int = 12
|
|
|
|
|
max_branch: int = 8
|
|
|
|
|
min_relevance: float = 0.35
|
|
|
|
|
same_domain_only: bool = True
|
|
|
|
|
analyze_page_types: list[str] = Field(default_factory=lambda: ["ProductPage", "BrandStoryPage", "ReviewPage"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def site_crawl_progress_payload(result, latest_page=None) -> dict[str, Any]:
|
|
|
|
|
payload = asdict(result)
|
|
|
|
|
if latest_page is not None:
|
|
|
|
|
payload["latest_page"] = asdict(latest_page)
|
2026-05-22 00:22:03 +09:00
|
|
|
pages = payload.get("pages") or []
|
|
|
|
|
payload["extraction_summary"] = {
|
|
|
|
|
"llm_skipped_count": sum(1 for page in pages if page.get("llm_skipped")),
|
|
|
|
|
"fallback_count": sum(1 for page in pages if page.get("fallback_used")),
|
|
|
|
|
"conflict_claim_count": sum(int(page.get("conflict_claim_count") or 0) for page in pages),
|
|
|
|
|
"agreement_claim_count": sum(int(page.get("agreement_claim_count") or 0) for page in pages),
|
|
|
|
|
"llm_call_count": sum(
|
|
|
|
|
1
|
|
|
|
|
for page in pages
|
|
|
|
|
if page.get("extraction_mode") in {"hybrid", "llm_only", "compare"}
|
|
|
|
|
and not page.get("llm_skipped")
|
|
|
|
|
and not page.get("fallback_used")
|
|
|
|
|
),
|
|
|
|
|
}
|
2026-05-12 19:40:31 +09:00
|
|
|
return payload
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_CLAIM_STATUS_ALIASES = {
|
|
|
|
|
"accept": "validated_claim",
|
|
|
|
|
"accepted": "validated_claim",
|
|
|
|
|
"validated": "validated_claim",
|
|
|
|
|
"validated_claim": "validated_claim",
|
|
|
|
|
"reject": "rejected",
|
|
|
|
|
"rejected": "rejected",
|
|
|
|
|
"active": "active",
|
|
|
|
|
"candidate": "active",
|
|
|
|
|
"pending": "active",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_claim_status(value: str | None) -> str | None:
|
|
|
|
|
if not value:
|
|
|
|
|
return None
|
|
|
|
|
return _CLAIM_STATUS_ALIASES.get(value.strip().lower())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _apply_claim_review(claim: models.Claim, status: str, reason: str | None) -> None:
|
|
|
|
|
claim.status = status
|
|
|
|
|
if reason:
|
|
|
|
|
claim.confidence_reason = reason
|
|
|
|
|
metadata = dict(claim.metadata_json or {})
|
|
|
|
|
review = dict(metadata.get("review") or {})
|
|
|
|
|
review["status"] = status
|
|
|
|
|
review["reason"] = reason or review.get("reason")
|
|
|
|
|
review["reviewed_at"] = models.utcnow().isoformat()
|
|
|
|
|
metadata["review"] = review
|
|
|
|
|
claim.metadata_json = metadata
|
|
|
|
|
claim.last_seen_at = models.utcnow()
|
|
|
|
|
|
|
|
|
|
|
2026-05-19 20:31:52 +09:00
|
|
|
def turtle_id(value: Any) -> str:
|
|
|
|
|
text = "".join(ch if ch.isalnum() else "_" for ch in str(value or "").strip())
|
|
|
|
|
text = "_".join(part for part in text.split("_") if part)
|
|
|
|
|
if not text:
|
|
|
|
|
return "value"
|
|
|
|
|
if text[0].isdigit():
|
|
|
|
|
text = f"n_{text}"
|
|
|
|
|
return text[:120]
|
|
|
|
|
|
|
|
|
|
|
2026-05-13 19:57:34 +09:00
|
|
|
def apply_crawl_request_overrides(config, request: CrawlRequest | DiscoverRequest) -> None:
|
|
|
|
|
check_robots_txt = request.respect_robots_txt
|
|
|
|
|
if check_robots_txt is None:
|
|
|
|
|
check_robots_txt = request.check_robots_txt
|
|
|
|
|
config.source_by_name(request.source_name).respect_robots_txt = check_robots_txt
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 19:40:31 +09:00
|
|
|
def crawl_job_response(job: models.CrawlJob) -> dict[str, Any]:
|
|
|
|
|
metadata = job.metadata_json or {}
|
|
|
|
|
return {
|
|
|
|
|
"job_id": job.id,
|
|
|
|
|
"status": job.status,
|
|
|
|
|
"url": job.url,
|
|
|
|
|
"error": job.error,
|
|
|
|
|
"scheduled_at": job.scheduled_at.isoformat() if job.scheduled_at else None,
|
|
|
|
|
"started_at": job.started_at.isoformat() if job.started_at else None,
|
|
|
|
|
"finished_at": job.finished_at.isoformat() if job.finished_at else None,
|
|
|
|
|
"progress": metadata.get("progress", {}),
|
|
|
|
|
"request": metadata.get("request", {}),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def update_site_crawl_job_metadata(job: models.CrawlJob, **updates: Any) -> None:
|
|
|
|
|
metadata = dict(job.metadata_json or {})
|
|
|
|
|
metadata.update(updates)
|
|
|
|
|
job.metadata_json = metadata
|
|
|
|
|
|
|
|
|
|
|
2026-05-22 00:22:03 +09:00
|
|
|
def mark_orphan_site_crawl_jobs_canceled(database_url: str) -> None:
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
rows = session.scalars(
|
|
|
|
|
select(models.CrawlJob).where(
|
|
|
|
|
models.CrawlJob.status.in_(["running", "cancel_requested"]),
|
|
|
|
|
)
|
|
|
|
|
).all()
|
|
|
|
|
for job in rows:
|
|
|
|
|
if (job.metadata_json or {}).get("kind") != "site_crawl":
|
|
|
|
|
continue
|
|
|
|
|
job.status = "canceled"
|
|
|
|
|
job.error = job.error or "canceled after server restart/interrupted crawl"
|
|
|
|
|
job.finished_at = models.utcnow()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def request_site_crawl_cancel(database_url: str, job_id: int) -> dict[str, Any]:
|
|
|
|
|
SITE_CRAWL_CANCEL_REQUESTS.add(job_id)
|
|
|
|
|
last_error: Exception | None = None
|
|
|
|
|
for attempt in range(3):
|
|
|
|
|
try:
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
job = session.get(models.CrawlJob, job_id)
|
|
|
|
|
if job is None or (job.metadata_json or {}).get("kind") != "site_crawl":
|
|
|
|
|
raise HTTPException(status_code=404, detail="site crawl job not found")
|
|
|
|
|
if job.status in {"completed", "failed", "canceled"}:
|
|
|
|
|
SITE_CRAWL_CANCEL_REQUESTS.discard(job_id)
|
|
|
|
|
return crawl_job_response(job)
|
|
|
|
|
job.status = "cancel_requested"
|
|
|
|
|
job.error = "cancel requested by user"
|
|
|
|
|
payload = crawl_job_response(job)
|
|
|
|
|
return payload
|
|
|
|
|
except OperationalError as exc:
|
|
|
|
|
last_error = exc
|
|
|
|
|
if "database is locked" not in str(exc).lower():
|
|
|
|
|
raise
|
|
|
|
|
time.sleep(0.25 * (attempt + 1))
|
|
|
|
|
return {
|
|
|
|
|
"job_id": job_id,
|
|
|
|
|
"status": "cancel_requested",
|
|
|
|
|
"url": None,
|
|
|
|
|
"error": f"cancel requested in memory; database was locked: {last_error}",
|
|
|
|
|
"scheduled_at": None,
|
|
|
|
|
"started_at": None,
|
|
|
|
|
"finished_at": None,
|
|
|
|
|
"progress": {},
|
|
|
|
|
"request": {},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-05-12 19:40:31 +09:00
|
|
|
def is_site_crawl_cancel_requested(session, job_id: int) -> bool:
|
2026-05-22 00:22:03 +09:00
|
|
|
if job_id in SITE_CRAWL_CANCEL_REQUESTS:
|
|
|
|
|
return True
|
2026-05-12 19:40:31 +09:00
|
|
|
session.expire_all()
|
|
|
|
|
job = session.get(models.CrawlJob, job_id)
|
|
|
|
|
return job is None or job.status == "cancel_requested"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def run_site_crawl_job(database_url: str, job_id: int, request_data: dict[str, Any]) -> None:
|
Phase 1.3: 시드 크롤 — by-project 엔드포인트 + CrawlPage 폼/폴링/취소
세션 영구화: UI_REBUILD_PLAN.md 신설
- 사용자 비전 5가지 흐름 + Phase 보드 + 작업 재개 가이드
- 세션이 끊겨도 이 파일 + git log로 어디까지 했는지 즉시 복원
백엔드 (crawler_platform/app/api/):
- routes.py: POST /crawl-site/by-project 추가 — config_path 의존 제거
- SiteCrawlByProjectRequest: project_name 기반, DB project.config 재구성
- run_site_crawl_job이 __config_dict 메타키 인식하도록 최소 변경
- config/loader.py: project_config_from_dict() 헬퍼 추출 (DB dict ↔ ProjectConfig)
프론트엔드 API (src/lib/api/):
- crawl.ts: crawlApi.startByProject/getJob/cancel + Zod 스키마
- 종료 상태 판정 헬퍼 isCrawlTerminal()
TanStack Query 훅 (src/hooks/):
- useCrawl.ts: useStartSiteCrawl, useCrawlJob (2초 폴링, 종료 시 자동 중단), useCancelCrawl
- queryKeys에 crawl.job 키
UI 프리미티브 (src/components/ui/):
- select.tsx, progress.tsx, badge.tsx (success/warning/destructive variants 포함)
CrawlPage 완전 교체 (src/pages/):
- 2열 레이아웃: 좌측 크롤 설정 폼 + 우측 진행 상태
- 폼: 소스 선택 (드롭다운, 비어있으면 소스 추가 안내) + 시드 URL + max_depth/max_pages + same_domain_only
- react-hook-form + zod 검증
- 진행 상태: 상태 배지, 진행률 바, visited/queued/analyzed 카운터, 최근 페이지, 에러 details
- 크롤 종료시 폴링 자동 중단, 완료시 "결과 검토" 버튼
- 취소 버튼 → /crawl-site/jobs/{id}/cancel
- sonner 토스트
i18n: crawl.* 키 (한/영)
다음 단계: Phase 1.4 — 자율 연구 (POST /research/run by-project 마이그레이션 + ResearchPage)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:04:41 +09:00
|
|
|
inline_config = request_data.pop("__config_dict", None)
|
2026-05-12 19:40:31 +09:00
|
|
|
request = SiteCrawlRequest(**request_data)
|
|
|
|
|
try:
|
Phase 1.3: 시드 크롤 — by-project 엔드포인트 + CrawlPage 폼/폴링/취소
세션 영구화: UI_REBUILD_PLAN.md 신설
- 사용자 비전 5가지 흐름 + Phase 보드 + 작업 재개 가이드
- 세션이 끊겨도 이 파일 + git log로 어디까지 했는지 즉시 복원
백엔드 (crawler_platform/app/api/):
- routes.py: POST /crawl-site/by-project 추가 — config_path 의존 제거
- SiteCrawlByProjectRequest: project_name 기반, DB project.config 재구성
- run_site_crawl_job이 __config_dict 메타키 인식하도록 최소 변경
- config/loader.py: project_config_from_dict() 헬퍼 추출 (DB dict ↔ ProjectConfig)
프론트엔드 API (src/lib/api/):
- crawl.ts: crawlApi.startByProject/getJob/cancel + Zod 스키마
- 종료 상태 판정 헬퍼 isCrawlTerminal()
TanStack Query 훅 (src/hooks/):
- useCrawl.ts: useStartSiteCrawl, useCrawlJob (2초 폴링, 종료 시 자동 중단), useCancelCrawl
- queryKeys에 crawl.job 키
UI 프리미티브 (src/components/ui/):
- select.tsx, progress.tsx, badge.tsx (success/warning/destructive variants 포함)
CrawlPage 완전 교체 (src/pages/):
- 2열 레이아웃: 좌측 크롤 설정 폼 + 우측 진행 상태
- 폼: 소스 선택 (드롭다운, 비어있으면 소스 추가 안내) + 시드 URL + max_depth/max_pages + same_domain_only
- react-hook-form + zod 검증
- 진행 상태: 상태 배지, 진행률 바, visited/queued/analyzed 카운터, 최근 페이지, 에러 details
- 크롤 종료시 폴링 자동 중단, 완료시 "결과 검토" 버튼
- 취소 버튼 → /crawl-site/jobs/{id}/cancel
- sonner 토스트
i18n: crawl.* 키 (한/영)
다음 단계: Phase 1.4 — 자율 연구 (POST /research/run by-project 마이그레이션 + ResearchPage)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:04:41 +09:00
|
|
|
if inline_config is not None:
|
|
|
|
|
config = project_config_from_dict(inline_config)
|
|
|
|
|
else:
|
|
|
|
|
config = load_project_config(request.config_path)
|
2026-05-13 19:57:34 +09:00
|
|
|
apply_crawl_request_overrides(config, request)
|
2026-05-12 19:40:31 +09:00
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
job = session.get(models.CrawlJob, job_id)
|
|
|
|
|
if job is None:
|
|
|
|
|
return
|
|
|
|
|
if job.status == "cancel_requested":
|
|
|
|
|
job.status = "canceled"
|
|
|
|
|
job.finished_at = models.utcnow()
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
job.status = "running"
|
|
|
|
|
job.started_at = models.utcnow()
|
|
|
|
|
update_site_crawl_job_metadata(
|
|
|
|
|
job,
|
|
|
|
|
progress={
|
|
|
|
|
"seed_url": request.url,
|
|
|
|
|
"visited_count": 0,
|
|
|
|
|
"analyzed_count": 0,
|
|
|
|
|
"queued_count": 1,
|
|
|
|
|
"skipped_count": 0,
|
|
|
|
|
"errors": [],
|
|
|
|
|
"pages": [],
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
session.commit()
|
|
|
|
|
|
|
|
|
|
repo = KnowledgeRepository(session)
|
|
|
|
|
crawler = SiteCrawler(
|
|
|
|
|
repo,
|
|
|
|
|
extractor_for_domain(
|
|
|
|
|
config.domain,
|
|
|
|
|
provider=request.extractor_provider,
|
|
|
|
|
model=request.extractor_model,
|
|
|
|
|
base_url=request.extractor_base_url,
|
2026-05-22 00:22:03 +09:00
|
|
|
extraction_mode=request.extraction_mode,
|
|
|
|
|
fallback_to_rules=request.fallback_to_rules,
|
2026-05-12 19:40:31 +09:00
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def save_progress(result, latest_page) -> None:
|
|
|
|
|
current_job = session.get(models.CrawlJob, job_id)
|
|
|
|
|
if current_job is None:
|
|
|
|
|
return
|
|
|
|
|
update_site_crawl_job_metadata(
|
|
|
|
|
current_job,
|
|
|
|
|
progress=site_crawl_progress_payload(result, latest_page),
|
|
|
|
|
)
|
|
|
|
|
session.commit()
|
|
|
|
|
|
|
|
|
|
def should_stop() -> bool:
|
|
|
|
|
return is_site_crawl_cancel_requested(session, job_id)
|
|
|
|
|
|
|
|
|
|
result = crawler.crawl_site(
|
|
|
|
|
config,
|
|
|
|
|
request.source_name,
|
|
|
|
|
request.url,
|
|
|
|
|
max_depth=max(request.max_depth, 0),
|
|
|
|
|
max_pages=max(min(request.max_pages, 500), 1),
|
|
|
|
|
same_domain_only=request.same_domain_only,
|
|
|
|
|
analyze_page_types=set(request.analyze_page_types),
|
|
|
|
|
progress_callback=save_progress,
|
|
|
|
|
should_stop=should_stop,
|
|
|
|
|
parent_job_id=job_id,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
finished_job = session.get(models.CrawlJob, job_id)
|
|
|
|
|
if finished_job is not None:
|
2026-05-22 00:22:03 +09:00
|
|
|
finished_job.status = (
|
|
|
|
|
"canceled"
|
|
|
|
|
if finished_job.status == "cancel_requested" or job_id in SITE_CRAWL_CANCEL_REQUESTS
|
|
|
|
|
else "completed"
|
|
|
|
|
)
|
2026-05-12 19:40:31 +09:00
|
|
|
finished_job.finished_at = models.utcnow()
|
|
|
|
|
update_site_crawl_job_metadata(
|
|
|
|
|
finished_job,
|
|
|
|
|
progress=site_crawl_progress_payload(result),
|
|
|
|
|
)
|
2026-05-22 00:22:03 +09:00
|
|
|
SITE_CRAWL_CANCEL_REQUESTS.discard(job_id)
|
2026-05-12 19:40:31 +09:00
|
|
|
except Exception as exc:
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
job = session.get(models.CrawlJob, job_id)
|
|
|
|
|
if job is not None:
|
|
|
|
|
job.status = "failed"
|
|
|
|
|
job.error = str(exc)
|
|
|
|
|
job.finished_at = models.utcnow()
|
|
|
|
|
metadata = dict(job.metadata_json or {})
|
|
|
|
|
progress = dict(metadata.get("progress") or {})
|
|
|
|
|
progress["errors"] = [*progress.get("errors", []), str(exc)]
|
|
|
|
|
update_site_crawl_job_metadata(job, progress=progress)
|
2026-05-22 00:22:03 +09:00
|
|
|
SITE_CRAWL_CANCEL_REQUESTS.discard(job_id)
|
2026-05-12 19:40:31 +09:00
|
|
|
|
|
|
|
|
|
2026-05-08 17:41:15 +09:00
|
|
|
def register_routes(app, database_url: str) -> None:
|
2026-05-20 20:59:40 +09:00
|
|
|
recover_interrupted_domain_discovery_jobs(database_url)
|
2026-05-22 00:22:03 +09:00
|
|
|
mark_orphan_site_crawl_jobs_canceled(database_url)
|
2026-05-20 20:59:40 +09:00
|
|
|
|
2026-05-08 17:41:15 +09:00
|
|
|
@app.get("/health")
|
|
|
|
|
def health():
|
|
|
|
|
return {"ok": True}
|
|
|
|
|
|
|
|
|
|
@app.get("/projects")
|
|
|
|
|
def projects():
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
rows = session.scalars(select(models.Project).order_by(models.Project.created_at.desc())).all()
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
"id": project.id,
|
|
|
|
|
"name": project.name,
|
|
|
|
|
"domain": project.domain,
|
|
|
|
|
"created_at": project.created_at.isoformat(),
|
|
|
|
|
"updated_at": project.updated_at.isoformat(),
|
|
|
|
|
}
|
|
|
|
|
for project in rows
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
@app.post("/projects")
|
|
|
|
|
def create_project(request: CreateProjectRequest):
|
|
|
|
|
config = load_project_config(request.config_path)
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
project = KnowledgeRepository(session).upsert_project(config)
|
|
|
|
|
return {"id": project.id, "name": project.name, "domain": project.domain}
|
|
|
|
|
|
Phase 1.1: 프로젝트 생성 — 폼 기반 도메인 선택 + 인라인 JSON 생성
백엔드 (crawler_platform/app/api/routes.py):
- POST /projects/inline 추가: 파일 시스템 의존 없이 JSON 본문으로 ProjectConfig 인라인 생성
- CreateProjectInlineRequest + InlineSourceConfig Pydantic 모델
- GET /domains 추가: 미리 정의된 도메인 목록 (perfume, tea, coffee, candle, supplement, gift)
- 각 도메인의 entity_types, predicates, attribute_count 반환
프론트엔드 API 레이어 (src/lib/api/):
- domains.ts: domainsApi.list + Zod 스키마
- ontology.ts: ontologyApi.get(domain) + Zod 스키마
- projects.ts: createInline() 메서드 + CreateProjectInlineRequest 타입
TanStack Query 훅 (src/hooks/):
- useDomains: 도메인 목록 (30분 staleTime)
- useOntology(domain): 도메인 상세
- useCreateProjectInline: 인라인 생성 mutation + projects 캐시 무효화
- queryKeys에 domains, ontology 키 팩토리 추가
UI 프리미티브 (src/components/ui/):
- input.tsx, label.tsx, textarea.tsx
OnboardingPage 완전 교체 (src/pages/OnboardingPage.tsx):
- react-hook-form + zod 검증 (project_name 정규식, 도메인 필수)
- 도메인 카드 그리드 선택 (entity/predicate 개수 미리보기)
- 백엔드 /projects/inline POST → 성공 시 sonner 토스트 + /sources/{name}으로 이동
- 로딩/에러/재시도 UI
기타:
- Vite proxy에 /domains prefix 추가
- i18n locale 키 onboarding.* 추가 (한/영)
다음 단계: Phase 1.2 — 온톨로지 엔티티/클레임 직접 입력 + URL 추출
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 14:46:03 +09:00
|
|
|
@app.post("/projects/inline")
|
|
|
|
|
def create_project_inline(request: CreateProjectInlineRequest):
|
|
|
|
|
config = request.to_project_config()
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
project = KnowledgeRepository(session).upsert_project(config)
|
|
|
|
|
return {"id": project.id, "name": project.name, "domain": project.domain}
|
|
|
|
|
|
|
|
|
|
@app.get("/domains")
|
|
|
|
|
def list_domains():
|
2026-05-20 20:59:40 +09:00
|
|
|
"""Available ontology domains for project creation."""
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
rows = session.scalars(select(models.DomainDefinition)).all()
|
|
|
|
|
custom_by_domain = {row.domain: row for row in rows}
|
|
|
|
|
payloads = []
|
|
|
|
|
for domain, built_in in DOMAIN_ONTOLOGIES.items():
|
|
|
|
|
row = custom_by_domain.pop(domain, None)
|
|
|
|
|
if row is not None:
|
|
|
|
|
payloads.append(
|
|
|
|
|
domain_summary_payload(
|
|
|
|
|
domain_row_to_ontology(row),
|
|
|
|
|
is_builtin=True,
|
|
|
|
|
is_custom=True,
|
|
|
|
|
description=row.description,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
else:
|
|
|
|
|
payloads.append(
|
|
|
|
|
domain_summary_payload(
|
|
|
|
|
built_in,
|
|
|
|
|
is_builtin=True,
|
|
|
|
|
is_custom=False,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
for row in sorted(custom_by_domain.values(), key=lambda item: item.domain):
|
|
|
|
|
payloads.append(
|
|
|
|
|
domain_summary_payload(
|
|
|
|
|
domain_row_to_ontology(row),
|
|
|
|
|
is_builtin=False,
|
|
|
|
|
is_custom=True,
|
|
|
|
|
description=row.description,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
return payloads
|
|
|
|
|
|
|
|
|
|
@app.post("/domains")
|
|
|
|
|
def create_domain(request: DomainDefinitionRequest):
|
|
|
|
|
domain = normalize_domain_name(request.domain)
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
existing = session.scalar(
|
|
|
|
|
select(models.DomainDefinition).where(models.DomainDefinition.domain == domain)
|
|
|
|
|
)
|
|
|
|
|
if existing is not None or domain in DOMAIN_ONTOLOGIES:
|
|
|
|
|
raise HTTPException(status_code=409, detail=f"Domain '{domain}' already exists.")
|
|
|
|
|
row = upsert_domain_definition(
|
|
|
|
|
session,
|
|
|
|
|
domain,
|
|
|
|
|
DomainDefinitionUpdateRequest(
|
|
|
|
|
description=request.description,
|
|
|
|
|
entity_types=request.entity_types,
|
|
|
|
|
predicates=request.predicates,
|
|
|
|
|
attributes=request.attributes,
|
|
|
|
|
aliases=request.aliases,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
return domain_summary_payload(
|
|
|
|
|
domain_row_to_ontology(row),
|
|
|
|
|
is_builtin=domain in DOMAIN_ONTOLOGIES,
|
|
|
|
|
is_custom=True,
|
|
|
|
|
description=row.description,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@app.put("/domains/{domain}")
|
|
|
|
|
def update_domain(domain: str, request: DomainDefinitionUpdateRequest):
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
row = upsert_domain_definition(session, domain, request)
|
|
|
|
|
return domain_summary_payload(
|
|
|
|
|
domain_row_to_ontology(row),
|
|
|
|
|
is_builtin=row.domain in DOMAIN_ONTOLOGIES,
|
|
|
|
|
is_custom=True,
|
|
|
|
|
description=row.description,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@app.get("/domains/{domain}/discovery/jobs")
|
|
|
|
|
def list_domain_discovery_jobs(domain: str, limit: int = 20):
|
|
|
|
|
normalized_domain = normalize_domain_name(domain)
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
rows = session.scalars(
|
|
|
|
|
select(models.DomainDiscoveryJob)
|
|
|
|
|
.where(models.DomainDiscoveryJob.domain == normalized_domain)
|
|
|
|
|
.order_by(models.DomainDiscoveryJob.created_at.desc())
|
|
|
|
|
.limit(max(min(limit, 100), 1))
|
|
|
|
|
).all()
|
|
|
|
|
return [domain_discovery_job_payload(row) for row in rows]
|
|
|
|
|
|
|
|
|
|
@app.get("/domains/{domain}/reference-sources")
|
|
|
|
|
def list_domain_reference_sources(domain: str):
|
|
|
|
|
normalized_domain = normalize_domain_name(domain)
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
rows = session.scalars(
|
|
|
|
|
select(models.DomainReferenceSource)
|
|
|
|
|
.where(models.DomainReferenceSource.domain == normalized_domain)
|
|
|
|
|
.order_by(models.DomainReferenceSource.updated_at.desc())
|
|
|
|
|
).all()
|
|
|
|
|
return [domain_reference_source_payload(row) for row in rows]
|
|
|
|
|
|
|
|
|
|
@app.post("/domains/{domain}/discovery/jobs")
|
|
|
|
|
def create_domain_discovery_job(
|
|
|
|
|
domain: str,
|
|
|
|
|
request: DomainDiscoveryRequest,
|
|
|
|
|
background_tasks: BackgroundTasks,
|
|
|
|
|
):
|
|
|
|
|
normalized_domain = normalize_domain_name(domain)
|
|
|
|
|
seed_urls = clean_seed_urls(request.seed_urls)
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
ensure_domain_definition(session, normalized_domain)
|
|
|
|
|
skipped_seed_urls: list[str] = []
|
|
|
|
|
if not request.force_recrawl:
|
|
|
|
|
skipped = already_crawled_reference_urls(session, normalized_domain, seed_urls)
|
|
|
|
|
skipped_seed_urls = [url for url in seed_urls if url in skipped]
|
|
|
|
|
seed_urls = [url for url in seed_urls if url not in skipped]
|
|
|
|
|
|
|
|
|
|
if not seed_urls:
|
|
|
|
|
job = models.DomainDiscoveryJob(
|
|
|
|
|
domain=normalized_domain,
|
|
|
|
|
status="completed",
|
|
|
|
|
seed_urls=[],
|
|
|
|
|
max_pages=max(min(request.max_pages, 200), 1),
|
|
|
|
|
max_depth=max(min(request.max_depth, 5), 0),
|
|
|
|
|
same_domain_only=request.same_domain_only,
|
|
|
|
|
fetcher=request.fetcher if request.fetcher in {"requests", "playwright", "browser"} else "requests",
|
|
|
|
|
respect_robots_txt=request.respect_robots_txt,
|
|
|
|
|
progress={
|
|
|
|
|
"visited_count": 0,
|
|
|
|
|
"queued_count": 0,
|
|
|
|
|
"candidate_count": 0,
|
|
|
|
|
"observation_count": 0,
|
|
|
|
|
"max_pages": max(min(request.max_pages, 200), 1),
|
|
|
|
|
"skipped_seed_urls": skipped_seed_urls,
|
|
|
|
|
"errors": [],
|
|
|
|
|
"pages": [],
|
|
|
|
|
},
|
|
|
|
|
result_summary={
|
|
|
|
|
"visited_count": 0,
|
|
|
|
|
"candidate_count": 0,
|
|
|
|
|
"skipped_seed_urls": skipped_seed_urls,
|
|
|
|
|
},
|
|
|
|
|
finished_at=models.utcnow(),
|
|
|
|
|
)
|
|
|
|
|
session.add(job)
|
|
|
|
|
session.flush()
|
|
|
|
|
return domain_discovery_job_payload(job)
|
|
|
|
|
|
|
|
|
|
job = models.DomainDiscoveryJob(
|
|
|
|
|
domain=normalized_domain,
|
|
|
|
|
status="pending",
|
|
|
|
|
seed_urls=seed_urls,
|
|
|
|
|
max_pages=max(min(request.max_pages, 200), 1),
|
|
|
|
|
max_depth=max(min(request.max_depth, 5), 0),
|
|
|
|
|
same_domain_only=request.same_domain_only,
|
|
|
|
|
fetcher=request.fetcher if request.fetcher in {"requests", "playwright", "browser"} else "requests",
|
|
|
|
|
respect_robots_txt=request.respect_robots_txt,
|
|
|
|
|
progress={
|
|
|
|
|
"visited_count": 0,
|
|
|
|
|
"queued_count": len(seed_urls),
|
|
|
|
|
"candidate_count": 0,
|
|
|
|
|
"observation_count": 0,
|
|
|
|
|
"max_pages": max(min(request.max_pages, 200), 1),
|
|
|
|
|
"skipped_seed_urls": skipped_seed_urls,
|
|
|
|
|
"errors": [],
|
|
|
|
|
"pages": [],
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
session.add(job)
|
|
|
|
|
session.flush()
|
|
|
|
|
response = domain_discovery_job_payload(job)
|
|
|
|
|
background_tasks.add_task(run_domain_discovery_job, database_url, response["job_id"])
|
|
|
|
|
return response
|
|
|
|
|
|
|
|
|
|
@app.get("/domains/{domain}/discovery/jobs/{job_id}")
|
|
|
|
|
def domain_discovery_job(domain: str, job_id: int):
|
|
|
|
|
normalized_domain = normalize_domain_name(domain)
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
job = session.get(models.DomainDiscoveryJob, job_id)
|
|
|
|
|
if job is None or job.domain != normalized_domain:
|
|
|
|
|
raise HTTPException(status_code=404, detail="domain discovery job not found")
|
|
|
|
|
return domain_discovery_job_payload(job)
|
|
|
|
|
|
|
|
|
|
@app.post("/domains/{domain}/discovery/jobs/{job_id}/cancel")
|
|
|
|
|
def cancel_domain_discovery_job(domain: str, job_id: int):
|
|
|
|
|
normalized_domain = normalize_domain_name(domain)
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
job = session.get(models.DomainDiscoveryJob, job_id)
|
|
|
|
|
if job is None or job.domain != normalized_domain:
|
|
|
|
|
raise HTTPException(status_code=404, detail="domain discovery job not found")
|
|
|
|
|
if job.status in {"completed", "failed", "canceled"}:
|
|
|
|
|
return domain_discovery_job_payload(job)
|
|
|
|
|
job.status = "cancel_requested"
|
|
|
|
|
job.error = "cancel requested by user"
|
|
|
|
|
return domain_discovery_job_payload(job)
|
|
|
|
|
|
|
|
|
|
@app.get("/domains/{domain}/candidates")
|
|
|
|
|
def list_domain_candidates(domain: str, status: str | None = None, limit: int = 200):
|
|
|
|
|
normalized_domain = normalize_domain_name(domain)
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
query = select(models.DomainSchemaCandidate).where(
|
|
|
|
|
models.DomainSchemaCandidate.domain == normalized_domain
|
|
|
|
|
)
|
|
|
|
|
if status:
|
|
|
|
|
query = query.where(models.DomainSchemaCandidate.status == status)
|
|
|
|
|
rows = session.scalars(
|
|
|
|
|
query.order_by(
|
|
|
|
|
models.DomainSchemaCandidate.status,
|
|
|
|
|
models.DomainSchemaCandidate.candidate_type,
|
|
|
|
|
models.DomainSchemaCandidate.confidence.desc(),
|
|
|
|
|
models.DomainSchemaCandidate.name,
|
|
|
|
|
).limit(max(min(limit, 500), 1))
|
|
|
|
|
).all()
|
|
|
|
|
payload = []
|
|
|
|
|
for row in rows:
|
|
|
|
|
evidence = session.scalars(
|
|
|
|
|
select(models.DomainCandidateEvidence)
|
|
|
|
|
.where(models.DomainCandidateEvidence.candidate_id == row.id)
|
|
|
|
|
.order_by(models.DomainCandidateEvidence.created_at.desc())
|
|
|
|
|
.limit(3)
|
|
|
|
|
).all()
|
|
|
|
|
payload.append(domain_candidate_payload(row, evidence))
|
|
|
|
|
return payload
|
|
|
|
|
|
|
|
|
|
@app.post("/domains/{domain}/candidates/apply")
|
|
|
|
|
def apply_domain_candidates(domain: str, request: DomainCandidateApplyRequest):
|
|
|
|
|
normalized_domain = normalize_domain_name(domain)
|
|
|
|
|
if not request.candidate_ids:
|
|
|
|
|
raise HTTPException(status_code=400, detail="No candidates selected.")
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
definition = ensure_domain_definition(session, normalized_domain)
|
|
|
|
|
rows = session.scalars(
|
|
|
|
|
select(models.DomainSchemaCandidate).where(
|
|
|
|
|
models.DomainSchemaCandidate.domain == normalized_domain,
|
|
|
|
|
models.DomainSchemaCandidate.id.in_(request.candidate_ids),
|
|
|
|
|
)
|
|
|
|
|
).all()
|
|
|
|
|
entity_types = set(definition.entity_types or [])
|
|
|
|
|
predicates = set(definition.predicates or [])
|
|
|
|
|
attributes = set(definition.attributes or [])
|
|
|
|
|
aliases = dict(definition.aliases or {})
|
|
|
|
|
applied = {"entity_type": 0, "predicate": 0, "attribute": 0, "alias": 0}
|
|
|
|
|
for row in rows:
|
|
|
|
|
if row.candidate_type == "entity_type":
|
|
|
|
|
entity_types.add(row.name)
|
|
|
|
|
applied["entity_type"] += 1
|
|
|
|
|
elif row.candidate_type == "predicate":
|
|
|
|
|
predicates.add(row.name)
|
|
|
|
|
applied["predicate"] += 1
|
|
|
|
|
elif row.candidate_type == "attribute":
|
|
|
|
|
attributes.add(row.name)
|
|
|
|
|
applied["attribute"] += 1
|
|
|
|
|
elif row.candidate_type == "alias":
|
|
|
|
|
metadata = dict(row.metadata_json or {})
|
|
|
|
|
targets = metadata.get("targets") or []
|
|
|
|
|
target = metadata.get("target") or (targets[0] if targets else None)
|
|
|
|
|
if target:
|
|
|
|
|
aliases[row.name] = str(target)
|
|
|
|
|
applied["alias"] += 1
|
|
|
|
|
row.status = "approved"
|
|
|
|
|
row.updated_at = models.utcnow()
|
|
|
|
|
definition.entity_types = sorted(entity_types)
|
|
|
|
|
definition.predicates = sorted(predicates)
|
|
|
|
|
definition.attributes = sorted(attributes)
|
|
|
|
|
definition.aliases = aliases
|
|
|
|
|
definition.is_builtin_override = normalized_domain in DOMAIN_ONTOLOGIES
|
|
|
|
|
definition.updated_at = models.utcnow()
|
|
|
|
|
return {
|
|
|
|
|
"ok": True,
|
|
|
|
|
"domain": normalized_domain,
|
|
|
|
|
"applied": applied,
|
|
|
|
|
"definition": domain_summary_payload(
|
|
|
|
|
domain_row_to_ontology(definition),
|
|
|
|
|
is_builtin=normalized_domain in DOMAIN_ONTOLOGIES,
|
|
|
|
|
is_custom=True,
|
|
|
|
|
description=definition.description,
|
|
|
|
|
),
|
Phase 1.1: 프로젝트 생성 — 폼 기반 도메인 선택 + 인라인 JSON 생성
백엔드 (crawler_platform/app/api/routes.py):
- POST /projects/inline 추가: 파일 시스템 의존 없이 JSON 본문으로 ProjectConfig 인라인 생성
- CreateProjectInlineRequest + InlineSourceConfig Pydantic 모델
- GET /domains 추가: 미리 정의된 도메인 목록 (perfume, tea, coffee, candle, supplement, gift)
- 각 도메인의 entity_types, predicates, attribute_count 반환
프론트엔드 API 레이어 (src/lib/api/):
- domains.ts: domainsApi.list + Zod 스키마
- ontology.ts: ontologyApi.get(domain) + Zod 스키마
- projects.ts: createInline() 메서드 + CreateProjectInlineRequest 타입
TanStack Query 훅 (src/hooks/):
- useDomains: 도메인 목록 (30분 staleTime)
- useOntology(domain): 도메인 상세
- useCreateProjectInline: 인라인 생성 mutation + projects 캐시 무효화
- queryKeys에 domains, ontology 키 팩토리 추가
UI 프리미티브 (src/components/ui/):
- input.tsx, label.tsx, textarea.tsx
OnboardingPage 완전 교체 (src/pages/OnboardingPage.tsx):
- react-hook-form + zod 검증 (project_name 정규식, 도메인 필수)
- 도메인 카드 그리드 선택 (entity/predicate 개수 미리보기)
- 백엔드 /projects/inline POST → 성공 시 sonner 토스트 + /sources/{name}으로 이동
- 로딩/에러/재시도 UI
기타:
- Vite proxy에 /domains prefix 추가
- i18n locale 키 onboarding.* 추가 (한/영)
다음 단계: Phase 1.2 — 온톨로지 엔티티/클레임 직접 입력 + URL 추출
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 14:46:03 +09:00
|
|
|
}
|
2026-05-20 20:59:40 +09:00
|
|
|
|
|
|
|
|
@app.post("/domains/{domain}/candidates/{candidate_id}/status")
|
|
|
|
|
def update_domain_candidate_status(domain: str, candidate_id: int, request: DomainCandidateStatusRequest):
|
|
|
|
|
normalized_domain = normalize_domain_name(domain)
|
|
|
|
|
if request.status not in {"pending_review", "approved", "rejected"}:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Unsupported candidate status.")
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
row = session.get(models.DomainSchemaCandidate, candidate_id)
|
|
|
|
|
if row is None or row.domain != normalized_domain:
|
|
|
|
|
raise HTTPException(status_code=404, detail="candidate not found")
|
|
|
|
|
row.status = request.status
|
|
|
|
|
row.updated_at = models.utcnow()
|
|
|
|
|
evidence = session.scalars(
|
|
|
|
|
select(models.DomainCandidateEvidence)
|
|
|
|
|
.where(models.DomainCandidateEvidence.candidate_id == row.id)
|
|
|
|
|
.order_by(models.DomainCandidateEvidence.created_at.desc())
|
|
|
|
|
.limit(3)
|
|
|
|
|
).all()
|
|
|
|
|
return domain_candidate_payload(row, evidence)
|
Phase 1.1: 프로젝트 생성 — 폼 기반 도메인 선택 + 인라인 JSON 생성
백엔드 (crawler_platform/app/api/routes.py):
- POST /projects/inline 추가: 파일 시스템 의존 없이 JSON 본문으로 ProjectConfig 인라인 생성
- CreateProjectInlineRequest + InlineSourceConfig Pydantic 모델
- GET /domains 추가: 미리 정의된 도메인 목록 (perfume, tea, coffee, candle, supplement, gift)
- 각 도메인의 entity_types, predicates, attribute_count 반환
프론트엔드 API 레이어 (src/lib/api/):
- domains.ts: domainsApi.list + Zod 스키마
- ontology.ts: ontologyApi.get(domain) + Zod 스키마
- projects.ts: createInline() 메서드 + CreateProjectInlineRequest 타입
TanStack Query 훅 (src/hooks/):
- useDomains: 도메인 목록 (30분 staleTime)
- useOntology(domain): 도메인 상세
- useCreateProjectInline: 인라인 생성 mutation + projects 캐시 무효화
- queryKeys에 domains, ontology 키 팩토리 추가
UI 프리미티브 (src/components/ui/):
- input.tsx, label.tsx, textarea.tsx
OnboardingPage 완전 교체 (src/pages/OnboardingPage.tsx):
- react-hook-form + zod 검증 (project_name 정규식, 도메인 필수)
- 도메인 카드 그리드 선택 (entity/predicate 개수 미리보기)
- 백엔드 /projects/inline POST → 성공 시 sonner 토스트 + /sources/{name}으로 이동
- 로딩/에러/재시도 UI
기타:
- Vite proxy에 /domains prefix 추가
- i18n locale 키 onboarding.* 추가 (한/영)
다음 단계: Phase 1.2 — 온톨로지 엔티티/클레임 직접 입력 + URL 추출
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 14:46:03 +09:00
|
|
|
|
2026-05-11 22:55:01 +09:00
|
|
|
@app.post("/projects/reset")
|
|
|
|
|
def reset_project(request: ResetProjectRequest):
|
|
|
|
|
config = load_project_config(request.config_path)
|
|
|
|
|
if request.project_name and request.project_name != config.project_name:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail=(
|
|
|
|
|
f"Selected project '{request.project_name}' does not match "
|
|
|
|
|
f"config project '{config.project_name}'."
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
repo = KnowledgeRepository(session)
|
|
|
|
|
project = session.scalar(select(models.Project).where(models.Project.name == config.project_name))
|
|
|
|
|
if project is None:
|
|
|
|
|
project = repo.upsert_project(config)
|
|
|
|
|
return {
|
|
|
|
|
"ok": True,
|
|
|
|
|
"name": project.name,
|
|
|
|
|
"domain": project.domain,
|
|
|
|
|
"created": True,
|
|
|
|
|
"reset": False,
|
|
|
|
|
"deleted": {},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
deleted = repo.reset_project_runtime_data(project.id)
|
|
|
|
|
project = repo.upsert_project(config)
|
|
|
|
|
return {
|
|
|
|
|
"ok": True,
|
|
|
|
|
"name": project.name,
|
|
|
|
|
"domain": project.domain,
|
|
|
|
|
"created": False,
|
|
|
|
|
"reset": True,
|
|
|
|
|
"deleted": deleted,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 20:59:40 +09:00
|
|
|
@app.post("/projects/{project_name}/reset")
|
|
|
|
|
def reset_project_by_name(project_name: str):
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
repo = KnowledgeRepository(session)
|
|
|
|
|
project = repo.get_project(project_name)
|
|
|
|
|
config = project_config_from_project_row(session, project)
|
|
|
|
|
deleted = repo.reset_project_runtime_data(project.id)
|
|
|
|
|
project = repo.upsert_project(config)
|
|
|
|
|
return {
|
|
|
|
|
"ok": True,
|
|
|
|
|
"name": project.name,
|
|
|
|
|
"domain": project.domain,
|
|
|
|
|
"reset": True,
|
|
|
|
|
"deleted": deleted,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@app.delete("/projects/{project_name}")
|
|
|
|
|
def delete_project(project_name: str):
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
project = KnowledgeRepository(session).get_project(project_name)
|
|
|
|
|
domain = project.domain
|
|
|
|
|
deleted = delete_project_data(session, project.id, include_project=True)
|
|
|
|
|
return {
|
|
|
|
|
"ok": True,
|
|
|
|
|
"name": project_name,
|
|
|
|
|
"domain": domain,
|
|
|
|
|
"deleted": deleted,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-08 17:41:15 +09:00
|
|
|
@app.get("/projects/{project_name}")
|
|
|
|
|
def project_detail(project_name: str):
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
project = KnowledgeRepository(session).get_project(project_name)
|
|
|
|
|
sources = session.scalars(select(models.Source).where(models.Source.project_id == project.id)).all()
|
|
|
|
|
return {
|
|
|
|
|
"id": project.id,
|
|
|
|
|
"name": project.name,
|
|
|
|
|
"domain": project.domain,
|
2026-05-14 23:37:21 +09:00
|
|
|
"config": project_config_to_dict(project_config_from_project_row(session, project)),
|
2026-05-08 17:41:15 +09:00
|
|
|
"sources": [
|
|
|
|
|
{
|
|
|
|
|
"id": source.id,
|
|
|
|
|
"name": source.name,
|
|
|
|
|
"type": source.type,
|
2026-05-14 14:53:39 +09:00
|
|
|
"base_url": source.base_url,
|
2026-05-08 17:41:15 +09:00
|
|
|
"trust_level": source.trust_level,
|
|
|
|
|
"respect_robots_txt": source.respect_robots_txt,
|
|
|
|
|
"rate_limit_per_minute": source.rate_limit_per_minute,
|
|
|
|
|
}
|
|
|
|
|
for source in sources
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 14:53:39 +09:00
|
|
|
@app.post("/projects/{project_name}/sources")
|
|
|
|
|
def add_project_source(project_name: str, request: InlineSourceConfig):
|
|
|
|
|
"""Add or update a source on an existing project."""
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
repo = KnowledgeRepository(session)
|
|
|
|
|
project = repo.get_project(project_name)
|
|
|
|
|
source = repo.upsert_source(project, SourceConfig(**request.model_dump()))
|
|
|
|
|
return {
|
|
|
|
|
"id": source.id,
|
|
|
|
|
"name": source.name,
|
|
|
|
|
"type": source.type,
|
|
|
|
|
"base_url": source.base_url,
|
|
|
|
|
"trust_level": source.trust_level,
|
|
|
|
|
"respect_robots_txt": source.respect_robots_txt,
|
|
|
|
|
"rate_limit_per_minute": source.rate_limit_per_minute,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@app.delete("/projects/{project_name}/sources/{source_name}")
|
|
|
|
|
def delete_project_source(project_name: str, source_name: str):
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
repo = KnowledgeRepository(session)
|
|
|
|
|
project = repo.get_project(project_name)
|
|
|
|
|
source = session.scalar(
|
|
|
|
|
select(models.Source).where(
|
|
|
|
|
models.Source.project_id == project.id,
|
|
|
|
|
models.Source.name == source_name,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if source is None:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=404,
|
|
|
|
|
detail=f"Source '{source_name}' not found in project '{project_name}'",
|
|
|
|
|
)
|
|
|
|
|
session.delete(source)
|
|
|
|
|
return {"ok": True, "deleted": source_name}
|
|
|
|
|
|
2026-05-08 17:41:15 +09:00
|
|
|
@app.get("/ontology/{domain}")
|
|
|
|
|
def ontology(domain: str):
|
2026-05-20 20:59:40 +09:00
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
return ontology_to_dict(ontology_for_domain_from_db(session, domain))
|
2026-05-08 17:41:15 +09:00
|
|
|
|
2026-05-12 19:40:31 +09:00
|
|
|
@app.get("/projects/{project_name}/ontology/registry")
|
|
|
|
|
def ontology_registry(project_name: str):
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
project = KnowledgeRepository(session).get_project(project_name)
|
|
|
|
|
return OntologyRegistry(session).registry_payload(project.id)
|
|
|
|
|
|
2026-05-19 20:31:52 +09:00
|
|
|
@app.post("/projects/{project_name}/schema/entity-types")
|
|
|
|
|
def create_schema_entity_type(project_name: str, request: SchemaEntityTypeRequest):
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
project = KnowledgeRepository(session).get_project(project_name)
|
|
|
|
|
row = OntologyRegistry(session).upsert_entity_type(
|
|
|
|
|
project.id,
|
|
|
|
|
name=request.name,
|
|
|
|
|
domain=request.domain,
|
|
|
|
|
description=request.description,
|
|
|
|
|
status=request.status,
|
|
|
|
|
confidence=min(max(request.confidence, 0.0), 1.0),
|
|
|
|
|
metadata={"origin": "manual_schema_designer", **request.metadata},
|
|
|
|
|
)
|
|
|
|
|
return {
|
|
|
|
|
"id": row.id,
|
|
|
|
|
"name": row.name,
|
|
|
|
|
"domain": row.domain,
|
|
|
|
|
"description": row.description,
|
|
|
|
|
"status": row.status,
|
|
|
|
|
"confidence": row.confidence,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@app.post("/projects/{project_name}/schema/relation-types")
|
|
|
|
|
def create_schema_relation_type(project_name: str, request: SchemaRelationTypeRequest):
|
|
|
|
|
confidence_rules = dict(request.confidence_rules or {})
|
|
|
|
|
if request.min_confidence is not None:
|
|
|
|
|
confidence_rules["min_confidence"] = min(max(request.min_confidence, 0.0), 1.0)
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
project = KnowledgeRepository(session).get_project(project_name)
|
|
|
|
|
row = OntologyRegistry(session).upsert_relation_type(
|
|
|
|
|
project.id,
|
|
|
|
|
name=request.name,
|
|
|
|
|
domain=request.domain,
|
|
|
|
|
description=request.description,
|
|
|
|
|
allowed_subject_types=request.allowed_subject_types,
|
|
|
|
|
allowed_object_types=request.allowed_object_types,
|
|
|
|
|
allowed_page_types=request.allowed_page_types,
|
|
|
|
|
allowed_source_zones=request.allowed_source_zones,
|
|
|
|
|
semantic_constraints=request.semantic_constraints,
|
|
|
|
|
confidence_rules=confidence_rules,
|
|
|
|
|
status=request.status,
|
|
|
|
|
confidence=min(max(request.confidence, 0.0), 1.0),
|
|
|
|
|
metadata={"origin": "manual_schema_designer", **request.metadata},
|
|
|
|
|
)
|
|
|
|
|
return {
|
|
|
|
|
"id": row.id,
|
|
|
|
|
"name": row.name,
|
|
|
|
|
"domain": row.domain,
|
|
|
|
|
"description": row.description,
|
|
|
|
|
"allowed_subject_types": row.allowed_subject_types or [],
|
|
|
|
|
"allowed_object_types": row.allowed_object_types or [],
|
|
|
|
|
"status": row.status,
|
|
|
|
|
"confidence": row.confidence,
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 19:40:31 +09:00
|
|
|
@app.get("/projects/{project_name}/ontology/proposals")
|
|
|
|
|
def ontology_proposals(project_name: str, limit: int = 100):
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
project = KnowledgeRepository(session).get_project(project_name)
|
|
|
|
|
rows = session.scalars(
|
|
|
|
|
select(models.OntologyProposal)
|
|
|
|
|
.where(models.OntologyProposal.project_id == project.id)
|
|
|
|
|
.order_by(models.OntologyProposal.updated_at.desc())
|
|
|
|
|
.limit(max(min(limit, 300), 1))
|
|
|
|
|
).all()
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
"id": row.id,
|
|
|
|
|
"proposal_type": row.proposal_type,
|
|
|
|
|
"name": row.name,
|
|
|
|
|
"reason": row.reason,
|
|
|
|
|
"evidence": row.evidence,
|
|
|
|
|
"status": row.status,
|
|
|
|
|
"confidence": row.confidence,
|
|
|
|
|
"metadata": row.metadata_json or {},
|
|
|
|
|
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
|
|
|
|
}
|
|
|
|
|
for row in rows
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
@app.get("/projects/{project_name}/ontology/triples")
|
|
|
|
|
def ontology_triples(project_name: str, status: str | None = None, limit: int = 100):
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
project = KnowledgeRepository(session).get_project(project_name)
|
|
|
|
|
query = select(models.OntologyTriple).where(models.OntologyTriple.project_id == project.id)
|
|
|
|
|
if status:
|
|
|
|
|
query = query.where(models.OntologyTriple.status == status)
|
|
|
|
|
rows = session.scalars(
|
|
|
|
|
query.order_by(models.OntologyTriple.last_seen_at.desc()).limit(max(min(limit, 300), 1))
|
|
|
|
|
).all()
|
|
|
|
|
results = []
|
|
|
|
|
for row in rows:
|
|
|
|
|
subject = session.get(models.Entity, row.subject_entity_id)
|
|
|
|
|
object_entity = session.get(models.Entity, row.object_entity_id) if row.object_entity_id else None
|
|
|
|
|
results.append(
|
|
|
|
|
{
|
|
|
|
|
"id": row.id,
|
|
|
|
|
"claim_id": row.claim_id,
|
|
|
|
|
"subject": subject.name if subject else None,
|
|
|
|
|
"subject_type": row.subject_type,
|
|
|
|
|
"predicate": row.predicate,
|
|
|
|
|
"object": object_entity.name if object_entity else None,
|
|
|
|
|
"object_type": row.object_type,
|
|
|
|
|
"object_value": row.object_value,
|
|
|
|
|
"value_type": row.value_type,
|
|
|
|
|
"status": row.status,
|
|
|
|
|
"confidence": row.confidence,
|
|
|
|
|
"support_count": row.support_count,
|
|
|
|
|
"metadata": row.metadata_json or {},
|
|
|
|
|
"last_seen_at": row.last_seen_at.isoformat() if row.last_seen_at else None,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return results
|
|
|
|
|
|
2026-05-19 20:31:52 +09:00
|
|
|
@app.get("/projects/{project_name}/export")
|
|
|
|
|
def export_ontology(
|
|
|
|
|
project_name: str,
|
|
|
|
|
format: str = "json",
|
|
|
|
|
status: str = "validated_claim",
|
|
|
|
|
include_evidence: bool = True,
|
|
|
|
|
limit: int = 1000,
|
|
|
|
|
):
|
|
|
|
|
fmt = format.strip().lower()
|
|
|
|
|
bounded_limit = max(min(limit, 5000), 1)
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
project = KnowledgeRepository(session).get_project(project_name)
|
|
|
|
|
query = (
|
|
|
|
|
select(models.Claim, models.Source, models.Page, models.Entity)
|
|
|
|
|
.join(models.Source, models.Claim.source_id == models.Source.id)
|
|
|
|
|
.join(models.Page, models.Claim.page_id == models.Page.id, isouter=True)
|
|
|
|
|
.join(models.Entity, models.Claim.subject_entity_id == models.Entity.id)
|
|
|
|
|
.where(models.Claim.project_id == project.id)
|
|
|
|
|
)
|
|
|
|
|
if status and status != "all":
|
|
|
|
|
normalized = _normalize_claim_status(status) or status
|
|
|
|
|
query = query.where(models.Claim.status == normalized)
|
|
|
|
|
rows = session.execute(
|
|
|
|
|
query.order_by(models.Claim.confidence.desc(), models.Claim.last_seen_at.desc()).limit(bounded_limit)
|
|
|
|
|
).all()
|
|
|
|
|
items: list[dict[str, Any]] = []
|
|
|
|
|
for claim, source, page, subject in rows:
|
|
|
|
|
object_entity = session.get(models.Entity, claim.object_entity_id) if claim.object_entity_id else None
|
|
|
|
|
evidence = None
|
|
|
|
|
if include_evidence:
|
|
|
|
|
evidence = session.scalar(
|
|
|
|
|
select(models.Evidence)
|
|
|
|
|
.where(models.Evidence.claim_id == claim.id)
|
|
|
|
|
.order_by(models.Evidence.created_at.desc())
|
|
|
|
|
)
|
|
|
|
|
items.append(
|
|
|
|
|
{
|
|
|
|
|
"claim_id": claim.id,
|
|
|
|
|
"subject": subject.name,
|
|
|
|
|
"subject_type": subject.entity_type,
|
|
|
|
|
"predicate": claim.predicate,
|
|
|
|
|
"object": object_entity.name if object_entity else claim.object_value,
|
|
|
|
|
"object_type": object_entity.entity_type if object_entity else claim.value_type,
|
|
|
|
|
"status": claim.status,
|
|
|
|
|
"confidence": claim.confidence,
|
|
|
|
|
"source": source.name,
|
|
|
|
|
"source_url": page.url if page else None,
|
|
|
|
|
"evidence_text": evidence.evidence_text if evidence else None,
|
|
|
|
|
"created_by": claim.extraction_method,
|
|
|
|
|
"last_seen_at": claim.last_seen_at.isoformat() if claim.last_seen_at else None,
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
filename = f"{project_name}_ontology.{fmt if fmt != 'turtle' else 'ttl'}"
|
|
|
|
|
if fmt == "json":
|
|
|
|
|
return {
|
|
|
|
|
"project": project_name,
|
|
|
|
|
"status": status,
|
|
|
|
|
"count": len(items),
|
|
|
|
|
"claims": items,
|
|
|
|
|
}
|
|
|
|
|
if fmt == "csv":
|
|
|
|
|
output = io.StringIO()
|
|
|
|
|
fieldnames = [
|
|
|
|
|
"claim_id",
|
|
|
|
|
"subject",
|
|
|
|
|
"subject_type",
|
|
|
|
|
"predicate",
|
|
|
|
|
"object",
|
|
|
|
|
"object_type",
|
|
|
|
|
"status",
|
|
|
|
|
"confidence",
|
|
|
|
|
"source",
|
|
|
|
|
"source_url",
|
|
|
|
|
"evidence_text",
|
|
|
|
|
"created_by",
|
|
|
|
|
"last_seen_at",
|
|
|
|
|
]
|
|
|
|
|
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
|
|
|
|
writer.writeheader()
|
|
|
|
|
for item in items:
|
|
|
|
|
row = {key: json.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else value for key, value in item.items()}
|
|
|
|
|
writer.writerow(row)
|
|
|
|
|
return Response(
|
|
|
|
|
content=output.getvalue(),
|
|
|
|
|
media_type="text/csv; charset=utf-8",
|
|
|
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
|
|
|
)
|
|
|
|
|
if fmt in {"ttl", "turtle"}:
|
|
|
|
|
lines = [
|
|
|
|
|
"@prefix ont: <https://example.local/ontology/> .",
|
|
|
|
|
"@prefix claim: <https://example.local/claim/> .",
|
|
|
|
|
"@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .",
|
|
|
|
|
"",
|
|
|
|
|
]
|
|
|
|
|
for item in items:
|
|
|
|
|
sid = turtle_id(item["subject"])
|
|
|
|
|
predicate = turtle_id(item["predicate"])
|
|
|
|
|
obj = item["object"]
|
|
|
|
|
if isinstance(obj, str) and obj.strip():
|
|
|
|
|
object_repr = f'ont:{turtle_id(obj)}'
|
|
|
|
|
else:
|
|
|
|
|
object_repr = json.dumps(obj, ensure_ascii=False)
|
|
|
|
|
lines.extend(
|
|
|
|
|
[
|
|
|
|
|
f"ont:{sid} ont:{predicate} {object_repr} .",
|
|
|
|
|
f"claim:c{item['claim_id']} ont:confidence \"{item['confidence']}\"^^xsd:decimal .",
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
if item.get("source_url"):
|
|
|
|
|
lines.append(f"claim:c{item['claim_id']} ont:sourceUrl {json.dumps(item['source_url'], ensure_ascii=False)} .")
|
|
|
|
|
if item.get("evidence_text"):
|
|
|
|
|
lines.append(f"claim:c{item['claim_id']} ont:evidenceText {json.dumps(item['evidence_text'], ensure_ascii=False)} .")
|
|
|
|
|
lines.append("")
|
|
|
|
|
return Response(
|
|
|
|
|
content="\n".join(lines),
|
|
|
|
|
media_type="text/turtle; charset=utf-8",
|
|
|
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
|
|
|
)
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"unsupported export format: {format}")
|
|
|
|
|
|
2026-05-12 19:40:31 +09:00
|
|
|
@app.get("/projects/{project_name}/knowledge-gaps")
|
|
|
|
|
def knowledge_gaps(project_name: str, limit: int = 100):
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
project = KnowledgeRepository(session).get_project(project_name)
|
|
|
|
|
return KnowledgeGapDetector(session).list_open(project.id, max(min(limit, 300), 1))
|
|
|
|
|
|
2026-05-08 17:41:15 +09:00
|
|
|
@app.post("/extractors/models")
|
|
|
|
|
def extractor_models(request: ExtractorModelsRequest):
|
|
|
|
|
try:
|
|
|
|
|
if request.provider == "lm_studio":
|
2026-05-22 20:16:28 +09:00
|
|
|
models = list_lmstudio_loaded_models(request.base_url or "http://localhost:1234/v1")
|
2026-05-08 17:41:15 +09:00
|
|
|
return {"ok": True, "models": models}
|
|
|
|
|
if request.provider == "openai":
|
|
|
|
|
import os
|
|
|
|
|
|
|
|
|
|
models = list_openai_compatible_models(
|
|
|
|
|
request.base_url or "https://api.openai.com/v1",
|
|
|
|
|
os.getenv("OPENAI_API_KEY"),
|
|
|
|
|
)
|
|
|
|
|
return {"ok": True, "models": models}
|
|
|
|
|
if request.provider == "ollama":
|
|
|
|
|
return {"ok": False, "error": "Ollama model listing is not implemented yet. Enter the model manually."}
|
|
|
|
|
return {"ok": True, "models": [{"id": "rule_based", "owned_by": "local"}]}
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
return {"ok": False, "error": str(exc), "models": []}
|
|
|
|
|
|
|
|
|
|
@app.post("/crawl")
|
|
|
|
|
def crawl(request: CrawlRequest):
|
|
|
|
|
config = load_project_config(request.config_path)
|
2026-05-13 19:57:34 +09:00
|
|
|
apply_crawl_request_overrides(config, request)
|
2026-05-08 17:41:15 +09:00
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
repo = KnowledgeRepository(session)
|
|
|
|
|
pipeline = CrawlPipeline(
|
|
|
|
|
repo,
|
|
|
|
|
extractor_for_domain(
|
|
|
|
|
config.domain,
|
|
|
|
|
provider=request.extractor_provider,
|
|
|
|
|
model=request.extractor_model,
|
|
|
|
|
base_url=request.extractor_base_url,
|
2026-05-22 00:22:03 +09:00
|
|
|
extraction_mode=request.extraction_mode,
|
|
|
|
|
fallback_to_rules=request.fallback_to_rules,
|
2026-05-08 17:41:15 +09:00
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
result = pipeline.crawl_url(config, request.source_name, request.url)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
2026-05-12 19:40:31 +09:00
|
|
|
return {
|
|
|
|
|
"page_id": result.page_id,
|
|
|
|
|
"claim_count": result.claim_count,
|
|
|
|
|
"entity_count": result.entity_count,
|
2026-05-22 00:22:03 +09:00
|
|
|
"extraction_mode": result.extraction_mode,
|
|
|
|
|
"effective_extraction_mode": result.effective_extraction_mode,
|
|
|
|
|
"llm_skipped": result.llm_skipped,
|
|
|
|
|
"llm_skip_reason": result.llm_skip_reason,
|
|
|
|
|
"fallback_used": result.fallback_used,
|
|
|
|
|
"agreement_claim_count": result.agreement_claim_count,
|
|
|
|
|
"conflict_claim_count": result.conflict_claim_count,
|
2026-05-12 19:40:31 +09:00
|
|
|
"crawl_status": result.crawl_status,
|
|
|
|
|
"extraction_status": result.extraction_status,
|
|
|
|
|
"page_type": result.page_type,
|
|
|
|
|
"raw_text_length": result.raw_text_length,
|
|
|
|
|
"clean_text_length": result.clean_text_length,
|
|
|
|
|
"warnings": result.warnings or [],
|
|
|
|
|
}
|
2026-05-08 17:41:15 +09:00
|
|
|
|
2026-05-11 13:02:11 +09:00
|
|
|
@app.post("/crawl-site")
|
2026-05-12 19:40:31 +09:00
|
|
|
def crawl_site(request: SiteCrawlRequest, background_tasks: BackgroundTasks):
|
2026-05-11 13:02:11 +09:00
|
|
|
config = load_project_config(request.config_path)
|
2026-05-13 19:57:34 +09:00
|
|
|
apply_crawl_request_overrides(config, request)
|
2026-05-11 13:02:11 +09:00
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
repo = KnowledgeRepository(session)
|
2026-05-12 19:40:31 +09:00
|
|
|
project = repo.upsert_project(config)
|
|
|
|
|
source = repo.get_source(project.id, request.source_name)
|
|
|
|
|
job = models.CrawlJob(
|
|
|
|
|
project_id=project.id,
|
|
|
|
|
source_id=source.id,
|
|
|
|
|
url=request.url,
|
|
|
|
|
status="pending",
|
|
|
|
|
metadata_json={
|
|
|
|
|
"kind": "site_crawl",
|
|
|
|
|
"request": request.model_dump(),
|
|
|
|
|
"progress": {
|
|
|
|
|
"seed_url": request.url,
|
|
|
|
|
"visited_count": 0,
|
|
|
|
|
"analyzed_count": 0,
|
|
|
|
|
"queued_count": 1,
|
|
|
|
|
"skipped_count": 0,
|
|
|
|
|
"errors": [],
|
|
|
|
|
"pages": [],
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-05-11 13:02:11 +09:00
|
|
|
)
|
2026-05-12 19:40:31 +09:00
|
|
|
session.add(job)
|
|
|
|
|
session.flush()
|
|
|
|
|
response = crawl_job_response(job)
|
|
|
|
|
background_tasks.add_task(run_site_crawl_job, database_url, response["job_id"], request.model_dump())
|
|
|
|
|
return response
|
|
|
|
|
|
Phase 1.3: 시드 크롤 — by-project 엔드포인트 + CrawlPage 폼/폴링/취소
세션 영구화: UI_REBUILD_PLAN.md 신설
- 사용자 비전 5가지 흐름 + Phase 보드 + 작업 재개 가이드
- 세션이 끊겨도 이 파일 + git log로 어디까지 했는지 즉시 복원
백엔드 (crawler_platform/app/api/):
- routes.py: POST /crawl-site/by-project 추가 — config_path 의존 제거
- SiteCrawlByProjectRequest: project_name 기반, DB project.config 재구성
- run_site_crawl_job이 __config_dict 메타키 인식하도록 최소 변경
- config/loader.py: project_config_from_dict() 헬퍼 추출 (DB dict ↔ ProjectConfig)
프론트엔드 API (src/lib/api/):
- crawl.ts: crawlApi.startByProject/getJob/cancel + Zod 스키마
- 종료 상태 판정 헬퍼 isCrawlTerminal()
TanStack Query 훅 (src/hooks/):
- useCrawl.ts: useStartSiteCrawl, useCrawlJob (2초 폴링, 종료 시 자동 중단), useCancelCrawl
- queryKeys에 crawl.job 키
UI 프리미티브 (src/components/ui/):
- select.tsx, progress.tsx, badge.tsx (success/warning/destructive variants 포함)
CrawlPage 완전 교체 (src/pages/):
- 2열 레이아웃: 좌측 크롤 설정 폼 + 우측 진행 상태
- 폼: 소스 선택 (드롭다운, 비어있으면 소스 추가 안내) + 시드 URL + max_depth/max_pages + same_domain_only
- react-hook-form + zod 검증
- 진행 상태: 상태 배지, 진행률 바, visited/queued/analyzed 카운터, 최근 페이지, 에러 details
- 크롤 종료시 폴링 자동 중단, 완료시 "결과 검토" 버튼
- 취소 버튼 → /crawl-site/jobs/{id}/cancel
- sonner 토스트
i18n: crawl.* 키 (한/영)
다음 단계: Phase 1.4 — 자율 연구 (POST /research/run by-project 마이그레이션 + ResearchPage)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:04:41 +09:00
|
|
|
@app.post("/crawl-site/by-project")
|
|
|
|
|
def crawl_site_by_project(
|
|
|
|
|
request: SiteCrawlByProjectRequest, background_tasks: BackgroundTasks
|
|
|
|
|
):
|
|
|
|
|
"""Start a site crawl against an existing DB project (no config_path)."""
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
repo = KnowledgeRepository(session)
|
|
|
|
|
project = repo.get_project(request.project_name)
|
2026-05-14 23:37:21 +09:00
|
|
|
config = project_config_from_project_row(session, project)
|
Phase 1.3: 시드 크롤 — by-project 엔드포인트 + CrawlPage 폼/폴링/취소
세션 영구화: UI_REBUILD_PLAN.md 신설
- 사용자 비전 5가지 흐름 + Phase 보드 + 작업 재개 가이드
- 세션이 끊겨도 이 파일 + git log로 어디까지 했는지 즉시 복원
백엔드 (crawler_platform/app/api/):
- routes.py: POST /crawl-site/by-project 추가 — config_path 의존 제거
- SiteCrawlByProjectRequest: project_name 기반, DB project.config 재구성
- run_site_crawl_job이 __config_dict 메타키 인식하도록 최소 변경
- config/loader.py: project_config_from_dict() 헬퍼 추출 (DB dict ↔ ProjectConfig)
프론트엔드 API (src/lib/api/):
- crawl.ts: crawlApi.startByProject/getJob/cancel + Zod 스키마
- 종료 상태 판정 헬퍼 isCrawlTerminal()
TanStack Query 훅 (src/hooks/):
- useCrawl.ts: useStartSiteCrawl, useCrawlJob (2초 폴링, 종료 시 자동 중단), useCancelCrawl
- queryKeys에 crawl.job 키
UI 프리미티브 (src/components/ui/):
- select.tsx, progress.tsx, badge.tsx (success/warning/destructive variants 포함)
CrawlPage 완전 교체 (src/pages/):
- 2열 레이아웃: 좌측 크롤 설정 폼 + 우측 진행 상태
- 폼: 소스 선택 (드롭다운, 비어있으면 소스 추가 안내) + 시드 URL + max_depth/max_pages + same_domain_only
- react-hook-form + zod 검증
- 진행 상태: 상태 배지, 진행률 바, visited/queued/analyzed 카운터, 최근 페이지, 에러 details
- 크롤 종료시 폴링 자동 중단, 완료시 "결과 검토" 버튼
- 취소 버튼 → /crawl-site/jobs/{id}/cancel
- sonner 토스트
i18n: crawl.* 키 (한/영)
다음 단계: Phase 1.4 — 자율 연구 (POST /research/run by-project 마이그레이션 + ResearchPage)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:04:41 +09:00
|
|
|
try:
|
|
|
|
|
source = repo.get_source(project.id, request.source_name)
|
2026-05-14 23:37:21 +09:00
|
|
|
config.source_by_name(request.source_name)
|
Phase 1.3: 시드 크롤 — by-project 엔드포인트 + CrawlPage 폼/폴링/취소
세션 영구화: UI_REBUILD_PLAN.md 신설
- 사용자 비전 5가지 흐름 + Phase 보드 + 작업 재개 가이드
- 세션이 끊겨도 이 파일 + git log로 어디까지 했는지 즉시 복원
백엔드 (crawler_platform/app/api/):
- routes.py: POST /crawl-site/by-project 추가 — config_path 의존 제거
- SiteCrawlByProjectRequest: project_name 기반, DB project.config 재구성
- run_site_crawl_job이 __config_dict 메타키 인식하도록 최소 변경
- config/loader.py: project_config_from_dict() 헬퍼 추출 (DB dict ↔ ProjectConfig)
프론트엔드 API (src/lib/api/):
- crawl.ts: crawlApi.startByProject/getJob/cancel + Zod 스키마
- 종료 상태 판정 헬퍼 isCrawlTerminal()
TanStack Query 훅 (src/hooks/):
- useCrawl.ts: useStartSiteCrawl, useCrawlJob (2초 폴링, 종료 시 자동 중단), useCancelCrawl
- queryKeys에 crawl.job 키
UI 프리미티브 (src/components/ui/):
- select.tsx, progress.tsx, badge.tsx (success/warning/destructive variants 포함)
CrawlPage 완전 교체 (src/pages/):
- 2열 레이아웃: 좌측 크롤 설정 폼 + 우측 진행 상태
- 폼: 소스 선택 (드롭다운, 비어있으면 소스 추가 안내) + 시드 URL + max_depth/max_pages + same_domain_only
- react-hook-form + zod 검증
- 진행 상태: 상태 배지, 진행률 바, visited/queued/analyzed 카운터, 최근 페이지, 에러 details
- 크롤 종료시 폴링 자동 중단, 완료시 "결과 검토" 버튼
- 취소 버튼 → /crawl-site/jobs/{id}/cancel
- sonner 토스트
i18n: crawl.* 키 (한/영)
다음 단계: Phase 1.4 — 자율 연구 (POST /research/run by-project 마이그레이션 + ResearchPage)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:04:41 +09:00
|
|
|
except KeyError as exc:
|
|
|
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
|
|
|
|
|
|
|
|
inner_request = request.to_site_crawl_request()
|
|
|
|
|
apply_crawl_request_overrides(config, inner_request)
|
|
|
|
|
|
|
|
|
|
job = models.CrawlJob(
|
|
|
|
|
project_id=project.id,
|
|
|
|
|
source_id=source.id,
|
|
|
|
|
url=request.url,
|
|
|
|
|
status="pending",
|
|
|
|
|
metadata_json={
|
|
|
|
|
"kind": "site_crawl",
|
|
|
|
|
"request": inner_request.model_dump(),
|
|
|
|
|
"project_name": request.project_name,
|
|
|
|
|
"progress": {
|
|
|
|
|
"seed_url": request.url,
|
|
|
|
|
"visited_count": 0,
|
|
|
|
|
"analyzed_count": 0,
|
|
|
|
|
"queued_count": 1,
|
|
|
|
|
"skipped_count": 0,
|
|
|
|
|
"errors": [],
|
|
|
|
|
"pages": [],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
session.add(job)
|
|
|
|
|
session.flush()
|
|
|
|
|
response = crawl_job_response(job)
|
|
|
|
|
|
2026-05-14 23:37:21 +09:00
|
|
|
task_payload = {
|
|
|
|
|
**inner_request.model_dump(),
|
|
|
|
|
"__config_dict": project_config_to_dict(config),
|
|
|
|
|
}
|
Phase 1.3: 시드 크롤 — by-project 엔드포인트 + CrawlPage 폼/폴링/취소
세션 영구화: UI_REBUILD_PLAN.md 신설
- 사용자 비전 5가지 흐름 + Phase 보드 + 작업 재개 가이드
- 세션이 끊겨도 이 파일 + git log로 어디까지 했는지 즉시 복원
백엔드 (crawler_platform/app/api/):
- routes.py: POST /crawl-site/by-project 추가 — config_path 의존 제거
- SiteCrawlByProjectRequest: project_name 기반, DB project.config 재구성
- run_site_crawl_job이 __config_dict 메타키 인식하도록 최소 변경
- config/loader.py: project_config_from_dict() 헬퍼 추출 (DB dict ↔ ProjectConfig)
프론트엔드 API (src/lib/api/):
- crawl.ts: crawlApi.startByProject/getJob/cancel + Zod 스키마
- 종료 상태 판정 헬퍼 isCrawlTerminal()
TanStack Query 훅 (src/hooks/):
- useCrawl.ts: useStartSiteCrawl, useCrawlJob (2초 폴링, 종료 시 자동 중단), useCancelCrawl
- queryKeys에 crawl.job 키
UI 프리미티브 (src/components/ui/):
- select.tsx, progress.tsx, badge.tsx (success/warning/destructive variants 포함)
CrawlPage 완전 교체 (src/pages/):
- 2열 레이아웃: 좌측 크롤 설정 폼 + 우측 진행 상태
- 폼: 소스 선택 (드롭다운, 비어있으면 소스 추가 안내) + 시드 URL + max_depth/max_pages + same_domain_only
- react-hook-form + zod 검증
- 진행 상태: 상태 배지, 진행률 바, visited/queued/analyzed 카운터, 최근 페이지, 에러 details
- 크롤 종료시 폴링 자동 중단, 완료시 "결과 검토" 버튼
- 취소 버튼 → /crawl-site/jobs/{id}/cancel
- sonner 토스트
i18n: crawl.* 키 (한/영)
다음 단계: Phase 1.4 — 자율 연구 (POST /research/run by-project 마이그레이션 + ResearchPage)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 15:04:41 +09:00
|
|
|
background_tasks.add_task(
|
|
|
|
|
run_site_crawl_job, database_url, response["job_id"], task_payload
|
|
|
|
|
)
|
|
|
|
|
return response
|
|
|
|
|
|
2026-05-12 19:40:31 +09:00
|
|
|
@app.get("/crawl-site/jobs/{job_id}")
|
|
|
|
|
def crawl_site_job(job_id: int):
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
job = session.get(models.CrawlJob, job_id)
|
|
|
|
|
if job is None or (job.metadata_json or {}).get("kind") != "site_crawl":
|
|
|
|
|
raise HTTPException(status_code=404, detail="site crawl job not found")
|
|
|
|
|
return crawl_job_response(job)
|
|
|
|
|
|
|
|
|
|
@app.post("/crawl-site/jobs/{job_id}/cancel")
|
|
|
|
|
def cancel_crawl_site_job(job_id: int):
|
2026-05-22 00:22:03 +09:00
|
|
|
return request_site_crawl_cancel(database_url, job_id)
|
2026-05-11 13:02:11 +09:00
|
|
|
|
2026-05-08 17:41:15 +09:00
|
|
|
@app.post("/discover")
|
|
|
|
|
def discover(request: DiscoverRequest):
|
|
|
|
|
config = load_project_config(request.config_path)
|
2026-05-13 19:57:34 +09:00
|
|
|
apply_crawl_request_overrides(config, request)
|
2026-05-08 17:41:15 +09:00
|
|
|
source_config = config.source_by_name(request.source_name)
|
|
|
|
|
robots = RobotsPolicy()
|
2026-05-13 19:57:34 +09:00
|
|
|
robots_decision = robots.check(request.url, source_config.respect_robots_txt)
|
|
|
|
|
if not robots_decision.allowed:
|
|
|
|
|
return {
|
|
|
|
|
"ok": False,
|
|
|
|
|
"error": f"{robots_decision.reason}: {request.url}",
|
|
|
|
|
"robots_status": robots_decision.status,
|
|
|
|
|
"robots_reason": robots_decision.reason,
|
|
|
|
|
"links": [],
|
|
|
|
|
}
|
2026-05-08 17:41:15 +09:00
|
|
|
fetcher = make_fetcher(source_config.fetcher, source_config.rate_limit_per_minute)
|
|
|
|
|
result = fetcher.fetch(request.url)
|
2026-05-12 19:40:31 +09:00
|
|
|
links = discover_links(result.analysis_html, result.final_url or request.url, request.limit)
|
2026-05-08 17:41:15 +09:00
|
|
|
return {
|
|
|
|
|
"ok": True,
|
|
|
|
|
"status_code": result.status_code,
|
|
|
|
|
"final_url": result.final_url,
|
2026-05-12 19:40:31 +09:00
|
|
|
"crawl_status": result.crawl_status,
|
2026-05-13 19:57:34 +09:00
|
|
|
"robots_status": robots_decision.status,
|
|
|
|
|
"robots_reason": robots_decision.reason,
|
2026-05-12 19:40:31 +09:00
|
|
|
"warnings": result.warnings,
|
2026-05-08 17:41:15 +09:00
|
|
|
"links": [asdict(link) for link in links],
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 19:40:31 +09:00
|
|
|
@app.post("/research/run")
|
|
|
|
|
def run_research(request: ResearchRunRequest):
|
|
|
|
|
config = load_project_config(request.config_path)
|
2026-05-13 19:57:34 +09:00
|
|
|
apply_crawl_request_overrides(config, request)
|
2026-05-12 19:40:31 +09:00
|
|
|
if request.project_name and request.project_name != config.project_name:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail=f"Selected project '{request.project_name}' does not match config project '{config.project_name}'.",
|
|
|
|
|
)
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
repo = KnowledgeRepository(session)
|
|
|
|
|
loop = GraphResearchLoop(
|
|
|
|
|
repo,
|
|
|
|
|
extractor_for_domain(
|
|
|
|
|
config.domain,
|
|
|
|
|
provider=request.extractor_provider,
|
|
|
|
|
model=request.extractor_model,
|
|
|
|
|
base_url=request.extractor_base_url,
|
2026-05-22 00:22:03 +09:00
|
|
|
extraction_mode=request.extraction_mode,
|
|
|
|
|
fallback_to_rules=request.fallback_to_rules,
|
2026-05-12 19:40:31 +09:00
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
result = loop.run(
|
|
|
|
|
project_config=config,
|
|
|
|
|
source_name=request.source_name,
|
|
|
|
|
seed_url=request.url or None,
|
|
|
|
|
seed_entity_id=request.seed_entity_id,
|
|
|
|
|
goal=request.goal,
|
|
|
|
|
max_depth=max(request.max_depth, 0),
|
|
|
|
|
max_steps=max(min(request.max_steps, 50), 1),
|
|
|
|
|
max_branch=max(min(request.max_branch, 30), 1),
|
Phase 1.4: 자율 연구 — by-project 엔드포인트 + ResearchPage + 세션 이력
백엔드 (crawler_platform/app/api/routes.py):
- POST /research/run/by-project 추가 — config_path 의존 제거
- ResearchRunByProjectRequest: project_name 기반, DB project.config 재구성
- GraphResearchLoop 동기 실행 (장시간 가능), asdict(result) 반환
- 기존 /projects/{n}/research/sessions, /research/sessions/{job_id}는 변경 없음
프론트엔드 API (src/lib/api/):
- research.ts: researchApi.startByProject/listSessions/getSession + Zod 스키마
- 결과 페이로드는 passthrough() (백엔드 dataclass 그대로 노출)
TanStack Query 훅 (src/hooks/):
- useResearch.ts: useStartResearch, useResearchSessions, useResearchSession
- queryKeys에 research.sessions, research.session 키
ResearchPage 신규 (src/pages/ResearchPage.tsx):
- 2열 레이아웃: 좌측 연구 설정 폼 + 우측 결과/이력
- 폼: 소스 선택, goal 텍스트(필수, 3~500자), 시드 URL(선택),
max_steps/max_branch/max_depth/min_relevance, same_domain_only
react-hook-form + zod 검증
- 결과 카드: 단계/페이지/엔티티/클레임 4종 stats + raw JSON details
- 세션 이력 카드: GET /projects/{n}/research/sessions 결과 리스트
- 실행 중 안내 (장시간 가능), sonner 토스트
라우팅 & Sidebar:
- App.tsx: /research/:projectId 라우트 추가
- AppShell: 사이드바에 "자율 연구" 메뉴 (Brain 아이콘)
i18n: research.*, nav.research 키 (한/영)
다음 단계: Phase 1.5 — 엔티티/클레임 직접 입력
(백엔드 POST /projects/{n}/entities, /claims 신규 필요)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 19:01:52 +09:00
|
|
|
min_relevance=min(max(request.min_relevance, 0.0), 1.0),
|
|
|
|
|
same_domain_only=request.same_domain_only,
|
|
|
|
|
analyze_page_types=set(request.analyze_page_types),
|
|
|
|
|
)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
return asdict(result)
|
|
|
|
|
|
|
|
|
|
@app.post("/research/run/by-project")
|
|
|
|
|
def run_research_by_project(request: ResearchRunByProjectRequest):
|
|
|
|
|
"""Run research against an existing DB project (no config_path)."""
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
repo = KnowledgeRepository(session)
|
|
|
|
|
project = repo.get_project(request.project_name)
|
2026-05-14 23:37:21 +09:00
|
|
|
config = project_config_from_project_row(session, project)
|
Phase 1.4: 자율 연구 — by-project 엔드포인트 + ResearchPage + 세션 이력
백엔드 (crawler_platform/app/api/routes.py):
- POST /research/run/by-project 추가 — config_path 의존 제거
- ResearchRunByProjectRequest: project_name 기반, DB project.config 재구성
- GraphResearchLoop 동기 실행 (장시간 가능), asdict(result) 반환
- 기존 /projects/{n}/research/sessions, /research/sessions/{job_id}는 변경 없음
프론트엔드 API (src/lib/api/):
- research.ts: researchApi.startByProject/listSessions/getSession + Zod 스키마
- 결과 페이로드는 passthrough() (백엔드 dataclass 그대로 노출)
TanStack Query 훅 (src/hooks/):
- useResearch.ts: useStartResearch, useResearchSessions, useResearchSession
- queryKeys에 research.sessions, research.session 키
ResearchPage 신규 (src/pages/ResearchPage.tsx):
- 2열 레이아웃: 좌측 연구 설정 폼 + 우측 결과/이력
- 폼: 소스 선택, goal 텍스트(필수, 3~500자), 시드 URL(선택),
max_steps/max_branch/max_depth/min_relevance, same_domain_only
react-hook-form + zod 검증
- 결과 카드: 단계/페이지/엔티티/클레임 4종 stats + raw JSON details
- 세션 이력 카드: GET /projects/{n}/research/sessions 결과 리스트
- 실행 중 안내 (장시간 가능), sonner 토스트
라우팅 & Sidebar:
- App.tsx: /research/:projectId 라우트 추가
- AppShell: 사이드바에 "자율 연구" 메뉴 (Brain 아이콘)
i18n: research.*, nav.research 키 (한/영)
다음 단계: Phase 1.5 — 엔티티/클레임 직접 입력
(백엔드 POST /projects/{n}/entities, /claims 신규 필요)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 19:01:52 +09:00
|
|
|
try:
|
|
|
|
|
source_config = config.source_by_name(request.source_name)
|
|
|
|
|
except KeyError as exc:
|
|
|
|
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
|
|
|
|
|
|
|
|
check_robots = request.respect_robots_txt
|
|
|
|
|
if check_robots is None:
|
|
|
|
|
check_robots = request.check_robots_txt
|
|
|
|
|
source_config.respect_robots_txt = check_robots
|
|
|
|
|
|
|
|
|
|
loop = GraphResearchLoop(
|
|
|
|
|
repo,
|
|
|
|
|
extractor_for_domain(
|
|
|
|
|
config.domain,
|
|
|
|
|
provider=request.extractor_provider,
|
|
|
|
|
model=request.extractor_model,
|
|
|
|
|
base_url=request.extractor_base_url,
|
2026-05-22 00:22:03 +09:00
|
|
|
extraction_mode=request.extraction_mode,
|
|
|
|
|
fallback_to_rules=request.fallback_to_rules,
|
Phase 1.4: 자율 연구 — by-project 엔드포인트 + ResearchPage + 세션 이력
백엔드 (crawler_platform/app/api/routes.py):
- POST /research/run/by-project 추가 — config_path 의존 제거
- ResearchRunByProjectRequest: project_name 기반, DB project.config 재구성
- GraphResearchLoop 동기 실행 (장시간 가능), asdict(result) 반환
- 기존 /projects/{n}/research/sessions, /research/sessions/{job_id}는 변경 없음
프론트엔드 API (src/lib/api/):
- research.ts: researchApi.startByProject/listSessions/getSession + Zod 스키마
- 결과 페이로드는 passthrough() (백엔드 dataclass 그대로 노출)
TanStack Query 훅 (src/hooks/):
- useResearch.ts: useStartResearch, useResearchSessions, useResearchSession
- queryKeys에 research.sessions, research.session 키
ResearchPage 신규 (src/pages/ResearchPage.tsx):
- 2열 레이아웃: 좌측 연구 설정 폼 + 우측 결과/이력
- 폼: 소스 선택, goal 텍스트(필수, 3~500자), 시드 URL(선택),
max_steps/max_branch/max_depth/min_relevance, same_domain_only
react-hook-form + zod 검증
- 결과 카드: 단계/페이지/엔티티/클레임 4종 stats + raw JSON details
- 세션 이력 카드: GET /projects/{n}/research/sessions 결과 리스트
- 실행 중 안내 (장시간 가능), sonner 토스트
라우팅 & Sidebar:
- App.tsx: /research/:projectId 라우트 추가
- AppShell: 사이드바에 "자율 연구" 메뉴 (Brain 아이콘)
i18n: research.*, nav.research 키 (한/영)
다음 단계: Phase 1.5 — 엔티티/클레임 직접 입력
(백엔드 POST /projects/{n}/entities, /claims 신규 필요)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 19:01:52 +09:00
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
try:
|
|
|
|
|
result = loop.run(
|
|
|
|
|
project_config=config,
|
|
|
|
|
source_name=request.source_name,
|
|
|
|
|
seed_url=request.url or None,
|
|
|
|
|
seed_entity_id=request.seed_entity_id,
|
|
|
|
|
goal=request.goal,
|
|
|
|
|
max_depth=max(request.max_depth, 0),
|
|
|
|
|
max_steps=max(min(request.max_steps, 50), 1),
|
|
|
|
|
max_branch=max(min(request.max_branch, 30), 1),
|
2026-05-12 19:40:31 +09:00
|
|
|
min_relevance=min(max(request.min_relevance, 0.0), 1.0),
|
|
|
|
|
same_domain_only=request.same_domain_only,
|
|
|
|
|
analyze_page_types=set(request.analyze_page_types),
|
|
|
|
|
)
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
|
return asdict(result)
|
|
|
|
|
|
|
|
|
|
@app.get("/projects/{project_name}/research/sessions")
|
|
|
|
|
def research_sessions(project_name: str, limit: int = 25):
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
project = KnowledgeRepository(session).get_project(project_name)
|
|
|
|
|
return ResearchMemoryStore(session).list_sessions(project.id, limit)
|
|
|
|
|
|
|
|
|
|
@app.get("/research/sessions/{job_id}")
|
|
|
|
|
def research_session(job_id: int):
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
job = session.get(models.CrawlJob, job_id)
|
|
|
|
|
if job is None or (job.metadata_json or {}).get("kind") != "research_session":
|
|
|
|
|
raise HTTPException(status_code=404, detail="research session not found")
|
|
|
|
|
return research_session_payload(job)
|
|
|
|
|
|
|
|
|
|
@app.get("/projects/{project_name}/graph/neighborhood")
|
2026-05-19 20:31:52 +09:00
|
|
|
def graph_neighborhood(
|
|
|
|
|
project_name: str,
|
|
|
|
|
entity_id: int | None = None,
|
|
|
|
|
limit: int = 120,
|
|
|
|
|
include_candidates: bool = False,
|
|
|
|
|
status: str | None = None,
|
|
|
|
|
):
|
2026-05-12 19:40:31 +09:00
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
project = KnowledgeRepository(session).get_project(project_name)
|
2026-05-19 20:31:52 +09:00
|
|
|
if status and status != "all":
|
|
|
|
|
normalized = _normalize_claim_status(status) or status
|
|
|
|
|
statuses = [normalized]
|
|
|
|
|
elif status == "all":
|
|
|
|
|
statuses = None
|
|
|
|
|
elif include_candidates:
|
|
|
|
|
statuses = ["validated_claim", "active", "candidate_claim", "rule_candidate"]
|
|
|
|
|
else:
|
|
|
|
|
statuses = ["validated_claim"]
|
|
|
|
|
return SemanticGraphQuery(session).neighborhood(
|
|
|
|
|
project.id,
|
|
|
|
|
entity_id,
|
|
|
|
|
max(min(limit, 300), 1),
|
|
|
|
|
statuses=statuses,
|
|
|
|
|
)
|
2026-05-12 19:40:31 +09:00
|
|
|
|
|
|
|
|
@app.get("/projects/{project_name}/graph/query")
|
|
|
|
|
def graph_query(
|
|
|
|
|
project_name: str,
|
|
|
|
|
kind: str = "trend_summary",
|
|
|
|
|
predicate: str = "hasAccord",
|
|
|
|
|
tag: str | None = None,
|
|
|
|
|
brand: str | None = None,
|
|
|
|
|
limit: int = 100,
|
|
|
|
|
):
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
project = KnowledgeRepository(session).get_project(project_name)
|
|
|
|
|
graph = SemanticGraphQuery(session)
|
|
|
|
|
bounded_limit = max(min(limit, 300), 1)
|
|
|
|
|
if kind == "brand_products":
|
|
|
|
|
return graph.brand_products(project.id, brand, bounded_limit)
|
|
|
|
|
if kind == "products_by_tag":
|
|
|
|
|
return graph.products_by_tag(project.id, predicate, tag, bounded_limit)
|
|
|
|
|
if kind == "relation_summary":
|
|
|
|
|
return graph.relation_summary(project.id, bounded_limit)
|
|
|
|
|
if kind == "entity_type_summary":
|
|
|
|
|
return graph.entity_type_summary(project.id, bounded_limit)
|
|
|
|
|
if kind == "trend_summary":
|
|
|
|
|
return graph.trend_summary(project.id, bounded_limit)
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"unknown graph query kind: {kind}")
|
|
|
|
|
|
2026-05-08 17:41:15 +09:00
|
|
|
@app.get("/projects/{project_name}/entities")
|
|
|
|
|
def project_entities(project_name: str, entity_type: str | None = None, limit: int = 50):
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
project = KnowledgeRepository(session).get_project(project_name)
|
|
|
|
|
query = select(models.Entity).where(models.Entity.project_id == project.id)
|
|
|
|
|
if entity_type:
|
|
|
|
|
query = query.where(models.Entity.entity_type == entity_type)
|
|
|
|
|
entities = session.scalars(query.limit(limit)).all()
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
"id": entity.id,
|
|
|
|
|
"type": entity.entity_type,
|
|
|
|
|
"name": entity.name,
|
|
|
|
|
"metadata": entity.metadata_json,
|
|
|
|
|
}
|
|
|
|
|
for entity in entities
|
|
|
|
|
]
|
|
|
|
|
|
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>
2026-05-14 19:10:37 +09:00
|
|
|
@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}
|
|
|
|
|
|
2026-05-08 17:41:15 +09:00
|
|
|
@app.get("/projects/{project_name}/claims")
|
2026-05-12 19:40:31 +09:00
|
|
|
def project_claims(
|
|
|
|
|
project_name: str,
|
|
|
|
|
limit: int = 100,
|
|
|
|
|
include_candidates: bool = False,
|
|
|
|
|
status: str | None = None,
|
|
|
|
|
):
|
2026-05-08 17:41:15 +09:00
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
project = KnowledgeRepository(session).get_project(project_name)
|
2026-05-12 19:40:31 +09:00
|
|
|
query = (
|
2026-05-08 17:41:15 +09:00
|
|
|
select(models.Claim, models.Source, models.Page, models.Entity)
|
|
|
|
|
.join(models.Source, models.Claim.source_id == models.Source.id)
|
|
|
|
|
.join(models.Page, models.Claim.page_id == models.Page.id, isouter=True)
|
|
|
|
|
.join(models.Entity, models.Claim.subject_entity_id == models.Entity.id)
|
|
|
|
|
.where(models.Claim.project_id == project.id)
|
2026-05-12 19:40:31 +09:00
|
|
|
)
|
|
|
|
|
if status:
|
|
|
|
|
normalized = _normalize_claim_status(status)
|
|
|
|
|
if normalized is None:
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"unknown claim status: {status}")
|
|
|
|
|
query = query.where(models.Claim.status == normalized)
|
|
|
|
|
elif not include_candidates:
|
|
|
|
|
query = query.where(models.Claim.status == "validated_claim")
|
|
|
|
|
rows = session.execute(query.order_by(models.Claim.last_seen_at.desc()).limit(limit)).all()
|
2026-05-08 17:41:15 +09:00
|
|
|
results: list[dict[str, Any]] = []
|
|
|
|
|
for claim, source, page, subject in rows:
|
|
|
|
|
object_name = None
|
2026-05-19 20:31:52 +09:00
|
|
|
object_type = None
|
2026-05-08 17:41:15 +09:00
|
|
|
if claim.object_entity_id:
|
|
|
|
|
object_entity = session.get(models.Entity, claim.object_entity_id)
|
|
|
|
|
object_name = object_entity.name if object_entity else None
|
2026-05-19 20:31:52 +09:00
|
|
|
object_type = object_entity.entity_type if object_entity else None
|
2026-05-08 17:41:15 +09:00
|
|
|
evidence = session.scalar(
|
|
|
|
|
select(models.Evidence)
|
|
|
|
|
.where(models.Evidence.claim_id == claim.id)
|
|
|
|
|
.order_by(models.Evidence.created_at.desc())
|
|
|
|
|
)
|
|
|
|
|
results.append(
|
|
|
|
|
{
|
|
|
|
|
"id": claim.id,
|
|
|
|
|
"subject": subject.name,
|
|
|
|
|
"subject_type": subject.entity_type,
|
|
|
|
|
"predicate": claim.predicate,
|
|
|
|
|
"object": object_name,
|
2026-05-19 20:31:52 +09:00
|
|
|
"object_type": object_type,
|
2026-05-08 17:41:15 +09:00
|
|
|
"object_value": claim.object_value,
|
|
|
|
|
"source": source.name,
|
|
|
|
|
"page_url": page.url if page else None,
|
|
|
|
|
"confidence": claim.confidence,
|
|
|
|
|
"confidence_reason": claim.confidence_reason,
|
2026-05-12 19:40:31 +09:00
|
|
|
"status": claim.status,
|
2026-05-19 20:31:52 +09:00
|
|
|
"extraction_method": claim.extraction_method,
|
2026-05-08 17:41:15 +09:00
|
|
|
"evidence_text": evidence.evidence_text if evidence else None,
|
2026-05-12 19:40:31 +09:00
|
|
|
"evidence_summary": evidence.evidence_summary if evidence else None,
|
|
|
|
|
"page_type": (claim.metadata_json or {}).get("page_type")
|
|
|
|
|
or ((page.metadata_json or {}).get("page_type") if page else None),
|
|
|
|
|
"source_zone": (claim.metadata_json or {}).get("source_zone"),
|
|
|
|
|
"source_selector": (claim.metadata_json or {}).get("source_selector"),
|
|
|
|
|
"evidence_found": (claim.metadata_json or {}).get("evidence_found"),
|
|
|
|
|
"validation_status": (claim.metadata_json or {}).get("validation_status"),
|
|
|
|
|
"graph_merge_status": (claim.metadata_json or {}).get("graph_merge_status"),
|
|
|
|
|
"graph_merge_reason": (claim.metadata_json or {}).get("graph_merge_reason"),
|
|
|
|
|
"confidence_breakdown": (claim.metadata_json or {}).get("confidence_breakdown"),
|
2026-05-22 00:22:03 +09:00
|
|
|
"agreement": (claim.metadata_json or {}).get("agreement"),
|
|
|
|
|
"extraction_source": (claim.metadata_json or {}).get("extraction_source"),
|
|
|
|
|
"claim_kind": (claim.metadata_json or {}).get("claim_kind"),
|
|
|
|
|
"rule_confidence": (claim.metadata_json or {}).get("rule_confidence"),
|
|
|
|
|
"llm_confidence": (claim.metadata_json or {}).get("llm_confidence"),
|
2026-05-12 19:40:31 +09:00
|
|
|
"review_required": (claim.metadata_json or {}).get("review_required"),
|
|
|
|
|
"review_reason": (claim.metadata_json or {}).get("review_reason"),
|
|
|
|
|
"conflict_status": (claim.metadata_json or {}).get("conflict_status"),
|
|
|
|
|
"source_history": (claim.metadata_json or {}).get("source_history") or [],
|
2026-05-08 17:41:15 +09:00
|
|
|
"last_seen_at": claim.last_seen_at.isoformat(),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
return results
|
|
|
|
|
|
2026-05-12 19:40:31 +09:00
|
|
|
@app.get("/projects/{project_name}/pipeline")
|
|
|
|
|
def project_pipeline(project_name: str):
|
|
|
|
|
from sqlalchemy import func as sa_func, distinct
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
project = KnowledgeRepository(session).get_project(project_name)
|
|
|
|
|
pid = project.id
|
|
|
|
|
|
|
|
|
|
pages_total = session.scalar(
|
|
|
|
|
select(sa_func.count(models.Page.id)).where(models.Page.project_id == pid)
|
|
|
|
|
) or 0
|
|
|
|
|
ext_total = session.scalar(
|
|
|
|
|
select(sa_func.count(models.ExtractionLog.id)).where(models.ExtractionLog.project_id == pid)
|
|
|
|
|
) or 0
|
|
|
|
|
ext_pages = session.scalar(
|
|
|
|
|
select(sa_func.count(distinct(models.ExtractionLog.page_id)))
|
|
|
|
|
.where(models.ExtractionLog.project_id == pid, models.ExtractionLog.page_id.isnot(None))
|
|
|
|
|
) or 0
|
|
|
|
|
ext_errors = session.scalar(
|
|
|
|
|
select(sa_func.count(models.ExtractionLog.id))
|
|
|
|
|
.where(models.ExtractionLog.project_id == pid, models.ExtractionLog.error.isnot(None))
|
|
|
|
|
) or 0
|
|
|
|
|
|
|
|
|
|
status_rows = session.execute(
|
|
|
|
|
select(models.Claim.status, sa_func.count(models.Claim.id))
|
|
|
|
|
.where(models.Claim.project_id == pid)
|
|
|
|
|
.group_by(models.Claim.status)
|
|
|
|
|
).all()
|
|
|
|
|
claim_statuses = {row[0]: int(row[1]) for row in status_rows}
|
|
|
|
|
claim_total = sum(claim_statuses.values())
|
|
|
|
|
|
|
|
|
|
entity_count = session.scalar(
|
|
|
|
|
select(sa_func.count(models.Entity.id)).where(models.Entity.project_id == pid)
|
|
|
|
|
) or 0
|
|
|
|
|
triple_count = session.scalar(
|
|
|
|
|
select(sa_func.count(models.OntologyTriple.id))
|
|
|
|
|
.where(models.OntologyTriple.project_id == pid, models.OntologyTriple.status == "active")
|
|
|
|
|
) or 0
|
|
|
|
|
|
|
|
|
|
entity_type_rows = session.execute(
|
|
|
|
|
select(models.Entity.entity_type, sa_func.count(models.Entity.id))
|
|
|
|
|
.where(models.Entity.project_id == pid)
|
|
|
|
|
.group_by(models.Entity.entity_type)
|
|
|
|
|
.order_by(sa_func.count(models.Entity.id).desc())
|
|
|
|
|
.limit(10)
|
|
|
|
|
).all()
|
|
|
|
|
|
|
|
|
|
recent_pages = session.scalars(
|
|
|
|
|
select(models.Page).where(models.Page.project_id == pid)
|
|
|
|
|
.order_by(models.Page.fetched_at.desc()).limit(5)
|
|
|
|
|
).all()
|
|
|
|
|
recent_claims_rows = session.execute(
|
|
|
|
|
select(models.Claim, models.Entity)
|
|
|
|
|
.join(models.Entity, models.Claim.subject_entity_id == models.Entity.id)
|
|
|
|
|
.where(models.Claim.project_id == pid)
|
|
|
|
|
.order_by(models.Claim.last_seen_at.desc()).limit(5)
|
|
|
|
|
).all()
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"stages": [
|
|
|
|
|
{"key": "crawled", "count": pages_total},
|
|
|
|
|
{"key": "extracted", "count": ext_pages, "extra": {"events": ext_total, "errors": ext_errors}},
|
|
|
|
|
{"key": "claims", "count": claim_total, "extra": claim_statuses},
|
|
|
|
|
{"key": "validated", "count": claim_statuses.get("validated_claim", 0)},
|
|
|
|
|
{"key": "graph", "count": triple_count, "extra": {"entities": entity_count}},
|
|
|
|
|
],
|
|
|
|
|
"entity_types": [{"type": row[0], "count": int(row[1])} for row in entity_type_rows],
|
|
|
|
|
"recent_pages": [
|
|
|
|
|
{
|
|
|
|
|
"id": p.id,
|
|
|
|
|
"url": p.url,
|
|
|
|
|
"title": p.title,
|
|
|
|
|
"status_code": p.status_code,
|
|
|
|
|
"page_type": (p.metadata_json or {}).get("page_type"),
|
|
|
|
|
"fetched_at": p.fetched_at.isoformat() if p.fetched_at else None,
|
|
|
|
|
}
|
|
|
|
|
for p in recent_pages
|
|
|
|
|
],
|
|
|
|
|
"recent_claims": [
|
|
|
|
|
{
|
|
|
|
|
"id": claim.id,
|
|
|
|
|
"subject": subject.name,
|
|
|
|
|
"predicate": claim.predicate,
|
|
|
|
|
"confidence": claim.confidence,
|
|
|
|
|
"status": claim.status,
|
|
|
|
|
"last_seen_at": claim.last_seen_at.isoformat() if claim.last_seen_at else None,
|
|
|
|
|
}
|
|
|
|
|
for claim, subject in recent_claims_rows
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 20:31:52 +09:00
|
|
|
@app.post("/projects/{project_name}/pipeline/rerun")
|
|
|
|
|
def rerun_pipeline_stage(
|
|
|
|
|
project_name: str,
|
|
|
|
|
request: PipelineRerunRequest,
|
|
|
|
|
background_tasks: BackgroundTasks,
|
|
|
|
|
):
|
|
|
|
|
crawl_stages = {
|
|
|
|
|
"source-crawl",
|
|
|
|
|
"page-clean",
|
|
|
|
|
"page-classification",
|
|
|
|
|
"entity-extraction",
|
|
|
|
|
"claim-generation",
|
|
|
|
|
"deduplication",
|
|
|
|
|
"validation",
|
|
|
|
|
}
|
|
|
|
|
route_by_stage = {
|
|
|
|
|
"human-review": f"/review/{project_name}",
|
|
|
|
|
"ontology-commit": f"/graph/{project_name}",
|
|
|
|
|
"export": f"/export/{project_name}",
|
|
|
|
|
}
|
|
|
|
|
if request.stage not in crawl_stages:
|
|
|
|
|
return {
|
|
|
|
|
"ok": True,
|
|
|
|
|
"stage": request.stage,
|
|
|
|
|
"action": "navigate",
|
|
|
|
|
"route": route_by_stage.get(request.stage, f"/pipeline/{project_name}"),
|
|
|
|
|
"message": "This stage is controlled from its workspace screen.",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
repo = KnowledgeRepository(session)
|
|
|
|
|
project = repo.get_project(project_name)
|
|
|
|
|
config = project_config_from_project_row(session, project)
|
|
|
|
|
latest_job = session.scalar(
|
|
|
|
|
select(models.CrawlJob)
|
|
|
|
|
.where(models.CrawlJob.project_id == project.id)
|
|
|
|
|
.order_by(models.CrawlJob.scheduled_at.desc())
|
|
|
|
|
)
|
|
|
|
|
latest_request = dict((latest_job.metadata_json or {}).get("request") or {}) if latest_job else {}
|
|
|
|
|
source_name = latest_request.get("source_name")
|
|
|
|
|
source = None
|
|
|
|
|
if source_name:
|
|
|
|
|
source = session.scalar(
|
|
|
|
|
select(models.Source).where(
|
|
|
|
|
models.Source.project_id == project.id,
|
|
|
|
|
models.Source.name == source_name,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
if source is None:
|
|
|
|
|
source = session.scalar(
|
|
|
|
|
select(models.Source)
|
|
|
|
|
.where(models.Source.project_id == project.id)
|
|
|
|
|
.order_by(models.Source.updated_at.desc())
|
|
|
|
|
)
|
|
|
|
|
if source is None:
|
|
|
|
|
raise HTTPException(status_code=400, detail="No source is registered for this project.")
|
|
|
|
|
url = latest_request.get("url") or source.base_url
|
|
|
|
|
if not url:
|
|
|
|
|
raise HTTPException(status_code=400, detail="No previous crawl URL or source base_url is available.")
|
|
|
|
|
|
|
|
|
|
inner_request = SiteCrawlRequest(
|
|
|
|
|
config_path="",
|
|
|
|
|
source_name=source.name,
|
|
|
|
|
url=url,
|
2026-05-22 00:22:03 +09:00
|
|
|
extraction_mode=str(latest_request.get("extraction_mode") or "hybrid"),
|
2026-05-19 20:31:52 +09:00
|
|
|
extractor_provider=str(latest_request.get("extractor_provider") or "lm_studio"),
|
|
|
|
|
extractor_model=latest_request.get("extractor_model"),
|
|
|
|
|
extractor_base_url=latest_request.get("extractor_base_url") or "http://localhost:1234/v1",
|
2026-05-22 00:22:03 +09:00
|
|
|
fallback_to_rules=bool(latest_request.get("fallback_to_rules", True)),
|
2026-05-19 20:31:52 +09:00
|
|
|
check_robots_txt=bool(latest_request.get("check_robots_txt") or False),
|
|
|
|
|
respect_robots_txt=latest_request.get("respect_robots_txt"),
|
|
|
|
|
max_depth=int(latest_request.get("max_depth") or 2),
|
|
|
|
|
max_pages=int(latest_request.get("max_pages") or 50),
|
|
|
|
|
same_domain_only=bool(latest_request.get("same_domain_only", True)),
|
|
|
|
|
analyze_page_types=list(
|
|
|
|
|
latest_request.get("analyze_page_types")
|
|
|
|
|
or ["ProductPage", "BrandStoryPage", "ReviewPage"]
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
apply_crawl_request_overrides(config, inner_request)
|
|
|
|
|
job = models.CrawlJob(
|
|
|
|
|
project_id=project.id,
|
|
|
|
|
source_id=source.id,
|
|
|
|
|
url=url,
|
|
|
|
|
status="pending",
|
|
|
|
|
metadata_json={
|
|
|
|
|
"kind": "site_crawl",
|
|
|
|
|
"request": inner_request.model_dump(),
|
|
|
|
|
"project_name": project_name,
|
|
|
|
|
"rerun_stage": request.stage,
|
|
|
|
|
"progress": {
|
|
|
|
|
"seed_url": url,
|
|
|
|
|
"visited_count": 0,
|
|
|
|
|
"analyzed_count": 0,
|
|
|
|
|
"queued_count": 1,
|
|
|
|
|
"skipped_count": 0,
|
|
|
|
|
"errors": [],
|
|
|
|
|
"pages": [],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
session.add(job)
|
|
|
|
|
session.flush()
|
|
|
|
|
response = crawl_job_response(job)
|
|
|
|
|
|
|
|
|
|
task_payload = {
|
|
|
|
|
**inner_request.model_dump(),
|
|
|
|
|
"__config_dict": project_config_to_dict(config),
|
|
|
|
|
}
|
|
|
|
|
background_tasks.add_task(
|
|
|
|
|
run_site_crawl_job,
|
|
|
|
|
database_url,
|
|
|
|
|
response["job_id"],
|
|
|
|
|
task_payload,
|
|
|
|
|
)
|
|
|
|
|
return {
|
|
|
|
|
**response,
|
|
|
|
|
"ok": True,
|
|
|
|
|
"stage": request.stage,
|
|
|
|
|
"action": "job_started",
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 19:40:31 +09:00
|
|
|
@app.get("/projects/{project_name}/search")
|
|
|
|
|
def project_search(project_name: str, q: str = "", limit: int = 10):
|
|
|
|
|
q = (q or "").strip()
|
|
|
|
|
if len(q) < 1:
|
|
|
|
|
return {"entities": [], "claims": [], "pages": [], "predicates": []}
|
|
|
|
|
like = f"%{q.lower()}%"
|
|
|
|
|
bounded = max(min(limit, 50), 1)
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
project = KnowledgeRepository(session).get_project(project_name)
|
|
|
|
|
pid = project.id
|
|
|
|
|
|
|
|
|
|
from sqlalchemy import func as sa_func, or_
|
|
|
|
|
entity_rows = session.scalars(
|
|
|
|
|
select(models.Entity)
|
|
|
|
|
.where(
|
|
|
|
|
models.Entity.project_id == pid,
|
|
|
|
|
or_(
|
|
|
|
|
sa_func.lower(models.Entity.name).like(like),
|
|
|
|
|
sa_func.lower(models.Entity.canonical_name).like(like),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.limit(bounded)
|
|
|
|
|
).all()
|
|
|
|
|
|
|
|
|
|
claim_rows = session.execute(
|
|
|
|
|
select(models.Claim, models.Entity)
|
|
|
|
|
.join(models.Entity, models.Claim.subject_entity_id == models.Entity.id)
|
|
|
|
|
.where(
|
|
|
|
|
models.Claim.project_id == pid,
|
|
|
|
|
or_(
|
|
|
|
|
sa_func.lower(models.Entity.name).like(like),
|
|
|
|
|
sa_func.lower(models.Claim.predicate).like(like),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.order_by(models.Claim.last_seen_at.desc())
|
|
|
|
|
.limit(bounded)
|
|
|
|
|
).all()
|
|
|
|
|
|
|
|
|
|
page_rows = session.scalars(
|
|
|
|
|
select(models.Page)
|
|
|
|
|
.where(
|
|
|
|
|
models.Page.project_id == pid,
|
|
|
|
|
or_(
|
|
|
|
|
sa_func.lower(models.Page.url).like(like),
|
|
|
|
|
sa_func.lower(models.Page.title).like(like),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.order_by(models.Page.fetched_at.desc())
|
|
|
|
|
.limit(bounded)
|
|
|
|
|
).all()
|
|
|
|
|
|
|
|
|
|
predicate_rows = session.scalars(
|
|
|
|
|
select(models.OntologyRelationType.name)
|
|
|
|
|
.where(
|
|
|
|
|
models.OntologyRelationType.project_id == pid,
|
|
|
|
|
sa_func.lower(models.OntologyRelationType.name).like(like),
|
|
|
|
|
)
|
|
|
|
|
.limit(bounded)
|
|
|
|
|
).all()
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"entities": [
|
|
|
|
|
{"id": e.id, "name": e.name, "type": e.entity_type, "canonical_name": e.canonical_name}
|
|
|
|
|
for e in entity_rows
|
|
|
|
|
],
|
|
|
|
|
"claims": [
|
|
|
|
|
{
|
|
|
|
|
"id": claim.id,
|
|
|
|
|
"subject": subject.name,
|
|
|
|
|
"predicate": claim.predicate,
|
|
|
|
|
"object_value": claim.object_value,
|
|
|
|
|
"confidence": claim.confidence,
|
|
|
|
|
"status": claim.status,
|
|
|
|
|
}
|
|
|
|
|
for claim, subject in claim_rows
|
|
|
|
|
],
|
|
|
|
|
"pages": [
|
|
|
|
|
{"id": p.id, "url": p.url, "title": p.title}
|
|
|
|
|
for p in page_rows
|
|
|
|
|
],
|
|
|
|
|
"predicates": list(predicate_rows),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@app.get("/projects/{project_name}/extraction-logs")
|
|
|
|
|
def project_extraction_logs(project_name: str, limit: int = 50):
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
project = KnowledgeRepository(session).get_project(project_name)
|
|
|
|
|
rows = session.execute(
|
|
|
|
|
select(models.ExtractionLog, models.Page)
|
|
|
|
|
.join(models.Page, models.ExtractionLog.page_id == models.Page.id, isouter=True)
|
|
|
|
|
.where(models.ExtractionLog.project_id == project.id)
|
|
|
|
|
.order_by(models.ExtractionLog.created_at.desc())
|
|
|
|
|
.limit(limit)
|
|
|
|
|
).all()
|
2026-05-22 19:07:07 +09:00
|
|
|
payload = []
|
|
|
|
|
for log, page in rows:
|
|
|
|
|
raw_output = log.raw_output or {}
|
|
|
|
|
summary = extraction_log_summary(raw_output)
|
|
|
|
|
payload.append({
|
2026-05-12 19:40:31 +09:00
|
|
|
"id": log.id,
|
|
|
|
|
"page_url": page.url if page else None,
|
|
|
|
|
"extractor_name": log.extractor_name,
|
|
|
|
|
"provider": log.provider,
|
|
|
|
|
"error": log.error,
|
|
|
|
|
"created_at": log.created_at.isoformat(),
|
2026-05-22 19:07:07 +09:00
|
|
|
"validation": raw_output.get("validation"),
|
|
|
|
|
"page_context": raw_output.get("page_context"),
|
|
|
|
|
"candidate_count": summary["candidate_count"],
|
|
|
|
|
"extraction_mode": raw_output.get("extraction_mode"),
|
|
|
|
|
"effective_extraction_mode": raw_output.get("effective_extraction_mode"),
|
|
|
|
|
"comparison": summary["comparison"],
|
|
|
|
|
"rule_entity_count": summary["rule_entity_count"],
|
|
|
|
|
"rule_claim_count": summary["rule_claim_count"],
|
|
|
|
|
"llm_entity_count": summary["llm_entity_count"],
|
|
|
|
|
"llm_claim_count": summary["llm_claim_count"],
|
|
|
|
|
"agreement_claim_count": summary["agreement_claim_count"],
|
|
|
|
|
"rule_only_claim_count": summary["rule_only_claim_count"],
|
|
|
|
|
"llm_only_claim_count": summary["llm_only_claim_count"],
|
|
|
|
|
"conflict_claim_count": summary["conflict_claim_count"],
|
|
|
|
|
"llm_skipped": raw_output.get("llm_skipped"),
|
|
|
|
|
"llm_skip_reason": raw_output.get("llm_skip_reason"),
|
|
|
|
|
"fallback": raw_output.get("fallback"),
|
2026-05-12 19:40:31 +09:00
|
|
|
"raw_output": log.raw_output,
|
2026-05-22 19:07:07 +09:00
|
|
|
})
|
|
|
|
|
return payload
|
2026-05-12 19:40:31 +09:00
|
|
|
|
2026-05-08 17:41:15 +09:00
|
|
|
@app.patch("/claims/{claim_id}/confidence")
|
|
|
|
|
def update_claim_confidence(claim_id: int, request: UpdateClaimConfidenceRequest):
|
|
|
|
|
confidence = min(max(request.confidence, 0.0), 1.0)
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
claim = session.get(models.Claim, claim_id)
|
|
|
|
|
if claim is None:
|
|
|
|
|
return {"ok": False, "error": "claim not found"}
|
|
|
|
|
claim.confidence = confidence
|
|
|
|
|
claim.confidence_reason = request.reason or "manual admin update"
|
|
|
|
|
claim.last_seen_at = models.utcnow()
|
|
|
|
|
return {"ok": True, "claim_id": claim.id, "confidence": claim.confidence}
|
|
|
|
|
|
2026-05-12 19:40:31 +09:00
|
|
|
@app.patch("/claims/{claim_id}/status")
|
|
|
|
|
def update_claim_status(claim_id: int, request: UpdateClaimStatusRequest):
|
|
|
|
|
status = _normalize_claim_status(request.status)
|
|
|
|
|
if status is None:
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"unknown claim status: {request.status}")
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
claim = session.get(models.Claim, claim_id)
|
|
|
|
|
if claim is None:
|
|
|
|
|
raise HTTPException(status_code=404, detail="claim not found")
|
|
|
|
|
_apply_claim_review(claim, status, request.reason)
|
|
|
|
|
return {"ok": True, "claim_id": claim.id, "status": claim.status}
|
|
|
|
|
|
|
|
|
|
@app.post("/claims/bulk-status")
|
|
|
|
|
def bulk_update_claim_status(request: BulkClaimStatusRequest):
|
|
|
|
|
status = _normalize_claim_status(request.status)
|
|
|
|
|
if status is None:
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"unknown claim status: {request.status}")
|
|
|
|
|
if not request.claim_ids:
|
|
|
|
|
return {"ok": True, "updated": 0}
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
rows = session.execute(
|
|
|
|
|
select(models.Claim).where(models.Claim.id.in_(request.claim_ids))
|
|
|
|
|
).scalars().all()
|
|
|
|
|
for claim in rows:
|
|
|
|
|
_apply_claim_review(claim, status, request.reason)
|
|
|
|
|
return {"ok": True, "updated": len(rows), "status": status}
|
|
|
|
|
|
2026-05-08 17:41:15 +09:00
|
|
|
@app.post("/entities/merge")
|
|
|
|
|
def merge_entities(request: MergeEntitiesRequest):
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
project = KnowledgeRepository(session).get_project(request.project_name)
|
|
|
|
|
source = session.get(models.Entity, request.source_entity_id)
|
|
|
|
|
target = session.get(models.Entity, request.target_entity_id)
|
|
|
|
|
if source is None or target is None or source.project_id != project.id or target.project_id != project.id:
|
|
|
|
|
return {"ok": False, "error": "entity not found in project"}
|
|
|
|
|
session.query(models.Claim).filter(models.Claim.subject_entity_id == source.id).update(
|
|
|
|
|
{models.Claim.subject_entity_id: target.id}
|
|
|
|
|
)
|
|
|
|
|
session.query(models.Claim).filter(models.Claim.object_entity_id == source.id).update(
|
|
|
|
|
{models.Claim.object_entity_id: target.id}
|
|
|
|
|
)
|
|
|
|
|
session.query(models.Relation).filter(models.Relation.subject_entity_id == source.id).update(
|
|
|
|
|
{models.Relation.subject_entity_id: target.id}
|
|
|
|
|
)
|
|
|
|
|
session.query(models.Relation).filter(models.Relation.object_entity_id == source.id).update(
|
|
|
|
|
{models.Relation.object_entity_id: target.id}
|
|
|
|
|
)
|
|
|
|
|
source.metadata_json = {**(source.metadata_json or {}), "merged_into": target.id}
|
|
|
|
|
source.updated_at = models.utcnow()
|
|
|
|
|
return {"ok": True, "source_entity_id": source.id, "target_entity_id": target.id}
|
|
|
|
|
|
|
|
|
|
@app.get("/projects/{project_name}/recommendation-tags")
|
|
|
|
|
def recommendation_tags(project_name: str):
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
project = KnowledgeRepository(session).get_project(project_name)
|
|
|
|
|
tag_predicates = {
|
|
|
|
|
"hasTopNote",
|
|
|
|
|
"hasMiddleNote",
|
|
|
|
|
"hasBaseNote",
|
|
|
|
|
"hasScentNote",
|
|
|
|
|
"hasFlavorNote",
|
|
|
|
|
"evokesMood",
|
|
|
|
|
"suitableForSeason",
|
|
|
|
|
"suitableForOccasion",
|
|
|
|
|
"hasReviewKeyword",
|
|
|
|
|
}
|
|
|
|
|
rows = session.execute(
|
|
|
|
|
select(models.Claim, models.Entity)
|
|
|
|
|
.join(models.Entity, models.Claim.object_entity_id == models.Entity.id)
|
2026-05-12 19:40:31 +09:00
|
|
|
.where(
|
|
|
|
|
models.Claim.project_id == project.id,
|
|
|
|
|
models.Claim.status == "validated_claim",
|
|
|
|
|
models.Claim.predicate.in_(tag_predicates),
|
|
|
|
|
)
|
2026-05-08 17:41:15 +09:00
|
|
|
).all()
|
|
|
|
|
grouped: dict[str, dict[str, Any]] = {}
|
|
|
|
|
for claim, entity in rows:
|
|
|
|
|
key = f"{claim.predicate}:{entity.canonical_name}"
|
|
|
|
|
if key not in grouped:
|
|
|
|
|
grouped[key] = {
|
|
|
|
|
"predicate": claim.predicate,
|
|
|
|
|
"name": entity.name,
|
|
|
|
|
"type": entity.entity_type,
|
|
|
|
|
"support_count": 0,
|
|
|
|
|
"max_confidence": 0.0,
|
|
|
|
|
}
|
|
|
|
|
grouped[key]["support_count"] += 1
|
|
|
|
|
grouped[key]["max_confidence"] = max(grouped[key]["max_confidence"], claim.confidence)
|
|
|
|
|
return sorted(grouped.values(), key=lambda item: (item["predicate"], -item["support_count"], item["name"]))
|
|
|
|
|
|
|
|
|
|
@app.post("/recommend")
|
|
|
|
|
def recommend(request: RecommendRequest):
|
|
|
|
|
with session_scope(database_url) as session:
|
|
|
|
|
repo = KnowledgeRepository(session)
|
|
|
|
|
project = repo.get_project(request.project_name)
|
|
|
|
|
recommender = RuleBasedRecommender(session)
|
|
|
|
|
pref = PreferenceInput(**request.preferences)
|
|
|
|
|
items = recommender.recommend(project.id, request.target_entity_type, pref, request.limit)
|
|
|
|
|
return [asdict(item) for item in items]
|