graph
This commit is contained in:
@@ -28,6 +28,11 @@ export const claimSchema = z
|
||||
graph_merge_status: z.string().nullable().optional(),
|
||||
graph_merge_reason: z.string().nullable().optional(),
|
||||
confidence_breakdown: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
agreement: z.string().nullable().optional(),
|
||||
extraction_source: z.string().nullable().optional(),
|
||||
claim_kind: z.string().nullable().optional(),
|
||||
rule_confidence: z.number().nullable().optional(),
|
||||
llm_confidence: z.number().nullable().optional(),
|
||||
review_required: z.boolean().nullable().optional(),
|
||||
review_reason: z.string().nullable().optional(),
|
||||
conflict_status: z.string().nullable().optional(),
|
||||
|
||||
@@ -9,10 +9,27 @@ export const crawlPageItemSchema = z
|
||||
status: z.string().optional(),
|
||||
page_type: z.string().optional(),
|
||||
title: z.string().nullable().optional(),
|
||||
extraction_mode: z.string().nullable().optional(),
|
||||
effective_extraction_mode: z.string().nullable().optional(),
|
||||
llm_skipped: z.boolean().optional(),
|
||||
llm_skip_reason: z.string().nullable().optional(),
|
||||
fallback_used: z.boolean().optional(),
|
||||
agreement_claim_count: z.number().optional(),
|
||||
conflict_claim_count: z.number().optional(),
|
||||
error: z.string().nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const crawlExtractionSummarySchema = z
|
||||
.object({
|
||||
llm_skipped_count: z.number().default(0),
|
||||
fallback_count: z.number().default(0),
|
||||
conflict_claim_count: z.number().default(0),
|
||||
agreement_claim_count: z.number().default(0),
|
||||
llm_call_count: z.number().default(0),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const crawlProgressSchema = z
|
||||
.object({
|
||||
seed_url: z.string().optional(),
|
||||
@@ -23,6 +40,7 @@ export const crawlProgressSchema = z
|
||||
errors: z.array(z.string()).optional(),
|
||||
pages: z.array(crawlPageItemSchema).optional(),
|
||||
latest_page: crawlPageItemSchema.optional(),
|
||||
extraction_summary: crawlExtractionSummarySchema.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -49,9 +67,11 @@ export interface StartSiteCrawlRequest {
|
||||
max_pages?: number;
|
||||
same_domain_only?: boolean;
|
||||
analyze_page_types?: string[];
|
||||
extraction_mode?: "rule_only" | "llm_only" | "hybrid" | "compare";
|
||||
extractor_provider?: string;
|
||||
extractor_model?: string | null;
|
||||
extractor_base_url?: string | null;
|
||||
fallback_to_rules?: boolean;
|
||||
check_robots_txt?: boolean;
|
||||
respect_robots_txt?: boolean | null;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,20 @@ export const extractionLogSchema = z
|
||||
validation: z.unknown().optional(),
|
||||
page_context: z.unknown().optional(),
|
||||
candidate_count: z.number().default(0),
|
||||
extraction_mode: z.string().nullable().optional(),
|
||||
effective_extraction_mode: z.string().nullable().optional(),
|
||||
comparison: recordSchema.nullable().optional(),
|
||||
rule_entity_count: z.number().nullable().optional(),
|
||||
rule_claim_count: z.number().nullable().optional(),
|
||||
llm_entity_count: z.number().nullable().optional(),
|
||||
llm_claim_count: z.number().nullable().optional(),
|
||||
agreement_claim_count: z.number().nullable().optional(),
|
||||
rule_only_claim_count: z.number().nullable().optional(),
|
||||
llm_only_claim_count: z.number().nullable().optional(),
|
||||
conflict_claim_count: z.number().nullable().optional(),
|
||||
llm_skipped: z.boolean().nullable().optional(),
|
||||
llm_skip_reason: z.string().nullable().optional(),
|
||||
fallback: z.string().nullable().optional(),
|
||||
raw_output: z.unknown().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -62,9 +62,11 @@ export interface StartResearchRequest {
|
||||
min_relevance?: number;
|
||||
same_domain_only?: boolean;
|
||||
analyze_page_types?: string[];
|
||||
extraction_mode?: "rule_only" | "llm_only" | "hybrid" | "compare";
|
||||
extractor_provider?: string;
|
||||
extractor_model?: string | null;
|
||||
extractor_base_url?: string | null;
|
||||
fallback_to_rules?: boolean;
|
||||
check_robots_txt?: boolean;
|
||||
respect_robots_txt?: boolean | null;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,11 @@ const startCrawlSchema = z.object({
|
||||
max_depth: z.number().int().min(0).max(10),
|
||||
max_pages: z.number().int().min(1).max(500),
|
||||
same_domain_only: z.boolean(),
|
||||
extraction_mode: z.enum(["rule_only", "llm_only", "hybrid", "compare"]),
|
||||
extractor_provider: z.enum(["lm_studio", "openai", "ollama"]),
|
||||
extractor_model: z.string().optional(),
|
||||
extractor_base_url: z.string().optional(),
|
||||
fallback_to_rules: z.boolean(),
|
||||
});
|
||||
|
||||
type StartCrawlFormValues = z.infer<typeof startCrawlSchema>;
|
||||
@@ -96,14 +101,22 @@ export default function CrawlPage() {
|
||||
max_depth: 2,
|
||||
max_pages: 30,
|
||||
same_domain_only: true,
|
||||
extraction_mode: "hybrid",
|
||||
extractor_provider: "lm_studio",
|
||||
extractor_model: "",
|
||||
extractor_base_url: "http://localhost:1234/v1",
|
||||
fallback_to_rules: true,
|
||||
},
|
||||
});
|
||||
|
||||
const onStart = async (values: StartCrawlFormValues) => {
|
||||
try {
|
||||
const usesLlm = values.extraction_mode !== "rule_only";
|
||||
const created = await startCrawl.mutateAsync({
|
||||
project_name: projectName,
|
||||
...values,
|
||||
extractor_model: usesLlm ? values.extractor_model || null : null,
|
||||
extractor_base_url: usesLlm ? values.extractor_base_url || null : null,
|
||||
});
|
||||
setActiveJobId(created.job_id);
|
||||
toast.success(
|
||||
@@ -136,6 +149,8 @@ export default function CrawlPage() {
|
||||
|
||||
const sources = project?.sources ?? [];
|
||||
const sourceName = watch("source_name");
|
||||
const extractionMode = watch("extraction_mode");
|
||||
const usesLlm = extractionMode !== "rule_only";
|
||||
const selectedSource = sources.find((s) => s.name === sourceName);
|
||||
const progress = job?.progress;
|
||||
const visited = progress?.visited_count ?? 0;
|
||||
@@ -340,6 +355,60 @@ export default function CrawlPage() {
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="space-y-3 rounded-md border bg-background-subtle p-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="extraction_mode">Extraction mode</Label>
|
||||
<Select id="extraction_mode" {...register("extraction_mode")}>
|
||||
<option value="hybrid">Hybrid</option>
|
||||
<option value="rule_only">Rule only</option>
|
||||
<option value="llm_only">LLM only</option>
|
||||
<option value="compare">Compare</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="extractor_provider">Provider</Label>
|
||||
<Select
|
||||
id="extractor_provider"
|
||||
disabled={!usesLlm}
|
||||
{...register("extractor_provider")}
|
||||
>
|
||||
<option value="lm_studio">LM Studio</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{usesLlm && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="extractor_model">Model</Label>
|
||||
<Input
|
||||
id="extractor_model"
|
||||
placeholder="deepseek-r1-distill-qwen-7b"
|
||||
{...register("extractor_model")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="extractor_base_url">Base URL</Label>
|
||||
<Input
|
||||
id="extractor_base_url"
|
||||
placeholder="http://localhost:1234/v1"
|
||||
{...register("extractor_base_url")}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-input"
|
||||
{...register("fallback_to_rules")}
|
||||
/>
|
||||
<span>Fallback to rules</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleDot,
|
||||
Eye,
|
||||
EyeOff,
|
||||
@@ -58,6 +67,18 @@ const COLORS = [
|
||||
|
||||
const VIEW_W = 860;
|
||||
const VIEW_H = 600;
|
||||
const GRAPH_PADDING = 64;
|
||||
const CLUSTER_CELL_W = 220;
|
||||
const CLUSTER_CELL_H = 176;
|
||||
const NODE_MIN_R = 8;
|
||||
const NODE_MAX_R = 16;
|
||||
const LITERAL_MIN_R = 6;
|
||||
const LITERAL_MAX_R = 11;
|
||||
|
||||
interface GraphCanvas {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface VisualNode {
|
||||
id: string;
|
||||
@@ -68,6 +89,7 @@ interface VisualNode {
|
||||
radius: number;
|
||||
color: string;
|
||||
source: "entity" | "literal";
|
||||
isHub?: boolean;
|
||||
raw?: GraphNode;
|
||||
}
|
||||
|
||||
@@ -85,6 +107,243 @@ function nodeColor(type: string, types: string[]): string {
|
||||
return COLORS[index % COLORS.length];
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function hashToUnit(input: string) {
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
hash ^= input.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0) / 4294967295;
|
||||
}
|
||||
|
||||
function predicateAngle(predicate: string) {
|
||||
const normalized = predicate.toLowerCase();
|
||||
if (normalized.includes("brand")) return -Math.PI / 6;
|
||||
if (normalized.includes("price") || normalized.includes("amount")) {
|
||||
return Math.PI / 2;
|
||||
}
|
||||
if (normalized.includes("note") || normalized.includes("description")) {
|
||||
return Math.PI;
|
||||
}
|
||||
if (normalized.includes("occasion") || normalized.includes("tag")) {
|
||||
return -Math.PI / 2;
|
||||
}
|
||||
return hashToUnit(predicate) * Math.PI * 2;
|
||||
}
|
||||
|
||||
function layoutGraph(nodes: VisualNode[], edges: VisualEdge[]): GraphCanvas {
|
||||
const count = nodes.length;
|
||||
if (!count) return { width: VIEW_W, height: VIEW_H };
|
||||
nodes.forEach((node) => {
|
||||
node.isHub = false;
|
||||
});
|
||||
if (count === 1) {
|
||||
const centerX = VIEW_W / 2;
|
||||
const centerY = VIEW_H / 2;
|
||||
nodes[0].x = centerX;
|
||||
nodes[0].y = centerY;
|
||||
return { width: VIEW_W, height: VIEW_H };
|
||||
}
|
||||
|
||||
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
||||
const outgoing = new Map<string, VisualEdge[]>();
|
||||
const incoming = new Map<string, VisualEdge[]>();
|
||||
const degree = new Map<string, number>();
|
||||
edges.forEach((edge) => {
|
||||
outgoing.set(edge.source, [...(outgoing.get(edge.source) ?? []), edge]);
|
||||
incoming.set(edge.target, [...(incoming.get(edge.target) ?? []), edge]);
|
||||
degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1);
|
||||
degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1);
|
||||
});
|
||||
|
||||
let centers = nodes.filter(
|
||||
(node) => node.source === "entity" && (outgoing.get(node.id)?.length ?? 0) > 0,
|
||||
);
|
||||
if (!centers.length) {
|
||||
centers = [...nodes]
|
||||
.sort((a, b) => (degree.get(b.id) ?? 0) - (degree.get(a.id) ?? 0))
|
||||
.slice(0, Math.max(1, Math.ceil(Math.sqrt(count))));
|
||||
}
|
||||
centers.sort((a, b) => {
|
||||
const scoreA = (outgoing.get(a.id)?.length ?? 0) * 3 + (degree.get(a.id) ?? 0);
|
||||
const scoreB = (outgoing.get(b.id)?.length ?? 0) * 3 + (degree.get(b.id) ?? 0);
|
||||
return scoreB - scoreA || a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const centerIds = new Set(centers.map((node) => node.id));
|
||||
centers.forEach((node) => {
|
||||
node.isHub = true;
|
||||
node.radius = clamp(node.radius + 2, NODE_MIN_R + 3, NODE_MAX_R + 2);
|
||||
});
|
||||
|
||||
const columns = centers.length <= 2 ? centers.length : Math.ceil(Math.sqrt(centers.length * 1.35));
|
||||
const rows = Math.ceil(centers.length / columns);
|
||||
const canvasWidth = Math.max(
|
||||
VIEW_W,
|
||||
GRAPH_PADDING * 2 + columns * CLUSTER_CELL_W,
|
||||
);
|
||||
const canvasHeight = Math.max(
|
||||
VIEW_H,
|
||||
GRAPH_PADDING * 2 + rows * CLUSTER_CELL_H,
|
||||
);
|
||||
const centerX = canvasWidth / 2;
|
||||
const centerY = canvasHeight / 2;
|
||||
const width = canvasWidth - GRAPH_PADDING * 2;
|
||||
const height = canvasHeight - GRAPH_PADDING * 2;
|
||||
const cellW = width / Math.max(columns, 1);
|
||||
const cellH = height / Math.max(rows, 1);
|
||||
const anchors = new Map<string, { x: number; y: number; strength: number }>();
|
||||
|
||||
centers.forEach((node, index) => {
|
||||
const row = Math.floor(index / columns);
|
||||
const col = index % columns;
|
||||
node.x = GRAPH_PADDING + cellW * (col + 0.5);
|
||||
node.y = GRAPH_PADDING + cellH * (row + 0.5);
|
||||
if (rows === 1) {
|
||||
node.y = centerY;
|
||||
}
|
||||
anchors.set(node.id, { x: node.x, y: node.y, strength: 0.55 });
|
||||
});
|
||||
|
||||
centers.forEach((center) => {
|
||||
const childEdges = (outgoing.get(center.id) ?? [])
|
||||
.filter((edge) => !centerIds.has(edge.target) && nodeById.has(edge.target))
|
||||
.sort((a, b) => {
|
||||
const angleA = predicateAngle(a.predicate);
|
||||
const angleB = predicateAngle(b.predicate);
|
||||
return angleA - angleB || a.predicate.localeCompare(b.predicate);
|
||||
});
|
||||
const groups = new Map<string, VisualEdge[]>();
|
||||
childEdges.forEach((edge) => {
|
||||
groups.set(edge.predicate, [...(groups.get(edge.predicate) ?? []), edge]);
|
||||
});
|
||||
|
||||
Array.from(groups.entries()).forEach(([predicate, group]) => {
|
||||
const baseAngle = predicateAngle(predicate);
|
||||
const spread = Math.min(0.78, 0.18 * Math.max(group.length - 1, 0));
|
||||
group.forEach((edge, index) => {
|
||||
const child = nodeById.get(edge.target);
|
||||
if (!child) return;
|
||||
const parentCount = (incoming.get(child.id) ?? []).filter((incomingEdge) =>
|
||||
centerIds.has(incomingEdge.source),
|
||||
).length;
|
||||
if (parentCount > 1) return;
|
||||
const offset =
|
||||
group.length === 1
|
||||
? 0
|
||||
: -spread / 2 + (spread * index) / Math.max(group.length - 1, 1);
|
||||
const distance =
|
||||
76 +
|
||||
Math.min(childEdges.length, 8) * 3 +
|
||||
hashToUnit(`${center.id}:${child.id}`) * 20;
|
||||
const x = center.x + Math.cos(baseAngle + offset) * distance;
|
||||
const y = center.y + Math.sin(baseAngle + offset) * distance;
|
||||
child.x = x;
|
||||
child.y = y;
|
||||
anchors.set(child.id, { x, y, strength: 0.4 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
nodes
|
||||
.filter((node) => !centerIds.has(node.id))
|
||||
.forEach((node) => {
|
||||
if (anchors.has(node.id)) return;
|
||||
const parentEdges = (incoming.get(node.id) ?? []).filter((edge) =>
|
||||
centerIds.has(edge.source),
|
||||
);
|
||||
if (parentEdges.length) {
|
||||
const parents = parentEdges
|
||||
.map((edge) => nodeById.get(edge.source))
|
||||
.filter((parent): parent is VisualNode => Boolean(parent));
|
||||
const avgX =
|
||||
parents.reduce((sum, parent) => sum + parent.x, 0) / parents.length;
|
||||
const avgY =
|
||||
parents.reduce((sum, parent) => sum + parent.y, 0) / parents.length;
|
||||
const isSharedTarget = parentEdges.length > 1;
|
||||
const angle = predicateAngle(parentEdges[0].predicate);
|
||||
const x = isSharedTarget ? avgX : avgX + Math.cos(angle) * 58;
|
||||
const y = isSharedTarget ? avgY : avgY + Math.sin(angle) * 58;
|
||||
node.x = x;
|
||||
node.y = y;
|
||||
anchors.set(node.id, { x, y, strength: isSharedTarget ? 0.46 : 0.32 });
|
||||
return;
|
||||
}
|
||||
|
||||
const relatedEdges = edges.filter(
|
||||
(edge) => edge.source === node.id || edge.target === node.id,
|
||||
);
|
||||
const related = relatedEdges
|
||||
.map((edge) =>
|
||||
edge.source === node.id
|
||||
? nodeById.get(edge.target)
|
||||
: nodeById.get(edge.source),
|
||||
)
|
||||
.filter((relatedNode): relatedNode is VisualNode => Boolean(relatedNode));
|
||||
if (related.length) {
|
||||
const avgX =
|
||||
related.reduce((sum, relatedNode) => sum + relatedNode.x, 0) /
|
||||
related.length;
|
||||
const avgY =
|
||||
related.reduce((sum, relatedNode) => sum + relatedNode.y, 0) /
|
||||
related.length;
|
||||
const angle = hashToUnit(node.id) * Math.PI * 2;
|
||||
node.x = avgX + Math.cos(angle) * 64;
|
||||
node.y = avgY + Math.sin(angle) * 64;
|
||||
} else {
|
||||
const angle = hashToUnit(node.id) * Math.PI * 2;
|
||||
node.x = centerX + Math.cos(angle) * 120;
|
||||
node.y = centerY + Math.sin(angle) * 120;
|
||||
}
|
||||
anchors.set(node.id, { x: node.x, y: node.y, strength: 0.24 });
|
||||
});
|
||||
|
||||
for (let tick = 0; tick < 80; tick += 1) {
|
||||
nodes.forEach((node) => {
|
||||
const anchor = anchors.get(node.id);
|
||||
if (!anchor) return;
|
||||
node.x += (anchor.x - node.x) * anchor.strength * 0.12;
|
||||
node.y += (anchor.y - node.y) * anchor.strength * 0.12;
|
||||
});
|
||||
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const a = nodes[i];
|
||||
for (let j = i + 1; j < count; j += 1) {
|
||||
const b = nodes[j];
|
||||
let dx = a.x - b.x;
|
||||
let dy = a.y - b.y;
|
||||
let distance = Math.sqrt(dx * dx + dy * dy);
|
||||
if (distance < 0.01) {
|
||||
dx = hashToUnit(`${a.id}:${b.id}:x`) - 0.5;
|
||||
dy = hashToUnit(`${a.id}:${b.id}:y`) - 0.5;
|
||||
distance = Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
const minDistance = a.radius + b.radius + 22;
|
||||
if (distance >= minDistance) continue;
|
||||
const force = ((minDistance - distance) / distance) * 0.32;
|
||||
const ax = dx * force;
|
||||
const ay = dy * force;
|
||||
const aAnchor = anchors.get(a.id)?.strength ?? 0.2;
|
||||
const bAnchor = anchors.get(b.id)?.strength ?? 0.2;
|
||||
a.x += ax * (1 - aAnchor);
|
||||
a.y += ay * (1 - aAnchor);
|
||||
b.x -= ax * (1 - bAnchor);
|
||||
b.y -= ay * (1 - bAnchor);
|
||||
}
|
||||
}
|
||||
|
||||
nodes.forEach((node) => {
|
||||
node.x = clamp(node.x, GRAPH_PADDING, canvasWidth - GRAPH_PADDING);
|
||||
node.y = clamp(node.y, GRAPH_PADDING, canvasHeight - GRAPH_PADDING);
|
||||
});
|
||||
}
|
||||
return { width: canvasWidth, height: canvasHeight };
|
||||
}
|
||||
|
||||
function buildGraph(
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
@@ -170,15 +429,21 @@ function buildGraph(
|
||||
visualNodes = visualNodes.filter((node) => connectedIds.has(node.id));
|
||||
}
|
||||
|
||||
const centerX = VIEW_W / 2;
|
||||
const centerY = VIEW_H / 2;
|
||||
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;
|
||||
const degree = new Map<string, number>();
|
||||
filteredEdges.forEach((edge) => {
|
||||
degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1);
|
||||
degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1);
|
||||
});
|
||||
return { nodes: visualNodes, edges: filteredEdges, types };
|
||||
visualNodes.forEach((node) => {
|
||||
const links = degree.get(node.id) ?? 0;
|
||||
const base =
|
||||
node.source === "literal"
|
||||
? clamp(LITERAL_MIN_R + Math.sqrt(links) * 1.4, LITERAL_MIN_R, LITERAL_MAX_R)
|
||||
: clamp(NODE_MIN_R + Math.sqrt(links) * 1.8, NODE_MIN_R, NODE_MAX_R);
|
||||
node.radius = base;
|
||||
});
|
||||
const canvas = layoutGraph(visualNodes, filteredEdges);
|
||||
return { nodes: visualNodes, edges: filteredEdges, types, canvas };
|
||||
}
|
||||
|
||||
function edgeEndpoint(edge: VisualEdge, nodes: VisualNode[]) {
|
||||
@@ -187,6 +452,26 @@ function edgeEndpoint(edge: VisualEdge, nodes: VisualNode[]) {
|
||||
return { source, target };
|
||||
}
|
||||
|
||||
function edgeLinePoints(source: VisualNode, target: VisualNode) {
|
||||
const dx = target.x - source.x;
|
||||
const dy = target.y - source.y;
|
||||
const distance = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
|
||||
const nx = dx / distance;
|
||||
const ny = dy / distance;
|
||||
const x1 = source.x + nx * (source.radius + 2);
|
||||
const y1 = source.y + ny * (source.radius + 2);
|
||||
const x2 = target.x - nx * (target.radius + 7);
|
||||
const y2 = target.y - ny * (target.radius + 7);
|
||||
return {
|
||||
x1,
|
||||
y1,
|
||||
x2,
|
||||
y2,
|
||||
midX: (x1 + x2) / 2,
|
||||
midY: (y1 + y2) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
export default function GraphViewPage() {
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
@@ -202,8 +487,17 @@ export default function GraphViewPage() {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [hiddenTypes, setHiddenTypes] = useState<Set<string>>(new Set());
|
||||
const [showLegend, setShowLegend] = useState(true);
|
||||
const [legendCollapsed, setLegendCollapsed] = useState(true);
|
||||
const [showMinimap, setShowMinimap] = useState(true);
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||
const panRef = useRef({
|
||||
active: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
scrollLeft: 0,
|
||||
scrollTop: 0,
|
||||
});
|
||||
|
||||
const graph = useGraphNeighborhood(projectName, {
|
||||
includeCandidates: includeCandidates && statusScope === "validated_claim",
|
||||
@@ -267,6 +561,72 @@ export default function GraphViewPage() {
|
||||
? visibleEdges.reduce((sum, edge) => sum + edge.confidence, 0) /
|
||||
visibleEdges.length
|
||||
: 0;
|
||||
const showEdgeLabels = visibleEdges.length <= 70;
|
||||
const showNodeLabels = visibleNodes.length <= 180;
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas || graph.isLoading || visibleNodes.length === 0) return;
|
||||
window.requestAnimationFrame(() => {
|
||||
canvas.scrollLeft = Math.max(0, (canvas.scrollWidth - canvas.clientWidth) / 2);
|
||||
canvas.scrollTop = Math.max(0, (canvas.scrollHeight - canvas.clientHeight) / 2);
|
||||
});
|
||||
}, [
|
||||
graph.isLoading,
|
||||
projectName,
|
||||
visibleNodes.length,
|
||||
visual.canvas.height,
|
||||
visual.canvas.width,
|
||||
]);
|
||||
|
||||
const startCanvasPan = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest("[data-graph-node], [data-graph-edge]")) return;
|
||||
panRef.current = {
|
||||
active: true,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
scrollLeft: event.currentTarget.scrollLeft,
|
||||
scrollTop: event.currentTarget.scrollTop,
|
||||
};
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
};
|
||||
|
||||
const moveCanvasPan = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!panRef.current.active) return;
|
||||
event.currentTarget.scrollLeft =
|
||||
panRef.current.scrollLeft - (event.clientX - panRef.current.x);
|
||||
event.currentTarget.scrollTop =
|
||||
panRef.current.scrollTop - (event.clientY - panRef.current.y);
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const endCanvasPan = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!panRef.current.active) return;
|
||||
panRef.current.active = false;
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
};
|
||||
|
||||
const jumpCanvasTo = (x: number, y: number) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
canvas.scrollTo({
|
||||
left: clamp(
|
||||
x - canvas.clientWidth / 2,
|
||||
0,
|
||||
Math.max(0, canvas.scrollWidth - canvas.clientWidth),
|
||||
),
|
||||
top: clamp(
|
||||
y - canvas.clientHeight / 2,
|
||||
0,
|
||||
Math.max(0, canvas.scrollHeight - canvas.clientHeight),
|
||||
),
|
||||
behavior: "smooth",
|
||||
});
|
||||
};
|
||||
|
||||
const selectNode = (node: VisualNode | VisualEdge) => {
|
||||
setSelected(node);
|
||||
@@ -530,61 +890,79 @@ export default function GraphViewPage() {
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
|
||||
className="block h-[640px] w-full bg-background-subtle text-muted-foreground"
|
||||
role="img"
|
||||
aria-label="Ontology graph"
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="relative h-[640px] cursor-grab overflow-auto bg-background-subtle active:cursor-grabbing"
|
||||
onPointerDown={startCanvasPan}
|
||||
onPointerMove={moveCanvasPan}
|
||||
onPointerUp={endCanvasPan}
|
||||
onPointerCancel={endCanvasPan}
|
||||
>
|
||||
<svg
|
||||
viewBox={`0 0 ${visual.canvas.width} ${visual.canvas.height}`}
|
||||
width={visual.canvas.width}
|
||||
height={visual.canvas.height}
|
||||
className="block min-h-full min-w-full text-muted-foreground"
|
||||
role="img"
|
||||
aria-label="Ontology graph"
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="arrow"
|
||||
markerWidth="10"
|
||||
markerHeight="10"
|
||||
refX="10"
|
||||
refY="3"
|
||||
markerWidth="6"
|
||||
markerHeight="6"
|
||||
refX="5.5"
|
||||
refY="2.5"
|
||||
orient="auto"
|
||||
markerUnits="strokeWidth"
|
||||
>
|
||||
<path d="M0,0 L0,6 L9,3 z" fill="currentColor" />
|
||||
<path d="M0,0 L0,5 L5.5,2.5 z" fill="currentColor" />
|
||||
</marker>
|
||||
</defs>
|
||||
{visibleEdges.map((edge) => {
|
||||
const { source, target } = edgeEndpoint(edge, visibleNodes);
|
||||
if (!source || !target) return null;
|
||||
const midX = (source.x + target.x) / 2;
|
||||
const midY = (source.y + target.y) / 2;
|
||||
const { x1, y1, x2, y2, midX, midY } = edgeLinePoints(
|
||||
source,
|
||||
target,
|
||||
);
|
||||
const isSelected =
|
||||
selected && "predicate" in selected && selected.id === edge.id;
|
||||
return (
|
||||
<g
|
||||
key={edge.id}
|
||||
data-graph-edge
|
||||
className="cursor-pointer"
|
||||
onClick={() => selectNode(edge)}
|
||||
>
|
||||
<line
|
||||
x1={source.x}
|
||||
y1={source.y}
|
||||
x2={target.x}
|
||||
y2={target.y}
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke="currentColor"
|
||||
strokeWidth={1 + edge.confidence * 3}
|
||||
strokeOpacity={isSelected ? 1 : 0.55}
|
||||
strokeWidth={isSelected ? 1.7 : 0.65 + edge.confidence * 0.75}
|
||||
strokeOpacity={isSelected ? 0.95 : 0.42}
|
||||
markerEnd="url(#arrow)"
|
||||
className={isSelected ? "text-brand-600 dark:text-brand-300" : ""}
|
||||
/>
|
||||
<text
|
||||
x={midX}
|
||||
y={midY}
|
||||
textAnchor="middle"
|
||||
className="fill-muted-foreground text-[10px]"
|
||||
paintOrder="stroke"
|
||||
stroke="hsl(var(--background))"
|
||||
strokeWidth="3"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{edge.predicate}
|
||||
</text>
|
||||
{(showEdgeLabels || isSelected) && (
|
||||
<text
|
||||
x={midX}
|
||||
y={midY - 2}
|
||||
textAnchor="middle"
|
||||
className="fill-muted-foreground text-[7px]"
|
||||
opacity={isSelected ? 0.95 : 0.62}
|
||||
paintOrder="stroke"
|
||||
stroke="hsl(var(--background))"
|
||||
strokeWidth="2"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{edge.predicate.length > 18
|
||||
? `${edge.predicate.slice(0, 18)}...`
|
||||
: edge.predicate}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
@@ -594,6 +972,7 @@ export default function GraphViewPage() {
|
||||
return (
|
||||
<g
|
||||
key={node.id}
|
||||
data-graph-node
|
||||
className="cursor-pointer"
|
||||
onClick={() => selectNode(node)}
|
||||
>
|
||||
@@ -602,31 +981,41 @@ export default function GraphViewPage() {
|
||||
cy={node.y}
|
||||
r={node.radius}
|
||||
fill={node.color}
|
||||
className={
|
||||
stroke={
|
||||
isSelected
|
||||
? "stroke-foreground"
|
||||
: "stroke-background"
|
||||
? "hsl(var(--foreground))"
|
||||
: node.isHub
|
||||
? "hsl(var(--muted-foreground))"
|
||||
: "hsl(var(--background))"
|
||||
}
|
||||
strokeWidth={isSelected ? 3 : 2}
|
||||
strokeWidth={isSelected ? 2.5 : node.isHub ? 2 : 1.3}
|
||||
/>
|
||||
<text
|
||||
x={node.x}
|
||||
y={node.y + node.radius + 14}
|
||||
textAnchor="middle"
|
||||
className="fill-foreground text-[11px] font-medium"
|
||||
paintOrder="stroke"
|
||||
stroke="hsl(var(--background))"
|
||||
strokeWidth="3"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{node.name.length > 24
|
||||
? `${node.name.slice(0, 24)}...`
|
||||
: node.name}
|
||||
</text>
|
||||
{(showNodeLabels || isSelected) && (
|
||||
<text
|
||||
x={node.x}
|
||||
y={node.y + node.radius + 10}
|
||||
textAnchor="middle"
|
||||
className={
|
||||
node.isHub
|
||||
? "fill-foreground text-[8px] font-semibold"
|
||||
: "fill-foreground text-[8px] font-medium"
|
||||
}
|
||||
opacity={node.source === "literal" ? 0.74 : 0.86}
|
||||
paintOrder="stroke"
|
||||
stroke="hsl(var(--background))"
|
||||
strokeWidth="2"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{node.name.length > 18
|
||||
? `${node.name.slice(0, 18)}...`
|
||||
: node.name}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Legend overlay (top-right) */}
|
||||
{showLegend && (
|
||||
@@ -635,12 +1024,18 @@ export default function GraphViewPage() {
|
||||
typeCounts={typeCounts}
|
||||
hiddenTypes={hiddenTypes}
|
||||
onToggle={toggleType}
|
||||
collapsed={legendCollapsed}
|
||||
onCollapsedChange={setLegendCollapsed}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Minimap overlay (bottom-right) */}
|
||||
{showMinimap && visibleNodes.length > 0 && (
|
||||
<Minimap nodes={visibleNodes} edges={visibleEdges} />
|
||||
<Minimap
|
||||
nodes={visibleNodes}
|
||||
edges={visibleEdges}
|
||||
onJump={jumpCanvasTo}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -803,58 +1198,83 @@ function LegendPanel({
|
||||
typeCounts,
|
||||
hiddenTypes,
|
||||
onToggle,
|
||||
collapsed,
|
||||
onCollapsedChange,
|
||||
}: {
|
||||
types: string[];
|
||||
typeCounts: Map<string, number>;
|
||||
hiddenTypes: Set<string>;
|
||||
onToggle: (type: string) => void;
|
||||
collapsed: boolean;
|
||||
onCollapsedChange: (collapsed: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute right-3 top-3 w-52 rounded-md border border-border bg-popover/95 shadow-md backdrop-blur-sm">
|
||||
<div className="border-b border-border px-3 py-1.5 text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Legend
|
||||
</div>
|
||||
<ul className="max-h-64 overflow-y-auto py-1">
|
||||
{types.map((type) => {
|
||||
const hidden = hiddenTypes.has(type);
|
||||
const count = typeCounts.get(type) ?? 0;
|
||||
return (
|
||||
<li key={type}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(type)}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors hover:bg-accent/60"
|
||||
aria-pressed={!hidden}
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: nodeColor(type, types),
|
||||
opacity: hidden ? 0.3 : 1,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
hidden
|
||||
? "flex-1 truncate text-muted-foreground/60 line-through"
|
||||
: "flex-1 truncate text-foreground"
|
||||
}
|
||||
<div
|
||||
className={
|
||||
collapsed
|
||||
? "absolute right-3 top-3 w-32 rounded-md border border-border bg-popover/95 shadow-md backdrop-blur-sm"
|
||||
: "absolute right-3 top-3 w-52 rounded-md border border-border bg-popover/95 shadow-md backdrop-blur-sm"
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCollapsedChange(!collapsed)}
|
||||
className="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-2xs font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent/60"
|
||||
aria-expanded={!collapsed}
|
||||
>
|
||||
<span>Legend</span>
|
||||
<span className="flex items-center gap-1 font-mono normal-case">
|
||||
{collapsed && types.length}
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<ul className="max-h-64 overflow-y-auto border-t border-border py-1">
|
||||
{types.map((type) => {
|
||||
const hidden = hiddenTypes.has(type);
|
||||
const count = typeCounts.get(type) ?? 0;
|
||||
return (
|
||||
<li key={type}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(type)}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors hover:bg-accent/60"
|
||||
aria-pressed={!hidden}
|
||||
>
|
||||
{type}
|
||||
</span>
|
||||
<span className="text-2xs text-muted-foreground tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
{hidden ? (
|
||||
<EyeOff className="h-3 w-3 text-muted-foreground/60" />
|
||||
) : (
|
||||
<Eye className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<span
|
||||
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: nodeColor(type, types),
|
||||
opacity: hidden ? 0.3 : 1,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
hidden
|
||||
? "flex-1 truncate text-muted-foreground/60 line-through"
|
||||
: "flex-1 truncate text-foreground"
|
||||
}
|
||||
>
|
||||
{type}
|
||||
</span>
|
||||
<span className="text-2xs text-muted-foreground tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
{hidden ? (
|
||||
<EyeOff className="h-3 w-3 text-muted-foreground/60" />
|
||||
) : (
|
||||
<Eye className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -865,9 +1285,11 @@ function LegendPanel({
|
||||
function Minimap({
|
||||
nodes,
|
||||
edges,
|
||||
onJump,
|
||||
}: {
|
||||
nodes: VisualNode[];
|
||||
edges: VisualEdge[];
|
||||
onJump: (x: number, y: number) => void;
|
||||
}) {
|
||||
const W = 180;
|
||||
const H = 130;
|
||||
@@ -886,6 +1308,14 @@ function Minimap({
|
||||
const offsetY = (H - h * scale) / 2;
|
||||
const tx = (x: number) => (x - minX) * scale + offsetX;
|
||||
const ty = (y: number) => (y - minY) * scale + offsetY;
|
||||
const jumpFromMinimap = (event: ReactMouseEvent<SVGSVGElement>) => {
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const localX = event.clientX - rect.left;
|
||||
const localY = event.clientY - rect.top;
|
||||
const graphX = clamp((localX - offsetX) / scale + minX, minX, maxX);
|
||||
const graphY = clamp((localY - offsetY) / scale + minY, minY, maxY);
|
||||
onJump(graphX, graphY);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-3 right-3 rounded-md border border-border bg-popover/95 shadow-md backdrop-blur-sm">
|
||||
@@ -898,8 +1328,11 @@ function Minimap({
|
||||
<svg
|
||||
width={W}
|
||||
height={H}
|
||||
className="block text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
className="block cursor-crosshair text-muted-foreground"
|
||||
role="button"
|
||||
aria-label="Jump to minimap position"
|
||||
tabIndex={0}
|
||||
onClick={jumpFromMinimap}
|
||||
>
|
||||
{edges.map((e) => {
|
||||
const s = nodes.find((n) => n.id === e.source);
|
||||
|
||||
@@ -48,6 +48,21 @@ function candidateClaims(log: ExtractionLog | undefined): unknown[] {
|
||||
return Array.isArray(nested) ? nested : [];
|
||||
}
|
||||
|
||||
function numberFrom(value: unknown): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function comparisonRecord(log: ExtractionLog | undefined): Record<string, unknown> {
|
||||
return {
|
||||
...asRecord(asRecord(log?.raw_output).comparison),
|
||||
...asRecord(log?.comparison),
|
||||
};
|
||||
}
|
||||
|
||||
function comparisonCount(log: ExtractionLog | undefined, key: string): number {
|
||||
return numberFrom(comparisonRecord(log)[key]);
|
||||
}
|
||||
|
||||
export default function PageAnalysisPage() {
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
@@ -211,6 +226,29 @@ export default function PageAnalysisPage() {
|
||||
label="Validation"
|
||||
value={humanizeValue(selectedLog.validation)}
|
||||
/>
|
||||
<InfoBox
|
||||
label="Mode"
|
||||
value={humanizeValue(
|
||||
selectedLog.effective_extraction_mode ??
|
||||
selectedLog.extraction_mode ??
|
||||
rawOutput.effective_extraction_mode ??
|
||||
rawOutput.extraction_mode,
|
||||
)}
|
||||
/>
|
||||
<InfoBox
|
||||
label="Rule / LLM"
|
||||
value={`${numberFrom(selectedLog.rule_claim_count ?? rawOutput.rule_claim_count)} / ${numberFrom(
|
||||
selectedLog.llm_claim_count ?? rawOutput.llm_claim_count,
|
||||
)}`}
|
||||
/>
|
||||
<InfoBox
|
||||
label="LLM"
|
||||
value={
|
||||
selectedLog.llm_skipped || rawOutput.llm_skipped
|
||||
? `skipped: ${humanizeValue(selectedLog.llm_skip_reason ?? rawOutput.llm_skip_reason)}`
|
||||
: humanizeValue(selectedLog.provider)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{selectedLog?.error && (
|
||||
@@ -221,6 +259,30 @@ export default function PageAnalysisPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selectedLog && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Rule / LLM Comparison</CardTitle>
|
||||
<CardDescription>
|
||||
Hybrid and compare runs keep agreement, difference, and conflict counts.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<ComparisonBox label="Both agree" value={comparisonCount(selectedLog, "both_agree")} tone="success" />
|
||||
<ComparisonBox label="Rule only" value={comparisonCount(selectedLog, "rule_only")} tone="warning" />
|
||||
<ComparisonBox label="LLM only" value={comparisonCount(selectedLog, "llm_only")} tone="info" />
|
||||
<ComparisonBox label="Conflict" value={comparisonCount(selectedLog, "conflict")} tone="danger" />
|
||||
<ComparisonBox
|
||||
label="Rejected"
|
||||
value={comparisonCount(selectedLog, "rejected_by_validation")}
|
||||
tone="muted"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -331,3 +393,30 @@ function InfoBox({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonBox({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
tone: "success" | "warning" | "info" | "danger" | "muted";
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === "success"
|
||||
? "border-green-200 bg-green-50 text-green-900"
|
||||
: tone === "warning"
|
||||
? "border-yellow-200 bg-yellow-50 text-yellow-900"
|
||||
: tone === "danger"
|
||||
? "border-red-200 bg-red-50 text-red-900"
|
||||
: tone === "info"
|
||||
? "border-blue-200 bg-blue-50 text-blue-900"
|
||||
: "border-border bg-background text-foreground";
|
||||
return (
|
||||
<div className={`rounded-md border px-3 py-2 ${toneClass}`}>
|
||||
<div className="text-xs opacity-80">{label}</div>
|
||||
<div className="mt-1 text-2xl font-semibold tabular-nums">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,11 @@ const startResearchSchema = z.object({
|
||||
max_branch: z.number().int().min(1).max(30),
|
||||
min_relevance: z.number().min(0).max(1),
|
||||
same_domain_only: z.boolean(),
|
||||
extraction_mode: z.enum(["rule_only", "llm_only", "hybrid", "compare"]),
|
||||
extractor_provider: z.enum(["lm_studio", "openai", "ollama"]),
|
||||
extractor_model: z.string().optional(),
|
||||
extractor_base_url: z.string().optional(),
|
||||
fallback_to_rules: z.boolean(),
|
||||
});
|
||||
|
||||
type StartResearchFormValues = z.infer<typeof startResearchSchema>;
|
||||
@@ -89,6 +94,7 @@ export default function ResearchPage() {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<StartResearchFormValues>({
|
||||
resolver: zodResolver(startResearchSchema),
|
||||
@@ -101,12 +107,18 @@ export default function ResearchPage() {
|
||||
max_branch: 8,
|
||||
min_relevance: 0.35,
|
||||
same_domain_only: true,
|
||||
extraction_mode: "hybrid",
|
||||
extractor_provider: "lm_studio",
|
||||
extractor_model: "",
|
||||
extractor_base_url: "http://localhost:1234/v1",
|
||||
fallback_to_rules: true,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (values: StartResearchFormValues) => {
|
||||
try {
|
||||
setLastResult(null);
|
||||
const usesLlm = values.extraction_mode !== "rule_only";
|
||||
const res = await startResearch.mutateAsync({
|
||||
project_name: projectName,
|
||||
source_name: values.source_name,
|
||||
@@ -117,6 +129,11 @@ export default function ResearchPage() {
|
||||
max_branch: values.max_branch,
|
||||
min_relevance: values.min_relevance,
|
||||
same_domain_only: values.same_domain_only,
|
||||
extraction_mode: values.extraction_mode,
|
||||
extractor_provider: values.extractor_provider,
|
||||
extractor_model: usesLlm ? values.extractor_model || null : null,
|
||||
extractor_base_url: usesLlm ? values.extractor_base_url || null : null,
|
||||
fallback_to_rules: values.fallback_to_rules,
|
||||
});
|
||||
setLastResult(res);
|
||||
toast.success(t("research.completed", "자율 연구가 완료되었습니다"));
|
||||
@@ -130,6 +147,8 @@ export default function ResearchPage() {
|
||||
};
|
||||
|
||||
const sources = project?.sources ?? [];
|
||||
const extractionMode = watch("extraction_mode");
|
||||
const usesLlm = extractionMode !== "rule_only";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-6 py-10">
|
||||
@@ -344,6 +363,60 @@ export default function ResearchPage() {
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="space-y-3 rounded-md border bg-background-subtle p-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="extraction_mode">Extraction mode</Label>
|
||||
<Select id="extraction_mode" {...register("extraction_mode")}>
|
||||
<option value="hybrid">Hybrid</option>
|
||||
<option value="rule_only">Rule only</option>
|
||||
<option value="llm_only">LLM only</option>
|
||||
<option value="compare">Compare</option>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="extractor_provider">Provider</Label>
|
||||
<Select
|
||||
id="extractor_provider"
|
||||
disabled={!usesLlm}
|
||||
{...register("extractor_provider")}
|
||||
>
|
||||
<option value="lm_studio">LM Studio</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{usesLlm && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="extractor_model">Model</Label>
|
||||
<Input
|
||||
id="extractor_model"
|
||||
placeholder="deepseek-r1-distill-qwen-7b"
|
||||
{...register("extractor_model")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="extractor_base_url">Base URL</Label>
|
||||
<Input
|
||||
id="extractor_base_url"
|
||||
placeholder="http://localhost:1234/v1"
|
||||
{...register("extractor_base_url")}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-input"
|
||||
{...register("fallback_to_rules")}
|
||||
/>
|
||||
<span>Fallback to rules</span>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
|
||||
@@ -60,12 +60,37 @@ function claimObjectText(claim: Claim): string {
|
||||
return humanizeValue(claim.object ?? claim.object_value);
|
||||
}
|
||||
|
||||
function agreementText(claim: Claim): string {
|
||||
return humanizeValue(claim.agreement ?? claim.claim_kind ?? "unknown");
|
||||
}
|
||||
|
||||
function agreementVariant(claim: Claim): BadgeProps["variant"] {
|
||||
switch ((claim.agreement ?? "").toLowerCase()) {
|
||||
case "rule_and_llm":
|
||||
return "success";
|
||||
case "conflict":
|
||||
return "destructive";
|
||||
case "rule_only":
|
||||
case "llm_only":
|
||||
return "warning";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
candidate: "Candidate",
|
||||
approved: "Approved",
|
||||
rejected: "Rejected",
|
||||
};
|
||||
|
||||
const AGREEMENT_LABELS: Record<string, string> = {
|
||||
rule_and_llm: "Rule + LLM",
|
||||
rule_only: "Rule only",
|
||||
llm_only: "LLM only",
|
||||
conflict: "Conflict",
|
||||
};
|
||||
|
||||
export default function ReviewPage() {
|
||||
const navigate = useNavigate();
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
@@ -78,6 +103,7 @@ export default function ReviewPage() {
|
||||
const updateStatus = useUpdateClaimStatus(projectName);
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||
const [agreementFilter, setAgreementFilter] = useState<string>("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
@@ -90,6 +116,9 @@ export default function ReviewPage() {
|
||||
if (statusFilter && reviewLabel(claim.status) !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
if (agreementFilter && (claim.agreement ?? "") !== agreementFilter) {
|
||||
return false;
|
||||
}
|
||||
if (!query) return true;
|
||||
return [
|
||||
claim.subject,
|
||||
@@ -102,7 +131,7 @@ export default function ReviewPage() {
|
||||
.map((value) => humanizeValue(value, "").toLowerCase())
|
||||
.some((value) => value.includes(query));
|
||||
});
|
||||
}, [claims.data, search, statusFilter]);
|
||||
}, [agreementFilter, claims.data, search, statusFilter]);
|
||||
|
||||
const selectedClaim = useMemo(() => {
|
||||
if (!selectedId) return null;
|
||||
@@ -122,6 +151,16 @@ export default function ReviewPage() {
|
||||
};
|
||||
}, [claims.data]);
|
||||
|
||||
const agreementCounts = useMemo(() => {
|
||||
const result: Record<string, number> = {};
|
||||
for (const claim of claims.data ?? []) {
|
||||
const key = claim.agreement ?? "";
|
||||
if (!key) continue;
|
||||
result[key] = (result[key] ?? 0) + 1;
|
||||
}
|
||||
return result;
|
||||
}, [claims.data]);
|
||||
|
||||
/* --------------------- Actions --------------------- */
|
||||
|
||||
const applyStatus = async (claim: Claim, status: string) => {
|
||||
@@ -166,9 +205,17 @@ export default function ReviewPage() {
|
||||
value: STATUS_LABELS[statusFilter] ?? statusFilter,
|
||||
onClear: () => setStatusFilter(""),
|
||||
});
|
||||
if (agreementFilter)
|
||||
chips.push({
|
||||
id: "agreement",
|
||||
label: "Agreement",
|
||||
value: AGREEMENT_LABELS[agreementFilter] ?? agreementFilter,
|
||||
onClear: () => setAgreementFilter(""),
|
||||
});
|
||||
const clearAll = () => {
|
||||
setSearch("");
|
||||
setStatusFilter("");
|
||||
setAgreementFilter("");
|
||||
};
|
||||
|
||||
/* --------------------- Table columns --------------------- */
|
||||
@@ -186,6 +233,28 @@ export default function ReviewPage() {
|
||||
size: 110,
|
||||
enablePinning: true,
|
||||
},
|
||||
{
|
||||
id: "agreement",
|
||||
header: "Agreement",
|
||||
accessorFn: (row) => row.agreement ?? "",
|
||||
cell: ({ row }) => (
|
||||
<Badge variant={agreementVariant(row.original)}>
|
||||
{agreementText(row.original)}
|
||||
</Badge>
|
||||
),
|
||||
size: 130,
|
||||
},
|
||||
{
|
||||
id: "method",
|
||||
header: "Method",
|
||||
accessorFn: (row) => row.extraction_source ?? row.claim_kind ?? "",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{humanizeValue(row.original.extraction_source ?? row.original.claim_kind)}
|
||||
</span>
|
||||
),
|
||||
size: 110,
|
||||
},
|
||||
{
|
||||
id: "subject",
|
||||
header: "Subject",
|
||||
@@ -377,6 +446,38 @@ export default function ReviewPage() {
|
||||
{ value: "rejected", label: "Rejected", hint: counts.rejected, swatch: "hsl(var(--danger))" },
|
||||
]}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="Agreement"
|
||||
value={agreementFilter}
|
||||
onChange={setAgreementFilter}
|
||||
placeholder="All agreements"
|
||||
options={[
|
||||
{
|
||||
value: "rule_and_llm",
|
||||
label: "Rule + LLM",
|
||||
hint: agreementCounts.rule_and_llm ?? 0,
|
||||
swatch: "hsl(var(--success))",
|
||||
},
|
||||
{
|
||||
value: "rule_only",
|
||||
label: "Rule only",
|
||||
hint: agreementCounts.rule_only ?? 0,
|
||||
swatch: "hsl(var(--warning))",
|
||||
},
|
||||
{
|
||||
value: "llm_only",
|
||||
label: "LLM only",
|
||||
hint: agreementCounts.llm_only ?? 0,
|
||||
swatch: "hsl(var(--info))",
|
||||
},
|
||||
{
|
||||
value: "conflict",
|
||||
label: "Conflict",
|
||||
hint: agreementCounts.conflict ?? 0,
|
||||
swatch: "hsl(var(--danger))",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</FilterPanel>
|
||||
|
||||
<DataTable<Claim>
|
||||
@@ -498,14 +599,28 @@ export default function ReviewPage() {
|
||||
{/* Detail grid */}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<DetailRow label="Source" value={selectedClaim.source} />
|
||||
<DetailRow
|
||||
label="Agreement"
|
||||
value={agreementText(selectedClaim)}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Page Type"
|
||||
value={selectedClaim.page_type}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Extraction Source"
|
||||
value={selectedClaim.extraction_source ?? selectedClaim.claim_kind}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Extraction Method"
|
||||
value={selectedClaim.extraction_method}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Rule / LLM Confidence"
|
||||
value={`${formatPercent(selectedClaim.rule_confidence)} / ${formatPercent(
|
||||
selectedClaim.llm_confidence,
|
||||
)}`}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Validation"
|
||||
value={
|
||||
@@ -539,6 +654,13 @@ export default function ReviewPage() {
|
||||
full
|
||||
/>
|
||||
)}
|
||||
{selectedClaim.review_reason && (
|
||||
<DetailRow
|
||||
label="Review Reason"
|
||||
value={selectedClaim.review_reason}
|
||||
full
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Evidence */}
|
||||
|
||||
Reference in New Issue
Block a user