[crawler_platform 삭제]
This commit is contained in:
3
ontology_platform/web/frontend/.gitignore
vendored
Normal file
3
ontology_platform/web/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
13
ontology_platform/web/frontend/index.html
Normal file
13
ontology_platform/web/frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content="Ontology Construction Platform with Phase 5 GraphRAG and Phase 7 LLM" />
|
||||
<title>Ontology Builder - AI-Powered Ontology Construction</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3790
ontology_platform/web/frontend/package-lock.json
generated
Normal file
3790
ontology_platform/web/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
ontology_platform/web/frontend/package.json
Normal file
44
ontology_platform/web/frontend/package.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "ontology-platform-ui",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^7.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.3.4",
|
||||
"@reduxjs/toolkit": "^1.9.7",
|
||||
"@tanstack/react-query": "^5.28.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"cytoscape": "^3.33.3",
|
||||
"i18next": "^23.7.6",
|
||||
"i18next-browser-languagedetector": "^7.2.0",
|
||||
"i18next-http-backend": "^2.4.2",
|
||||
"lucide-react": "^0.292.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.48.0",
|
||||
"react-i18next": "^13.4.0",
|
||||
"react-redux": "^8.1.3",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"reactflow": "^11.10.1",
|
||||
"sonner": "^1.2.3",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"zod": "^3.22.4"
|
||||
}
|
||||
}
|
||||
6
ontology_platform/web/frontend/postcss.config.js
Normal file
6
ontology_platform/web/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
172
ontology_platform/web/frontend/public/locales/en/common.json
Normal file
172
ontology_platform/web/frontend/public/locales/en/common.json
Normal file
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Ontology Builder",
|
||||
"subtitle": "AI-powered ontology construction"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"onboard": "New Project",
|
||||
"sources": "Sources",
|
||||
"crawl": "Crawl",
|
||||
"research": "Research",
|
||||
"editor": "Editor",
|
||||
"review": "Review",
|
||||
"toggleSidebar": "Toggle sidebar"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Ontology Builder",
|
||||
"subtitle": "Build and manage domain ontologies with AI-powered extraction",
|
||||
"newProject": "New Project",
|
||||
"projects": "Projects",
|
||||
"projectCount": "{{count}} total",
|
||||
"loadFailed": "Failed to load projects",
|
||||
"empty": {
|
||||
"title": "No projects yet",
|
||||
"hint": "Create your first project to start building an ontology"
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"title": "Create New Project",
|
||||
"formTitle": "Choose Ontology Domain",
|
||||
"formDesc": "Pick the domain of ontology you want to build and give your project a name.",
|
||||
"projectName": "Project Name",
|
||||
"projectNameHint": "Letters, digits, _ and - only (2~64 chars)",
|
||||
"domain": "Domain",
|
||||
"domainSummary": "{{entities}} entity types · {{predicates}} predicates",
|
||||
"submit": "Create Project",
|
||||
"created": "Project created: {{name}}",
|
||||
"createFailed": "Create failed: {{msg}}"
|
||||
},
|
||||
"sources": {
|
||||
"title": "Configure Sources",
|
||||
"next": "Proceed to Crawl",
|
||||
"listTitle": "Registered Sources",
|
||||
"listDesc": "Reference sites used for ontology construction",
|
||||
"empty": "No sources yet. Add one using the form on the right.",
|
||||
"addTitle": "Add Source",
|
||||
"addDesc": "Enter information about the reference site",
|
||||
"name": "Name",
|
||||
"type": "Type",
|
||||
"baseUrl": "Base URL",
|
||||
"trust": "Trust",
|
||||
"rateLimit": "rate/min",
|
||||
"respectRobots": "Respect robots.txt",
|
||||
"add": "Add Source",
|
||||
"delete": "Delete",
|
||||
"confirmDelete": "Delete source '{{name}}'?",
|
||||
"added": "Source added: {{name}}",
|
||||
"addFailed": "Add failed: {{msg}}",
|
||||
"deleted": "Source deleted: {{name}}",
|
||||
"deleteFailed": "Delete failed: {{msg}}"
|
||||
},
|
||||
"research": {
|
||||
"title": "Autonomous Research",
|
||||
"formTitle": "Research Settings",
|
||||
"formDesc": "AI follows links from a seed to autonomously expand the ontology",
|
||||
"source": "Source",
|
||||
"pickSource": "Pick a source...",
|
||||
"goal": "Goal",
|
||||
"goalPlaceholder": "e.g. Collect note compositions and seasonal recommendations of popular perfume brands",
|
||||
"seedUrl": "Seed URL",
|
||||
"optional": "optional",
|
||||
"maxSteps": "Max Steps",
|
||||
"maxBranch": "Branch Width",
|
||||
"maxDepth": "Max Depth",
|
||||
"minRelevance": "Min Relevance",
|
||||
"sameDomainOnly": "Same domain only",
|
||||
"start": "Start Research",
|
||||
"runningHint": "This may take a while. Don't close the page until it finishes.",
|
||||
"runningTitle": "AI is researching...",
|
||||
"completed": "Research completed",
|
||||
"failed": "Failed: {{msg}}",
|
||||
"doneHint": "Done",
|
||||
"idleHint": "Start research on the left to see results here",
|
||||
"resultTitle": "Latest Result",
|
||||
"resultDesc": "Outcome of the research run in this session",
|
||||
"stepsTaken": "Steps",
|
||||
"pagesVisited": "Pages",
|
||||
"entitiesFound": "Entities",
|
||||
"claimsAdded": "Claims",
|
||||
"rawResult": "Raw JSON",
|
||||
"historyTitle": "Session History",
|
||||
"historyDesc": "Past research sessions for this project",
|
||||
"historyEmpty": "No sessions yet",
|
||||
"pages": "pages"
|
||||
},
|
||||
"editor": {
|
||||
"title": "Ontology Editor",
|
||||
"entitiesTab": "Entities",
|
||||
"claimsTab": "Claims",
|
||||
"bulkTab": "JSON Bulk",
|
||||
"addEntity": "Add Entity",
|
||||
"addEntityDesc": "Pick from the domain's entity_types",
|
||||
"entityType": "Type",
|
||||
"pickType": "Pick type...",
|
||||
"entityName": "Name",
|
||||
"add": "Add",
|
||||
"entitiesList": "Entities",
|
||||
"entityCount": "{{count}} total",
|
||||
"entitiesEmpty": "No entities yet",
|
||||
"entityAdded": "Entity added: {{name}}",
|
||||
"entityAddFailed": "Add failed: {{msg}}",
|
||||
"confirmDeleteEntity": "Delete entity '{{name}}'?",
|
||||
"addClaim": "Add Claim",
|
||||
"addClaimDesc": "Subject-Predicate-Object form",
|
||||
"source": "Source",
|
||||
"pickSource": "Pick source...",
|
||||
"subject": "Subject",
|
||||
"pickSubject": "Pick entity...",
|
||||
"predicate": "Predicate",
|
||||
"pickPredicate": "Pick predicate...",
|
||||
"objectKind": "Object kind",
|
||||
"objectEntity": "Other entity",
|
||||
"objectValue": "Literal value",
|
||||
"pickObject": "Pick entity...",
|
||||
"confidence": "Confidence",
|
||||
"claimsList": "Claims",
|
||||
"claimCount": "{{count}} total",
|
||||
"claimsEmpty": "No claims yet",
|
||||
"claimAdded": "Claim added",
|
||||
"claimAddFailed": "Add failed: {{msg}}",
|
||||
"confirmDeleteClaim": "Delete this claim?",
|
||||
"bulkTitle": "JSON Bulk Input",
|
||||
"bulkDesc": "JSON of shape { entities: [{ entity_type, name, metadata? }] }",
|
||||
"bulkSubmit": "Bulk Add",
|
||||
"bulkAdded": "{{count}} entities added"
|
||||
},
|
||||
"crawl": {
|
||||
"title": "Seed Crawl",
|
||||
"formTitle": "Crawl Settings",
|
||||
"formDesc": "Start from a seed URL and follow links to extract information",
|
||||
"source": "Source",
|
||||
"pickSource": "Pick a source...",
|
||||
"noSources": "No sources registered. Add a reference source first.",
|
||||
"addSource": "Add Source",
|
||||
"seedUrl": "Seed URL",
|
||||
"maxDepth": "Max Depth",
|
||||
"maxPages": "Max Pages",
|
||||
"sameDomainOnly": "Same domain only",
|
||||
"start": "Start Crawl",
|
||||
"started": "Crawl started (job #{{id}})",
|
||||
"startFailed": "Start failed: {{msg}}",
|
||||
"cancel": "Cancel",
|
||||
"cancelRequested": "Cancel requested",
|
||||
"cancelFailed": "Cancel failed: {{msg}}",
|
||||
"progressTitle": "Progress",
|
||||
"idleHint": "Enter a seed URL and start the crawl",
|
||||
"visited": "Visited",
|
||||
"queued": "Queued",
|
||||
"analyzed": "Analyzed",
|
||||
"latestPage": "Latest Page",
|
||||
"errorsCount": "{{count}} errors",
|
||||
"doneHint": "Crawl complete. Go review the results.",
|
||||
"review": "Review"
|
||||
},
|
||||
"common": {
|
||||
"retry": "Retry",
|
||||
"cancel": "Cancel",
|
||||
"next": "Next",
|
||||
"back": "Back",
|
||||
"complete": "Complete"
|
||||
}
|
||||
}
|
||||
172
ontology_platform/web/frontend/public/locales/ko/common.json
Normal file
172
ontology_platform/web/frontend/public/locales/ko/common.json
Normal file
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "온톨로지 빌더",
|
||||
"subtitle": "AI 기반 온톨로지 구축 플랫폼"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "대시보드",
|
||||
"onboard": "프로젝트 생성",
|
||||
"sources": "참고 소스",
|
||||
"crawl": "크롤 진행",
|
||||
"research": "자율 연구",
|
||||
"editor": "온톨로지 편집",
|
||||
"review": "결과 검토",
|
||||
"toggleSidebar": "사이드바 토글"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "온톨로지 빌더",
|
||||
"subtitle": "도메인 온톨로지를 AI 추출로 구축하고 관리합니다",
|
||||
"newProject": "새 프로젝트",
|
||||
"projects": "프로젝트 목록",
|
||||
"projectCount": "{{count}}개",
|
||||
"loadFailed": "프로젝트를 불러오지 못했습니다",
|
||||
"empty": {
|
||||
"title": "아직 프로젝트가 없습니다",
|
||||
"hint": "첫 프로젝트를 만들어 온톨로지 구축을 시작하세요"
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"title": "새 프로젝트 만들기",
|
||||
"formTitle": "온톨로지 도메인 선택",
|
||||
"formDesc": "어떤 종류의 온톨로지를 구축할지 도메인을 선택하고 프로젝트 이름을 정해주세요.",
|
||||
"projectName": "프로젝트 이름",
|
||||
"projectNameHint": "영문, 숫자, _ , - 만 사용 (2~64자)",
|
||||
"domain": "도메인",
|
||||
"domainSummary": "엔티티 {{entities}}종 · 관계 {{predicates}}개",
|
||||
"submit": "프로젝트 만들기",
|
||||
"created": "프로젝트가 생성되었습니다: {{name}}",
|
||||
"createFailed": "생성 실패: {{msg}}"
|
||||
},
|
||||
"sources": {
|
||||
"title": "참고 소스 설정",
|
||||
"next": "크롤 진행",
|
||||
"listTitle": "등록된 소스",
|
||||
"listDesc": "프로젝트 온톨로지 구축에 사용할 참고 사이트 목록",
|
||||
"empty": "아직 등록된 소스가 없습니다. 오른쪽 폼에서 추가하세요.",
|
||||
"addTitle": "소스 추가",
|
||||
"addDesc": "참고할 사이트 정보를 입력하세요",
|
||||
"name": "이름",
|
||||
"type": "타입",
|
||||
"baseUrl": "Base URL",
|
||||
"trust": "신뢰도",
|
||||
"rateLimit": "rate/분",
|
||||
"respectRobots": "robots.txt 준수",
|
||||
"add": "소스 추가",
|
||||
"delete": "삭제",
|
||||
"confirmDelete": "정말 '{{name}}' 소스를 삭제하시겠습니까?",
|
||||
"added": "소스가 추가되었습니다: {{name}}",
|
||||
"addFailed": "추가 실패: {{msg}}",
|
||||
"deleted": "소스가 삭제되었습니다: {{name}}",
|
||||
"deleteFailed": "삭제 실패: {{msg}}"
|
||||
},
|
||||
"research": {
|
||||
"title": "자율 연구",
|
||||
"formTitle": "자율 연구 설정",
|
||||
"formDesc": "AI가 시드에서 시작해 스스로 링크를 따라가며 온톨로지를 확장합니다",
|
||||
"source": "참고 소스",
|
||||
"pickSource": "소스를 선택하세요...",
|
||||
"goal": "목표",
|
||||
"goalPlaceholder": "예: 인기 브랜드 향수의 노트 구성과 시즌 추천 정보 수집",
|
||||
"seedUrl": "시드 URL",
|
||||
"optional": "선택",
|
||||
"maxSteps": "최대 단계",
|
||||
"maxBranch": "분기 폭",
|
||||
"maxDepth": "최대 깊이",
|
||||
"minRelevance": "최소 관련도",
|
||||
"sameDomainOnly": "동일 도메인만 탐색",
|
||||
"start": "자율 연구 시작",
|
||||
"runningHint": "장시간 걸릴 수 있습니다. 완료될 때까지 페이지를 닫지 마세요.",
|
||||
"runningTitle": "AI가 연구 중입니다...",
|
||||
"completed": "자율 연구가 완료되었습니다",
|
||||
"failed": "실패: {{msg}}",
|
||||
"doneHint": "완료",
|
||||
"idleHint": "왼쪽에서 자율 연구를 시작하면 결과가 여기에 표시됩니다",
|
||||
"resultTitle": "최근 결과",
|
||||
"resultDesc": "이번 세션에서 실행된 연구의 결과",
|
||||
"stepsTaken": "단계",
|
||||
"pagesVisited": "페이지",
|
||||
"entitiesFound": "엔티티",
|
||||
"claimsAdded": "클레임",
|
||||
"rawResult": "원시 응답 JSON",
|
||||
"historyTitle": "세션 이력",
|
||||
"historyDesc": "이 프로젝트의 자율 연구 세션 기록",
|
||||
"historyEmpty": "아직 실행된 세션이 없습니다",
|
||||
"pages": "페이지"
|
||||
},
|
||||
"editor": {
|
||||
"title": "온톨로지 직접 편집",
|
||||
"entitiesTab": "엔티티",
|
||||
"claimsTab": "클레임",
|
||||
"bulkTab": "JSON 일괄 입력",
|
||||
"addEntity": "엔티티 추가",
|
||||
"addEntityDesc": "온톨로지 도메인의 entity_types 중에서 선택",
|
||||
"entityType": "타입",
|
||||
"pickType": "타입 선택...",
|
||||
"entityName": "이름",
|
||||
"add": "추가",
|
||||
"entitiesList": "엔티티 목록",
|
||||
"entityCount": "{{count}}개",
|
||||
"entitiesEmpty": "아직 등록된 엔티티가 없습니다",
|
||||
"entityAdded": "엔티티가 추가되었습니다: {{name}}",
|
||||
"entityAddFailed": "추가 실패: {{msg}}",
|
||||
"confirmDeleteEntity": "엔티티 '{{name}}'을 삭제하시겠습니까?",
|
||||
"addClaim": "클레임 추가",
|
||||
"addClaimDesc": "주어-술어-목적어 형태로 직접 입력",
|
||||
"source": "소스",
|
||||
"pickSource": "소스 선택...",
|
||||
"subject": "주어 (Subject)",
|
||||
"pickSubject": "엔티티 선택...",
|
||||
"predicate": "술어 (Predicate)",
|
||||
"pickPredicate": "술어 선택...",
|
||||
"objectKind": "목적어 유형",
|
||||
"objectEntity": "다른 엔티티",
|
||||
"objectValue": "리터럴 값",
|
||||
"pickObject": "엔티티 선택...",
|
||||
"confidence": "신뢰도",
|
||||
"claimsList": "클레임 목록",
|
||||
"claimCount": "{{count}}개",
|
||||
"claimsEmpty": "아직 등록된 클레임이 없습니다",
|
||||
"claimAdded": "클레임이 추가되었습니다",
|
||||
"claimAddFailed": "추가 실패: {{msg}}",
|
||||
"confirmDeleteClaim": "클레임을 삭제하시겠습니까?",
|
||||
"bulkTitle": "JSON 일괄 입력",
|
||||
"bulkDesc": "{ entities: [{ entity_type, name, metadata? }] } 형태의 JSON",
|
||||
"bulkSubmit": "일괄 추가",
|
||||
"bulkAdded": "{{count}}개 엔티티가 추가되었습니다"
|
||||
},
|
||||
"crawl": {
|
||||
"title": "시드 크롤",
|
||||
"formTitle": "크롤 설정",
|
||||
"formDesc": "시드 URL에서 시작해 링크를 따라가며 정보를 추출합니다",
|
||||
"source": "참고 소스",
|
||||
"pickSource": "소스를 선택하세요...",
|
||||
"noSources": "등록된 소스가 없습니다. 먼저 참고 소스를 추가하세요.",
|
||||
"addSource": "소스 추가",
|
||||
"seedUrl": "시드 URL",
|
||||
"maxDepth": "최대 깊이",
|
||||
"maxPages": "최대 페이지",
|
||||
"sameDomainOnly": "동일 도메인만 따라가기",
|
||||
"start": "크롤 시작",
|
||||
"started": "크롤이 시작되었습니다 (job #{{id}})",
|
||||
"startFailed": "시작 실패: {{msg}}",
|
||||
"cancel": "취소",
|
||||
"cancelRequested": "취소 요청됨",
|
||||
"cancelFailed": "취소 실패: {{msg}}",
|
||||
"progressTitle": "진행 상태",
|
||||
"idleHint": "왼쪽에서 시드 URL을 입력하고 시작하세요",
|
||||
"visited": "방문",
|
||||
"queued": "대기",
|
||||
"analyzed": "분석",
|
||||
"latestPage": "최근 페이지",
|
||||
"errorsCount": "에러 {{count}}건",
|
||||
"doneHint": "크롤 완료. 결과 검토로 이동하세요.",
|
||||
"review": "결과 검토"
|
||||
},
|
||||
"common": {
|
||||
"retry": "다시 시도",
|
||||
"cancel": "취소",
|
||||
"next": "다음",
|
||||
"back": "이전",
|
||||
"complete": "완료"
|
||||
}
|
||||
}
|
||||
39
ontology_platform/web/frontend/src/App.tsx
Normal file
39
ontology_platform/web/frontend/src/App.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import AppShell from "@/components/layout/AppShell";
|
||||
import OnboardingPage from "@/pages/OnboardingPage";
|
||||
import ConfigureSourcesPage from "@/pages/ConfigureSourcesPage";
|
||||
import CrawlPage from "@/pages/CrawlPage";
|
||||
import ResearchPage from "@/pages/ResearchPage";
|
||||
import OntologyEditorPage from "@/pages/OntologyEditorPage";
|
||||
import ReviewPage from "@/pages/ReviewPage";
|
||||
import DashboardPage from "@/pages/DashboardPage";
|
||||
import BuildPipelinePage from "@/pages/BuildPipelinePage";
|
||||
import PageAnalysisPage from "@/pages/PageAnalysisPage";
|
||||
import SchemaDesignerPage from "@/pages/SchemaDesignerPage";
|
||||
import QualityInspectorPage from "@/pages/QualityInspectorPage";
|
||||
import GraphViewPage from "@/pages/GraphViewPage";
|
||||
import ExportApiPage from "@/pages/ExportApiPage";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/onboard" element={<OnboardingPage />} />
|
||||
<Route path="/sources/:projectId" element={<ConfigureSourcesPage />} />
|
||||
<Route path="/crawl/:projectId" element={<CrawlPage />} />
|
||||
<Route path="/pipeline/:projectId" element={<BuildPipelinePage />} />
|
||||
<Route path="/analysis/:projectId" element={<PageAnalysisPage />} />
|
||||
<Route path="/schema/:projectId" element={<SchemaDesignerPage />} />
|
||||
<Route path="/research/:projectId" element={<ResearchPage />} />
|
||||
<Route path="/editor/:projectId" element={<OntologyEditorPage />} />
|
||||
<Route path="/review/:projectId" element={<ReviewPage />} />
|
||||
<Route path="/quality/:projectId" element={<QualityInspectorPage />} />
|
||||
<Route path="/graph/:projectId" element={<GraphViewPage />} />
|
||||
<Route path="/export/:projectId" element={<ExportApiPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
matchPath,
|
||||
NavLink,
|
||||
Outlet,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
UploadCloud,
|
||||
Settings2,
|
||||
Activity,
|
||||
Brain,
|
||||
Code2,
|
||||
GitBranch,
|
||||
Network,
|
||||
ListChecks,
|
||||
Menu,
|
||||
SearchCheck,
|
||||
ShieldCheck,
|
||||
ClipboardCheck,
|
||||
Layers3,
|
||||
} from "lucide-react";
|
||||
import { RootState } from "@/stores";
|
||||
import { toggleSidebar } from "@/stores/slices/uiSlice";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useProjects } from "@/hooks/useProjects";
|
||||
|
||||
interface NavItem {
|
||||
to?: string;
|
||||
projectPath?: string;
|
||||
labelKey: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ to: "/", labelKey: "nav.dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ to: "/onboard", labelKey: "nav.onboard", label: "New Project", icon: UploadCloud },
|
||||
{ projectPath: "sources", labelKey: "nav.sources", label: "Source Explorer", icon: Settings2 },
|
||||
{ projectPath: "crawl", labelKey: "nav.crawl", label: "Seed Crawl", icon: Activity },
|
||||
{ projectPath: "pipeline", labelKey: "nav.pipeline", label: "Build Pipeline", icon: Layers3 },
|
||||
{ projectPath: "analysis", labelKey: "nav.analysis", label: "Page Analysis", icon: SearchCheck },
|
||||
{ projectPath: "schema", labelKey: "nav.schema", label: "Schema Designer", icon: ShieldCheck },
|
||||
{ projectPath: "editor", labelKey: "nav.editor", label: "Entity Manager", icon: Network },
|
||||
{ projectPath: "review", labelKey: "nav.review", label: "Claim Review", icon: ListChecks },
|
||||
{ projectPath: "quality", labelKey: "nav.quality", label: "Quality Inspector", icon: ClipboardCheck },
|
||||
{ projectPath: "graph", labelKey: "nav.graph", label: "Graph View", icon: GitBranch },
|
||||
{ projectPath: "export", labelKey: "nav.export", label: "Export / API", icon: Code2 },
|
||||
{ projectPath: "research", labelKey: "nav.research", label: "Graph Research", icon: Brain },
|
||||
];
|
||||
|
||||
const projectRoutePatterns = [
|
||||
"/sources/:projectId",
|
||||
"/crawl/:projectId",
|
||||
"/pipeline/:projectId",
|
||||
"/analysis/:projectId",
|
||||
"/schema/:projectId",
|
||||
"/research/:projectId",
|
||||
"/editor/:projectId",
|
||||
"/review/:projectId",
|
||||
"/quality/:projectId",
|
||||
"/graph/:projectId",
|
||||
"/export/:projectId",
|
||||
];
|
||||
|
||||
function projectIdFromPathname(pathname: string): string | undefined {
|
||||
for (const pattern of projectRoutePatterns) {
|
||||
const match = matchPath({ path: pattern, end: false }, pathname);
|
||||
if (match?.params.projectId) return match.params.projectId;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function workspaceSectionFromPathname(pathname: string): string | undefined {
|
||||
for (const pattern of projectRoutePatterns) {
|
||||
const match = matchPath({ path: pattern, end: false }, pathname);
|
||||
if (match?.params.projectId) return pattern.split("/")[1];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export default function AppShell() {
|
||||
const { t } = useTranslation();
|
||||
const sidebarOpen = useSelector((s: RootState) => s.ui.sidebarOpen);
|
||||
const dispatch = useDispatch();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const routeProjectId = projectIdFromPathname(location.pathname);
|
||||
const workspaceSection = workspaceSectionFromPathname(location.pathname);
|
||||
const { data: projects } = useProjects();
|
||||
const fallbackProjectId = projects?.[0]?.name;
|
||||
const routeProjectExists =
|
||||
!routeProjectId ||
|
||||
!projects ||
|
||||
projects.some((project) => project.name === routeProjectId);
|
||||
const currentProjectId = routeProjectExists
|
||||
? routeProjectId ?? fallbackProjectId
|
||||
: fallbackProjectId;
|
||||
|
||||
useEffect(() => {
|
||||
if (routeProjectId && projects && !routeProjectExists) {
|
||||
const nextPath =
|
||||
workspaceSection && fallbackProjectId
|
||||
? `/${workspaceSection}/${encodeURIComponent(fallbackProjectId)}`
|
||||
: "/";
|
||||
navigate(nextPath, { replace: true });
|
||||
}
|
||||
}, [
|
||||
fallbackProjectId,
|
||||
navigate,
|
||||
projects,
|
||||
routeProjectExists,
|
||||
routeProjectId,
|
||||
workspaceSection,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background text-foreground">
|
||||
<aside
|
||||
className={cn(
|
||||
"border-r bg-card transition-all duration-200 ease-out",
|
||||
sidebarOpen ? "w-60" : "w-16",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-14 items-center justify-between px-4 border-b">
|
||||
{sidebarOpen && (
|
||||
<span className="text-sm font-semibold">
|
||||
{t("app.title", "Ontology Builder")}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => dispatch(toggleSidebar())}
|
||||
aria-label={t("nav.toggleSidebar", "Toggle sidebar")}
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{sidebarOpen && (
|
||||
<div className="border-b px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("nav.currentProject", "Current project")}
|
||||
</p>
|
||||
<p className="mt-1 truncate text-sm font-medium">
|
||||
{currentProjectId ??
|
||||
t("nav.noProjectSelected", "Select a project")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<nav className="flex flex-col gap-1 p-2">
|
||||
{navItems.map(({ to, projectPath, labelKey, label, icon: Icon }) => {
|
||||
const href =
|
||||
to ??
|
||||
(currentProjectId
|
||||
? `/${projectPath}/${encodeURIComponent(currentProjectId)}`
|
||||
: undefined);
|
||||
|
||||
if (!href) {
|
||||
return (
|
||||
<div
|
||||
key={labelKey}
|
||||
className="flex cursor-not-allowed items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground/50"
|
||||
title={t(
|
||||
"nav.selectProjectFirst",
|
||||
"Select a project from the dashboard first",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" />
|
||||
{sidebarOpen && <span>{t(labelKey, label)}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={labelKey}
|
||||
to={href}
|
||||
end={href === "/"}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" />
|
||||
{sidebarOpen && <span>{t(labelKey, label)}</span>}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="flex flex-1 flex-col">
|
||||
<header className="flex h-14 items-center justify-between border-b bg-card px-6">
|
||||
<h1 className="text-sm font-medium text-muted-foreground">
|
||||
{t("app.subtitle", "AI-powered ontology construction")}
|
||||
</h1>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
ontology_platform/web/frontend/src/components/ui/badge.tsx
Normal file
36
ontology_platform/web/frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground",
|
||||
outline: "text-foreground",
|
||||
success:
|
||||
"border-transparent bg-green-100 text-green-800",
|
||||
warning:
|
||||
"border-transparent bg-yellow-100 text-yellow-800",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
},
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLSpanElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
export function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<span className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { badgeVariants };
|
||||
51
ontology_platform/web/frontend/src/components/ui/button.tsx
Normal file
51
ontology_platform/web/frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { buttonVariants };
|
||||
76
ontology_platform/web/frontend/src/components/ui/card.tsx
Normal file
76
ontology_platform/web/frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
export const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
export const CardTitle = React.forwardRef<
|
||||
HTMLHeadingElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
export const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
export const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
export const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
21
ontology_platform/web/frontend/src/components/ui/input.tsx
Normal file
21
ontology_platform/web/frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type = "text", ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
17
ontology_platform/web/frontend/src/components/ui/label.tsx
Normal file
17
ontology_platform/web/frontend/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Label = React.forwardRef<
|
||||
HTMLLabelElement,
|
||||
React.LabelHTMLAttributes<HTMLLabelElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = "Label";
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value?: number;
|
||||
max?: number;
|
||||
indeterminate?: boolean;
|
||||
}
|
||||
|
||||
export const Progress = React.forwardRef<HTMLDivElement, ProgressProps>(
|
||||
({ className, value = 0, max = 100, indeterminate, ...props }, ref) => {
|
||||
const percent = Math.min(100, Math.max(0, (value / max) * 100));
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={max}
|
||||
aria-valuenow={indeterminate ? undefined : value}
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full bg-primary transition-all",
|
||||
indeterminate && "w-1/3 animate-pulse",
|
||||
)}
|
||||
style={indeterminate ? undefined : { width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
Progress.displayName = "Progress";
|
||||
22
ontology_platform/web/frontend/src/components/ui/select.tsx
Normal file
22
ontology_platform/web/frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type SelectProps = React.SelectHTMLAttributes<HTMLSelectElement>;
|
||||
|
||||
export const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<select
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</select>
|
||||
);
|
||||
},
|
||||
);
|
||||
Select.displayName = "Select";
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
98
ontology_platform/web/frontend/src/components/ui/tabs.tsx
Normal file
98
ontology_platform/web/frontend/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TabsContextValue {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const TabsContext = React.createContext<TabsContextValue | null>(null);
|
||||
|
||||
function useTabs() {
|
||||
const ctx = React.useContext(TabsContext);
|
||||
if (!ctx) throw new Error("Tabs primitives must be used within <Tabs>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
interface TabsProps {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Tabs({ value, onValueChange, className, children }: TabsProps) {
|
||||
return (
|
||||
<TabsContext.Provider value={{ value, onChange: onValueChange }}>
|
||||
<div className={cn("flex flex-col gap-3", className)}>{children}</div>
|
||||
</TabsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabsList({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-start gap-1 rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabsTrigger({
|
||||
value,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const ctx = useTabs();
|
||||
const active = ctx.value === value;
|
||||
return (
|
||||
<button
|
||||
role="tab"
|
||||
type="button"
|
||||
aria-selected={active}
|
||||
onClick={() => ctx.onChange(value)}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
active
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabsContent({
|
||||
value,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const ctx = useTabs();
|
||||
if (ctx.value !== value) return null;
|
||||
return (
|
||||
<div role="tabpanel" className={cn("focus-visible:outline-none", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||
|
||||
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
Textarea.displayName = "Textarea";
|
||||
109
ontology_platform/web/frontend/src/hooks/queryKeys.ts
Normal file
109
ontology_platform/web/frontend/src/hooks/queryKeys.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
export const queryKeys = {
|
||||
projects: {
|
||||
all: ["projects"] as const,
|
||||
list: () => [...queryKeys.projects.all, "list"] as const,
|
||||
detail: (name: string) =>
|
||||
[...queryKeys.projects.all, "detail", name] as const,
|
||||
},
|
||||
domains: {
|
||||
all: ["domains"] as const,
|
||||
list: () => [...queryKeys.domains.all, "list"] as const,
|
||||
},
|
||||
ontology: {
|
||||
all: ["ontology"] as const,
|
||||
byDomain: (domain: string) =>
|
||||
[...queryKeys.ontology.all, "byDomain", domain] as const,
|
||||
},
|
||||
sources: {
|
||||
all: ["sources"] as const,
|
||||
byProject: (projectName: string) =>
|
||||
[...queryKeys.sources.all, "byProject", projectName] as const,
|
||||
},
|
||||
crawl: {
|
||||
all: ["crawl"] as const,
|
||||
job: (jobId: string) =>
|
||||
[...queryKeys.crawl.all, "job", jobId] as const,
|
||||
},
|
||||
research: {
|
||||
all: ["research"] as const,
|
||||
sessions: (projectName: string) =>
|
||||
[...queryKeys.research.all, "sessions", projectName] as const,
|
||||
session: (jobId: string) =>
|
||||
[...queryKeys.research.all, "session", jobId] as const,
|
||||
},
|
||||
entities: {
|
||||
all: ["entities"] as const,
|
||||
list: (projectName: string, entityType?: string) =>
|
||||
[
|
||||
...queryKeys.entities.all,
|
||||
"list",
|
||||
projectName,
|
||||
entityType ?? "*",
|
||||
] as const,
|
||||
},
|
||||
claims: {
|
||||
all: ["claims"] as const,
|
||||
list: (projectName: string, status?: string, limit?: number) =>
|
||||
[
|
||||
...queryKeys.claims.all,
|
||||
"list",
|
||||
projectName,
|
||||
status ?? "*",
|
||||
limit ?? "*",
|
||||
] as const,
|
||||
},
|
||||
platform: {
|
||||
all: ["platform"] as const,
|
||||
pipeline: (projectName: string) =>
|
||||
[...queryKeys.platform.all, "pipeline", projectName] as const,
|
||||
extractionLogs: (projectName: string, limit: number) =>
|
||||
[
|
||||
...queryKeys.platform.all,
|
||||
"extractionLogs",
|
||||
projectName,
|
||||
limit,
|
||||
] as const,
|
||||
registry: (projectName: string) =>
|
||||
[...queryKeys.platform.all, "registry", projectName] as const,
|
||||
proposals: (projectName: string, limit: number) =>
|
||||
[...queryKeys.platform.all, "proposals", projectName, limit] as const,
|
||||
triples: (projectName: string, status: string, limit: number) =>
|
||||
[
|
||||
...queryKeys.platform.all,
|
||||
"triples",
|
||||
projectName,
|
||||
status,
|
||||
limit,
|
||||
] as const,
|
||||
graph: (
|
||||
projectName: string,
|
||||
entityId: string,
|
||||
includeCandidates: boolean,
|
||||
status: string,
|
||||
limit: number,
|
||||
) =>
|
||||
[
|
||||
...queryKeys.platform.all,
|
||||
"graph",
|
||||
projectName,
|
||||
entityId,
|
||||
includeCandidates,
|
||||
status,
|
||||
limit,
|
||||
] as const,
|
||||
exportJson: (
|
||||
projectName: string,
|
||||
status: string,
|
||||
includeEvidence: boolean,
|
||||
limit: number,
|
||||
) =>
|
||||
[
|
||||
...queryKeys.platform.all,
|
||||
"exportJson",
|
||||
projectName,
|
||||
status,
|
||||
includeEvidence,
|
||||
limit,
|
||||
] as const,
|
||||
},
|
||||
};
|
||||
71
ontology_platform/web/frontend/src/hooks/useClaims.ts
Normal file
71
ontology_platform/web/frontend/src/hooks/useClaims.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Claim, CreateClaimRequest, claimsApi } from "@/lib/api/claims";
|
||||
import { queryKeys } from "./queryKeys";
|
||||
|
||||
export function useClaims(
|
||||
projectName: string,
|
||||
options: { status?: string; includeCandidates?: boolean; limit?: number } = {},
|
||||
) {
|
||||
return useQuery<Claim[]>({
|
||||
queryKey: queryKeys.claims.list(projectName, options.status, options.limit),
|
||||
queryFn: () =>
|
||||
claimsApi.list(projectName, {
|
||||
status: options.status,
|
||||
includeCandidates: options.includeCandidates,
|
||||
limit: options.limit,
|
||||
}),
|
||||
enabled: Boolean(projectName),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateClaim(projectName: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: CreateClaimRequest) =>
|
||||
claimsApi.create(projectName, body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.claims.all,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteClaim(projectName: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (claimId: string | number) =>
|
||||
claimsApi.delete(projectName, claimId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.claims.all,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateClaimStatus(projectName: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
claimId,
|
||||
status,
|
||||
reason,
|
||||
}: {
|
||||
claimId: string | number;
|
||||
status: string;
|
||||
reason?: string | null;
|
||||
}) => claimsApi.updateStatus(claimId, { status, reason }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.claims.all,
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.platform.all,
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.platform.pipeline(projectName),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
44
ontology_platform/web/frontend/src/hooks/useCrawl.ts
Normal file
44
ontology_platform/web/frontend/src/hooks/useCrawl.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
CrawlJob,
|
||||
crawlApi,
|
||||
isCrawlTerminal,
|
||||
StartSiteCrawlRequest,
|
||||
} from "@/lib/api/crawl";
|
||||
import { queryKeys } from "./queryKeys";
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
|
||||
export function useStartSiteCrawl() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: StartSiteCrawlRequest) => crawlApi.startByProject(body),
|
||||
onSuccess: (job) => {
|
||||
queryClient.setQueryData(queryKeys.crawl.job(job.job_id), job);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCrawlJob(jobId: string | null | undefined) {
|
||||
return useQuery<CrawlJob>({
|
||||
queryKey: queryKeys.crawl.job(jobId ?? ""),
|
||||
queryFn: () => crawlApi.getJob(jobId!),
|
||||
enabled: Boolean(jobId),
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data as CrawlJob | undefined;
|
||||
if (!data) return POLL_INTERVAL_MS;
|
||||
return isCrawlTerminal(data.status) ? false : POLL_INTERVAL_MS;
|
||||
},
|
||||
refetchIntervalInBackground: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCancelCrawl() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (jobId: string) => crawlApi.cancel(jobId),
|
||||
onSuccess: (job) => {
|
||||
queryClient.setQueryData(queryKeys.crawl.job(job.job_id), job);
|
||||
},
|
||||
});
|
||||
}
|
||||
21
ontology_platform/web/frontend/src/hooks/useDomains.ts
Normal file
21
ontology_platform/web/frontend/src/hooks/useDomains.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { domainsApi, DomainSummary } from "@/lib/api/domains";
|
||||
import { ontologyApi, OntologyDetail } from "@/lib/api/ontology";
|
||||
import { queryKeys } from "./queryKeys";
|
||||
|
||||
export function useDomains() {
|
||||
return useQuery<DomainSummary[]>({
|
||||
queryKey: queryKeys.domains.list(),
|
||||
queryFn: () => domainsApi.list(),
|
||||
staleTime: 1000 * 60 * 30,
|
||||
});
|
||||
}
|
||||
|
||||
export function useOntology(domain: string | undefined) {
|
||||
return useQuery<OntologyDetail>({
|
||||
queryKey: queryKeys.ontology.byDomain(domain ?? ""),
|
||||
queryFn: () => ontologyApi.get(domain!),
|
||||
enabled: Boolean(domain),
|
||||
staleTime: 1000 * 60 * 30,
|
||||
});
|
||||
}
|
||||
58
ontology_platform/web/frontend/src/hooks/useEntities.ts
Normal file
58
ontology_platform/web/frontend/src/hooks/useEntities.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
BulkCreateEntitiesRequest,
|
||||
CreateEntityRequest,
|
||||
entitiesApi,
|
||||
Entity,
|
||||
} from "@/lib/api/entities";
|
||||
import { queryKeys } from "./queryKeys";
|
||||
|
||||
export function useEntities(projectName: string, entityType?: string) {
|
||||
return useQuery<Entity[]>({
|
||||
queryKey: queryKeys.entities.list(projectName, entityType),
|
||||
queryFn: () => entitiesApi.list(projectName, entityType),
|
||||
enabled: Boolean(projectName),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateEntity(projectName: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: CreateEntityRequest) =>
|
||||
entitiesApi.create(projectName, body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.entities.all,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useBulkCreateEntities(projectName: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: BulkCreateEntitiesRequest) =>
|
||||
entitiesApi.bulkCreate(projectName, body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.entities.all,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteEntity(projectName: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (entityId: string | number) =>
|
||||
entitiesApi.delete(projectName, entityId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.entities.all,
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.claims.all,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
146
ontology_platform/web/frontend/src/hooks/usePlatform.ts
Normal file
146
ontology_platform/web/frontend/src/hooks/usePlatform.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
CreateSchemaEntityTypeRequest,
|
||||
CreateSchemaRelationTypeRequest,
|
||||
ExtractionLog,
|
||||
ExportJson,
|
||||
GraphNeighborhood,
|
||||
OntologyProposal,
|
||||
OntologyRegistry,
|
||||
OntologyTriple,
|
||||
PipelineSummary,
|
||||
RerunStageResponse,
|
||||
platformApi,
|
||||
} from "@/lib/api/platform";
|
||||
import { queryKeys } from "./queryKeys";
|
||||
|
||||
export function usePipeline(projectName: string) {
|
||||
return useQuery<PipelineSummary>({
|
||||
queryKey: queryKeys.platform.pipeline(projectName),
|
||||
queryFn: () => platformApi.pipeline(projectName),
|
||||
enabled: Boolean(projectName),
|
||||
});
|
||||
}
|
||||
|
||||
export function useExtractionLogs(projectName: string, limit = 50) {
|
||||
return useQuery<ExtractionLog[]>({
|
||||
queryKey: queryKeys.platform.extractionLogs(projectName, limit),
|
||||
queryFn: () => platformApi.extractionLogs(projectName, limit),
|
||||
enabled: Boolean(projectName),
|
||||
});
|
||||
}
|
||||
|
||||
export function useOntologyRegistry(projectName: string) {
|
||||
return useQuery<OntologyRegistry>({
|
||||
queryKey: queryKeys.platform.registry(projectName),
|
||||
queryFn: () => platformApi.registry(projectName),
|
||||
enabled: Boolean(projectName),
|
||||
});
|
||||
}
|
||||
|
||||
export function useOntologyProposals(projectName: string, limit = 100) {
|
||||
return useQuery<OntologyProposal[]>({
|
||||
queryKey: queryKeys.platform.proposals(projectName, limit),
|
||||
queryFn: () => platformApi.proposals(projectName, limit),
|
||||
enabled: Boolean(projectName),
|
||||
});
|
||||
}
|
||||
|
||||
export function useOntologyTriples(
|
||||
projectName: string,
|
||||
status?: string,
|
||||
limit = 200,
|
||||
) {
|
||||
return useQuery<OntologyTriple[]>({
|
||||
queryKey: queryKeys.platform.triples(projectName, status ?? "*", limit),
|
||||
queryFn: () => platformApi.triples(projectName, status, limit),
|
||||
enabled: Boolean(projectName),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGraphNeighborhood(
|
||||
projectName: string,
|
||||
options: {
|
||||
entityId?: string | number | null;
|
||||
includeCandidates?: boolean;
|
||||
status?: string;
|
||||
limit?: number;
|
||||
} = {},
|
||||
) {
|
||||
const entityKey = options.entityId == null ? "*" : String(options.entityId);
|
||||
const statusKey = options.status ?? "*";
|
||||
const limit = options.limit ?? 200;
|
||||
return useQuery<GraphNeighborhood>({
|
||||
queryKey: queryKeys.platform.graph(
|
||||
projectName,
|
||||
entityKey,
|
||||
Boolean(options.includeCandidates),
|
||||
statusKey,
|
||||
limit,
|
||||
),
|
||||
queryFn: () => platformApi.graphNeighborhood(projectName, options),
|
||||
enabled: Boolean(projectName),
|
||||
});
|
||||
}
|
||||
|
||||
export function useExportJson(
|
||||
projectName: string,
|
||||
options: { status?: string; includeEvidence?: boolean; limit?: number } = {},
|
||||
) {
|
||||
const status = options.status ?? "validated_claim";
|
||||
const includeEvidence = options.includeEvidence ?? true;
|
||||
const limit = options.limit ?? 1000;
|
||||
return useQuery<ExportJson>({
|
||||
queryKey: queryKeys.platform.exportJson(
|
||||
projectName,
|
||||
status,
|
||||
includeEvidence,
|
||||
limit,
|
||||
),
|
||||
queryFn: () =>
|
||||
platformApi.exportJson(projectName, {
|
||||
status,
|
||||
includeEvidence,
|
||||
limit,
|
||||
}),
|
||||
enabled: Boolean(projectName),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRerunPipelineStage(projectName: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<RerunStageResponse, Error, string>({
|
||||
mutationFn: (stage) => platformApi.rerunStage(projectName, stage),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.platform.pipeline(projectName),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateSchemaEntityType(projectName: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: CreateSchemaEntityTypeRequest) =>
|
||||
platformApi.createSchemaEntityType(projectName, body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.platform.registry(projectName),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateSchemaRelationType(projectName: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: CreateSchemaRelationTypeRequest) =>
|
||||
platformApi.createSchemaRelationType(projectName, body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.platform.registry(projectName),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
45
ontology_platform/web/frontend/src/hooks/useProjects.ts
Normal file
45
ontology_platform/web/frontend/src/hooks/useProjects.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
CreateProjectInlineRequest,
|
||||
CreateProjectRequest,
|
||||
projectsApi,
|
||||
ProjectSummary,
|
||||
ProjectDetail,
|
||||
} from "@/lib/api/projects";
|
||||
import { queryKeys } from "./queryKeys";
|
||||
|
||||
export function useProjects() {
|
||||
return useQuery<ProjectSummary[]>({
|
||||
queryKey: queryKeys.projects.list(),
|
||||
queryFn: () => projectsApi.list(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useProject(name: string | undefined) {
|
||||
return useQuery<ProjectDetail>({
|
||||
queryKey: queryKeys.projects.detail(name ?? ""),
|
||||
queryFn: () => projectsApi.detail(name!),
|
||||
enabled: Boolean(name),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateProject() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: CreateProjectRequest) => projectsApi.create(body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateProjectInline() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: CreateProjectInlineRequest) =>
|
||||
projectsApi.createInline(body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
37
ontology_platform/web/frontend/src/hooks/useResearch.ts
Normal file
37
ontology_platform/web/frontend/src/hooks/useResearch.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
researchApi,
|
||||
ResearchRunResult,
|
||||
ResearchSessionDetail,
|
||||
ResearchSessionItem,
|
||||
StartResearchRequest,
|
||||
} from "@/lib/api/research";
|
||||
import { queryKeys } from "./queryKeys";
|
||||
|
||||
export function useStartResearch(projectName: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<ResearchRunResult, Error, StartResearchRequest>({
|
||||
mutationFn: (body) => researchApi.startByProject(body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.research.sessions(projectName),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useResearchSessions(projectName: string, limit = 25) {
|
||||
return useQuery<ResearchSessionItem[]>({
|
||||
queryKey: queryKeys.research.sessions(projectName),
|
||||
queryFn: () => researchApi.listSessions(projectName, limit),
|
||||
enabled: Boolean(projectName),
|
||||
});
|
||||
}
|
||||
|
||||
export function useResearchSession(jobId: string | null | undefined) {
|
||||
return useQuery<ResearchSessionDetail>({
|
||||
queryKey: queryKeys.research.session(jobId ?? ""),
|
||||
queryFn: () => researchApi.getSession(jobId!),
|
||||
enabled: Boolean(jobId),
|
||||
});
|
||||
}
|
||||
29
ontology_platform/web/frontend/src/hooks/useSources.ts
Normal file
29
ontology_platform/web/frontend/src/hooks/useSources.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { CreateSourceRequest, sourcesApi } from "@/lib/api/sources";
|
||||
import { queryKeys } from "./queryKeys";
|
||||
|
||||
export function useCreateSource(projectName: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: CreateSourceRequest) =>
|
||||
sourcesApi.create(projectName, body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.projects.detail(projectName),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteSource(projectName: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (sourceName: string) =>
|
||||
sourcesApi.delete(projectName, sourceName),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.projects.detail(projectName),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
25
ontology_platform/web/frontend/src/i18n.ts
Normal file
25
ontology_platform/web/frontend/src/i18n.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import i18n from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import HttpBackend from "i18next-http-backend";
|
||||
|
||||
i18n
|
||||
.use(HttpBackend)
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
fallbackLng: "en",
|
||||
ns: ["common", "pages"],
|
||||
defaultNS: "common",
|
||||
backend: {
|
||||
loadPath: "/static/locales/{{lng}}/{{ns}}.json",
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
react: {
|
||||
useSuspense: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
17
ontology_platform/web/frontend/src/legacy/api.js
Normal file
17
ontology_platform/web/frontend/src/legacy/api.js
Normal file
@@ -0,0 +1,17 @@
|
||||
export async function api(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...options,
|
||||
});
|
||||
if (!response.ok) {
|
||||
let detail = `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const body = await response.json();
|
||||
detail = body.detail || body.error || detail;
|
||||
} catch {
|
||||
// Keep status text.
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
298
ontology_platform/web/frontend/src/legacy/graph.js
Normal file
298
ontology_platform/web/frontend/src/legacy/graph.js
Normal file
@@ -0,0 +1,298 @@
|
||||
import cytoscape from "cytoscape";
|
||||
import { api } from "./api.js";
|
||||
import { state } from "./state.js";
|
||||
import { $, escapeHtml } from "./utils.js";
|
||||
import { t, applyI18n, onLangChange } from "./i18n.js";
|
||||
import { refreshInspector } from "./inspector.js";
|
||||
|
||||
const ENTITY_COLOR_PALETTE = [
|
||||
"#7aa2ff", "#f59e0b", "#a78bfa", "#34d399", "#f472b6",
|
||||
"#22d3ee", "#fb923c", "#facc15", "#94a3b8", "#fb7185",
|
||||
];
|
||||
|
||||
let cy = null;
|
||||
let raw = { nodes: [], edges: [] };
|
||||
let filters = {
|
||||
search: "",
|
||||
entity_type: "",
|
||||
predicate: "",
|
||||
min_confidence: 0,
|
||||
layout: "cose",
|
||||
};
|
||||
let typeColors = new Map();
|
||||
|
||||
export function graphTabHtml() {
|
||||
return `
|
||||
<section id="graph" class="tab-panel">
|
||||
<div class="graph-toolbar">
|
||||
<input id="graphSearch" type="search" data-i18n-placeholder="graph.search_placeholder" />
|
||||
<select id="graphEntityType"><option value="" data-i18n="graph.entity_type_all"></option></select>
|
||||
<select id="graphPredicate"><option value="" data-i18n="graph.predicate_all"></option></select>
|
||||
<label class="graph-slider">
|
||||
<span data-i18n="graph.min_confidence"></span>
|
||||
<input id="graphMinConfidence" type="range" min="0" max="1" step="0.05" value="0" />
|
||||
<output id="graphMinConfidenceOut">0%</output>
|
||||
</label>
|
||||
<label class="graph-slider">
|
||||
<span data-i18n="graph.layout"></span>
|
||||
<select id="graphLayout">
|
||||
<option value="cose" data-i18n="graph.layout.cose"></option>
|
||||
<option value="concentric" data-i18n="graph.layout.concentric"></option>
|
||||
<option value="circle" data-i18n="graph.layout.circle"></option>
|
||||
<option value="breadthfirst" data-i18n="graph.layout.breadthfirst"></option>
|
||||
<option value="grid" data-i18n="graph.layout.grid"></option>
|
||||
</select>
|
||||
</label>
|
||||
<button id="graphFitBtn" data-i18n="graph.fit"></button>
|
||||
<button id="graphReloadBtn" class="primary" data-i18n="graph.reload"></button>
|
||||
</div>
|
||||
<div class="graph-stats">
|
||||
<span id="graphStats" class="muted"></span>
|
||||
<span id="graphLegend" class="graph-legend"></span>
|
||||
</div>
|
||||
<div class="graph-stage">
|
||||
<div id="graphContainer"></div>
|
||||
<div id="graphEmpty" class="graph-empty muted" data-i18n="graph.empty"></div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
export function mountGraph() {
|
||||
applyI18n($("graph"));
|
||||
|
||||
$("graphSearch").addEventListener("input", (e) => { filters.search = e.target.value.trim().toLowerCase(); applyFilters(); });
|
||||
$("graphEntityType").addEventListener("change", (e) => { filters.entity_type = e.target.value; applyFilters(); });
|
||||
$("graphPredicate").addEventListener("change", (e) => { filters.predicate = e.target.value; applyFilters(); });
|
||||
$("graphMinConfidence").addEventListener("input", (e) => {
|
||||
filters.min_confidence = Number(e.target.value);
|
||||
$("graphMinConfidenceOut").textContent = `${Math.round(filters.min_confidence * 100)}%`;
|
||||
applyFilters();
|
||||
});
|
||||
$("graphLayout").addEventListener("change", (e) => { filters.layout = e.target.value; runLayout(); });
|
||||
$("graphFitBtn").addEventListener("click", () => cy?.fit(undefined, 40));
|
||||
$("graphReloadBtn").addEventListener("click", () => loadGraph().catch(() => {}));
|
||||
|
||||
onLangChange(() => applyI18n($("graph")));
|
||||
}
|
||||
|
||||
export async function loadGraph() {
|
||||
if (!state.selectedProject) return;
|
||||
const data = await api(`/projects/${encodeURIComponent(state.selectedProject)}/graph/neighborhood?limit=300`);
|
||||
raw = data ?? { nodes: [], edges: [] };
|
||||
rebuildTypeColors(raw.nodes);
|
||||
populateFilters(raw);
|
||||
render();
|
||||
}
|
||||
|
||||
function rebuildTypeColors(nodes) {
|
||||
const types = [...new Set(nodes.map((n) => n.type).filter(Boolean))].sort();
|
||||
typeColors = new Map();
|
||||
types.forEach((tp, idx) => typeColors.set(tp, ENTITY_COLOR_PALETTE[idx % ENTITY_COLOR_PALETTE.length]));
|
||||
}
|
||||
|
||||
function populateFilters(data) {
|
||||
const entitySel = $("graphEntityType");
|
||||
const predSel = $("graphPredicate");
|
||||
const types = [...new Set(data.nodes.map((n) => n.type).filter(Boolean))].sort();
|
||||
const preds = [...new Set(data.edges.map((e) => e.predicate).filter(Boolean))].sort();
|
||||
entitySel.innerHTML = `<option value="">${escapeHtml(t("graph.entity_type_all"))}</option>` +
|
||||
types.map((tp) => `<option value="${escapeHtml(tp)}" ${tp === filters.entity_type ? "selected" : ""}>${escapeHtml(tp)}</option>`).join("");
|
||||
predSel.innerHTML = `<option value="">${escapeHtml(t("graph.predicate_all"))}</option>` +
|
||||
preds.map((p) => `<option value="${escapeHtml(p)}" ${p === filters.predicate ? "selected" : ""}>${escapeHtml(p)}</option>`).join("");
|
||||
renderLegend(types);
|
||||
}
|
||||
|
||||
function renderLegend(types) {
|
||||
$("graphLegend").innerHTML = types.slice(0, 12).map((tp) => `
|
||||
<span class="legend-item"><i style="background:${typeColors.get(tp)}"></i>${escapeHtml(tp)}</span>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function buildElements() {
|
||||
const minConf = filters.min_confidence;
|
||||
const search = filters.search;
|
||||
const typeFilter = filters.entity_type;
|
||||
const predFilter = filters.predicate;
|
||||
|
||||
const nodeKeep = new Set();
|
||||
const elements = [];
|
||||
|
||||
for (const edge of raw.edges) {
|
||||
if (edge.confidence < minConf) continue;
|
||||
if (predFilter && edge.predicate !== predFilter) continue;
|
||||
if (edge.target == null) continue;
|
||||
nodeKeep.add(edge.source);
|
||||
nodeKeep.add(edge.target);
|
||||
}
|
||||
|
||||
const visibleNodes = raw.nodes.filter((n) => {
|
||||
if (typeFilter && n.type !== typeFilter) return false;
|
||||
if (search && !(n.name || "").toLowerCase().includes(search) &&
|
||||
!(n.canonical_name || "").toLowerCase().includes(search)) return false;
|
||||
if (filters.predicate || filters.min_confidence > 0) return nodeKeep.has(n.id);
|
||||
return true;
|
||||
});
|
||||
const visibleIds = new Set(visibleNodes.map((n) => n.id));
|
||||
|
||||
visibleNodes.forEach((n) => {
|
||||
elements.push({
|
||||
group: "nodes",
|
||||
data: {
|
||||
id: `n${n.id}`,
|
||||
entityId: n.id,
|
||||
label: n.name,
|
||||
type: n.type,
|
||||
canonical: n.canonical_name,
|
||||
metadata: n.metadata,
|
||||
color: typeColors.get(n.type) || "#7aa2ff",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
raw.edges.forEach((e) => {
|
||||
if (e.confidence < minConf) return;
|
||||
if (predFilter && e.predicate !== predFilter) return;
|
||||
if (e.target == null) return;
|
||||
if (!visibleIds.has(e.source) || !visibleIds.has(e.target)) return;
|
||||
elements.push({
|
||||
group: "edges",
|
||||
data: {
|
||||
id: `c${e.claim_id}`,
|
||||
claimId: e.claim_id,
|
||||
source: `n${e.source}`,
|
||||
target: `n${e.target}`,
|
||||
predicate: e.predicate,
|
||||
confidence: e.confidence,
|
||||
metadata: e.metadata,
|
||||
targetName: e.object?.name,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return { elements, visibleNodeCount: visibleNodes.length };
|
||||
}
|
||||
|
||||
function render() {
|
||||
const { elements, visibleNodeCount } = buildElements();
|
||||
updateStats(visibleNodeCount);
|
||||
|
||||
const empty = $("graphEmpty");
|
||||
if (!raw.nodes.length) {
|
||||
empty.style.display = "flex";
|
||||
} else {
|
||||
empty.style.display = "none";
|
||||
}
|
||||
|
||||
if (!cy) {
|
||||
cy = cytoscape({
|
||||
container: $("graphContainer"),
|
||||
elements,
|
||||
wheelSensitivity: 0.2,
|
||||
style: cytoscapeStyle(),
|
||||
layout: layoutOpts(filters.layout),
|
||||
});
|
||||
cy.on("tap", "node", (event) => {
|
||||
const data = event.target.data();
|
||||
state.selection = { kind: "graph_node", data };
|
||||
refreshInspector();
|
||||
});
|
||||
cy.on("tap", "edge", (event) => {
|
||||
const data = event.target.data();
|
||||
state.selection = { kind: "graph_edge", data };
|
||||
refreshInspector();
|
||||
});
|
||||
} else {
|
||||
cy.elements().remove();
|
||||
cy.add(elements);
|
||||
runLayout();
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
if (!cy) return render();
|
||||
render();
|
||||
}
|
||||
|
||||
function runLayout() {
|
||||
if (!cy) return;
|
||||
cy.layout(layoutOpts(filters.layout)).run();
|
||||
}
|
||||
|
||||
function layoutOpts(name) {
|
||||
if (name === "cose") {
|
||||
return { name: "cose", animate: false, idealEdgeLength: 110, nodeRepulsion: 16000, padding: 40 };
|
||||
}
|
||||
if (name === "concentric") {
|
||||
return { name: "concentric", concentric: (n) => n.degree(false), levelWidth: () => 1, animate: false, padding: 30 };
|
||||
}
|
||||
if (name === "breadthfirst") {
|
||||
return { name: "breadthfirst", directed: true, animate: false, padding: 30, spacingFactor: 1.2 };
|
||||
}
|
||||
return { name, animate: false, padding: 30 };
|
||||
}
|
||||
|
||||
function updateStats(shown) {
|
||||
const total = raw.nodes.length;
|
||||
const edges = raw.edges.length;
|
||||
$("graphStats").textContent =
|
||||
`${total} ${t("graph.stats.nodes")} · ${edges} ${t("graph.stats.edges")} · ${shown} ${t("graph.stats.shown")}`;
|
||||
}
|
||||
|
||||
function cytoscapeStyle() {
|
||||
return [
|
||||
{
|
||||
selector: "node",
|
||||
style: {
|
||||
"background-color": "data(color)",
|
||||
"label": "data(label)",
|
||||
"color": "#e6ecf3",
|
||||
"font-size": 11,
|
||||
"text-outline-color": "#0f1216",
|
||||
"text-outline-width": 2,
|
||||
"text-valign": "bottom",
|
||||
"text-margin-y": 6,
|
||||
"width": 24,
|
||||
"height": 24,
|
||||
"border-width": 1,
|
||||
"border-color": "#0f1216",
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: "node:selected",
|
||||
style: {
|
||||
"border-color": "#ffffff",
|
||||
"border-width": 2,
|
||||
"width": 28,
|
||||
"height": 28,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: "edge",
|
||||
style: {
|
||||
"width": "mapData(confidence, 0, 1, 1, 4)",
|
||||
"line-color": "#364154",
|
||||
"target-arrow-color": "#364154",
|
||||
"target-arrow-shape": "triangle",
|
||||
"curve-style": "bezier",
|
||||
"label": "data(predicate)",
|
||||
"font-size": 9,
|
||||
"color": "#8c98ab",
|
||||
"text-rotation": "autorotate",
|
||||
"text-background-color": "#0f1216",
|
||||
"text-background-opacity": 0.85,
|
||||
"text-background-padding": 2,
|
||||
"arrow-scale": 0.8,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: "edge:selected",
|
||||
style: {
|
||||
"line-color": "#7aa2ff",
|
||||
"target-arrow-color": "#7aa2ff",
|
||||
"color": "#7aa2ff",
|
||||
"width": 3,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
543
ontology_platform/web/frontend/src/legacy/i18n.js
Normal file
543
ontology_platform/web/frontend/src/legacy/i18n.js
Normal file
@@ -0,0 +1,543 @@
|
||||
const STORAGE_KEY = "ocp.lang";
|
||||
const SUPPORTED = ["ko", "en"];
|
||||
const DEFAULT_LANG = "ko";
|
||||
|
||||
const dict = {
|
||||
ko: {
|
||||
"app.title": "RPG Onta",
|
||||
"app.subtitle": "프로젝트 크롤, 온톨로지 매핑, 클레임 리뷰, 추천 태그",
|
||||
"header.refresh": "새로고침",
|
||||
"header.lang.ko": "한",
|
||||
"header.lang.en": "EN",
|
||||
|
||||
"sidebar.projects": "프로젝트",
|
||||
"sidebar.config_path": "설정 파일 경로",
|
||||
"sidebar.create_project": "프로젝트 생성",
|
||||
"sidebar.reset_project": "현재 프로젝트 데이터 리셋",
|
||||
"sidebar.crawl": "크롤",
|
||||
"sidebar.source": "소스",
|
||||
"sidebar.url_or_seed": "URL / 시드 URL",
|
||||
"sidebar.analyzer": "분석기",
|
||||
"sidebar.model": "모델",
|
||||
"sidebar.base_url": "Base URL",
|
||||
"sidebar.test_analyzer": "분석기 테스트",
|
||||
"sidebar.discover": "링크 탐색",
|
||||
"sidebar.crawl_url": "URL 크롤",
|
||||
"sidebar.max_depth": "최대 깊이",
|
||||
"sidebar.max_pages": "최대 페이지",
|
||||
"sidebar.same_domain": "같은 도메인만",
|
||||
"sidebar.respect_robots": "robots.txt 준수",
|
||||
"sidebar.crawl_site": "시드부터 사이트 크롤",
|
||||
"sidebar.stop_crawl": "현재 크롤 중지",
|
||||
|
||||
"tabs.overview": "개요",
|
||||
"tabs.graph": "그래프",
|
||||
"tabs.ontology": "온톨로지",
|
||||
"tabs.entities": "엔티티",
|
||||
"tabs.claims": "클레임",
|
||||
"tabs.debug": "디버그",
|
||||
"tabs.research": "리서치",
|
||||
"tabs.tags": "태그",
|
||||
"tabs.recommend": "추천",
|
||||
|
||||
"graph.search_placeholder": "엔티티명 검색...",
|
||||
"graph.entity_type_all": "전체 타입",
|
||||
"graph.predicate_all": "전체 관계",
|
||||
"graph.min_confidence": "최소 신뢰도",
|
||||
"graph.layout": "레이아웃",
|
||||
"graph.layout.cose": "Force",
|
||||
"graph.layout.circle": "원형",
|
||||
"graph.layout.concentric": "동심원",
|
||||
"graph.layout.grid": "그리드",
|
||||
"graph.layout.breadthfirst": "트리",
|
||||
"graph.reload": "그래프 새로고침",
|
||||
"graph.fit": "맞춤",
|
||||
"graph.empty": "아직 데이터가 없습니다. 크롤이 클레임을 만들면 그래프에 노드가 나타납니다.",
|
||||
"graph.stats.nodes": "노드",
|
||||
"graph.stats.edges": "엣지",
|
||||
"graph.stats.shown": "표시",
|
||||
|
||||
"overview.project": "프로젝트",
|
||||
"overview.domain": "도메인",
|
||||
"overview.sources": "소스",
|
||||
"overview.entities": "엔티티",
|
||||
"overview.sources_heading": "소스",
|
||||
|
||||
"pipeline.heading": "파이프라인",
|
||||
"pipeline.refresh": "새로고침",
|
||||
"pipeline.stage.crawled": "크롤됨",
|
||||
"pipeline.stage.extracted": "추출됨",
|
||||
"pipeline.stage.claims": "클레임",
|
||||
"pipeline.stage.validated": "승인됨",
|
||||
"pipeline.stage.graph": "그래프 트리플",
|
||||
"pipeline.retained": "유지율",
|
||||
"pipeline.entity_types_heading": "엔티티 타입 분포",
|
||||
"pipeline.recent_pages": "최근 페이지",
|
||||
"pipeline.recent_claims": "최근 클레임",
|
||||
"pipeline.empty.pages": "아직 크롤된 페이지가 없습니다.",
|
||||
"pipeline.empty.claims": "아직 생성된 클레임이 없습니다.",
|
||||
|
||||
"search.placeholder": "엔티티 · 클레임 · 페이지 검색...",
|
||||
"search.aria_label": "전역 검색",
|
||||
"search.group.entities": "엔티티",
|
||||
"search.group.claims": "클레임",
|
||||
"search.group.pages": "페이지",
|
||||
"search.group.predicates": "술어",
|
||||
"search.empty": "결과 없음",
|
||||
"search.hint": "최소 1글자 이상 입력하세요",
|
||||
"table.name": "이름",
|
||||
"table.type": "타입",
|
||||
"table.trust": "신뢰도",
|
||||
"table.robots": "robots",
|
||||
"table.rate": "속도제한",
|
||||
"table.domain": "도메인",
|
||||
"table.status": "상태",
|
||||
"table.confidence": "신뢰도",
|
||||
"table.relation": "관계",
|
||||
"table.subject": "주체",
|
||||
"table.object": "객체",
|
||||
"table.support": "근거 수",
|
||||
"table.predicate": "술어",
|
||||
"table.tag": "태그",
|
||||
"table.brand": "브랜드",
|
||||
"table.product": "상품",
|
||||
"table.count": "수",
|
||||
"table.no_data": "데이터 없음.",
|
||||
"table.id": "ID",
|
||||
"table.metadata": "메타데이터",
|
||||
"table.reason": "사유",
|
||||
"table.gap": "갭",
|
||||
"table.target": "대상",
|
||||
"table.priority": "우선순위",
|
||||
"table.description": "설명",
|
||||
"table.score": "점수",
|
||||
"table.reasons": "근거",
|
||||
|
||||
"ontology.refresh_registry": "레지스트리 새로고침",
|
||||
"ontology.configured_entity_types": "설정된 엔티티 타입",
|
||||
"ontology.configured_predicates": "설정된 술어",
|
||||
"ontology.registry_entity_types": "레지스트리 엔티티 타입",
|
||||
"ontology.registry_relation_types": "레지스트리 관계 타입",
|
||||
"ontology.triples": "온톨로지 트리플",
|
||||
"ontology.proposals": "스키마 제안",
|
||||
"ontology.knowledge_gaps": "지식 갭",
|
||||
|
||||
"entities.all_types": "전체 타입",
|
||||
"entities.load": "불러오기",
|
||||
"entities.merge_source_placeholder": "병합할 엔티티 ID",
|
||||
"entities.merge_target_placeholder": "유지할 엔티티 ID",
|
||||
"entities.merge": "병합",
|
||||
|
||||
"claims.show_candidates": "후보 포함",
|
||||
"claims.refresh": "클레임 새로고침",
|
||||
"claims.save": "저장",
|
||||
"claims.confidence_label": "신뢰도",
|
||||
"claims.reason_label": "사유",
|
||||
|
||||
"workbench.status_filter": "상태",
|
||||
"workbench.status.all": "전체",
|
||||
"workbench.status.active": "활성",
|
||||
"workbench.status.validated_claim": "승인됨",
|
||||
"workbench.status.rejected": "거절됨",
|
||||
"workbench.search_placeholder": "주체/술어/객체 검색...",
|
||||
"workbench.predicate_filter": "관계",
|
||||
"workbench.predicate_all": "전체 관계",
|
||||
"workbench.empty": "표시할 클레임이 없습니다.",
|
||||
"workbench.select_hint": "왼쪽 목록에서 클레임을 선택하세요.",
|
||||
"workbench.bulk_count": "{count}개 선택됨",
|
||||
"workbench.action.accept": "승인",
|
||||
"workbench.action.reject": "거절",
|
||||
"workbench.action.unreview": "리뷰 취소",
|
||||
"workbench.action.save_confidence": "신뢰도 저장",
|
||||
"workbench.action.accept_similar": "동일 술어+객체 일괄 승인",
|
||||
"workbench.shortcuts": "단축키",
|
||||
"workbench.shortcut.nav": "J/K · 이동",
|
||||
"workbench.shortcut.accept": "A · 승인",
|
||||
"workbench.shortcut.reject": "R · 거절",
|
||||
"workbench.shortcut.bulk_accept": "Shift+A · 선택 일괄 승인",
|
||||
"workbench.shortcut.bulk_reject": "Shift+R · 선택 일괄 거절",
|
||||
"workbench.shortcut.toggle": "Space · 선택 토글",
|
||||
"workbench.shortcut.search": "/ · 검색",
|
||||
"workbench.shortcut.escape": "Esc · 선택 해제",
|
||||
"workbench.detail.subject": "주체",
|
||||
"workbench.detail.predicate": "술어",
|
||||
"workbench.detail.object": "객체",
|
||||
"workbench.detail.status": "상태",
|
||||
"workbench.detail.confidence": "신뢰도",
|
||||
"workbench.detail.evidence": "근거 텍스트",
|
||||
"workbench.detail.source": "출처",
|
||||
"workbench.detail.page": "페이지",
|
||||
"workbench.detail.breakdown": "신뢰도 분해",
|
||||
"workbench.detail.reason_placeholder": "사유 (선택)",
|
||||
"workbench.toast.reviewed": "리뷰 적용됨",
|
||||
"workbench.toast.bulk_done": "{count}개 적용됨",
|
||||
|
||||
"debug.refresh_logs": "추출 로그 새로고침",
|
||||
|
||||
"research.heading": "그래프 리서치",
|
||||
"research.goal": "목표",
|
||||
"research.seed_entity": "시드 엔티티 ID",
|
||||
"research.max_steps": "최대 스텝",
|
||||
"research.min_relevance": "최소 관련도",
|
||||
"research.run": "그래프 리서치 실행",
|
||||
"research.load_sessions": "세션 불러오기",
|
||||
"research.graph_query": "그래프 쿼리",
|
||||
"research.filter_placeholder": "브랜드 또는 태그 필터",
|
||||
"research.run_query": "쿼리 실행",
|
||||
"research.query.trend_summary": "트렌드 요약",
|
||||
"research.query.brand_products": "브랜드 상품",
|
||||
"research.query.products_by_tag": "태그별 상품",
|
||||
"research.query.relation_summary": "관계 요약",
|
||||
"research.query.entity_type_summary": "엔티티 타입 요약",
|
||||
|
||||
"tags.load": "태그 불러오기",
|
||||
|
||||
"recommend.preferred_notes": "선호 노트",
|
||||
"recommend.avoided_notes": "기피 노트",
|
||||
"recommend.preferred_moods": "선호 무드",
|
||||
"recommend.season": "계절",
|
||||
"recommend.occasion": "상황",
|
||||
"recommend.test": "추천 테스트",
|
||||
|
||||
"inspector.title": "인스펙터",
|
||||
"inspector.hint": "엔티티/클레임/페이지를 선택하면 상세가 표시됩니다.",
|
||||
"inspector.project_info": "프로젝트 정보",
|
||||
"inspector.no_selection": "선택 없음",
|
||||
"inspector.latest_activity": "최근 활동",
|
||||
"inspector.latest_activity.empty": "최근 크롤/추출 활동이 없습니다.",
|
||||
|
||||
"extractor.rule_based": "규칙 기반",
|
||||
"extractor.openai": "OpenAI API",
|
||||
"extractor.ollama": "Ollama",
|
||||
"extractor.lm_studio": "LM Studio",
|
||||
|
||||
"toast.project_created": "프로젝트 생성됨",
|
||||
"toast.reset_confirm": "현재 프로젝트의 크롤 데이터(페이지/엔티티/클레임)를 리셋하시겠습니까?",
|
||||
"toast.reset_done": "리셋 완료",
|
||||
"toast.url_copied": "URL이 입력란에 복사됨",
|
||||
"toast.url_crawl_completed": "URL 크롤 완료",
|
||||
"toast.crawl_failed": "크롤 실패",
|
||||
"toast.site_crawl_started": "사이트 크롤 시작",
|
||||
"toast.site_crawl_failed": "사이트 크롤 실패",
|
||||
"toast.site_crawl_completed": "사이트 크롤 완료",
|
||||
"toast.stop_requested": "중지 요청됨",
|
||||
"toast.stop_failed": "중지 실패",
|
||||
"toast.analyzer_connected": "분석기 연결됨",
|
||||
"toast.analyzer_failed": "분석기 실패",
|
||||
"toast.discovery_failed": "탐색 실패",
|
||||
"toast.claim_updated": "클레임 업데이트됨",
|
||||
"toast.entity_merged": "엔티티 병합됨",
|
||||
"toast.merge_check": "엔티티 ID를 확인하세요",
|
||||
"toast.research_completed": "리서치 완료",
|
||||
"toast.research_failed": "리서치 실패",
|
||||
"toast.config_path_required": "설정 파일 경로가 필요합니다",
|
||||
|
||||
"status.starting_site_crawl": "시드부터 사이트 크롤 시작 중...",
|
||||
"status.discovering": "링크 탐색 중...",
|
||||
"status.testing_analyzer": "분석기 테스트 중...",
|
||||
"status.crawling_url": "URL 1건 크롤 중...",
|
||||
"status.stopping_crawl": "사이트 크롤 중지 중",
|
||||
"status.research_running": "그래프 리서치 실행 중...",
|
||||
"status.no_crawl": "준비됨",
|
||||
},
|
||||
en: {
|
||||
"app.title": "Ontology Crawler",
|
||||
"app.subtitle": "Project crawler, ontology mapping, claim review, recommendation tags",
|
||||
"header.refresh": "Refresh",
|
||||
"header.lang.ko": "한",
|
||||
"header.lang.en": "EN",
|
||||
|
||||
"sidebar.projects": "Projects",
|
||||
"sidebar.config_path": "Config path",
|
||||
"sidebar.create_project": "Create project",
|
||||
"sidebar.reset_project": "Reset current project data",
|
||||
"sidebar.crawl": "Crawl",
|
||||
"sidebar.source": "Source",
|
||||
"sidebar.url_or_seed": "URL / Seed URL",
|
||||
"sidebar.analyzer": "Analyzer",
|
||||
"sidebar.model": "Model",
|
||||
"sidebar.base_url": "Base URL",
|
||||
"sidebar.test_analyzer": "Test analyzer",
|
||||
"sidebar.discover": "Discover links",
|
||||
"sidebar.crawl_url": "Crawl URL",
|
||||
"sidebar.max_depth": "Max depth",
|
||||
"sidebar.max_pages": "Max pages",
|
||||
"sidebar.same_domain": "Same domain",
|
||||
"sidebar.respect_robots": "Respect robots.txt",
|
||||
"sidebar.crawl_site": "Crawl site from seed",
|
||||
"sidebar.stop_crawl": "Stop current crawl",
|
||||
|
||||
"tabs.overview": "Overview",
|
||||
"tabs.graph": "Graph",
|
||||
"tabs.ontology": "Ontology",
|
||||
"tabs.entities": "Entities",
|
||||
"tabs.claims": "Claims",
|
||||
"tabs.debug": "Debug",
|
||||
"tabs.research": "Research",
|
||||
"tabs.tags": "Tags",
|
||||
"tabs.recommend": "Recommend",
|
||||
|
||||
"graph.search_placeholder": "Search entity name...",
|
||||
"graph.entity_type_all": "All types",
|
||||
"graph.predicate_all": "All relations",
|
||||
"graph.min_confidence": "Min confidence",
|
||||
"graph.layout": "Layout",
|
||||
"graph.layout.cose": "Force",
|
||||
"graph.layout.circle": "Circle",
|
||||
"graph.layout.concentric": "Concentric",
|
||||
"graph.layout.grid": "Grid",
|
||||
"graph.layout.breadthfirst": "Tree",
|
||||
"graph.reload": "Reload graph",
|
||||
"graph.fit": "Fit",
|
||||
"graph.empty": "No data yet. As crawling produces claims, nodes will appear on the graph.",
|
||||
"graph.stats.nodes": "nodes",
|
||||
"graph.stats.edges": "edges",
|
||||
"graph.stats.shown": "shown",
|
||||
|
||||
"overview.project": "Project",
|
||||
"overview.domain": "Domain",
|
||||
"overview.sources": "Sources",
|
||||
"overview.entities": "Entities",
|
||||
"overview.sources_heading": "Sources",
|
||||
|
||||
"pipeline.heading": "Pipeline",
|
||||
"pipeline.refresh": "Refresh",
|
||||
"pipeline.stage.crawled": "Crawled",
|
||||
"pipeline.stage.extracted": "Extracted",
|
||||
"pipeline.stage.claims": "Claims",
|
||||
"pipeline.stage.validated": "Validated",
|
||||
"pipeline.stage.graph": "Graph triples",
|
||||
"pipeline.retained": "retained",
|
||||
"pipeline.entity_types_heading": "Entity type breakdown",
|
||||
"pipeline.recent_pages": "Recent pages",
|
||||
"pipeline.recent_claims": "Recent claims",
|
||||
"pipeline.empty.pages": "No pages crawled yet.",
|
||||
"pipeline.empty.claims": "No claims generated yet.",
|
||||
|
||||
"search.placeholder": "Search entities, claims, pages...",
|
||||
"search.aria_label": "Global search",
|
||||
"search.group.entities": "Entities",
|
||||
"search.group.claims": "Claims",
|
||||
"search.group.pages": "Pages",
|
||||
"search.group.predicates": "Predicates",
|
||||
"search.empty": "No results",
|
||||
"search.hint": "Type at least 1 character",
|
||||
"table.name": "Name",
|
||||
"table.type": "Type",
|
||||
"table.trust": "Trust",
|
||||
"table.robots": "Robots",
|
||||
"table.rate": "Rate",
|
||||
"table.domain": "Domain",
|
||||
"table.status": "Status",
|
||||
"table.confidence": "Confidence",
|
||||
"table.relation": "Relation",
|
||||
"table.subject": "Subject",
|
||||
"table.object": "Object",
|
||||
"table.support": "Support",
|
||||
"table.predicate": "Predicate",
|
||||
"table.tag": "Tag",
|
||||
"table.brand": "Brand",
|
||||
"table.product": "Product",
|
||||
"table.count": "Count",
|
||||
"table.no_data": "No data.",
|
||||
"table.id": "ID",
|
||||
"table.metadata": "Metadata",
|
||||
"table.reason": "Reason",
|
||||
"table.gap": "Gap",
|
||||
"table.target": "Target",
|
||||
"table.priority": "Priority",
|
||||
"table.description": "Description",
|
||||
"table.score": "Score",
|
||||
"table.reasons": "Reasons",
|
||||
|
||||
"ontology.refresh_registry": "Refresh registry",
|
||||
"ontology.configured_entity_types": "Configured Entity Types",
|
||||
"ontology.configured_predicates": "Configured Predicates",
|
||||
"ontology.registry_entity_types": "Registry Entity Types",
|
||||
"ontology.registry_relation_types": "Registry Relation Types",
|
||||
"ontology.triples": "Ontology Triples",
|
||||
"ontology.proposals": "Schema Proposals",
|
||||
"ontology.knowledge_gaps": "Knowledge Gaps",
|
||||
|
||||
"entities.all_types": "All types",
|
||||
"entities.load": "Load",
|
||||
"entities.merge_source_placeholder": "Entity ID to merge",
|
||||
"entities.merge_target_placeholder": "Entity ID to keep",
|
||||
"entities.merge": "Merge",
|
||||
|
||||
"claims.show_candidates": "Show candidates",
|
||||
"claims.refresh": "Refresh claims",
|
||||
"claims.save": "Save",
|
||||
"claims.confidence_label": "confidence",
|
||||
"claims.reason_label": "reason",
|
||||
|
||||
"workbench.status_filter": "Status",
|
||||
"workbench.status.all": "All",
|
||||
"workbench.status.active": "Active",
|
||||
"workbench.status.validated_claim": "Validated",
|
||||
"workbench.status.rejected": "Rejected",
|
||||
"workbench.search_placeholder": "Search subject/predicate/object...",
|
||||
"workbench.predicate_filter": "Predicate",
|
||||
"workbench.predicate_all": "All predicates",
|
||||
"workbench.empty": "No claims to display.",
|
||||
"workbench.select_hint": "Select a claim from the list on the left.",
|
||||
"workbench.bulk_count": "{count} selected",
|
||||
"workbench.action.accept": "Accept",
|
||||
"workbench.action.reject": "Reject",
|
||||
"workbench.action.unreview": "Unreview",
|
||||
"workbench.action.save_confidence": "Save confidence",
|
||||
"workbench.action.accept_similar": "Accept all with same predicate+object",
|
||||
"workbench.shortcuts": "Shortcuts",
|
||||
"workbench.shortcut.nav": "J/K · navigate",
|
||||
"workbench.shortcut.accept": "A · accept",
|
||||
"workbench.shortcut.reject": "R · reject",
|
||||
"workbench.shortcut.bulk_accept": "Shift+A · bulk accept",
|
||||
"workbench.shortcut.bulk_reject": "Shift+R · bulk reject",
|
||||
"workbench.shortcut.toggle": "Space · toggle select",
|
||||
"workbench.shortcut.search": "/ · search",
|
||||
"workbench.shortcut.escape": "Esc · clear selection",
|
||||
"workbench.detail.subject": "Subject",
|
||||
"workbench.detail.predicate": "Predicate",
|
||||
"workbench.detail.object": "Object",
|
||||
"workbench.detail.status": "Status",
|
||||
"workbench.detail.confidence": "Confidence",
|
||||
"workbench.detail.evidence": "Evidence text",
|
||||
"workbench.detail.source": "Source",
|
||||
"workbench.detail.page": "Page",
|
||||
"workbench.detail.breakdown": "Confidence breakdown",
|
||||
"workbench.detail.reason_placeholder": "reason (optional)",
|
||||
"workbench.toast.reviewed": "Review applied",
|
||||
"workbench.toast.bulk_done": "{count} updated",
|
||||
|
||||
"debug.refresh_logs": "Refresh extraction logs",
|
||||
|
||||
"research.heading": "Semantic Exploration",
|
||||
"research.goal": "Goal",
|
||||
"research.seed_entity": "Seed entity ID",
|
||||
"research.max_steps": "Max steps",
|
||||
"research.min_relevance": "Min relevance",
|
||||
"research.run": "Run graph research",
|
||||
"research.load_sessions": "Load sessions",
|
||||
"research.graph_query": "Graph Query",
|
||||
"research.filter_placeholder": "brand or tag filter",
|
||||
"research.run_query": "Run query",
|
||||
"research.query.trend_summary": "Trend summary",
|
||||
"research.query.brand_products": "Brand products",
|
||||
"research.query.products_by_tag": "Products by tag",
|
||||
"research.query.relation_summary": "Relation summary",
|
||||
"research.query.entity_type_summary": "Entity type summary",
|
||||
|
||||
"tags.load": "Load tags",
|
||||
|
||||
"recommend.preferred_notes": "Preferred notes",
|
||||
"recommend.avoided_notes": "Avoided notes",
|
||||
"recommend.preferred_moods": "Preferred moods",
|
||||
"recommend.season": "Season",
|
||||
"recommend.occasion": "Occasion",
|
||||
"recommend.test": "Test recommendation",
|
||||
|
||||
"inspector.title": "Inspector",
|
||||
"inspector.hint": "Select an entity/claim/page to see details.",
|
||||
"inspector.project_info": "Project info",
|
||||
"inspector.no_selection": "Nothing selected",
|
||||
"inspector.latest_activity": "Latest activity",
|
||||
"inspector.latest_activity.empty": "No recent crawl or extraction activity.",
|
||||
|
||||
"extractor.rule_based": "Rule-based",
|
||||
"extractor.openai": "OpenAI API",
|
||||
"extractor.ollama": "Ollama",
|
||||
"extractor.lm_studio": "LM Studio",
|
||||
|
||||
"toast.project_created": "Project created",
|
||||
"toast.reset_confirm": "Reset current project crawl data (pages/entities/claims)?",
|
||||
"toast.reset_done": "Reset completed",
|
||||
"toast.url_copied": "URL copied to input",
|
||||
"toast.url_crawl_completed": "URL crawl completed",
|
||||
"toast.crawl_failed": "Crawl failed",
|
||||
"toast.site_crawl_started": "Site crawl started",
|
||||
"toast.site_crawl_failed": "Site crawl failed",
|
||||
"toast.site_crawl_completed": "Site crawl completed",
|
||||
"toast.stop_requested": "Stop requested",
|
||||
"toast.stop_failed": "Stop failed",
|
||||
"toast.analyzer_connected": "Analyzer connected",
|
||||
"toast.analyzer_failed": "Analyzer failed",
|
||||
"toast.discovery_failed": "Discovery failed",
|
||||
"toast.claim_updated": "Claim updated",
|
||||
"toast.entity_merged": "Entity merged",
|
||||
"toast.merge_check": "Check entity IDs",
|
||||
"toast.research_completed": "Research completed",
|
||||
"toast.research_failed": "Research failed",
|
||||
"toast.config_path_required": "Config path is required",
|
||||
|
||||
"status.starting_site_crawl": "Starting site crawl from seed...",
|
||||
"status.discovering": "Discovering links...",
|
||||
"status.testing_analyzer": "Testing analyzer...",
|
||||
"status.crawling_url": "Crawling one URL...",
|
||||
"status.stopping_crawl": "Stopping site crawl",
|
||||
"status.research_running": "Running graph research...",
|
||||
"status.no_crawl": "Ready",
|
||||
},
|
||||
};
|
||||
|
||||
let current = pickInitialLang();
|
||||
const listeners = new Set();
|
||||
|
||||
function pickInitialLang() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored && SUPPORTED.includes(stored)) return stored;
|
||||
} catch {}
|
||||
const navLang = (navigator.language || "").toLowerCase();
|
||||
if (navLang.startsWith("en")) return "en";
|
||||
return DEFAULT_LANG;
|
||||
}
|
||||
|
||||
export function getLang() {
|
||||
return current;
|
||||
}
|
||||
|
||||
export function setLang(next) {
|
||||
if (!SUPPORTED.includes(next) || next === current) return;
|
||||
current = next;
|
||||
try { localStorage.setItem(STORAGE_KEY, next); } catch {}
|
||||
document.documentElement.setAttribute("lang", next);
|
||||
applyI18n(document);
|
||||
listeners.forEach((fn) => {
|
||||
try { fn(next); } catch (error) { console.warn("i18n listener failed", error); }
|
||||
});
|
||||
}
|
||||
|
||||
export function onLangChange(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
export function t(key, fallback) {
|
||||
const table = dict[current] || dict[DEFAULT_LANG];
|
||||
if (key in table) return table[key];
|
||||
if (fallback != null) return fallback;
|
||||
return key;
|
||||
}
|
||||
|
||||
export function applyI18n(root = document) {
|
||||
root.querySelectorAll("[data-i18n]").forEach((node) => {
|
||||
const key = node.getAttribute("data-i18n");
|
||||
if (!key) return;
|
||||
node.textContent = t(key);
|
||||
});
|
||||
root.querySelectorAll("[data-i18n-placeholder]").forEach((node) => {
|
||||
const key = node.getAttribute("data-i18n-placeholder");
|
||||
if (!key) return;
|
||||
node.setAttribute("placeholder", t(key));
|
||||
});
|
||||
root.querySelectorAll("[data-i18n-title]").forEach((node) => {
|
||||
const key = node.getAttribute("data-i18n-title");
|
||||
if (!key) return;
|
||||
node.setAttribute("title", t(key));
|
||||
});
|
||||
root.querySelectorAll("[data-i18n-aria-label]").forEach((node) => {
|
||||
const key = node.getAttribute("data-i18n-aria-label");
|
||||
if (!key) return;
|
||||
node.setAttribute("aria-label", t(key));
|
||||
});
|
||||
}
|
||||
148
ontology_platform/web/frontend/src/legacy/inspector.js
Normal file
148
ontology_platform/web/frontend/src/legacy/inspector.js
Normal file
@@ -0,0 +1,148 @@
|
||||
import { state } from "./state.js";
|
||||
import { escapeHtml } from "./utils.js";
|
||||
import { t, applyI18n, onLangChange } from "./i18n.js";
|
||||
|
||||
let host = null;
|
||||
|
||||
export function mountInspector(target) {
|
||||
host = target;
|
||||
render();
|
||||
onLangChange(render);
|
||||
window.addEventListener("inspector:update", render);
|
||||
}
|
||||
|
||||
export function refreshInspector() {
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!host) return;
|
||||
host.innerHTML = template();
|
||||
applyI18n(host);
|
||||
}
|
||||
|
||||
function template() {
|
||||
const selection = state.selection;
|
||||
return `
|
||||
<div class="inspector-head">
|
||||
<h2 data-i18n="inspector.title"></h2>
|
||||
<p class="muted" data-i18n="inspector.hint"></p>
|
||||
</div>
|
||||
${selection ? renderSelection(selection) : renderDefault()}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDefault() {
|
||||
const detail = state.projectDetail;
|
||||
const activity = state.latestActivity;
|
||||
return `
|
||||
<section class="inspector-section">
|
||||
<h3 data-i18n="inspector.project_info"></h3>
|
||||
${detail ? `
|
||||
<dl class="kv">
|
||||
<dt>${escapeHtml(t("overview.project"))}</dt><dd>${escapeHtml(detail.name)}</dd>
|
||||
<dt>${escapeHtml(t("overview.domain"))}</dt><dd>${escapeHtml(detail.domain)}</dd>
|
||||
<dt>${escapeHtml(t("overview.sources"))}</dt><dd>${(detail.sources ?? []).length}</dd>
|
||||
</dl>
|
||||
` : `<p class="muted" data-i18n="inspector.no_selection"></p>`}
|
||||
</section>
|
||||
<section class="inspector-section">
|
||||
<h3 data-i18n="inspector.latest_activity"></h3>
|
||||
${activity ? renderActivity(activity) : `<p class="muted" data-i18n="inspector.latest_activity.empty"></p>`}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderActivity(activity) {
|
||||
if (activity.kind === "site_crawl") {
|
||||
const job = activity.job ?? {};
|
||||
const progress = job.progress ?? {};
|
||||
return `
|
||||
<dl class="kv">
|
||||
<dt>job</dt><dd>${escapeHtml(job.job_id ?? "-")}</dd>
|
||||
<dt>status</dt><dd>${escapeHtml(job.status ?? "-")}</dd>
|
||||
<dt>visited</dt><dd>${progress.visited_count ?? 0}</dd>
|
||||
<dt>analyzed</dt><dd>${progress.analyzed_count ?? 0}</dd>
|
||||
<dt>skipped</dt><dd>${progress.skipped_count ?? 0}</dd>
|
||||
<dt>queued</dt><dd>${progress.queued_count ?? 0}</dd>
|
||||
</dl>
|
||||
`;
|
||||
}
|
||||
if (activity.kind === "url_crawl") {
|
||||
const r = activity.result ?? {};
|
||||
return `
|
||||
<dl class="kv">
|
||||
<dt>page</dt><dd>${escapeHtml(r.page_id ?? "-")}</dd>
|
||||
<dt>page_type</dt><dd>${escapeHtml(r.page_type ?? "-")}</dd>
|
||||
<dt>crawl</dt><dd>${escapeHtml(r.crawl_status ?? "-")}</dd>
|
||||
<dt>extraction</dt><dd>${escapeHtml(r.extraction_status ?? "-")}</dd>
|
||||
<dt>claims</dt><dd>${r.claim_count ?? 0}</dd>
|
||||
<dt>entities</dt><dd>${r.entity_count ?? 0}</dd>
|
||||
</dl>
|
||||
`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function renderSelection(selection) {
|
||||
if (selection.kind === "entity") {
|
||||
const e = selection.data;
|
||||
return `
|
||||
<section class="inspector-section">
|
||||
<h3>${escapeHtml(e.name)}</h3>
|
||||
<dl class="kv">
|
||||
<dt>${escapeHtml(t("table.id"))}</dt><dd>${escapeHtml(e.id)}</dd>
|
||||
<dt>${escapeHtml(t("table.type"))}</dt><dd>${escapeHtml(e.type)}</dd>
|
||||
</dl>
|
||||
<pre class="code">${escapeHtml(JSON.stringify(e.metadata ?? {}, null, 2))}</pre>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
if (selection.kind === "claim") {
|
||||
const c = selection.data;
|
||||
return `
|
||||
<section class="inspector-section">
|
||||
<h3>${escapeHtml(c.subject)} · ${escapeHtml(c.predicate)}</h3>
|
||||
<dl class="kv">
|
||||
<dt>${escapeHtml(t("table.object"))}</dt><dd>${escapeHtml(c.object ?? JSON.stringify(c.object_value ?? ""))}</dd>
|
||||
<dt>${escapeHtml(t("table.status"))}</dt><dd>${escapeHtml(c.status ?? "")}</dd>
|
||||
<dt>${escapeHtml(t("table.confidence"))}</dt><dd>${Math.round((c.confidence ?? 0) * 100)}%</dd>
|
||||
</dl>
|
||||
${c.evidence_text ? `<p class="evidence">${escapeHtml(c.evidence_text)}</p>` : ""}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
if (selection.kind === "graph_node") {
|
||||
const n = selection.data;
|
||||
return `
|
||||
<section class="inspector-section">
|
||||
<h3>${escapeHtml(n.label)}</h3>
|
||||
<dl class="kv">
|
||||
<dt>${escapeHtml(t("table.id"))}</dt><dd>${escapeHtml(n.entityId)}</dd>
|
||||
<dt>${escapeHtml(t("table.type"))}</dt><dd>${escapeHtml(n.type ?? "")}</dd>
|
||||
${n.canonical ? `<dt>canonical</dt><dd>${escapeHtml(n.canonical)}</dd>` : ""}
|
||||
</dl>
|
||||
${n.metadata && Object.keys(n.metadata).length
|
||||
? `<pre class="code">${escapeHtml(JSON.stringify(n.metadata, null, 2))}</pre>`
|
||||
: ""}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
if (selection.kind === "graph_edge") {
|
||||
const e = selection.data;
|
||||
return `
|
||||
<section class="inspector-section">
|
||||
<h3>${escapeHtml(e.predicate)}</h3>
|
||||
<dl class="kv">
|
||||
<dt>${escapeHtml(t("table.confidence"))}</dt><dd>${Math.round((e.confidence ?? 0) * 100)}%</dd>
|
||||
${e.targetName ? `<dt>${escapeHtml(t("table.object"))}</dt><dd>${escapeHtml(e.targetName)}</dd>` : ""}
|
||||
<dt>claim</dt><dd>${escapeHtml(e.claimId)}</dd>
|
||||
</dl>
|
||||
${e.metadata && Object.keys(e.metadata).length
|
||||
? `<pre class="code">${escapeHtml(JSON.stringify(e.metadata, null, 2))}</pre>`
|
||||
: ""}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
83
ontology_platform/web/frontend/src/legacy/main.js
Normal file
83
ontology_platform/web/frontend/src/legacy/main.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import "./styles/index.css";
|
||||
import { mountShell } from "./shell.js";
|
||||
import { mountSidebar, renderProjects, renderSourceOptions } from "./sidebar.js";
|
||||
import {
|
||||
mountWorkspace,
|
||||
renderOverview,
|
||||
renderOntology,
|
||||
loadOntologyRegistry,
|
||||
loadEntities,
|
||||
loadExtractionLogs,
|
||||
loadResearchSessions,
|
||||
loadGraphQuery,
|
||||
loadTags,
|
||||
} from "./workspace.js";
|
||||
import { loadGraph } from "./graph.js";
|
||||
import { loadWorkbench } from "./workbench.js";
|
||||
import { loadPipeline } from "./pipeline.js";
|
||||
import { mountGlobalSearch } from "./search.js";
|
||||
import { mountInspector, refreshInspector } from "./inspector.js";
|
||||
import { api } from "./api.js";
|
||||
import { state } from "./state.js";
|
||||
import { toast } from "./utils.js";
|
||||
import { onLangChange } from "./i18n.js";
|
||||
|
||||
const root = document.getElementById("app");
|
||||
const hosts = mountShell(root);
|
||||
mountSidebar(hosts.sidebarHost, {
|
||||
onProjectChanged: loadProjects,
|
||||
onCrawlUpdate: () => refreshInspector(),
|
||||
refreshAll,
|
||||
});
|
||||
mountWorkspace(hosts.workspaceHost);
|
||||
mountInspector(hosts.inspectorHost);
|
||||
mountGlobalSearch();
|
||||
|
||||
document.getElementById("refreshBtn").addEventListener("click", loadProjects);
|
||||
window.addEventListener("project:select", (event) => selectProject(event.detail));
|
||||
window.addEventListener("workspace:refresh", refreshAll);
|
||||
onLangChange(() => refreshInspector());
|
||||
|
||||
loadProjects().catch((error) => toast(error.message));
|
||||
|
||||
async function loadProjects() {
|
||||
state.projects = await api("/projects");
|
||||
if (!state.selectedProject && state.projects.length) {
|
||||
state.selectedProject = state.projects[0].name;
|
||||
}
|
||||
renderProjects();
|
||||
if (state.selectedProject) {
|
||||
await selectProject(state.selectedProject);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectProject(projectName) {
|
||||
state.selectedProject = projectName;
|
||||
state.projectDetail = await api(`/projects/${encodeURIComponent(projectName)}`);
|
||||
state.ontology = await api(`/ontology/${encodeURIComponent(state.projectDetail.domain)}`);
|
||||
renderProjects();
|
||||
renderSourceOptions(state.projectDetail);
|
||||
renderOverview();
|
||||
renderOntology();
|
||||
refreshInspector();
|
||||
await refreshAll();
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
if (!state.selectedProject) return;
|
||||
const loaders = [
|
||||
["pipeline", loadPipeline],
|
||||
["ontology registry", loadOntologyRegistry],
|
||||
["entities", loadEntities],
|
||||
["claims workbench", loadWorkbench],
|
||||
["extraction logs", loadExtractionLogs],
|
||||
["research sessions", loadResearchSessions],
|
||||
["graph query", loadGraphQuery],
|
||||
["tags", loadTags],
|
||||
["graph", loadGraph],
|
||||
];
|
||||
await Promise.all(loaders.map(async ([label, fn]) => {
|
||||
try { await fn(); } catch (error) { console.warn(`${label} load failed`, error); }
|
||||
}));
|
||||
refreshInspector();
|
||||
}
|
||||
224
ontology_platform/web/frontend/src/legacy/pipeline.js
Normal file
224
ontology_platform/web/frontend/src/legacy/pipeline.js
Normal file
@@ -0,0 +1,224 @@
|
||||
import { api } from "./api.js";
|
||||
import { state } from "./state.js";
|
||||
import { $, escapeHtml } from "./utils.js";
|
||||
import { t, applyI18n, onLangChange } from "./i18n.js";
|
||||
|
||||
let data = null;
|
||||
|
||||
export function pipelineHtml() {
|
||||
return `
|
||||
<section id="overview" class="tab-panel active">
|
||||
<div class="pipeline-head">
|
||||
<h2 data-i18n="pipeline.heading"></h2>
|
||||
<button id="pipelineRefreshBtn" class="ghost" data-i18n="pipeline.refresh"></button>
|
||||
</div>
|
||||
<div class="pipeline-stages" id="pipelineStages"></div>
|
||||
|
||||
<div class="summary-grid" id="pipelineSummary"></div>
|
||||
|
||||
<div class="wide-panel">
|
||||
<h2 data-i18n="pipeline.entity_types_heading"></h2>
|
||||
<div id="pipelineEntityTypes" class="entity-type-bars"></div>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<div class="wide-panel">
|
||||
<h2 data-i18n="pipeline.recent_pages"></h2>
|
||||
<div id="pipelineRecentPages" class="recent-list"></div>
|
||||
</div>
|
||||
<div class="wide-panel">
|
||||
<h2 data-i18n="pipeline.recent_claims"></h2>
|
||||
<div id="pipelineRecentClaims" class="recent-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wide-panel">
|
||||
<h2 data-i18n="overview.sources_heading"></h2>
|
||||
<div id="sourceTable" class="table"></div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
export function mountPipeline() {
|
||||
const tab = $("overview");
|
||||
applyI18n(tab);
|
||||
$("pipelineRefreshBtn").addEventListener("click", () => loadPipeline().catch(() => {}));
|
||||
onLangChange(() => { applyI18n(tab); render(); });
|
||||
}
|
||||
|
||||
export async function loadPipeline() {
|
||||
if (!state.selectedProject) return;
|
||||
data = await api(`/projects/${encodeURIComponent(state.selectedProject)}/pipeline`);
|
||||
render();
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!data) return;
|
||||
renderStages(data.stages || []);
|
||||
renderSummary(data.stages || []);
|
||||
renderEntityTypes(data.entity_types || []);
|
||||
renderRecentPages(data.recent_pages || []);
|
||||
renderRecentClaims(data.recent_claims || []);
|
||||
}
|
||||
|
||||
const STAGE_COLORS = {
|
||||
crawled: "#7aa2ff",
|
||||
extracted: "#22d3ee",
|
||||
claims: "#facc15",
|
||||
validated: "#4ade80",
|
||||
graph: "#a78bfa",
|
||||
};
|
||||
|
||||
function renderStages(stages) {
|
||||
const max = Math.max(1, ...stages.map((s) => s.count || 0));
|
||||
const host = $("pipelineStages");
|
||||
host.innerHTML = stages.map((stage, idx) => {
|
||||
const prev = idx > 0 ? stages[idx - 1].count : null;
|
||||
const retention = prev && prev > 0 ? Math.round((stage.count / prev) * 100) : null;
|
||||
const pct = (stage.count / max) * 100;
|
||||
const color = STAGE_COLORS[stage.key] || "#7aa2ff";
|
||||
const extra = stageExtraText(stage);
|
||||
return `
|
||||
<div class="pipeline-card" style="--stage-color:${color}">
|
||||
<div class="pipeline-card-head">
|
||||
<span class="pipeline-stage-name">${escapeHtml(t(`pipeline.stage.${stage.key}`, stage.key))}</span>
|
||||
${retention != null ? `<span class="pipeline-retention">${retention}% ${escapeHtml(t("pipeline.retained"))}</span>` : ""}
|
||||
</div>
|
||||
<div class="pipeline-count">${stage.count}</div>
|
||||
<div class="pipeline-bar"><div class="pipeline-bar-fill" style="width:${pct}%"></div></div>
|
||||
${extra ? `<div class="pipeline-extra muted">${extra}</div>` : ""}
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function stageExtraText(stage) {
|
||||
if (!stage.extra) return "";
|
||||
if (stage.key === "extracted") {
|
||||
return `events ${stage.extra.events ?? 0} · errors ${stage.extra.errors ?? 0}`;
|
||||
}
|
||||
if (stage.key === "claims") {
|
||||
return Object.entries(stage.extra).map(([k, v]) => `${escapeHtml(k)} ${v}`).join(" · ");
|
||||
}
|
||||
if (stage.key === "graph") {
|
||||
return `entities ${stage.extra.entities ?? 0}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function renderSummary(stages) {
|
||||
const detail = state.projectDetail;
|
||||
const claims = stages.find((s) => s.key === "claims");
|
||||
const validated = stages.find((s) => s.key === "validated");
|
||||
$("pipelineSummary").innerHTML = `
|
||||
<div class="metric"><span>${escapeHtml(t("overview.project"))}</span><strong>${escapeHtml(detail?.name ?? "-")}</strong></div>
|
||||
<div class="metric"><span>${escapeHtml(t("overview.domain"))}</span><strong>${escapeHtml(detail?.domain ?? "-")}</strong></div>
|
||||
<div class="metric"><span>${escapeHtml(t("overview.sources"))}</span><strong>${(detail?.sources ?? []).length}</strong></div>
|
||||
<div class="metric"><span>${escapeHtml(t("overview.entities"))}</span><strong id="metricEntities">${stages.find(s => s.key === "graph")?.extra?.entities ?? 0}</strong></div>
|
||||
<div class="metric"><span>${escapeHtml(t("pipeline.stage.claims"))}</span><strong>${claims?.count ?? 0}</strong></div>
|
||||
<div class="metric"><span>${escapeHtml(t("pipeline.stage.validated"))}</span><strong>${validated?.count ?? 0}</strong></div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderEntityTypes(rows) {
|
||||
const host = $("pipelineEntityTypes");
|
||||
if (!rows.length) {
|
||||
host.innerHTML = `<p class="muted" style="font-size:11px;margin:0">${escapeHtml(t("pipeline.empty.claims"))}</p>`;
|
||||
return;
|
||||
}
|
||||
const max = Math.max(1, ...rows.map((r) => r.count));
|
||||
host.innerHTML = rows.map((row) => {
|
||||
const pct = (row.count / max) * 100;
|
||||
return `
|
||||
<div class="entity-type-row">
|
||||
<span class="entity-type-name">${escapeHtml(row.type)}</span>
|
||||
<div class="entity-type-bar"><div class="entity-type-bar-fill" style="width:${pct}%"></div></div>
|
||||
<span class="entity-type-count">${row.count}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function renderRecentPages(rows) {
|
||||
const host = $("pipelineRecentPages");
|
||||
if (!rows.length) {
|
||||
host.innerHTML = `<p class="muted" style="font-size:11px;margin:0">${escapeHtml(t("pipeline.empty.pages"))}</p>`;
|
||||
return;
|
||||
}
|
||||
host.innerHTML = rows.map((p) => `
|
||||
<div class="recent-item">
|
||||
<div class="recent-line">
|
||||
<span class="recent-badge">${escapeHtml(p.page_type ?? "")}</span>
|
||||
<span class="recent-code">${escapeHtml(p.status_code ?? "-")}</span>
|
||||
<span class="recent-time">${escapeHtml(formatTime(p.fetched_at))}</span>
|
||||
</div>
|
||||
<div class="recent-title">${escapeHtml(p.title ?? p.url ?? "")}</div>
|
||||
<a class="recent-url" href="${escapeHtml(p.url ?? "")}" target="_blank" rel="noreferrer">${escapeHtml(p.url ?? "")}</a>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderRecentClaims(rows) {
|
||||
const host = $("pipelineRecentClaims");
|
||||
if (!rows.length) {
|
||||
host.innerHTML = `<p class="muted" style="font-size:11px;margin:0">${escapeHtml(t("pipeline.empty.claims"))}</p>`;
|
||||
return;
|
||||
}
|
||||
host.innerHTML = rows.map((c) => `
|
||||
<div class="recent-item">
|
||||
<div class="recent-line">
|
||||
<strong>${escapeHtml(c.subject ?? "")}</strong>
|
||||
<span class="recent-predicate">${escapeHtml(c.predicate ?? "")}</span>
|
||||
</div>
|
||||
<div class="recent-line">
|
||||
<span class="wb-status" data-status="${escapeHtml(c.status ?? "active")}">${escapeHtml(c.status ?? "")}</span>
|
||||
<span class="recent-conf">${Math.round((c.confidence ?? 0) * 100)}%</span>
|
||||
<span class="recent-time">${escapeHtml(formatTime(c.last_seen_at))}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function formatTime(iso) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
const now = Date.now();
|
||||
const diff = (now - d.getTime()) / 1000;
|
||||
if (diff < 60) return `${Math.floor(diff)}s`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
|
||||
return `${Math.floor(diff / 86400)}d`;
|
||||
}
|
||||
|
||||
export function renderPipelineSourceTable(detail) {
|
||||
// kept for the source table at the bottom; reuse workspace helpers
|
||||
const host = $("sourceTable");
|
||||
if (!host) return;
|
||||
const rows = detail?.sources ?? [];
|
||||
if (!rows.length) {
|
||||
host.innerHTML = `<table><tbody><tr><td class="muted">${escapeHtml(t("table.no_data"))}</td></tr></tbody></table>`;
|
||||
return;
|
||||
}
|
||||
host.innerHTML = `
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>${escapeHtml(t("table.name"))}</th>
|
||||
<th>${escapeHtml(t("table.type"))}</th>
|
||||
<th>${escapeHtml(t("table.trust"))}</th>
|
||||
<th>${escapeHtml(t("table.robots"))}</th>
|
||||
<th>${escapeHtml(t("table.rate"))}</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows.map((s) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(s.name)}</td>
|
||||
<td>${escapeHtml(s.type)}</td>
|
||||
<td>${s.trust_level}</td>
|
||||
<td>${s.respect_robots_txt ? "on" : "off"}</td>
|
||||
<td>${s.rate_limit_per_minute}/min</td>
|
||||
</tr>
|
||||
`).join("")}</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
172
ontology_platform/web/frontend/src/legacy/search.js
Normal file
172
ontology_platform/web/frontend/src/legacy/search.js
Normal file
@@ -0,0 +1,172 @@
|
||||
import { api } from "./api.js";
|
||||
import { state } from "./state.js";
|
||||
import { $, escapeHtml } from "./utils.js";
|
||||
import { t, applyI18n, onLangChange } from "./i18n.js";
|
||||
import { refreshInspector } from "./inspector.js";
|
||||
|
||||
let debounceTimer = null;
|
||||
let lastQuery = "";
|
||||
let resultsCache = null;
|
||||
|
||||
export function mountGlobalSearch() {
|
||||
const input = $("globalSearchInput");
|
||||
const panel = $("globalSearchPanel");
|
||||
applyI18n(input.parentElement);
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
const q = input.value.trim();
|
||||
if (debounceTimer) window.clearTimeout(debounceTimer);
|
||||
debounceTimer = window.setTimeout(() => runSearch(q), 200);
|
||||
});
|
||||
input.addEventListener("focus", () => {
|
||||
if (lastQuery) renderPanel(resultsCache); else renderHint();
|
||||
panel.hidden = false;
|
||||
});
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") { input.value = ""; panel.hidden = true; input.blur(); }
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
const first = panel.querySelector("[data-search-item]");
|
||||
first?.focus();
|
||||
}
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!panel.contains(event.target) && event.target !== input) panel.hidden = true;
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "/" && !event.target.matches("input, textarea, select")) {
|
||||
event.preventDefault();
|
||||
input.focus();
|
||||
input.select();
|
||||
}
|
||||
});
|
||||
onLangChange(() => {
|
||||
applyI18n(input.parentElement);
|
||||
if (resultsCache) renderPanel(resultsCache);
|
||||
});
|
||||
}
|
||||
|
||||
async function runSearch(q) {
|
||||
if (!state.selectedProject) return;
|
||||
lastQuery = q;
|
||||
const panel = $("globalSearchPanel");
|
||||
if (!q) { renderHint(); return; }
|
||||
try {
|
||||
resultsCache = await api(`/projects/${encodeURIComponent(state.selectedProject)}/search?q=${encodeURIComponent(q)}&limit=8`);
|
||||
renderPanel(resultsCache);
|
||||
} catch (error) {
|
||||
panel.innerHTML = `<div class="search-empty muted">${escapeHtml(error.message)}</div>`;
|
||||
panel.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderHint() {
|
||||
const panel = $("globalSearchPanel");
|
||||
panel.innerHTML = `<div class="search-empty muted">${escapeHtml(t("search.hint"))}</div>`;
|
||||
panel.hidden = false;
|
||||
}
|
||||
|
||||
function renderPanel(results) {
|
||||
const panel = $("globalSearchPanel");
|
||||
if (!results) return;
|
||||
const groups = [
|
||||
{ key: "entities", items: results.entities, render: renderEntity },
|
||||
{ key: "claims", items: results.claims, render: renderClaim },
|
||||
{ key: "pages", items: results.pages, render: renderPage },
|
||||
{ key: "predicates", items: results.predicates, render: renderPredicate },
|
||||
].filter((g) => (g.items ?? []).length > 0);
|
||||
|
||||
if (!groups.length) {
|
||||
panel.innerHTML = `<div class="search-empty muted">${escapeHtml(t("search.empty"))}</div>`;
|
||||
} else {
|
||||
panel.innerHTML = groups.map((g) => `
|
||||
<div class="search-group">
|
||||
<div class="search-group-head">${escapeHtml(t(`search.group.${g.key}`))}</div>
|
||||
${g.items.map((item, idx) => g.render(item, idx)).join("")}
|
||||
</div>
|
||||
`).join("");
|
||||
panel.querySelectorAll("[data-search-item]").forEach((node) => {
|
||||
node.addEventListener("click", () => handleClick(node.dataset.searchItem, JSON.parse(node.dataset.payload)));
|
||||
node.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter") { event.preventDefault(); node.click(); }
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
(node.nextElementSibling || panel.querySelector("[data-search-item]"))?.focus?.();
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
(node.previousElementSibling || panel.querySelector("[data-search-item]:last-of-type"))?.focus?.();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
$("globalSearchInput").focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
panel.hidden = false;
|
||||
}
|
||||
|
||||
function renderEntity(e) {
|
||||
return `
|
||||
<button class="search-item" data-search-item="entity" data-payload='${escapeHtmlAttr(JSON.stringify(e))}'>
|
||||
<span class="search-item-main">${escapeHtml(e.name)}</span>
|
||||
<span class="search-item-meta">${escapeHtml(e.type ?? "")}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderClaim(c) {
|
||||
const obj = c.object_value ? JSON.stringify(c.object_value) : "";
|
||||
return `
|
||||
<button class="search-item" data-search-item="claim" data-payload='${escapeHtmlAttr(JSON.stringify(c))}'>
|
||||
<span class="search-item-main">${escapeHtml(c.subject ?? "")} · ${escapeHtml(c.predicate ?? "")} ${escapeHtml(obj)}</span>
|
||||
<span class="search-item-meta">${Math.round((c.confidence ?? 0) * 100)}%</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPage(p) {
|
||||
return `
|
||||
<button class="search-item" data-search-item="page" data-payload='${escapeHtmlAttr(JSON.stringify(p))}'>
|
||||
<span class="search-item-main">${escapeHtml(p.title ?? p.url ?? "")}</span>
|
||||
<span class="search-item-meta">${escapeHtml(p.url ?? "")}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderPredicate(name) {
|
||||
return `
|
||||
<button class="search-item" data-search-item="predicate" data-payload='${escapeHtmlAttr(JSON.stringify(name))}'>
|
||||
<span class="search-item-main">${escapeHtml(name)}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeHtmlAttr(value) {
|
||||
return String(value).replaceAll("&", "&").replaceAll("'", "'").replaceAll('"', """);
|
||||
}
|
||||
|
||||
function handleClick(kind, payload) {
|
||||
const panel = $("globalSearchPanel");
|
||||
panel.hidden = true;
|
||||
if (kind === "entity") {
|
||||
activateTab("entities");
|
||||
state.selection = { kind: "entity", data: { id: payload.id, name: payload.name, type: payload.type, metadata: {} } };
|
||||
refreshInspector();
|
||||
} else if (kind === "claim") {
|
||||
activateTab("claims");
|
||||
state.selection = { kind: "claim", data: { ...payload, object: undefined } };
|
||||
refreshInspector();
|
||||
} else if (kind === "page") {
|
||||
if (payload.url) window.open(payload.url, "_blank", "noreferrer");
|
||||
} else if (kind === "predicate") {
|
||||
activateTab("graph");
|
||||
const sel = $("graphPredicate");
|
||||
if (sel) { sel.value = payload; sel.dispatchEvent(new Event("change")); }
|
||||
}
|
||||
}
|
||||
|
||||
function activateTab(name) {
|
||||
document.querySelectorAll(".tab").forEach((n) => n.classList.toggle("active", n.dataset.tab === name));
|
||||
document.querySelectorAll(".tab-panel").forEach((n) => n.classList.toggle("active", n.id === name));
|
||||
}
|
||||
63
ontology_platform/web/frontend/src/legacy/shell.js
Normal file
63
ontology_platform/web/frontend/src/legacy/shell.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import { getLang, setLang, t, applyI18n, onLangChange } from "./i18n.js";
|
||||
|
||||
export function mountShell(root) {
|
||||
root.innerHTML = `
|
||||
<header class="topbar">
|
||||
<div class="topbar-brand">
|
||||
<h1 data-i18n="app.title"></h1>
|
||||
<p data-i18n="app.subtitle"></p>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<div class="global-search">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" class="global-search-icon" aria-hidden="true">
|
||||
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M11 19a8 8 0 1 1 0-16 8 8 0 0 1 0 16Zm10 2-4.35-4.35"/>
|
||||
</svg>
|
||||
<input id="globalSearchInput" type="search" autocomplete="off"
|
||||
data-i18n-placeholder="search.placeholder" data-i18n-aria-label="search.aria_label" />
|
||||
<div id="globalSearchPanel" class="global-search-panel" hidden></div>
|
||||
</div>
|
||||
<div class="lang-toggle" role="group" aria-label="Language">
|
||||
<button class="lang-btn" data-lang="ko" data-i18n="header.lang.ko"></button>
|
||||
<button class="lang-btn" data-lang="en" data-i18n="header.lang.en"></button>
|
||||
</div>
|
||||
<button id="refreshBtn" class="icon-button" data-i18n-title="header.refresh" data-i18n-aria-label="header.refresh">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" width="18" height="18">
|
||||
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M3 12a9 9 0 0 1 15.5-6.3L21 8M21 3v5h-5M21 12a9 9 0 0 1-15.5 6.3L3 16M3 21v-5h5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<aside id="sidebarHost" class="sidebar"></aside>
|
||||
<section id="workspaceHost" class="workspace"></section>
|
||||
<aside id="inspectorHost" class="inspector"></aside>
|
||||
</main>
|
||||
|
||||
<div id="toast" role="status" aria-live="polite"></div>
|
||||
`;
|
||||
|
||||
document.documentElement.setAttribute("lang", getLang());
|
||||
applyI18n(root);
|
||||
|
||||
const langButtons = root.querySelectorAll(".lang-btn");
|
||||
const syncLangButtons = () => {
|
||||
const current = getLang();
|
||||
langButtons.forEach((btn) => {
|
||||
btn.classList.toggle("active", btn.dataset.lang === current);
|
||||
});
|
||||
};
|
||||
langButtons.forEach((btn) => {
|
||||
btn.addEventListener("click", () => setLang(btn.dataset.lang));
|
||||
});
|
||||
syncLangButtons();
|
||||
onLangChange(syncLangButtons);
|
||||
|
||||
return {
|
||||
sidebarHost: document.getElementById("sidebarHost"),
|
||||
workspaceHost: document.getElementById("workspaceHost"),
|
||||
inspectorHost: document.getElementById("inspectorHost"),
|
||||
};
|
||||
}
|
||||
420
ontology_platform/web/frontend/src/legacy/sidebar.js
Normal file
420
ontology_platform/web/frontend/src/legacy/sidebar.js
Normal file
@@ -0,0 +1,420 @@
|
||||
import { api } from "./api.js";
|
||||
import { state } from "./state.js";
|
||||
import { $, escapeHtml, toast } from "./utils.js";
|
||||
import { t, applyI18n, onLangChange } from "./i18n.js";
|
||||
|
||||
export function mountSidebar(host, { onProjectChanged, onCrawlUpdate, refreshAll }) {
|
||||
host.innerHTML = sidebarHtml();
|
||||
applyI18n(host);
|
||||
|
||||
$("createProjectBtn").addEventListener("click", () => createProject(onProjectChanged));
|
||||
$("initProjectBtn").addEventListener("click", () => initializeCurrentProject(onProjectChanged));
|
||||
$("discoverBtn").addEventListener("click", discover);
|
||||
$("crawlBtn").addEventListener("click", () => crawlOnce(refreshAll));
|
||||
$("siteCrawlBtn").addEventListener("click", () => crawlSite(onCrawlUpdate, refreshAll));
|
||||
$("stopSiteCrawlBtn").addEventListener("click", stopSiteCrawl);
|
||||
$("extractorProvider").addEventListener("change", updateExtractorOptions);
|
||||
$("testExtractorBtn").addEventListener("click", () => testExtractor({ announce: true }));
|
||||
|
||||
updateExtractorOptions();
|
||||
connectDefaultAnalyzer();
|
||||
|
||||
onLangChange(() => {
|
||||
applyI18n(host);
|
||||
renderProjects();
|
||||
renderSiteCrawlProgress(latestJob);
|
||||
});
|
||||
}
|
||||
|
||||
function sidebarHtml() {
|
||||
return `
|
||||
<section class="panel">
|
||||
<div class="panel-head"><h2 data-i18n="sidebar.projects"></h2></div>
|
||||
<div class="field-row">
|
||||
<input id="configPath" value="configs/perfume_subscription.yaml" data-i18n-aria-label="sidebar.config_path" />
|
||||
<button id="createProjectBtn" data-i18n-title="sidebar.create_project">+</button>
|
||||
</div>
|
||||
<button id="initProjectBtn" class="full" data-i18n="sidebar.reset_project"></button>
|
||||
<div id="projectList" class="list"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head"><h2 data-i18n="sidebar.crawl"></h2></div>
|
||||
<label>
|
||||
<span data-i18n="sidebar.source"></span>
|
||||
<select id="sourceSelect"></select>
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="sidebar.url_or_seed"></span>
|
||||
<input id="crawlUrl" value="tests/fixtures/sample_perfume.html" />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="sidebar.analyzer"></span>
|
||||
<select id="extractorProvider">
|
||||
<option value="rule_based" data-i18n="extractor.rule_based"></option>
|
||||
<option value="openai" data-i18n="extractor.openai"></option>
|
||||
<option value="ollama" data-i18n="extractor.ollama"></option>
|
||||
<option value="lm_studio" selected data-i18n="extractor.lm_studio"></option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="extractor-options" id="extractorOptions">
|
||||
<label>
|
||||
<span data-i18n="sidebar.model"></span>
|
||||
<input id="extractorModel" placeholder="local or API model" />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="sidebar.base_url"></span>
|
||||
<input id="extractorBaseUrl" value="http://localhost:1234/v1" placeholder="optional provider endpoint" />
|
||||
</label>
|
||||
<button id="testExtractorBtn" data-i18n="sidebar.test_analyzer"></button>
|
||||
</div>
|
||||
<div class="button-grid">
|
||||
<button id="discoverBtn" data-i18n="sidebar.discover"></button>
|
||||
<button id="crawlBtn" class="primary" data-i18n="sidebar.crawl_url"></button>
|
||||
</div>
|
||||
<div class="site-controls">
|
||||
<label>
|
||||
<span data-i18n="sidebar.max_depth"></span>
|
||||
<input id="siteMaxDepth" type="number" min="0" max="5" value="2" />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="sidebar.max_pages"></span>
|
||||
<input id="siteMaxPages" type="number" min="1" max="500" value="25" />
|
||||
</label>
|
||||
<label class="check-row">
|
||||
<input id="sameDomainOnly" type="checkbox" checked />
|
||||
<span data-i18n="sidebar.same_domain"></span>
|
||||
</label>
|
||||
<label class="check-row">
|
||||
<input id="respectRobotsTxt" type="checkbox" />
|
||||
<span data-i18n="sidebar.respect_robots"></span>
|
||||
</label>
|
||||
</div>
|
||||
<button id="siteCrawlBtn" class="primary full" data-i18n="sidebar.crawl_site"></button>
|
||||
<button id="stopSiteCrawlBtn" class="full" disabled data-i18n="sidebar.stop_crawl"></button>
|
||||
<div id="crawlResult" class="mini-log"></div>
|
||||
<div id="discoveredLinks" class="discovered-links"></div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
export function requestBase() {
|
||||
return {
|
||||
config_path: $("configPath").value.trim(),
|
||||
source_name: $("sourceSelect").value,
|
||||
url: $("crawlUrl").value.trim(),
|
||||
extractor_provider: $("extractorProvider").value,
|
||||
extractor_model: $("extractorModel").value.trim() || null,
|
||||
extractor_base_url: $("extractorBaseUrl").value.trim() || null,
|
||||
check_robots_txt: $("respectRobotsTxt")?.checked ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderProjects() {
|
||||
const list = $("projectList");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
state.projects.forEach((project) => {
|
||||
const button = document.createElement("button");
|
||||
button.className = `project-item ${state.selectedProject === project.name ? "active" : ""}`;
|
||||
button.innerHTML = `<strong>${escapeHtml(project.name)}</strong><span>${escapeHtml(project.domain)}</span>`;
|
||||
button.addEventListener("click", () => {
|
||||
const event = new CustomEvent("project:select", { detail: project.name });
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
list.appendChild(button);
|
||||
});
|
||||
}
|
||||
|
||||
export function renderSourceOptions(detail) {
|
||||
const sourceSelect = $("sourceSelect");
|
||||
if (!sourceSelect) return;
|
||||
sourceSelect.innerHTML = "";
|
||||
(detail?.sources ?? []).forEach((source) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = source.name;
|
||||
option.textContent = `${source.name} (${source.type})`;
|
||||
sourceSelect.appendChild(option);
|
||||
});
|
||||
}
|
||||
|
||||
async function createProject(onProjectChanged) {
|
||||
const configPath = $("configPath").value.trim();
|
||||
if (!configPath) return;
|
||||
try {
|
||||
const result = await api("/projects", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ config_path: configPath }),
|
||||
});
|
||||
toast(`${t("toast.project_created")}: ${result.name}`);
|
||||
state.selectedProject = result.name;
|
||||
await onProjectChanged?.();
|
||||
} catch (error) {
|
||||
toast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeCurrentProject(onProjectChanged) {
|
||||
const configPath = $("configPath").value.trim();
|
||||
if (!configPath) {
|
||||
toast(t("toast.config_path_required"));
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(t("toast.reset_confirm"))) return;
|
||||
try {
|
||||
const result = await api("/projects/reset", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
config_path: configPath,
|
||||
project_name: state.selectedProject,
|
||||
}),
|
||||
});
|
||||
state.selectedProject = result.name;
|
||||
await onProjectChanged?.();
|
||||
if (result.reset) {
|
||||
const c = result.deleted?.claims ?? 0;
|
||||
const e = result.deleted?.entities ?? 0;
|
||||
const p = result.deleted?.pages ?? 0;
|
||||
$("crawlResult").textContent = `${t("toast.reset_done")}: pages ${p}, entities ${e}, claims ${c}`;
|
||||
toast(`${t("toast.reset_done")}: ${result.name}`);
|
||||
} else {
|
||||
$("crawlResult").textContent = `${t("toast.project_created")}: ${result.name}`;
|
||||
toast(`${t("toast.project_created")}: ${result.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
toast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function crawlOnce(refreshAll) {
|
||||
if (!state.selectedProject) return;
|
||||
$("crawlResult").textContent = t("status.crawling_url");
|
||||
try {
|
||||
const result = await api("/crawl", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(requestBase()),
|
||||
});
|
||||
$("crawlResult").textContent =
|
||||
`URL done: page ${result.page_id}, ${result.crawl_status}/${result.extraction_status}, ${result.page_type}, raw ${result.raw_text_length}, clean ${result.clean_text_length}, claims ${result.claim_count}, entities ${result.entity_count}`;
|
||||
state.latestActivity = { kind: "url_crawl", result };
|
||||
toast(t("toast.url_crawl_completed"));
|
||||
await refreshAll?.();
|
||||
} catch (error) {
|
||||
$("crawlResult").textContent = `${t("toast.crawl_failed")}: ${error.message}`;
|
||||
toast(t("toast.crawl_failed"));
|
||||
}
|
||||
}
|
||||
|
||||
let latestJob = null;
|
||||
|
||||
async function crawlSite(onCrawlUpdate, refreshAll) {
|
||||
if (!state.selectedProject) return;
|
||||
stopSiteCrawlPolling();
|
||||
state.siteCrawlPageCount = 0;
|
||||
state.siteCrawlPollErrorCount = 0;
|
||||
$("crawlResult").textContent = t("status.starting_site_crawl");
|
||||
$("discoveredLinks").innerHTML = "";
|
||||
try {
|
||||
const job = await api("/crawl-site", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
...requestBase(),
|
||||
max_depth: Number($("siteMaxDepth").value || 0),
|
||||
max_pages: Number($("siteMaxPages").value || 1),
|
||||
same_domain_only: $("sameDomainOnly").checked,
|
||||
analyze_page_types: ["ProductPage", "BrandStoryPage", "ReviewPage"],
|
||||
}),
|
||||
});
|
||||
$("crawlResult").textContent = `Site crawl queued: job ${job.job_id}`;
|
||||
state.activeSiteCrawlJobId = job.job_id;
|
||||
$("stopSiteCrawlBtn").disabled = false;
|
||||
renderSiteCrawlProgress(job);
|
||||
pollSiteCrawl(job.job_id, onCrawlUpdate, refreshAll);
|
||||
toast(t("toast.site_crawl_started"));
|
||||
} catch (error) {
|
||||
$("crawlResult").textContent = `${t("toast.site_crawl_failed")}: ${error.message}`;
|
||||
toast(t("toast.site_crawl_failed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function stopSiteCrawl() {
|
||||
const jobId = state.activeSiteCrawlJobId;
|
||||
if (!jobId) return;
|
||||
$("stopSiteCrawlBtn").disabled = true;
|
||||
$("crawlResult").textContent = `${t("status.stopping_crawl")}: job ${jobId}`;
|
||||
try {
|
||||
const job = await api(`/crawl-site/jobs/${jobId}/cancel`, { method: "POST" });
|
||||
renderSiteCrawlProgress(job);
|
||||
toast(t("toast.stop_requested"));
|
||||
} catch (error) {
|
||||
$("stopSiteCrawlBtn").disabled = false;
|
||||
$("crawlResult").textContent = `${t("toast.stop_failed")}: ${error.message}`;
|
||||
toast(t("toast.stop_failed"));
|
||||
}
|
||||
}
|
||||
|
||||
function stopSiteCrawlPolling() {
|
||||
if (state.siteCrawlPoll) {
|
||||
window.clearTimeout(state.siteCrawlPoll);
|
||||
state.siteCrawlPoll = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollSiteCrawl(jobId, onCrawlUpdate, refreshAll) {
|
||||
let job;
|
||||
try {
|
||||
job = await api(`/crawl-site/jobs/${jobId}`);
|
||||
} catch (error) {
|
||||
const transient = state.siteCrawlPollErrorCount = (state.siteCrawlPollErrorCount ?? 0) + 1;
|
||||
$("crawlResult").textContent = `Site crawl status check failed (retry ${transient}): ${error.message}`;
|
||||
if (transient >= 5) {
|
||||
stopSiteCrawlPolling();
|
||||
toast(t("toast.site_crawl_failed"));
|
||||
return;
|
||||
}
|
||||
state.siteCrawlPoll = window.setTimeout(() => pollSiteCrawl(jobId, onCrawlUpdate, refreshAll), 3000);
|
||||
return;
|
||||
}
|
||||
state.siteCrawlPollErrorCount = 0;
|
||||
|
||||
const progress = job.progress ?? {};
|
||||
const pageCount = progress.pages?.length ?? 0;
|
||||
renderSiteCrawlProgress(job);
|
||||
onCrawlUpdate?.(job);
|
||||
if (pageCount !== state.siteCrawlPageCount) {
|
||||
state.siteCrawlPageCount = pageCount;
|
||||
try { await refreshAll?.(); } catch (error) { console.warn("refreshAll during poll failed", error); }
|
||||
}
|
||||
if (["completed", "failed", "canceled"].includes(job.status)) {
|
||||
stopSiteCrawlPolling();
|
||||
state.activeSiteCrawlJobId = null;
|
||||
$("stopSiteCrawlBtn").disabled = true;
|
||||
toast(job.status === "completed" ? t("toast.site_crawl_completed") : `${t("toast.site_crawl_failed")}: ${job.status}`);
|
||||
return;
|
||||
}
|
||||
state.siteCrawlPoll = window.setTimeout(() => pollSiteCrawl(jobId, onCrawlUpdate, refreshAll), 1500);
|
||||
}
|
||||
|
||||
function renderSiteCrawlProgress(job) {
|
||||
latestJob = job;
|
||||
if (!job) return;
|
||||
const progress = job.progress ?? {};
|
||||
const pages = progress.pages ?? [];
|
||||
$("crawlResult").textContent =
|
||||
`Site ${job.status}: visited ${progress.visited_count ?? 0}, analyzed ${progress.analyzed_count ?? 0}, skipped ${progress.skipped_count ?? 0}, queued ${progress.queued_count ?? 0}`;
|
||||
$("discoveredLinks").innerHTML = pages.map(renderSitePage).join("");
|
||||
document.querySelectorAll("[data-discovered-url]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
$("crawlUrl").value = button.dataset.discoveredUrl;
|
||||
toast(t("toast.url_copied"));
|
||||
});
|
||||
});
|
||||
state.latestActivity = { kind: "site_crawl", job };
|
||||
}
|
||||
|
||||
function renderSitePage(page) {
|
||||
const diagnostics = `raw ${page.raw_text_length ?? 0}, clean ${page.clean_text_length ?? 0}, removed ${page.removed_noise_zones_count ?? 0}`;
|
||||
const warnings = (page.warnings ?? []).length ? `, warnings: ${(page.warnings ?? []).join("; ")}` : "";
|
||||
return `
|
||||
<button class="discovered-link" data-discovered-url="${escapeHtml(page.url)}">
|
||||
<strong>${escapeHtml(page.status)} · ${escapeHtml(page.page_type)} · depth ${page.depth}</strong>
|
||||
<span>${escapeHtml(page.url)}</span>
|
||||
<span>${escapeHtml(diagnostics)}${escapeHtml(warnings)}</span>
|
||||
<span>claims ${page.claim_count}, entities ${page.entity_count}, links ${page.discovered_count}${page.error ? `, error: ${escapeHtml(page.error)}` : ""}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
async function discover() {
|
||||
$("crawlResult").textContent = t("status.discovering");
|
||||
$("discoveredLinks").innerHTML = "";
|
||||
try {
|
||||
const result = await api("/discover", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
config_path: $("configPath").value.trim(),
|
||||
source_name: $("sourceSelect").value,
|
||||
url: $("crawlUrl").value.trim(),
|
||||
limit: 30,
|
||||
check_robots_txt: $("respectRobotsTxt")?.checked ?? false,
|
||||
}),
|
||||
});
|
||||
if (!result.ok) {
|
||||
$("crawlResult").textContent = result.error ?? t("toast.discovery_failed");
|
||||
return;
|
||||
}
|
||||
$("crawlResult").textContent = `Discovered ${result.links.length} links`;
|
||||
$("discoveredLinks").innerHTML = result.links.map(renderDiscoveredLink).join("");
|
||||
document.querySelectorAll("[data-discovered-url]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
$("crawlUrl").value = button.dataset.discoveredUrl;
|
||||
toast(t("toast.url_copied"));
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
$("crawlResult").textContent = `${t("toast.discovery_failed")}: ${error.message}`;
|
||||
toast(t("toast.discovery_failed"));
|
||||
}
|
||||
}
|
||||
|
||||
function renderDiscoveredLink(link) {
|
||||
return `
|
||||
<button class="discovered-link" data-discovered-url="${escapeHtml(link.url)}">
|
||||
<strong>${escapeHtml(link.label)}</strong>
|
||||
<span>${escapeHtml(link.kind)} · ${escapeHtml(link.url)}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function updateExtractorOptions() {
|
||||
const provider = $("extractorProvider").value;
|
||||
$("extractorOptions").classList.toggle("active", provider !== "rule_based");
|
||||
if (provider === "ollama" && !$("extractorBaseUrl").value.trim()) {
|
||||
$("extractorBaseUrl").placeholder = "http://localhost:11434/api/chat";
|
||||
} else if (provider === "lm_studio" && !$("extractorBaseUrl").value.trim()) {
|
||||
$("extractorBaseUrl").value = "http://localhost:1234/v1";
|
||||
$("extractorBaseUrl").placeholder = "http://localhost:1234/v1";
|
||||
} else {
|
||||
$("extractorBaseUrl").placeholder = "optional provider endpoint";
|
||||
}
|
||||
}
|
||||
|
||||
async function testExtractor({ announce = true } = {}) {
|
||||
const provider = $("extractorProvider").value;
|
||||
const baseUrl = $("extractorBaseUrl").value.trim();
|
||||
if (announce) $("crawlResult").textContent = t("status.testing_analyzer");
|
||||
try {
|
||||
const result = await api("/extractors/models", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ provider, base_url: baseUrl || null }),
|
||||
});
|
||||
if (!result.ok) {
|
||||
if (announce) {
|
||||
$("crawlResult").textContent = `${t("toast.analyzer_failed")}: ${result.error}`;
|
||||
toast(t("toast.analyzer_failed"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const models = result.models ?? [];
|
||||
if (models.length && !$("extractorModel").value.trim()) {
|
||||
$("extractorModel").value = models[0].id;
|
||||
}
|
||||
if (announce) {
|
||||
$("crawlResult").textContent = models.length
|
||||
? `${t("toast.analyzer_connected")}. Models: ${models.map((m) => m.id).join(", ")}`
|
||||
: `${t("toast.analyzer_connected")}. No models returned.`;
|
||||
toast(t("toast.analyzer_connected"));
|
||||
}
|
||||
} catch (error) {
|
||||
if (announce) toast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function connectDefaultAnalyzer() {
|
||||
if ($("extractorProvider").value !== "lm_studio") return;
|
||||
try {
|
||||
await testExtractor({ announce: false });
|
||||
} catch {
|
||||
// LM Studio may not be running yet.
|
||||
}
|
||||
}
|
||||
15
ontology_platform/web/frontend/src/legacy/state.js
Normal file
15
ontology_platform/web/frontend/src/legacy/state.js
Normal file
@@ -0,0 +1,15 @@
|
||||
export const state = {
|
||||
projects: [],
|
||||
selectedProject: null,
|
||||
projectDetail: null,
|
||||
ontology: null,
|
||||
|
||||
siteCrawlPoll: null,
|
||||
siteCrawlPageCount: 0,
|
||||
siteCrawlPollErrorCount: 0,
|
||||
activeSiteCrawlJobId: null,
|
||||
|
||||
latestActivity: null,
|
||||
|
||||
selection: null,
|
||||
};
|
||||
128
ontology_platform/web/frontend/src/legacy/styles/components.css
Normal file
128
ontology_platform/web/frontend/src/legacy/styles/components.css
Normal file
@@ -0,0 +1,128 @@
|
||||
.table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
.table th, .table td {
|
||||
text-align: left;
|
||||
padding: 7px 10px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.table th {
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
font-size: 10px;
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
.table tbody tr:hover {
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
.table code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.row-link {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-accent);
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.row-link:hover { text-decoration: underline; }
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.claim-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.claim-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.claim-card:hover {
|
||||
border-color: var(--color-border-strong);
|
||||
}
|
||||
.claim-main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.claim-main strong { color: var(--color-text-strong); }
|
||||
.claim-main .predicate {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.claim-main .confidence {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-success);
|
||||
font-size: 11px;
|
||||
}
|
||||
.evidence {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 4px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.claim-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.claim-actions input {
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px 8px;
|
||||
color: var(--color-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
.claim-actions input[type="number"] {
|
||||
max-width: 80px;
|
||||
}
|
||||
.claim-actions button {
|
||||
padding: 4px 12px;
|
||||
background: var(--color-accent);
|
||||
color: #0c111c;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
125
ontology_platform/web/frontend/src/legacy/styles/graph.css
Normal file
125
ontology_platform/web/frontend/src/legacy/styles/graph.css
Normal file
@@ -0,0 +1,125 @@
|
||||
.graph-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.graph-toolbar input[type="search"] {
|
||||
flex: 1 1 200px;
|
||||
padding: 6px 10px;
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
}
|
||||
.graph-toolbar input[type="search"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px var(--color-accent-soft);
|
||||
}
|
||||
.graph-toolbar select {
|
||||
padding: 6px 10px;
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.graph-toolbar button {
|
||||
padding: 6px 12px;
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.graph-toolbar button:hover { border-color: var(--color-border-strong); }
|
||||
.graph-toolbar button.primary {
|
||||
background: var(--color-accent);
|
||||
color: #0c111c;
|
||||
border-color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.graph-toolbar button.primary:hover { background: var(--color-accent-hover); }
|
||||
|
||||
.graph-slider {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.graph-slider input[type="range"] {
|
||||
width: 110px;
|
||||
}
|
||||
.graph-slider output {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-text);
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.graph-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.graph-legend {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
.legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.legend-item i {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #0f1216;
|
||||
}
|
||||
|
||||
.graph-stage {
|
||||
position: relative;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
height: calc(100vh - var(--topbar-height) - 240px);
|
||||
min-height: 420px;
|
||||
overflow: hidden;
|
||||
}
|
||||
#graphContainer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.graph-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
font-size: 12px;
|
||||
}
|
||||
10
ontology_platform/web/frontend/src/legacy/styles/index.css
Normal file
10
ontology_platform/web/frontend/src/legacy/styles/index.css
Normal file
@@ -0,0 +1,10 @@
|
||||
@import "./tokens.css";
|
||||
@import "./shell.css";
|
||||
@import "./sidebar.css";
|
||||
@import "./workspace.css";
|
||||
@import "./inspector.css";
|
||||
@import "./components.css";
|
||||
@import "./graph.css";
|
||||
@import "./workbench.css";
|
||||
@import "./pipeline.css";
|
||||
@import "./search.css";
|
||||
@@ -0,0 +1,76 @@
|
||||
.inspector-head {
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.inspector-head h2 {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.inspector-head p {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.inspector-section {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.inspector-section h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 4px 10px;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.kv dt {
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.4px;
|
||||
padding-top: 1px;
|
||||
}
|
||||
.kv dd {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.inspector .code,
|
||||
.inspector pre.code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.inspector .evidence {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg);
|
||||
border-left: 2px solid var(--color-accent);
|
||||
padding: 6px 8px;
|
||||
border-radius: 2px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
183
ontology_platform/web/frontend/src/legacy/styles/pipeline.css
Normal file
183
ontology_platform/web/frontend/src/legacy/styles/pipeline.css
Normal file
@@ -0,0 +1,183 @@
|
||||
.pipeline-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.pipeline-head h2 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.pipeline-head button.ghost {
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 5px 10px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pipeline-head button.ghost:hover { border-color: var(--color-border-strong); }
|
||||
|
||||
.pipeline-stages {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.pipeline-card {
|
||||
--stage-color: #7aa2ff;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 3px solid var(--stage-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.pipeline-card-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
.pipeline-stage-name {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.pipeline-retention {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--stage-color);
|
||||
}
|
||||
.pipeline-count {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-strong);
|
||||
font-family: var(--font-mono);
|
||||
line-height: 1.2;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.pipeline-bar {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: var(--color-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.pipeline-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--stage-color);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.pipeline-extra {
|
||||
margin-top: 6px;
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.entity-type-bars {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.entity-type-row {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr 50px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
.entity-type-name {
|
||||
color: var(--color-text);
|
||||
font-size: 11px;
|
||||
}
|
||||
.entity-type-bar {
|
||||
height: 6px;
|
||||
background: var(--color-bg);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.entity-type-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--color-accent);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.entity-type-count {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-text-muted);
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.recent-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.recent-item {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.recent-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.recent-badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.recent-code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.recent-time {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.recent-title {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-strong);
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.recent-url {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
word-break: break-all;
|
||||
text-decoration: none;
|
||||
}
|
||||
.recent-url:hover { color: var(--color-accent); text-decoration: underline; }
|
||||
.recent-predicate {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.recent-conf {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--color-success);
|
||||
}
|
||||
98
ontology_platform/web/frontend/src/legacy/styles/search.css
Normal file
98
ontology_platform/web/frontend/src/legacy/styles/search.css
Normal file
@@ -0,0 +1,98 @@
|
||||
.global-search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
max-width: 360px;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0 10px;
|
||||
}
|
||||
.global-search-icon {
|
||||
color: var(--color-text-muted);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.global-search input {
|
||||
flex: 1 1 auto;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
outline: none;
|
||||
color: var(--color-text);
|
||||
font-size: 12px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.global-search input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.global-search-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
z-index: 20;
|
||||
}
|
||||
.global-search-panel[hidden] { display: none; }
|
||||
|
||||
.search-empty {
|
||||
padding: 12px 14px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.search-group {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.search-group:last-child { border-bottom: 0; }
|
||||
.search-group-head {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--color-text-muted);
|
||||
padding: 6px 12px 4px;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.search-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 7px 12px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
.search-item:hover,
|
||||
.search-item:focus {
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-text-strong);
|
||||
outline: none;
|
||||
}
|
||||
.search-item-main {
|
||||
flex: 1 1 auto;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.search-item-meta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.global-search { max-width: 200px; }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.global-search { display: none; }
|
||||
}
|
||||
136
ontology_platform/web/frontend/src/legacy/styles/shell.css
Normal file
136
ontology_platform/web/frontend/src/legacy/styles/shell.css
Normal file
@@ -0,0 +1,136 @@
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: var(--topbar-height);
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 18px;
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.topbar-brand h1 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
letter-spacing: 0.2px;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
.topbar-brand p {
|
||||
font-size: 11px;
|
||||
margin: 2px 0 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.lang-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background: var(--color-surface-2);
|
||||
}
|
||||
.lang-btn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3px;
|
||||
min-width: 32px;
|
||||
}
|
||||
.lang-btn:hover { color: var(--color-text); }
|
||||
.lang-btn.active {
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-2);
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.icon-button:hover { color: var(--color-text); border-color: var(--color-border-strong); }
|
||||
|
||||
.layout {
|
||||
flex: 1 1 auto;
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width) 1fr var(--inspector-width);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar, .workspace, .inspector {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.sidebar {
|
||||
background: var(--color-surface);
|
||||
border-right: 1px solid var(--color-border);
|
||||
padding: 14px;
|
||||
}
|
||||
.workspace {
|
||||
padding: 14px 18px;
|
||||
}
|
||||
.inspector {
|
||||
background: var(--color-surface);
|
||||
border-left: 1px solid var(--color-border);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
#toast {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
background: var(--color-surface-3);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px 14px;
|
||||
font-size: 12px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
pointer-events: none;
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 100;
|
||||
}
|
||||
#toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.layout {
|
||||
grid-template-columns: var(--sidebar-width) 1fr;
|
||||
}
|
||||
.inspector { display: none; }
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sidebar { display: none; }
|
||||
}
|
||||
199
ontology_platform/web/frontend/src/legacy/styles/sidebar.css
Normal file
199
ontology_platform/web/frontend/src/legacy/styles/sidebar.css
Normal file
@@ -0,0 +1,199 @@
|
||||
.sidebar .panel {
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.sidebar .panel-head h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.sidebar label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.sidebar label > span:first-child {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.sidebar input,
|
||||
.sidebar select {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.sidebar input:focus,
|
||||
.sidebar select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 2px var(--color-accent-soft);
|
||||
}
|
||||
|
||||
.sidebar .field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.sidebar .field-row button {
|
||||
padding: 6px 10px;
|
||||
background: var(--color-surface-3);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar button {
|
||||
padding: 7px 10px;
|
||||
background: var(--color-surface-3);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sidebar button:hover:not(:disabled) {
|
||||
border-color: var(--color-border-strong);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
.sidebar button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.sidebar button.primary {
|
||||
background: var(--color-accent);
|
||||
color: #0c111c;
|
||||
border-color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.sidebar button.primary:hover:not(:disabled) {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
.sidebar button.full {
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.project-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: left;
|
||||
padding: 7px 10px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.project-item.active {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent-soft);
|
||||
}
|
||||
.project-item strong {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
.project-item span {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.extractor-options {
|
||||
display: none;
|
||||
border-top: 1px dashed var(--color-border);
|
||||
margin-top: 6px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.extractor-options.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.button-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.site-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.check-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
text-transform: none !important;
|
||||
letter-spacing: 0 !important;
|
||||
color: var(--color-text) !important;
|
||||
}
|
||||
.check-row input[type="checkbox"] {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.mini-log {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 8px;
|
||||
margin-top: 6px;
|
||||
min-height: 24px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.discovered-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.discovered-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: left;
|
||||
padding: 6px 8px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
.discovered-link:hover { border-color: var(--color-border-strong); }
|
||||
.discovered-link strong { color: var(--color-text-strong); font-weight: 600; }
|
||||
.discovered-link span {
|
||||
color: var(--color-text-muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
62
ontology_platform/web/frontend/src/legacy/styles/tokens.css
Normal file
62
ontology_platform/web/frontend/src/legacy/styles/tokens.css
Normal file
@@ -0,0 +1,62 @@
|
||||
:root {
|
||||
--color-bg: #0f1216;
|
||||
--color-surface: #161a21;
|
||||
--color-surface-2: #1c212b;
|
||||
--color-surface-3: #232a36;
|
||||
--color-border: #262d39;
|
||||
--color-border-strong: #364154;
|
||||
|
||||
--color-text: #e6ecf3;
|
||||
--color-text-muted: #8c98ab;
|
||||
--color-text-strong: #ffffff;
|
||||
|
||||
--color-accent: #7aa2ff;
|
||||
--color-accent-hover: #8eb3ff;
|
||||
--color-accent-soft: rgba(122, 162, 255, 0.12);
|
||||
|
||||
--color-success: #4ade80;
|
||||
--color-warn: #facc15;
|
||||
--color-danger: #f87171;
|
||||
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 10px;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||
--shadow-md: 0 6px 24px rgba(0, 0, 0, 0.45);
|
||||
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans KR", "Pretendard", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
|
||||
--sidebar-width: 280px;
|
||||
--inspector-width: 320px;
|
||||
--topbar-height: 52px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
button, input, select, textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
a { color: var(--color-accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
.muted { color: var(--color-text-muted); }
|
||||
314
ontology_platform/web/frontend/src/legacy/styles/workbench.css
Normal file
314
ontology_platform/web/frontend/src/legacy/styles/workbench.css
Normal file
@@ -0,0 +1,314 @@
|
||||
.workbench {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - var(--topbar-height) - 80px);
|
||||
}
|
||||
|
||||
.workbench-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.workbench-toolbar select,
|
||||
.workbench-toolbar input[type="search"] {
|
||||
padding: 6px 10px;
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
}
|
||||
.workbench-toolbar input[type="search"] {
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
.workbench-toolbar button {
|
||||
padding: 6px 12px;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
.workbench-toolbar button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.wb-success { background: rgba(74, 222, 128, 0.15) !important; color: var(--color-success) !important; border-color: rgba(74, 222, 128, 0.3) !important; }
|
||||
.wb-danger { background: rgba(248, 113, 113, 0.12) !important; color: var(--color-danger) !important; border-color: rgba(248, 113, 113, 0.3) !important; }
|
||||
.wb-success:hover:not(:disabled) { background: rgba(74, 222, 128, 0.25) !important; }
|
||||
.wb-danger:hover:not(:disabled) { background: rgba(248, 113, 113, 0.22) !important; }
|
||||
|
||||
.wb-selected {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.workbench-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 360px) 1fr;
|
||||
gap: 10px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.workbench-body { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.workbench-list {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: auto;
|
||||
max-height: calc(100vh - var(--topbar-height) - 200px);
|
||||
}
|
||||
.wb-empty {
|
||||
padding: 30px 20px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wb-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: 9px 12px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.wb-row:hover { background: var(--color-surface-2); }
|
||||
.wb-row.active { background: var(--color-accent-soft); border-left: 2px solid var(--color-accent); padding-left: 10px; }
|
||||
.wb-row.selected { background: rgba(122, 162, 255, 0.08); }
|
||||
.wb-row.active.selected { background: var(--color-accent-soft); }
|
||||
|
||||
.wb-row-main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
.wb-row-main strong { color: var(--color-text-strong); font-weight: 600; }
|
||||
.wb-row-main .predicate {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-style: normal;
|
||||
}
|
||||
.wb-row-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
}
|
||||
.wb-status {
|
||||
font-family: var(--font-mono);
|
||||
text-transform: uppercase;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--color-surface-3);
|
||||
color: var(--color-text-muted);
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.wb-status[data-status="validated_claim"] { background: rgba(74, 222, 128, 0.15); color: var(--color-success); }
|
||||
.wb-status[data-status="rejected"] { background: rgba(248, 113, 113, 0.15); color: var(--color-danger); }
|
||||
.wb-status[data-status="active"] { background: rgba(250, 204, 21, 0.12); color: var(--color-warn); }
|
||||
|
||||
.wb-confidence {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.workbench-detail {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px 18px;
|
||||
overflow: auto;
|
||||
max-height: calc(100vh - var(--topbar-height) - 200px);
|
||||
}
|
||||
|
||||
.wb-detail-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.wb-detail-head h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
.wb-detail-head h3 .predicate {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
background: var(--color-accent-soft);
|
||||
color: var(--color-accent);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
.wb-detail-status {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.wb-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wb-actions button {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.wb-actions button:hover { border-color: var(--color-border-strong); }
|
||||
.wb-actions kbd {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 3px;
|
||||
padding: 0 5px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.wb-kv {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
gap: 6px 12px;
|
||||
margin: 0 0 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.wb-kv dt {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
padding-top: 1px;
|
||||
}
|
||||
.wb-kv dd { margin: 0; word-break: break-all; }
|
||||
|
||||
.wb-evidence {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.wb-evidence h4 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
.wb-evidence pre {
|
||||
margin: 0;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--color-text);
|
||||
line-height: 1.55;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
.wb-evidence mark {
|
||||
background: rgba(250, 204, 21, 0.28);
|
||||
color: var(--color-text-strong);
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.wb-confidence-edit {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.wb-confidence-edit label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.wb-confidence-edit input[type="number"] {
|
||||
width: 80px;
|
||||
padding: 5px 8px;
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.wb-confidence-edit input:not([type="number"]) {
|
||||
flex: 1 1 200px;
|
||||
padding: 5px 8px;
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
}
|
||||
.wb-confidence-edit button {
|
||||
padding: 5px 12px;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
.wb-confidence-edit button:hover { border-color: var(--color-border-strong); }
|
||||
|
||||
.workbench-shortcuts {
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.workbench-shortcuts strong {
|
||||
color: var(--color-text);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
151
ontology_platform/web/frontend/src/legacy/styles/workspace.css
Normal file
151
ontology_platform/web/frontend/src/legacy/styles/workspace.css
Normal file
@@ -0,0 +1,151 @@
|
||||
.workspace {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.tab {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.tab:hover { color: var(--color-text); }
|
||||
.tab.active {
|
||||
color: var(--color-text-strong);
|
||||
border-bottom-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.tab-panel { display: none; }
|
||||
.tab-panel.active { display: block; }
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.metric {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.metric span {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.metric strong {
|
||||
font-size: 20px;
|
||||
color: var(--color-text-strong);
|
||||
margin-top: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wide-panel {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.wide-panel h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.split .wide-panel { margin-bottom: 0; }
|
||||
@media (max-width: 1100px) {
|
||||
.split { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.toolbar.wrap { flex-wrap: wrap; }
|
||||
.toolbar button,
|
||||
.toolbar select,
|
||||
.toolbar input {
|
||||
padding: 6px 10px;
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toolbar button:hover { border-color: var(--color-border-strong); }
|
||||
|
||||
.merge-bar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.merge-bar input {
|
||||
padding: 6px 10px;
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.merge-bar button {
|
||||
padding: 6px 14px;
|
||||
background: var(--color-surface-3);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.recommend-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.recommend-grid label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.recommend-grid input {
|
||||
padding: 6px 10px;
|
||||
background: var(--color-surface-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
42
ontology_platform/web/frontend/src/legacy/utils.js
Normal file
42
ontology_platform/web/frontend/src/legacy/utils.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { t } from "./i18n.js";
|
||||
|
||||
export const $ = (id) => document.getElementById(id);
|
||||
|
||||
export function csv(value) {
|
||||
return String(value || "").split(",").map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
export function chip(value) {
|
||||
return `<span class="chip">${escapeHtml(value)}</span>`;
|
||||
}
|
||||
|
||||
export function table(headers, rows) {
|
||||
if (!rows.length) {
|
||||
return `<table><tbody><tr><td>${escapeHtml(t("table.no_data"))}</td></tr></tbody></table>`;
|
||||
}
|
||||
return `
|
||||
<table>
|
||||
<thead><tr>${headers.map((header) => `<th>${escapeHtml(header)}</th>`).join("")}</tr></thead>
|
||||
<tbody>${rows.map((row) => `<tr>${row.map((cell) => `<td>${String(cell ?? "")}</td>`).join("")}</tr>`).join("")}</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
let toastTimer = null;
|
||||
export function toast(message) {
|
||||
const node = document.getElementById("toast");
|
||||
if (!node) return;
|
||||
node.textContent = message;
|
||||
node.classList.add("show");
|
||||
if (toastTimer) window.clearTimeout(toastTimer);
|
||||
toastTimer = window.setTimeout(() => node.classList.remove("show"), 2400);
|
||||
}
|
||||
374
ontology_platform/web/frontend/src/legacy/workbench.js
Normal file
374
ontology_platform/web/frontend/src/legacy/workbench.js
Normal file
@@ -0,0 +1,374 @@
|
||||
import { api } from "./api.js";
|
||||
import { state } from "./state.js";
|
||||
import { $, escapeHtml, toast } from "./utils.js";
|
||||
import { t, applyI18n, onLangChange } from "./i18n.js";
|
||||
|
||||
let allClaims = [];
|
||||
let viewClaims = [];
|
||||
let activeId = null;
|
||||
const selectedIds = new Set();
|
||||
const filters = { status: "", predicate: "", search: "" };
|
||||
let keyHandler = null;
|
||||
|
||||
export function workbenchHtml() {
|
||||
return `
|
||||
<section id="claims" class="tab-panel">
|
||||
<div class="workbench">
|
||||
<div class="workbench-toolbar">
|
||||
<select id="wbStatusFilter">
|
||||
<option value="" data-i18n="workbench.status.all"></option>
|
||||
<option value="active" data-i18n="workbench.status.active"></option>
|
||||
<option value="validated_claim" data-i18n="workbench.status.validated_claim"></option>
|
||||
<option value="rejected" data-i18n="workbench.status.rejected"></option>
|
||||
</select>
|
||||
<select id="wbPredicateFilter">
|
||||
<option value="" data-i18n="workbench.predicate_all"></option>
|
||||
</select>
|
||||
<input id="wbSearch" type="search" data-i18n-placeholder="workbench.search_placeholder" />
|
||||
<span class="wb-selected" id="wbSelectedCount"></span>
|
||||
<button id="wbBulkAcceptBtn" class="wb-success" disabled data-i18n="workbench.action.accept"></button>
|
||||
<button id="wbBulkRejectBtn" class="wb-danger" disabled data-i18n="workbench.action.reject"></button>
|
||||
</div>
|
||||
<div class="workbench-body">
|
||||
<div class="workbench-list" id="wbList"></div>
|
||||
<div class="workbench-detail" id="wbDetail"></div>
|
||||
</div>
|
||||
<div class="workbench-shortcuts">
|
||||
<strong data-i18n="workbench.shortcuts"></strong>
|
||||
<span data-i18n="workbench.shortcut.nav"></span>
|
||||
<span data-i18n="workbench.shortcut.accept"></span>
|
||||
<span data-i18n="workbench.shortcut.reject"></span>
|
||||
<span data-i18n="workbench.shortcut.toggle"></span>
|
||||
<span data-i18n="workbench.shortcut.bulk_accept"></span>
|
||||
<span data-i18n="workbench.shortcut.bulk_reject"></span>
|
||||
<span data-i18n="workbench.shortcut.search"></span>
|
||||
<span data-i18n="workbench.shortcut.escape"></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
export function mountWorkbench() {
|
||||
const tab = $("claims");
|
||||
applyI18n(tab);
|
||||
onLangChange(() => { applyI18n(tab); render(); });
|
||||
|
||||
$("wbStatusFilter").addEventListener("change", (e) => { filters.status = e.target.value; render(); });
|
||||
$("wbPredicateFilter").addEventListener("change", (e) => { filters.predicate = e.target.value; render(); });
|
||||
$("wbSearch").addEventListener("input", (e) => { filters.search = e.target.value.trim().toLowerCase(); render(); });
|
||||
$("wbBulkAcceptBtn").addEventListener("click", () => bulkReview("validated_claim"));
|
||||
$("wbBulkRejectBtn").addEventListener("click", () => bulkReview("rejected"));
|
||||
|
||||
attachKeyboard();
|
||||
}
|
||||
|
||||
export async function loadWorkbench() {
|
||||
if (!state.selectedProject) return;
|
||||
const proj = encodeURIComponent(state.selectedProject);
|
||||
const [validated, candidates] = await Promise.all([
|
||||
api(`/projects/${proj}/claims?limit=200&include_candidates=true`),
|
||||
api(`/projects/${proj}/claims?limit=200&status=rejected`).catch(() => []),
|
||||
]);
|
||||
const map = new Map();
|
||||
for (const c of [...validated, ...candidates]) map.set(c.id, c);
|
||||
allClaims = [...map.values()].sort((a, b) => (b.last_seen_at ?? "").localeCompare(a.last_seen_at ?? ""));
|
||||
populatePredicateFilter();
|
||||
render();
|
||||
}
|
||||
|
||||
function populatePredicateFilter() {
|
||||
const select = $("wbPredicateFilter");
|
||||
if (!select) return;
|
||||
const preds = [...new Set(allClaims.map((c) => c.predicate).filter(Boolean))].sort();
|
||||
const prev = filters.predicate;
|
||||
select.innerHTML = `<option value="">${escapeHtml(t("workbench.predicate_all"))}</option>` +
|
||||
preds.map((p) => `<option value="${escapeHtml(p)}" ${p === prev ? "selected" : ""}>${escapeHtml(p)}</option>`).join("");
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const search = filters.search;
|
||||
viewClaims = allClaims.filter((c) => {
|
||||
if (filters.status && c.status !== filters.status) return false;
|
||||
if (filters.predicate && c.predicate !== filters.predicate) return false;
|
||||
if (search) {
|
||||
const hay = `${c.subject ?? ""} ${c.predicate ?? ""} ${c.object ?? ""} ${c.evidence_text ?? ""}`.toLowerCase();
|
||||
if (!hay.includes(search)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!viewClaims.some((c) => c.id === activeId)) {
|
||||
activeId = viewClaims[0]?.id ?? null;
|
||||
}
|
||||
for (const id of [...selectedIds]) {
|
||||
if (!viewClaims.some((c) => c.id === id)) selectedIds.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
applyFilters();
|
||||
renderList();
|
||||
renderDetail();
|
||||
renderBulkBar();
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const list = $("wbList");
|
||||
if (!viewClaims.length) {
|
||||
list.innerHTML = `<div class="wb-empty muted" data-i18n="workbench.empty">${escapeHtml(t("workbench.empty"))}</div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML = viewClaims.map((c) => listItem(c)).join("");
|
||||
list.querySelectorAll("[data-claim-row]").forEach((row) => {
|
||||
const id = Number(row.dataset.claimRow);
|
||||
row.addEventListener("click", (event) => {
|
||||
if (event.shiftKey || event.ctrlKey || event.metaKey) {
|
||||
toggleSelect(id);
|
||||
} else {
|
||||
activeId = id;
|
||||
selectedIds.clear();
|
||||
selectedIds.add(id);
|
||||
}
|
||||
renderList();
|
||||
renderDetail();
|
||||
renderBulkBar();
|
||||
scrollActiveIntoView();
|
||||
});
|
||||
});
|
||||
scrollActiveIntoView();
|
||||
}
|
||||
|
||||
function listItem(c) {
|
||||
const isActive = c.id === activeId;
|
||||
const isSelected = selectedIds.has(c.id);
|
||||
const statusKey = `workbench.status.${c.status ?? "active"}`;
|
||||
return `
|
||||
<button class="wb-row ${isActive ? "active" : ""} ${isSelected ? "selected" : ""}"
|
||||
data-claim-row="${c.id}" data-status="${escapeHtml(c.status ?? "active")}">
|
||||
<span class="wb-row-main">
|
||||
<strong>${escapeHtml(c.subject ?? "")}</strong>
|
||||
<em class="predicate">${escapeHtml(c.predicate ?? "")}</em>
|
||||
<span>${escapeHtml(c.object ?? "")}</span>
|
||||
</span>
|
||||
<span class="wb-row-meta">
|
||||
<span class="wb-status" data-status="${escapeHtml(c.status ?? "active")}">${escapeHtml(t(statusKey, c.status ?? ""))}</span>
|
||||
<span class="wb-confidence">${Math.round((c.confidence ?? 0) * 100)}%</span>
|
||||
</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDetail() {
|
||||
const detail = $("wbDetail");
|
||||
const c = viewClaims.find((x) => x.id === activeId);
|
||||
if (!c) {
|
||||
detail.innerHTML = `<div class="wb-empty muted">${escapeHtml(t("workbench.select_hint"))}</div>`;
|
||||
return;
|
||||
}
|
||||
const statusKey = `workbench.status.${c.status ?? "active"}`;
|
||||
const breakdown = c.confidence_breakdown
|
||||
? `LLM ${pct(c.confidence_breakdown.llm_confidence)} · evidence ${pct(c.confidence_breakdown.evidence_confidence)} · ontology ${pct(c.confidence_breakdown.ontology_confidence)} · stored ${pct(c.confidence_breakdown.stored_confidence ?? c.confidence)}`
|
||||
: "";
|
||||
detail.innerHTML = `
|
||||
<div class="wb-detail-head">
|
||||
<div>
|
||||
<h3>${escapeHtml(c.subject ?? "")} <span class="predicate">${escapeHtml(c.predicate ?? "")}</span> ${escapeHtml(c.object ?? "")}</h3>
|
||||
<div class="wb-detail-status">
|
||||
<span class="wb-status" data-status="${escapeHtml(c.status ?? "active")}">${escapeHtml(t(statusKey, c.status ?? ""))}</span>
|
||||
<span class="wb-confidence">${Math.round((c.confidence ?? 0) * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wb-actions">
|
||||
<button class="wb-success" data-action="accept">${escapeHtml(t("workbench.action.accept"))} <kbd>A</kbd></button>
|
||||
<button class="wb-danger" data-action="reject">${escapeHtml(t("workbench.action.reject"))} <kbd>R</kbd></button>
|
||||
<button data-action="unreview">${escapeHtml(t("workbench.action.unreview"))}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="wb-kv">
|
||||
<dt>${escapeHtml(t("workbench.detail.subject"))}</dt><dd>${escapeHtml(c.subject ?? "")} <span class="muted">(${escapeHtml(c.subject_type ?? "")})</span></dd>
|
||||
<dt>${escapeHtml(t("workbench.detail.predicate"))}</dt><dd>${escapeHtml(c.predicate ?? "")}</dd>
|
||||
<dt>${escapeHtml(t("workbench.detail.object"))}</dt><dd>${escapeHtml(c.object ?? JSON.stringify(c.object_value ?? ""))}</dd>
|
||||
<dt>${escapeHtml(t("workbench.detail.source"))}</dt><dd>${escapeHtml(c.source ?? "")}</dd>
|
||||
<dt>${escapeHtml(t("workbench.detail.page"))}</dt><dd>${c.page_url ? `<a href="${escapeHtml(c.page_url)}" target="_blank" rel="noreferrer">${escapeHtml(c.page_url)}</a>` : "-"}</dd>
|
||||
${breakdown ? `<dt>${escapeHtml(t("workbench.detail.breakdown"))}</dt><dd>${escapeHtml(breakdown)}</dd>` : ""}
|
||||
</dl>
|
||||
|
||||
${c.evidence_text ? `
|
||||
<section class="wb-evidence">
|
||||
<h4>${escapeHtml(t("workbench.detail.evidence"))}</h4>
|
||||
<pre>${highlightEvidence(c.evidence_text, c)}</pre>
|
||||
</section>
|
||||
` : ""}
|
||||
|
||||
<div class="wb-confidence-edit">
|
||||
<label>${escapeHtml(t("workbench.detail.confidence"))}
|
||||
<input id="wbConfidence" type="number" min="0" max="1" step="0.01" value="${c.confidence ?? 0}" />
|
||||
</label>
|
||||
<input id="wbReason" data-i18n-placeholder="workbench.detail.reason_placeholder" value="${escapeHtml(c.confidence_reason ?? "")}" />
|
||||
<button data-action="save-confidence">${escapeHtml(t("workbench.action.save_confidence"))}</button>
|
||||
<button data-action="accept-similar">${escapeHtml(t("workbench.action.accept_similar"))}</button>
|
||||
</div>
|
||||
`;
|
||||
applyI18n(detail);
|
||||
|
||||
detail.querySelectorAll("[data-action]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => handleAction(btn.dataset.action));
|
||||
});
|
||||
}
|
||||
|
||||
function highlightEvidence(text, claim) {
|
||||
const tokens = [];
|
||||
if (claim.subject) tokens.push(claim.subject);
|
||||
if (claim.object) tokens.push(claim.object);
|
||||
let html = escapeHtml(text);
|
||||
for (const tok of tokens) {
|
||||
if (!tok || tok.length < 2) continue;
|
||||
const safe = escapeRegex(tok);
|
||||
html = html.replace(new RegExp(safe, "gi"), (m) => `<mark>${m}</mark>`);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function escapeRegex(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function renderBulkBar() {
|
||||
const count = selectedIds.size;
|
||||
$("wbSelectedCount").textContent = count > 1 ? t("workbench.bulk_count").replace("{count}", count) : "";
|
||||
const hasMulti = count > 1;
|
||||
$("wbBulkAcceptBtn").disabled = !hasMulti;
|
||||
$("wbBulkRejectBtn").disabled = !hasMulti;
|
||||
}
|
||||
|
||||
function pct(value) {
|
||||
if (value == null) return "-";
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
function toggleSelect(id) {
|
||||
if (selectedIds.has(id)) selectedIds.delete(id);
|
||||
else selectedIds.add(id);
|
||||
if (selectedIds.size === 1) activeId = [...selectedIds][0];
|
||||
}
|
||||
|
||||
function scrollActiveIntoView() {
|
||||
const row = document.querySelector(`[data-claim-row="${activeId}"]`);
|
||||
if (row) row.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
async function handleAction(action) {
|
||||
const c = viewClaims.find((x) => x.id === activeId);
|
||||
if (!c) return;
|
||||
if (action === "accept") {
|
||||
await applySingleStatus(c.id, "validated_claim");
|
||||
} else if (action === "reject") {
|
||||
await applySingleStatus(c.id, "rejected");
|
||||
} else if (action === "unreview") {
|
||||
await applySingleStatus(c.id, "active");
|
||||
} else if (action === "save-confidence") {
|
||||
await saveConfidence(c);
|
||||
} else if (action === "accept-similar") {
|
||||
await acceptSimilar(c);
|
||||
}
|
||||
}
|
||||
|
||||
async function applySingleStatus(claimId, status) {
|
||||
const reason = $("wbReason")?.value?.trim() || null;
|
||||
try {
|
||||
await api(`/claims/${claimId}/status`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ status, reason }),
|
||||
});
|
||||
toast(t("workbench.toast.reviewed"));
|
||||
await loadWorkbench();
|
||||
} catch (error) {
|
||||
toast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfidence(c) {
|
||||
const confidence = Number($("wbConfidence").value);
|
||||
const reason = $("wbReason").value.trim() || null;
|
||||
try {
|
||||
await api(`/claims/${c.id}/confidence`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ confidence, reason }),
|
||||
});
|
||||
toast(t("toast.claim_updated"));
|
||||
await loadWorkbench();
|
||||
} catch (error) {
|
||||
toast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptSimilar(c) {
|
||||
const objectKey = c.object ?? JSON.stringify(c.object_value ?? "");
|
||||
const ids = allClaims
|
||||
.filter((x) => x.predicate === c.predicate && (x.object ?? JSON.stringify(x.object_value ?? "")) === objectKey)
|
||||
.map((x) => x.id);
|
||||
if (!ids.length) return;
|
||||
try {
|
||||
const result = await api("/claims/bulk-status", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ claim_ids: ids, status: "validated_claim", reason: $("wbReason")?.value?.trim() || null }),
|
||||
});
|
||||
toast(t("workbench.toast.bulk_done").replace("{count}", result.updated ?? ids.length));
|
||||
await loadWorkbench();
|
||||
} catch (error) {
|
||||
toast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkReview(status) {
|
||||
const ids = [...selectedIds];
|
||||
if (ids.length < 2) return;
|
||||
try {
|
||||
const result = await api("/claims/bulk-status", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ claim_ids: ids, status, reason: null }),
|
||||
});
|
||||
toast(t("workbench.toast.bulk_done").replace("{count}", result.updated ?? ids.length));
|
||||
selectedIds.clear();
|
||||
await loadWorkbench();
|
||||
} catch (error) {
|
||||
toast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function attachKeyboard() {
|
||||
if (keyHandler) document.removeEventListener("keydown", keyHandler);
|
||||
keyHandler = (event) => {
|
||||
if (!claimsTabActive()) return;
|
||||
if (event.target.matches("input, textarea, select")) return;
|
||||
const key = event.key;
|
||||
if (key === "j" || key === "ArrowDown") { event.preventDefault(); moveActive(1); return; }
|
||||
if (key === "k" || key === "ArrowUp") { event.preventDefault(); moveActive(-1); return; }
|
||||
if (key === " ") { event.preventDefault(); if (activeId != null) { toggleSelect(activeId); render(); } return; }
|
||||
if (key === "Escape") { selectedIds.clear(); render(); return; }
|
||||
if (key === "/") { event.preventDefault(); $("wbSearch")?.focus(); return; }
|
||||
if (key === "a" && !event.shiftKey) { event.preventDefault(); handleAction("accept"); return; }
|
||||
if (key === "r" && !event.shiftKey) { event.preventDefault(); handleAction("reject"); return; }
|
||||
if (event.shiftKey && (key === "A" || key === "a")) { event.preventDefault(); bulkReview("validated_claim"); return; }
|
||||
if (event.shiftKey && (key === "R" || key === "r")) { event.preventDefault(); bulkReview("rejected"); return; }
|
||||
};
|
||||
document.addEventListener("keydown", keyHandler);
|
||||
}
|
||||
|
||||
function claimsTabActive() {
|
||||
const tab = $("claims");
|
||||
return tab && tab.classList.contains("active");
|
||||
}
|
||||
|
||||
function moveActive(direction) {
|
||||
if (!viewClaims.length) return;
|
||||
const idx = viewClaims.findIndex((c) => c.id === activeId);
|
||||
let next = idx + direction;
|
||||
if (next < 0) next = 0;
|
||||
if (next >= viewClaims.length) next = viewClaims.length - 1;
|
||||
activeId = viewClaims[next].id;
|
||||
selectedIds.clear();
|
||||
selectedIds.add(activeId);
|
||||
render();
|
||||
}
|
||||
495
ontology_platform/web/frontend/src/legacy/workspace.js
Normal file
495
ontology_platform/web/frontend/src/legacy/workspace.js
Normal file
@@ -0,0 +1,495 @@
|
||||
import { api } from "./api.js";
|
||||
import { state } from "./state.js";
|
||||
import { $, escapeHtml, chip, table, toast, csv } from "./utils.js";
|
||||
import { t, applyI18n, onLangChange } from "./i18n.js";
|
||||
import { requestBase } from "./sidebar.js";
|
||||
import { refreshInspector } from "./inspector.js";
|
||||
import { graphTabHtml, mountGraph, loadGraph } from "./graph.js";
|
||||
import { workbenchHtml, mountWorkbench, loadWorkbench } from "./workbench.js";
|
||||
import { pipelineHtml, mountPipeline, loadPipeline, renderPipelineSourceTable } from "./pipeline.js";
|
||||
|
||||
let host = null;
|
||||
|
||||
export function mountWorkspace(target) {
|
||||
host = target;
|
||||
host.innerHTML = workspaceHtml();
|
||||
applyI18n(host);
|
||||
|
||||
document.querySelectorAll(".tab").forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
document.querySelectorAll(".tab").forEach((n) => n.classList.remove("active"));
|
||||
document.querySelectorAll(".tab-panel").forEach((n) => n.classList.remove("active"));
|
||||
tab.classList.add("active");
|
||||
document.getElementById(tab.dataset.tab).classList.add("active");
|
||||
});
|
||||
});
|
||||
|
||||
mountGraph();
|
||||
mountWorkbench();
|
||||
mountPipeline();
|
||||
$("loadOntologyRegistryBtn").addEventListener("click", loadOntologyRegistry);
|
||||
$("loadEntitiesBtn").addEventListener("click", loadEntities);
|
||||
$("mergeEntitiesBtn").addEventListener("click", mergeEntities);
|
||||
$("loadExtractionLogsBtn").addEventListener("click", loadExtractionLogs);
|
||||
$("runResearchBtn").addEventListener("click", runResearch);
|
||||
$("loadResearchBtn").addEventListener("click", loadResearchSessions);
|
||||
$("loadGraphQueryBtn").addEventListener("click", loadGraphQuery);
|
||||
$("loadTagsBtn").addEventListener("click", loadTags);
|
||||
$("recommendBtn").addEventListener("click", recommend);
|
||||
|
||||
onLangChange(() => {
|
||||
applyI18n(host);
|
||||
renderOverview();
|
||||
renderOntology();
|
||||
loadPipeline().catch(() => {});
|
||||
loadOntologyRegistry().catch(() => {});
|
||||
loadEntities().catch(() => {});
|
||||
loadWorkbench().catch(() => {});
|
||||
loadExtractionLogs().catch(() => {});
|
||||
loadResearchSessions().catch(() => {});
|
||||
loadGraphQuery().catch(() => {});
|
||||
loadTags().catch(() => {});
|
||||
loadGraph().catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
function workspaceHtml() {
|
||||
return `
|
||||
<nav class="tabs" aria-label="Admin sections">
|
||||
<button class="tab active" data-tab="overview" data-i18n="tabs.overview"></button>
|
||||
<button class="tab" data-tab="graph" data-i18n="tabs.graph"></button>
|
||||
<button class="tab" data-tab="ontology" data-i18n="tabs.ontology"></button>
|
||||
<button class="tab" data-tab="entities" data-i18n="tabs.entities"></button>
|
||||
<button class="tab" data-tab="claims" data-i18n="tabs.claims"></button>
|
||||
<button class="tab" data-tab="debug" data-i18n="tabs.debug"></button>
|
||||
<button class="tab" data-tab="research" data-i18n="tabs.research"></button>
|
||||
<button class="tab" data-tab="tags" data-i18n="tabs.tags"></button>
|
||||
<button class="tab" data-tab="recommend" data-i18n="tabs.recommend"></button>
|
||||
</nav>
|
||||
|
||||
${graphTabHtml()}
|
||||
|
||||
${pipelineHtml()}
|
||||
|
||||
<section id="ontology" class="tab-panel">
|
||||
<div class="toolbar">
|
||||
<button id="loadOntologyRegistryBtn" data-i18n="ontology.refresh_registry"></button>
|
||||
</div>
|
||||
<div class="split">
|
||||
<div class="wide-panel">
|
||||
<h2 data-i18n="ontology.configured_entity_types"></h2>
|
||||
<div id="ontologyEntities" class="chips"></div>
|
||||
</div>
|
||||
<div class="wide-panel">
|
||||
<h2 data-i18n="ontology.configured_predicates"></h2>
|
||||
<div id="ontologyPredicates" class="chips"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="split">
|
||||
<div class="wide-panel">
|
||||
<h2 data-i18n="ontology.registry_entity_types"></h2>
|
||||
<div id="ontologyRegistryEntityTable" class="table"></div>
|
||||
</div>
|
||||
<div class="wide-panel">
|
||||
<h2 data-i18n="ontology.registry_relation_types"></h2>
|
||||
<div id="ontologyRegistryRelationTable" class="table"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wide-panel">
|
||||
<h2 data-i18n="ontology.triples"></h2>
|
||||
<div id="ontologyTripleTable" class="table"></div>
|
||||
</div>
|
||||
<div class="split">
|
||||
<div class="wide-panel">
|
||||
<h2 data-i18n="ontology.proposals"></h2>
|
||||
<div id="ontologyProposalTable" class="table"></div>
|
||||
</div>
|
||||
<div class="wide-panel">
|
||||
<h2 data-i18n="ontology.knowledge_gaps"></h2>
|
||||
<div id="knowledgeGapTable" class="table"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="entities" class="tab-panel">
|
||||
<div class="toolbar wrap">
|
||||
<select id="entityTypeFilter"></select>
|
||||
<button id="loadEntitiesBtn" data-i18n="entities.load"></button>
|
||||
</div>
|
||||
<div class="merge-bar">
|
||||
<input id="mergeSourceId" data-i18n-placeholder="entities.merge_source_placeholder" data-i18n-aria-label="entities.merge_source_placeholder" />
|
||||
<input id="mergeTargetId" data-i18n-placeholder="entities.merge_target_placeholder" data-i18n-aria-label="entities.merge_target_placeholder" />
|
||||
<button id="mergeEntitiesBtn" data-i18n="entities.merge"></button>
|
||||
</div>
|
||||
<div id="entityTable" class="table"></div>
|
||||
</section>
|
||||
|
||||
${workbenchHtml()}
|
||||
|
||||
<section id="debug" class="tab-panel">
|
||||
<div class="toolbar">
|
||||
<button id="loadExtractionLogsBtn" data-i18n="debug.refresh_logs"></button>
|
||||
</div>
|
||||
<div id="extractionLogTable" class="claim-list"></div>
|
||||
</section>
|
||||
|
||||
<section id="research" class="tab-panel">
|
||||
<div class="wide-panel">
|
||||
<h2 data-i18n="research.heading"></h2>
|
||||
<div class="recommend-grid">
|
||||
<label>
|
||||
<span data-i18n="research.goal"></span>
|
||||
<input id="researchGoal" value="Forment perfume ecosystem exploration" />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="research.seed_entity"></span>
|
||||
<input id="researchSeedEntityId" placeholder="optional entity id" />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="research.max_steps"></span>
|
||||
<input id="researchMaxSteps" type="number" min="1" max="50" value="8" />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="research.min_relevance"></span>
|
||||
<input id="researchMinRelevance" type="number" min="0" max="1" step="0.05" value="0.35" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="button-grid">
|
||||
<button id="runResearchBtn" class="primary" data-i18n="research.run"></button>
|
||||
<button id="loadResearchBtn" data-i18n="research.load_sessions"></button>
|
||||
</div>
|
||||
<div id="researchResult" class="mini-log"></div>
|
||||
<div id="researchSessions" class="claim-list"></div>
|
||||
</div>
|
||||
<div class="wide-panel">
|
||||
<h2 data-i18n="research.graph_query"></h2>
|
||||
<div class="toolbar wrap">
|
||||
<select id="graphQueryKind">
|
||||
<option value="trend_summary" data-i18n="research.query.trend_summary"></option>
|
||||
<option value="brand_products" data-i18n="research.query.brand_products"></option>
|
||||
<option value="products_by_tag" data-i18n="research.query.products_by_tag"></option>
|
||||
<option value="relation_summary" data-i18n="research.query.relation_summary"></option>
|
||||
<option value="entity_type_summary" data-i18n="research.query.entity_type_summary"></option>
|
||||
</select>
|
||||
<input id="graphQueryText" data-i18n-placeholder="research.filter_placeholder" />
|
||||
<button id="loadGraphQueryBtn" data-i18n="research.run_query"></button>
|
||||
</div>
|
||||
<div id="graphQueryTable" class="table"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="tags" class="tab-panel">
|
||||
<div class="toolbar">
|
||||
<button id="loadTagsBtn" data-i18n="tags.load"></button>
|
||||
</div>
|
||||
<div id="tagTable" class="table"></div>
|
||||
</section>
|
||||
|
||||
<section id="recommend" class="tab-panel">
|
||||
<div class="recommend-grid">
|
||||
<label><span data-i18n="recommend.preferred_notes"></span><input id="preferredNotes" value="Bergamot, Musk" /></label>
|
||||
<label><span data-i18n="recommend.avoided_notes"></span><input id="avoidedNotes" value="" /></label>
|
||||
<label><span data-i18n="recommend.preferred_moods"></span><input id="preferredMoods" value="Fresh" /></label>
|
||||
<label><span data-i18n="recommend.season"></span><input id="seasonContext" value="Summer" /></label>
|
||||
<label><span data-i18n="recommend.occasion"></span><input id="occasionContext" value="Daily" /></label>
|
||||
</div>
|
||||
<button id="recommendBtn" class="primary" data-i18n="recommend.test"></button>
|
||||
<div id="recommendTable" class="table"></div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderOverview() {
|
||||
renderPipelineSourceTable(state.projectDetail);
|
||||
}
|
||||
|
||||
export function renderOntology() {
|
||||
const entityTypes = state.ontology?.entity_types ?? [];
|
||||
const predicates = state.ontology?.predicates ?? [];
|
||||
$("ontologyEntities").innerHTML = entityTypes.map(chip).join("");
|
||||
$("ontologyPredicates").innerHTML = predicates.map(chip).join("");
|
||||
$("entityTypeFilter").innerHTML = `<option value="">${escapeHtml(t("entities.all_types"))}</option>${entityTypes
|
||||
.map((type) => `<option value="${escapeHtml(type)}">${escapeHtml(type)}</option>`)
|
||||
.join("")}`;
|
||||
}
|
||||
|
||||
export async function loadOntologyRegistry() {
|
||||
if (!state.selectedProject) return;
|
||||
const proj = encodeURIComponent(state.selectedProject);
|
||||
const [registry, proposals, gaps, triples] = await Promise.all([
|
||||
api(`/projects/${proj}/ontology/registry`),
|
||||
api(`/projects/${proj}/ontology/proposals?limit=50`),
|
||||
api(`/projects/${proj}/knowledge-gaps?limit=50`),
|
||||
api(`/projects/${proj}/ontology/triples?limit=50`),
|
||||
]);
|
||||
$("ontologyRegistryEntityTable").innerHTML = table(
|
||||
[t("table.type"), t("table.domain"), t("table.status"), t("table.confidence")],
|
||||
(registry.entity_types ?? []).map((row) => [
|
||||
row.name, row.domain, row.status, Math.round((row.confidence ?? 0) * 100) + "%",
|
||||
]),
|
||||
);
|
||||
$("ontologyRegistryRelationTable").innerHTML = table(
|
||||
[t("table.relation"), t("table.domain"), t("table.subject"), t("table.object"), t("table.status")],
|
||||
(registry.relation_types ?? []).map((row) => [
|
||||
row.name, row.domain,
|
||||
(row.allowed_subject_types ?? []).join(", "),
|
||||
(row.allowed_object_types ?? []).join(", ") || (row.semantic_constraints?.literal_value ? "literal" : ""),
|
||||
row.status,
|
||||
]),
|
||||
);
|
||||
$("ontologyProposalTable").innerHTML = table(
|
||||
[t("table.type"), t("table.name"), t("table.status"), t("table.reason")],
|
||||
(proposals ?? []).map((row) => [row.proposal_type, row.name, row.status, escapeHtml(row.reason ?? "")]),
|
||||
);
|
||||
$("knowledgeGapTable").innerHTML = table(
|
||||
[t("table.gap"), t("table.target"), t("table.priority"), t("table.description")],
|
||||
(gaps ?? []).map((row) => [
|
||||
row.gap_type,
|
||||
`${row.target_type ?? ""}: ${row.target_name ?? ""}`,
|
||||
Math.round((row.priority ?? 0) * 100) + "%",
|
||||
escapeHtml(row.description ?? ""),
|
||||
]),
|
||||
);
|
||||
$("ontologyTripleTable").innerHTML = table(
|
||||
[t("table.subject"), t("table.relation"), t("table.object"), t("table.status"), t("table.support")],
|
||||
(triples ?? []).map((row) => [
|
||||
row.subject ?? "",
|
||||
row.predicate,
|
||||
row.object ?? JSON.stringify(row.object_value ?? ""),
|
||||
row.status,
|
||||
row.support_count,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
let lastEntities = [];
|
||||
|
||||
export async function loadEntities() {
|
||||
if (!state.selectedProject) return;
|
||||
const type = $("entityTypeFilter").value;
|
||||
const query = type ? `?entity_type=${encodeURIComponent(type)}&limit=100` : "?limit=100";
|
||||
const entities = await api(`/projects/${encodeURIComponent(state.selectedProject)}/entities${query}`);
|
||||
lastEntities = entities;
|
||||
$("metricEntities").textContent = entities.length;
|
||||
$("entityTable").innerHTML = table(
|
||||
[t("table.id"), t("table.type"), t("table.name"), t("table.metadata")],
|
||||
entities.map((entity) => [
|
||||
`<button class="row-link" data-entity-id="${entity.id}">${escapeHtml(entity.id)}</button>`,
|
||||
entity.type,
|
||||
escapeHtml(entity.name),
|
||||
`<code>${escapeHtml(JSON.stringify(entity.metadata ?? {}))}</code>`,
|
||||
]),
|
||||
);
|
||||
$("entityTable").querySelectorAll("[data-entity-id]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const id = Number(btn.dataset.entityId);
|
||||
const e = lastEntities.find((x) => x.id === id);
|
||||
if (!e) return;
|
||||
state.selection = { kind: "entity", data: e };
|
||||
refreshInspector();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function mergeEntities() {
|
||||
if (!state.selectedProject) return;
|
||||
const sourceId = Number($("mergeSourceId").value);
|
||||
const targetId = Number($("mergeTargetId").value);
|
||||
if (!sourceId || !targetId || sourceId === targetId) {
|
||||
toast(t("toast.merge_check"));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await api("/entities/merge", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
project_name: state.selectedProject,
|
||||
source_entity_id: sourceId,
|
||||
target_entity_id: targetId,
|
||||
}),
|
||||
});
|
||||
if (!result.ok) {
|
||||
toast(result.error ?? t("toast.merge_check"));
|
||||
return;
|
||||
}
|
||||
toast(t("toast.entity_merged"));
|
||||
$("mergeSourceId").value = "";
|
||||
$("mergeTargetId").value = "";
|
||||
const event = new CustomEvent("workspace:refresh");
|
||||
window.dispatchEvent(event);
|
||||
} catch (error) {
|
||||
toast(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadExtractionLogs() {
|
||||
if (!state.selectedProject) return;
|
||||
const logs = await api(`/projects/${encodeURIComponent(state.selectedProject)}/extraction-logs?limit=50`);
|
||||
$("extractionLogTable").innerHTML = logs.map(renderExtractionLog).join("");
|
||||
}
|
||||
|
||||
function renderExtractionLog(log) {
|
||||
const validation = log.validation ?? {};
|
||||
const context = log.page_context ?? {};
|
||||
const rejected = validation.rejected_claims ?? [];
|
||||
const rejectedPreview = rejected
|
||||
.slice(0, 3)
|
||||
.map((item) => `${item.predicate ?? ""}: ${item.reason ?? ""}`)
|
||||
.join(" | ");
|
||||
return `
|
||||
<article class="claim-card">
|
||||
<div class="claim-main">
|
||||
<strong>${escapeHtml(log.extractor_name)}</strong>
|
||||
<span>${escapeHtml(log.provider)}</span>
|
||||
<span>${escapeHtml(validation.claim_status ?? "")}</span>
|
||||
<span>accepted ${validation.accepted_claim_count ?? 0}</span>
|
||||
<span>rejected ${validation.rejected_claim_count ?? 0}</span>
|
||||
</div>
|
||||
<div class="evidence">${escapeHtml(context.page_type ?? "")} / ${escapeHtml(context.crawl_status ?? "")} / ${escapeHtml(context.extraction_status ?? "")}</div>
|
||||
<div class="evidence">${escapeHtml(log.page_url ?? "")}</div>
|
||||
<div class="evidence">${escapeHtml(rejectedPreview)}</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
async function runResearch() {
|
||||
if (!state.selectedProject) return;
|
||||
$("researchResult").textContent = t("status.research_running");
|
||||
const seedEntityRaw = $("researchSeedEntityId").value.trim();
|
||||
try {
|
||||
const result = await api("/research/run", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
...requestBase(),
|
||||
project_name: state.selectedProject,
|
||||
seed_entity_id: seedEntityRaw ? Number(seedEntityRaw) : null,
|
||||
goal: $("researchGoal").value.trim() || "Semantic ontology exploration",
|
||||
max_depth: Number($("siteMaxDepth").value || 2),
|
||||
max_steps: Number($("researchMaxSteps").value || 8),
|
||||
max_branch: 8,
|
||||
min_relevance: Number($("researchMinRelevance").value || 0.35),
|
||||
same_domain_only: $("sameDomainOnly").checked,
|
||||
analyze_page_types: ["ProductPage", "BrandStoryPage", "ReviewPage"],
|
||||
}),
|
||||
});
|
||||
$("researchResult").textContent =
|
||||
`Research ${result.status}: explored ${result.explored_count}, analyzed ${result.analyzed_count}, queued ${result.queued_count}, skipped ${result.skipped_count}`;
|
||||
toast(t("toast.research_completed"));
|
||||
const event = new CustomEvent("workspace:refresh");
|
||||
window.dispatchEvent(event);
|
||||
} catch (error) {
|
||||
$("researchResult").textContent = `${t("toast.research_failed")}: ${error.message}`;
|
||||
toast(t("toast.research_failed"));
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadResearchSessions() {
|
||||
if (!state.selectedProject) return;
|
||||
const sessions = await api(`/projects/${encodeURIComponent(state.selectedProject)}/research/sessions?limit=20`);
|
||||
$("researchSessions").innerHTML = sessions.map(renderResearchSession).join("");
|
||||
}
|
||||
|
||||
function renderResearchSession(session) {
|
||||
const history = session.history ?? [];
|
||||
const latest = history.length ? history[history.length - 1] : null;
|
||||
const memory = session.memory ?? {};
|
||||
const conflicts = memory.conflicts ?? [];
|
||||
return `
|
||||
<article class="claim-card">
|
||||
<div class="claim-main">
|
||||
<strong>${escapeHtml(session.name ?? "Research session")}</strong>
|
||||
<span>${escapeHtml(session.status ?? "")}</span>
|
||||
<span>history ${history.length}</span>
|
||||
<span>queue ${(session.queue ?? []).length}</span>
|
||||
<span>conflicts ${conflicts.length}</span>
|
||||
</div>
|
||||
<div class="evidence">${escapeHtml(session.goal ?? "")}</div>
|
||||
<div class="evidence">seed ${escapeHtml(session.seed ?? "")}</div>
|
||||
<div class="evidence">${escapeHtml(latest?.outcome?.reason ?? latest?.outcome?.status ?? "")}</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
export async function loadGraphQuery() {
|
||||
if (!state.selectedProject) return;
|
||||
const kind = $("graphQueryKind")?.value ?? "trend_summary";
|
||||
const text = $("graphQueryText")?.value.trim() ?? "";
|
||||
let query = `kind=${encodeURIComponent(kind)}`;
|
||||
if (kind === "brand_products" && text) query += `&brand=${encodeURIComponent(text)}`;
|
||||
if (kind === "products_by_tag" && text) query += `&tag=${encodeURIComponent(text)}`;
|
||||
const rows = await api(`/projects/${encodeURIComponent(state.selectedProject)}/graph/query?${query}`);
|
||||
$("graphQueryTable").innerHTML = renderGraphRows(kind, rows);
|
||||
}
|
||||
|
||||
function renderGraphRows(kind, rows) {
|
||||
if (kind === "brand_products") {
|
||||
return table(
|
||||
[t("table.product"), t("table.type"), t("table.brand"), t("table.confidence")],
|
||||
rows.map((row) => [row.product, row.product_type, row.brand ?? "", Math.round(row.confidence * 100) + "%"]),
|
||||
);
|
||||
}
|
||||
if (kind === "products_by_tag") {
|
||||
return table(
|
||||
[t("table.product"), t("table.predicate"), t("table.tag"), t("table.confidence")],
|
||||
rows.map((row) => [row.product, row.predicate, row.tag, Math.round(row.confidence * 100) + "%"]),
|
||||
);
|
||||
}
|
||||
if (kind === "relation_summary") {
|
||||
return table(
|
||||
[t("table.predicate"), t("table.status"), t("table.support"), t("table.confidence")],
|
||||
rows.map((row) => [row.predicate, row.status, row.support_count, Math.round((row.max_confidence ?? 0) * 100) + "%"]),
|
||||
);
|
||||
}
|
||||
if (kind === "entity_type_summary") {
|
||||
return table(
|
||||
[t("table.type"), t("table.count")],
|
||||
rows.map((row) => [row.entity_type, row.count]),
|
||||
);
|
||||
}
|
||||
return table(
|
||||
[t("table.predicate"), t("table.name"), t("table.type"), t("table.support"), t("table.confidence")],
|
||||
rows.map((row) => [
|
||||
row.predicate, row.name, row.entity_type, row.support_count,
|
||||
Math.round((row.max_confidence ?? 0) * 100) + "%",
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadTags() {
|
||||
if (!state.selectedProject) return;
|
||||
const tags = await api(`/projects/${encodeURIComponent(state.selectedProject)}/recommendation-tags`);
|
||||
$("tagTable").innerHTML = table(
|
||||
[t("table.predicate"), t("table.type"), t("table.name"), t("table.support"), t("table.confidence")],
|
||||
tags.map((tag) => [
|
||||
tag.predicate, tag.type, tag.name, tag.support_count,
|
||||
Math.round((tag.max_confidence ?? 0) * 100) + "%",
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
async function recommend() {
|
||||
if (!state.selectedProject) return;
|
||||
try {
|
||||
const result = await api("/recommend", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
project_name: state.selectedProject,
|
||||
target_entity_type: state.projectDetail?.config?.recommendation?.target_entity_type ?? "Perfume",
|
||||
preferences: {
|
||||
preferred_notes: csv($("preferredNotes").value),
|
||||
avoided_notes: csv($("avoidedNotes").value),
|
||||
preferred_moods: csv($("preferredMoods").value),
|
||||
season_context: $("seasonContext").value.trim() || null,
|
||||
occasion_context: $("occasionContext").value.trim() || null,
|
||||
},
|
||||
limit: 10,
|
||||
}),
|
||||
});
|
||||
$("recommendTable").innerHTML = table(
|
||||
[t("table.name"), t("table.type"), t("table.score"), t("table.reasons")],
|
||||
result.map((item) => [item.name, item.entity_type, item.score, (item.reasons ?? []).join(", ")]),
|
||||
);
|
||||
} catch (error) {
|
||||
toast(error.message);
|
||||
}
|
||||
}
|
||||
116
ontology_platform/web/frontend/src/lib/api/claims.ts
Normal file
116
ontology_platform/web/frontend/src/lib/api/claims.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { z } from "zod";
|
||||
import { apiClient } from "./client";
|
||||
import { deleteResponseSchema } from "./entities";
|
||||
|
||||
const idLike = z.union([z.string(), z.number()]).transform(String);
|
||||
|
||||
export const claimSchema = z
|
||||
.object({
|
||||
id: idLike,
|
||||
subject: z.string().optional(),
|
||||
subject_type: z.string().optional(),
|
||||
predicate: z.string(),
|
||||
object: z.string().nullable().optional(),
|
||||
object_type: z.string().nullable().optional(),
|
||||
object_value: z.unknown().nullable().optional(),
|
||||
source: z.string().nullable().optional(),
|
||||
page_url: z.string().nullable().optional(),
|
||||
confidence: z.number().optional(),
|
||||
confidence_reason: z.string().nullable().optional(),
|
||||
status: z.string().optional(),
|
||||
evidence_text: z.string().nullable().optional(),
|
||||
evidence_summary: z.string().nullable().optional(),
|
||||
page_type: z.string().nullable().optional(),
|
||||
source_zone: z.string().nullable().optional(),
|
||||
source_selector: z.string().nullable().optional(),
|
||||
extraction_method: z.string().nullable().optional(),
|
||||
validation_status: z.string().nullable().optional(),
|
||||
graph_merge_status: z.string().nullable().optional(),
|
||||
graph_merge_reason: z.string().nullable().optional(),
|
||||
confidence_breakdown: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
review_required: z.boolean().nullable().optional(),
|
||||
review_reason: z.string().nullable().optional(),
|
||||
conflict_status: z.string().nullable().optional(),
|
||||
source_history: z.array(z.unknown()).optional(),
|
||||
last_seen_at: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const claimListSchema = z.array(claimSchema);
|
||||
|
||||
export const createClaimResponseSchema = z.object({
|
||||
id: idLike,
|
||||
subject_entity_id: z.union([z.string(), z.number()]).transform(String),
|
||||
predicate: z.string(),
|
||||
object_entity_id: z
|
||||
.union([z.string(), z.number(), z.null()])
|
||||
.nullable()
|
||||
.optional(),
|
||||
object_value: z.unknown().nullable().optional(),
|
||||
confidence: z.number(),
|
||||
status: z.string(),
|
||||
});
|
||||
|
||||
export type Claim = z.infer<typeof claimSchema>;
|
||||
|
||||
export interface CreateClaimRequest {
|
||||
source_name: string;
|
||||
subject_entity_id: number;
|
||||
predicate: string;
|
||||
object_entity_id?: number | null;
|
||||
object_value?: unknown;
|
||||
confidence?: number;
|
||||
confidence_reason?: string;
|
||||
evidence_text?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UpdateClaimStatusRequest {
|
||||
status: string;
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
export const updateClaimStatusResponseSchema = z.object({
|
||||
ok: z.boolean(),
|
||||
claim_id: idLike,
|
||||
status: z.string(),
|
||||
});
|
||||
|
||||
export const claimsApi = {
|
||||
list: (
|
||||
projectName: string,
|
||||
options: {
|
||||
limit?: number;
|
||||
includeCandidates?: boolean;
|
||||
status?: string;
|
||||
} = {},
|
||||
) =>
|
||||
apiClient.get(
|
||||
`/projects/${encodeURIComponent(projectName)}/claims`,
|
||||
claimListSchema,
|
||||
{
|
||||
query: {
|
||||
limit: options.limit ?? 100,
|
||||
include_candidates: options.includeCandidates,
|
||||
status: options.status,
|
||||
},
|
||||
},
|
||||
),
|
||||
create: (projectName: string, body: CreateClaimRequest) =>
|
||||
apiClient.post(
|
||||
`/projects/${encodeURIComponent(projectName)}/claims`,
|
||||
createClaimResponseSchema,
|
||||
body,
|
||||
),
|
||||
delete: (projectName: string, claimId: string | number) =>
|
||||
apiClient.delete(
|
||||
`/projects/${encodeURIComponent(projectName)}/claims/${claimId}`,
|
||||
deleteResponseSchema,
|
||||
),
|
||||
updateStatus: (claimId: string | number, body: UpdateClaimStatusRequest) =>
|
||||
apiClient.patch(
|
||||
`/claims/${encodeURIComponent(String(claimId))}/status`,
|
||||
updateClaimStatusResponseSchema,
|
||||
body,
|
||||
),
|
||||
};
|
||||
103
ontology_platform/web/frontend/src/lib/api/client.ts
Normal file
103
ontology_platform/web/frontend/src/lib/api/client.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public statusText: string,
|
||||
public body: unknown,
|
||||
public url: string,
|
||||
) {
|
||||
super(`${status} ${statusText} - ${url}`);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
interface RequestOptions extends Omit<RequestInit, "body"> {
|
||||
body?: unknown;
|
||||
query?: Record<string, string | number | boolean | undefined | null>;
|
||||
}
|
||||
|
||||
function buildUrl(path: string, query?: RequestOptions["query"]): string {
|
||||
if (!query) return path;
|
||||
const params = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v === undefined || v === null) continue;
|
||||
params.append(k, String(v));
|
||||
}
|
||||
const qs = params.toString();
|
||||
return qs ? `${path}?${qs}` : path;
|
||||
}
|
||||
|
||||
async function request<S extends z.ZodTypeAny>(
|
||||
path: string,
|
||||
schema: S,
|
||||
options: RequestOptions = {},
|
||||
): Promise<z.output<S>> {
|
||||
const { body, query, headers, ...rest } = options;
|
||||
const url = buildUrl(path, query);
|
||||
|
||||
const init: RequestInit = {
|
||||
...rest,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
|
||||
...headers,
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
};
|
||||
|
||||
const response = await fetch(url, init);
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
const raw =
|
||||
contentType.includes("application/json") && response.status !== 204
|
||||
? await response.json()
|
||||
: await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, response.statusText, raw, url);
|
||||
}
|
||||
|
||||
const parsed = schema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
throw new ApiError(
|
||||
response.status,
|
||||
"Response schema mismatch",
|
||||
{ issues: parsed.error.issues, raw },
|
||||
url,
|
||||
);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
get: <S extends z.ZodTypeAny>(
|
||||
path: string,
|
||||
schema: S,
|
||||
opts?: RequestOptions,
|
||||
) =>
|
||||
request(path, schema, { ...opts, method: "GET" }),
|
||||
post: <S extends z.ZodTypeAny>(
|
||||
path: string,
|
||||
schema: S,
|
||||
body?: unknown,
|
||||
opts?: RequestOptions,
|
||||
) => request(path, schema, { ...opts, method: "POST", body }),
|
||||
put: <S extends z.ZodTypeAny>(
|
||||
path: string,
|
||||
schema: S,
|
||||
body?: unknown,
|
||||
opts?: RequestOptions,
|
||||
) => request(path, schema, { ...opts, method: "PUT", body }),
|
||||
patch: <S extends z.ZodTypeAny>(
|
||||
path: string,
|
||||
schema: S,
|
||||
body?: unknown,
|
||||
opts?: RequestOptions,
|
||||
) => request(path, schema, { ...opts, method: "PATCH", body }),
|
||||
delete: <S extends z.ZodTypeAny>(
|
||||
path: string,
|
||||
schema: S,
|
||||
opts?: RequestOptions,
|
||||
) =>
|
||||
request(path, schema, { ...opts, method: "DELETE" }),
|
||||
};
|
||||
82
ontology_platform/web/frontend/src/lib/api/crawl.ts
Normal file
82
ontology_platform/web/frontend/src/lib/api/crawl.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { z } from "zod";
|
||||
import { apiClient } from "./client";
|
||||
|
||||
const stringFromAny = z.union([z.string(), z.number()]).transform(String);
|
||||
|
||||
export const crawlPageItemSchema = z
|
||||
.object({
|
||||
url: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
page_type: z.string().optional(),
|
||||
title: z.string().nullable().optional(),
|
||||
error: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const crawlProgressSchema = z
|
||||
.object({
|
||||
seed_url: z.string().optional(),
|
||||
visited_count: z.number().optional(),
|
||||
analyzed_count: z.number().optional(),
|
||||
queued_count: z.number().optional(),
|
||||
skipped_count: z.number().optional(),
|
||||
errors: z.array(z.string()).optional(),
|
||||
pages: z.array(crawlPageItemSchema).optional(),
|
||||
latest_page: crawlPageItemSchema.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const crawlJobSchema = z.object({
|
||||
job_id: stringFromAny,
|
||||
status: z.string(),
|
||||
url: z.string().nullable().optional(),
|
||||
error: z.string().nullable().optional(),
|
||||
scheduled_at: z.string().nullable().optional(),
|
||||
started_at: z.string().nullable().optional(),
|
||||
finished_at: z.string().nullable().optional(),
|
||||
progress: crawlProgressSchema.default({}),
|
||||
request: z.record(z.string(), z.unknown()).default({}),
|
||||
});
|
||||
|
||||
export type CrawlJob = z.infer<typeof crawlJobSchema>;
|
||||
export type CrawlProgress = z.infer<typeof crawlProgressSchema>;
|
||||
|
||||
export interface StartSiteCrawlRequest {
|
||||
project_name: string;
|
||||
source_name: string;
|
||||
url: string;
|
||||
max_depth?: number;
|
||||
max_pages?: number;
|
||||
same_domain_only?: boolean;
|
||||
analyze_page_types?: string[];
|
||||
extractor_provider?: string;
|
||||
extractor_model?: string | null;
|
||||
extractor_base_url?: string | null;
|
||||
check_robots_txt?: boolean;
|
||||
respect_robots_txt?: boolean | null;
|
||||
}
|
||||
|
||||
export const TERMINAL_CRAWL_STATUSES = new Set([
|
||||
"completed",
|
||||
"failed",
|
||||
"canceled",
|
||||
]);
|
||||
|
||||
export function isCrawlTerminal(status: string): boolean {
|
||||
return TERMINAL_CRAWL_STATUSES.has(status);
|
||||
}
|
||||
|
||||
export const crawlApi = {
|
||||
startByProject: (body: StartSiteCrawlRequest) =>
|
||||
apiClient.post("/crawl-site/by-project", crawlJobSchema, body),
|
||||
getJob: (jobId: string) =>
|
||||
apiClient.get(
|
||||
`/crawl-site/jobs/${encodeURIComponent(jobId)}`,
|
||||
crawlJobSchema,
|
||||
),
|
||||
cancel: (jobId: string) =>
|
||||
apiClient.post(
|
||||
`/crawl-site/jobs/${encodeURIComponent(jobId)}/cancel`,
|
||||
crawlJobSchema,
|
||||
),
|
||||
};
|
||||
17
ontology_platform/web/frontend/src/lib/api/domains.ts
Normal file
17
ontology_platform/web/frontend/src/lib/api/domains.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { z } from "zod";
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export const domainSummarySchema = z.object({
|
||||
domain: z.string(),
|
||||
entity_types: z.array(z.string()),
|
||||
predicates: z.array(z.string()),
|
||||
attribute_count: z.number().int(),
|
||||
});
|
||||
|
||||
export const domainListSchema = z.array(domainSummarySchema);
|
||||
|
||||
export type DomainSummary = z.infer<typeof domainSummarySchema>;
|
||||
|
||||
export const domainsApi = {
|
||||
list: () => apiClient.get("/domains", domainListSchema),
|
||||
};
|
||||
72
ontology_platform/web/frontend/src/lib/api/entities.ts
Normal file
72
ontology_platform/web/frontend/src/lib/api/entities.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { z } from "zod";
|
||||
import { apiClient } from "./client";
|
||||
|
||||
const idLike = z.union([z.string(), z.number()]).transform(String);
|
||||
|
||||
export const entitySchema = z.object({
|
||||
id: idLike,
|
||||
type: z.string(),
|
||||
name: z.string(),
|
||||
metadata: z.record(z.string(), z.unknown()).default({}),
|
||||
});
|
||||
|
||||
export const entityListSchema = z.array(entitySchema);
|
||||
|
||||
export const bulkCreateEntitiesResponseSchema = z.object({
|
||||
created: z.number(),
|
||||
entities: z.array(
|
||||
z.object({
|
||||
id: idLike,
|
||||
type: z.string(),
|
||||
name: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const deleteResponseSchema = z.object({
|
||||
ok: z.boolean(),
|
||||
deleted: z.union([z.string(), z.number()]),
|
||||
});
|
||||
|
||||
export type Entity = z.infer<typeof entitySchema>;
|
||||
|
||||
export interface CreateEntityRequest {
|
||||
entity_type: string;
|
||||
name: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface BulkCreateEntitiesRequest {
|
||||
entities: CreateEntityRequest[];
|
||||
}
|
||||
|
||||
export const entitiesApi = {
|
||||
list: (projectName: string, entityType?: string, limit = 200) =>
|
||||
apiClient.get(
|
||||
`/projects/${encodeURIComponent(projectName)}/entities`,
|
||||
entityListSchema,
|
||||
{
|
||||
query: {
|
||||
limit,
|
||||
...(entityType ? { entity_type: entityType } : {}),
|
||||
},
|
||||
},
|
||||
),
|
||||
create: (projectName: string, body: CreateEntityRequest) =>
|
||||
apiClient.post(
|
||||
`/projects/${encodeURIComponent(projectName)}/entities`,
|
||||
entitySchema,
|
||||
body,
|
||||
),
|
||||
bulkCreate: (projectName: string, body: BulkCreateEntitiesRequest) =>
|
||||
apiClient.post(
|
||||
`/projects/${encodeURIComponent(projectName)}/entities/bulk`,
|
||||
bulkCreateEntitiesResponseSchema,
|
||||
body,
|
||||
),
|
||||
delete: (projectName: string, entityId: string | number) =>
|
||||
apiClient.delete(
|
||||
`/projects/${encodeURIComponent(projectName)}/entities/${entityId}`,
|
||||
deleteResponseSchema,
|
||||
),
|
||||
};
|
||||
10
ontology_platform/web/frontend/src/lib/api/index.ts
Normal file
10
ontology_platform/web/frontend/src/lib/api/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { apiClient, ApiError } from "./client";
|
||||
export * from "./projects";
|
||||
export * from "./domains";
|
||||
export * from "./ontology";
|
||||
export * from "./sources";
|
||||
export * from "./crawl";
|
||||
export * from "./research";
|
||||
export * from "./entities";
|
||||
export * from "./claims";
|
||||
export * from "./platform";
|
||||
20
ontology_platform/web/frontend/src/lib/api/ontology.ts
Normal file
20
ontology_platform/web/frontend/src/lib/api/ontology.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod";
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export const ontologyDetailSchema = z.object({
|
||||
domain: z.string(),
|
||||
entity_types: z.array(z.string()),
|
||||
predicates: z.array(z.string()),
|
||||
attributes: z.array(z.string()),
|
||||
aliases: z.record(z.string(), z.string()).default({}),
|
||||
});
|
||||
|
||||
export type OntologyDetail = z.infer<typeof ontologyDetailSchema>;
|
||||
|
||||
export const ontologyApi = {
|
||||
get: (domain: string) =>
|
||||
apiClient.get(
|
||||
`/ontology/${encodeURIComponent(domain)}`,
|
||||
ontologyDetailSchema,
|
||||
),
|
||||
};
|
||||
356
ontology_platform/web/frontend/src/lib/api/platform.ts
Normal file
356
ontology_platform/web/frontend/src/lib/api/platform.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
import { z } from "zod";
|
||||
import { apiClient } from "./client";
|
||||
|
||||
const idLike = z.union([z.string(), z.number()]).transform(String);
|
||||
const recordSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
export const pipelineStageSchema = z
|
||||
.object({
|
||||
key: z.string(),
|
||||
count: z.number().default(0),
|
||||
extra: recordSchema.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const pipelineSummarySchema = z
|
||||
.object({
|
||||
stages: z.array(pipelineStageSchema).default([]),
|
||||
entity_types: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
count: z.number(),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
recent_pages: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: idLike,
|
||||
url: z.string(),
|
||||
title: z.string().nullable().optional(),
|
||||
status_code: z.number().nullable().optional(),
|
||||
page_type: z.string().nullable().optional(),
|
||||
fetched_at: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
)
|
||||
.default([]),
|
||||
recent_claims: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: idLike,
|
||||
subject: z.string().nullable().optional(),
|
||||
predicate: z.string(),
|
||||
confidence: z.number().optional(),
|
||||
status: z.string().optional(),
|
||||
last_seen_at: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
)
|
||||
.default([]),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const extractionLogSchema = z
|
||||
.object({
|
||||
id: idLike,
|
||||
page_url: z.string().nullable().optional(),
|
||||
extractor_name: z.string(),
|
||||
provider: z.string(),
|
||||
error: z.string().nullable().optional(),
|
||||
created_at: z.string(),
|
||||
validation: z.unknown().optional(),
|
||||
page_context: z.unknown().optional(),
|
||||
candidate_count: z.number().default(0),
|
||||
raw_output: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const ontologyRegistrySchema = z
|
||||
.object({
|
||||
entity_types: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: idLike,
|
||||
name: z.string(),
|
||||
domain: z.string().optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
status: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
confidence: z.number().optional(),
|
||||
metadata: recordSchema.optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
)
|
||||
.default([]),
|
||||
relation_types: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: idLike,
|
||||
name: z.string(),
|
||||
domain: z.string().optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
allowed_subject_types: z.array(z.string()).default([]),
|
||||
allowed_object_types: z.array(z.string()).default([]),
|
||||
allowed_page_types: z.array(z.string()).default([]),
|
||||
allowed_source_zones: z.array(z.string()).default([]),
|
||||
semantic_constraints: recordSchema.default({}),
|
||||
confidence_rules: recordSchema.default({}),
|
||||
status: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
confidence: z.number().optional(),
|
||||
metadata: recordSchema.optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
)
|
||||
.default([]),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const ontologyProposalSchema = z
|
||||
.object({
|
||||
id: idLike,
|
||||
proposal_type: z.string(),
|
||||
name: z.string(),
|
||||
reason: z.string().nullable().optional(),
|
||||
evidence: z.string().nullable().optional(),
|
||||
status: z.string(),
|
||||
confidence: z.number().optional(),
|
||||
metadata: recordSchema.optional(),
|
||||
updated_at: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const ontologyTripleSchema = z
|
||||
.object({
|
||||
id: idLike,
|
||||
claim_id: z.union([z.string(), z.number(), z.null()]).nullable().optional(),
|
||||
subject: z.string().nullable().optional(),
|
||||
subject_type: z.string().nullable().optional(),
|
||||
predicate: z.string(),
|
||||
object: z.string().nullable().optional(),
|
||||
object_type: z.string().nullable().optional(),
|
||||
object_value: z.unknown().nullable().optional(),
|
||||
value_type: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
confidence: z.number().optional(),
|
||||
support_count: z.number().optional(),
|
||||
metadata: recordSchema.optional(),
|
||||
last_seen_at: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const graphNodeSchema = z
|
||||
.object({
|
||||
id: idLike,
|
||||
name: z.string(),
|
||||
type: z.string().optional(),
|
||||
canonical_name: z.string().nullable().optional(),
|
||||
metadata: recordSchema.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const graphEdgeSchema = z
|
||||
.object({
|
||||
claim_id: idLike,
|
||||
source: idLike,
|
||||
target: z.union([z.string(), z.number(), z.null()]).nullable().optional(),
|
||||
target_value: z.unknown().nullable().optional(),
|
||||
predicate: z.string(),
|
||||
status: z.string().optional(),
|
||||
confidence: z.number().optional(),
|
||||
last_seen_at: z.string().nullable().optional(),
|
||||
metadata: recordSchema.optional(),
|
||||
object: z
|
||||
.object({
|
||||
id: idLike,
|
||||
name: z.string(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const graphNeighborhoodSchema = z
|
||||
.object({
|
||||
nodes: z.array(graphNodeSchema).default([]),
|
||||
edges: z.array(graphEdgeSchema).default([]),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const exportJsonSchema = z
|
||||
.object({
|
||||
project: z.string(),
|
||||
status: z.string(),
|
||||
count: z.number(),
|
||||
claims: z.array(z.record(z.string(), z.unknown())).default([]),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const rerunStageResponseSchema = z
|
||||
.object({
|
||||
ok: z.boolean().optional(),
|
||||
stage: z.string(),
|
||||
action: z.string(),
|
||||
route: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
job_id: idLike.optional(),
|
||||
status: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const schemaEntityTypeResponseSchema = z
|
||||
.object({
|
||||
id: idLike,
|
||||
name: z.string(),
|
||||
domain: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const schemaRelationTypeResponseSchema = z
|
||||
.object({
|
||||
id: idLike,
|
||||
name: z.string(),
|
||||
domain: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type PipelineSummary = z.infer<typeof pipelineSummarySchema>;
|
||||
export type ExtractionLog = z.infer<typeof extractionLogSchema>;
|
||||
export type OntologyRegistry = z.infer<typeof ontologyRegistrySchema>;
|
||||
export type OntologyProposal = z.infer<typeof ontologyProposalSchema>;
|
||||
export type OntologyTriple = z.infer<typeof ontologyTripleSchema>;
|
||||
export type GraphNeighborhood = z.infer<typeof graphNeighborhoodSchema>;
|
||||
export type GraphNode = z.infer<typeof graphNodeSchema>;
|
||||
export type GraphEdge = z.infer<typeof graphEdgeSchema>;
|
||||
export type ExportJson = z.infer<typeof exportJsonSchema>;
|
||||
export type RerunStageResponse = z.infer<typeof rerunStageResponseSchema>;
|
||||
|
||||
export interface CreateSchemaEntityTypeRequest {
|
||||
name: string;
|
||||
domain?: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateSchemaRelationTypeRequest {
|
||||
name: string;
|
||||
domain?: string;
|
||||
description?: string | null;
|
||||
allowed_subject_types?: string[];
|
||||
allowed_object_types?: string[];
|
||||
min_confidence?: number;
|
||||
}
|
||||
|
||||
export const platformApi = {
|
||||
pipeline: (projectName: string) =>
|
||||
apiClient.get(
|
||||
`/projects/${encodeURIComponent(projectName)}/pipeline`,
|
||||
pipelineSummarySchema,
|
||||
),
|
||||
extractionLogs: (projectName: string, limit = 50) =>
|
||||
apiClient.get(
|
||||
`/projects/${encodeURIComponent(projectName)}/extraction-logs`,
|
||||
z.array(extractionLogSchema),
|
||||
{ query: { limit } },
|
||||
),
|
||||
registry: (projectName: string) =>
|
||||
apiClient.get(
|
||||
`/projects/${encodeURIComponent(projectName)}/ontology/registry`,
|
||||
ontologyRegistrySchema,
|
||||
),
|
||||
proposals: (projectName: string, limit = 100) =>
|
||||
apiClient.get(
|
||||
`/projects/${encodeURIComponent(projectName)}/ontology/proposals`,
|
||||
z.array(ontologyProposalSchema),
|
||||
{ query: { limit } },
|
||||
),
|
||||
triples: (projectName: string, status?: string, limit = 200) =>
|
||||
apiClient.get(
|
||||
`/projects/${encodeURIComponent(projectName)}/ontology/triples`,
|
||||
z.array(ontologyTripleSchema),
|
||||
{ query: { status, limit } },
|
||||
),
|
||||
graphNeighborhood: (
|
||||
projectName: string,
|
||||
options: {
|
||||
entityId?: string | number | null;
|
||||
limit?: number;
|
||||
includeCandidates?: boolean;
|
||||
status?: string;
|
||||
} = {},
|
||||
) =>
|
||||
apiClient.get(
|
||||
`/projects/${encodeURIComponent(projectName)}/graph/neighborhood`,
|
||||
graphNeighborhoodSchema,
|
||||
{
|
||||
query: {
|
||||
entity_id: options.entityId,
|
||||
limit: options.limit ?? 200,
|
||||
include_candidates: options.includeCandidates,
|
||||
status: options.status,
|
||||
},
|
||||
},
|
||||
),
|
||||
exportJson: (
|
||||
projectName: string,
|
||||
options: { status?: string; includeEvidence?: boolean; limit?: number } = {},
|
||||
) =>
|
||||
apiClient.get(
|
||||
`/projects/${encodeURIComponent(projectName)}/export`,
|
||||
exportJsonSchema,
|
||||
{
|
||||
query: {
|
||||
format: "json",
|
||||
status: options.status ?? "validated_claim",
|
||||
include_evidence: options.includeEvidence ?? true,
|
||||
limit: options.limit ?? 1000,
|
||||
},
|
||||
},
|
||||
),
|
||||
exportUrl: (
|
||||
projectName: string,
|
||||
format: "json" | "csv" | "turtle",
|
||||
status = "validated_claim",
|
||||
includeEvidence = true,
|
||||
limit = 1000,
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
format,
|
||||
status,
|
||||
include_evidence: String(includeEvidence),
|
||||
limit: String(limit),
|
||||
});
|
||||
return `/projects/${encodeURIComponent(projectName)}/export?${params.toString()}`;
|
||||
},
|
||||
rerunStage: (projectName: string, stage: string) =>
|
||||
apiClient.post(
|
||||
`/projects/${encodeURIComponent(projectName)}/pipeline/rerun`,
|
||||
rerunStageResponseSchema,
|
||||
{ stage },
|
||||
),
|
||||
createSchemaEntityType: (
|
||||
projectName: string,
|
||||
body: CreateSchemaEntityTypeRequest,
|
||||
) =>
|
||||
apiClient.post(
|
||||
`/projects/${encodeURIComponent(projectName)}/schema/entity-types`,
|
||||
schemaEntityTypeResponseSchema,
|
||||
body,
|
||||
),
|
||||
createSchemaRelationType: (
|
||||
projectName: string,
|
||||
body: CreateSchemaRelationTypeRequest,
|
||||
) =>
|
||||
apiClient.post(
|
||||
`/projects/${encodeURIComponent(projectName)}/schema/relation-types`,
|
||||
schemaRelationTypeResponseSchema,
|
||||
body,
|
||||
),
|
||||
};
|
||||
80
ontology_platform/web/frontend/src/lib/api/projects.ts
Normal file
80
ontology_platform/web/frontend/src/lib/api/projects.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { z } from "zod";
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export const projectSummarySchema = z.object({
|
||||
id: z.union([z.string(), z.number()]).transform(String),
|
||||
name: z.string(),
|
||||
domain: z.string(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
});
|
||||
|
||||
export const projectListSchema = z.array(projectSummarySchema);
|
||||
|
||||
export const projectSourceSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]).transform(String),
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
base_url: z.string().nullable().optional(),
|
||||
trust_level: z.union([z.string(), z.number()]).nullable().optional(),
|
||||
respect_robots_txt: z.boolean().nullable().optional(),
|
||||
rate_limit_per_minute: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export const projectDetailSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]).transform(String),
|
||||
name: z.string(),
|
||||
domain: z.string(),
|
||||
config: z.unknown().optional(),
|
||||
sources: z.array(projectSourceSchema).default([]),
|
||||
});
|
||||
|
||||
export const createProjectResponseSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]).transform(String),
|
||||
name: z.string(),
|
||||
domain: z.string(),
|
||||
});
|
||||
|
||||
export type ProjectSummary = z.infer<typeof projectSummarySchema>;
|
||||
export type ProjectDetail = z.infer<typeof projectDetailSchema>;
|
||||
export type ProjectSource = z.infer<typeof projectSourceSchema>;
|
||||
|
||||
export interface CreateProjectRequest {
|
||||
config_path: string;
|
||||
}
|
||||
|
||||
export interface InlineSource {
|
||||
name: string;
|
||||
type?: string;
|
||||
trust_level?: number;
|
||||
base_url?: string | null;
|
||||
allowed_paths?: string[];
|
||||
parser?: string;
|
||||
fetcher?: string;
|
||||
rate_limit_per_minute?: number;
|
||||
respect_robots_txt?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateProjectInlineRequest {
|
||||
project_name: string;
|
||||
domain: string;
|
||||
target_entities?: string[];
|
||||
fields?: string[];
|
||||
sources?: InlineSource[];
|
||||
ontology?: Record<string, unknown>;
|
||||
recommendation?: Record<string, unknown>;
|
||||
update_policy?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const projectsApi = {
|
||||
list: () => apiClient.get("/projects", projectListSchema),
|
||||
detail: (projectName: string) =>
|
||||
apiClient.get(
|
||||
`/projects/${encodeURIComponent(projectName)}`,
|
||||
projectDetailSchema,
|
||||
),
|
||||
create: (body: CreateProjectRequest) =>
|
||||
apiClient.post("/projects", createProjectResponseSchema, body),
|
||||
createInline: (body: CreateProjectInlineRequest) =>
|
||||
apiClient.post("/projects/inline", createProjectResponseSchema, body),
|
||||
};
|
||||
86
ontology_platform/web/frontend/src/lib/api/research.ts
Normal file
86
ontology_platform/web/frontend/src/lib/api/research.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { z } from "zod";
|
||||
import { apiClient } from "./client";
|
||||
|
||||
const idLike = z.union([z.string(), z.number()]).transform(String);
|
||||
|
||||
/**
|
||||
* Research result and session payload are loose by design — the backend
|
||||
* returns `asdict(result)` of a Python dataclass we don't want to mirror
|
||||
* field-for-field. Keep core fields strict and let extras pass through.
|
||||
*/
|
||||
export const researchRunResultSchema = z
|
||||
.object({
|
||||
job_id: idLike.optional(),
|
||||
project_id: idLike.optional(),
|
||||
project_name: z.string().optional(),
|
||||
goal: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
started_at: z.string().nullable().optional(),
|
||||
finished_at: z.string().nullable().optional(),
|
||||
steps_taken: z.number().optional(),
|
||||
pages_visited: z.number().optional(),
|
||||
entities_found: z.number().optional(),
|
||||
claims_added: z.number().optional(),
|
||||
error: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type ResearchRunResult = z.infer<typeof researchRunResultSchema>;
|
||||
|
||||
export const researchSessionItemSchema = z
|
||||
.object({
|
||||
job_id: idLike,
|
||||
status: z.string().optional(),
|
||||
goal: z.string().nullable().optional(),
|
||||
seed_url: z.string().nullable().optional(),
|
||||
started_at: z.string().nullable().optional(),
|
||||
finished_at: z.string().nullable().optional(),
|
||||
steps_taken: z.number().nullable().optional(),
|
||||
pages_visited: z.number().nullable().optional(),
|
||||
entities_found: z.number().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const researchSessionListSchema = z.array(researchSessionItemSchema);
|
||||
export type ResearchSessionItem = z.infer<typeof researchSessionItemSchema>;
|
||||
|
||||
export const researchSessionDetailSchema = researchSessionItemSchema.extend({
|
||||
trace: z.array(z.record(z.string(), z.unknown())).optional(),
|
||||
});
|
||||
|
||||
export type ResearchSessionDetail = z.infer<typeof researchSessionDetailSchema>;
|
||||
|
||||
export interface StartResearchRequest {
|
||||
project_name: string;
|
||||
source_name: string;
|
||||
url?: string;
|
||||
seed_entity_id?: number | null;
|
||||
goal?: string;
|
||||
max_depth?: number;
|
||||
max_steps?: number;
|
||||
max_branch?: number;
|
||||
min_relevance?: number;
|
||||
same_domain_only?: boolean;
|
||||
analyze_page_types?: string[];
|
||||
extractor_provider?: string;
|
||||
extractor_model?: string | null;
|
||||
extractor_base_url?: string | null;
|
||||
check_robots_txt?: boolean;
|
||||
respect_robots_txt?: boolean | null;
|
||||
}
|
||||
|
||||
export const researchApi = {
|
||||
startByProject: (body: StartResearchRequest) =>
|
||||
apiClient.post("/research/run/by-project", researchRunResultSchema, body),
|
||||
listSessions: (projectName: string, limit = 25) =>
|
||||
apiClient.get(
|
||||
`/projects/${encodeURIComponent(projectName)}/research/sessions`,
|
||||
researchSessionListSchema,
|
||||
{ query: { limit } },
|
||||
),
|
||||
getSession: (jobId: string) =>
|
||||
apiClient.get(
|
||||
`/research/sessions/${encodeURIComponent(jobId)}`,
|
||||
researchSessionDetailSchema,
|
||||
),
|
||||
};
|
||||
45
ontology_platform/web/frontend/src/lib/api/sources.ts
Normal file
45
ontology_platform/web/frontend/src/lib/api/sources.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { z } from "zod";
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export const sourceSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]).transform(String),
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
base_url: z.string().nullable().optional(),
|
||||
trust_level: z.number().nullable().optional(),
|
||||
respect_robots_txt: z.boolean().nullable().optional(),
|
||||
rate_limit_per_minute: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export const deleteSourceResponseSchema = z.object({
|
||||
ok: z.boolean(),
|
||||
deleted: z.string(),
|
||||
});
|
||||
|
||||
export type Source = z.infer<typeof sourceSchema>;
|
||||
|
||||
export interface CreateSourceRequest {
|
||||
name: string;
|
||||
type?: string;
|
||||
trust_level?: number;
|
||||
base_url?: string | null;
|
||||
allowed_paths?: string[];
|
||||
parser?: string;
|
||||
fetcher?: string;
|
||||
rate_limit_per_minute?: number;
|
||||
respect_robots_txt?: boolean;
|
||||
}
|
||||
|
||||
export const sourcesApi = {
|
||||
create: (projectName: string, body: CreateSourceRequest) =>
|
||||
apiClient.post(
|
||||
`/projects/${encodeURIComponent(projectName)}/sources`,
|
||||
sourceSchema,
|
||||
body,
|
||||
),
|
||||
delete: (projectName: string, sourceName: string) =>
|
||||
apiClient.delete(
|
||||
`/projects/${encodeURIComponent(projectName)}/sources/${encodeURIComponent(sourceName)}`,
|
||||
deleteSourceResponseSchema,
|
||||
),
|
||||
};
|
||||
64
ontology_platform/web/frontend/src/lib/display.ts
Normal file
64
ontology_platform/web/frontend/src/lib/display.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export function humanizeValue(value: unknown, empty = "-"): string {
|
||||
if (value === null || value === undefined || value === "") return empty;
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const rendered = value
|
||||
.map((item) => humanizeValue(item, ""))
|
||||
.filter(Boolean);
|
||||
return rendered.length ? rendered.join(", ") : empty;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
const record = value as Record<string, unknown>;
|
||||
for (const key of [
|
||||
"label",
|
||||
"name",
|
||||
"title",
|
||||
"value",
|
||||
"text",
|
||||
"display",
|
||||
"canonical_name",
|
||||
]) {
|
||||
if (record[key] !== undefined && record[key] !== null) {
|
||||
return humanizeValue(record[key], empty);
|
||||
}
|
||||
}
|
||||
const compact = Object.entries(record)
|
||||
.filter(([, item]) => item !== undefined && item !== null && item !== "")
|
||||
.slice(0, 4)
|
||||
.map(([key, item]) => `${key}: ${humanizeValue(item, "")}`)
|
||||
.filter((item) => !item.endsWith(": "));
|
||||
return compact.length ? compact.join(" · ") : empty;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function formatPercent(value: unknown, empty = "-"): string {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) return empty;
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
export function formatDateTime(value: unknown, empty = "-"): string {
|
||||
if (typeof value !== "string" || !value) return empty;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
export function reviewLabel(status: string | undefined | null): string {
|
||||
switch ((status ?? "").toLowerCase()) {
|
||||
case "validated_claim":
|
||||
return "approved";
|
||||
case "rejected":
|
||||
return "rejected";
|
||||
case "active":
|
||||
case "candidate_claim":
|
||||
case "rule_candidate":
|
||||
return "candidate";
|
||||
default:
|
||||
return status || "candidate";
|
||||
}
|
||||
}
|
||||
|
||||
14
ontology_platform/web/frontend/src/lib/queryClient.ts
Normal file
14
ontology_platform/web/frontend/src/lib/queryClient.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime)
|
||||
retry: 1,
|
||||
},
|
||||
mutations: {
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
6
ontology_platform/web/frontend/src/lib/utils.ts
Normal file
6
ontology_platform/web/frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
24
ontology_platform/web/frontend/src/main.tsx
Normal file
24
ontology_platform/web/frontend/src/main.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { Provider } from "react-redux";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Toaster } from "sonner";
|
||||
import "./i18n";
|
||||
import App from "./App";
|
||||
import store from "./stores";
|
||||
import { queryClient } from "./lib/queryClient";
|
||||
import "./styles/globals.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter basename="/static/">
|
||||
<App />
|
||||
<Toaster position="top-right" />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
465
ontology_platform/web/frontend/src/pages/BuildPipelinePage.tsx
Normal file
465
ontology_platform/web/frontend/src/pages/BuildPipelinePage.tsx
Normal file
@@ -0,0 +1,465 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Database,
|
||||
ExternalLink,
|
||||
FileSearch,
|
||||
Layers3,
|
||||
ListChecks,
|
||||
PlayCircle,
|
||||
RotateCw,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge, BadgeProps } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useProject } from "@/hooks/useProjects";
|
||||
import { usePipeline, useRerunPipelineStage } from "@/hooks/usePlatform";
|
||||
import { formatDateTime, formatPercent } from "@/lib/display";
|
||||
|
||||
interface PipelineStep {
|
||||
key: string;
|
||||
name: string;
|
||||
description: string;
|
||||
count: number;
|
||||
target: number;
|
||||
status: "done" | "running" | "waiting" | "issue";
|
||||
extra?: string;
|
||||
}
|
||||
|
||||
const navigableStages = new Set(["human-review", "ontology-commit", "export"]);
|
||||
|
||||
function numberFrom(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function statusVariant(status: PipelineStep["status"]): BadgeProps["variant"] {
|
||||
switch (status) {
|
||||
case "done":
|
||||
return "success";
|
||||
case "running":
|
||||
return "default";
|
||||
case "issue":
|
||||
return "warning";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
}
|
||||
|
||||
function actionLabel(step: PipelineStep): string {
|
||||
if (step.key === "human-review") return "Open review";
|
||||
if (step.key === "ontology-commit") return "Open graph";
|
||||
if (step.key === "export") return "Open export";
|
||||
return "Rerun";
|
||||
}
|
||||
|
||||
export default function BuildPipelinePage() {
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const projectName = projectId ?? "";
|
||||
const { data: project } = useProject(projectName);
|
||||
const pipeline = usePipeline(projectName);
|
||||
const rerunStage = useRerunPipelineStage(projectName);
|
||||
const [notice, setNotice] = useState<{
|
||||
tone: "success" | "error" | "info";
|
||||
text: string;
|
||||
} | null>(null);
|
||||
|
||||
const steps = useMemo<PipelineStep[]>(() => {
|
||||
const stages = pipeline.data?.stages ?? [];
|
||||
const byKey = new Map(stages.map((stage) => [stage.key, stage]));
|
||||
const crawled = numberFrom(byKey.get("crawled")?.count);
|
||||
const extracted = numberFrom(byKey.get("extracted")?.count);
|
||||
const extractionEvents = numberFrom(byKey.get("extracted")?.extra?.events);
|
||||
const extractionErrors = numberFrom(byKey.get("extracted")?.extra?.errors);
|
||||
const claimStatuses = byKey.get("claims")?.extra ?? {};
|
||||
const claims = numberFrom(byKey.get("claims")?.count);
|
||||
const validated = numberFrom(byKey.get("validated")?.count);
|
||||
const rejected = numberFrom(claimStatuses.rejected);
|
||||
const candidates =
|
||||
numberFrom(claimStatuses.active) +
|
||||
numberFrom(claimStatuses.rule_candidate) +
|
||||
numberFrom(claimStatuses.candidate_claim);
|
||||
const graph = numberFrom(byKey.get("graph")?.count);
|
||||
const entities = numberFrom(byKey.get("graph")?.extra?.entities);
|
||||
const pageTyped =
|
||||
pipeline.data?.recent_pages.filter((page) => page.page_type).length ?? 0;
|
||||
|
||||
return [
|
||||
{
|
||||
key: "source-crawl",
|
||||
name: "Source Crawl",
|
||||
description: "Fetch source HTML, metadata, and in-domain links.",
|
||||
count: crawled,
|
||||
target: Math.max(crawled, 1),
|
||||
status: crawled > 0 ? "done" : "waiting",
|
||||
},
|
||||
{
|
||||
key: "page-clean",
|
||||
name: "Page Clean",
|
||||
description: "Normalize page text and remove repeated UI noise.",
|
||||
count: crawled,
|
||||
target: Math.max(crawled, 1),
|
||||
status: crawled > 0 ? "done" : "waiting",
|
||||
},
|
||||
{
|
||||
key: "page-classification",
|
||||
name: "Page Classification",
|
||||
description: "Classify product, brand, review, and supporting pages.",
|
||||
count: pageTyped,
|
||||
target: Math.max(crawled, 1),
|
||||
status: pageTyped > 0 ? "done" : crawled > 0 ? "running" : "waiting",
|
||||
},
|
||||
{
|
||||
key: "entity-extraction",
|
||||
name: "Entity Extraction",
|
||||
description: "Extract candidate entities from typed pages.",
|
||||
count: entities,
|
||||
target: Math.max(entities, 1),
|
||||
status: entities > 0 ? "done" : extracted > 0 ? "running" : "waiting",
|
||||
extra: `${extractionEvents} extraction events`,
|
||||
},
|
||||
{
|
||||
key: "claim-generation",
|
||||
name: "Claim Generation",
|
||||
description: "Turn entities and page evidence into relation claims.",
|
||||
count: claims,
|
||||
target: Math.max(claims, 1),
|
||||
status: claims > 0 ? "done" : entities > 0 ? "running" : "waiting",
|
||||
},
|
||||
{
|
||||
key: "deduplication",
|
||||
name: "Deduplication",
|
||||
description: "Collapse repeated entities and duplicate relation claims.",
|
||||
count: Math.max(claims - candidates, 0),
|
||||
target: Math.max(claims, 1),
|
||||
status: claims > 0 ? "done" : "waiting",
|
||||
},
|
||||
{
|
||||
key: "validation",
|
||||
name: "Validation",
|
||||
description: "Check schema fit, evidence quality, and conflicts.",
|
||||
count: Math.max(claims - candidates, 0),
|
||||
target: Math.max(claims, 1),
|
||||
status: extractionErrors > 0 ? "issue" : claims > 0 ? "done" : "waiting",
|
||||
extra: extractionErrors > 0 ? `${extractionErrors} errors` : undefined,
|
||||
},
|
||||
{
|
||||
key: "human-review",
|
||||
name: "Human Review",
|
||||
description: "Approve, reject, or send uncertain claims back for cleanup.",
|
||||
count: validated + rejected,
|
||||
target: Math.max(claims, 1),
|
||||
status:
|
||||
validated + rejected > 0
|
||||
? "done"
|
||||
: claims > 0
|
||||
? "running"
|
||||
: "waiting",
|
||||
extra: `${validated} approved / ${rejected} rejected`,
|
||||
},
|
||||
{
|
||||
key: "ontology-commit",
|
||||
name: "Ontology Commit",
|
||||
description: "Project accepted claims into the graph triple store.",
|
||||
count: graph,
|
||||
target: Math.max(validated, 1),
|
||||
status: graph > 0 ? "done" : validated > 0 ? "running" : "waiting",
|
||||
},
|
||||
{
|
||||
key: "export",
|
||||
name: "Export",
|
||||
description: "Prepare JSON, CSV, Turtle, and API access for downstream use.",
|
||||
count: graph,
|
||||
target: Math.max(graph, 1),
|
||||
status: graph > 0 ? "done" : "waiting",
|
||||
},
|
||||
];
|
||||
}, [pipeline.data]);
|
||||
|
||||
const completed = steps.filter((step) => step.status === "done").length;
|
||||
const overall = steps.length ? (completed / steps.length) * 100 : 0;
|
||||
|
||||
async function handleStageAction(step: PipelineStep) {
|
||||
setNotice(null);
|
||||
try {
|
||||
const response = await rerunStage.mutateAsync(step.key);
|
||||
if (response.action === "navigate" && response.route) {
|
||||
navigate(response.route);
|
||||
return;
|
||||
}
|
||||
setNotice({
|
||||
tone: "success",
|
||||
text:
|
||||
response.job_id != null
|
||||
? `Started job #${response.job_id} for ${step.name}.`
|
||||
: response.message ?? `${step.name} action completed.`,
|
||||
});
|
||||
pipeline.refetch();
|
||||
} catch (error) {
|
||||
setNotice({
|
||||
tone: "error",
|
||||
text: error instanceof Error ? error.message : "Stage action failed.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-6 py-8">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/crawl/${projectName}`)}
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Layers3 className="h-6 w-6 text-primary" />
|
||||
Build Pipeline
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project?.name ?? projectName} pipeline status and recovery actions
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => pipeline.refetch()}>
|
||||
<RotateCw className="h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{notice && (
|
||||
<Card
|
||||
className={
|
||||
notice.tone === "error"
|
||||
? "mb-6 border-destructive"
|
||||
: "mb-6 border-green-200"
|
||||
}
|
||||
>
|
||||
<CardContent className="flex items-center gap-2 py-4 text-sm">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{notice.text}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{pipeline.isError && (
|
||||
<Card className="mb-6 border-destructive">
|
||||
<CardContent className="flex items-center gap-2 py-4 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{(pipeline.error as Error).message}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-4">
|
||||
<MetricCard
|
||||
icon={Database}
|
||||
label="Collected pages"
|
||||
value={pipeline.data?.stages.find((s) => s.key === "crawled")?.count}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={FileSearch}
|
||||
label="Extraction pages"
|
||||
value={pipeline.data?.stages.find((s) => s.key === "extracted")?.count}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={ListChecks}
|
||||
label="Claims"
|
||||
value={pipeline.data?.stages.find((s) => s.key === "claims")?.count}
|
||||
/>
|
||||
<MetricCard
|
||||
icon={CheckCircle2}
|
||||
label="Overall"
|
||||
value={formatPercent(overall / 100)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Ontology Build Flow</CardTitle>
|
||||
<CardDescription>
|
||||
The ten major build stages, current counts, and recovery entry points.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
{completed}/{steps.length} complete
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Progress value={overall} className="mb-5" />
|
||||
{pipeline.isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-20" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{steps.map((step, index) => {
|
||||
const progress =
|
||||
step.target > 0
|
||||
? Math.min(100, (step.count / step.target) * 100)
|
||||
: 0;
|
||||
const isNavigable = navigableStages.has(step.key);
|
||||
return (
|
||||
<article
|
||||
key={step.key}
|
||||
className="rounded-md border bg-background p-4"
|
||||
>
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Step {index + 1}
|
||||
</div>
|
||||
<h3 className="font-semibold">{step.name}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={statusVariant(step.status)}>
|
||||
{step.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Progress value={progress} className="h-2" />
|
||||
<span className="w-16 text-right text-xs text-muted-foreground">
|
||||
{Math.round(progress)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<div>
|
||||
<span>{step.count.toLocaleString()} items</span>
|
||||
{step.extra && <span> / {step.extra}</span>}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isNavigable ? "outline" : "secondary"}
|
||||
disabled={!projectName || rerunStage.isPending}
|
||||
onClick={() => handleStageAction(step)}
|
||||
>
|
||||
{isNavigable ? (
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<RotateCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{rerunStage.isPending
|
||||
? "Working"
|
||||
: actionLabel(step)}
|
||||
</Button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Pages</CardTitle>
|
||||
<CardDescription>Most recently collected and typed pages.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="divide-y">
|
||||
{(pipeline.data?.recent_pages ?? []).map((page) => (
|
||||
<li key={page.id} className="py-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{page.page_type ?? "unknown"}</Badge>
|
||||
<span className="truncate font-medium">
|
||||
{page.title || page.url}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs text-muted-foreground">
|
||||
{page.url}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{formatDateTime(page.fetched_at)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{pipeline.data?.recent_pages.length === 0 && (
|
||||
<li className="py-6 text-center text-sm text-muted-foreground">
|
||||
No collected pages yet.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Claims</CardTitle>
|
||||
<CardDescription>Latest relation claims from the build flow.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="divide-y">
|
||||
{(pipeline.data?.recent_claims ?? []).map((claim) => (
|
||||
<li key={claim.id} className="py-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<PlayCircle className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="truncate font-medium">
|
||||
{claim.subject || "-"}
|
||||
</span>
|
||||
<Badge variant="secondary">{claim.predicate}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span>{formatPercent(claim.confidence)}</span>
|
||||
<span>{claim.status}</span>
|
||||
<span>{formatDateTime(claim.last_seen_at)}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{pipeline.data?.recent_claims.length === 0 && (
|
||||
<li className="py-6 text-center text-sm text-muted-foreground">
|
||||
No claims have been generated yet.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
value: number | string | undefined;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between gap-4 py-4">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-2xl font-semibold">
|
||||
{typeof value === "number" ? value.toLocaleString() : value ?? "-"}
|
||||
</div>
|
||||
</div>
|
||||
<Icon className="h-5 w-5 text-muted-foreground" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useProject } from "@/hooks/useProjects";
|
||||
import { useCreateSource, useDeleteSource } from "@/hooks/useSources";
|
||||
|
||||
const SOURCE_TYPES = [
|
||||
"official",
|
||||
"review",
|
||||
"blog",
|
||||
"news",
|
||||
"community",
|
||||
"unknown",
|
||||
] as const;
|
||||
|
||||
const sourceSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, "최소 2자 이상")
|
||||
.max(64, "최대 64자")
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, "영문/숫자/_/-만 허용"),
|
||||
type: z.enum(SOURCE_TYPES),
|
||||
base_url: z
|
||||
.string()
|
||||
.url("올바른 URL 형식이 아닙니다")
|
||||
.or(z.literal(""))
|
||||
.optional(),
|
||||
trust_level: z
|
||||
.number({ invalid_type_error: "0~1 사이의 숫자" })
|
||||
.min(0)
|
||||
.max(1),
|
||||
rate_limit_per_minute: z
|
||||
.number({ invalid_type_error: "양의 정수" })
|
||||
.int()
|
||||
.min(1)
|
||||
.max(600),
|
||||
respect_robots_txt: z.boolean(),
|
||||
});
|
||||
|
||||
type SourceFormValues = z.infer<typeof sourceSchema>;
|
||||
|
||||
export default function ConfigureSourcesPage() {
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const { t } = useTranslation();
|
||||
const projectName = projectId ?? "";
|
||||
|
||||
const {
|
||||
data: project,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useProject(projectName);
|
||||
|
||||
const createSource = useCreateSource(projectName);
|
||||
const deleteSource = useDeleteSource(projectName);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<SourceFormValues>({
|
||||
resolver: zodResolver(sourceSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
type: "official",
|
||||
base_url: "",
|
||||
trust_level: 0.7,
|
||||
rate_limit_per_minute: 30,
|
||||
respect_robots_txt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const onAddSource = async (values: SourceFormValues) => {
|
||||
try {
|
||||
await createSource.mutateAsync({
|
||||
...values,
|
||||
base_url: values.base_url || null,
|
||||
});
|
||||
toast.success(
|
||||
t("sources.added", "소스가 추가되었습니다: {{name}}", {
|
||||
name: values.name,
|
||||
}),
|
||||
);
|
||||
reset();
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
t("sources.addFailed", "추가 실패: {{msg}}", {
|
||||
msg: (e as Error).message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async (sourceName: string) => {
|
||||
if (
|
||||
!confirm(
|
||||
t("sources.confirmDelete", "정말 '{{name}}' 소스를 삭제하시겠습니까?", {
|
||||
name: sourceName,
|
||||
}),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteSource.mutateAsync(sourceName);
|
||||
toast.success(
|
||||
t("sources.deleted", "소스가 삭제되었습니다: {{name}}", {
|
||||
name: sourceName,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
t("sources.deleteFailed", "삭제 실패: {{msg}}", {
|
||||
msg: (e as Error).message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-6 py-10">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate("/")}
|
||||
aria-label={t("common.back", "이전")}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("sources.title", "참고 소스 설정")}
|
||||
</h1>
|
||||
{project && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.name}{" "}
|
||||
<span className="capitalize text-muted-foreground/70">
|
||||
· {project.domain}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => navigate(`/crawl/${projectName}`)}
|
||||
disabled={!project || (project.sources?.length ?? 0) === 0}
|
||||
>
|
||||
{t("sources.next", "크롤 진행")}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<Card className="mb-6 border-destructive">
|
||||
<CardContent className="flex items-center justify-between gap-3 py-4">
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>{(error as Error).message}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
{t("common.retry", "다시 시도")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
|
||||
<section>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("sources.listTitle", "등록된 소스")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"sources.listDesc",
|
||||
"프로젝트 온톨로지 구축에 사용할 참고 사이트 목록",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading && (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-16" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project && project.sources.length === 0 && (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"sources.empty",
|
||||
"아직 등록된 소스가 없습니다. 오른쪽 폼에서 추가하세요.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{project && project.sources.length > 0 && (
|
||||
<ul className="divide-y">
|
||||
{project.sources.map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className="flex items-center justify-between gap-4 py-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{s.name}</span>
|
||||
<span className="rounded bg-secondary px-1.5 py-0.5 text-xs text-secondary-foreground">
|
||||
{s.type}
|
||||
</span>
|
||||
{typeof s.trust_level === "number" && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("sources.trust", "신뢰도")}{" "}
|
||||
{s.trust_level.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{s.base_url && (
|
||||
<a
|
||||
href={s.base_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{s.base_url}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDelete(s.name)}
|
||||
disabled={deleteSource.isPending}
|
||||
aria-label={t("sources.delete", "삭제")}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<aside>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("sources.addTitle", "소스 추가")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("sources.addDesc", "참고할 사이트 정보를 입력하세요")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={handleSubmit(onAddSource)}
|
||||
className="space-y-4"
|
||||
noValidate
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="src_name">
|
||||
{t("sources.name", "이름")}
|
||||
</Label>
|
||||
<Input
|
||||
id="src_name"
|
||||
placeholder="official_brand_site"
|
||||
{...register("name")}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="src_type">
|
||||
{t("sources.type", "타입")}
|
||||
</Label>
|
||||
<select
|
||||
id="src_type"
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
{...register("type")}
|
||||
>
|
||||
{SOURCE_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="src_url">
|
||||
{t("sources.baseUrl", "Base URL")}
|
||||
</Label>
|
||||
<Input
|
||||
id="src_url"
|
||||
placeholder="https://example.com"
|
||||
type="url"
|
||||
{...register("base_url")}
|
||||
/>
|
||||
{errors.base_url && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.base_url.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="src_trust">
|
||||
{t("sources.trust", "신뢰도")}
|
||||
</Label>
|
||||
<Input
|
||||
id="src_trust"
|
||||
type="number"
|
||||
step="0.05"
|
||||
min={0}
|
||||
max={1}
|
||||
{...register("trust_level", { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.trust_level && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.trust_level.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="src_rate">
|
||||
{t("sources.rateLimit", "rate/분")}
|
||||
</Label>
|
||||
<Input
|
||||
id="src_rate"
|
||||
type="number"
|
||||
min={1}
|
||||
max={600}
|
||||
{...register("rate_limit_per_minute", {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
{errors.rate_limit_per_minute && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.rate_limit_per_minute.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-input"
|
||||
{...register("respect_robots_txt")}
|
||||
/>
|
||||
<span>
|
||||
{t("sources.respectRobots", "robots.txt 준수")}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isSubmitting || createSource.isPending}
|
||||
>
|
||||
{createSource.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
{t("sources.add", "소스 추가")}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
485
ontology_platform/web/frontend/src/pages/CrawlPage.tsx
Normal file
485
ontology_platform/web/frontend/src/pages/CrawlPage.tsx
Normal file
@@ -0,0 +1,485 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
Globe,
|
||||
Loader2,
|
||||
PlayCircle,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Badge, BadgeProps } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useProject } from "@/hooks/useProjects";
|
||||
import {
|
||||
useCancelCrawl,
|
||||
useCrawlJob,
|
||||
useStartSiteCrawl,
|
||||
} from "@/hooks/useCrawl";
|
||||
import { isCrawlTerminal } from "@/lib/api/crawl";
|
||||
|
||||
const startCrawlSchema = z.object({
|
||||
source_name: z.string().min(1, "소스를 선택하세요"),
|
||||
url: z.string().url("올바른 URL 형식이 아닙니다"),
|
||||
max_depth: z.number().int().min(0).max(10),
|
||||
max_pages: z.number().int().min(1).max(500),
|
||||
same_domain_only: z.boolean(),
|
||||
});
|
||||
|
||||
type StartCrawlFormValues = z.infer<typeof startCrawlSchema>;
|
||||
|
||||
function statusBadgeVariant(status: string): BadgeProps["variant"] {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "success";
|
||||
case "failed":
|
||||
return "destructive";
|
||||
case "canceled":
|
||||
return "secondary";
|
||||
case "cancel_requested":
|
||||
return "warning";
|
||||
case "running":
|
||||
case "pending":
|
||||
return "default";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
return <Badge variant={statusBadgeVariant(status)}>{status}</Badge>;
|
||||
}
|
||||
|
||||
export default function CrawlPage() {
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const { t } = useTranslation();
|
||||
const projectName = projectId ?? "";
|
||||
|
||||
const { data: project, isLoading, isError, error, refetch } = useProject(projectName);
|
||||
const [activeJobId, setActiveJobId] = useState<string | null>(null);
|
||||
const { data: job } = useCrawlJob(activeJobId);
|
||||
const startCrawl = useStartSiteCrawl();
|
||||
const cancelCrawl = useCancelCrawl();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<StartCrawlFormValues>({
|
||||
resolver: zodResolver(startCrawlSchema),
|
||||
defaultValues: {
|
||||
source_name: "",
|
||||
url: "",
|
||||
max_depth: 2,
|
||||
max_pages: 30,
|
||||
same_domain_only: true,
|
||||
},
|
||||
});
|
||||
|
||||
const onStart = async (values: StartCrawlFormValues) => {
|
||||
try {
|
||||
const created = await startCrawl.mutateAsync({
|
||||
project_name: projectName,
|
||||
...values,
|
||||
});
|
||||
setActiveJobId(created.job_id);
|
||||
toast.success(
|
||||
t("crawl.started", "크롤이 시작되었습니다 (job #{{id}})", {
|
||||
id: created.job_id,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
t("crawl.startFailed", "시작 실패: {{msg}}", {
|
||||
msg: (e as Error).message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onCancel = async () => {
|
||||
if (!activeJobId) return;
|
||||
try {
|
||||
await cancelCrawl.mutateAsync(activeJobId);
|
||||
toast.info(t("crawl.cancelRequested", "취소 요청됨"));
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
t("crawl.cancelFailed", "취소 실패: {{msg}}", {
|
||||
msg: (e as Error).message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const sources = project?.sources ?? [];
|
||||
const sourceName = watch("source_name");
|
||||
const selectedSource = sources.find((s) => s.name === sourceName);
|
||||
const progress = job?.progress;
|
||||
const visited = progress?.visited_count ?? 0;
|
||||
const queued = progress?.queued_count ?? 0;
|
||||
const analyzed = progress?.analyzed_count ?? 0;
|
||||
const errors_ = progress?.errors ?? [];
|
||||
const max_pages = watch("max_pages");
|
||||
const progressPct = job && max_pages
|
||||
? Math.min(100, (visited / max_pages) * 100)
|
||||
: 0;
|
||||
const terminal = job ? isCrawlTerminal(job.status) : false;
|
||||
const running = job && !terminal;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-10">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/sources/${projectName}`)}
|
||||
aria-label={t("common.back", "이전")}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("crawl.title", "시드 크롤")}
|
||||
</h1>
|
||||
{project && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.name}
|
||||
<span className="capitalize text-muted-foreground/70">
|
||||
{" "}
|
||||
· {project.domain}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{terminal && job?.status === "completed" && (
|
||||
<Button onClick={() => navigate(`/pipeline/${projectName}`)}>
|
||||
{t("crawl.review", "파이프라인 확인")}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<Card className="mb-6 border-destructive">
|
||||
<CardContent className="flex items-center justify-between gap-3 py-4">
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>{(error as Error).message}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
{t("common.retry", "다시 시도")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[420px_1fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("crawl.formTitle", "크롤 설정")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"crawl.formDesc",
|
||||
"시드 URL에서 시작해 링크를 따라가며 정보를 추출합니다",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={handleSubmit(onStart)}
|
||||
className="space-y-4"
|
||||
noValidate
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="source_name">
|
||||
{t("crawl.source", "참고 소스")}
|
||||
</Label>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-10" />
|
||||
) : (
|
||||
<Select
|
||||
id="source_name"
|
||||
{...register("source_name")}
|
||||
onChange={(e) => {
|
||||
const nextSourceName = e.target.value;
|
||||
const nextSource = sources.find(
|
||||
(source) => source.name === nextSourceName,
|
||||
);
|
||||
|
||||
setValue("source_name", nextSourceName, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
});
|
||||
setValue("url", nextSource?.base_url ?? "", {
|
||||
shouldValidate: Boolean(nextSource?.base_url),
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">
|
||||
{t("crawl.pickSource", "소스를 선택하세요...")}
|
||||
</option>
|
||||
{sources.map((s) => (
|
||||
<option key={s.id} value={s.name}>
|
||||
{s.name} ({s.type})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
{sources.length === 0 && !isLoading && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"crawl.noSources",
|
||||
"등록된 소스가 없습니다. 먼저 참고 소스를 추가하세요.",
|
||||
)}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="text-primary underline"
|
||||
onClick={() => navigate(`/sources/${projectName}`)}
|
||||
>
|
||||
{t("crawl.addSource", "소스 추가")}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{errors.source_name && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.source_name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="url">{t("crawl.seedUrl", "시드 URL")}</Label>
|
||||
<Input
|
||||
id="url"
|
||||
placeholder={
|
||||
selectedSource?.base_url ?? "https://example.com/start"
|
||||
}
|
||||
{...register("url")}
|
||||
/>
|
||||
{errors.url && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.url.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="max_depth">
|
||||
{t("crawl.maxDepth", "최대 깊이")}
|
||||
</Label>
|
||||
<Input
|
||||
id="max_depth"
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
{...register("max_depth", { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.max_depth && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.max_depth.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="max_pages">
|
||||
{t("crawl.maxPages", "최대 페이지")}
|
||||
</Label>
|
||||
<Input
|
||||
id="max_pages"
|
||||
type="number"
|
||||
min={1}
|
||||
max={500}
|
||||
{...register("max_pages", { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.max_pages && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.max_pages.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-input"
|
||||
{...register("same_domain_only")}
|
||||
/>
|
||||
<span>
|
||||
{t("crawl.sameDomainOnly", "동일 도메인만 따라가기")}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isSubmitting || startCrawl.isPending || Boolean(running)}
|
||||
>
|
||||
{startCrawl.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<PlayCircle className="h-4 w-4" />
|
||||
)}
|
||||
{t("crawl.start", "크롤 시작")}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("crawl.progressTitle", "진행 상태")}
|
||||
</CardTitle>
|
||||
{job && (
|
||||
<CardDescription className="flex items-center gap-2">
|
||||
<span>job #{job.job_id}</span>
|
||||
<StatusBadge status={job.status} />
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
{running && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
disabled={cancelCrawl.isPending}
|
||||
>
|
||||
<Ban className="h-4 w-4" />
|
||||
{t("crawl.cancel", "취소")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!job && (
|
||||
<div className="flex flex-col items-center gap-3 py-12 text-center text-muted-foreground">
|
||||
<Globe className="h-12 w-12 opacity-40" />
|
||||
<p>
|
||||
{t(
|
||||
"crawl.idleHint",
|
||||
"왼쪽에서 시드 URL을 입력하고 시작하세요",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t("crawl.visited", "방문")} {visited}
|
||||
{" / "}
|
||||
{max_pages}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("crawl.queued", "대기")} {queued} ·{" "}
|
||||
{t("crawl.analyzed", "분석")} {analyzed}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={progressPct} indeterminate={running && visited === 0} />
|
||||
</div>
|
||||
|
||||
{job.url && (
|
||||
<div className="rounded-md bg-secondary/30 px-3 py-2 text-xs">
|
||||
<div className="text-muted-foreground">
|
||||
{t("crawl.seedUrl", "시드 URL")}
|
||||
</div>
|
||||
<a
|
||||
href={job.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="break-all text-foreground hover:underline"
|
||||
>
|
||||
{job.url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress?.latest_page && (
|
||||
<div className="rounded-md border bg-background px-3 py-2 text-xs">
|
||||
<div className="mb-1 text-muted-foreground">
|
||||
{t("crawl.latestPage", "최근 페이지")}
|
||||
</div>
|
||||
<div className="truncate font-medium">
|
||||
{progress.latest_page.title || progress.latest_page.url}
|
||||
</div>
|
||||
{progress.latest_page.page_type && (
|
||||
<Badge variant="outline" className="mt-1">
|
||||
{progress.latest_page.page_type}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job.error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
<XCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<span>{job.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errors_.length > 0 && (
|
||||
<details className="rounded-md border bg-background">
|
||||
<summary className="cursor-pointer px-3 py-2 text-sm font-medium">
|
||||
{t("crawl.errorsCount", "에러 {{count}}건", {
|
||||
count: errors_.length,
|
||||
})}
|
||||
</summary>
|
||||
<ul className="max-h-40 overflow-y-auto px-4 py-2 text-xs text-muted-foreground">
|
||||
{errors_.map((e, i) => (
|
||||
<li key={i} className="border-b py-1 last:border-0">
|
||||
{e}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{terminal && job.status === "completed" && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-700">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
{t("crawl.doneHint", "크롤 완료. 구축 파이프라인으로 이동하세요.")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
ontology_platform/web/frontend/src/pages/DashboardPage.tsx
Normal file
180
ontology_platform/web/frontend/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, FolderOpen, AlertCircle, Clock } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useProjects } from "@/hooks/useProjects";
|
||||
import { usePipeline } from "@/hooks/usePlatform";
|
||||
import { formatPercent } from "@/lib/display";
|
||||
import { ProjectSummary } from "@/lib/api/projects";
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { data: projects, isLoading, isError, error, refetch } = useProjects();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-6 py-10">
|
||||
<div className="mb-8 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{t("dashboard.title", "Ontology Builder")}
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
{t(
|
||||
"dashboard.subtitle",
|
||||
"Build and manage domain ontologies with AI-powered extraction",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => navigate("/onboard")}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("dashboard.newProject", "New Project")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("dashboard.projects", "Projects")}
|
||||
</h2>
|
||||
{projects && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("dashboard.projectCount", "{{count}} total", {
|
||||
count: projects.length,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-32" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<Card className="border-destructive">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-destructive">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
{t("dashboard.loadFailed", "Failed to load projects")}
|
||||
</CardTitle>
|
||||
<CardDescription>{(error as Error).message}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" onClick={() => refetch()}>
|
||||
{t("common.retry", "Retry")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{projects && projects.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12 text-center">
|
||||
<FolderOpen className="h-12 w-12 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t("dashboard.empty.title", "No projects yet")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"dashboard.empty.hint",
|
||||
"Create your first project to start building an ontology",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => navigate("/onboard")}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("dashboard.newProject", "New Project")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{projects && projects.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((p) => (
|
||||
<ProjectCard key={p.id} project={p} onOpen={navigate} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectCard({
|
||||
project,
|
||||
onOpen,
|
||||
}: {
|
||||
project: ProjectSummary;
|
||||
onOpen: (path: string) => void;
|
||||
}) {
|
||||
const pipeline = usePipeline(project.name);
|
||||
const claimStage = pipeline.data?.stages.find((stage) => stage.key === "claims");
|
||||
const approvedStage = pipeline.data?.stages.find(
|
||||
(stage) => stage.key === "validated",
|
||||
);
|
||||
const pageStage = pipeline.data?.stages.find((stage) => stage.key === "crawled");
|
||||
const claimCount = claimStage?.count ?? 0;
|
||||
const approvedCount = approvedStage?.count ?? 0;
|
||||
const approvalRate = claimCount > 0 ? approvedCount / claimCount : 0;
|
||||
|
||||
return (
|
||||
<Card
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onOpen(`/sources/${project.name}`)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
onOpen(`/sources/${project.name}`);
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer transition-colors hover:bg-accent/40"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>{project.name}</CardTitle>
|
||||
<CardDescription>{project.domain}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs">
|
||||
<ProjectStat label="Pages" value={pageStage?.count ?? 0} />
|
||||
<ProjectStat label="Claims" value={claimCount} />
|
||||
<ProjectStat label="Approved" value={formatPercent(approvalRate)} />
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatRelative(project.updated_at)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectStat({ label, value }: { label: string; value: number | string }) {
|
||||
return (
|
||||
<div className="rounded-md border bg-background px-2 py-1">
|
||||
<div className="text-muted-foreground">{label}</div>
|
||||
<div className="font-semibold">
|
||||
{typeof value === "number" ? value.toLocaleString() : value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
402
ontology_platform/web/frontend/src/pages/ExportApiPage.tsx
Normal file
402
ontology_platform/web/frontend/src/pages/ExportApiPage.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Braces,
|
||||
Clipboard,
|
||||
Code2,
|
||||
FileJson,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useExportJson } from "@/hooks/usePlatform";
|
||||
import { useProject } from "@/hooks/useProjects";
|
||||
import { platformApi } from "@/lib/api/platform";
|
||||
import { formatDateTime, formatPercent, humanizeValue } from "@/lib/display";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const statusOptions = [
|
||||
{ value: "validated_claim", label: "Validated claims" },
|
||||
{ value: "active", label: "Active claims" },
|
||||
{ value: "candidate_claim", label: "Candidate claims" },
|
||||
{ value: "rule_candidate", label: "Rule candidates" },
|
||||
{ value: "rejected", label: "Rejected claims" },
|
||||
{ value: "all", label: "All statuses" },
|
||||
];
|
||||
|
||||
function valueFrom(row: Record<string, unknown>, key: string): string {
|
||||
return humanizeValue(row[key]);
|
||||
}
|
||||
|
||||
export default function ExportApiPage() {
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const projectName = projectId ?? "";
|
||||
const { data: project } = useProject(projectName);
|
||||
const [status, setStatus] = useState("validated_claim");
|
||||
const [includeEvidence, setIncludeEvidence] = useState(true);
|
||||
const [limit, setLimit] = useState(1000);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
const exportData = useExportJson(projectName, {
|
||||
status,
|
||||
includeEvidence,
|
||||
limit,
|
||||
});
|
||||
|
||||
const urls = useMemo(
|
||||
() => ({
|
||||
json: platformApi.exportUrl(
|
||||
projectName,
|
||||
"json",
|
||||
status,
|
||||
includeEvidence,
|
||||
limit,
|
||||
),
|
||||
csv: platformApi.exportUrl(
|
||||
projectName,
|
||||
"csv",
|
||||
status,
|
||||
includeEvidence,
|
||||
limit,
|
||||
),
|
||||
turtle: platformApi.exportUrl(
|
||||
projectName,
|
||||
"turtle",
|
||||
status,
|
||||
includeEvidence,
|
||||
limit,
|
||||
),
|
||||
}),
|
||||
[includeEvidence, limit, projectName, status],
|
||||
);
|
||||
const origin =
|
||||
typeof window !== "undefined" && window.location?.origin
|
||||
? window.location.origin
|
||||
: "";
|
||||
const absoluteJsonUrl = `${origin}${urls.json}`;
|
||||
const curlSnippet = `curl -L "${absoluteJsonUrl}"`;
|
||||
const fetchSnippet = `const response = await fetch("${urls.json}");\nconst ontology = await response.json();`;
|
||||
const previewRows = exportData.data?.claims.slice(0, 8) ?? [];
|
||||
const avgConfidence = exportData.data?.claims.length
|
||||
? exportData.data.claims.reduce((sum, row) => {
|
||||
const confidence = row.confidence;
|
||||
return sum + (typeof confidence === "number" ? confidence : 0);
|
||||
}, 0) / exportData.data.claims.length
|
||||
: 0;
|
||||
|
||||
async function copy(text: string, key: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(key);
|
||||
window.setTimeout(() => setCopied(null), 1600);
|
||||
} catch {
|
||||
setCopied("failed");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-6 py-8">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/graph/${projectName}`)}
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Code2 className="h-6 w-6 text-primary" />
|
||||
Export / API Center
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project?.name ?? projectName} exports, endpoint settings, and integration snippets.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => exportData.refetch()}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{exportData.isError && (
|
||||
<Card className="mb-6 border-destructive">
|
||||
<CardContent className="flex items-center gap-2 py-4 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{(exportData.error as Error).message}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="mb-6 grid gap-3 lg:grid-cols-[220px_180px_1fr_180px]">
|
||||
<Select value={status} onChange={(event) => setStatus(event.target.value)}>
|
||||
{statusOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={5000}
|
||||
value={limit}
|
||||
onChange={(event) =>
|
||||
setLimit(Math.max(1, Math.min(5000, Number(event.target.value) || 1)))
|
||||
}
|
||||
aria-label="Export row limit"
|
||||
/>
|
||||
<label className="flex items-center gap-2 rounded-md border px-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeEvidence}
|
||||
onChange={(event) => setIncludeEvidence(event.target.checked)}
|
||||
/>
|
||||
Include evidence text
|
||||
</label>
|
||||
<Badge variant="outline" className="justify-center py-2">
|
||||
{exportData.data?.count ?? 0} rows
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="text-xs text-muted-foreground">Export rows</div>
|
||||
<div className="mt-1 text-3xl font-semibold">
|
||||
{(exportData.data?.count ?? 0).toLocaleString()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="text-xs text-muted-foreground">Status scope</div>
|
||||
<div className="mt-1 text-lg font-semibold">
|
||||
{statusOptions.find((option) => option.value === status)?.label}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="text-xs text-muted-foreground">Avg confidence</div>
|
||||
<div className="mt-1 text-3xl font-semibold">
|
||||
{formatPercent(avgConfidence)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1fr_380px]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Export Preview</CardTitle>
|
||||
<CardDescription>
|
||||
First rows from the JSON export endpoint.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<DownloadLink href={urls.json} icon={FileJson} label="JSON" />
|
||||
<DownloadLink href={urls.csv} icon={FileSpreadsheet} label="CSV" />
|
||||
<DownloadLink href={urls.turtle} icon={FileText} label="Turtle" />
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{exportData.isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-16" />
|
||||
))}
|
||||
</div>
|
||||
) : previewRows.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">
|
||||
No export rows match the current settings.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="py-2 pr-3">Claim</th>
|
||||
<th className="py-2 pr-3">Subject</th>
|
||||
<th className="py-2 pr-3">Predicate</th>
|
||||
<th className="py-2 pr-3">Object</th>
|
||||
<th className="py-2 pr-3">Confidence</th>
|
||||
<th className="py-2 pr-3">Last seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{previewRows.map((row, index) => (
|
||||
<tr key={`${valueFrom(row, "claim_id")}-${index}`}>
|
||||
<td className="py-3 pr-3 font-medium">
|
||||
#{valueFrom(row, "claim_id")}
|
||||
</td>
|
||||
<td className="max-w-[180px] truncate py-3 pr-3">
|
||||
{valueFrom(row, "subject")}
|
||||
</td>
|
||||
<td className="py-3 pr-3">
|
||||
<Badge variant="secondary">
|
||||
{valueFrom(row, "predicate")}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="max-w-[220px] truncate py-3 pr-3">
|
||||
{valueFrom(row, "object")}
|
||||
</td>
|
||||
<td className="py-3 pr-3">
|
||||
{typeof row.confidence === "number"
|
||||
? formatPercent(row.confidence)
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="py-3 pr-3">
|
||||
{formatDateTime(
|
||||
typeof row.last_seen_at === "string"
|
||||
? row.last_seen_at
|
||||
: undefined,
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<aside className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Braces className="h-5 w-5" />
|
||||
API Endpoint
|
||||
</CardTitle>
|
||||
<CardDescription>Same settings as the preview.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<CodeBlock value={absoluteJsonUrl} />
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => copy(absoluteJsonUrl, "endpoint")}
|
||||
>
|
||||
<Clipboard className="h-4 w-4" />
|
||||
{copied === "endpoint" ? "Copied" : "Copy endpoint"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Integration Snippets</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Snippet
|
||||
title="cURL"
|
||||
value={curlSnippet}
|
||||
copied={copied === "curl"}
|
||||
onCopy={() => copy(curlSnippet, "curl")}
|
||||
/>
|
||||
<Snippet
|
||||
title="JavaScript"
|
||||
value={fetchSnippet}
|
||||
copied={copied === "fetch"}
|
||||
onCopy={() => copy(fetchSnippet, "fetch")}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Formats</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<FormatRow label="JSON" detail="API-first claim objects with evidence." />
|
||||
<FormatRow label="CSV" detail="Spreadsheet-friendly rows for review." />
|
||||
<FormatRow label="Turtle" detail="RDF triples for semantic graph tools." />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadLink({
|
||||
href,
|
||||
icon: Icon,
|
||||
label,
|
||||
}: {
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ value }: { value: string }) {
|
||||
return (
|
||||
<pre className="max-h-40 overflow-auto rounded-md bg-secondary/40 p-3 text-xs">
|
||||
<code>{value}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function Snippet({
|
||||
title,
|
||||
value,
|
||||
copied,
|
||||
onCopy,
|
||||
}: {
|
||||
title: string;
|
||||
value: string;
|
||||
copied: boolean;
|
||||
onCopy: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">{title}</span>
|
||||
<Button variant="ghost" size="sm" onClick={onCopy}>
|
||||
<Clipboard className="h-4 w-4" />
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
<CodeBlock value={value} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormatRow({ label, detail }: { label: string; detail: string }) {
|
||||
return (
|
||||
<div className="rounded-md border px-3 py-2">
|
||||
<div className="font-medium">{label}</div>
|
||||
<div className="text-xs text-muted-foreground">{detail}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
567
ontology_platform/web/frontend/src/pages/GraphViewPage.tsx
Normal file
567
ontology_platform/web/frontend/src/pages/GraphViewPage.tsx
Normal file
@@ -0,0 +1,567 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
CircleDot,
|
||||
GitBranch,
|
||||
Network,
|
||||
RefreshCw,
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useGraphNeighborhood } from "@/hooks/usePlatform";
|
||||
import { useProject } from "@/hooks/useProjects";
|
||||
import { GraphEdge, GraphNode } from "@/lib/api/platform";
|
||||
import { formatDateTime, formatPercent, humanizeValue } from "@/lib/display";
|
||||
|
||||
const COLORS = [
|
||||
"#2563eb",
|
||||
"#059669",
|
||||
"#d97706",
|
||||
"#7c3aed",
|
||||
"#db2777",
|
||||
"#0891b2",
|
||||
"#4b5563",
|
||||
"#dc2626",
|
||||
];
|
||||
|
||||
interface VisualNode {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
x: number;
|
||||
y: number;
|
||||
radius: number;
|
||||
color: string;
|
||||
source: "entity" | "literal";
|
||||
raw?: GraphNode;
|
||||
}
|
||||
|
||||
interface VisualEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
predicate: string;
|
||||
confidence: number;
|
||||
raw: GraphEdge;
|
||||
}
|
||||
|
||||
function nodeColor(type: string, types: string[]): string {
|
||||
const index = Math.max(types.indexOf(type), 0);
|
||||
return COLORS[index % COLORS.length];
|
||||
}
|
||||
|
||||
function buildGraph(
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
options: {
|
||||
search: string;
|
||||
predicate: string;
|
||||
entityType: string;
|
||||
minConfidence: number;
|
||||
},
|
||||
) {
|
||||
const search = options.search.trim().toLowerCase();
|
||||
const types = Array.from(
|
||||
new Set(nodes.map((node) => node.type || "Entity").concat("Literal")),
|
||||
).sort();
|
||||
const nodeMap = new Map<string, VisualNode>();
|
||||
nodes.forEach((node) => {
|
||||
const type = node.type || "Entity";
|
||||
nodeMap.set(String(node.id), {
|
||||
id: String(node.id),
|
||||
name: node.name,
|
||||
type,
|
||||
x: 0,
|
||||
y: 0,
|
||||
radius: 18,
|
||||
color: nodeColor(type, types),
|
||||
source: "entity",
|
||||
raw: node,
|
||||
});
|
||||
});
|
||||
|
||||
const visualEdges: VisualEdge[] = [];
|
||||
edges.forEach((edge) => {
|
||||
const confidence = edge.confidence ?? 0;
|
||||
if (confidence < options.minConfidence) return;
|
||||
if (options.predicate && edge.predicate !== options.predicate) return;
|
||||
const source = String(edge.source);
|
||||
const target =
|
||||
edge.target !== null && edge.target !== undefined
|
||||
? String(edge.target)
|
||||
: `literal-${edge.claim_id}`;
|
||||
if (!nodeMap.has(source)) return;
|
||||
if (!nodeMap.has(target)) {
|
||||
const label = humanizeValue(edge.target_value ?? edge.object?.name);
|
||||
nodeMap.set(target, {
|
||||
id: target,
|
||||
name: label || "(empty value)",
|
||||
type: "Literal",
|
||||
x: 0,
|
||||
y: 0,
|
||||
radius: 14,
|
||||
color: nodeColor("Literal", types),
|
||||
source: "literal",
|
||||
});
|
||||
}
|
||||
visualEdges.push({
|
||||
id: String(edge.claim_id),
|
||||
source,
|
||||
target,
|
||||
predicate: edge.predicate,
|
||||
confidence,
|
||||
raw: edge,
|
||||
});
|
||||
});
|
||||
|
||||
let visualNodes = Array.from(nodeMap.values()).filter((node) => {
|
||||
if (options.entityType && node.type !== options.entityType) return false;
|
||||
if (!search) return true;
|
||||
return (
|
||||
node.name.toLowerCase().includes(search) ||
|
||||
node.type.toLowerCase().includes(search)
|
||||
);
|
||||
});
|
||||
const visibleIds = new Set(visualNodes.map((node) => node.id));
|
||||
const filteredEdges = visualEdges.filter(
|
||||
(edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target),
|
||||
);
|
||||
const connectedIds = new Set<string>();
|
||||
filteredEdges.forEach((edge) => {
|
||||
connectedIds.add(edge.source);
|
||||
connectedIds.add(edge.target);
|
||||
});
|
||||
if (options.predicate || options.minConfidence > 0 || search) {
|
||||
visualNodes = visualNodes.filter((node) => connectedIds.has(node.id));
|
||||
}
|
||||
|
||||
const centerX = 430;
|
||||
const centerY = 300;
|
||||
const radius = Math.max(160, Math.min(260, visualNodes.length * 13));
|
||||
visualNodes.forEach((node, index) => {
|
||||
const angle = (Math.PI * 2 * index) / Math.max(visualNodes.length, 1);
|
||||
node.x = centerX + Math.cos(angle) * radius;
|
||||
node.y = centerY + Math.sin(angle) * radius;
|
||||
});
|
||||
return { nodes: visualNodes, edges: filteredEdges, types };
|
||||
}
|
||||
|
||||
function edgeEndpoint(edge: VisualEdge, nodes: VisualNode[]) {
|
||||
const source = nodes.find((node) => node.id === edge.source);
|
||||
const target = nodes.find((node) => node.id === edge.target);
|
||||
return { source, target };
|
||||
}
|
||||
|
||||
export default function GraphViewPage() {
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const projectName = projectId ?? "";
|
||||
const { data: project } = useProject(projectName);
|
||||
const [statusScope, setStatusScope] = useState("validated_claim");
|
||||
const [includeCandidates, setIncludeCandidates] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [predicate, setPredicate] = useState("");
|
||||
const [entityType, setEntityType] = useState("");
|
||||
const [minConfidence, setMinConfidence] = useState(0);
|
||||
const [selected, setSelected] = useState<VisualNode | VisualEdge | null>(null);
|
||||
const graph = useGraphNeighborhood(projectName, {
|
||||
includeCandidates: includeCandidates && statusScope === "validated_claim",
|
||||
limit: 300,
|
||||
status:
|
||||
includeCandidates && statusScope === "validated_claim"
|
||||
? undefined
|
||||
: statusScope,
|
||||
});
|
||||
|
||||
const predicates = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set((graph.data?.edges ?? []).map((edge) => edge.predicate)),
|
||||
).sort(),
|
||||
[graph.data?.edges],
|
||||
);
|
||||
const visual = useMemo(
|
||||
() =>
|
||||
buildGraph(graph.data?.nodes ?? [], graph.data?.edges ?? [], {
|
||||
search,
|
||||
predicate,
|
||||
entityType,
|
||||
minConfidence,
|
||||
}),
|
||||
[
|
||||
entityType,
|
||||
graph.data?.edges,
|
||||
graph.data?.nodes,
|
||||
minConfidence,
|
||||
predicate,
|
||||
search,
|
||||
],
|
||||
);
|
||||
const averageConfidence = visual.edges.length
|
||||
? visual.edges.reduce((sum, edge) => sum + edge.confidence, 0) /
|
||||
visual.edges.length
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-6 py-8">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/quality/${projectName}`)}
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Network className="h-6 w-6 text-primary" />
|
||||
Graph View
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project?.name ?? projectName} relation graph, candidates, and edge evidence.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => graph.refetch()}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{graph.isError && (
|
||||
<Card className="mb-6 border-destructive">
|
||||
<CardContent className="flex items-center gap-2 py-4 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{(graph.error as Error).message}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="mb-4 grid gap-3 xl:grid-cols-[1fr_180px_180px_180px_220px]">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="pl-9"
|
||||
placeholder="Search node or type"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={entityType}
|
||||
onChange={(event) => setEntityType(event.target.value)}
|
||||
>
|
||||
<option value="">All types</option>
|
||||
{visual.types.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
value={predicate}
|
||||
onChange={(event) => setPredicate(event.target.value)}
|
||||
>
|
||||
<option value="">All predicates</option>
|
||||
{predicates.map((name) => (
|
||||
<option key={name} value={name}>
|
||||
{name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Select
|
||||
value={statusScope}
|
||||
onChange={(event) => setStatusScope(event.target.value)}
|
||||
>
|
||||
<option value="validated_claim">Validated</option>
|
||||
<option value="candidate_claim">Candidate</option>
|
||||
<option value="rule_candidate">Rule candidate</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="all">All statuses</option>
|
||||
</Select>
|
||||
<label className="flex items-center gap-2 rounded-md border px-3 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeCandidates}
|
||||
disabled={statusScope !== "validated_claim"}
|
||||
onChange={(event) => setIncludeCandidates(event.target.checked)}
|
||||
/>
|
||||
Blend candidates
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 rounded-md border px-4 py-3">
|
||||
<div className="mb-2 flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<SlidersHorizontal className="h-4 w-4" />
|
||||
Minimum confidence
|
||||
</span>
|
||||
<span>{formatPercent(minConfidence)}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={minConfidence}
|
||||
onChange={(event) => setMinConfidence(Number(event.target.value))}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1fr_360px]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Ontology Network</CardTitle>
|
||||
<CardDescription>
|
||||
{visual.nodes.length} nodes / {visual.edges.length} edges shown
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{visual.types.slice(0, 8).map((type) => (
|
||||
<span key={type} className="flex items-center gap-1 text-xs">
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: nodeColor(type, visual.types) }}
|
||||
/>
|
||||
{type}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{graph.isLoading ? (
|
||||
<Skeleton className="h-[640px]" />
|
||||
) : visual.nodes.length === 0 ? (
|
||||
<div className="flex h-[500px] flex-col items-center justify-center gap-2 text-center text-sm text-muted-foreground">
|
||||
<GitBranch className="h-12 w-12 opacity-50" />
|
||||
No graph is available for the current filters.
|
||||
</div>
|
||||
) : (
|
||||
<svg
|
||||
viewBox="0 0 860 600"
|
||||
className="h-[640px] w-full rounded-md border bg-secondary/20"
|
||||
role="img"
|
||||
aria-label="Ontology graph"
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="arrow"
|
||||
markerWidth="10"
|
||||
markerHeight="10"
|
||||
refX="10"
|
||||
refY="3"
|
||||
orient="auto"
|
||||
markerUnits="strokeWidth"
|
||||
>
|
||||
<path d="M0,0 L0,6 L9,3 z" fill="#64748b" />
|
||||
</marker>
|
||||
</defs>
|
||||
{visual.edges.map((edge) => {
|
||||
const { source, target } = edgeEndpoint(edge, visual.nodes);
|
||||
if (!source || !target) return null;
|
||||
const midX = (source.x + target.x) / 2;
|
||||
const midY = (source.y + target.y) / 2;
|
||||
return (
|
||||
<g
|
||||
key={edge.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setSelected(edge)}
|
||||
>
|
||||
<line
|
||||
x1={source.x}
|
||||
y1={source.y}
|
||||
x2={target.x}
|
||||
y2={target.y}
|
||||
stroke="#64748b"
|
||||
strokeWidth={1 + edge.confidence * 3}
|
||||
strokeOpacity="0.65"
|
||||
markerEnd="url(#arrow)"
|
||||
/>
|
||||
<text
|
||||
x={midX}
|
||||
y={midY}
|
||||
textAnchor="middle"
|
||||
className="fill-slate-600 text-[10px]"
|
||||
>
|
||||
{edge.predicate}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{visual.nodes.map((node) => (
|
||||
<g
|
||||
key={node.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => setSelected(node)}
|
||||
>
|
||||
<circle
|
||||
cx={node.x}
|
||||
cy={node.y}
|
||||
r={node.radius}
|
||||
fill={node.color}
|
||||
stroke={
|
||||
selected && "id" in selected && selected.id === node.id
|
||||
? "#111827"
|
||||
: "#ffffff"
|
||||
}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<text
|
||||
x={node.x}
|
||||
y={node.y + node.radius + 14}
|
||||
textAnchor="middle"
|
||||
className="fill-slate-800 text-[11px] font-medium"
|
||||
>
|
||||
{node.name.length > 24
|
||||
? `${node.name.slice(0, 24)}...`
|
||||
: node.name}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<aside className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Selection</CardTitle>
|
||||
<CardDescription>Click a node or edge to inspect it.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!selected && (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
Nothing selected.
|
||||
</p>
|
||||
)}
|
||||
{selected && "predicate" in selected && (
|
||||
<div className="space-y-3">
|
||||
<Badge variant="secondary">{selected.predicate}</Badge>
|
||||
<Detail label="Claim" value={`#${selected.id}`} />
|
||||
<Detail label="Status" value={selected.raw.status} />
|
||||
<Detail
|
||||
label="Confidence"
|
||||
value={formatPercent(selected.confidence)}
|
||||
/>
|
||||
<Detail
|
||||
label="Object"
|
||||
value={selected.raw.object?.name ?? selected.raw.target_value}
|
||||
/>
|
||||
<Detail
|
||||
label="Last seen"
|
||||
value={formatDateTime(selected.raw.last_seen_at)}
|
||||
/>
|
||||
<Detail
|
||||
label="Metadata"
|
||||
value={humanizeValue(selected.raw.metadata)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{selected && !("predicate" in selected) && (
|
||||
<div className="space-y-3">
|
||||
<Badge variant="outline">{selected.type}</Badge>
|
||||
<Detail label="Name" value={selected.name} />
|
||||
<Detail label="Source" value={selected.source} />
|
||||
<Detail label="Canonical" value={selected.raw?.canonical_name} />
|
||||
<Detail
|
||||
label="Metadata"
|
||||
value={humanizeValue(selected.raw?.metadata)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Graph Health</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<HealthRow label="Visible nodes" value={visual.nodes.length} />
|
||||
<HealthRow label="Visible edges" value={visual.edges.length} />
|
||||
<HealthRow
|
||||
label="Avg confidence"
|
||||
value={
|
||||
visual.edges.length ? formatPercent(averageConfidence) : "-"
|
||||
}
|
||||
/>
|
||||
<Progress value={averageConfidence * 100} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CircleDot className="h-5 w-5" />
|
||||
Predicate Counts
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{predicates.map((name) => {
|
||||
const count = (graph.data?.edges ?? []).filter(
|
||||
(edge) => edge.predicate === name,
|
||||
).length;
|
||||
return (
|
||||
<li
|
||||
key={name}
|
||||
className="flex items-center justify-between rounded-md border px-3 py-2"
|
||||
>
|
||||
<span>{name}</span>
|
||||
<span className="font-medium">{count}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{predicates.length === 0 && (
|
||||
<li className="py-4 text-center text-muted-foreground">
|
||||
No predicates found.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Detail({ label, value }: { label: string; value: unknown }) {
|
||||
return (
|
||||
<div className="rounded-md border px-3 py-2 text-sm">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 break-words font-medium">{humanizeValue(value)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HealthRow({ label, value }: { label: string; value: number | string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-medium">
|
||||
{typeof value === "number" ? value.toLocaleString() : value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
237
ontology_platform/web/frontend/src/pages/OnboardingPage.tsx
Normal file
237
ontology_platform/web/frontend/src/pages/OnboardingPage.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { AlertCircle, ArrowLeft, Loader2 } from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useDomains } from "@/hooks/useDomains";
|
||||
import { useCreateProjectInline } from "@/hooks/useProjects";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const projectNameRegex = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
const schema = z.object({
|
||||
project_name: z
|
||||
.string()
|
||||
.min(2, "최소 2자 이상")
|
||||
.max(64, "최대 64자")
|
||||
.regex(projectNameRegex, "영문/숫자/_/- 만 허용"),
|
||||
domain: z.string().min(1, "도메인을 선택하세요"),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data: domains,
|
||||
isLoading: domainsLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useDomains();
|
||||
const createProject = useCreateProjectInline();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { project_name: "", domain: "" },
|
||||
});
|
||||
|
||||
const selectedDomain = watch("domain");
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
const selected = domains?.find((d) => d.domain === values.domain);
|
||||
try {
|
||||
const created = await createProject.mutateAsync({
|
||||
project_name: values.project_name,
|
||||
domain: values.domain,
|
||||
target_entities: selected?.entity_types ?? [],
|
||||
});
|
||||
toast.success(
|
||||
t("onboarding.created", "프로젝트가 생성되었습니다: {{name}}", {
|
||||
name: created.name,
|
||||
}),
|
||||
);
|
||||
navigate(`/sources/${created.name}`);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
t("onboarding.createFailed", "생성 실패: {{msg}}", {
|
||||
msg: (e as Error).message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-6 py-10">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate("/")}
|
||||
aria-label={t("common.back", "이전")}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("onboarding.title", "새 프로젝트 만들기")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{t("onboarding.formTitle", "온톨로지 도메인 선택")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"onboarding.formDesc",
|
||||
"어떤 종류의 온톨로지를 구축할지 도메인을 선택하고 프로젝트 이름을 정해주세요.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-6"
|
||||
noValidate
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project_name">
|
||||
{t("onboarding.projectName", "프로젝트 이름")}
|
||||
</Label>
|
||||
<Input
|
||||
id="project_name"
|
||||
placeholder="my_perfume_project"
|
||||
aria-invalid={Boolean(errors.project_name)}
|
||||
{...register("project_name")}
|
||||
/>
|
||||
{errors.project_name && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.project_name.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"onboarding.projectNameHint",
|
||||
"영문, 숫자, _ , - 만 사용 (2~64자)",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("onboarding.domain", "도메인")}</Label>
|
||||
{isError && (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="flex items-center justify-between gap-3 py-4">
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>{(error as Error).message}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
{t("common.retry", "다시 시도")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{domainsLoading && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-24" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{domains && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{domains.map((d) => {
|
||||
const active = selectedDomain === d.domain;
|
||||
return (
|
||||
<button
|
||||
key={d.domain}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setValue("domain", d.domain, {
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
active && "border-primary bg-accent/60",
|
||||
)}
|
||||
>
|
||||
<div className="mb-1 font-semibold capitalize">
|
||||
{d.domain}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("onboarding.domainSummary", {
|
||||
entities: d.entity_types.length,
|
||||
predicates: d.predicates.length,
|
||||
defaultValue:
|
||||
"엔티티 {{entities}}종 · 관계 {{predicates}}개",
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-2 line-clamp-2 text-xs text-muted-foreground/70">
|
||||
{d.entity_types.slice(0, 5).join(", ")}
|
||||
{d.entity_types.length > 5 && " …"}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{errors.domain && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.domain.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 border-t pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/")}
|
||||
disabled={isSubmitting || createProject.isPending}
|
||||
>
|
||||
{t("common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || createProject.isPending}
|
||||
>
|
||||
{createProject.isPending && (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{t("onboarding.submit", "프로젝트 만들기")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
688
ontology_platform/web/frontend/src/pages/OntologyEditorPage.tsx
Normal file
688
ontology_platform/web/frontend/src/pages/OntologyEditorPage.tsx
Normal file
@@ -0,0 +1,688 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
FileJson,
|
||||
Link2,
|
||||
Loader2,
|
||||
Network,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useProject } from "@/hooks/useProjects";
|
||||
import { useOntology } from "@/hooks/useDomains";
|
||||
import {
|
||||
useBulkCreateEntities,
|
||||
useCreateEntity,
|
||||
useDeleteEntity,
|
||||
useEntities,
|
||||
} from "@/hooks/useEntities";
|
||||
import {
|
||||
useClaims,
|
||||
useCreateClaim,
|
||||
useDeleteClaim,
|
||||
} from "@/hooks/useClaims";
|
||||
|
||||
const entitySchema = z.object({
|
||||
entity_type: z.string().min(1, "타입을 선택하세요"),
|
||||
name: z.string().min(1, "이름을 입력하세요").max(240),
|
||||
});
|
||||
type EntityFormValues = z.infer<typeof entitySchema>;
|
||||
|
||||
const claimSchema = z.object({
|
||||
source_name: z.string().min(1, "소스를 선택하세요"),
|
||||
subject_entity_id: z.number().int().min(1, "주어 엔티티 선택"),
|
||||
predicate: z.string().min(1, "술어를 선택하세요"),
|
||||
object_kind: z.enum(["entity", "value"]),
|
||||
object_entity_id: z.number().int().nullable().optional(),
|
||||
object_value: z.string().optional(),
|
||||
confidence: z.number().min(0).max(1),
|
||||
});
|
||||
type ClaimFormValues = z.infer<typeof claimSchema>;
|
||||
|
||||
export default function OntologyEditorPage() {
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const { t } = useTranslation();
|
||||
const projectName = projectId ?? "";
|
||||
const [tab, setTab] = useState<"entities" | "claims" | "bulk">("entities");
|
||||
|
||||
const { data: project, isLoading: projectLoading, isError, error, refetch } =
|
||||
useProject(projectName);
|
||||
const { data: ontology } = useOntology(project?.domain);
|
||||
|
||||
const entities = useEntities(projectName);
|
||||
const claims = useClaims(projectName, { includeCandidates: true });
|
||||
|
||||
const createEntity = useCreateEntity(projectName);
|
||||
const bulkCreate = useBulkCreateEntities(projectName);
|
||||
const deleteEntity = useDeleteEntity(projectName);
|
||||
const createClaim = useCreateClaim(projectName);
|
||||
const deleteClaim = useDeleteClaim(projectName);
|
||||
|
||||
const sources = project?.sources ?? [];
|
||||
const entityTypeOptions = useMemo(() => {
|
||||
if (ontology?.entity_types?.length) return ontology.entity_types;
|
||||
return ["Entity", "Concept", "Attribute"];
|
||||
}, [ontology]);
|
||||
const predicateOptions = useMemo(() => {
|
||||
if (ontology?.predicates?.length) return ontology.predicates;
|
||||
return ["hasAttribute", "relatedTo", "sameAs"];
|
||||
}, [ontology]);
|
||||
|
||||
// ── Entity form ───────────────────────────────────────────────
|
||||
const entityForm = useForm<EntityFormValues>({
|
||||
resolver: zodResolver(entitySchema),
|
||||
defaultValues: { entity_type: "", name: "" },
|
||||
});
|
||||
|
||||
const onCreateEntity = async (values: EntityFormValues) => {
|
||||
try {
|
||||
await createEntity.mutateAsync({
|
||||
entity_type: values.entity_type,
|
||||
name: values.name,
|
||||
});
|
||||
toast.success(
|
||||
t("editor.entityAdded", "엔티티가 추가되었습니다: {{name}}", {
|
||||
name: values.name,
|
||||
}),
|
||||
);
|
||||
entityForm.reset({ entity_type: values.entity_type, name: "" });
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
t("editor.entityAddFailed", "추가 실패: {{msg}}", {
|
||||
msg: (e as Error).message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Claim form ────────────────────────────────────────────────
|
||||
const claimForm = useForm<ClaimFormValues>({
|
||||
resolver: zodResolver(claimSchema),
|
||||
defaultValues: {
|
||||
source_name: "",
|
||||
subject_entity_id: 0,
|
||||
predicate: "",
|
||||
object_kind: "entity",
|
||||
object_entity_id: null,
|
||||
object_value: "",
|
||||
confidence: 1.0,
|
||||
},
|
||||
});
|
||||
const objectKind = claimForm.watch("object_kind");
|
||||
|
||||
const onCreateClaim = async (values: ClaimFormValues) => {
|
||||
try {
|
||||
await createClaim.mutateAsync({
|
||||
source_name: values.source_name,
|
||||
subject_entity_id: values.subject_entity_id,
|
||||
predicate: values.predicate,
|
||||
object_entity_id:
|
||||
values.object_kind === "entity" ? values.object_entity_id : null,
|
||||
object_value:
|
||||
values.object_kind === "value" ? values.object_value : undefined,
|
||||
confidence: values.confidence,
|
||||
});
|
||||
toast.success(t("editor.claimAdded", "클레임이 추가되었습니다"));
|
||||
claimForm.reset({
|
||||
...claimForm.getValues(),
|
||||
object_entity_id: null,
|
||||
object_value: "",
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
t("editor.claimAddFailed", "추가 실패: {{msg}}", {
|
||||
msg: (e as Error).message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Bulk JSON form ────────────────────────────────────────────
|
||||
const [bulkText, setBulkText] = useState(
|
||||
JSON.stringify(
|
||||
{
|
||||
entities: [
|
||||
{ entity_type: "Entity", name: "Example A" },
|
||||
{ entity_type: "Entity", name: "Example B" },
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
const [bulkError, setBulkError] = useState<string | null>(null);
|
||||
|
||||
const onBulkSubmit = async () => {
|
||||
setBulkError(null);
|
||||
try {
|
||||
const parsed = JSON.parse(bulkText);
|
||||
const list = Array.isArray(parsed.entities)
|
||||
? parsed.entities
|
||||
: Array.isArray(parsed)
|
||||
? parsed
|
||||
: null;
|
||||
if (!list || list.length === 0) {
|
||||
throw new Error("최상위에 entities 배열이 있어야 합니다");
|
||||
}
|
||||
const normalized = list.map((item: Record<string, unknown>, idx: number) => {
|
||||
if (
|
||||
typeof item !== "object" ||
|
||||
item === null ||
|
||||
typeof item.entity_type !== "string" ||
|
||||
typeof item.name !== "string"
|
||||
) {
|
||||
throw new Error(
|
||||
`항목 [${idx}]에 entity_type/name 문자열이 필요합니다`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
entity_type: item.entity_type,
|
||||
name: item.name,
|
||||
metadata:
|
||||
typeof item.metadata === "object" && item.metadata !== null
|
||||
? (item.metadata as Record<string, unknown>)
|
||||
: {},
|
||||
};
|
||||
});
|
||||
const res = await bulkCreate.mutateAsync({ entities: normalized });
|
||||
toast.success(
|
||||
t("editor.bulkAdded", "{{count}}개 엔티티가 추가되었습니다", {
|
||||
count: res.created,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
setBulkError((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-6 py-10">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/sources/${projectName}`)}
|
||||
aria-label={t("common.back", "이전")}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Network className="h-6 w-6 text-primary" />
|
||||
{t("editor.title", "온톨로지 직접 편집")}
|
||||
</h1>
|
||||
{project && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.name}
|
||||
<span className="capitalize text-muted-foreground/70">
|
||||
{" "}
|
||||
· {project.domain}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<Card className="mb-6 border-destructive">
|
||||
<CardContent className="flex items-center justify-between gap-3 py-4">
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>{(error as Error).message}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
{t("common.retry", "다시 시도")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as typeof tab)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="entities">
|
||||
{t("editor.entitiesTab", "엔티티")} ({entities.data?.length ?? 0})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="claims">
|
||||
<Link2 className="mr-1 h-4 w-4" />
|
||||
{t("editor.claimsTab", "클레임")} ({claims.data?.length ?? 0})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="bulk">
|
||||
<FileJson className="mr-1 h-4 w-4" />
|
||||
{t("editor.bulkTab", "JSON 일괄 입력")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── Entities Tab ─────────────────────────────────────── */}
|
||||
<TabsContent value="entities">
|
||||
<div className="grid gap-6 lg:grid-cols-[380px_1fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("editor.addEntity", "엔티티 추가")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"editor.addEntityDesc",
|
||||
"온톨로지 도메인의 entity_types 중에서 선택",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={entityForm.handleSubmit(onCreateEntity)}
|
||||
className="space-y-3"
|
||||
noValidate
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("editor.entityType", "타입")}</Label>
|
||||
<Select {...entityForm.register("entity_type")}>
|
||||
<option value="">
|
||||
{t("editor.pickType", "타입 선택...")}
|
||||
</option>
|
||||
{entityTypeOptions.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{entityForm.formState.errors.entity_type && (
|
||||
<p className="text-xs text-destructive">
|
||||
{entityForm.formState.errors.entity_type.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("editor.entityName", "이름")}</Label>
|
||||
<Input
|
||||
placeholder="Chanel No.5"
|
||||
{...entityForm.register("name")}
|
||||
/>
|
||||
{entityForm.formState.errors.name && (
|
||||
<p className="text-xs text-destructive">
|
||||
{entityForm.formState.errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={createEntity.isPending}
|
||||
>
|
||||
{createEntity.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
{t("editor.add", "추가")}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{t("editor.entitiesList", "엔티티 목록")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{entities.data
|
||||
? t("editor.entityCount", "{{count}}개", {
|
||||
count: entities.data.length,
|
||||
})
|
||||
: t("dashboard.loadFailed", "")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{entities.isLoading && (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{entities.data && entities.data.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
{t("editor.entitiesEmpty", "아직 등록된 엔티티가 없습니다")}
|
||||
</p>
|
||||
)}
|
||||
{entities.data && entities.data.length > 0 && (
|
||||
<ul className="max-h-[600px] divide-y overflow-y-auto">
|
||||
{entities.data.map((e) => (
|
||||
<li
|
||||
key={e.id}
|
||||
className="flex items-center justify-between gap-3 py-2 text-sm"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{e.type}</Badge>
|
||||
<span className="truncate font-medium">
|
||||
{e.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
t(
|
||||
"editor.confirmDeleteEntity",
|
||||
"엔티티 '{{name}}'을 삭제하시겠습니까?",
|
||||
{ name: e.name },
|
||||
),
|
||||
)
|
||||
) {
|
||||
deleteEntity.mutate(e.id);
|
||||
}
|
||||
}}
|
||||
disabled={deleteEntity.isPending}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Claims Tab ───────────────────────────────────────── */}
|
||||
<TabsContent value="claims">
|
||||
<div className="grid gap-6 lg:grid-cols-[420px_1fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("editor.addClaim", "클레임 추가")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"editor.addClaimDesc",
|
||||
"주어-술어-목적어 형태로 직접 입력",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={claimForm.handleSubmit(onCreateClaim)}
|
||||
className="space-y-3"
|
||||
noValidate
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("editor.source", "소스")}</Label>
|
||||
<Select {...claimForm.register("source_name")}>
|
||||
<option value="">
|
||||
{t("editor.pickSource", "소스 선택...")}
|
||||
</option>
|
||||
{sources.map((s) => (
|
||||
<option key={s.id} value={s.name}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{claimForm.formState.errors.source_name && (
|
||||
<p className="text-xs text-destructive">
|
||||
{claimForm.formState.errors.source_name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("editor.subject", "주어 (Subject)")}</Label>
|
||||
<Select
|
||||
{...claimForm.register("subject_entity_id", {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
>
|
||||
<option value={0}>
|
||||
{t("editor.pickSubject", "엔티티 선택...")}
|
||||
</option>
|
||||
{entities.data?.map((e) => (
|
||||
<option key={e.id} value={e.id}>
|
||||
[{e.type}] {e.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{claimForm.formState.errors.subject_entity_id && (
|
||||
<p className="text-xs text-destructive">
|
||||
{claimForm.formState.errors.subject_entity_id.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("editor.predicate", "술어 (Predicate)")}</Label>
|
||||
<Select {...claimForm.register("predicate")}>
|
||||
<option value="">
|
||||
{t("editor.pickPredicate", "술어 선택...")}
|
||||
</option>
|
||||
{predicateOptions.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("editor.objectKind", "목적어 유형")}</Label>
|
||||
<Select {...claimForm.register("object_kind")}>
|
||||
<option value="entity">
|
||||
{t("editor.objectEntity", "다른 엔티티")}
|
||||
</option>
|
||||
<option value="value">
|
||||
{t("editor.objectValue", "리터럴 값")}
|
||||
</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{objectKind === "entity" ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("editor.objectEntity", "목적 엔티티")}</Label>
|
||||
<Select
|
||||
{...claimForm.register("object_entity_id", {
|
||||
valueAsNumber: true,
|
||||
setValueAs: (v) =>
|
||||
v === "" || v === null || v === undefined
|
||||
? null
|
||||
: Number(v),
|
||||
})}
|
||||
>
|
||||
<option value="">
|
||||
{t("editor.pickObject", "엔티티 선택...")}
|
||||
</option>
|
||||
{entities.data?.map((e) => (
|
||||
<option key={e.id} value={e.id}>
|
||||
[{e.type}] {e.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("editor.objectValue", "값")}</Label>
|
||||
<Input
|
||||
placeholder="2024-01-15 또는 임의 문자열"
|
||||
{...claimForm.register("object_value")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("editor.confidence", "신뢰도")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.05"
|
||||
min={0}
|
||||
max={1}
|
||||
{...claimForm.register("confidence", {
|
||||
valueAsNumber: true,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={createClaim.isPending}
|
||||
>
|
||||
{createClaim.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
{t("editor.add", "추가")}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("editor.claimsList", "클레임 목록")}</CardTitle>
|
||||
<CardDescription>
|
||||
{claims.data &&
|
||||
t("editor.claimCount", "{{count}}개", {
|
||||
count: claims.data.length,
|
||||
})}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{claims.isLoading && (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{claims.data && claims.data.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
{t("editor.claimsEmpty", "아직 등록된 클레임이 없습니다")}
|
||||
</p>
|
||||
)}
|
||||
{claims.data && claims.data.length > 0 && (
|
||||
<ul className="max-h-[600px] divide-y overflow-y-auto">
|
||||
{claims.data.map((c) => (
|
||||
<li
|
||||
key={c.id}
|
||||
className="flex items-start justify-between gap-3 py-3 text-sm"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="font-medium">
|
||||
{c.subject ?? "?"}
|
||||
</span>
|
||||
<Badge variant="secondary">{c.predicate}</Badge>
|
||||
<span className="font-medium">
|
||||
{c.object ?? String(c.object_value ?? "—")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("editor.source", "소스")}: {c.source ?? "—"}
|
||||
</span>
|
||||
{typeof c.confidence === "number" && (
|
||||
<span>
|
||||
{t("editor.confidence", "신뢰도")}:{" "}
|
||||
{c.confidence.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
{c.status && (
|
||||
<Badge variant="outline">{c.status}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
t(
|
||||
"editor.confirmDeleteClaim",
|
||||
"클레임을 삭제하시겠습니까?",
|
||||
),
|
||||
)
|
||||
) {
|
||||
deleteClaim.mutate(c.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Bulk Tab ─────────────────────────────────────────── */}
|
||||
<TabsContent value="bulk">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("editor.bulkTitle", "JSON 일괄 입력")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"editor.bulkDesc",
|
||||
"{ entities: [{ entity_type, name, metadata? }] } 형태의 JSON",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Textarea
|
||||
value={bulkText}
|
||||
onChange={(e) => setBulkText(e.target.value)}
|
||||
rows={12}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{bulkError && (
|
||||
<p className="text-sm text-destructive">{bulkError}</p>
|
||||
)}
|
||||
<Button
|
||||
onClick={onBulkSubmit}
|
||||
disabled={bulkCreate.isPending || projectLoading}
|
||||
>
|
||||
{bulkCreate.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<FileJson className="h-4 w-4" />
|
||||
)}
|
||||
{t("editor.bulkSubmit", "일괄 추가")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
333
ontology_platform/web/frontend/src/pages/PageAnalysisPage.tsx
Normal file
333
ontology_platform/web/frontend/src/pages/PageAnalysisPage.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Code2,
|
||||
FileText,
|
||||
Highlighter,
|
||||
Loader2,
|
||||
SearchCheck,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useClaims } from "@/hooks/useClaims";
|
||||
import { useProject } from "@/hooks/useProjects";
|
||||
import { useExtractionLogs } from "@/hooks/usePlatform";
|
||||
import { formatDateTime, formatPercent, humanizeValue } from "@/lib/display";
|
||||
import { Claim } from "@/lib/api/claims";
|
||||
import { ExtractionLog } from "@/lib/api/platform";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function firstText(...values: unknown[]): string {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim()) return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function candidateClaims(log: ExtractionLog | undefined): unknown[] {
|
||||
const raw = asRecord(log?.raw_output);
|
||||
const direct = raw.candidate_claims;
|
||||
if (Array.isArray(direct)) return direct;
|
||||
const validation = asRecord(raw.validation);
|
||||
const nested = validation.candidate_claims;
|
||||
return Array.isArray(nested) ? nested : [];
|
||||
}
|
||||
|
||||
export default function PageAnalysisPage() {
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const projectName = projectId ?? "";
|
||||
const { data: project } = useProject(projectName);
|
||||
const logs = useExtractionLogs(projectName, 100);
|
||||
const claims = useClaims(projectName, {
|
||||
includeCandidates: true,
|
||||
limit: 300,
|
||||
});
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const selectedLog = useMemo(() => {
|
||||
const all = logs.data ?? [];
|
||||
return all.find((log) => log.id === selectedId) ?? all[0];
|
||||
}, [logs.data, selectedId]);
|
||||
|
||||
const selectedPageClaims = useMemo<Claim[]>(() => {
|
||||
if (!selectedLog?.page_url) return [];
|
||||
return (claims.data ?? []).filter(
|
||||
(claim) => claim.page_url === selectedLog.page_url,
|
||||
);
|
||||
}, [claims.data, selectedLog?.page_url]);
|
||||
|
||||
const pageContext = asRecord(selectedLog?.page_context);
|
||||
const rawOutput = asRecord(selectedLog?.raw_output);
|
||||
const rawContext = asRecord(rawOutput.page_context);
|
||||
const cleanedText = firstText(
|
||||
pageContext.clean_text,
|
||||
pageContext.cleaned_text,
|
||||
pageContext.text,
|
||||
rawContext.clean_text,
|
||||
rawContext.cleaned_text,
|
||||
rawContext.text,
|
||||
);
|
||||
const htmlText = firstText(
|
||||
pageContext.html,
|
||||
pageContext.raw_html,
|
||||
rawContext.html,
|
||||
rawContext.raw_html,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-6 py-8">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/pipeline/${projectName}`)}
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<SearchCheck className="h-6 w-6 text-primary" />
|
||||
Page Analysis
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project?.name ?? projectName} · 추출 근거와 페이지 분석 로그
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => logs.refetch()}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(logs.isError || claims.isError) && (
|
||||
<Card className="mb-6 border-destructive">
|
||||
<CardContent className="flex items-center gap-2 py-4 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{(logs.error as Error | undefined)?.message ??
|
||||
(claims.error as Error | undefined)?.message}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[360px_1fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Analysis Runs</CardTitle>
|
||||
<CardDescription>
|
||||
페이지별 추출 실행 기록과 오류 상태
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{logs.isLoading && (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-16" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{logs.data?.length === 0 && (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
아직 분석 로그가 없습니다. 먼저 크롤을 실행하세요.
|
||||
</p>
|
||||
)}
|
||||
<ul className="max-h-[720px] divide-y overflow-y-auto">
|
||||
{(logs.data ?? []).map((log) => (
|
||||
<li key={log.id} className="py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedId(log.id)}
|
||||
className="w-full rounded-md px-3 py-2 text-left transition-colors hover:bg-accent/10"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{log.page_url ?? `run #${log.id}`}
|
||||
</span>
|
||||
<Badge variant={log.error ? "destructive" : "success"}>
|
||||
{log.error ? "error" : "ok"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{log.extractor_name}</span>
|
||||
<span>{log.provider}</span>
|
||||
<span>{log.candidate_count} candidates</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{formatDateTime(log.created_at)}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Selected Page</CardTitle>
|
||||
<CardDescription>
|
||||
{selectedLog?.page_url ?? "분석 로그를 선택하세요"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{selectedLog && (
|
||||
<Badge variant={selectedLog.error ? "destructive" : "outline"}>
|
||||
{selectedLog.provider}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!selectedLog && (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
표시할 페이지 분석 결과가 없습니다.
|
||||
</p>
|
||||
)}
|
||||
{selectedLog && (
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<InfoBox label="Extractor" value={selectedLog.extractor_name} />
|
||||
<InfoBox
|
||||
label="Candidates"
|
||||
value={String(selectedLog.candidate_count)}
|
||||
/>
|
||||
<InfoBox
|
||||
label="Validation"
|
||||
value={humanizeValue(selectedLog.validation)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{selectedLog?.error && (
|
||||
<div className="mt-4 rounded-md border border-destructive bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
{selectedLog.error}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5" />
|
||||
Cleaned Text
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
AI 분석에 투입된 정제 본문
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="max-h-80 overflow-auto whitespace-pre-wrap rounded-md bg-secondary/30 p-3 text-xs leading-relaxed">
|
||||
{cleanedText || "정제 본문이 저장되어 있지 않습니다."}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Code2 className="h-5 w-5" />
|
||||
Raw Context
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
원문 HTML 또는 분석 입력 컨텍스트
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="max-h-80 overflow-auto whitespace-pre-wrap rounded-md bg-secondary/30 p-3 text-xs leading-relaxed">
|
||||
{htmlText || JSON.stringify(pageContext || rawContext, null, 2)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Extracted Claims</CardTitle>
|
||||
<CardDescription>
|
||||
같은 페이지에서 생성된 클레임과 근거 문장
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{claims.isLoading && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
클레임을 불러오는 중
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{selectedPageClaims.map((claim) => (
|
||||
<article key={claim.id} className="rounded-md border p-4">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-medium">
|
||||
{humanizeValue(claim.subject)}
|
||||
</span>
|
||||
<Badge variant="secondary">{claim.predicate}</Badge>
|
||||
<span className="font-medium">
|
||||
{humanizeValue(claim.object ?? claim.object_value)}
|
||||
</span>
|
||||
<Badge variant="outline">
|
||||
{formatPercent(claim.confidence)}
|
||||
</Badge>
|
||||
</div>
|
||||
{claim.evidence_text && (
|
||||
<div className="mt-3 rounded-md bg-yellow-50 px-3 py-2 text-sm text-yellow-950">
|
||||
<Highlighter className="mr-1 inline h-4 w-4" />
|
||||
{claim.evidence_text}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
{!claims.isLoading && selectedPageClaims.length === 0 && (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
이 페이지 URL과 연결된 클레임이 없습니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Candidate Payload</CardTitle>
|
||||
<CardDescription>
|
||||
추출기가 반환한 후보 원본 일부
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-secondary/30 p-3 text-xs">
|
||||
{JSON.stringify(candidateClaims(selectedLog), null, 2)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoBox({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border bg-background px-3 py-2">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 truncate text-sm font-medium">{value || "-"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,801 @@
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
ClipboardCheck,
|
||||
Link,
|
||||
ShieldAlert,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge, BadgeProps } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useClaims } from "@/hooks/useClaims";
|
||||
import { useEntities } from "@/hooks/useEntities";
|
||||
import { useProject } from "@/hooks/useProjects";
|
||||
import { useOntologyRegistry, usePipeline } from "@/hooks/usePlatform";
|
||||
import { Claim } from "@/lib/api/claims";
|
||||
import { Entity } from "@/lib/api/entities";
|
||||
import { OntologyRegistry } from "@/lib/api/platform";
|
||||
import { formatPercent, humanizeValue } from "@/lib/display";
|
||||
|
||||
interface QualityIssue {
|
||||
id: string;
|
||||
type: string;
|
||||
severity: "high" | "medium" | "low";
|
||||
title: string;
|
||||
detail: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
type RelationRule = OntologyRegistry["relation_types"][number];
|
||||
|
||||
const okValidationStatuses = new Set(["valid", "ok", "passed", "clean"]);
|
||||
const okGraphStatuses = new Set(["merged", "active", "synced", "ok", "ready"]);
|
||||
const genericLabels = new Set([
|
||||
"value",
|
||||
"keyword",
|
||||
"name",
|
||||
"item",
|
||||
"product",
|
||||
"brand",
|
||||
"accord",
|
||||
"note",
|
||||
"unknown",
|
||||
]);
|
||||
|
||||
function severityVariant(severity: QualityIssue["severity"]): BadgeProps["variant"] {
|
||||
switch (severity) {
|
||||
case "high":
|
||||
return "destructive";
|
||||
case "medium":
|
||||
return "warning";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedName(value: unknown): string {
|
||||
return humanizeValue(value).trim().toLowerCase().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function claimObject(claim: Claim): string {
|
||||
return humanizeValue(claim.object ?? claim.object_value);
|
||||
}
|
||||
|
||||
function claimValueType(claim: Claim): string | undefined {
|
||||
const valueType = (claim as Claim & { value_type?: unknown }).value_type;
|
||||
return typeof valueType === "string" && valueType.trim()
|
||||
? valueType.trim()
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function relationKey(claim: Claim): string {
|
||||
return `${claim.subject ?? ""}|${claim.predicate}|${claimObject(claim)}`.toLowerCase();
|
||||
}
|
||||
|
||||
function allowedIncludes(values: string[], value: string | undefined | null) {
|
||||
if (!value) return false;
|
||||
const normalized = value.toLowerCase();
|
||||
return values.some((item) => item.toLowerCase() === normalized);
|
||||
}
|
||||
|
||||
function listLabel(values: string[]) {
|
||||
return values.length ? values.join(", ") : "-";
|
||||
}
|
||||
|
||||
function ruleNumber(
|
||||
record: Record<string, unknown> | undefined,
|
||||
keys: string[],
|
||||
): number | undefined {
|
||||
if (!record) return undefined;
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function minConfidenceFor(rule: RelationRule | undefined): number {
|
||||
return (
|
||||
ruleNumber(rule?.confidence_rules, [
|
||||
"min_confidence",
|
||||
"minimum_confidence",
|
||||
"minConfidence",
|
||||
]) ?? 0.7
|
||||
);
|
||||
}
|
||||
|
||||
function daysSince(value: string | null | undefined): number | undefined {
|
||||
if (!value) return undefined;
|
||||
const time = new Date(value).getTime();
|
||||
if (!Number.isFinite(time)) return undefined;
|
||||
return (Date.now() - time) / 86_400_000;
|
||||
}
|
||||
|
||||
function buildIssues(
|
||||
claims: Claim[],
|
||||
entities: Entity[],
|
||||
relationRules: RelationRule[],
|
||||
): QualityIssue[] {
|
||||
const issues: QualityIssue[] = [];
|
||||
const seenRelations = new Map<string, Claim>();
|
||||
const entityNameGroups = new Map<string, Entity[]>();
|
||||
const connectedNames = new Set<string>();
|
||||
const entityTypeByName = new Map<string, string>();
|
||||
const relationRuleByName = new Map<string, RelationRule>();
|
||||
|
||||
relationRules.forEach((rule) => {
|
||||
relationRuleByName.set(rule.name.toLowerCase(), rule);
|
||||
});
|
||||
|
||||
for (const entity of entities) {
|
||||
const key = normalizedName(entity.name);
|
||||
if (!key) continue;
|
||||
const group = entityNameGroups.get(key) ?? [];
|
||||
group.push(entity);
|
||||
entityNameGroups.set(key, group);
|
||||
entityTypeByName.set(key, entity.type);
|
||||
}
|
||||
|
||||
for (const claim of claims) {
|
||||
const object = claimObject(claim);
|
||||
const target = `${humanizeValue(claim.subject)} ${claim.predicate} ${object}`;
|
||||
const rule = relationRuleByName.get(claim.predicate.toLowerCase());
|
||||
const subjectType =
|
||||
claim.subject_type ?? entityTypeByName.get(normalizedName(claim.subject));
|
||||
const objectType =
|
||||
claim.object_type ??
|
||||
entityTypeByName.get(normalizedName(object)) ??
|
||||
claimValueType(claim);
|
||||
const evidence = claim.evidence_text?.trim() ?? "";
|
||||
const sourceUrl = claim.page_url?.trim() ?? "";
|
||||
|
||||
if (!rule) {
|
||||
issues.push({
|
||||
id: `unknown-predicate-${claim.id}`,
|
||||
type: "unknown_predicate",
|
||||
severity: "medium",
|
||||
title: "Predicate is not registered",
|
||||
detail: `${claim.predicate} is missing from the ontology registry.`,
|
||||
target,
|
||||
});
|
||||
}
|
||||
|
||||
if (!sourceUrl || !evidence) {
|
||||
issues.push({
|
||||
id: `source-${claim.id}`,
|
||||
type: "missing_source",
|
||||
severity: "high",
|
||||
title: "Source or evidence is missing",
|
||||
detail: "Every claim should carry both page URL and evidence text.",
|
||||
target,
|
||||
});
|
||||
} else if (evidence.length < 24) {
|
||||
issues.push({
|
||||
id: `thin-evidence-${claim.id}`,
|
||||
type: "thin_evidence",
|
||||
severity: "medium",
|
||||
title: "Evidence snippet is too thin",
|
||||
detail: "The supporting text is short enough to be ambiguous.",
|
||||
target,
|
||||
});
|
||||
}
|
||||
|
||||
if (evidence && claim.subject && object) {
|
||||
const evidenceLower = evidence.toLowerCase();
|
||||
const subjectLower = normalizedName(claim.subject);
|
||||
const objectLower = normalizedName(object);
|
||||
const subjectMentioned =
|
||||
subjectLower.length > 2 && evidenceLower.includes(subjectLower);
|
||||
const objectMentioned =
|
||||
objectLower.length > 2 && evidenceLower.includes(objectLower);
|
||||
if (!subjectMentioned && !objectMentioned) {
|
||||
issues.push({
|
||||
id: `evidence-alignment-${claim.id}`,
|
||||
type: "weak_evidence_alignment",
|
||||
severity: "low",
|
||||
title: "Evidence does not mention the relation terms",
|
||||
detail: "The snippet may support the page generally, but not this exact relation.",
|
||||
target,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const minConfidence = minConfidenceFor(rule);
|
||||
if (typeof claim.confidence !== "number") {
|
||||
issues.push({
|
||||
id: `confidence-missing-${claim.id}`,
|
||||
type: "missing_confidence",
|
||||
severity: "medium",
|
||||
title: "Confidence is missing",
|
||||
detail: "Claims need confidence for review prioritization.",
|
||||
target,
|
||||
});
|
||||
} else if (claim.confidence < minConfidence) {
|
||||
issues.push({
|
||||
id: `confidence-${claim.id}`,
|
||||
type: "low_confidence",
|
||||
severity: claim.confidence < Math.max(0.45, minConfidence - 0.25) ? "high" : "medium",
|
||||
title: "Confidence is below rule threshold",
|
||||
detail: `${formatPercent(claim.confidence)} is below ${formatPercent(minConfidence)}.`,
|
||||
target,
|
||||
});
|
||||
}
|
||||
|
||||
if (rule?.allowed_subject_types.length) {
|
||||
if (!subjectType) {
|
||||
issues.push({
|
||||
id: `subject-type-missing-${claim.id}`,
|
||||
type: "missing_subject_type",
|
||||
severity: "medium",
|
||||
title: "Subject type is missing",
|
||||
detail: `Allowed subject types: ${listLabel(rule.allowed_subject_types)}.`,
|
||||
target,
|
||||
});
|
||||
} else if (!allowedIncludes(rule.allowed_subject_types, subjectType)) {
|
||||
issues.push({
|
||||
id: `subject-type-${claim.id}`,
|
||||
type: "subject_type_mismatch",
|
||||
severity: "high",
|
||||
title: "Subject type violates relation rule",
|
||||
detail: `${subjectType} is not one of ${listLabel(rule.allowed_subject_types)}.`,
|
||||
target,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (rule?.allowed_object_types.length) {
|
||||
if (!objectType) {
|
||||
issues.push({
|
||||
id: `object-type-missing-${claim.id}`,
|
||||
type: "missing_object_type",
|
||||
severity: "medium",
|
||||
title: "Object type is missing",
|
||||
detail: `Allowed object types: ${listLabel(rule.allowed_object_types)}.`,
|
||||
target,
|
||||
});
|
||||
} else if (!allowedIncludes(rule.allowed_object_types, objectType)) {
|
||||
issues.push({
|
||||
id: `object-type-${claim.id}`,
|
||||
type: "object_type_mismatch",
|
||||
severity: "high",
|
||||
title: "Object type violates relation rule",
|
||||
detail: `${objectType} is not one of ${listLabel(rule.allowed_object_types)}.`,
|
||||
target,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (rule?.allowed_page_types.length) {
|
||||
if (!claim.page_type) {
|
||||
issues.push({
|
||||
id: `page-type-missing-${claim.id}`,
|
||||
type: "missing_page_type",
|
||||
severity: "low",
|
||||
title: "Page type is missing",
|
||||
detail: `Allowed page types: ${listLabel(rule.allowed_page_types)}.`,
|
||||
target,
|
||||
});
|
||||
} else if (!allowedIncludes(rule.allowed_page_types, claim.page_type)) {
|
||||
issues.push({
|
||||
id: `page-type-${claim.id}`,
|
||||
type: "page_type_mismatch",
|
||||
severity: "medium",
|
||||
title: "Page type violates relation rule",
|
||||
detail: `${claim.page_type} is not one of ${listLabel(rule.allowed_page_types)}.`,
|
||||
target,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (rule?.allowed_source_zones.length) {
|
||||
if (!claim.source_zone) {
|
||||
issues.push({
|
||||
id: `source-zone-missing-${claim.id}`,
|
||||
type: "missing_source_zone",
|
||||
severity: "low",
|
||||
title: "Source zone is missing",
|
||||
detail: `Allowed source zones: ${listLabel(rule.allowed_source_zones)}.`,
|
||||
target,
|
||||
});
|
||||
} else if (!allowedIncludes(rule.allowed_source_zones, claim.source_zone)) {
|
||||
issues.push({
|
||||
id: `source-zone-${claim.id}`,
|
||||
type: "source_zone_mismatch",
|
||||
severity: "medium",
|
||||
title: "Source zone violates relation rule",
|
||||
detail: `${claim.source_zone} is not one of ${listLabel(rule.allowed_source_zones)}.`,
|
||||
target,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
claim.validation_status &&
|
||||
!okValidationStatuses.has(claim.validation_status.toLowerCase())
|
||||
) {
|
||||
issues.push({
|
||||
id: `schema-${claim.id}`,
|
||||
type: "schema_violation",
|
||||
severity: "high",
|
||||
title: "Validation status needs attention",
|
||||
detail: claim.validation_status,
|
||||
target,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
claim.graph_merge_status &&
|
||||
!okGraphStatuses.has(claim.graph_merge_status.toLowerCase())
|
||||
) {
|
||||
issues.push({
|
||||
id: `graph-merge-${claim.id}`,
|
||||
type: "graph_merge_warning",
|
||||
severity: "medium",
|
||||
title: "Graph merge status is not ready",
|
||||
detail: claim.graph_merge_reason || claim.graph_merge_status,
|
||||
target,
|
||||
});
|
||||
}
|
||||
|
||||
if (claim.review_required || claim.conflict_status) {
|
||||
issues.push({
|
||||
id: `review-${claim.id}`,
|
||||
type: "review_required",
|
||||
severity: claim.conflict_status ? "high" : "medium",
|
||||
title: "Human review is required",
|
||||
detail: claim.review_reason || claim.conflict_status || "review required",
|
||||
target,
|
||||
});
|
||||
}
|
||||
|
||||
const staleDays = daysSince(claim.last_seen_at);
|
||||
if (staleDays === undefined) {
|
||||
issues.push({
|
||||
id: `last-seen-missing-${claim.id}`,
|
||||
type: "missing_last_seen",
|
||||
severity: "low",
|
||||
title: "Last seen timestamp is missing",
|
||||
detail: "Freshness checks need a last_seen_at value.",
|
||||
target,
|
||||
});
|
||||
} else if (staleDays > 180) {
|
||||
issues.push({
|
||||
id: `stale-${claim.id}`,
|
||||
type: "stale_claim",
|
||||
severity: "medium",
|
||||
title: "Claim is stale",
|
||||
detail: `Last observed ${Math.round(staleDays)} days ago.`,
|
||||
target,
|
||||
});
|
||||
}
|
||||
|
||||
const subjectLabel = normalizedName(claim.subject);
|
||||
const objectLabel = normalizedName(object);
|
||||
if (genericLabels.has(subjectLabel) || subjectLabel.length <= 1) {
|
||||
issues.push({
|
||||
id: `generic-subject-${claim.id}`,
|
||||
type: "generic_subject",
|
||||
severity: "medium",
|
||||
title: "Subject label is too generic",
|
||||
detail: "Generic entity labels should be normalized before graph commit.",
|
||||
target,
|
||||
});
|
||||
}
|
||||
if (genericLabels.has(objectLabel) || objectLabel.length <= 1) {
|
||||
issues.push({
|
||||
id: `generic-object-${claim.id}`,
|
||||
type: "generic_object",
|
||||
severity: "low",
|
||||
title: "Object label is too generic",
|
||||
detail: "The object value may need a more specific entity or literal label.",
|
||||
target,
|
||||
});
|
||||
}
|
||||
|
||||
const key = relationKey(claim);
|
||||
const existing = seenRelations.get(key);
|
||||
if (existing) {
|
||||
issues.push({
|
||||
id: `duplicate-claim-${claim.id}`,
|
||||
type: "duplicate_claim",
|
||||
severity: existing.status !== claim.status ? "medium" : "low",
|
||||
title: "Duplicate relation claim",
|
||||
detail: `Claim #${existing.id} already has the same subject, predicate, and object.`,
|
||||
target,
|
||||
});
|
||||
} else {
|
||||
seenRelations.set(key, claim);
|
||||
}
|
||||
if (claim.subject) connectedNames.add(normalizedName(claim.subject));
|
||||
if (object) connectedNames.add(normalizedName(object));
|
||||
}
|
||||
|
||||
for (const group of entityNameGroups.values()) {
|
||||
if (group.length > 1) {
|
||||
issues.push({
|
||||
id: `duplicate-entity-${group[0].id}`,
|
||||
type: "duplicate_entity",
|
||||
severity: "medium",
|
||||
title: "Duplicate entity candidate",
|
||||
detail: `${group.length} entities share the same normalized name.`,
|
||||
target: group[0].name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const entity of entities) {
|
||||
const entityName = normalizedName(entity.name);
|
||||
if (!connectedNames.has(entityName)) {
|
||||
issues.push({
|
||||
id: `isolated-${entity.id}`,
|
||||
type: "isolated_entity",
|
||||
severity: "low",
|
||||
title: "Entity is isolated",
|
||||
detail: "No relation currently connects to this entity in the loaded claim set.",
|
||||
target: `[${entity.type}] ${entity.name}`,
|
||||
});
|
||||
}
|
||||
if (["entity", "unknown", "unknown_entity"].includes(entity.type.toLowerCase())) {
|
||||
issues.push({
|
||||
id: `generic-entity-type-${entity.id}`,
|
||||
type: "generic_entity_type",
|
||||
severity: "low",
|
||||
title: "Entity type is generic",
|
||||
detail: "The entity should be assigned to a domain-specific type.",
|
||||
target: `[${entity.type}] ${entity.name}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
function countByType(issues: QualityIssue[]) {
|
||||
const counts = new Map<string, number>();
|
||||
issues.forEach((issue) => {
|
||||
counts.set(issue.type, (counts.get(issue.type) ?? 0) + 1);
|
||||
});
|
||||
return Array.from(counts.entries())
|
||||
.map(([type, count]) => ({ type, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
export default function QualityInspectorPage() {
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const projectName = projectId ?? "";
|
||||
const { data: project } = useProject(projectName);
|
||||
const claims = useClaims(projectName, {
|
||||
includeCandidates: true,
|
||||
limit: 300,
|
||||
});
|
||||
const entities = useEntities(projectName, undefined);
|
||||
const registry = useOntologyRegistry(projectName);
|
||||
const pipeline = usePipeline(projectName);
|
||||
|
||||
const issues = useMemo(
|
||||
() =>
|
||||
buildIssues(
|
||||
claims.data ?? [],
|
||||
entities.data ?? [],
|
||||
registry.data?.relation_types ?? [],
|
||||
),
|
||||
[claims.data, entities.data, registry.data?.relation_types],
|
||||
);
|
||||
const high = issues.filter((issue) => issue.severity === "high").length;
|
||||
const medium = issues.filter((issue) => issue.severity === "medium").length;
|
||||
const low = issues.filter((issue) => issue.severity === "low").length;
|
||||
const score = Math.max(
|
||||
0,
|
||||
Math.round(100 - high * 10 - medium * 5 - low * 1.5),
|
||||
);
|
||||
const issueCounts = countByType(issues);
|
||||
const relationRuleNames = new Set(
|
||||
(registry.data?.relation_types ?? []).map((row) => row.name),
|
||||
);
|
||||
const unknownPredicates = Array.from(
|
||||
new Set(
|
||||
(claims.data ?? [])
|
||||
.map((claim) => claim.predicate)
|
||||
.filter((predicate) => !relationRuleNames.has(predicate)),
|
||||
),
|
||||
);
|
||||
const reviewedCount = (claims.data ?? []).filter((claim) =>
|
||||
["validated_claim", "rejected"].includes(claim.status ?? ""),
|
||||
).length;
|
||||
const reviewRate = claims.data?.length
|
||||
? reviewedCount / claims.data.length
|
||||
: 0;
|
||||
const schemaIssueCount = issues.filter((issue) =>
|
||||
[
|
||||
"unknown_predicate",
|
||||
"subject_type_mismatch",
|
||||
"object_type_mismatch",
|
||||
"page_type_mismatch",
|
||||
"source_zone_mismatch",
|
||||
"schema_violation",
|
||||
].includes(issue.type),
|
||||
).length;
|
||||
const evidenceIssueCount = issues.filter((issue) =>
|
||||
["missing_source", "thin_evidence", "weak_evidence_alignment"].includes(
|
||||
issue.type,
|
||||
),
|
||||
).length;
|
||||
const visibleIssues = issues.slice(0, 250);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-6 py-8">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/review/${projectName}`)}
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<ClipboardCheck className="h-6 w-6 text-primary" />
|
||||
Quality Inspector
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project?.name ?? projectName} schema, evidence, duplicate, and graph readiness checks.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
claims.refetch();
|
||||
entities.refetch();
|
||||
registry.refetch();
|
||||
pipeline.refetch();
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(claims.isError || entities.isError || registry.isError) && (
|
||||
<Card className="mb-6 border-destructive">
|
||||
<CardContent className="flex items-center gap-2 py-4 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{(claims.error as Error | undefined)?.message ??
|
||||
(entities.error as Error | undefined)?.message ??
|
||||
(registry.error as Error | undefined)?.message}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-5">
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="text-xs text-muted-foreground">Quality score</div>
|
||||
<div className="mt-1 text-3xl font-semibold">{score}</div>
|
||||
<Progress value={score} className="mt-3" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Metric label="High" value={high} tone="high" />
|
||||
<Metric label="Medium" value={medium} tone="medium" />
|
||||
<Metric label="Low" value={low} tone="low" />
|
||||
<Metric label="Review rate" value={formatPercent(reviewRate)} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_360px]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<CardTitle>Validation Issues</CardTitle>
|
||||
{issues.length > visibleIssues.length && (
|
||||
<Badge variant="outline">
|
||||
Showing {visibleIssues.length} of {issues.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
Rule-based warnings generated from claim evidence and ontology registry constraints.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(claims.isLoading || entities.isLoading || registry.isLoading) && (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-20" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!claims.isLoading &&
|
||||
!entities.isLoading &&
|
||||
!registry.isLoading &&
|
||||
issues.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-2 py-12 text-center text-sm text-muted-foreground">
|
||||
<ClipboardCheck className="h-10 w-10 opacity-50" />
|
||||
No quality warnings were found in the loaded scope.
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{visibleIssues.map((issue) => (
|
||||
<article key={issue.id} className="rounded-md border p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant={severityVariant(issue.severity)}>
|
||||
{issue.severity}
|
||||
</Badge>
|
||||
<Badge variant="outline">{issue.type}</Badge>
|
||||
<span className="font-medium">{issue.title}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{issue.detail}
|
||||
</p>
|
||||
<div className="mt-2 rounded-md bg-secondary/30 px-3 py-2 text-sm">
|
||||
{issue.target}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ShieldAlert className="h-5 w-5" />
|
||||
Schema Coverage
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Predicate and type coverage against the ontology registry.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<ReadinessRow label="Relation rules" value={relationRuleNames.size} />
|
||||
<ReadinessRow label="Schema warnings" value={schemaIssueCount} />
|
||||
<ReadinessRow label="Evidence warnings" value={evidenceIssueCount} />
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>Unknown predicates</span>
|
||||
<Badge variant={unknownPredicates.length ? "warning" : "success"}>
|
||||
{unknownPredicates.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{unknownPredicates.map((predicate) => (
|
||||
<Badge key={predicate} variant="outline">
|
||||
{predicate}
|
||||
</Badge>
|
||||
))}
|
||||
{unknownPredicates.length === 0 && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
All loaded predicates are registered.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Link className="h-5 w-5" />
|
||||
Graph Readiness
|
||||
</CardTitle>
|
||||
<CardDescription>Build output ready for graph and exports.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<ReadinessRow
|
||||
label="Collected pages"
|
||||
value={
|
||||
pipeline.data?.stages.find((stage) => stage.key === "crawled")
|
||||
?.count ?? 0
|
||||
}
|
||||
/>
|
||||
<ReadinessRow
|
||||
label="Approved claims"
|
||||
value={
|
||||
pipeline.data?.stages.find((stage) => stage.key === "validated")
|
||||
?.count ?? 0
|
||||
}
|
||||
/>
|
||||
<ReadinessRow
|
||||
label="Graph triples"
|
||||
value={
|
||||
pipeline.data?.stages.find((stage) => stage.key === "graph")
|
||||
?.count ?? 0
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
Issue Mix
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm">
|
||||
{issueCounts.slice(0, 8).map((item) => (
|
||||
<li
|
||||
key={item.type}
|
||||
className="flex items-center justify-between rounded-md border px-3 py-2"
|
||||
>
|
||||
<span>{item.type}</span>
|
||||
<span className="font-medium">{item.count}</span>
|
||||
</li>
|
||||
))}
|
||||
{issueCounts.length === 0 && (
|
||||
<li className="py-4 text-center text-muted-foreground">
|
||||
No issues in the loaded scope.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recommended Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
{high > 0 && (
|
||||
<li>Resolve missing evidence, type violations, and conflicts before export.</li>
|
||||
)}
|
||||
{medium > 0 && (
|
||||
<li>Review stale claims, thin evidence, duplicate relations, and missing rule fields.</li>
|
||||
)}
|
||||
{unknownPredicates.length > 0 && (
|
||||
<li>Add missing predicate rules in Schema Designer.</li>
|
||||
)}
|
||||
{score >= 90 && (
|
||||
<li>The ontology is ready for graph inspection and Export/API handoff.</li>
|
||||
)}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | string;
|
||||
tone?: QualityIssue["severity"];
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 flex items-center justify-between gap-2">
|
||||
<span className="text-2xl font-semibold">{value}</span>
|
||||
{tone && <Badge variant={severityVariant(tone)}>{tone}</Badge>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadinessRow({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-medium">{value.toLocaleString()}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
526
ontology_platform/web/frontend/src/pages/ResearchPage.tsx
Normal file
526
ontology_platform/web/frontend/src/pages/ResearchPage.tsx
Normal file
@@ -0,0 +1,526 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Brain,
|
||||
CheckCircle2,
|
||||
History,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Target,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge, BadgeProps } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useProject } from "@/hooks/useProjects";
|
||||
import {
|
||||
useResearchSessions,
|
||||
useStartResearch,
|
||||
} from "@/hooks/useResearch";
|
||||
import { ResearchRunResult } from "@/lib/api/research";
|
||||
|
||||
const startResearchSchema = z.object({
|
||||
source_name: z.string().min(1, "소스를 선택하세요"),
|
||||
url: z
|
||||
.string()
|
||||
.url("올바른 URL 형식이 아닙니다")
|
||||
.or(z.literal(""))
|
||||
.optional(),
|
||||
goal: z
|
||||
.string()
|
||||
.min(3, "최소 3자")
|
||||
.max(500, "최대 500자"),
|
||||
max_depth: z.number().int().min(0).max(10),
|
||||
max_steps: z.number().int().min(1).max(50),
|
||||
max_branch: z.number().int().min(1).max(30),
|
||||
min_relevance: z.number().min(0).max(1),
|
||||
same_domain_only: z.boolean(),
|
||||
});
|
||||
|
||||
type StartResearchFormValues = z.infer<typeof startResearchSchema>;
|
||||
|
||||
function sessionStatusVariant(status?: string | null): BadgeProps["variant"] {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return "success";
|
||||
case "failed":
|
||||
return "destructive";
|
||||
case "canceled":
|
||||
return "secondary";
|
||||
case "running":
|
||||
case "pending":
|
||||
return "default";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
}
|
||||
|
||||
export default function ResearchPage() {
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const { t } = useTranslation();
|
||||
const projectName = projectId ?? "";
|
||||
|
||||
const { data: project, isLoading: projectLoading, isError, error, refetch } =
|
||||
useProject(projectName);
|
||||
const sessions = useResearchSessions(projectName);
|
||||
const startResearch = useStartResearch(projectName);
|
||||
|
||||
const [lastResult, setLastResult] = useState<ResearchRunResult | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<StartResearchFormValues>({
|
||||
resolver: zodResolver(startResearchSchema),
|
||||
defaultValues: {
|
||||
source_name: "",
|
||||
url: "",
|
||||
goal: "Semantic ontology exploration",
|
||||
max_depth: 2,
|
||||
max_steps: 12,
|
||||
max_branch: 8,
|
||||
min_relevance: 0.35,
|
||||
same_domain_only: true,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: StartResearchFormValues) => {
|
||||
try {
|
||||
setLastResult(null);
|
||||
const res = await startResearch.mutateAsync({
|
||||
project_name: projectName,
|
||||
source_name: values.source_name,
|
||||
url: values.url || undefined,
|
||||
goal: values.goal,
|
||||
max_depth: values.max_depth,
|
||||
max_steps: values.max_steps,
|
||||
max_branch: values.max_branch,
|
||||
min_relevance: values.min_relevance,
|
||||
same_domain_only: values.same_domain_only,
|
||||
});
|
||||
setLastResult(res);
|
||||
toast.success(t("research.completed", "자율 연구가 완료되었습니다"));
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
t("research.failed", "실패: {{msg}}", {
|
||||
msg: (e as Error).message,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const sources = project?.sources ?? [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-10">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/sources/${projectName}`)}
|
||||
aria-label={t("common.back", "이전")}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Brain className="h-6 w-6 text-primary" />
|
||||
{t("research.title", "자율 연구")}
|
||||
</h1>
|
||||
{project && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project.name}
|
||||
<span className="capitalize text-muted-foreground/70">
|
||||
{" "}
|
||||
· {project.domain}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<Card className="mb-6 border-destructive">
|
||||
<CardContent className="flex items-center justify-between gap-3 py-4">
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>{(error as Error).message}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
{t("common.retry", "다시 시도")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[460px_1fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Target className="h-5 w-5" />
|
||||
{t("research.formTitle", "자율 연구 설정")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"research.formDesc",
|
||||
"AI가 시드에서 시작해 스스로 링크를 따라가며 온톨로지를 확장합니다",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
noValidate
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="source_name">
|
||||
{t("research.source", "참고 소스")}
|
||||
</Label>
|
||||
{projectLoading ? (
|
||||
<Skeleton className="h-10" />
|
||||
) : (
|
||||
<Select
|
||||
id="source_name"
|
||||
{...register("source_name")}
|
||||
onChange={(e) =>
|
||||
setValue("source_name", e.target.value, {
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
{t("research.pickSource", "소스를 선택하세요...")}
|
||||
</option>
|
||||
{sources.map((s) => (
|
||||
<option key={s.id} value={s.name}>
|
||||
{s.name} ({s.type})
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
{errors.source_name && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.source_name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="goal">{t("research.goal", "목표")}</Label>
|
||||
<Textarea
|
||||
id="goal"
|
||||
rows={2}
|
||||
placeholder={t(
|
||||
"research.goalPlaceholder",
|
||||
"예: 인기 브랜드 향수의 노트 구성과 시즌 추천 정보 수집",
|
||||
)}
|
||||
{...register("goal")}
|
||||
/>
|
||||
{errors.goal && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.goal.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="url">
|
||||
{t("research.seedUrl", "시드 URL")}{" "}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({t("research.optional", "선택")})
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="url"
|
||||
placeholder="https://example.com/start"
|
||||
{...register("url")}
|
||||
/>
|
||||
{errors.url && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.url.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="max_steps">
|
||||
{t("research.maxSteps", "최대 단계")}
|
||||
</Label>
|
||||
<Input
|
||||
id="max_steps"
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
{...register("max_steps", { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.max_steps && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.max_steps.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="max_branch">
|
||||
{t("research.maxBranch", "분기 폭")}
|
||||
</Label>
|
||||
<Input
|
||||
id="max_branch"
|
||||
type="number"
|
||||
min={1}
|
||||
max={30}
|
||||
{...register("max_branch", { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.max_branch && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.max_branch.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="max_depth">
|
||||
{t("research.maxDepth", "최대 깊이")}
|
||||
</Label>
|
||||
<Input
|
||||
id="max_depth"
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
{...register("max_depth", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="min_relevance">
|
||||
{t("research.minRelevance", "최소 관련도")}
|
||||
</Label>
|
||||
<Input
|
||||
id="min_relevance"
|
||||
type="number"
|
||||
step="0.05"
|
||||
min={0}
|
||||
max={1}
|
||||
{...register("min_relevance", { valueAsNumber: true })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-input"
|
||||
{...register("same_domain_only")}
|
||||
/>
|
||||
<span>
|
||||
{t("research.sameDomainOnly", "동일 도메인만 탐색")}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isSubmitting || startResearch.isPending}
|
||||
>
|
||||
{startResearch.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-4 w-4" />
|
||||
)}
|
||||
{t("research.start", "자율 연구 시작")}
|
||||
</Button>
|
||||
{startResearch.isPending && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"research.runningHint",
|
||||
"장시간 걸릴 수 있습니다. 완료될 때까지 페이지를 닫지 마세요.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("research.resultTitle", "최근 결과")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"research.resultDesc",
|
||||
"이번 세션에서 실행된 연구의 결과",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!lastResult && !startResearch.isPending && (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"research.idleHint",
|
||||
"왼쪽에서 자율 연구를 시작하면 결과가 여기에 표시됩니다",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{startResearch.isPending && (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-center text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p>{t("research.runningTitle", "AI가 연구 중입니다...")}</p>
|
||||
</div>
|
||||
)}
|
||||
{lastResult && !startResearch.isPending && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-green-700">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
{t("research.doneHint", "완료")}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm sm:grid-cols-4">
|
||||
<Stat
|
||||
label={t("research.stepsTaken", "단계")}
|
||||
value={lastResult.steps_taken}
|
||||
/>
|
||||
<Stat
|
||||
label={t("research.pagesVisited", "페이지")}
|
||||
value={lastResult.pages_visited}
|
||||
/>
|
||||
<Stat
|
||||
label={t("research.entitiesFound", "엔티티")}
|
||||
value={lastResult.entities_found}
|
||||
/>
|
||||
<Stat
|
||||
label={t("research.claimsAdded", "클레임")}
|
||||
value={lastResult.claims_added}
|
||||
/>
|
||||
</div>
|
||||
{lastResult.error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0" />
|
||||
<span>{lastResult.error}</span>
|
||||
</div>
|
||||
)}
|
||||
<details className="rounded-md border bg-background">
|
||||
<summary className="cursor-pointer px-3 py-2 text-sm font-medium">
|
||||
{t("research.rawResult", "원시 응답 JSON")}
|
||||
</summary>
|
||||
<pre className="max-h-64 overflow-auto px-4 py-2 text-xs text-muted-foreground">
|
||||
{JSON.stringify(lastResult, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<History className="h-5 w-5" />
|
||||
{t("research.historyTitle", "세션 이력")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"research.historyDesc",
|
||||
"이 프로젝트의 자율 연구 세션 기록",
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{sessions.isLoading && (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{sessions.data && sessions.data.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
{t("research.historyEmpty", "아직 실행된 세션이 없습니다")}
|
||||
</p>
|
||||
)}
|
||||
{sessions.data && sessions.data.length > 0 && (
|
||||
<ul className="divide-y">
|
||||
{sessions.data.map((s) => (
|
||||
<li
|
||||
key={s.job_id}
|
||||
className="flex items-center justify-between gap-3 py-3 text-sm"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">#{s.job_id}</span>
|
||||
{s.status && (
|
||||
<Badge variant={sessionStatusVariant(s.status)}>
|
||||
{s.status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{s.goal && (
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{s.goal}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted-foreground">
|
||||
{typeof s.pages_visited === "number" && (
|
||||
<div>
|
||||
{s.pages_visited}{" "}
|
||||
{t("research.pages", "페이지")}
|
||||
</div>
|
||||
)}
|
||||
{s.started_at && (
|
||||
<div>{new Date(s.started_at).toLocaleString()}</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | undefined | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border bg-background px-3 py-2">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-xl font-semibold">
|
||||
{typeof value === "number" ? value : "—"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
436
ontology_platform/web/frontend/src/pages/ReviewPage.tsx
Normal file
436
ontology_platform/web/frontend/src/pages/ReviewPage.tsx
Normal file
@@ -0,0 +1,436 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Check,
|
||||
Eye,
|
||||
Filter,
|
||||
ListChecks,
|
||||
Search,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge, BadgeProps } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useClaims, useUpdateClaimStatus } from "@/hooks/useClaims";
|
||||
import { useProject } from "@/hooks/useProjects";
|
||||
import {
|
||||
formatDateTime,
|
||||
formatPercent,
|
||||
humanizeValue,
|
||||
reviewLabel,
|
||||
} from "@/lib/display";
|
||||
import { Claim } from "@/lib/api/claims";
|
||||
|
||||
function statusVariant(status: string | undefined): BadgeProps["variant"] {
|
||||
switch (reviewLabel(status)) {
|
||||
case "approved":
|
||||
return "success";
|
||||
case "rejected":
|
||||
return "destructive";
|
||||
case "candidate":
|
||||
return "warning";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
}
|
||||
|
||||
function confidenceBucket(claim: Claim): string {
|
||||
if (typeof claim.confidence !== "number") return "unknown";
|
||||
if (claim.confidence >= 0.9) return "auto-approve candidate";
|
||||
if (claim.confidence >= 0.7) return "normal review";
|
||||
return "priority review";
|
||||
}
|
||||
|
||||
function claimObject(claim: Claim): string {
|
||||
return humanizeValue(claim.object ?? claim.object_value);
|
||||
}
|
||||
|
||||
export default function ReviewPage() {
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const projectName = projectId ?? "";
|
||||
const { data: project } = useProject(projectName);
|
||||
const claims = useClaims(projectName, {
|
||||
includeCandidates: true,
|
||||
limit: 300,
|
||||
});
|
||||
const updateStatus = useUpdateClaimStatus(projectName);
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const filteredClaims = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return (claims.data ?? []).filter((claim) => {
|
||||
if (statusFilter !== "all" && reviewLabel(claim.status) !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
if (!query) return true;
|
||||
return [
|
||||
claim.subject,
|
||||
claim.predicate,
|
||||
claimObject(claim),
|
||||
claim.evidence_text,
|
||||
claim.page_url,
|
||||
claim.source,
|
||||
]
|
||||
.map((value) => humanizeValue(value, "").toLowerCase())
|
||||
.some((value) => value.includes(query));
|
||||
});
|
||||
}, [claims.data, search, statusFilter]);
|
||||
|
||||
const selectedClaim = useMemo(() => {
|
||||
return (
|
||||
filteredClaims.find((claim) => claim.id === selectedId) ??
|
||||
filteredClaims[0]
|
||||
);
|
||||
}, [filteredClaims, selectedId]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const all = claims.data ?? [];
|
||||
return {
|
||||
total: all.length,
|
||||
candidate: all.filter((claim) => reviewLabel(claim.status) === "candidate")
|
||||
.length,
|
||||
approved: all.filter((claim) => reviewLabel(claim.status) === "approved")
|
||||
.length,
|
||||
rejected: all.filter((claim) => reviewLabel(claim.status) === "rejected")
|
||||
.length,
|
||||
};
|
||||
}, [claims.data]);
|
||||
|
||||
const applyStatus = async (claim: Claim, status: string) => {
|
||||
try {
|
||||
await updateStatus.mutateAsync({
|
||||
claimId: claim.id,
|
||||
status,
|
||||
reason:
|
||||
status === "rejected"
|
||||
? "Rejected from Claim Review"
|
||||
: "Updated from Claim Review",
|
||||
});
|
||||
toast.success(`Claim ${status}`);
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-6 py-8">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/schema/${projectName}`)}
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<ListChecks className="h-6 w-6 text-primary" />
|
||||
Claim Review
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project?.name ?? projectName} · 후보, 승인, 반려 상태 분리 검토
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => navigate(`/quality/${projectName}`)}>
|
||||
Quality Inspector
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{claims.isError && (
|
||||
<Card className="mb-6 border-destructive">
|
||||
<CardContent className="flex items-center gap-2 py-4 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{(claims.error as Error).message}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-4">
|
||||
<Metric label="Total" value={counts.total} />
|
||||
<Metric label="Candidate" value={counts.candidate} />
|
||||
<Metric label="Approved" value={counts.approved} />
|
||||
<Metric label="Rejected" value={counts.rejected} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_420px]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Review Queue</CardTitle>
|
||||
<CardDescription>
|
||||
신뢰도, 출처, 생성 방식, 검증 결과를 함께 확인합니다.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-wrap gap-2">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="w-56 pl-9"
|
||||
placeholder="Search claims"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Filter className="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value)}
|
||||
className="w-40 pl-9"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="candidate">Candidate</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{claims.isLoading && (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<Skeleton key={index} className="h-24" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!claims.isLoading && filteredClaims.length === 0 && (
|
||||
<p className="py-10 text-center text-sm text-muted-foreground">
|
||||
조건에 맞는 클레임이 없습니다.
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
{filteredClaims.map((claim) => (
|
||||
<article
|
||||
key={claim.id}
|
||||
className="rounded-md border bg-background p-4"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedId(claim.id)}
|
||||
className="min-w-0 flex-1 text-left"
|
||||
>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<Badge variant={statusVariant(claim.status)}>
|
||||
{reviewLabel(claim.status)}
|
||||
</Badge>
|
||||
<Badge variant="secondary">{claim.predicate}</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{confidenceBucket(claim)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-medium">
|
||||
{humanizeValue(claim.subject)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">-></span>
|
||||
<span className="font-medium">{claimObject(claim)}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
<span>Confidence {formatPercent(claim.confidence)}</span>
|
||||
<span>Source {humanizeValue(claim.source)}</span>
|
||||
<span>
|
||||
Evidence {claim.evidence_text ? "yes" : "missing"}
|
||||
</span>
|
||||
<span>
|
||||
Method {humanizeValue(claim.extraction_method)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedId(claim.id)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
Detail
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => applyStatus(claim, "validated_claim")}
|
||||
disabled={updateStatus.isPending}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => applyStatus(claim, "rejected")}
|
||||
disabled={updateStatus.isPending}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<aside className="lg:sticky lg:top-4 lg:self-start">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Claim Detail</CardTitle>
|
||||
<CardDescription>
|
||||
근거, 출처, 검증 결과, 히스토리
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!selectedClaim && (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
클레임을 선택하세요.
|
||||
</p>
|
||||
)}
|
||||
{selectedClaim && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant={statusVariant(selectedClaim.status)}>
|
||||
{reviewLabel(selectedClaim.status)}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{formatPercent(selectedClaim.confidence)}
|
||||
</Badge>
|
||||
{selectedClaim.review_required && (
|
||||
<Badge variant="warning">review required</Badge>
|
||||
)}
|
||||
</div>
|
||||
<DetailRow label="Subject" value={selectedClaim.subject} />
|
||||
<DetailRow label="Subject Type" value={selectedClaim.subject_type} />
|
||||
<DetailRow label="Predicate" value={selectedClaim.predicate} />
|
||||
<DetailRow
|
||||
label="Object"
|
||||
value={selectedClaim.object ?? selectedClaim.object_value}
|
||||
/>
|
||||
<DetailRow label="Source" value={selectedClaim.source} />
|
||||
<DetailRow label="Source URL" value={selectedClaim.page_url} />
|
||||
<DetailRow
|
||||
label="Page Type"
|
||||
value={selectedClaim.page_type}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Created By"
|
||||
value={selectedClaim.extraction_method}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Validation"
|
||||
value={
|
||||
selectedClaim.validation_status ??
|
||||
selectedClaim.graph_merge_status
|
||||
}
|
||||
/>
|
||||
{selectedClaim.graph_merge_reason && (
|
||||
<DetailRow
|
||||
label="Graph Reason"
|
||||
value={selectedClaim.graph_merge_reason}
|
||||
/>
|
||||
)}
|
||||
{selectedClaim.evidence_text && (
|
||||
<section>
|
||||
<h3 className="mb-2 text-sm font-medium">Evidence Text</h3>
|
||||
<div className="rounded-md bg-yellow-50 px-3 py-2 text-sm leading-relaxed text-yellow-950">
|
||||
{selectedClaim.evidence_text}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{selectedClaim.confidence_breakdown && (
|
||||
<section>
|
||||
<h3 className="mb-2 text-sm font-medium">
|
||||
Confidence Breakdown
|
||||
</h3>
|
||||
<pre className="max-h-44 overflow-auto rounded-md bg-secondary/30 p-3 text-xs">
|
||||
{JSON.stringify(
|
||||
selectedClaim.confidence_breakdown,
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</section>
|
||||
)}
|
||||
{selectedClaim.source_history &&
|
||||
selectedClaim.source_history.length > 0 && (
|
||||
<section>
|
||||
<h3 className="mb-2 text-sm font-medium">
|
||||
Source History
|
||||
</h3>
|
||||
<pre className="max-h-44 overflow-auto rounded-md bg-secondary/30 p-3 text-xs">
|
||||
{JSON.stringify(selectedClaim.source_history, null, 2)}
|
||||
</pre>
|
||||
</section>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||
<Button
|
||||
onClick={() =>
|
||||
applyStatus(selectedClaim, "validated_claim")
|
||||
}
|
||||
disabled={updateStatus.isPending}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => applyStatus(selectedClaim, "rejected")}
|
||||
disabled={updateStatus.isPending}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Last seen {formatDateTime(selectedClaim.last_seen_at)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-2xl font-semibold">
|
||||
{value.toLocaleString()}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: unknown }) {
|
||||
return (
|
||||
<div className="rounded-md border bg-background px-3 py-2 text-sm">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 break-words font-medium">{humanizeValue(value)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
451
ontology_platform/web/frontend/src/pages/SchemaDesignerPage.tsx
Normal file
451
ontology_platform/web/frontend/src/pages/SchemaDesignerPage.tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
BookOpen,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
Plus,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useOntology } from "@/hooks/useDomains";
|
||||
import { useProject } from "@/hooks/useProjects";
|
||||
import {
|
||||
useCreateSchemaEntityType,
|
||||
useCreateSchemaRelationType,
|
||||
useOntologyProposals,
|
||||
useOntologyRegistry,
|
||||
} from "@/hooks/usePlatform";
|
||||
import { formatDateTime, formatPercent, humanizeValue } from "@/lib/display";
|
||||
|
||||
function splitList(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export default function SchemaDesignerPage() {
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const projectName = projectId ?? "";
|
||||
const { data: project } = useProject(projectName);
|
||||
const ontology = useOntology(project?.domain);
|
||||
const registry = useOntologyRegistry(projectName);
|
||||
const proposals = useOntologyProposals(projectName, 100);
|
||||
const createEntityType = useCreateSchemaEntityType(projectName);
|
||||
const createRelationType = useCreateSchemaRelationType(projectName);
|
||||
|
||||
const [entityName, setEntityName] = useState("");
|
||||
const [entityDescription, setEntityDescription] = useState("");
|
||||
const [relationName, setRelationName] = useState("");
|
||||
const [relationSubjectTypes, setRelationSubjectTypes] = useState("");
|
||||
const [relationObjectTypes, setRelationObjectTypes] = useState("");
|
||||
const [relationDescription, setRelationDescription] = useState("");
|
||||
|
||||
const domainTypes = useMemo(
|
||||
() => new Set(ontology.data?.entity_types ?? []),
|
||||
[ontology.data?.entity_types],
|
||||
);
|
||||
const registeredEntityNames = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
(registry.data?.entity_types ?? []).map((row) =>
|
||||
row.name.toLowerCase(),
|
||||
),
|
||||
),
|
||||
[registry.data?.entity_types],
|
||||
);
|
||||
const unregisteredDomainTypes = (ontology.data?.entity_types ?? []).filter(
|
||||
(name) => !registeredEntityNames.has(name.toLowerCase()),
|
||||
);
|
||||
|
||||
const onCreateEntity = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!entityName.trim()) return;
|
||||
try {
|
||||
await createEntityType.mutateAsync({
|
||||
name: entityName.trim(),
|
||||
domain: project?.domain ?? "generic",
|
||||
description: entityDescription.trim() || null,
|
||||
});
|
||||
toast.success("Entity type saved");
|
||||
setEntityName("");
|
||||
setEntityDescription("");
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const onCreateRelation = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!relationName.trim()) return;
|
||||
try {
|
||||
await createRelationType.mutateAsync({
|
||||
name: relationName.trim(),
|
||||
domain: project?.domain ?? "generic",
|
||||
description: relationDescription.trim() || null,
|
||||
allowed_subject_types: splitList(relationSubjectTypes),
|
||||
allowed_object_types: splitList(relationObjectTypes),
|
||||
min_confidence: 0.8,
|
||||
});
|
||||
toast.success("Predicate rule saved");
|
||||
setRelationName("");
|
||||
setRelationSubjectTypes("");
|
||||
setRelationObjectTypes("");
|
||||
setRelationDescription("");
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-6 py-8">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/analysis/${projectName}`)}
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<ShieldCheck className="h-6 w-6 text-primary" />
|
||||
Schema Designer
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{project?.name ?? projectName} · 엔티티 타입과 Predicate 규칙 관리
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => registry.refetch()}>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{(registry.isError || ontology.isError || proposals.isError) && (
|
||||
<Card className="mb-6 border-destructive">
|
||||
<CardContent className="flex items-center gap-2 py-4 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{(registry.error as Error | undefined)?.message ??
|
||||
(ontology.error as Error | undefined)?.message ??
|
||||
(proposals.error as Error | undefined)?.message}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="mb-6 grid gap-4 md:grid-cols-4">
|
||||
<Metric label="Entity types" value={registry.data?.entity_types.length} />
|
||||
<Metric
|
||||
label="Predicates"
|
||||
value={registry.data?.relation_types.length}
|
||||
/>
|
||||
<Metric
|
||||
label="Schema proposals"
|
||||
value={proposals.data?.length}
|
||||
/>
|
||||
<Metric
|
||||
label="Domain coverage"
|
||||
value={
|
||||
ontology.data?.entity_types.length
|
||||
? formatPercent(
|
||||
1 -
|
||||
unregisteredDomainTypes.length /
|
||||
ontology.data.entity_types.length,
|
||||
)
|
||||
: "-"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[380px_1fr]">
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BookOpen className="h-5 w-5" />
|
||||
Entity Type
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
AI가 생성할 수 있는 개체 종류를 명시합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-3" onSubmit={onCreateEntity}>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="entityName">Name</Label>
|
||||
<Input
|
||||
id="entityName"
|
||||
value={entityName}
|
||||
onChange={(event) => setEntityName(event.target.value)}
|
||||
placeholder="Product"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="entityDescription">Description</Label>
|
||||
<Textarea
|
||||
id="entityDescription"
|
||||
value={entityDescription}
|
||||
onChange={(event) =>
|
||||
setEntityDescription(event.target.value)
|
||||
}
|
||||
rows={2}
|
||||
placeholder="상품 또는 서비스 개체"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={createEntityType.isPending}
|
||||
>
|
||||
{createEntityType.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
Save entity type
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<GitBranch className="h-5 w-5" />
|
||||
Predicate Rule
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
주어/목적어 타입과 최소 신뢰도 기준을 정의합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="space-y-3" onSubmit={onCreateRelation}>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="relationName">Predicate</Label>
|
||||
<Input
|
||||
id="relationName"
|
||||
value={relationName}
|
||||
onChange={(event) => setRelationName(event.target.value)}
|
||||
placeholder="hasBrand"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="subjectTypes">Subject types</Label>
|
||||
<Input
|
||||
id="subjectTypes"
|
||||
value={relationSubjectTypes}
|
||||
onChange={(event) =>
|
||||
setRelationSubjectTypes(event.target.value)
|
||||
}
|
||||
placeholder="Product, Perfume"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="objectTypes">Object types</Label>
|
||||
<Input
|
||||
id="objectTypes"
|
||||
value={relationObjectTypes}
|
||||
onChange={(event) =>
|
||||
setRelationObjectTypes(event.target.value)
|
||||
}
|
||||
placeholder="Brand"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="relationDescription">Description</Label>
|
||||
<Textarea
|
||||
id="relationDescription"
|
||||
value={relationDescription}
|
||||
onChange={(event) =>
|
||||
setRelationDescription(event.target.value)
|
||||
}
|
||||
rows={2}
|
||||
placeholder="상품이 특정 브랜드에 속함"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={createRelationType.isPending}
|
||||
>
|
||||
{createRelationType.isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
Save predicate rule
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Registered Entity Types</CardTitle>
|
||||
<CardDescription>
|
||||
프로젝트 스키마 레지스트리에 저장된 타입
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{registry.isLoading ? (
|
||||
<Skeleton className="h-32" />
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{(registry.data?.entity_types ?? []).map((row) => (
|
||||
<article key={row.id} className="rounded-md border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-medium">{row.name}</span>
|
||||
<Badge variant={domainTypes.has(row.name) ? "success" : "outline"}>
|
||||
{row.domain ?? "generic"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-sm text-muted-foreground">
|
||||
{row.description || humanizeValue(row.metadata)}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Predicate Rules</CardTitle>
|
||||
<CardDescription>
|
||||
허용 관계와 타입 제약. 검증 단계에서 활용됩니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{(registry.data?.relation_types ?? []).map((row) => (
|
||||
<article key={row.id} className="rounded-md border p-4">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{row.name}</span>
|
||||
<Badge variant="outline">{row.status ?? "active"}</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatPercent(row.confidence)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid gap-3 text-sm md:grid-cols-2">
|
||||
<SchemaList
|
||||
label="Subject"
|
||||
values={row.allowed_subject_types}
|
||||
/>
|
||||
<SchemaList
|
||||
label="Object"
|
||||
values={row.allowed_object_types}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
{row.description ||
|
||||
humanizeValue(row.semantic_constraints)}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
{registry.data?.relation_types.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
등록된 Predicate 규칙이 없습니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Schema Proposals</CardTitle>
|
||||
<CardDescription>
|
||||
추출 중 발견된 스키마 보강 후보
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="divide-y">
|
||||
{(proposals.data ?? []).map((proposal) => (
|
||||
<li key={proposal.id} className="py-3 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{proposal.proposal_type}
|
||||
</Badge>
|
||||
<span className="font-medium">{proposal.name}</span>
|
||||
<Badge variant="outline">{proposal.status}</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{proposal.reason || proposal.evidence || "-"}
|
||||
</p>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{formatDateTime(proposal.updated_at)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{proposals.data?.length === 0 && (
|
||||
<li className="py-6 text-center text-sm text-muted-foreground">
|
||||
현재 스키마 제안이 없습니다.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | string | undefined;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-2xl font-semibold">
|
||||
{typeof value === "number" ? value.toLocaleString() : value ?? "-"}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SchemaList({ label, values }: { label: string; values: string[] }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-muted-foreground">{label}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{values.length ? (
|
||||
values.map((value) => (
|
||||
<Badge key={value} variant="outline">
|
||||
{value}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">Any</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
17
ontology_platform/web/frontend/src/stores/index.ts
Normal file
17
ontology_platform/web/frontend/src/stores/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import ontologyReducer from "./slices/ontologySlice";
|
||||
import crawlReducer from "./slices/crawlSlice";
|
||||
import uiReducer from "./slices/uiSlice";
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
ontology: ontologyReducer,
|
||||
crawl: crawlReducer,
|
||||
ui: uiReducer,
|
||||
},
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
|
||||
export default store;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
export type CrawlStepKey = "fetching" | "extracting" | "merging" | "validating";
|
||||
|
||||
export interface CrawlUiState {
|
||||
activeJobId: string | null;
|
||||
expandedStep: CrawlStepKey | null;
|
||||
showLogs: boolean;
|
||||
autoScrollLogs: boolean;
|
||||
}
|
||||
|
||||
const initialState: CrawlUiState = {
|
||||
activeJobId: null,
|
||||
expandedStep: null,
|
||||
showLogs: true,
|
||||
autoScrollLogs: true,
|
||||
};
|
||||
|
||||
const crawlSlice = createSlice({
|
||||
name: "crawl",
|
||||
initialState,
|
||||
reducers: {
|
||||
setActiveJob: (state, action: PayloadAction<string | null>) => {
|
||||
state.activeJobId = action.payload;
|
||||
},
|
||||
expandStep: (state, action: PayloadAction<CrawlStepKey | null>) => {
|
||||
state.expandedStep = action.payload;
|
||||
},
|
||||
toggleLogs: (state) => {
|
||||
state.showLogs = !state.showLogs;
|
||||
},
|
||||
setAutoScroll: (state, action: PayloadAction<boolean>) => {
|
||||
state.autoScrollLogs = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setActiveJob, expandStep, toggleLogs, setAutoScroll } =
|
||||
crawlSlice.actions;
|
||||
export default crawlSlice.reducer;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
export type OntologyFormat = "yaml" | "json" | "owl";
|
||||
|
||||
export interface OntologyDraft {
|
||||
name: string;
|
||||
domain: string;
|
||||
format: OntologyFormat | null;
|
||||
fileName: string | null;
|
||||
rawText: string;
|
||||
}
|
||||
|
||||
export interface OntologyDraftState {
|
||||
draft: OntologyDraft;
|
||||
isDirty: boolean;
|
||||
}
|
||||
|
||||
const emptyDraft: OntologyDraft = {
|
||||
name: "",
|
||||
domain: "",
|
||||
format: null,
|
||||
fileName: null,
|
||||
rawText: "",
|
||||
};
|
||||
|
||||
const initialState: OntologyDraftState = {
|
||||
draft: emptyDraft,
|
||||
isDirty: false,
|
||||
};
|
||||
|
||||
const ontologySlice = createSlice({
|
||||
name: "ontology",
|
||||
initialState,
|
||||
reducers: {
|
||||
updateDraft: (state, action: PayloadAction<Partial<OntologyDraft>>) => {
|
||||
state.draft = { ...state.draft, ...action.payload };
|
||||
state.isDirty = true;
|
||||
},
|
||||
resetDraft: (state) => {
|
||||
state.draft = emptyDraft;
|
||||
state.isDirty = false;
|
||||
},
|
||||
markClean: (state) => {
|
||||
state.isDirty = false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { updateDraft, resetDraft, markClean } = ontologySlice.actions;
|
||||
export default ontologySlice.reducer;
|
||||
65
ontology_platform/web/frontend/src/stores/slices/uiSlice.ts
Normal file
65
ontology_platform/web/frontend/src/stores/slices/uiSlice.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
||||
|
||||
export interface UIState {
|
||||
sidebarOpen: boolean;
|
||||
currentStep: number; // 0: dashboard, 1: onboard, 2: sources, 3: crawl, 4: review
|
||||
selectedProjectId: string | null;
|
||||
isLoading: boolean;
|
||||
notification: {
|
||||
type: "success" | "error" | "info" | "warning" | null;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
const initialState: UIState = {
|
||||
sidebarOpen: true,
|
||||
currentStep: 0,
|
||||
selectedProjectId: null,
|
||||
isLoading: false,
|
||||
notification: {
|
||||
type: null,
|
||||
message: "",
|
||||
},
|
||||
};
|
||||
|
||||
const uiSlice = createSlice({
|
||||
name: "ui",
|
||||
initialState,
|
||||
reducers: {
|
||||
toggleSidebar: (state) => {
|
||||
state.sidebarOpen = !state.sidebarOpen;
|
||||
},
|
||||
setCurrentStep: (state, action: PayloadAction<number>) => {
|
||||
state.currentStep = action.payload;
|
||||
},
|
||||
setSelectedProject: (state, action: PayloadAction<string | null>) => {
|
||||
state.selectedProjectId = action.payload;
|
||||
},
|
||||
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||
state.isLoading = action.payload;
|
||||
},
|
||||
showNotification: (
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
type: "success" | "error" | "info" | "warning";
|
||||
message: string;
|
||||
}>
|
||||
) => {
|
||||
state.notification = action.payload;
|
||||
},
|
||||
clearNotification: (state) => {
|
||||
state.notification = { type: null, message: "" };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
toggleSidebar,
|
||||
setCurrentStep,
|
||||
setSelectedProject,
|
||||
setLoading,
|
||||
showNotification,
|
||||
clearNotification,
|
||||
} = uiSlice.actions;
|
||||
|
||||
export default uiSlice.reducer;
|
||||
60
ontology_platform/web/frontend/src/styles/globals.css
Normal file
60
ontology_platform/web/frontend/src/styles/globals.css
Normal file
@@ -0,0 +1,60 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--muted: 221.2 63.6% 97%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 221.2 83.2% 53.3%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--radius: 0.5rem;
|
||||
--primary: 222.2 47.6% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96%;
|
||||
--secondary-foreground: 222.2 47.6% 11.2%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 91.2% 59.8%;
|
||||
--accent-foreground: 222.2 47.6% 11.2%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.6% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
44
ontology_platform/web/frontend/tailwind.config.js
Normal file
44
ontology_platform/web/frontend/tailwind.config.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
40
ontology_platform/web/frontend/tsconfig.json
Normal file
40
ontology_platform/web/frontend/tsconfig.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path mapping */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@components/*": ["src/components/*"],
|
||||
"@pages/*": ["src/pages/*"],
|
||||
"@hooks/*": ["src/hooks/*"],
|
||||
"@stores/*": ["src/stores/*"],
|
||||
"@types/*": ["src/types/*"],
|
||||
"@utils/*": ["src/utils/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
ontology_platform/web/frontend/tsconfig.node.json
Normal file
10
ontology_platform/web/frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
49
ontology_platform/web/frontend/vite.config.js
Normal file
49
ontology_platform/web/frontend/vite.config.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "node:path";
|
||||
|
||||
const BACKEND = "http://127.0.0.1:8001";
|
||||
const PROXY_PREFIXES = [
|
||||
"/projects",
|
||||
"/ontology",
|
||||
"/ontologies",
|
||||
"/domains",
|
||||
"/extractors",
|
||||
"/crawl",
|
||||
"/crawl-site",
|
||||
"/discover",
|
||||
"/research",
|
||||
"/recommend",
|
||||
"/claims",
|
||||
"/entities",
|
||||
"/health",
|
||||
"/info",
|
||||
"/process",
|
||||
"/api/v1",
|
||||
"/api/v1/graph",
|
||||
"/api/v1/llm",
|
||||
];
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
root: ".",
|
||||
base: "/static/",
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(process.cwd(), "src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "../static",
|
||||
emptyOutDir: true,
|
||||
assetsDir: "assets",
|
||||
sourcemap: true,
|
||||
},
|
||||
server: {
|
||||
port: 8000,
|
||||
strictPort: true,
|
||||
proxy: Object.fromEntries(
|
||||
PROXY_PREFIXES.map((prefix) => [prefix, { target: BACKEND, changeOrigin: false }]),
|
||||
),
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user