AgentGraph / frontend /src /components /features /traces /OptionalPipelineSection.tsx
wu981526092's picture
Add comprehensive perturbation testing system with E2E tests
795b72e
import React, { useState, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertCircle,
CheckCircle,
Clock,
Play,
Eye,
Zap,
Shield,
GitBranch,
Sparkles,
Settings,
} from "lucide-react";
import { KnowledgeGraph, PerturbationConfig } from "@/types";
import { useNotification } from "@/context/NotificationContext";
import { api } from "@/lib/api";
import { EnrichResults } from "./EnrichResults";
import { PerturbResults } from "./PerturbResults";
import { PerturbationTestConfig } from "./PerturbationTestConfig";
interface OptionalPipelineSectionProps {
knowledgeGraph: KnowledgeGraph;
onStageComplete?: (stage: string) => void;
}
interface StageState {
status: "idle" | "running" | "completed" | "error";
progress: number;
taskId?: string;
error?: string;
}
interface PipelineStageConfig {
id: string;
name: string;
description: string;
icon: React.ComponentType<{ className?: string }>;
apiEndpoint: (kgId: string) => string;
benefit: string;
}
const PIPELINE_STAGES: PipelineStageConfig[] = [
{
id: "enrich",
name: "Prompt Reconstruction",
description: "Reconstruct original prompts from conversation patterns",
icon: Sparkles,
apiEndpoint: (kgId) => `/knowledge-graphs/${kgId}/enrich`,
benefit: "Understand conversation context and intent",
},
{
id: "perturb",
name: "Perturbation Testing",
description: "Test graph robustness through systematic variations",
icon: Shield,
apiEndpoint: (kgId) => `/knowledge-graphs/${kgId}/perturb`,
benefit: "Validate relationship reliability and stability",
},
{
id: "causal",
name: "Causal Analysis",
description: "Analyze causal relationships and dependencies",
icon: GitBranch,
apiEndpoint: (kgId) => `/knowledge-graphs/${kgId}/analyze`,
benefit: "Discover cause-and-effect patterns",
},
];
export function OptionalPipelineSection({
knowledgeGraph,
onStageComplete,
}: OptionalPipelineSectionProps) {
const { showNotification } = useNotification();
const [stageStates, setStageStates] = useState<Record<string, StageState>>({
enrich: {
status: knowledgeGraph.is_enriched ? "completed" : "idle",
progress: knowledgeGraph.is_enriched ? 100 : 0,
},
perturb: {
status: knowledgeGraph.is_perturbed ? "completed" : "idle",
progress: knowledgeGraph.is_perturbed ? 100 : 0,
},
causal: {
status: knowledgeGraph.is_analyzed ? "completed" : "idle",
progress: knowledgeGraph.is_analyzed ? 100 : 0,
},
});
const [selectedStageResults, setSelectedStageResults] = useState<{
stage: string;
data: any;
} | null>(null);
const [showPerturbConfig, setShowPerturbConfig] = useState(false);
const getStageStatusColor = (status: StageState["status"]) => {
switch (status) {
case "completed":
return "bg-green-500/10 text-green-700 border-green-200";
case "running":
return "bg-blue-500/10 text-blue-700 border-blue-200";
case "error":
return "bg-red-500/10 text-red-700 border-red-200";
default:
return "bg-gray-500/10 text-gray-700 border-gray-200";
}
};
const pollTaskStatus = useCallback(
async (stageId: string, taskId: string) => {
const maxAttempts = 60; // 5 minutes at 5-second intervals
let attempts = 0;
const poll = async () => {
try {
const status = await api.tasks.get(taskId);
setStageStates((prev) => {
const currentState = prev[stageId] || {
status: "idle" as const,
progress: 0,
};
return {
...prev,
[stageId]: {
...currentState,
progress: status.progress || currentState.progress,
},
};
});
if (status.status === "completed" || status.status === "COMPLETED") {
setStageStates((prev) => ({
...prev,
[stageId]: { status: "completed", progress: 100 },
}));
onStageComplete?.(stageId);
showNotification({
type: "success",
title: "Stage Completed",
message: `${
PIPELINE_STAGES.find((s) => s.id === stageId)?.name
} has finished successfully.`,
});
return;
} else if (status.status === "failed" || status.status === "FAILED") {
setStageStates((prev) => ({
...prev,
[stageId]: {
status: "error",
progress: 0,
error: status.error || "Unknown error",
},
}));
showNotification({
type: "error",
title: "Stage Failed",
message: status.error || "Processing failed",
});
return;
}
// Continue polling
attempts++;
if (attempts < maxAttempts) {
setTimeout(poll, 5000); // Poll every 5 seconds
} else {
// Timeout
setStageStates((prev) => ({
...prev,
[stageId]: {
status: "error",
progress: 0,
error: "Processing timeout",
},
}));
}
} catch (error) {
console.error("Error polling task status:", error);
// Continue polling on error, might be temporary
attempts++;
if (attempts < maxAttempts) {
setTimeout(poll, 5000);
}
}
};
// Start polling
setTimeout(poll, 2000); // First check after 2 seconds
},
[onStageComplete, showNotification]
);
const runPerturbWithConfig = useCallback(
async (config: PerturbationConfig) => {
const stageConfig = PIPELINE_STAGES.find((s) => s.id === "perturb")!;
setStageStates((prev) => ({
...prev,
perturb: { status: "running", progress: 0 },
}));
try {
const response = await api.knowledgeGraphs.perturb(
knowledgeGraph.kg_id,
config
);
if (response.task_id) {
pollTaskStatus("perturb", response.task_id);
showNotification({
type: "info",
title: `${stageConfig.name} Started`,
message: "Processing has begun with custom configuration.",
});
}
} catch (error) {
console.error("Error running perturb stage:", error);
setStageStates((prev) => ({
...prev,
perturb: {
status: "error",
progress: 0,
error: error instanceof Error ? error.message : "Unknown error",
},
}));
showNotification({
type: "error",
title: `${stageConfig.name} Failed`,
message: error instanceof Error ? error.message : "An error occurred",
});
}
},
[knowledgeGraph.kg_id, showNotification, pollTaskStatus]
);
const runStage = useCallback(
async (stageConfig: PipelineStageConfig) => {
const { id } = stageConfig;
// For perturb stage, open the configuration dialog instead
if (id === "perturb") {
setShowPerturbConfig(true);
return;
}
setStageStates((prev) => ({
...prev,
[id]: { status: "running", progress: 0 },
}));
try {
// Call the API endpoint based on stage
let response;
if (id === "enrich") {
response = await api.knowledgeGraphs.enrich(knowledgeGraph.kg_id);
} else if (id === "causal") {
response = await api.knowledgeGraphs.analyze(knowledgeGraph.kg_id);
} else {
throw new Error(`Unknown stage: ${id}`);
}
if (response.task_id) {
// Start polling for task status
pollTaskStatus(id, response.task_id);
showNotification({
type: "info",
title: `${stageConfig.name} Started`,
message: "Processing has begun. This may take several minutes.",
});
} else {
// Mark as completed immediately if no task ID
setStageStates((prev) => ({
...prev,
[id]: { status: "completed", progress: 100 },
}));
onStageComplete?.(id);
}
} catch (error) {
console.error(`Error running ${id} stage:`, error);
setStageStates((prev) => ({
...prev,
[id]: {
status: "error",
progress: 0,
error: error instanceof Error ? error.message : "Unknown error",
},
}));
showNotification({
type: "error",
title: `${stageConfig.name} Failed`,
message: error instanceof Error ? error.message : "An error occurred",
});
}
},
[knowledgeGraph.kg_id, showNotification, onStageComplete, pollTaskStatus]
);
const viewStageResults = useCallback(
async (stageId: string) => {
try {
const results = await api.knowledgeGraphs.getStageResults(
knowledgeGraph.kg_id,
stageId
);
setSelectedStageResults({
stage: stageId,
data: results,
});
} catch (error) {
showNotification({
type: "error",
title: "Failed to Load Results",
message: "Could not retrieve stage results",
});
}
},
[knowledgeGraph.kg_id, showNotification]
);
const anyStageRunning = Object.values(stageStates).some(
(state) => state.status === "running"
);
return (
<>
<div className="space-y-6">
{/* Advanced Processing - Unified */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<Zap className="h-6 w-6" />
Advanced Processing
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-6">
{/* Description */}
<div className="text-muted-foreground">
<p className="text-base">
Enhance your knowledge graph with advanced analysis
techniques. These optional stages provide deeper insights but
are not required for basic graph functionality.
</p>
</div>
{/* Horizontal Stepper */}
<div className="relative">
{/* Step Circles - Evenly Distributed */}
<div className="flex items-center justify-between">
{PIPELINE_STAGES.map((stage, index) => {
const state = stageStates[stage.id] || {
status: "idle" as const,
progress: 0,
};
const StageIcon = stage.icon;
return (
<div
key={stage.id}
className="flex flex-col items-center flex-1"
>
{/* Step Circle */}
<div
className={`
w-12 h-12 rounded-full border-2 flex items-center justify-center transition-all
${
state.status === "completed"
? "bg-green-500 border-green-500 text-white"
: state.status === "running"
? "bg-blue-500 border-blue-500 text-white animate-pulse"
: state.status === "error"
? "bg-red-500 border-red-500 text-white"
: "bg-background border-muted-foreground text-muted-foreground"
}
`}
>
{state.status === "completed" ? (
<CheckCircle className="h-6 w-6" />
) : state.status === "running" ? (
<Clock className="h-6 w-6" />
) : state.status === "error" ? (
<AlertCircle className="h-6 w-6" />
) : (
<StageIcon className="h-6 w-6" />
)}
</div>
{/* Step Number */}
<span className="text-xs font-medium mt-1 text-muted-foreground">
Step {index + 1}
</span>
</div>
);
})}
</div>
{/* Connector Lines - Positioned Absolutely */}
<div className="absolute top-6 left-0 right-0 flex items-center justify-between px-12">
{PIPELINE_STAGES.slice(0, -1).map((stage, _index) => {
const state = stageStates[stage.id] || {
status: "idle" as const,
progress: 0,
};
return (
<div
key={`connector-${stage.id}`}
className="flex-1 mx-8"
>
<div
className={`
h-0.5 transition-all
${
state.status === "completed"
? "bg-green-500"
: "bg-muted-foreground/30"
}
`}
/>
</div>
);
})}
</div>
</div>
{/* Stage Labels */}
<div className="flex items-start justify-between">
{PIPELINE_STAGES.map((stage, _index) => {
const state = stageStates[stage.id] || {
status: "idle" as const,
progress: 0,
};
return (
<div
key={`${stage.id}-label`}
className="flex-1 text-center"
>
<h3 className="font-medium text-sm">{stage.name}</h3>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
{stage.description}
</p>
<Badge
variant="outline"
className={`${getStageStatusColor(
state.status
)} font-medium mt-2`}
>
{state.status === "idle"
? "Ready"
: state.status === "running"
? "Running"
: state.status === "completed"
? "Completed"
: "Error"}
</Badge>
</div>
);
})}
</div>
{/* Error Display */}
{Object.values(stageStates).some((s) => s.error) && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-700 font-medium">
{Object.values(stageStates).find((s) => s.error)?.error}
</p>
</div>
)}
{/* Action Buttons - Aligned with Step Circles */}
<div className="flex items-center justify-between">
{PIPELINE_STAGES.map((stage) => {
const state = stageStates[stage.id] || {
status: "idle" as const,
progress: 0,
};
return (
<div
key={`${stage.id}-action`}
className="flex-1 flex justify-center"
>
{state.status === "idle" && (
<Button
onClick={() => runStage(stage)}
disabled={anyStageRunning}
className="gap-2"
>
{stage.id === "perturb" ? (
<>
<Settings className="h-4 w-4" />
Configure & Run
</>
) : (
<>
<Play className="h-4 w-4" />
Run {stage.name}
</>
)}
</Button>
)}
{state.status === "completed" && (
<Button
variant="outline"
onClick={() => viewStageResults(stage.id)}
className="gap-2 bg-green-50 border-green-200 hover:bg-green-100"
>
<Eye className="h-4 w-4" />
View Results
</Button>
)}
{state.status === "error" && (
<Button
variant="outline"
onClick={() => runStage(stage)}
disabled={anyStageRunning}
className="gap-2"
>
<Play className="h-4 w-4" />
Retry
</Button>
)}
{state.status === "running" && (
<Button disabled className="gap-2">
<Clock className="h-4 w-4" />
Running...
</Button>
)}
</div>
);
})}
</div>
</div>
</CardContent>
</Card>
</div>
{/* Stage Results Dialog */}
{selectedStageResults && (
<Dialog
open={!!selectedStageResults}
onOpenChange={() => setSelectedStageResults(null)}
>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{
PIPELINE_STAGES.find(
(s) => s.id === selectedStageResults.stage
)?.name
}{" "}
Results
</DialogTitle>
<DialogDescription>
Analysis results for knowledge graph {knowledgeGraph.kg_id}
</DialogDescription>
</DialogHeader>
<div className="mt-4">
{/* Render appropriate results component based on stage */}
{selectedStageResults.stage === "enrich" && (
<EnrichResults
data={selectedStageResults.data}
knowledgeGraphId={knowledgeGraph.kg_id}
/>
)}
{selectedStageResults.stage === "perturb" && (
<PerturbResults
data={selectedStageResults.data}
knowledgeGraphId={knowledgeGraph.kg_id}
/>
)}
{selectedStageResults.stage === "causal" && (
<div className="p-8 text-center">
<h3 className="text-lg font-semibold mb-2">
Causal Analysis Results
</h3>
<p className="text-muted-foreground mb-4">
Rich causal analysis visualization is in development.
</p>
<pre className="bg-muted p-4 rounded-lg text-sm overflow-auto text-left max-h-96">
{JSON.stringify(selectedStageResults.data, null, 2)}
</pre>
</div>
)}
</div>
</DialogContent>
</Dialog>
)}
{/* Perturbation Test Configuration Dialog */}
<PerturbationTestConfig
open={showPerturbConfig}
onOpenChange={setShowPerturbConfig}
onRun={runPerturbWithConfig}
/>
</>
);
}