[crawler_platform 삭제]

This commit is contained in:
LASTA_DEV01\lasta
2026-05-20 13:21:08 +09:00
parent be717a8f9a
commit fc69dfd063
173 changed files with 307 additions and 1982 deletions

View 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>
);
}