diff --git a/web/app/components/evaluation/__tests__/index.spec.tsx b/web/app/components/evaluation/__tests__/index.spec.tsx index 1568b60be7..350543121f 100644 --- a/web/app/components/evaluation/__tests__/index.spec.tsx +++ b/web/app/components/evaluation/__tests__/index.spec.tsx @@ -11,6 +11,7 @@ const mockUseDefaultEvaluationMetrics = vi.hoisted(() => vi.fn()) const mockUseEvaluationConfig = vi.hoisted(() => vi.fn()) const mockUseSaveEvaluationConfigMutation = vi.hoisted(() => vi.fn()) const mockUseStartEvaluationRunMutation = vi.hoisted(() => vi.fn()) +const mockUseEvaluationTemplateColumnsMutation = vi.hoisted(() => vi.fn()) const mockUsePublishedPipelineInfo = vi.hoisted(() => vi.fn()) const mockUseSnippetPublishedWorkflow = vi.hoisted(() => vi.fn()) @@ -55,6 +56,7 @@ vi.mock('@/service/use-evaluation', () => ({ useDefaultEvaluationMetrics: (...args: unknown[]) => mockUseDefaultEvaluationMetrics(...args), useSaveEvaluationConfigMutation: (...args: unknown[]) => mockUseSaveEvaluationConfigMutation(...args), useStartEvaluationRunMutation: (...args: unknown[]) => mockUseStartEvaluationRunMutation(...args), + useEvaluationTemplateColumnsMutation: (...args: unknown[]) => mockUseEvaluationTemplateColumnsMutation(...args), })) vi.mock('@/service/use-pipeline', () => ({ @@ -170,6 +172,10 @@ describe('Evaluation', () => { isPending: false, mutate: vi.fn(), }) + mockUseEvaluationTemplateColumnsMutation.mockReturnValue({ + isPending: false, + mutate: vi.fn(), + }) mockUsePublishedPipelineInfo.mockReturnValue({ data: { graph: { @@ -624,6 +630,15 @@ describe('Evaluation', () => { it('should download the fixed pipeline template columns', () => { const createElement = document.createElement.bind(document) + const getTemplateColumns = vi.fn((_input: unknown, options?: { onSuccess?: (value: { columns: string[] }) => void }) => { + options?.onSuccess?.({ + columns: ['index', 'query', 'expected_output'], + }) + }) + mockUseEvaluationTemplateColumnsMutation.mockReturnValue({ + isPending: false, + mutate: getTemplateColumns, + }) let downloadLink: HTMLAnchorElement | undefined const createElementSpy = vi.spyOn(document, 'createElement').mockImplementation((tagName, options) => { const element = createElement(tagName, options) @@ -645,6 +660,16 @@ describe('Evaluation', () => { const templateContent = decodeURIComponent(downloadLink?.href ?? '').replace('data:text/csv;charset=utf-8,', '') expect(downloadLink?.download).toBe('pipeline-evaluation-template.csv') expect(templateContent.trim().split(',')).toEqual(['index', 'query', 'expected_output']) + expect(getTemplateColumns).toHaveBeenCalledWith({ + params: { + targetType: 'datasets', + targetId: 'dataset-template', + }, + body: expect.objectContaining({ + evaluation_model: 'gpt-4o-mini', + evaluation_model_provider: 'openai', + }), + }, expect.any(Object)) createElementSpy.mockRestore() }) diff --git a/web/app/components/evaluation/components/batch-test-panel/input-fields-tab.tsx b/web/app/components/evaluation/components/batch-test-panel/input-fields-tab.tsx index a305be4561..19e23719ba 100644 --- a/web/app/components/evaluation/components/batch-test-panel/input-fields-tab.tsx +++ b/web/app/components/evaluation/components/batch-test-panel/input-fields-tab.tsx @@ -23,7 +23,6 @@ const InputFieldsTab = ({ const actions = useInputFieldsActions({ resourceType, resourceId, - inputFields, isInputFieldsLoading, isPanelReady, isRunnable, diff --git a/web/app/components/evaluation/components/batch-test-panel/input-fields/__tests__/input-fields-utils.spec.ts b/web/app/components/evaluation/components/batch-test-panel/input-fields/__tests__/input-fields-utils.spec.ts index 94e8c5f091..ef5ef12e08 100644 --- a/web/app/components/evaluation/components/batch-test-panel/input-fields/__tests__/input-fields-utils.spec.ts +++ b/web/app/components/evaluation/components/batch-test-panel/input-fields/__tests__/input-fields-utils.spec.ts @@ -2,32 +2,19 @@ import { buildTemplateCsvContent, getExampleValue } from '../input-fields-utils' describe('input fields utils', () => { describe('buildTemplateCsvContent', () => { - it('should use index as the first CSV column and append expected_output as the last CSV column', () => { + it('should build CSV content from API columns without injecting columns', () => { expect(buildTemplateCsvContent([ - { name: 'query', type: 'string' }, - { name: 'context', type: 'string' }, - ])).toBe('index,query,context,expected_output\n') - }) - - it('should not duplicate expected_output when it already exists', () => { - expect(buildTemplateCsvContent([ - { name: 'query', type: 'string' }, - { name: 'expected_output', type: 'string' }, + 'index', + 'query', + 'expected_output', ])).toBe('index,query,expected_output\n') }) - it('should not duplicate index when it already exists', () => { + it('should escape CSV column names', () => { expect(buildTemplateCsvContent([ - { name: 'query', type: 'string' }, - { name: 'index', type: 'number' }, - ])).toBe('index,query,expected_output\n') - }) - - it('should escape CSV column names before appending expected_output', () => { - expect(buildTemplateCsvContent([ - { name: 'query,text', type: 'string' }, - { name: 'answer "draft"', type: 'string' }, - ])).toBe('index,"query,text","answer ""draft""",expected_output\n') + 'query,text', + 'answer "draft"', + ])).toBe('"query,text","answer ""draft"""\n') }) }) diff --git a/web/app/components/evaluation/components/batch-test-panel/input-fields/input-fields-utils.ts b/web/app/components/evaluation/components/batch-test-panel/input-fields/input-fields-utils.ts index 0666dfb4b6..75247918f6 100644 --- a/web/app/components/evaluation/components/batch-test-panel/input-fields/input-fields-utils.ts +++ b/web/app/components/evaluation/components/batch-test-panel/input-fields/input-fields-utils.ts @@ -11,7 +11,6 @@ export type InputField = { } export const INDEX_FIELD_NAME = 'index' -export const EXPECTED_OUTPUT_FIELD_NAME = 'expected_output' export const getGraphNodes = (graph?: Record) => { return Array.isArray(graph?.nodes) ? graph.nodes as Node[] : [] @@ -65,15 +64,8 @@ const escapeCsvCell = (value: string) => { return `"${value.replace(/"/g, '""')}"` } -export const buildTemplateCsvContent = (inputFields: InputField[]) => { - const fieldNames = inputFields - .map(field => field.name) - .filter(name => name !== INDEX_FIELD_NAME) - const templateFieldNames = fieldNames.includes(EXPECTED_OUTPUT_FIELD_NAME) - ? [INDEX_FIELD_NAME, ...fieldNames] - : [INDEX_FIELD_NAME, ...fieldNames, EXPECTED_OUTPUT_FIELD_NAME] - - return `${templateFieldNames.map(escapeCsvCell).join(',')}\n` +export const buildTemplateCsvContent = (columns: string[]) => { + return `${columns.map(escapeCsvCell).join(',')}\n` } export const getFileExtension = (fileName: string) => { diff --git a/web/app/components/evaluation/components/batch-test-panel/input-fields/use-input-fields-actions.ts b/web/app/components/evaluation/components/batch-test-panel/input-fields/use-input-fields-actions.ts index c4b4b0182b..f17ea597ce 100644 --- a/web/app/components/evaluation/components/batch-test-panel/input-fields/use-input-fields-actions.ts +++ b/web/app/components/evaluation/components/batch-test-panel/input-fields/use-input-fields-actions.ts @@ -1,14 +1,13 @@ import type { EvaluationResourceProps } from '../../../types' -import type { InputField } from './input-fields-utils' import { toast } from '@langgenius/dify-ui/toast' import { useMutation } from '@tanstack/react-query' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { upload } from '@/service/base' -import { useStartEvaluationRunMutation } from '@/service/use-evaluation' +import { useEvaluationTemplateColumnsMutation, useStartEvaluationRunMutation } from '@/service/use-evaluation' import { formatFileSize } from '@/utils/format' import { useEvaluationResource, useEvaluationStore } from '../../../store' -import { buildEvaluationRunRequest } from '../../../store-utils' +import { buildEvaluationConfigPayload, buildEvaluationRunRequest } from '../../../store-utils' import { buildTemplateCsvContent, getFileExtension } from './input-fields-utils' type UploadedFileMeta = { @@ -17,22 +16,18 @@ type UploadedFileMeta = { } type UseInputFieldsActionsParams = EvaluationResourceProps & { - inputFields: InputField[] isInputFieldsLoading: boolean isPanelReady: boolean isRunnable: boolean - templateContent?: string templateFileName: string } export const useInputFieldsActions = ({ resourceType, resourceId, - inputFields, isInputFieldsLoading, isPanelReady, isRunnable, - templateContent, templateFileName, }: UseInputFieldsActionsParams) => { const { t } = useTranslation('evaluation') @@ -42,6 +37,7 @@ export const useInputFieldsActions = ({ const setUploadedFile = useEvaluationStore(state => state.setUploadedFile) const setUploadedFileName = useEvaluationStore(state => state.setUploadedFileName) const startRunMutation = useStartEvaluationRunMutation() + const templateColumnsMutation = useEvaluationTemplateColumnsMutation() const [isUploadPopoverOpen, setIsUploadPopoverOpen] = useState(false) const [uploadedFileMeta, setUploadedFileMeta] = useState(null) const uploadMutation = useMutation({ @@ -71,21 +67,41 @@ export const useInputFieldsActions = ({ const isRunning = startRunMutation.isPending const uploadedFileId = resource.uploadedFileId const currentFileName = uploadedFileMeta?.name ?? resource.uploadedFileName - const canDownloadTemplate = isPanelReady && !isInputFieldsLoading && inputFields.length > 0 + const canDownloadTemplate = isPanelReady && !templateColumnsMutation.isPending const isRunDisabled = !isRunnable || !uploadedFileId || isFileUploading || isRunning const uploadButtonDisabled = !isPanelReady || isInputFieldsLoading || isRunning const handleDownloadTemplate = () => { - if (!inputFields.length) { - toast.warning(t('batch.noInputFields')) + const body = buildEvaluationConfigPayload(resource, resourceType) + + if (!body) { + toast.warning(t('batch.validation')) return } - const content = templateContent ?? buildTemplateCsvContent(inputFields) - const link = document.createElement('a') - link.href = `data:text/csv;charset=utf-8,${encodeURIComponent(content)}` - link.download = templateFileName - link.click() + templateColumnsMutation.mutate({ + params: { + targetType: resourceType, + targetId: resourceId, + }, + body, + }, { + onSuccess: ({ columns }) => { + if (!columns.length) { + toast.warning(t('batch.noTemplateColumns')) + return + } + + const content = buildTemplateCsvContent(columns) + const link = document.createElement('a') + link.href = `data:text/csv;charset=utf-8,${encodeURIComponent(content)}` + link.download = templateFileName + link.click() + }, + onError: () => { + toast.error(t('batch.templateColumnsError')) + }, + }) } const handleRun = () => { diff --git a/web/app/components/evaluation/components/pipeline/pipeline-batch-actions.tsx b/web/app/components/evaluation/components/pipeline/pipeline-batch-actions.tsx index f7ac97069f..fc5c341929 100644 --- a/web/app/components/evaluation/components/pipeline/pipeline-batch-actions.tsx +++ b/web/app/components/evaluation/components/pipeline/pipeline-batch-actions.tsx @@ -14,7 +14,6 @@ const PIPELINE_INPUT_FIELDS: InputField[] = [ { name: 'query', type: 'string' }, { name: 'expected_output', type: 'string' }, ] -const PIPELINE_TEMPLATE_CONTENT = 'index,query,expected_output\n' const PipelineBatchActions = ({ resourceType, @@ -27,11 +26,9 @@ const PipelineBatchActions = ({ const actions = useInputFieldsActions({ resourceType, resourceId, - inputFields: PIPELINE_INPUT_FIELDS, isInputFieldsLoading: false, isPanelReady: isConfigReady, isRunnable, - templateContent: PIPELINE_TEMPLATE_CONTENT, templateFileName: EVALUATION_TEMPLATE_FILE_NAMES[resourceType], }) diff --git a/web/contract/console/evaluation.ts b/web/contract/console/evaluation.ts index ac961732f8..1ac8d51ef7 100644 --- a/web/contract/console/evaluation.ts +++ b/web/contract/console/evaluation.ts @@ -14,6 +14,7 @@ import type { EvaluationRunDetailResponse, EvaluationRunRequest, EvaluationTargetType, + EvaluationTemplateColumnsResponse, EvaluationVersionDetailResponse, EvaluationWorkflowAssociatedTargetsResponse, } from '@/types/evaluation' @@ -154,6 +155,20 @@ export const evaluationTemplateDownloadContract = base }>()) .output(type()) +export const evaluationTemplateColumnsContract = base + .route({ + path: '/{targetType}/{targetId}/evaluation/template-columns', + method: 'POST', + }) + .input(type<{ + params: { + targetType: EvaluationTargetType + targetId: string + } + body: EvaluationConfigData + }>()) + .output(type()) + export const evaluationConfigContract = base .route({ path: '/{targetType}/{targetId}/evaluation', diff --git a/web/contract/router.ts b/web/contract/router.ts index 555d49e233..8812ad5b1c 100644 --- a/web/contract/router.ts +++ b/web/contract/router.ts @@ -20,6 +20,7 @@ import { evaluationMetricsContract, evaluationNodeInfoContract, evaluationRunDetailContract, + evaluationTemplateColumnsContract, evaluationTemplateDownloadContract, evaluationVersionDetailContract, evaluationWorkflowAssociatedTargetsContract, @@ -140,6 +141,7 @@ export const consoleRouterContract = { }, evaluation: { templateDownload: evaluationTemplateDownloadContract, + templateColumns: evaluationTemplateColumnsContract, config: evaluationConfigContract, saveConfig: saveEvaluationConfigContract, logs: evaluationLogsContract, diff --git a/web/i18n/en-US/evaluation.json b/web/i18n/en-US/evaluation.json index ac463df967..adc74a2252 100644 --- a/web/i18n/en-US/evaluation.json +++ b/web/i18n/en-US/evaluation.json @@ -7,6 +7,7 @@ "batch.loadingInputFields": "Loading input fields...", "batch.noInputFields": "No published start node input fields found.", "batch.noSnippetInputFields": "No published snippet input fields found.", + "batch.noTemplateColumns": "No template columns found.", "batch.noticeDescription": "Configuration incomplete. Select the Judge Model and Metrics on the left to generate your batch test template.", "batch.noticeTitle": "Quick start", "batch.removeUploadedFile": "Remove uploaded file", @@ -20,6 +21,7 @@ "batch.status.success": "Success", "batch.tabs.history": "Test History", "batch.tabs.input-fields": "Input Fields", + "batch.templateColumnsError": "Failed to generate the CSV template.", "batch.title": "Batch Test", "batch.uploadAndRun": "Upload & Run Test", "batch.uploadDropzoneEmphasis": "filled", diff --git a/web/i18n/zh-Hans/evaluation.json b/web/i18n/zh-Hans/evaluation.json index aacec3f271..d23277d0e7 100644 --- a/web/i18n/zh-Hans/evaluation.json +++ b/web/i18n/zh-Hans/evaluation.json @@ -7,6 +7,7 @@ "batch.loadingInputFields": "正在加载输入字段...", "batch.noInputFields": "未找到已发布 Start 节点的输入字段。", "batch.noSnippetInputFields": "未找到已发布的片段输入字段。", + "batch.noTemplateColumns": "未找到模板列。", "batch.noticeDescription": "配置尚未完成。请先在左侧选择判定模型和指标,以生成批量测试模板。", "batch.noticeTitle": "快速开始", "batch.removeUploadedFile": "移除已上传文件", @@ -20,6 +21,7 @@ "batch.status.success": "成功", "batch.tabs.history": "测试历史", "batch.tabs.input-fields": "输入字段", + "batch.templateColumnsError": "生成 CSV 模板失败。", "batch.title": "批量测试", "batch.uploadAndRun": "上传并运行测试", "batch.uploadDropzoneEmphasis": "已填写的", diff --git a/web/service/use-evaluation.ts b/web/service/use-evaluation.ts index 84750417c3..fb171fe1d8 100644 --- a/web/service/use-evaluation.ts +++ b/web/service/use-evaluation.ts @@ -131,6 +131,10 @@ export const useStartEvaluationRunMutation = () => { })) } +export const useEvaluationTemplateColumnsMutation = () => { + return useMutation(consoleQuery.evaluation.templateColumns.mutationOptions()) +} + export const useAvailableEvaluationWorkflows = ( params: AvailableEvaluationWorkflowsParams = {}, options?: { enabled?: boolean }, diff --git a/web/types/evaluation.ts b/web/types/evaluation.ts index a02df57ac8..aa8f615a15 100644 --- a/web/types/evaluation.ts +++ b/web/types/evaluation.ts @@ -59,6 +59,10 @@ export type EvaluationRunRequest = EvaluationConfigData & { file_id: string } +export type EvaluationTemplateColumnsResponse = { + columns: string[] +} + export type EvaluationRunStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' export type EvaluationJudgmentMetricsSummary = {