diff --git a/web/app/components/evaluation/components/metric-section/__tests__/index.spec.tsx b/web/app/components/evaluation/components/metric-section/__tests__/index.spec.tsx index 4fb22e7595..36ee1fae1c 100644 --- a/web/app/components/evaluation/components/metric-section/__tests__/index.spec.tsx +++ b/web/app/components/evaluation/components/metric-section/__tests__/index.spec.tsx @@ -31,7 +31,13 @@ describe('MetricSection', () => { mockUseEvaluationNodeInfoMutation.mockReturnValue({ isPending: false, - mutate: vi.fn(), + mutate: (_input: unknown, options?: { onSuccess?: (data: Record>) => void }) => { + options?.onSuccess?.({ + 'answer-correctness': [ + { node_id: 'node-answer', title: 'Answer Node', type: 'llm' }, + ], + }) + }, }) }) @@ -103,6 +109,65 @@ describe('MetricSection', () => { expect(screen.getByText('Answer Node')).toBeInTheDocument() }) + + it('should show only unselected nodes in the add-node dropdown and append the selected node', () => { + // Arrange + mockUseEvaluationNodeInfoMutation.mockReturnValue({ + isPending: false, + mutate: (_input: unknown, options?: { onSuccess?: (data: Record>) => void }) => { + options?.onSuccess?.({ + 'answer-correctness': [ + { node_id: 'node-1', title: 'LLM 1', type: 'llm' }, + { node_id: 'node-2', title: 'LLM 2', type: 'llm' }, + ], + }) + }, + }) + + act(() => { + useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', [ + { node_id: 'node-1', title: 'LLM 1', type: 'llm' }, + ]) + }) + + // Act + renderMetricSection() + + fireEvent.click(screen.getByRole('button', { name: 'evaluation.metrics.addNode' })) + + // Assert + expect(screen.queryByRole('menuitem', { name: 'LLM 1' })).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('menuitem', { name: 'LLM 2' })) + + expect(screen.getByText('LLM 2')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'evaluation.metrics.addNode' })).not.toBeInTheDocument() + }) + + it('should hide the add-node button when the builtin metric already targets all nodes', () => { + // Arrange + mockUseEvaluationNodeInfoMutation.mockReturnValue({ + isPending: false, + mutate: (_input: unknown, options?: { onSuccess?: (data: Record>) => void }) => { + options?.onSuccess?.({ + 'answer-correctness': [ + { node_id: 'node-1', title: 'LLM 1', type: 'llm' }, + { node_id: 'node-2', title: 'LLM 2', type: 'llm' }, + ], + }) + }, + }) + + act(() => { + useEvaluationStore.getState().addBuiltinMetric(resourceType, resourceId, 'answer-correctness', []) + }) + + // Act + renderMetricSection() + + // Assert + expect(screen.getByText('evaluation.metrics.nodesAll')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'evaluation.metrics.addNode' })).not.toBeInTheDocument() + }) }) // Verify the extracted custom metric editor card renders inside the metric card. diff --git a/web/app/components/evaluation/components/metric-section/builtin-metric-card.tsx b/web/app/components/evaluation/components/metric-section/builtin-metric-card.tsx index ece7c35892..cccbb43b49 100644 --- a/web/app/components/evaluation/components/metric-section/builtin-metric-card.tsx +++ b/web/app/components/evaluation/components/metric-section/builtin-metric-card.tsx @@ -1,21 +1,30 @@ 'use client' import type { EvaluationMetric, EvaluationResourceProps } from '../../types' +import type { NodeInfo } from '@/types/evaluation' import { useState } from 'react' import { useTranslation } from 'react-i18next' import Button from '@/app/components/base/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/app/components/base/ui/dropdown-menu' import { cn } from '@/utils/classnames' import { useEvaluationStore } from '../../store' -import { getMetricVisual, getNodeVisual, getToneClasses } from '../metric-selector/utils' +import { dedupeNodeInfoList, getMetricVisual, getNodeVisual, getToneClasses } from '../metric-selector/utils' type BuiltinMetricCardProps = EvaluationResourceProps & { metric: EvaluationMetric + availableNodeInfoList?: NodeInfo[] } const BuiltinMetricCard = ({ resourceType, resourceId, metric, + availableNodeInfoList = [], }: BuiltinMetricCardProps) => { const { t } = useTranslation('evaluation') const updateBuiltinMetric = useEvaluationStore(state => state.addBuiltinMetric) @@ -23,6 +32,12 @@ const BuiltinMetricCard = ({ const [isExpanded, setIsExpanded] = useState(true) const metricVisual = getMetricVisual(metric.optionId) const metricToneClasses = getToneClasses(metricVisual.tone) + const selectedNodeInfoList = metric.nodeInfoList ?? [] + const selectedNodeIdSet = new Set(selectedNodeInfoList.map(nodeInfo => nodeInfo.node_id)) + const selectableNodeInfoList = selectedNodeInfoList.length > 0 + ? availableNodeInfoList.filter(nodeInfo => !selectedNodeIdSet.has(nodeInfo.node_id)) + : [] + const shouldShowAddNode = selectableNodeInfoList.length > 0 return (
@@ -59,8 +74,8 @@ const BuiltinMetricCard = ({ {isExpanded && (
- {metric.nodeInfoList?.length - ? metric.nodeInfoList.map((nodeInfo) => { + {selectedNodeInfoList.length + ? selectedNodeInfoList.map((nodeInfo) => { const nodeVisual = getNodeVisual(nodeInfo) const nodeToneClasses = getToneClasses(nodeVisual.tone) @@ -81,7 +96,7 @@ const BuiltinMetricCard = ({ resourceType, resourceId, metric.optionId, - metric.nodeInfoList?.filter(item => item.node_id !== nodeInfo.node_id) ?? [], + selectedNodeInfoList.filter(item => item.node_id !== nodeInfo.node_id), )} >
)}
diff --git a/web/app/components/evaluation/components/metric-section/index.tsx b/web/app/components/evaluation/components/metric-section/index.tsx index 70a9606ee5..eac266c996 100644 --- a/web/app/components/evaluation/components/metric-section/index.tsx +++ b/web/app/components/evaluation/components/metric-section/index.tsx @@ -1,9 +1,13 @@ 'use client' import type { EvaluationResourceProps } from '../../types' +import type { NodeInfo } from '@/types/evaluation' +import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useAvailableEvaluationMetrics, useEvaluationNodeInfoMutation } from '@/service/use-evaluation' import { useEvaluationResource } from '../../store' import MetricSelector from '../metric-selector' +import { toEvaluationTargetType } from '../metric-selector/utils' import { InlineSectionHeader } from '../section-header' import MetricCard from './metric-card' import MetricSectionEmptyState from './metric-section-empty-state' @@ -14,7 +18,52 @@ const MetricSection = ({ }: EvaluationResourceProps) => { const { t } = useTranslation('evaluation') const resource = useEvaluationResource(resourceType, resourceId) + const [nodeInfoMap, setNodeInfoMap] = useState>({}) const hasMetrics = resource.metrics.length > 0 + const hasBuiltinMetrics = resource.metrics.some(metric => metric.kind === 'builtin') + const shouldLoadNodeInfo = resourceType !== 'pipeline' && !!resourceId && hasBuiltinMetrics + const { data: availableMetricsData } = useAvailableEvaluationMetrics(shouldLoadNodeInfo) + const { mutate: loadNodeInfo } = useEvaluationNodeInfoMutation() + const availableMetricIds = useMemo(() => availableMetricsData?.metrics ?? [], [availableMetricsData?.metrics]) + const availableMetricIdsKey = availableMetricIds.join(',') + const resolvedNodeInfoMap = shouldLoadNodeInfo ? nodeInfoMap : {} + + useEffect(() => { + if (!shouldLoadNodeInfo || availableMetricIds.length === 0) + return + + let isActive = true + + loadNodeInfo( + { + params: { + targetType: toEvaluationTargetType(resourceType), + targetId: resourceId, + }, + body: { + metrics: availableMetricIds, + }, + }, + { + onSuccess: (data) => { + if (!isActive) + return + + setNodeInfoMap(data) + }, + onError: () => { + if (!isActive) + return + + setNodeInfoMap({}) + }, + }, + ) + + return () => { + isActive = false + } + }, [availableMetricIds, availableMetricIdsKey, loadNodeInfo, resourceId, resourceType, shouldLoadNodeInfo]) return (
@@ -30,6 +79,7 @@ const MetricSection = ({ resourceType={resourceType} resourceId={resourceId} metric={metric} + availableNodeInfoList={metric.kind === 'builtin' ? (resolvedNodeInfoMap[metric.optionId] ?? []) : undefined} /> ))} { if (metric.kind === 'custom-workflow') { return ( @@ -28,6 +31,7 @@ const MetricCard = ({ resourceType={resourceType} resourceId={resourceId} metric={metric} + availableNodeInfoList={availableNodeInfoList} /> ) }