mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 18:27:19 +08:00
feat(web): support variable selecting in variable mapping
This commit is contained in:
parent
2a1761ac06
commit
4879ea5cd5
@ -30,9 +30,10 @@ describe('evaluation store', () => {
|
||||
workflowAppId: 'custom-workflow-app-id',
|
||||
workflowName: config.workflowOptions[0].label,
|
||||
})
|
||||
store.updateCustomMetricMapping(resourceType, resourceId, initialMetric!.id, initialMetric!.customConfig!.mappings[0].id, {
|
||||
sourceFieldId: config.fieldOptions[0].id,
|
||||
targetVariableId: config.workflowOptions[0].targetVariables[0].id,
|
||||
store.syncCustomMetricMappings(resourceType, resourceId, initialMetric!.id, ['query'])
|
||||
const syncedMetric = useEvaluationStore.getState().resources['apps:app-1'].metrics.find(metric => metric.id === initialMetric!.id)
|
||||
store.updateCustomMetricMapping(resourceType, resourceId, initialMetric!.id, syncedMetric!.customConfig!.mappings[0].id, {
|
||||
outputVariableId: 'answer',
|
||||
})
|
||||
|
||||
const configuredMetric = useEvaluationStore.getState().resources['apps:app-1'].metrics.find(metric => metric.id === initialMetric!.id)
|
||||
@ -159,7 +160,7 @@ describe('evaluation store', () => {
|
||||
customized_metrics: {
|
||||
evaluation_workflow_id: 'workflow-precision-review',
|
||||
input_fields: {
|
||||
'app.input.query': 'query',
|
||||
query: 'answer',
|
||||
},
|
||||
},
|
||||
judgement_conditions: [{
|
||||
@ -203,8 +204,8 @@ describe('evaluation store', () => {
|
||||
])
|
||||
expect(hydratedState.metrics[1].kind).toBe('custom-workflow')
|
||||
expect(hydratedState.metrics[1].customConfig?.workflowId).toBe('workflow-precision-review')
|
||||
expect(hydratedState.metrics[1].customConfig?.mappings[0].sourceFieldId).toBe('app.input.query')
|
||||
expect(hydratedState.metrics[1].customConfig?.mappings[0].targetVariableId).toBe('query')
|
||||
expect(hydratedState.metrics[1].customConfig?.mappings[0].inputVariableId).toBe('query')
|
||||
expect(hydratedState.metrics[1].customConfig?.mappings[0].outputVariableId).toBe('answer')
|
||||
expect(hydratedState.conditions[0].logicalOperator).toBe('or')
|
||||
expect(hydratedState.conditions[0].items[0]).toMatchObject({
|
||||
fieldId: 'system.has_context',
|
||||
|
||||
@ -1,21 +1,30 @@
|
||||
import type { EvaluationMetric } from '../../../types'
|
||||
import type { CodeNodeType } from '@/app/components/workflow/nodes/code/types'
|
||||
import type { EndNodeType } from '@/app/components/workflow/nodes/end/types'
|
||||
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import type { SnippetWorkflow } from '@/types/snippet'
|
||||
import type { FetchWorkflowDraftResponse } from '@/types/workflow'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/types'
|
||||
import CustomMetricEditorCard from '..'
|
||||
import { useEvaluationStore } from '../../../store'
|
||||
|
||||
const mockUseAppWorkflow = vi.hoisted(() => vi.fn())
|
||||
const mockUseSnippetPublishedWorkflow = vi.hoisted(() => vi.fn())
|
||||
const mockUseAvailableEvaluationWorkflows = vi.hoisted(() => vi.fn())
|
||||
const mockUseInfiniteScroll = vi.hoisted(() => vi.fn())
|
||||
const mockPublishedGraphVariablePicker = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/service/use-workflow', () => ({
|
||||
useAppWorkflow: (...args: unknown[]) => mockUseAppWorkflow(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-snippet-workflows', () => ({
|
||||
useSnippetPublishedWorkflow: (...args: unknown[]) => mockUseSnippetPublishedWorkflow(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-evaluation', () => ({
|
||||
useAvailableEvaluationWorkflows: (...args: unknown[]) => mockUseAvailableEvaluationWorkflows(...args),
|
||||
}))
|
||||
@ -24,6 +33,13 @@ vi.mock('ahooks', () => ({
|
||||
useInfiniteScroll: (...args: unknown[]) => mockUseInfiniteScroll(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../published-graph-variable-picker', () => ({
|
||||
default: (props: Record<string, unknown>) => {
|
||||
mockPublishedGraphVariablePicker(props)
|
||||
return <div data-testid="published-graph-variable-picker" />
|
||||
},
|
||||
}))
|
||||
|
||||
const createStartNode = (): Node<StartNodeType> => ({
|
||||
id: 'start-node',
|
||||
type: 'custom',
|
||||
@ -32,7 +48,20 @@ const createStartNode = (): Node<StartNodeType> => ({
|
||||
type: BlockEnum.Start,
|
||||
title: 'Start',
|
||||
desc: '',
|
||||
variables: [],
|
||||
variables: [
|
||||
{
|
||||
variable: 'user_question',
|
||||
label: 'User Question',
|
||||
type: InputVarType.textInput,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
variable: 'retrieved_context',
|
||||
label: 'Retrieved Context',
|
||||
type: InputVarType.textInput,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
@ -50,6 +79,33 @@ const createEndNode = (
|
||||
},
|
||||
})
|
||||
|
||||
const createCodeNode = (
|
||||
id: string,
|
||||
title: string,
|
||||
outputs: Record<string, { type: VarType }>,
|
||||
): Node<CodeNodeType> => ({
|
||||
id,
|
||||
type: 'custom',
|
||||
position: { x: 100, y: 0 },
|
||||
data: {
|
||||
type: BlockEnum.Code,
|
||||
title,
|
||||
desc: '',
|
||||
code: '',
|
||||
code_language: CodeLanguage.python3,
|
||||
outputs: Object.fromEntries(
|
||||
Object.entries(outputs).map(([key, value]) => [
|
||||
key,
|
||||
{
|
||||
type: value.type,
|
||||
children: null,
|
||||
},
|
||||
]),
|
||||
),
|
||||
variables: [],
|
||||
},
|
||||
})
|
||||
|
||||
const createWorkflow = (
|
||||
nodes: Node[],
|
||||
): FetchWorkflowDraftResponse => ({
|
||||
@ -80,6 +136,20 @@ const createWorkflow = (
|
||||
marked_comment: 'Published',
|
||||
})
|
||||
|
||||
const createSnippetWorkflow = (
|
||||
nodes: Node[],
|
||||
): SnippetWorkflow => ({
|
||||
id: 'snippet-workflow-1',
|
||||
graph: {
|
||||
nodes,
|
||||
edges: [],
|
||||
},
|
||||
features: {},
|
||||
hash: 'snippet-hash-1',
|
||||
created_at: 1710000000,
|
||||
updated_at: 1710000001,
|
||||
})
|
||||
|
||||
const createMetric = (): EvaluationMetric => ({
|
||||
id: 'metric-1',
|
||||
optionId: 'custom-1',
|
||||
@ -88,12 +158,16 @@ const createMetric = (): EvaluationMetric => ({
|
||||
description: 'Map workflow variables to your evaluation inputs.',
|
||||
customConfig: {
|
||||
workflowId: 'workflow-1',
|
||||
workflowAppId: 'app-1',
|
||||
workflowAppId: 'workflow-app-1',
|
||||
workflowName: 'Evaluation Workflow',
|
||||
mappings: [{
|
||||
id: 'mapping-1',
|
||||
sourceFieldId: null,
|
||||
targetVariableId: null,
|
||||
inputVariableId: 'user_question',
|
||||
outputVariableId: 'current-node.answer',
|
||||
}, {
|
||||
id: 'mapping-2',
|
||||
inputVariableId: 'retrieved_context',
|
||||
outputVariableId: 'current-node.score',
|
||||
}],
|
||||
},
|
||||
})
|
||||
@ -102,6 +176,7 @@ describe('CustomMetricEditorCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useEvaluationStore.setState({ resources: {} })
|
||||
mockPublishedGraphVariablePicker.mockReset()
|
||||
|
||||
mockUseInfiniteScroll.mockImplementation(() => undefined)
|
||||
mockUseAvailableEvaluationWorkflows.mockReturnValue({
|
||||
@ -119,48 +194,74 @@ describe('CustomMetricEditorCard', () => {
|
||||
isFetchingNextPage: false,
|
||||
isLoading: false,
|
||||
})
|
||||
mockUseSnippetPublishedWorkflow.mockReturnValue({ data: undefined })
|
||||
})
|
||||
|
||||
// Verify end-node outputs are shown after a workflow is selected.
|
||||
// Verify the selected evaluation workflow still drives the output summary section.
|
||||
describe('Outputs', () => {
|
||||
it('should render the selected workflow outputs from the end node', () => {
|
||||
mockUseAppWorkflow.mockReturnValue({
|
||||
data: createWorkflow([
|
||||
createStartNode(),
|
||||
createEndNode([
|
||||
{ variable: 'answer_score', value_selector: ['end', 'answer_score'], value_type: VarType.number },
|
||||
{ variable: 'reason', value_selector: ['end', 'reason'], value_type: VarType.string },
|
||||
]),
|
||||
const selectedWorkflow = createWorkflow([
|
||||
createStartNode(),
|
||||
createEndNode([
|
||||
{ variable: 'answer_score', value_selector: ['end', 'answer_score'], value_type: VarType.number },
|
||||
{ variable: 'reason', value_selector: ['end', 'reason'], value_type: VarType.string },
|
||||
]),
|
||||
])
|
||||
const currentAppWorkflow = createWorkflow([
|
||||
createCodeNode('current-node', 'Current Node', {
|
||||
answer: { type: VarType.string },
|
||||
score: { type: VarType.number },
|
||||
}),
|
||||
])
|
||||
|
||||
mockUseAppWorkflow.mockImplementation((appId: string) => {
|
||||
if (appId === 'workflow-app-1')
|
||||
return { data: selectedWorkflow }
|
||||
if (appId === 'app-under-test')
|
||||
return { data: currentAppWorkflow }
|
||||
|
||||
return { data: undefined }
|
||||
})
|
||||
|
||||
render(
|
||||
<CustomMetricEditorCard
|
||||
resourceType="apps"
|
||||
resourceId="app-1"
|
||||
resourceId="app-under-test"
|
||||
metric={createMetric()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('evaluation.metrics.custom.outputTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('answer_score')).toBeInTheDocument()
|
||||
expect(screen.getByText('number')).toBeInTheDocument()
|
||||
expect(screen.getByText('reason')).toBeInTheDocument()
|
||||
expect(screen.getByText('string')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('answer_score').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('number').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('reason').length).toBeGreaterThan(0)
|
||||
expect(screen.getAllByText('string').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should hide the output section when the selected workflow has no end outputs', () => {
|
||||
mockUseAppWorkflow.mockReturnValue({
|
||||
data: createWorkflow([
|
||||
createStartNode(),
|
||||
createEndNode([]),
|
||||
]),
|
||||
const selectedWorkflow = createWorkflow([
|
||||
createStartNode(),
|
||||
createEndNode([]),
|
||||
])
|
||||
const currentAppWorkflow = createWorkflow([
|
||||
createCodeNode('current-node', 'Current Node', {
|
||||
answer: { type: VarType.string },
|
||||
}),
|
||||
])
|
||||
|
||||
mockUseAppWorkflow.mockImplementation((appId: string) => {
|
||||
if (appId === 'workflow-app-1')
|
||||
return { data: selectedWorkflow }
|
||||
if (appId === 'app-under-test')
|
||||
return { data: currentAppWorkflow }
|
||||
|
||||
return { data: undefined }
|
||||
})
|
||||
|
||||
render(
|
||||
<CustomMetricEditorCard
|
||||
resourceType="apps"
|
||||
resourceId="app-1"
|
||||
resourceId="app-under-test"
|
||||
metric={createMetric()}
|
||||
/>,
|
||||
)
|
||||
@ -168,4 +269,94 @@ describe('CustomMetricEditorCard', () => {
|
||||
expect(screen.queryByText('evaluation.metrics.custom.outputTitle')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// Verify mapping rows use workflow start variables on the left and current published graph variables on the right.
|
||||
describe('Variable Mapping', () => {
|
||||
it('should pass the current app published graph and saved selector values to the picker', () => {
|
||||
const selectedWorkflow = createWorkflow([
|
||||
createStartNode(),
|
||||
createEndNode([
|
||||
{ variable: 'answer_score', value_selector: ['end', 'answer_score'], value_type: VarType.number },
|
||||
{ variable: 'reason', value_selector: ['end', 'reason'], value_type: VarType.string },
|
||||
]),
|
||||
])
|
||||
const currentAppWorkflow = createWorkflow([
|
||||
createStartNode(),
|
||||
createCodeNode('current-node', 'Current Node', {
|
||||
answer: { type: VarType.string },
|
||||
score: { type: VarType.number },
|
||||
}),
|
||||
])
|
||||
|
||||
mockUseAppWorkflow.mockImplementation((appId: string) => {
|
||||
if (appId === 'workflow-app-1')
|
||||
return { data: selectedWorkflow }
|
||||
if (appId === 'app-under-test')
|
||||
return { data: currentAppWorkflow }
|
||||
|
||||
return { data: undefined }
|
||||
})
|
||||
|
||||
render(
|
||||
<CustomMetricEditorCard
|
||||
resourceType="apps"
|
||||
resourceId="app-under-test"
|
||||
metric={createMetric()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('user_question')).toBeInTheDocument()
|
||||
expect(screen.getByText('retrieved_context')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('string')).toHaveLength(3)
|
||||
expect(mockPublishedGraphVariablePicker).toHaveBeenCalledTimes(2)
|
||||
expect(mockPublishedGraphVariablePicker.mock.calls[0][0]).toMatchObject({
|
||||
nodes: currentAppWorkflow.graph.nodes,
|
||||
edges: currentAppWorkflow.graph.edges,
|
||||
value: 'current-node.answer',
|
||||
})
|
||||
expect(mockPublishedGraphVariablePicker.mock.calls[1][0]).toMatchObject({
|
||||
nodes: currentAppWorkflow.graph.nodes,
|
||||
edges: currentAppWorkflow.graph.edges,
|
||||
value: 'current-node.score',
|
||||
})
|
||||
})
|
||||
|
||||
it('should use the current snippet published graph when editing a snippet evaluation', () => {
|
||||
const selectedWorkflow = createWorkflow([
|
||||
createStartNode(),
|
||||
createEndNode([
|
||||
{ variable: 'reason', value_selector: ['end', 'reason'], value_type: VarType.string },
|
||||
]),
|
||||
])
|
||||
const currentSnippetWorkflow = createSnippetWorkflow([
|
||||
createCodeNode('snippet-node', 'Snippet Node', {
|
||||
result: { type: VarType.string },
|
||||
}),
|
||||
])
|
||||
|
||||
mockUseAppWorkflow.mockImplementation((appId: string) => {
|
||||
if (appId === 'workflow-app-1')
|
||||
return { data: selectedWorkflow }
|
||||
|
||||
return { data: undefined }
|
||||
})
|
||||
mockUseSnippetPublishedWorkflow.mockReturnValue({
|
||||
data: currentSnippetWorkflow,
|
||||
})
|
||||
|
||||
render(
|
||||
<CustomMetricEditorCard
|
||||
resourceType="snippets"
|
||||
resourceId="snippet-under-test"
|
||||
metric={createMetric()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockPublishedGraphVariablePicker).toHaveBeenCalledTimes(2)
|
||||
expect(mockPublishedGraphVariablePicker.mock.calls[0][0]).toMatchObject({
|
||||
nodes: currentSnippetWorkflow.graph.nodes,
|
||||
edges: currentSnippetWorkflow.graph.edges,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -3,11 +3,12 @@
|
||||
import type { EvaluationMetric, EvaluationResourceProps } from '../../types'
|
||||
import type { EndNodeType } from '@/app/components/workflow/nodes/end/types'
|
||||
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import { useMemo } from 'react'
|
||||
import type { Edge, InputVar, Node } from '@/app/components/workflow/types'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { inputVarTypeToVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils'
|
||||
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
|
||||
import { useSnippetPublishedWorkflow } from '@/service/use-snippet-workflows'
|
||||
import { useAppWorkflow } from '@/service/use-workflow'
|
||||
import { isCustomMetricConfigured, useEvaluationStore } from '../../store'
|
||||
import MappingRow from './mapping-row'
|
||||
@ -17,16 +18,16 @@ type CustomMetricEditorCardProps = EvaluationResourceProps & {
|
||||
metric: EvaluationMetric
|
||||
}
|
||||
|
||||
const getWorkflowTargetVariables = (
|
||||
const getWorkflowInputVariables = (
|
||||
nodes?: Array<Node>,
|
||||
) => {
|
||||
const startNode = nodes?.find(node => node.data.type === BlockEnum.Start) as Node<StartNodeType> | undefined
|
||||
if (!startNode || !Array.isArray(startNode.data.variables))
|
||||
return []
|
||||
|
||||
return startNode.data.variables.map(variable => ({
|
||||
return startNode.data.variables.map((variable: InputVar) => ({
|
||||
id: variable.variable,
|
||||
label: typeof variable.label === 'string' ? variable.label : variable.variable,
|
||||
valueType: inputVarTypeToVarType(variable.type ?? InputVarType.textInput),
|
||||
}))
|
||||
}
|
||||
|
||||
@ -39,9 +40,11 @@ const getWorkflowOutputs = (nodes?: Array<Node>) => {
|
||||
return []
|
||||
|
||||
return endNode.data.outputs
|
||||
.filter(output => typeof output.variable === 'string' && !!output.variable)
|
||||
.map(output => ({
|
||||
variable: output.variable,
|
||||
valueType: output.value_type,
|
||||
id: output.variable,
|
||||
valueType: typeof output.value_type === 'string' ? output.value_type : null,
|
||||
nodeTitle: typeof endNode.data.title === 'string' && endNode.data.title ? endNode.data.title : 'End',
|
||||
}))
|
||||
})
|
||||
}
|
||||
@ -54,6 +57,14 @@ const getWorkflowName = (workflow: {
|
||||
return workflow.marked_name || workflow.app_name || workflow.id
|
||||
}
|
||||
|
||||
const getGraphNodes = (graph?: Record<string, unknown>) => {
|
||||
return Array.isArray(graph?.nodes) ? graph.nodes as Node[] : []
|
||||
}
|
||||
|
||||
const getGraphEdges = (graph?: Record<string, unknown>) => {
|
||||
return Array.isArray(graph?.edges) ? graph.edges as Edge[] : []
|
||||
}
|
||||
|
||||
const CustomMetricEditorCard = ({
|
||||
resourceType,
|
||||
resourceId,
|
||||
@ -61,18 +72,60 @@ const CustomMetricEditorCard = ({
|
||||
}: CustomMetricEditorCardProps) => {
|
||||
const { t } = useTranslation('evaluation')
|
||||
const setCustomMetricWorkflow = useEvaluationStore(state => state.setCustomMetricWorkflow)
|
||||
const addCustomMetricMapping = useEvaluationStore(state => state.addCustomMetricMapping)
|
||||
const syncCustomMetricMappings = useEvaluationStore(state => state.syncCustomMetricMappings)
|
||||
const updateCustomMetricMapping = useEvaluationStore(state => state.updateCustomMetricMapping)
|
||||
const removeCustomMetricMapping = useEvaluationStore(state => state.removeCustomMetricMapping)
|
||||
const { data: selectedWorkflow } = useAppWorkflow(metric.customConfig?.workflowAppId ?? '')
|
||||
const targetOptions = useMemo(() => {
|
||||
return getWorkflowTargetVariables(selectedWorkflow?.graph.nodes)
|
||||
const { data: currentAppWorkflow } = useAppWorkflow(resourceType === 'apps' ? resourceId : '')
|
||||
const { data: currentSnippetWorkflow } = useSnippetPublishedWorkflow(resourceType === 'snippets' ? resourceId : '')
|
||||
const inputVariables = useMemo(() => {
|
||||
return getWorkflowInputVariables(selectedWorkflow?.graph.nodes)
|
||||
}, [selectedWorkflow?.graph.nodes])
|
||||
const workflowOutputs = useMemo(() => {
|
||||
return getWorkflowOutputs(selectedWorkflow?.graph.nodes)
|
||||
}, [selectedWorkflow?.graph.nodes])
|
||||
const publishedGraph = useMemo(() => {
|
||||
if (resourceType === 'apps') {
|
||||
return {
|
||||
nodes: currentAppWorkflow?.graph.nodes ?? [],
|
||||
edges: currentAppWorkflow?.graph.edges ?? [],
|
||||
environmentVariables: currentAppWorkflow?.environment_variables ?? [],
|
||||
conversationVariables: currentAppWorkflow?.conversation_variables ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: getGraphNodes(currentSnippetWorkflow?.graph),
|
||||
edges: getGraphEdges(currentSnippetWorkflow?.graph),
|
||||
environmentVariables: [],
|
||||
conversationVariables: [],
|
||||
}
|
||||
}, [
|
||||
currentAppWorkflow?.conversation_variables,
|
||||
currentAppWorkflow?.environment_variables,
|
||||
currentAppWorkflow?.graph.edges,
|
||||
currentAppWorkflow?.graph.nodes,
|
||||
currentSnippetWorkflow?.graph,
|
||||
resourceType,
|
||||
])
|
||||
const inputVariableIds = useMemo(() => inputVariables.map(variable => variable.id), [inputVariables])
|
||||
const isConfigured = isCustomMetricConfigured(metric)
|
||||
|
||||
useEffect(() => {
|
||||
if (!metric.customConfig?.workflowId)
|
||||
return
|
||||
|
||||
const currentInputVariableIds = metric.customConfig.mappings
|
||||
.map(mapping => mapping.inputVariableId)
|
||||
.filter((value): value is string => !!value)
|
||||
|
||||
if (currentInputVariableIds.length === inputVariableIds.length
|
||||
&& currentInputVariableIds.every((value, index) => value === inputVariableIds[index])) {
|
||||
return
|
||||
}
|
||||
|
||||
syncCustomMetricMappings(resourceType, resourceId, metric.id, inputVariableIds)
|
||||
}, [inputVariableIds, metric.customConfig?.mappings, metric.customConfig?.workflowId, metric.id, resourceId, resourceType, syncCustomMetricMappings])
|
||||
|
||||
if (!metric.customConfig)
|
||||
return null
|
||||
|
||||
@ -88,6 +141,37 @@ const CustomMetricEditorCard = ({
|
||||
})}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="system-xs-medium-uppercase text-text-secondary">{t('metrics.custom.mappingTitle')}</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{inputVariables.map((inputVariable) => {
|
||||
const mapping = metric.customConfig?.mappings.find(item => item.inputVariableId === inputVariable.id)
|
||||
|
||||
return (
|
||||
<MappingRow
|
||||
key={inputVariable.id}
|
||||
inputVariable={inputVariable}
|
||||
publishedGraph={publishedGraph}
|
||||
value={mapping?.outputVariableId ?? null}
|
||||
onUpdate={(outputVariableId) => {
|
||||
if (!mapping)
|
||||
return
|
||||
|
||||
updateCustomMetricMapping(resourceType, resourceId, metric.id, mapping.id, { outputVariableId })
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{!isConfigured && (
|
||||
<div className="mt-3 rounded-lg bg-background-section px-3 py-2 system-xs-regular text-text-tertiary">
|
||||
{t('metrics.custom.mappingWarning')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!!workflowOutputs.length && (
|
||||
<div className="mt-4 py-1">
|
||||
<div className="min-h-6 system-xs-medium-uppercase text-text-tertiary">
|
||||
@ -95,8 +179,8 @@ const CustomMetricEditorCard = ({
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-y-1 px-2 py-2 system-xs-regular text-text-tertiary">
|
||||
{workflowOutputs.map((output, index) => (
|
||||
<div key={output.variable} className="flex items-center">
|
||||
<span className="px-1 system-xs-medium text-text-secondary">{output.variable}</span>
|
||||
<div key={`${output.nodeTitle}-${output.id}-${index}`} className="flex items-center">
|
||||
<span className="px-1 system-xs-medium text-text-secondary">{output.id}</span>
|
||||
{output.valueType && (
|
||||
<span>{output.valueType}</span>
|
||||
)}
|
||||
@ -108,38 +192,6 @@ const CustomMetricEditorCard = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="system-xs-medium-uppercase text-text-secondary">{t('metrics.custom.mappingTitle')}</div>
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
className="text-text-accent"
|
||||
onClick={() => addCustomMetricMapping(resourceType, resourceId, metric.id)}
|
||||
>
|
||||
<span aria-hidden="true" className="mr-1 i-ri-add-line h-4 w-4" />
|
||||
{t('metrics.custom.addMapping')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{metric.customConfig.mappings.map(mapping => (
|
||||
<MappingRow
|
||||
key={mapping.id}
|
||||
resourceType={resourceType}
|
||||
mapping={mapping}
|
||||
targetOptions={targetOptions}
|
||||
onUpdate={patch => updateCustomMetricMapping(resourceType, resourceId, metric.id, mapping.id, patch)}
|
||||
onRemove={() => removeCustomMetricMapping(resourceType, resourceId, metric.id, mapping.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!isConfigured && (
|
||||
<div className="mt-3 rounded-lg bg-background-section px-3 py-2 system-xs-regular text-text-tertiary">
|
||||
{t('metrics.custom.mappingWarning')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,74 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import type { CustomMetricMapping, EvaluationResourceType } from '../../types'
|
||||
import type {
|
||||
ConversationVariable,
|
||||
Edge,
|
||||
EnvironmentVariable,
|
||||
Node,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectGroupLabel,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/app/components/base/ui/select'
|
||||
import { getEvaluationMockConfig } from '../../mock'
|
||||
import { groupFieldOptions } from '../../utils'
|
||||
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
|
||||
import PublishedGraphVariablePicker from './published-graph-variable-picker'
|
||||
|
||||
type MappingRowProps = {
|
||||
resourceType: EvaluationResourceType
|
||||
mapping: CustomMetricMapping
|
||||
targetOptions: Array<{ id: string, label: string }>
|
||||
onUpdate: (patch: { sourceFieldId?: string | null, targetVariableId?: string | null }) => void
|
||||
onRemove: () => void
|
||||
inputVariable: {
|
||||
id: string
|
||||
valueType: string
|
||||
}
|
||||
publishedGraph: {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
environmentVariables: EnvironmentVariable[]
|
||||
conversationVariables: ConversationVariable[]
|
||||
}
|
||||
value: string | null
|
||||
onUpdate: (outputVariableId: string | null) => void
|
||||
}
|
||||
|
||||
const MappingRow = ({
|
||||
resourceType,
|
||||
mapping,
|
||||
targetOptions,
|
||||
inputVariable,
|
||||
publishedGraph,
|
||||
value,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
}: MappingRowProps) => {
|
||||
const { t } = useTranslation('evaluation')
|
||||
const config = getEvaluationMockConfig(resourceType)
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-card-bg p-3 xl:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)_auto]">
|
||||
<Select value={mapping.sourceFieldId ?? ''} onValueChange={value => onUpdate({ sourceFieldId: value })}>
|
||||
<SelectTrigger className="bg-transparent hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt">
|
||||
<SelectValue placeholder={t('metrics.custom.sourcePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupFieldOptions(config.fieldOptions).map(([groupName, fields]) => (
|
||||
<SelectGroup key={groupName}>
|
||||
<SelectGroupLabel>{groupName}</SelectGroupLabel>
|
||||
{fields.map(field => (
|
||||
<SelectItem key={field.id} value={field.id}>{field.label}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center justify-center text-text-quaternary">
|
||||
<span aria-hidden="true" className="i-ri-arrow-down-s-line h-4 w-4 -rotate-90" />
|
||||
<div className="flex items-center">
|
||||
<div className="flex h-8 w-[200px] items-center rounded-md px-2">
|
||||
<div className="flex min-w-0 items-center gap-0.5 px-1">
|
||||
<Variable02 className="h-3.5 w-3.5 shrink-0 text-text-accent" />
|
||||
<div className="truncate system-xs-medium text-text-secondary">{inputVariable.id}</div>
|
||||
<div className="shrink-0 system-xs-regular text-text-tertiary">{inputVariable.valueType}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Select value={mapping.targetVariableId ?? ''} onValueChange={value => onUpdate({ targetVariableId: value })}>
|
||||
<SelectTrigger className="bg-transparent hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt">
|
||||
<SelectValue placeholder={t('metrics.custom.targetPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetOptions.map(option => (
|
||||
<SelectItem key={option.id} value={option.id}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex h-8 w-9 items-center justify-center px-3 system-xs-medium text-text-tertiary">
|
||||
<span aria-hidden="true">→</span>
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="small" aria-label={t('metrics.remove')} onClick={onRemove}>
|
||||
<span aria-hidden="true" className="i-ri-delete-bin-line h-4 w-4" />
|
||||
</Button>
|
||||
<PublishedGraphVariablePicker
|
||||
className="grow"
|
||||
nodes={publishedGraph.nodes}
|
||||
edges={publishedGraph.edges}
|
||||
environmentVariables={publishedGraph.environmentVariables}
|
||||
conversationVariables={publishedGraph.conversationVariables}
|
||||
value={value}
|
||||
placeholder={t('metrics.custom.outputPlaceholder')}
|
||||
onChange={onUpdate}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
|
||||
import type { EndNodeType } from '@/app/components/workflow/nodes/end/types'
|
||||
import type {
|
||||
ConversationVariable,
|
||||
Edge,
|
||||
EnvironmentVariable,
|
||||
Node,
|
||||
ValueSelector,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { useMemo } from 'react'
|
||||
import ReactFlow, { ReactFlowProvider } from 'reactflow'
|
||||
import { WorkflowContext } from '@/app/components/workflow/context'
|
||||
import { createHooksStore, HooksStoreContext } from '@/app/components/workflow/hooks-store'
|
||||
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
|
||||
import { createWorkflowStore } from '@/app/components/workflow/store/workflow'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { variableTransformer } from '@/app/components/workflow/utils/variable'
|
||||
|
||||
type PublishedGraphVariablePickerProps = {
|
||||
className?: string
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
environmentVariables?: EnvironmentVariable[]
|
||||
conversationVariables?: ConversationVariable[]
|
||||
placeholder: string
|
||||
value: string | null
|
||||
onChange: (value: string | null) => void
|
||||
}
|
||||
|
||||
const PICKER_NODE_ID = '__evaluation-variable-picker__'
|
||||
|
||||
const createPickerNode = (): Node<EndNodeType> => ({
|
||||
id: PICKER_NODE_ID,
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
type: BlockEnum.End,
|
||||
title: 'End',
|
||||
desc: '',
|
||||
outputs: [],
|
||||
},
|
||||
})
|
||||
|
||||
const PublishedGraphVariablePicker = ({
|
||||
className,
|
||||
nodes,
|
||||
edges,
|
||||
environmentVariables = [],
|
||||
conversationVariables = [],
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
}: PublishedGraphVariablePickerProps) => {
|
||||
const workflowStore = useMemo(() => {
|
||||
const store = createWorkflowStore({})
|
||||
store.setState({
|
||||
isWorkflowDataLoaded: true,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragPipelineVariables: [],
|
||||
dataSourceList: [],
|
||||
})
|
||||
return store
|
||||
}, [conversationVariables, environmentVariables])
|
||||
|
||||
const hooksStore = useMemo(() => createHooksStore({}), [])
|
||||
|
||||
const pickerNodes = useMemo(() => {
|
||||
return [...nodes, createPickerNode()]
|
||||
}, [nodes])
|
||||
|
||||
const pickerValue = useMemo<ValueSelector>(() => {
|
||||
if (!value)
|
||||
return []
|
||||
|
||||
return variableTransformer(value) as ValueSelector
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<WorkflowContext.Provider value={workflowStore}>
|
||||
<HooksStoreContext.Provider value={hooksStore}>
|
||||
<div id="workflow-container" className={className}>
|
||||
<ReactFlowProvider>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute h-px w-px overflow-hidden opacity-0"
|
||||
>
|
||||
<div style={{ width: 800, height: 600 }}>
|
||||
<ReactFlow nodes={pickerNodes} edges={edges} fitView />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VarReferencePicker
|
||||
className="grow"
|
||||
nodeId={PICKER_NODE_ID}
|
||||
readonly={!nodes.length}
|
||||
isShowNodeName
|
||||
value={pickerValue}
|
||||
onChange={(nextValue) => {
|
||||
if (!Array.isArray(nextValue) || !nextValue.length) {
|
||||
onChange(null)
|
||||
return
|
||||
}
|
||||
|
||||
onChange(nextValue.join('.'))
|
||||
}}
|
||||
availableNodes={nodes}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
</HooksStoreContext.Provider>
|
||||
</WorkflowContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default PublishedGraphVariablePicker
|
||||
@ -208,7 +208,7 @@ describe('MetricSection', () => {
|
||||
expect(screen.getByText('Custom Evaluator')).toBeInTheDocument()
|
||||
expect(screen.getByText('evaluation.metrics.custom.warningBadge')).toBeInTheDocument()
|
||||
expect(screen.getByText('evaluation.metrics.custom.workflowPlaceholder')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'evaluation.metrics.custom.addMapping' })).toBeInTheDocument()
|
||||
expect(screen.getByText('evaluation.metrics.custom.mappingTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable adding another custom metric when one already exists', () => {
|
||||
|
||||
@ -89,20 +89,16 @@ const normalizeCustomMetricMappings = (
|
||||
value: EvaluationCustomizedMetric['input_fields'],
|
||||
): CustomMetricMapping[] => {
|
||||
if (!value)
|
||||
return [createCustomMetricMapping()]
|
||||
return []
|
||||
|
||||
const mappings = Object.entries(value)
|
||||
.filter((entry): entry is [string, string] => {
|
||||
const [, targetVariableId] = entry
|
||||
return typeof targetVariableId === 'string' && !!targetVariableId
|
||||
const [, outputVariableId] = entry
|
||||
return typeof outputVariableId === 'string' && !!outputVariableId
|
||||
})
|
||||
.map(([sourceFieldId, targetVariableId]) => ({
|
||||
id: createId('mapping'),
|
||||
sourceFieldId,
|
||||
targetVariableId,
|
||||
}))
|
||||
.map(([inputVariableId, outputVariableId]) => createCustomMetricMapping(inputVariableId, outputVariableId))
|
||||
|
||||
return mappings.length > 0 ? mappings : [createCustomMetricMapping()]
|
||||
return mappings
|
||||
}
|
||||
|
||||
const normalizeCustomMetric = (
|
||||
@ -228,14 +224,38 @@ export function createBuiltinMetric(
|
||||
}
|
||||
}
|
||||
|
||||
export function createCustomMetricMapping(): CustomMetricMapping {
|
||||
function createCustomMetricMapping(
|
||||
inputVariableId: string | null = null,
|
||||
outputVariableId: string | null = null,
|
||||
): CustomMetricMapping {
|
||||
return {
|
||||
id: createId('mapping'),
|
||||
sourceFieldId: null,
|
||||
targetVariableId: null,
|
||||
inputVariableId,
|
||||
outputVariableId,
|
||||
}
|
||||
}
|
||||
|
||||
export const syncCustomMetricMappings = (
|
||||
mappings: CustomMetricMapping[],
|
||||
inputVariableIds: string[],
|
||||
) => {
|
||||
const mappingByInputVariableId = new Map(
|
||||
mappings
|
||||
.filter(mapping => !!mapping.inputVariableId)
|
||||
.map(mapping => [mapping.inputVariableId, mapping]),
|
||||
)
|
||||
|
||||
return inputVariableIds.map((inputVariableId) => {
|
||||
const existingMapping = mappingByInputVariableId.get(inputVariableId)
|
||||
return existingMapping
|
||||
? {
|
||||
...existingMapping,
|
||||
inputVariableId,
|
||||
}
|
||||
: createCustomMetricMapping(inputVariableId, null)
|
||||
})
|
||||
}
|
||||
|
||||
export function createCustomMetric(): EvaluationMetric {
|
||||
return {
|
||||
id: createId('metric'),
|
||||
@ -247,7 +267,7 @@ export function createCustomMetric(): EvaluationMetric {
|
||||
workflowId: null,
|
||||
workflowAppId: null,
|
||||
workflowName: null,
|
||||
mappings: [createCustomMetricMapping()],
|
||||
mappings: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -362,7 +382,7 @@ export const isCustomMetricConfigured = (metric: EvaluationMetric) => {
|
||||
return false
|
||||
|
||||
return metric.customConfig.mappings.length > 0
|
||||
&& metric.customConfig.mappings.every(mapping => !!mapping.sourceFieldId && !!mapping.targetVariableId)
|
||||
&& metric.customConfig.mappings.every(mapping => !!mapping.inputVariableId && !!mapping.outputVariableId)
|
||||
}
|
||||
|
||||
export const isEvaluationRunnable = (state: EvaluationResourceState) => {
|
||||
|
||||
@ -15,12 +15,12 @@ import {
|
||||
createBuiltinMetric,
|
||||
createConditionGroup,
|
||||
createCustomMetric,
|
||||
createCustomMetricMapping,
|
||||
getAllowedOperators as getAllowedOperatorsFromUtils,
|
||||
getConditionValue,
|
||||
isCustomMetricConfigured as isCustomMetricConfiguredFromUtils,
|
||||
isEvaluationRunnable as isEvaluationRunnableFromUtils,
|
||||
requiresConditionValue as requiresConditionValueFromUtils,
|
||||
syncCustomMetricMappings as syncCustomMetricMappingsFromUtils,
|
||||
updateConditionGroup,
|
||||
updateMetric,
|
||||
updateResourceState,
|
||||
@ -41,15 +41,19 @@ type EvaluationStore = {
|
||||
metricId: string,
|
||||
workflow: { workflowId: string, workflowAppId: string, workflowName: string },
|
||||
) => void
|
||||
addCustomMetricMapping: (resourceType: EvaluationResourceType, resourceId: string, metricId: string) => void
|
||||
syncCustomMetricMappings: (
|
||||
resourceType: EvaluationResourceType,
|
||||
resourceId: string,
|
||||
metricId: string,
|
||||
inputVariableIds: string[],
|
||||
) => void
|
||||
updateCustomMetricMapping: (
|
||||
resourceType: EvaluationResourceType,
|
||||
resourceId: string,
|
||||
metricId: string,
|
||||
mappingId: string,
|
||||
patch: { sourceFieldId?: string | null, targetVariableId?: string | null },
|
||||
patch: { inputVariableId?: string | null, outputVariableId?: string | null },
|
||||
) => void
|
||||
removeCustomMetricMapping: (resourceType: EvaluationResourceType, resourceId: string, metricId: string, mappingId: string) => void
|
||||
addConditionGroup: (resourceType: EvaluationResourceType, resourceId: string) => void
|
||||
removeConditionGroup: (resourceType: EvaluationResourceType, resourceId: string, groupId: string) => void
|
||||
setConditionGroupOperator: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, logicalOperator: 'and' | 'or') => void
|
||||
@ -170,7 +174,7 @@ export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
|
||||
workflowName: workflow.workflowName,
|
||||
mappings: metric.customConfig.mappings.map(mapping => ({
|
||||
...mapping,
|
||||
targetVariableId: null,
|
||||
outputVariableId: null,
|
||||
})),
|
||||
}
|
||||
: metric.customConfig,
|
||||
@ -178,7 +182,7 @@ export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
|
||||
})),
|
||||
}))
|
||||
},
|
||||
addCustomMetricMapping: (resourceType, resourceId, metricId) => {
|
||||
syncCustomMetricMappings: (resourceType, resourceId, metricId, inputVariableIds) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
@ -187,7 +191,7 @@ export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
|
||||
customConfig: metric.customConfig
|
||||
? {
|
||||
...metric.customConfig,
|
||||
mappings: [...metric.customConfig.mappings, createCustomMetricMapping()],
|
||||
mappings: syncCustomMetricMappingsFromUtils(metric.customConfig.mappings, inputVariableIds),
|
||||
}
|
||||
: metric.customConfig,
|
||||
})),
|
||||
@ -210,22 +214,6 @@ export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
|
||||
})),
|
||||
}))
|
||||
},
|
||||
removeCustomMetricMapping: (resourceType, resourceId, metricId, mappingId) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
...resource,
|
||||
metrics: updateMetric(resource.metrics, metricId, metric => ({
|
||||
...metric,
|
||||
customConfig: metric.customConfig
|
||||
? {
|
||||
...metric.customConfig,
|
||||
mappings: metric.customConfig.mappings.filter(mapping => mapping.id !== mappingId),
|
||||
}
|
||||
: metric.customConfig,
|
||||
})),
|
||||
})),
|
||||
}))
|
||||
},
|
||||
addConditionGroup: (resourceType, resourceId) => {
|
||||
set(state => ({
|
||||
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
|
||||
|
||||
@ -62,8 +62,8 @@ export type EvaluationFieldOption = {
|
||||
|
||||
export type CustomMetricMapping = {
|
||||
id: string
|
||||
sourceFieldId: string | null
|
||||
targetVariableId: string | null
|
||||
inputVariableId: string | null
|
||||
outputVariableId: string | null
|
||||
}
|
||||
|
||||
export type CustomMetricConfig = {
|
||||
|
||||
@ -63,16 +63,14 @@
|
||||
"metrics.addNode": "Add Node",
|
||||
"metrics.added": "Added",
|
||||
"metrics.collapseNodes": "Collapse nodes",
|
||||
"metrics.custom.addMapping": "Add Mapping",
|
||||
"metrics.custom.description": "Select an evaluation workflow and map your variables before running tests.",
|
||||
"metrics.custom.footerDescription": "Connect your published evaluation workflows",
|
||||
"metrics.custom.footerTitle": "Custom metrics",
|
||||
"metrics.custom.limitDescription": "Only one custom metric can be added.",
|
||||
"metrics.custom.mappingTitle": "Variable Mapping",
|
||||
"metrics.custom.mappingWarning": "Complete the workflow selection and each variable mapping to enable batch tests.",
|
||||
"metrics.custom.outputPlaceholder": "Select an output variable",
|
||||
"metrics.custom.outputTitle": "Output",
|
||||
"metrics.custom.sourcePlaceholder": "Source variable",
|
||||
"metrics.custom.targetPlaceholder": "Target variable",
|
||||
"metrics.custom.title": "Custom Evaluator",
|
||||
"metrics.custom.warningBadge": "Needs setup",
|
||||
"metrics.custom.workflowLabel": "Evaluation Workflow",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user