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(() => { 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 (

Build Pipeline

{project?.name ?? projectName} pipeline status and recovery actions

{notice && ( {notice.text} )} {pipeline.isError && ( {(pipeline.error as Error).message} )}
s.key === "crawled")?.count} /> s.key === "extracted")?.count} /> s.key === "claims")?.count} />
Ontology Build Flow The ten major build stages, current counts, and recovery entry points.
{completed}/{steps.length} complete
{pipeline.isLoading ? (
{Array.from({ length: 6 }).map((_, index) => ( ))}
) : (
{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 (
Step {index + 1}

{step.name}

{step.description}

{step.status}
{Math.round(progress)}%
{step.count.toLocaleString()} items {step.extra && / {step.extra}}
); })}
)}
Recent Pages Most recently collected and typed pages.
    {(pipeline.data?.recent_pages ?? []).map((page) => (
  • {page.page_type ?? "unknown"} {page.title || page.url}
    {page.url}
    {formatDateTime(page.fetched_at)}
  • ))} {pipeline.data?.recent_pages.length === 0 && (
  • No collected pages yet.
  • )}
Recent Claims Latest relation claims from the build flow.
    {(pipeline.data?.recent_claims ?? []).map((claim) => (
  • {claim.subject || "-"} {claim.predicate}
    {formatPercent(claim.confidence)} {claim.status} {formatDateTime(claim.last_seen_at)}
  • ))} {pipeline.data?.recent_claims.length === 0 && (
  • No claims have been generated yet.
  • )}
); } function MetricCard({ icon: Icon, label, value, }: { icon: React.ComponentType<{ className?: string }>; label: string; value: number | string | undefined; }) { return (
{label}
{typeof value === "number" ? value.toLocaleString() : value ?? "-"}
); }