feat(web): judgement condition

This commit is contained in:
JzoNg 2026-04-09 20:18:25 +08:00
parent 4d1499ef75
commit 5316372772
15 changed files with 693 additions and 443 deletions

View File

@ -1,6 +1,5 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import Evaluation from '..'
import { getEvaluationMockConfig } from '../mock'
import { useEvaluationStore } from '../store'
const mockUseAvailableEvaluationMetrics = vi.hoisted(() => vi.fn())
@ -121,22 +120,19 @@ describe('Evaluation', () => {
const resourceType = 'apps'
const resourceId = 'app-2'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
const stringField = config.fieldOptions.find(field => field.type === 'string')!
let groupId = ''
let itemId = ''
let conditionId = ''
act(() => {
store.ensureResource(resourceType, resourceId)
store.setJudgeModel(resourceType, resourceId, 'openai::gpt-4o-mini')
store.addBuiltinMetric(resourceType, resourceId, 'faithfulness', [
{ node_id: 'node-faithfulness', title: 'Retriever Node', type: 'retriever' },
])
store.addCondition(resourceType, resourceId)
const group = useEvaluationStore.getState().resources['apps:app-2'].conditions[0]
groupId = group.id
itemId = group.items[0].id
store.updateConditionField(resourceType, resourceId, groupId, itemId, stringField.id)
store.updateConditionOperator(resourceType, resourceId, groupId, itemId, 'contains')
const condition = useEvaluationStore.getState().resources['apps:app-2'].conditions.conditions[0]
conditionId = condition.id
store.updateConditionOperator(resourceType, resourceId, conditionId, '=')
})
let rerender: ReturnType<typeof render>['rerender']
@ -147,7 +143,7 @@ describe('Evaluation', () => {
expect(screen.getByPlaceholderText('evaluation.conditions.valuePlaceholder')).toBeInTheDocument()
act(() => {
store.updateConditionOperator(resourceType, resourceId, groupId, itemId, 'is_empty')
store.updateConditionOperator(resourceType, resourceId, conditionId, 'is null')
rerender(<Evaluation resourceType={resourceType} resourceId={resourceId} />)
})
@ -249,7 +245,7 @@ describe('Evaluation', () => {
metric: 'context-precision',
}],
customized_metrics: null,
judgement_conditions: null,
judgment_config: null,
},
})

View File

@ -31,6 +31,11 @@ describe('evaluation store', () => {
workflowName: config.workflowOptions[0].label,
})
store.syncCustomMetricMappings(resourceType, resourceId, initialMetric!.id, ['query'])
store.syncCustomMetricOutputs(resourceType, resourceId, initialMetric!.id, [{
id: 'score',
valueType: 'number',
}])
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',
@ -40,6 +45,7 @@ describe('evaluation store', () => {
expect(isCustomMetricConfigured(configuredMetric!)).toBe(true)
expect(configuredMetric!.customConfig!.workflowAppId).toBe('custom-workflow-app-id')
expect(configuredMetric!.customConfig!.workflowName).toBe(config.workflowOptions[0].label)
expect(configuredMetric!.customConfig!.outputs).toEqual([{ id: 'score', valueType: 'number' }])
})
it('should only add one custom metric', () => {
@ -77,7 +83,7 @@ describe('evaluation store', () => {
expect(useEvaluationStore.getState().resources['apps:app-2'].metrics.some(metric => metric.id === addedMetric!.id)).toBe(false)
})
it('should upsert builtin metric node selections', () => {
it('should upsert builtin metric node selections and prune stale conditions', () => {
const resourceType = 'apps'
const resourceId = 'app-4'
const store = useEvaluationStore.getState()
@ -88,63 +94,78 @@ describe('evaluation store', () => {
store.addBuiltinMetric(resourceType, resourceId, metricId, [
{ node_id: 'node-1', title: 'Answer Node', type: 'answer' },
])
store.addCondition(resourceType, resourceId)
store.addBuiltinMetric(resourceType, resourceId, metricId, [
{ node_id: 'node-2', title: 'Retriever Node', type: 'retriever' },
])
const metric = useEvaluationStore.getState().resources['apps:app-4'].metrics.find(item => item.optionId === metricId)
const state = useEvaluationStore.getState().resources['apps:app-4']
const metric = state.metrics.find(item => item.optionId === metricId)
expect(metric?.nodeInfoList).toEqual([
{ node_id: 'node-2', title: 'Retriever Node', type: 'retriever' },
])
expect(useEvaluationStore.getState().resources['apps:app-4'].metrics.filter(item => item.optionId === metricId)).toHaveLength(1)
expect(state.metrics.filter(item => item.optionId === metricId)).toHaveLength(1)
expect(state.conditions.conditions).toHaveLength(0)
})
it('should update condition groups and adapt operators to field types', () => {
const resourceType = 'datasets'
const resourceId = 'dataset-1'
it('should build numeric conditions from selected metrics', () => {
const resourceType = 'apps'
const resourceId = 'app-conditions'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
store.addBuiltinMetric(resourceType, resourceId, config.builtinMetrics[0].id, [
{ node_id: 'node-answer', title: 'Answer Node', type: 'llm' },
])
store.setConditionLogicalOperator(resourceType, resourceId, 'or')
store.addCondition(resourceType, resourceId)
const initialGroup = useEvaluationStore.getState().resources['datasets:dataset-1'].conditions[0]
store.setConditionGroupOperator(resourceType, resourceId, initialGroup.id, 'or')
store.addConditionGroup(resourceType, resourceId)
const state = useEvaluationStore.getState().resources['apps:app-conditions']
const condition = state.conditions.conditions[0]
const booleanField = config.fieldOptions.find(field => field.type === 'boolean')!
const currentItem = useEvaluationStore.getState().resources['datasets:dataset-1'].conditions[0].items[0]
store.updateConditionField(resourceType, resourceId, initialGroup.id, currentItem.id, booleanField.id)
const updatedGroup = useEvaluationStore.getState().resources['datasets:dataset-1'].conditions[0]
expect(updatedGroup.logicalOperator).toBe('or')
expect(updatedGroup.items[0].operator).toBe('is')
expect(getAllowedOperators(resourceType, booleanField.id)).toEqual(['is', 'is_not'])
expect(state.conditions.logicalOperator).toBe('or')
expect(condition.variableSelector).toEqual(['node-answer', 'answer-correctness'])
expect(condition.comparisonOperator).toBe('=')
expect(getAllowedOperators(state.metrics, condition.variableSelector)).toEqual(['=', '≠', '>', '<', '≥', '≤', 'is null', 'is not null'])
})
it('should clear values for empty operators', () => {
it('should clear values for operators without values', () => {
const resourceType = 'apps'
const resourceId = 'app-3'
const store = useEvaluationStore.getState()
const config = getEvaluationMockConfig(resourceType)
store.ensureResource(resourceType, resourceId)
store.addCustomMetric(resourceType, resourceId)
const stringField = config.fieldOptions.find(field => field.type === 'string')!
const item = useEvaluationStore.getState().resources['apps:app-3'].conditions[0].items[0]
const customMetric = useEvaluationStore.getState().resources['apps:app-3'].metrics.find(metric => metric.kind === 'custom-workflow')!
store.setCustomMetricWorkflow(resourceType, resourceId, customMetric.id, {
workflowId: config.workflowOptions[0].id,
workflowAppId: 'custom-workflow-app-id',
workflowName: config.workflowOptions[0].label,
})
store.syncCustomMetricOutputs(resourceType, resourceId, customMetric.id, [{
id: 'reason',
valueType: 'string',
}])
store.addCondition(resourceType, resourceId)
store.updateConditionField(resourceType, resourceId, useEvaluationStore.getState().resources['apps:app-3'].conditions[0].id, item.id, stringField.id)
store.updateConditionOperator(resourceType, resourceId, useEvaluationStore.getState().resources['apps:app-3'].conditions[0].id, item.id, 'is_empty')
const condition = useEvaluationStore.getState().resources['apps:app-3'].conditions.conditions[0]
const updatedItem = useEvaluationStore.getState().resources['apps:app-3'].conditions[0].items[0]
store.updateConditionMetric(resourceType, resourceId, condition.id, [config.workflowOptions[0].id, 'reason'])
store.updateConditionValue(resourceType, resourceId, condition.id, 'needs follow-up')
store.updateConditionOperator(resourceType, resourceId, condition.id, 'empty')
expect(getAllowedOperators(resourceType, stringField.id)).toEqual(['contains', 'not_contains', 'is', 'is_not', 'is_empty', 'is_not_empty'])
expect(requiresConditionValue('is_empty')).toBe(false)
expect(updatedItem.value).toBeNull()
const updatedCondition = useEvaluationStore.getState().resources['apps:app-3'].conditions.conditions[0]
expect(requiresConditionValue('empty')).toBe(false)
expect(updatedCondition.value).toBeNull()
})
it('should hydrate resource state from evaluation config', () => {
it('should hydrate resource state from judgment_config', () => {
const resourceType = 'apps'
const resourceId = 'app-5'
const store = useEvaluationStore.getState()
@ -162,15 +183,19 @@ describe('evaluation store', () => {
input_fields: {
query: 'answer',
},
},
judgement_conditions: [{
logical_operator: 'or',
items: [{
field_id: 'system.has_context',
operator: 'is',
value: true,
output_fields: [{
variable: 'reason',
value_type: 'string',
}],
}],
},
judgment_config: {
logical_operator: 'or',
conditions: [{
variable_selector: ['node-1', 'faithfulness'],
comparison_operator: '≥',
value: '0.9',
}],
},
}
store.ensureResource(resourceType, resourceId)
@ -206,11 +231,12 @@ describe('evaluation store', () => {
expect(hydratedState.metrics[1].customConfig?.workflowId).toBe('workflow-precision-review')
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',
operator: 'is',
value: true,
expect(hydratedState.metrics[1].customConfig?.outputs).toEqual([{ id: 'reason', valueType: 'string' }])
expect(hydratedState.conditions.logicalOperator).toBe('or')
expect(hydratedState.conditions.conditions[0]).toMatchObject({
variableSelector: ['node-1', 'faithfulness'],
comparisonOperator: '≥',
value: '0.9',
})
expect(hydratedState.activeBatchTab).toBe('history')
expect(hydratedState.uploadedFileName).toBe('batch.csv')

View File

@ -2,12 +2,12 @@
import type {
ComparisonOperator,
EvaluationFieldOption,
ConditionMetricOption,
EvaluationResourceProps,
JudgmentConditionGroup,
JudgmentConditionItem,
} from '../../types'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import {
@ -20,79 +20,103 @@ import {
SelectValue,
} from '@/app/components/base/ui/select'
import { cn } from '@/utils/classnames'
import { getEvaluationMockConfig } from '../../mock'
import { getAllowedOperators, requiresConditionValue, useEvaluationStore } from '../../store'
import { getFieldTypeIconClassName, getOperatorLabel, groupFieldOptions } from '../../utils'
import { getAllowedOperators, requiresConditionValue, useEvaluationResource, useEvaluationStore } from '../../store'
import {
buildConditionMetricOptions,
getComparisonOperatorLabel,
isSelectorEqual,
serializeVariableSelector,
} from '../../utils'
type ConditionFieldLabelProps = {
field?: EvaluationFieldOption
type ConditionMetricLabelProps = {
metric?: ConditionMetricOption
placeholder: string
}
type ConditionFieldSelectProps = {
field?: EvaluationFieldOption
fieldOptions: EvaluationFieldOption[]
type ConditionMetricSelectProps = {
metric?: ConditionMetricOption
metricOptions: ConditionMetricOption[]
placeholder: string
onChange: (fieldId: string) => void
onChange: (variableSelector: [string, string]) => void
}
type ConditionOperatorSelectProps = {
field?: EvaluationFieldOption
operator: ComparisonOperator
operators: ComparisonOperator[]
onChange: (operator: ComparisonOperator) => void
}
type FieldValueInputProps = {
field?: EvaluationFieldOption
operator: ComparisonOperator
value: string | number | boolean | null
onChange: (value: string | number | boolean | null) => void
type ConditionValueInputProps = {
metric?: ConditionMetricOption
condition: JudgmentConditionItem
onChange: (value: string | string[] | boolean | null) => void
}
type ConditionGroupProps = EvaluationResourceProps & {
group: JudgmentConditionGroup
index: number
type ConditionGroupProps = EvaluationResourceProps
const getMetricValueTypeIconClassName = (valueType: ConditionMetricOption['valueType']) => {
if (valueType === 'number')
return 'i-ri-hashtag'
if (valueType === 'boolean')
return 'i-ri-checkbox-circle-line'
return 'i-ri-bar-chart-box-line'
}
const ConditionFieldLabel = ({
field,
const ConditionMetricLabel = ({
metric,
placeholder,
}: ConditionFieldLabelProps) => {
if (!field)
}: ConditionMetricLabelProps) => {
if (!metric)
return <span className="px-1 system-sm-regular text-components-input-text-placeholder">{placeholder}</span>
return (
<div className="flex min-w-0 items-center gap-2 px-1">
<div className="inline-flex h-6 min-w-0 items-center gap-1 rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark pr-1.5 pl-[5px] shadow-xs">
<span className={cn(getFieldTypeIconClassName(field.type), 'h-3 w-3 shrink-0 text-text-secondary')} />
<span className="truncate system-xs-medium text-text-secondary">{field.label}</span>
<span className={cn(getMetricValueTypeIconClassName(metric.valueType), 'h-3 w-3 shrink-0 text-text-secondary')} />
<span className="truncate system-xs-medium text-text-secondary">{metric.label}</span>
</div>
<span className="shrink-0 system-xs-regular text-text-tertiary">{field.type}</span>
<span className="shrink-0 system-xs-regular text-text-tertiary">{metric.group}</span>
</div>
)
}
const ConditionFieldSelect = ({
field,
fieldOptions,
const ConditionMetricSelect = ({
metric,
metricOptions,
placeholder,
onChange,
}: ConditionFieldSelectProps) => {
}: ConditionMetricSelectProps) => {
const groupedMetricOptions = useMemo(() => {
return Object.entries(metricOptions.reduce<Record<string, ConditionMetricOption[]>>((acc, option) => {
acc[option.group] = [...(acc[option.group] ?? []), option]
return acc
}, {}))
}, [metricOptions])
return (
<Select value={field?.id ?? ''} onValueChange={value => value && onChange(value)}>
<Select
value={serializeVariableSelector(metric?.variableSelector)}
onValueChange={(value) => {
const nextMetric = metricOptions.find(option => serializeVariableSelector(option.variableSelector) === value)
if (nextMetric)
onChange(nextMetric.variableSelector)
}}
>
<SelectTrigger className="h-auto bg-transparent px-1 py-1 hover:bg-transparent focus-visible:bg-transparent">
<ConditionFieldLabel field={field} placeholder={placeholder} />
<ConditionMetricLabel metric={metric} placeholder={placeholder} />
</SelectTrigger>
<SelectContent popupClassName="w-[320px]">
{groupFieldOptions(fieldOptions).map(([groupName, fields]) => (
<SelectContent popupClassName="w-[360px]">
{groupedMetricOptions.map(([groupName, options]) => (
<SelectGroup key={groupName}>
<SelectGroupLabel className="px-3 pt-2 pb-1 system-xs-medium-uppercase text-text-tertiary">{groupName}</SelectGroupLabel>
{fields.map(option => (
<SelectItem key={option.id} value={option.id}>
{options.map(option => (
<SelectItem key={option.id} value={serializeVariableSelector(option.variableSelector)}>
<div className="flex min-w-0 items-center gap-2">
<span className={cn(getFieldTypeIconClassName(option.type), 'h-3.5 w-3.5 shrink-0 text-text-tertiary')} />
<span className={cn(getMetricValueTypeIconClassName(option.valueType), 'h-3.5 w-3.5 shrink-0 text-text-tertiary')} />
<span className="truncate">{option.label}</span>
<span className="shrink-0 text-text-quaternary">{option.description}</span>
</div>
</SelectItem>
))}
@ -104,22 +128,21 @@ const ConditionFieldSelect = ({
}
const ConditionOperatorSelect = ({
field,
operator,
operators,
onChange,
}: ConditionOperatorSelectProps) => {
const { t } = useTranslation('evaluation')
const { t } = useTranslation()
return (
<Select value={operator} onValueChange={value => value && onChange(value as ComparisonOperator)}>
<SelectTrigger className="h-8 w-auto min-w-[88px] gap-1 rounded-md bg-transparent px-1.5 py-0 hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt">
<span className="truncate system-xs-medium text-text-secondary">{getOperatorLabel(operator, field?.type, t)}</span>
<span className="truncate system-xs-medium text-text-secondary">{getComparisonOperatorLabel(operator, t)}</span>
</SelectTrigger>
<SelectContent className="z-[1002]" popupClassName="w-[240px] bg-components-panel-bg-blur backdrop-blur-[10px]">
{operators.map(nextOperator => (
<SelectItem key={nextOperator} value={nextOperator}>
{getOperatorLabel(nextOperator, field?.type, t)}
{getComparisonOperatorLabel(nextOperator, t)}
</SelectItem>
))}
</SelectContent>
@ -127,21 +150,20 @@ const ConditionOperatorSelect = ({
)
}
const FieldValueInput = ({
field,
operator,
value,
const ConditionValueInput = ({
metric,
condition,
onChange,
}: FieldValueInputProps) => {
}: ConditionValueInputProps) => {
const { t } = useTranslation('evaluation')
if (!field || !requiresConditionValue(operator))
if (!metric || !requiresConditionValue(condition.comparisonOperator))
return null
if (field.type === 'boolean') {
if (metric.valueType === 'boolean') {
return (
<div className="px-2 py-1.5">
<Select value={value === null ? '' : String(value)} onValueChange={nextValue => onChange(nextValue === 'true')}>
<Select value={condition.value === null ? '' : String(condition.value)} onValueChange={nextValue => onChange(nextValue === 'true')}>
<SelectTrigger className="bg-transparent hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt">
<SelectValue placeholder={t('conditions.selectValue')} />
</SelectTrigger>
@ -154,38 +176,27 @@ const FieldValueInput = ({
)
}
if (field.type === 'enum') {
return (
<div className="px-2 py-1.5">
<Select value={typeof value === 'string' ? value : ''} onValueChange={nextValue => onChange(nextValue)}>
<SelectTrigger className="bg-transparent hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt">
<SelectValue placeholder={t('conditions.selectValue')} />
</SelectTrigger>
<SelectContent>
{(field.options ?? []).map(option => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
const isMultiValue = condition.comparisonOperator === 'in' || condition.comparisonOperator === 'not in'
const inputValue = Array.isArray(condition.value)
? condition.value.join(', ')
: typeof condition.value === 'boolean'
? ''
: condition.value ?? ''
return (
<div className="px-2 py-1.5">
<Input
type={field.type === 'number' ? 'number' : 'text'}
value={value === null || typeof value === 'boolean' ? '' : value}
type={metric.valueType === 'number' && !isMultiValue ? 'number' : 'text'}
value={inputValue}
className="border-none bg-transparent shadow-none hover:border-none hover:bg-state-base-hover-alt focus:border-none focus:bg-state-base-hover-alt focus:shadow-none"
placeholder={t('conditions.valuePlaceholder')}
onChange={(e) => {
if (field.type === 'number') {
const nextValue = e.target.value
onChange(nextValue === '' ? null : Number(nextValue))
if (isMultiValue) {
onChange(e.target.value.split(',').map(item => item.trim()).filter(Boolean))
return
}
onChange(e.target.value)
onChange(e.target.value === '' ? null : e.target.value)
}}
/>
</div>
@ -195,20 +206,17 @@ const FieldValueInput = ({
const ConditionGroup = ({
resourceType,
resourceId,
group,
index,
}: ConditionGroupProps) => {
const { t } = useTranslation('evaluation')
const config = getEvaluationMockConfig(resourceType)
const resource = useEvaluationResource(resourceType, resourceId)
const metricOptions = useMemo(() => buildConditionMetricOptions(resource.metrics), [resource.metrics])
const logicalLabels = {
and: t('conditions.logical.and'),
or: t('conditions.logical.or'),
}
const removeConditionGroup = useEvaluationStore(state => state.removeConditionGroup)
const setConditionGroupOperator = useEvaluationStore(state => state.setConditionGroupOperator)
const addConditionItem = useEvaluationStore(state => state.addConditionItem)
const removeConditionItem = useEvaluationStore(state => state.removeConditionItem)
const updateConditionField = useEvaluationStore(state => state.updateConditionField)
const setConditionLogicalOperator = useEvaluationStore(state => state.setConditionLogicalOperator)
const removeCondition = useEvaluationStore(state => state.removeCondition)
const updateConditionMetric = useEvaluationStore(state => state.updateConditionMetric)
const updateConditionOperator = useEvaluationStore(state => state.updateConditionOperator)
const updateConditionValue = useEvaluationStore(state => state.updateConditionValue)
@ -216,7 +224,6 @@ const ConditionGroup = ({
<div className="rounded-2xl border border-divider-subtle bg-components-card-bg p-4">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Badge>{t('conditions.groupLabel', { index: index + 1 })}</Badge>
<div className="flex rounded-lg border border-divider-subtle bg-background-default-subtle p-1">
{(['and', 'or'] as const).map(operator => (
<button
@ -224,65 +231,50 @@ const ConditionGroup = ({
type="button"
className={cn(
'rounded-md px-3 py-1.5 system-xs-medium-uppercase',
group.logicalOperator === operator
resource.conditions.logicalOperator === operator
? 'bg-components-card-bg text-text-primary shadow-xs'
: 'text-text-tertiary',
)}
onClick={() => setConditionGroupOperator(resourceType, resourceId, group.id, operator)}
onClick={() => setConditionLogicalOperator(resourceType, resourceId, operator)}
>
{logicalLabels[operator]}
</button>
))}
</div>
</div>
<div className="flex items-center gap-2">
<Button size="small" variant="ghost" onClick={() => addConditionItem(resourceType, resourceId, group.id)}>
<span aria-hidden="true" className="mr-1 i-ri-add-line h-4 w-4" />
{t('conditions.addCondition')}
</Button>
<Button
size="small"
variant="ghost"
aria-label={t('conditions.removeGroup')}
onClick={() => removeConditionGroup(resourceType, resourceId, group.id)}
>
<span aria-hidden="true" className="i-ri-delete-bin-line h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-3">
{group.items.map((item) => {
const field = config.fieldOptions.find(option => option.id === item.fieldId)
const allowedOperators = getAllowedOperators(resourceType, item.fieldId)
const showValue = !!field && requiresConditionValue(item.operator)
{resource.conditions.conditions.map((condition) => {
const metric = metricOptions.find(option => isSelectorEqual(option.variableSelector, condition.variableSelector))
const allowedOperators = getAllowedOperators(resource.metrics, condition.variableSelector)
const showValue = !!metric && requiresConditionValue(condition.comparisonOperator)
return (
<div key={item.id} className="flex items-start overflow-hidden rounded-lg">
<div key={condition.id} className="flex items-start overflow-hidden rounded-lg">
<div className="min-w-0 flex-1 rounded-lg bg-components-input-bg-normal">
<div className="flex items-center gap-0 pr-1">
<div className="min-w-0 flex-1 py-1">
<ConditionFieldSelect
field={field}
fieldOptions={config.fieldOptions}
<ConditionMetricSelect
metric={metric}
metricOptions={metricOptions}
placeholder={t('conditions.fieldPlaceholder')}
onChange={value => updateConditionField(resourceType, resourceId, group.id, item.id, value)}
onChange={value => updateConditionMetric(resourceType, resourceId, condition.id, value)}
/>
</div>
<div className="h-3 w-px bg-divider-regular" />
<ConditionOperatorSelect
field={field}
operator={item.operator}
operator={condition.comparisonOperator}
operators={allowedOperators}
onChange={value => updateConditionOperator(resourceType, resourceId, group.id, item.id, value)}
onChange={value => updateConditionOperator(resourceType, resourceId, condition.id, value)}
/>
</div>
{showValue && (
<div className="border-t border-divider-subtle">
<FieldValueInput
field={field}
operator={item.operator}
value={item.value}
onChange={value => updateConditionValue(resourceType, resourceId, group.id, item.id, value)}
<ConditionValueInput
metric={metric}
condition={condition}
onChange={value => updateConditionValue(resourceType, resourceId, condition.id, value)}
/>
</div>
)}
@ -292,7 +284,7 @@ const ConditionGroup = ({
size="small"
variant="ghost"
aria-label={t('conditions.removeCondition')}
onClick={() => removeConditionItem(resourceType, resourceId, group.id, item.id)}
onClick={() => removeCondition(resourceType, resourceId, condition.id)}
>
<span aria-hidden="true" className="i-ri-close-line h-4 w-4" />
</Button>

View File

@ -1,9 +1,11 @@
'use client'
import type { EvaluationResourceProps } from '../../types'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
import { useEvaluationResource, useEvaluationStore } from '../../store'
import { buildConditionMetricOptions } from '../../utils'
import { InlineSectionHeader } from '../section-header'
import ConditionGroup from './condition-group'
@ -13,8 +15,9 @@ const ConditionsSection = ({
}: EvaluationResourceProps) => {
const { t } = useTranslation('evaluation')
const resource = useEvaluationResource(resourceType, resourceId)
const addConditionGroup = useEvaluationStore(state => state.addConditionGroup)
const canAddCondition = resource.metrics.length > 0
const addCondition = useEvaluationStore(state => state.addCondition)
const conditionMetricOptions = useMemo(() => buildConditionMetricOptions(resource.metrics), [resource.metrics])
const canAddCondition = conditionMetricOptions.length > 0
return (
<section className="max-w-[700px] py-4">
@ -23,20 +26,17 @@ const ConditionsSection = ({
tooltip={t('conditions.description')}
/>
<div className="mt-2 space-y-4">
{resource.conditions.length === 0 && (
{resource.conditions.conditions.length === 0 && (
<div className="rounded-xl bg-background-section px-3 py-3 system-xs-regular text-text-tertiary">
{t('conditions.emptyDescription')}
</div>
)}
{resource.conditions.map((group, index) => (
{resource.conditions.conditions.length > 0 && (
<ConditionGroup
key={group.id}
resourceType={resourceType}
resourceId={resourceId}
group={group}
index={index}
/>
))}
)}
<button
type="button"
className={cn(
@ -44,7 +44,7 @@ const ConditionsSection = ({
!canAddCondition && 'cursor-not-allowed text-components-button-secondary-accent-text-disabled',
)}
disabled={!canAddCondition}
onClick={() => addConditionGroup(resourceType, resourceId)}
onClick={() => addCondition(resourceType, resourceId)}
>
<span aria-hidden="true" className="mr-1 i-ri-add-line h-4 w-4" />
{t('conditions.addCondition')}

View File

@ -156,6 +156,7 @@ const createMetric = (): EvaluationMetric => ({
kind: 'custom-workflow',
label: 'Custom Evaluator',
description: 'Map workflow variables to your evaluation inputs.',
valueType: 'number',
customConfig: {
workflowId: 'workflow-1',
workflowAppId: 'workflow-app-1',
@ -169,6 +170,7 @@ const createMetric = (): EvaluationMetric => ({
inputVariableId: 'retrieved_context',
outputVariableId: 'current-node.score',
}],
outputs: [],
},
})

View File

@ -73,6 +73,7 @@ const CustomMetricEditorCard = ({
const { t } = useTranslation('evaluation')
const setCustomMetricWorkflow = useEvaluationStore(state => state.setCustomMetricWorkflow)
const syncCustomMetricMappings = useEvaluationStore(state => state.syncCustomMetricMappings)
const syncCustomMetricOutputs = useEvaluationStore(state => state.syncCustomMetricOutputs)
const updateCustomMetricMapping = useEvaluationStore(state => state.updateCustomMetricMapping)
const { data: selectedWorkflow } = useAppWorkflow(metric.customConfig?.workflowAppId ?? '')
const { data: currentAppWorkflow } = useAppWorkflow(resourceType === 'apps' ? resourceId : '')
@ -126,6 +127,23 @@ const CustomMetricEditorCard = ({
syncCustomMetricMappings(resourceType, resourceId, metric.id, inputVariableIds)
}, [inputVariableIds, metric.customConfig?.mappings, metric.customConfig?.workflowId, metric.id, resourceId, resourceType, syncCustomMetricMappings])
useEffect(() => {
if (!metric.customConfig?.workflowId)
return
const currentOutputs = metric.customConfig.outputs
if (
currentOutputs.length === workflowOutputs.length
&& currentOutputs.every((output, index) =>
output.id === workflowOutputs[index]?.id && output.valueType === workflowOutputs[index]?.valueType,
)
) {
return
}
syncCustomMetricOutputs(resourceType, resourceId, metric.id, workflowOutputs)
}, [metric.customConfig?.outputs, metric.customConfig?.workflowId, metric.id, resourceId, resourceType, syncCustomMetricOutputs, workflowOutputs])
if (!metric.customConfig)
return null

View File

@ -18,6 +18,7 @@ export const buildMetricOption = (metricId: string): MetricOption => ({
id: metricId,
label: humanizeMetricId(metricId),
description: '',
valueType: 'number',
})
export const getMetricVisual = (metricId: string): { icon: string, tone: MetricVisualTone } => {

View File

@ -1,5 +1,4 @@
import type {
ComparisonOperator,
EvaluationFieldOption,
EvaluationMockConfig,
EvaluationResourceType,
@ -29,31 +28,37 @@ const builtinMetrics: MetricOption[] = [
id: 'answer-correctness',
label: 'Answer Correctness',
description: 'Compares the response with the expected answer and scores factual alignment.',
valueType: 'number',
},
{
id: 'faithfulness',
label: 'Faithfulness',
description: 'Checks whether the answer stays grounded in the retrieved evidence.',
valueType: 'number',
},
{
id: 'relevance',
label: 'Relevance',
description: 'Evaluates how directly the answer addresses the original request.',
valueType: 'number',
},
{
id: 'latency',
label: 'Latency',
description: 'Captures runtime responsiveness for the full execution path.',
valueType: 'number',
},
{
id: 'token-usage',
label: 'Token Usage',
description: 'Tracks prompt and completion token consumption for the run.',
valueType: 'number',
},
{
id: 'tool-success-rate',
label: 'Tool Success Rate',
description: 'Measures whether each required tool invocation finishes without failure.',
valueType: 'number',
},
]
@ -62,16 +67,19 @@ const pipelineBuiltinMetrics: MetricOption[] = [
id: 'context-precision',
label: 'Context Precision',
description: 'Measures whether retrieved chunks stay tightly aligned to the request.',
valueType: 'number',
},
{
id: 'context-recall',
label: 'Context Recall',
description: 'Checks whether the retrieval result includes the evidence needed to answer.',
valueType: 'number',
},
{
id: 'context-relevance',
label: 'Context Relevance',
description: 'Scores how useful the retrieved context is for downstream generation.',
valueType: 'number',
},
]
@ -121,20 +129,6 @@ const snippetFields: EvaluationFieldOption[] = [
{ id: 'system.requires_review', label: 'Requires Review', group: 'System', type: 'boolean' },
]
export const getComparisonOperators = (fieldType: EvaluationFieldOption['type']): ComparisonOperator[] => {
if (fieldType === 'number')
return ['is', 'is_not', 'greater_than', 'less_than', 'greater_or_equal', 'less_or_equal', 'is_empty', 'is_not_empty']
if (fieldType === 'boolean' || fieldType === 'enum')
return ['is', 'is_not']
return ['contains', 'not_contains', 'is', 'is_not', 'is_empty', 'is_not_empty']
}
export const getDefaultOperator = (fieldType: EvaluationFieldOption['type']): ComparisonOperator => {
return getComparisonOperators(fieldType)[0]
}
export const getEvaluationMockConfig = (resourceType: EvaluationResourceType): EvaluationMockConfig => {
if (resourceType === 'datasets') {
return {

View File

@ -2,24 +2,30 @@ import type {
BatchTestRecord,
ComparisonOperator,
CustomMetricMapping,
EvaluationFieldOption,
EvaluationMetric,
EvaluationResourceState,
EvaluationResourceType,
JudgmentConditionGroup,
JudgmentConditionItem,
JudgmentConfig,
MetricOption,
} from './types'
import type {
EvaluationConditionValue,
EvaluationConfig,
EvaluationCustomizedMetric,
EvaluationDefaultMetric,
EvaluationJudgementConditionGroup,
EvaluationJudgementConditionItem,
EvaluationJudgmentCondition,
EvaluationJudgmentConditionValue,
EvaluationJudgmentConfig,
NodeInfo,
} from '@/types/evaluation'
import { getComparisonOperators, getDefaultOperator, getEvaluationMockConfig } from './mock'
import { encodeModelSelection } from './utils'
import { getEvaluationMockConfig } from './mock'
import {
buildConditionMetricOptions,
encodeModelSelection,
getComparisonOperators,
getDefaultComparisonOperator,
requiresComparisonValue,
} from './utils'
type EvaluationStoreResources = Record<string, EvaluationResourceState>
@ -41,6 +47,7 @@ const resolveMetricOption = (resourceType: EvaluationResourceType, metricId: str
id: metricId,
label: humanizeMetricId(metricId),
description: '',
valueType: 'number',
}
}
@ -91,14 +98,32 @@ const normalizeCustomMetricMappings = (
if (!value)
return []
const mappings = Object.entries(value)
return Object.entries(value)
.filter((entry): entry is [string, string] => {
const [, outputVariableId] = entry
return typeof outputVariableId === 'string' && !!outputVariableId
})
.map(([inputVariableId, outputVariableId]) => createCustomMetricMapping(inputVariableId, outputVariableId))
}
return mappings
const normalizeCustomMetricOutputs = (
value: EvaluationCustomizedMetric['output_fields'],
) => {
if (!value)
return []
return value
.map((output) => {
const id = typeof output.variable === 'string' ? output.variable : ''
if (!id)
return null
return {
id,
valueType: typeof output.value_type === 'string' ? output.value_type : null,
}
})
.filter((output): output is { id: string, valueType: string | null } => !!output)
}
const normalizeCustomMetric = (
@ -120,92 +145,137 @@ const normalizeCustomMetric = (
...customMetric.customConfig,
workflowId,
mappings: normalizeCustomMetricMappings(value.input_fields),
outputs: normalizeCustomMetricOutputs(value.output_fields),
}
: customMetric.customConfig,
}]
}
const normalizeVariableSelector = (value: string[] | undefined): [string, string] | null => {
if (!Array.isArray(value) || value.length < 2)
return null
const [scope, metricName] = value
return typeof scope === 'string' && !!scope && typeof metricName === 'string' && !!metricName
? [scope, metricName]
: null
}
const getNormalizedConditionValue = (
operator: ComparisonOperator,
previousValue: EvaluationJudgmentConditionValue | string | number | boolean | null | undefined,
) => {
if (!requiresComparisonValue(operator))
return null
if (Array.isArray(previousValue))
return previousValue.filter((item): item is string => typeof item === 'string' && !!item)
if (typeof previousValue === 'boolean')
return previousValue
if (typeof previousValue === 'number')
return String(previousValue)
return typeof previousValue === 'string' ? previousValue : null
}
const getRawJudgmentConfig = (config: EvaluationConfig): EvaluationJudgmentConfig | null | undefined => {
if (config.judgment_config)
return config.judgment_config
if (
config.judgement_conditions
&& !Array.isArray(config.judgement_conditions)
&& 'conditions' in config.judgement_conditions
) {
return config.judgement_conditions as EvaluationJudgmentConfig
}
return null
}
const normalizeConditionItem = (
resourceType: EvaluationResourceType,
value: EvaluationJudgementConditionItem,
): JudgmentConditionGroup['items'][number] => {
const fieldId = typeof value.fieldId === 'string'
? value.fieldId
: typeof value.field_id === 'string'
? value.field_id
: null
const operatorValue = typeof value.operator === 'string' ? value.operator : null
const field = getEvaluationMockConfig(resourceType).fieldOptions.find(option => option.id === fieldId)
const allowedOperators = field ? getComparisonOperators(field.type) : ['contains']
const operator = operatorValue && allowedOperators.includes(operatorValue as ComparisonOperator)
? operatorValue as ComparisonOperator
: field
? getDefaultOperator(field.type)
: 'contains'
const rawValue: EvaluationConditionValue = value.value ?? null
value: EvaluationJudgmentCondition,
metrics: EvaluationMetric[],
): JudgmentConditionItem | null => {
const variableSelector = normalizeVariableSelector(value.variable_selector)
if (!variableSelector)
return null
const metricOption = buildConditionMetricOptions(metrics).find(option =>
option.variableSelector[0] === variableSelector[0] && option.variableSelector[1] === variableSelector[1],
)
if (!metricOption)
return null
const allowedOperators = getComparisonOperators(metricOption.valueType)
const rawOperator = typeof value.comparison_operator === 'string' ? value.comparison_operator : ''
const comparisonOperator = allowedOperators.includes(rawOperator as ComparisonOperator)
? rawOperator as ComparisonOperator
: getDefaultComparisonOperator(metricOption.valueType)
return {
id: typeof value.id === 'string' ? value.id : createId('condition'),
fieldId,
operator,
value: getConditionValue(field, operator, rawValue),
id: createId('condition'),
variableSelector,
comparisonOperator,
value: getConditionValue(metricOption.valueType, comparisonOperator, value.value),
}
}
const normalizeConditionGroups = (
resourceType: EvaluationResourceType,
value: EvaluationConfig['judgement_conditions'],
): JudgmentConditionGroup[] => {
const groupsValue: EvaluationJudgementConditionGroup[] = Array.isArray(value)
? value
: Array.isArray(value?.groups)
? value.groups
: []
const createEmptyJudgmentConfig = (): JudgmentConfig => {
return {
logicalOperator: 'and',
conditions: [],
}
}
const groups = groupsValue
.map((group) => {
const itemsValue = Array.isArray(group.items) ? group.items : []
const items = itemsValue
.map(item => normalizeConditionItem(resourceType, item))
const normalizeJudgmentConfig = (
config: EvaluationConfig,
metrics: EvaluationMetric[],
): JudgmentConfig => {
const rawJudgmentConfig = getRawJudgmentConfig(config)
if (items.length === 0)
return null
if (!rawJudgmentConfig)
return createEmptyJudgmentConfig()
return {
id: typeof group.id === 'string' ? group.id : createId('group'),
logicalOperator: group.logicalOperator === 'or' || group.logical_operator === 'or' ? 'or' : 'and',
items,
} satisfies JudgmentConditionGroup
})
.filter((group): group is JudgmentConditionGroup => !!group)
const conditions = (rawJudgmentConfig.conditions ?? [])
.map(condition => normalizeConditionItem(condition, metrics))
.filter((condition): condition is JudgmentConditionItem => !!condition)
return groups.length > 0 ? groups : [createConditionGroup(resourceType)]
return {
logicalOperator: rawJudgmentConfig.logical_operator === 'or' ? 'or' : 'and',
conditions,
}
}
export const buildResourceKey = (resourceType: EvaluationResourceType, resourceId: string) => `${resourceType}:${resourceId}`
const conditionOperatorsWithoutValue: ComparisonOperator[] = ['is_empty', 'is_not_empty']
export const requiresConditionValue = (operator: ComparisonOperator) => !conditionOperatorsWithoutValue.includes(operator)
export const requiresConditionValue = (operator: ComparisonOperator) => {
return requiresComparisonValue(operator)
}
export function getConditionValue(
field: EvaluationFieldOption | undefined,
valueType: EvaluationMetric['valueType'] | undefined,
operator: ComparisonOperator,
previousValue: string | number | boolean | null = null,
previousValue?: EvaluationJudgmentConditionValue | string | number | boolean | null,
) {
if (!field || !requiresConditionValue(operator))
if (!valueType || !requiresConditionValue(operator))
return null
if (field.type === 'boolean')
if (valueType === 'boolean')
return typeof previousValue === 'boolean' ? previousValue : null
if (field.type === 'enum')
return typeof previousValue === 'string' ? previousValue : null
if (operator === 'in' || operator === 'not in') {
if (Array.isArray(previousValue))
return previousValue.filter((item): item is string => typeof item === 'string' && !!item)
if (field.type === 'number')
return typeof previousValue === 'number' ? previousValue : null
return typeof previousValue === 'string' && previousValue
? previousValue.split(',').map(item => item.trim()).filter(Boolean)
: []
}
return typeof previousValue === 'string' ? previousValue : null
return getNormalizedConditionValue(operator, previousValue)
}
export function createBuiltinMetric(
@ -219,6 +289,7 @@ export function createBuiltinMetric(
kind: 'builtin',
label: metric.label,
description: metric.description,
valueType: metric.valueType,
threshold,
nodeInfoList,
}
@ -263,40 +334,66 @@ export function createCustomMetric(): EvaluationMetric {
kind: 'custom-workflow',
label: 'Custom Evaluator',
description: 'Map workflow variables to your evaluation inputs.',
valueType: 'number',
customConfig: {
workflowId: null,
workflowAppId: null,
workflowName: null,
mappings: [],
outputs: [],
},
}
}
export const buildConditionItem = (resourceType: EvaluationResourceType) => {
const field = getEvaluationMockConfig(resourceType).fieldOptions[0]
const operator = field ? getDefaultOperator(field.type) : 'contains'
export const buildConditionItem = (metrics: EvaluationMetric[]): JudgmentConditionItem => {
const metricOption = buildConditionMetricOptions(metrics)[0]
const comparisonOperator = metricOption ? getDefaultComparisonOperator(metricOption.valueType) : 'is'
return {
id: createId('condition'),
fieldId: field?.id ?? null,
operator,
value: getConditionValue(field, operator),
variableSelector: metricOption?.variableSelector ?? null,
comparisonOperator,
value: getConditionValue(metricOption?.valueType, comparisonOperator),
}
}
export function createConditionGroup(resourceType: EvaluationResourceType): JudgmentConditionGroup {
export const syncJudgmentConfigWithMetrics = (
judgmentConfig: JudgmentConfig,
metrics: EvaluationMetric[],
): JudgmentConfig => {
const metricOptions = buildConditionMetricOptions(metrics)
return {
id: createId('group'),
logicalOperator: 'and',
items: [buildConditionItem(resourceType)],
logicalOperator: judgmentConfig.logicalOperator,
conditions: judgmentConfig.conditions
.map((condition) => {
const metricOption = metricOptions.find(option =>
option.variableSelector[0] === condition.variableSelector?.[0]
&& option.variableSelector[1] === condition.variableSelector?.[1],
)
if (!metricOption)
return null
const allowedOperators = getComparisonOperators(metricOption.valueType)
const comparisonOperator = allowedOperators.includes(condition.comparisonOperator)
? condition.comparisonOperator
: getDefaultComparisonOperator(metricOption.valueType)
return {
...condition,
comparisonOperator,
value: getConditionValue(metricOption.valueType, comparisonOperator, condition.value),
}
})
.filter((condition): condition is JudgmentConditionItem => !!condition),
}
}
export const buildInitialState = (resourceType: EvaluationResourceType): EvaluationResourceState => {
export const buildInitialState = (_resourceType: EvaluationResourceType): EvaluationResourceState => {
return {
judgeModelId: null,
metrics: [],
conditions: [createConditionGroup(resourceType)],
conditions: createEmptyJudgmentConfig(),
activeBatchTab: 'input-fields',
uploadedFileName: null,
batchRecords: [],
@ -309,14 +406,15 @@ export const buildStateFromEvaluationConfig = (
): EvaluationResourceState => {
const defaultMetrics = normalizeDefaultMetrics(resourceType, config.default_metrics)
const customMetrics = normalizeCustomMetric(config.customized_metrics)
const metrics = [...defaultMetrics, ...customMetrics]
return {
...buildInitialState(resourceType),
judgeModelId: config.evaluation_model && config.evaluation_model_provider
? encodeModelSelection(config.evaluation_model_provider, config.evaluation_model)
: null,
metrics: [...defaultMetrics, ...customMetrics],
conditions: normalizeConditionGroups(resourceType, config.judgement_conditions),
metrics,
conditions: normalizeJudgmentConfig(config, metrics),
}
}
@ -353,12 +451,6 @@ export const updateMetric = (
updater: (metric: EvaluationMetric) => EvaluationMetric,
) => metrics.map(metric => metric.id === metricId ? updater(metric) : metric)
export const updateConditionGroup = (
groups: JudgmentConditionGroup[],
groupId: string,
updater: (group: JudgmentConditionGroup) => JudgmentConditionGroup,
) => groups.map(group => group.id === groupId ? updater(group) : group)
export const createBatchTestRecord = (
resourceType: EvaluationResourceType,
uploadedFileName: string | null | undefined,
@ -389,14 +481,19 @@ export const isEvaluationRunnable = (state: EvaluationResourceState) => {
return !!state.judgeModelId
&& state.metrics.length > 0
&& state.metrics.every(isCustomMetricConfigured)
&& state.conditions.some(group => group.items.length > 0)
}
export const getAllowedOperators = (resourceType: EvaluationResourceType, fieldId: string | null) => {
const field = getEvaluationMockConfig(resourceType).fieldOptions.find(option => option.id === fieldId)
export const getAllowedOperators = (
metrics: EvaluationMetric[],
variableSelector: [string, string] | null,
) => {
const metricOption = buildConditionMetricOptions(metrics).find(option =>
option.variableSelector[0] === variableSelector?.[0]
&& option.variableSelector[1] === variableSelector?.[1],
)
if (!field)
return ['contains'] as ComparisonOperator[]
if (!metricOption)
return ['is'] as ComparisonOperator[]
return getComparisonOperators(field.type)
return getComparisonOperators(metricOption.valueType)
}

View File

@ -5,7 +5,7 @@ import type {
} from './types'
import type { EvaluationConfig, NodeInfo } from '@/types/evaluation'
import { create } from 'zustand'
import { getDefaultOperator, getEvaluationMockConfig } from './mock'
import { getEvaluationMockConfig } from './mock'
import {
buildConditionItem,
buildInitialState,
@ -13,7 +13,6 @@ import {
buildStateFromEvaluationConfig,
createBatchTestRecord,
createBuiltinMetric,
createConditionGroup,
createCustomMetric,
getAllowedOperators as getAllowedOperatorsFromUtils,
getConditionValue,
@ -21,10 +20,11 @@ import {
isEvaluationRunnable as isEvaluationRunnableFromUtils,
requiresConditionValue as requiresConditionValueFromUtils,
syncCustomMetricMappings as syncCustomMetricMappingsFromUtils,
updateConditionGroup,
syncJudgmentConfigWithMetrics,
updateMetric,
updateResourceState,
} from './store-utils'
import { buildConditionMetricOptions } from './utils'
type EvaluationStore = {
resources: Record<string, EvaluationResourceState>
@ -47,6 +47,12 @@ type EvaluationStore = {
metricId: string,
inputVariableIds: string[],
) => void
syncCustomMetricOutputs: (
resourceType: EvaluationResourceType,
resourceId: string,
metricId: string,
outputs: Array<{ id: string, valueType: string | null }>,
) => void
updateCustomMetricMapping: (
resourceType: EvaluationResourceType,
resourceId: string,
@ -54,19 +60,16 @@ type EvaluationStore = {
mappingId: string,
patch: { inputVariableId?: string | null, outputVariableId?: string | null },
) => 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
addConditionItem: (resourceType: EvaluationResourceType, resourceId: string, groupId: string) => void
removeConditionItem: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, itemId: string) => void
updateConditionField: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, itemId: string, fieldId: string) => void
updateConditionOperator: (resourceType: EvaluationResourceType, resourceId: string, groupId: string, itemId: string, operator: ComparisonOperator) => void
setConditionLogicalOperator: (resourceType: EvaluationResourceType, resourceId: string, logicalOperator: 'and' | 'or') => void
addCondition: (resourceType: EvaluationResourceType, resourceId: string) => void
removeCondition: (resourceType: EvaluationResourceType, resourceId: string, conditionId: string) => void
updateConditionMetric: (resourceType: EvaluationResourceType, resourceId: string, conditionId: string, variableSelector: [string, string]) => void
updateConditionOperator: (resourceType: EvaluationResourceType, resourceId: string, conditionId: string, operator: ComparisonOperator) => void
updateConditionValue: (
resourceType: EvaluationResourceType,
resourceId: string,
groupId: string,
itemId: string,
value: string | number | boolean | null,
conditionId: string,
value: string | string[] | boolean | null,
) => void
setBatchTab: (resourceType: EvaluationResourceType, resourceId: string, tab: EvaluationResourceState['activeBatchTab']) => void
setUploadedFileName: (resourceType: EvaluationResourceType, resourceId: string, uploadedFileName: string | null) => void
@ -117,17 +120,22 @@ export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
set((state) => {
return {
resources: updateResourceState(state.resources, resourceType, resourceId, currentResource => ({
...currentResource,
metrics: currentResource.metrics.some(metric => metric.optionId === optionId && metric.kind === 'builtin')
resources: updateResourceState(state.resources, resourceType, resourceId, (currentResource) => {
const metrics = currentResource.metrics.some(metric => metric.optionId === optionId && metric.kind === 'builtin')
? currentResource.metrics.map(metric => metric.optionId === optionId && metric.kind === 'builtin'
? {
...metric,
nodeInfoList,
}
: metric)
: [...currentResource.metrics, createBuiltinMetric(option, nodeInfoList)],
})),
: [...currentResource.metrics, createBuiltinMetric(option, nodeInfoList)]
return {
...currentResource,
metrics,
conditions: syncJudgmentConfigWithMetrics(currentResource.conditions, metrics),
}
}),
}
})
},
@ -144,27 +152,36 @@ export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
},
addCustomMetric: (resourceType, resourceId) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
metrics: resource.metrics.some(metric => metric.kind === 'custom-workflow')
resources: updateResourceState(state.resources, resourceType, resourceId, (resource) => {
const metrics = resource.metrics.some(metric => metric.kind === 'custom-workflow')
? resource.metrics
: [...resource.metrics, createCustomMetric()],
})),
: [...resource.metrics, createCustomMetric()]
return {
...resource,
metrics,
conditions: syncJudgmentConfigWithMetrics(resource.conditions, metrics),
}
}),
}))
},
removeMetric: (resourceType, resourceId, metricId) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
metrics: resource.metrics.filter(metric => metric.id !== metricId),
})),
resources: updateResourceState(state.resources, resourceType, resourceId, (resource) => {
const metrics = resource.metrics.filter(metric => metric.id !== metricId)
return {
...resource,
metrics,
conditions: syncJudgmentConfigWithMetrics(resource.conditions, metrics),
}
}),
}))
},
setCustomMetricWorkflow: (resourceType, resourceId, metricId, workflow) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
metrics: updateMetric(resource.metrics, metricId, metric => ({
resources: updateResourceState(state.resources, resourceType, resourceId, (resource) => {
const metrics = updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
@ -176,10 +193,17 @@ export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
...mapping,
outputVariableId: null,
})),
outputs: [],
}
: metric.customConfig,
})),
})),
}))
return {
...resource,
metrics,
conditions: syncJudgmentConfigWithMetrics(resource.conditions, metrics),
}
}),
}))
},
syncCustomMetricMappings: (resourceType, resourceId, metricId, inputVariableIds) => {
@ -198,6 +222,27 @@ export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
})),
}))
},
syncCustomMetricOutputs: (resourceType, resourceId, metricId, outputs) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, (resource) => {
const metrics = updateMetric(resource.metrics, metricId, metric => ({
...metric,
customConfig: metric.customConfig
? {
...metric.customConfig,
outputs,
}
: metric.customConfig,
}))
return {
...resource,
metrics,
conditions: syncJudgmentConfigWithMetrics(resource.conditions, metrics),
}
}),
}))
},
updateCustomMetricMapping: (resourceType, resourceId, metricId, mappingId, patch) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
@ -214,114 +259,101 @@ export const useEvaluationStore = create<EvaluationStore>((set, get) => ({
})),
}))
},
addConditionGroup: (resourceType, resourceId) => {
setConditionLogicalOperator: (resourceType, resourceId, logicalOperator) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
conditions: [...resource.conditions, createConditionGroup(resourceType)],
})),
}))
},
removeConditionGroup: (resourceType, resourceId, groupId) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
conditions: resource.conditions.filter(group => group.id !== groupId),
})),
}))
},
setConditionGroupOperator: (resourceType, resourceId, groupId, logicalOperator) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
conditions: {
...resource.conditions,
logicalOperator,
})),
},
})),
}))
},
addConditionItem: (resourceType, resourceId, groupId) => {
addCondition: (resourceType, resourceId) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: [...group.items, buildConditionItem(resourceType)],
})),
conditions: {
...resource.conditions,
conditions: [...resource.conditions.conditions, buildConditionItem(resource.metrics)],
},
})),
}))
},
removeConditionItem: (resourceType, resourceId, groupId, itemId) => {
removeCondition: (resourceType, resourceId, conditionId) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.filter(item => item.id !== itemId),
})),
conditions: {
...resource.conditions,
conditions: resource.conditions.conditions.filter(condition => condition.id !== conditionId),
},
})),
}))
},
updateConditionField: (resourceType, resourceId, groupId, itemId, fieldId) => {
const field = getEvaluationMockConfig(resourceType).fieldOptions.find(option => option.id === fieldId)
updateConditionMetric: (resourceType, resourceId, conditionId, variableSelector) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.map((item) => {
if (item.id !== itemId)
return item
resources: updateResourceState(state.resources, resourceType, resourceId, (resource) => {
const allowedOperators = getAllowedOperatorsFromUtils(resource.metrics, variableSelector)
const comparisonOperator = allowedOperators[0]
const metricOption = buildConditionMetricOptions(resource.metrics).find(option =>
option.variableSelector[0] === variableSelector[0] && option.variableSelector[1] === variableSelector[1],
)
const nextOperator = field ? getDefaultOperator(field.type) : item.operator
return {
...item,
fieldId,
operator: nextOperator,
value: getConditionValue(field, nextOperator),
}
}),
})),
})),
return {
...resource,
conditions: {
...resource.conditions,
conditions: resource.conditions.conditions.map(condition => condition.id === conditionId
? {
...condition,
variableSelector,
comparisonOperator,
value: getConditionValue(metricOption?.valueType, comparisonOperator),
}
: condition),
},
}
}),
}))
},
updateConditionOperator: (resourceType, resourceId, groupId, itemId, operator) => {
updateConditionOperator: (resourceType, resourceId, conditionId, operator) => {
set((state) => {
const fieldOptions = getEvaluationMockConfig(resourceType).fieldOptions
return {
resources: updateResourceState(state.resources, resourceType, resourceId, currentResource => ({
...currentResource,
conditions: updateConditionGroup(currentResource.conditions, groupId, group => ({
...group,
items: group.items.map((item) => {
if (item.id !== itemId)
return item
conditions: {
...currentResource.conditions,
conditions: currentResource.conditions.conditions.map((condition) => {
if (condition.id !== conditionId)
return condition
const field = fieldOptions.find(option => option.id === item.fieldId)
const metricOption = buildConditionMetricOptions(currentResource.metrics)
.find(option =>
option.variableSelector[0] === condition.variableSelector?.[0]
&& option.variableSelector[1] === condition.variableSelector?.[1],
)
return {
...item,
operator,
value: getConditionValue(field, operator, item.value),
...condition,
comparisonOperator: operator,
value: getConditionValue(metricOption?.valueType, operator, condition.value),
}
}),
})),
},
})),
}
})
},
updateConditionValue: (resourceType, resourceId, groupId, itemId, value) => {
updateConditionValue: (resourceType, resourceId, conditionId, value) => {
set(state => ({
resources: updateResourceState(state.resources, resourceType, resourceId, resource => ({
...resource,
conditions: updateConditionGroup(resource.conditions, groupId, group => ({
...group,
items: group.items.map(item => item.id === itemId ? { ...item, value } : item),
})),
conditions: {
...resource.conditions,
conditions: resource.conditions.conditions.map(condition => condition.id === conditionId ? { ...condition, value } : condition),
},
})),
}))
},
@ -374,8 +406,11 @@ export const useEvaluationResource = (resourceType: EvaluationResourceType, reso
return useEvaluationStore(state => state.resources[resourceKey] ?? (initialResourceCache[resourceKey] ??= buildInitialState(resourceType)))
}
export const getAllowedOperators = (resourceType: EvaluationResourceType, fieldId: string | null) => {
return getAllowedOperatorsFromUtils(resourceType, fieldId)
export const getAllowedOperators = (
metrics: EvaluationResourceState['metrics'],
variableSelector: [string, string] | null,
) => {
return getAllowedOperatorsFromUtils(metrics, variableSelector)
}
export const isCustomMetricConfigured = (metric: EvaluationResourceState['metrics'][number]) => {

View File

@ -13,17 +13,27 @@ export type BatchTestTab = 'input-fields' | 'history'
export type FieldType = 'string' | 'number' | 'boolean' | 'enum'
export type ConditionMetricValueType = 'string' | 'number' | 'boolean'
export type ComparisonOperator
= | 'contains'
| 'not_contains'
| 'not contains'
| 'start with'
| 'end with'
| 'is'
| 'is_not'
| 'is_empty'
| 'is_not_empty'
| 'greater_than'
| 'less_than'
| 'greater_or_equal'
| 'less_or_equal'
| 'is not'
| 'empty'
| 'not empty'
| 'in'
| 'not in'
| '='
| '≠'
| '>'
| '<'
| '≥'
| '≤'
| 'is null'
| 'is not null'
export type JudgeModelOption = {
id: string
@ -35,6 +45,7 @@ export type MetricOption = {
id: string
label: string
description: string
valueType: ConditionMetricValueType
}
export type EvaluationWorkflowOption = {
@ -69,6 +80,10 @@ export type CustomMetricConfig = {
workflowAppId: string | null
workflowName: string | null
mappings: CustomMetricMapping[]
outputs: Array<{
id: string
valueType: string | null
}>
}
export type EvaluationMetric = {
@ -77,6 +92,7 @@ export type EvaluationMetric = {
kind: MetricKind
label: string
description: string
valueType: ConditionMetricValueType
threshold?: number
nodeInfoList?: NodeInfo[]
customConfig?: CustomMetricConfig
@ -84,15 +100,23 @@ export type EvaluationMetric = {
export type JudgmentConditionItem = {
id: string
fieldId: string | null
operator: ComparisonOperator
value: string | number | boolean | null
variableSelector: [string, string] | null
comparisonOperator: ComparisonOperator
value: string | string[] | boolean | null
}
export type JudgmentConditionGroup = {
id: string
export type JudgmentConfig = {
logicalOperator: 'and' | 'or'
items: JudgmentConditionItem[]
conditions: JudgmentConditionItem[]
}
export type ConditionMetricOption = {
id: string
group: string
label: string
description: string
valueType: ConditionMetricValueType
variableSelector: [string, string]
}
export type BatchTestRecord = {
@ -106,7 +130,7 @@ export type BatchTestRecord = {
export type EvaluationResourceState = {
judgeModelId: string | null
metrics: EvaluationMetric[]
conditions: JudgmentConditionGroup[]
conditions: JudgmentConfig
activeBatchTab: BatchTestTab
uploadedFileName: string | null
batchRecords: BatchTestRecord[]

View File

@ -1,16 +1,16 @@
import type { TFunction } from 'i18next'
import type { ComparisonOperator, EvaluationFieldOption } from './types'
import type {
ComparisonOperator,
ConditionMetricOption,
ConditionMetricValueType,
EvaluationMetric,
} from './types'
export const TAB_CLASS_NAME = 'flex-1 rounded-lg px-3 py-2 text-left system-sm-medium'
const compactOperatorLabels: Partial<Record<ComparisonOperator, string>> = {
is: '=',
is_not: '!=',
greater_than: '>',
less_than: '<',
greater_or_equal: '>=',
less_or_equal: '<=',
}
const rawOperatorLabels = new Set<ComparisonOperator>(['=', '≠', '>', '<', '≥', '≤'])
const noValueOperators = new Set<ComparisonOperator>(['empty', 'not empty', 'is null', 'is not null'])
export const encodeModelSelection = (provider: string, model: string) => `${provider}::${model}`
@ -25,33 +25,84 @@ export const decodeModelSelection = (judgeModelId: string | null) => {
return { provider, model }
}
export const groupFieldOptions = (fieldOptions: EvaluationFieldOption[]) => {
return Object.entries(fieldOptions.reduce<Record<string, EvaluationFieldOption[]>>((acc, field) => {
acc[field.group] = [...(acc[field.group] ?? []), field]
return acc
}, {}))
}
export const getOperatorLabel = (
export const getComparisonOperatorLabel = (
operator: ComparisonOperator,
fieldType: EvaluationFieldOption['type'] | undefined,
t: TFunction<'evaluation'>,
t: TFunction,
) => {
if (fieldType === 'number' && compactOperatorLabels[operator])
return compactOperatorLabels[operator] as string
if (rawOperatorLabels.has(operator))
return operator
return t(`conditions.operators.${operator}` as const)
return t(`nodes.ifElse.comparisonOperator.${operator}` as never, { ns: 'workflow' } as never) as unknown as string
}
export const getFieldTypeIconClassName = (fieldType: EvaluationFieldOption['type']) => {
if (fieldType === 'number')
return 'i-ri-hashtag'
if (fieldType === 'boolean')
return 'i-ri-checkbox-circle-line'
if (fieldType === 'enum')
return 'i-ri-list-check-2'
return 'i-ri-text'
export const requiresComparisonValue = (operator: ComparisonOperator) => {
return !noValueOperators.has(operator)
}
const getMetricValueType = (valueType: string | null | undefined): ConditionMetricValueType => {
if (valueType === 'number' || valueType === 'integer')
return 'number'
if (valueType === 'boolean')
return 'boolean'
return 'string'
}
export const getComparisonOperators = (valueType: ConditionMetricValueType): ComparisonOperator[] => {
if (valueType === 'number')
return ['=', '≠', '>', '<', '≥', '≤', 'is null', 'is not null']
if (valueType === 'boolean')
return ['is', 'is not', 'is null', 'is not null']
return ['contains', 'not contains', 'start with', 'end with', 'is', 'is not', 'empty', 'not empty', 'in', 'not in', 'is null', 'is not null']
}
export const getDefaultComparisonOperator = (valueType: ConditionMetricValueType): ComparisonOperator => {
return getComparisonOperators(valueType)[0]
}
export const buildConditionMetricOptions = (metrics: EvaluationMetric[]): ConditionMetricOption[] => {
return metrics.flatMap((metric) => {
if (metric.kind === 'builtin') {
return (metric.nodeInfoList ?? []).map((nodeInfo) => {
return {
id: `${nodeInfo.node_id}:${metric.optionId}`,
group: nodeInfo.title,
label: metric.label,
description: nodeInfo.type,
valueType: metric.valueType,
variableSelector: [nodeInfo.node_id, metric.optionId] as [string, string],
}
})
}
const customConfig = metric.customConfig
if (!customConfig?.workflowId)
return []
return customConfig.outputs.map((output) => {
return {
id: `${customConfig.workflowId}:${output.id}`,
group: customConfig.workflowName ?? metric.label,
label: output.id,
description: customConfig.workflowName ?? metric.label,
valueType: getMetricValueType(output.valueType),
variableSelector: [customConfig.workflowId, output.id] as [string, string],
}
})
})
}
export const serializeVariableSelector = (value: [string, string] | null | undefined) => {
return value ? JSON.stringify(value) : ''
}
export const isSelectorEqual = (
left: [string, string] | null | undefined,
right: [string, string] | null | undefined,
) => {
return left?.[0] === right?.[0] && left?.[1] === right?.[1]
}

View File

@ -23,7 +23,7 @@
"conditions.description": "Define additional rules for when results should pass or fail.",
"conditions.emptyDescription": "Add metrics above to configure pass/fail thresholds.",
"conditions.emptyTitle": "No conditions yet",
"conditions.fieldPlaceholder": "Select field",
"conditions.fieldPlaceholder": "Select metric",
"conditions.groupLabel": "Group {{index}}",
"conditions.logical.and": "AND",
"conditions.logical.or": "OR",
@ -39,7 +39,7 @@
"conditions.operators.not_contains": "Does not contain",
"conditions.removeCondition": "Remove condition",
"conditions.removeGroup": "Remove condition group",
"conditions.selectFieldFirst": "Select a field first",
"conditions.selectFieldFirst": "Select a metric first",
"conditions.selectValue": "Choose a value",
"conditions.title": "Judgment Conditions",
"conditions.valuePlaceholder": "Enter a value",

View File

@ -23,7 +23,7 @@
"conditions.description": "定义额外规则,决定结果何时通过或失败。",
"conditions.emptyDescription": "请先添加上方指标,再配置通过 / 失败阈值。",
"conditions.emptyTitle": "还没有条件",
"conditions.fieldPlaceholder": "选择字段",
"conditions.fieldPlaceholder": "选择指标",
"conditions.groupLabel": "条件组 {{index}}",
"conditions.logical.and": "且",
"conditions.logical.or": "或",
@ -39,7 +39,7 @@
"conditions.operators.not_contains": "不包含",
"conditions.removeCondition": "删除条件",
"conditions.removeGroup": "删除条件组",
"conditions.selectFieldFirst": "请先选择字段",
"conditions.selectFieldFirst": "请先选择指标",
"conditions.selectValue": "选择值",
"conditions.title": "判定条件",
"conditions.valuePlaceholder": "输入值",

View File

@ -1,5 +1,18 @@
export type EvaluationTargetType = 'apps' | 'snippets' | 'datasets'
export type EvaluationJudgmentConditionValue = string | string[] | boolean
export type EvaluationJudgmentCondition = {
variable_selector?: string[]
comparison_operator?: string
value?: EvaluationJudgmentConditionValue
}
export type EvaluationJudgmentConfig = {
logical_operator?: 'and' | 'or'
conditions?: EvaluationJudgmentCondition[]
}
export type EvaluationConditionValue = string | number | boolean | null
export type EvaluationJudgementConditionItem = {
@ -28,7 +41,8 @@ export type EvaluationConfig = {
evaluation_model_provider: string | null
default_metrics?: EvaluationDefaultMetric[] | null
customized_metrics?: EvaluationCustomizedMetric | null
judgement_conditions: EvaluationJudgementConditions | null
judgment_config?: EvaluationJudgmentConfig | null
judgement_conditions?: EvaluationJudgementConditions | null
}
export type NodeInfo = {
@ -57,7 +71,7 @@ export type EvaluationConfigData = {
evaluation_model_provider?: string
default_metrics?: EvaluationDefaultMetric[] | null
customized_metrics?: EvaluationCustomizedMetric | null
judgment_config?: EvaluationJudgementConditions | null
judgment_config?: EvaluationJudgmentConfig | null
}
export type EvaluationRunRequest = EvaluationConfigData & {