From 1ee8a5161e5c5530724ff272793be67b6e12e4bc Mon Sep 17 00:00:00 2001 From: lyx Date: Mon, 15 Sep 2025 20:30:34 +0800 Subject: [PATCH 01/84] feat(evaluation): Optimize the interface and interaction details of the evaluation dataset - Added the 'evaluation' namespace to i18n constants - Fixed the logic for the disabled prompt in DatasetSelectModal, which now depends on dataset item status - Adjusted the column width layout of the error modal to optimize the display space for error messages - Added the missing i18n namespace for the evaluation dataset detail page - Optimized the code format for status update on the file import page - Updated the AI icon in the evaluation dataset list to a smaller version - Added the missing 'evaluation' and 'dataset' i18n namespaces to evaluation-related pages --- packages/web/i18n/constants.ts | 3 ++- .../app/src/components/core/app/DatasetSelectModal.tsx | 8 +++++--- .../dashboard/evaluation/dataset/errorModal.tsx | 6 +++--- .../pages/dashboard/evaluation/dataset/detail/index.tsx | 8 +++++++- .../src/pages/dashboard/evaluation/dataset/fileImport.tsx | 2 +- .../app/src/pages/dashboard/evaluation/dataset/index.tsx | 2 +- projects/app/src/pages/dashboard/evaluation/index.tsx | 2 +- 7 files changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/web/i18n/constants.ts b/packages/web/i18n/constants.ts index d568e47831ee..59e7f5351330 100644 --- a/packages/web/i18n/constants.ts +++ b/packages/web/i18n/constants.ts @@ -20,7 +20,8 @@ export const I18N_NAMESPACES = [ 'account_team', 'account_model', 'dashboard_mcp', - 'dashboard_evaluation' + 'dashboard_evaluation', + 'evaluation' ]; export const I18N_NAMESPACES_MAP = I18N_NAMESPACES.reduce( diff --git a/projects/app/src/components/core/app/DatasetSelectModal.tsx b/projects/app/src/components/core/app/DatasetSelectModal.tsx index 85349956abe0..6a041d7f1a49 100644 --- a/projects/app/src/components/core/app/DatasetSelectModal.tsx +++ b/projects/app/src/components/core/app/DatasetSelectModal.tsx @@ -72,8 +72,10 @@ export const DatasetSelectModal = ({ ); }; - const getDisableTip = () => { - return scene === 'smartGenerate' ? t('app:no_data_for_smart_generate') : ''; + const getDisableTip = (item: DatasetListItemType) => { + return scene === 'smartGenerate' && isDatasetDisabled(item) + ? t('app:no_data_for_smart_generate') + : ''; }; // Cache compatible datasets by vector model to avoid repeated filtering @@ -255,7 +257,7 @@ export const DatasetSelectModal = ({ onClick={(e) => e.stopPropagation()} // Prevent parent click when clicking checkbox > {item.type !== DatasetTypeEnum.folder && ( - + { - + - + @@ -149,7 +149,7 @@ const ErrorModal = ({ isOpen, onClose, collectionId }: ErrorModalProps) => { {t(error.dataId)} - - {dimensions.map((dimension) => ( + {filteredDimensions.map((dimension) => ( {
{t('dashboard_evaluation:source_knowledge_base')}{t('dashboard_evaluation:source_knowledge_base')} {t('dashboard_evaluation:source_chunk')}{t('dashboard_evaluation:error_message')}{t('dashboard_evaluation:error_message')} {t('dashboard_evaluation:operations')}
+ { { label: ( - + {t('dashboard_evaluation:smart_generation')} ), diff --git a/projects/app/src/pages/dashboard/evaluation/index.tsx b/projects/app/src/pages/dashboard/evaluation/index.tsx index 1961d8544ea7..a55f32df7a7b 100644 --- a/projects/app/src/pages/dashboard/evaluation/index.tsx +++ b/projects/app/src/pages/dashboard/evaluation/index.tsx @@ -59,7 +59,7 @@ export default Evaluation; export async function getServerSideProps(content: any) { return { props: { - ...(await serviceSideProps(content, ['dashboard_evaluation'])) + ...(await serviceSideProps(content, ['dashboard_evaluation', 'evaluation', 'dataset'])) } }; } From 51a2edb305dfb63de9e6ab221bfc1649a0c5b32d Mon Sep 17 00:00:00 2001 From: SuXiangcheng Date: Tue, 16 Sep 2025 10:56:39 +0800 Subject: [PATCH 02/84] [update] load builtin evaluation metrics from config file --- .../global/core/evaluation/metric/type.d.ts | 16 +- .../core/evaluation/metric/provider.ts | 45 ++ .../service/core/evaluation/metric/schema.ts | 23 +- .../support/permission/evaluation/auth.ts | 9 +- projects/app/data/metric.json | 41 +- projects/app/src/instrumentation.ts | 7 - .../evaluation/task/ManageDimension.tsx | 4 +- .../pages/api/core/evaluation/metric/list.ts | 84 ++-- .../app/src/service/common/system/index.ts | 41 -- .../api/core/evaluation/metric/list.test.ts | 422 +++++++++--------- 10 files changed, 325 insertions(+), 367 deletions(-) create mode 100644 packages/service/core/evaluation/metric/provider.ts diff --git a/packages/global/core/evaluation/metric/type.d.ts b/packages/global/core/evaluation/metric/type.d.ts index 3720ce1a2aed..cf0dc611574c 100644 --- a/packages/global/core/evaluation/metric/type.d.ts +++ b/packages/global/core/evaluation/metric/type.d.ts @@ -36,14 +36,14 @@ export type EvalMetricSchemaType = { type: EvalMetricTypeEnum; prompt?: string; - userInputRequired: boolean; - actualOutputRequired: boolean; - expectedOutputRequired: boolean; - contextRequired: boolean; - retrievalContextRequired: boolean; - - embeddingRequired: boolean; - llmRequired: boolean; + userInputRequired?: boolean; + actualOutputRequired?: boolean; + expectedOutputRequired?: boolean; + contextRequired?: boolean; + retrievalContextRequired?: boolean; + + embeddingRequired?: boolean; + llmRequired?: boolean; createTime: Date; updateTime: Date; diff --git a/packages/service/core/evaluation/metric/provider.ts b/packages/service/core/evaluation/metric/provider.ts new file mode 100644 index 000000000000..9cdb23e8bfcb --- /dev/null +++ b/packages/service/core/evaluation/metric/provider.ts @@ -0,0 +1,45 @@ +import { readConfigData } from '../../../../../projects/app/src/service/common/system'; +import { EvalMetricTypeEnum } from '@fastgpt/global/core/evaluation/metric/constants'; +import type { EvalMetricSchemaType } from '@fastgpt/global/core/evaluation/metric/type'; + +let cachedBuiltinMetrics: EvalMetricSchemaType[] | null = null; +let loadingPromise: Promise | null = null; + +export async function getBuiltinMetrics(): Promise { + if (cachedBuiltinMetrics) { + return cachedBuiltinMetrics; + } + + if (loadingPromise) { + return loadingPromise; + } + + loadingPromise = loadBuiltinMetrics(); + + try { + const result = await loadingPromise; + cachedBuiltinMetrics = result; + return result; + } finally { + loadingPromise = null; + } +} + +async function loadBuiltinMetrics(): Promise { + const metricContent = await readConfigData('metric.json'); + const { builtinMetrics } = JSON.parse(metricContent); + + return (builtinMetrics || []).map((metric: any) => ({ + _id: `builtin_${metric.name}`, + teamId: '', + tmbId: '', + name: metric.name, + description: metric.description || '', + type: EvalMetricTypeEnum.Builtin, + createTime: new Date(), + updateTime: new Date(), + ...Object.fromEntries( + Object.entries(metric).filter(([key, value]) => key.endsWith('Required') && value === true) + ) + })) as EvalMetricSchemaType[]; +} diff --git a/packages/service/core/evaluation/metric/schema.ts b/packages/service/core/evaluation/metric/schema.ts index 2b0ce254a431..9b05293a89a1 100644 --- a/packages/service/core/evaluation/metric/schema.ts +++ b/packages/service/core/evaluation/metric/schema.ts @@ -23,11 +23,15 @@ const EvalMetricSchema = new Schema({ }, name: { type: String, - required: true + required: true, + trim: true, + maxlength: 100 }, description: { type: String, - required: false + default: '', + trim: true, + maxlength: 100 }, type: { type: String, @@ -41,34 +45,34 @@ const EvalMetricSchema = new Schema({ userInputRequired: { type: Boolean, - default: false + required: false }, actualOutputRequired: { type: Boolean, - default: false + required: false }, expectedOutputRequired: { type: Boolean, - default: false + required: false }, contextRequired: { type: Boolean, - default: false + required: false }, retrievalContextRequired: { type: Boolean, - default: false + required: false }, embeddingRequired: { type: Boolean, - default: false + required: false }, llmRequired: { type: Boolean, - default: false + required: false }, createTime: { @@ -85,6 +89,7 @@ const EvalMetricSchema = new Schema({ EvalMetricSchema.index({ teamId: 1, name: 1 }, { unique: true }); EvalMetricSchema.index({ createTime: -1 }); +EvalMetricSchema.index({ updateTime: -1 }); EvalMetricSchema.pre('save', function (next) { this.updateTime = new Date(); diff --git a/packages/service/support/permission/evaluation/auth.ts b/packages/service/support/permission/evaluation/auth.ts index 7f234765db53..1b420ddc657f 100644 --- a/packages/service/support/permission/evaluation/auth.ts +++ b/packages/service/support/permission/evaluation/auth.ts @@ -19,8 +19,6 @@ import { BucketNameEnum } from '@fastgpt/global/common/file/constants'; import { CommonErrEnum } from '@fastgpt/global/common/error/code/common'; import { Permission } from '@fastgpt/global/support/permission/controller'; import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation'; -import { EvalMetricTypeEnum } from '@fastgpt/global/core/evaluation/metric/constants'; -import { TeamEvaluationCreatePermissionVal } from '@fastgpt/global/support/permission/user/constant'; // ================ Authentication and Authorization for eval task ================ export const authEvaluationByTmbId = async ({ @@ -302,7 +300,7 @@ export const authEvalMetricByTmbId = async ({ } // 团队权限验证 - 内置metric允许跨团队访问 - if (String(metric.teamId) !== teamId && metric.type !== EvalMetricTypeEnum.Builtin) { + if (String(metric.teamId) !== teamId) { return Promise.reject(EvaluationErrEnum.evalMetricNotFound); } @@ -315,11 +313,6 @@ export const authEvalMetricByTmbId = async ({ return { Per: new EvaluationPermission({ isOwner: true }) }; } - // 内置metric特殊处理:允许有evaluation权限的用户访问 - if (metric.type === EvalMetricTypeEnum.Builtin && String(metric.teamId) !== teamId) { - return { Per: new EvaluationPermission({ role: ReadPermissionVal, isOwner: false }) }; - } - // 获取evaluation资源的权限(evalMetric复用evaluation权限) const role = await getResourcePermission({ teamId, diff --git a/projects/app/data/metric.json b/projects/app/data/metric.json index ec1b7ac5a764..0cfd83abe275 100644 --- a/projects/app/data/metric.json +++ b/projects/app/data/metric.json @@ -2,68 +2,49 @@ "builtinMetrics": [ { "name": "answer_correctness", - "description": "Evaluates the factual accuracy of model responses compared to ground truth answers.", + "description": "Evaluates the factual consistency between the generated answer and the reference answer, evaluating whether it is accurate and error-free.", "userInputRequired": true, "actualOutputRequired": true, "expectedOutputRequired": true, - "contextRequired": false, - "retrievalContextRequired": false, "embeddingRequired": true, "llmRequired": true }, { "name": "answer_similarity", - "description": "Measures semantic similarity between model output and reference answer.", - "userInputRequired": false, + "description": "Evaluates the semantic alignment between the generated answer and the reference answer, determining whether they convey the same core information.", "actualOutputRequired": true, "expectedOutputRequired": true, - "contextRequired": false, - "retrievalContextRequired": false, - "embeddingRequired": true, - "llmRequired": false + "embeddingRequired": true }, { "name": "answer_relevancy", - "description": "Measures how relevant the generated answer is to the user's question.", + "description": "Evaluates how well the generated answer aligns with the question, judging whether the response directly addresses the query.", "userInputRequired": true, - "actualOutputRequired": true, - "expectedOutputRequired": false, - "contextRequired": false, - "retrievalContextRequired": false, - "embeddingRequired": false, + "actualOutputRequired": true, "llmRequired": true }, { "name": "context_precision", - "description": "Measures the proportion of relevant contexts among the retrieved contexts.", + "description": "Evaluates whether high-value information is prioritized in the retrieved content, reflecting the quality of ranking and information density.", "userInputRequired": true, - "actualOutputRequired": false, - "expectedOutputRequired": true, - "contextRequired": false, - "retrievalContextRequired": true, - "embeddingRequired": false, + "expectedOutputRequired": true, + "retrievalContextRequired": true, "llmRequired": true }, { "name": "context_recall", - "description": "Measures the proportion of relevant contexts that were successfully retrieved.", - "userInputRequired": true, - "actualOutputRequired": false, + "description": "Evaluates whether the retrieval system successfully retrieves all key information necessary for formulating the answer, assessing the completeness of retrieval.", + "userInputRequired": true, "expectedOutputRequired": true, - "contextRequired": false, "retrievalContextRequired": true, - "embeddingRequired": false, "llmRequired": true }, { "name": "faithfulness", - "description": "Evaluates whether the generated answer is faithful to the retrieved context.", + "description": "Evaluates whether the generated answer remains faithful to the provided context, determining whether it contains fabricated or inaccurate content.", "userInputRequired": true, "actualOutputRequired": true, - "expectedOutputRequired": false, - "contextRequired": false, "retrievalContextRequired": true, - "embeddingRequired": false, "llmRequired": true } ] diff --git a/projects/app/src/instrumentation.ts b/projects/app/src/instrumentation.ts index 4ff9645479f2..e01026caf335 100644 --- a/projects/app/src/instrumentation.ts +++ b/projects/app/src/instrumentation.ts @@ -51,13 +51,6 @@ export async function register() { //init system config;init vector database;init root user await Promise.all([getInitConfig(), initVectorStore(), initRootUser(), loadSystemModels()]); - try { - const { initBuiltinMetrics } = await import('@/service/common/system'); - await initBuiltinMetrics(); - } catch (error) { - console.error('Init metrics error:', error); - } - try { await preLoadWorker(); } catch (error) { diff --git a/projects/app/src/pageComponents/dashboard/evaluation/task/ManageDimension.tsx b/projects/app/src/pageComponents/dashboard/evaluation/task/ManageDimension.tsx index d38df44e5dbd..c31b1730018a 100644 --- a/projects/app/src/pageComponents/dashboard/evaluation/task/ManageDimension.tsx +++ b/projects/app/src/pageComponents/dashboard/evaluation/task/ManageDimension.tsx @@ -62,8 +62,8 @@ const transformMetricToDimension = ( description: metric.description || '', evaluationModel: metric.llmRequired ? defaultEvaluationModel || '' : '', // 使用默认评估模型 indexModel: metric.embeddingRequired ? defaultEmbeddingModel || '' : undefined, // 如果不需要索引模型则为 undefined - llmRequired: metric.llmRequired, - embeddingRequired: metric.embeddingRequired, + llmRequired: metric.llmRequired ?? false, + embeddingRequired: metric.embeddingRequired ?? false, isSelected: false }; }; diff --git a/projects/app/src/pages/api/core/evaluation/metric/list.ts b/projects/app/src/pages/api/core/evaluation/metric/list.ts index fcfb845d41a2..68c1a13e1fd1 100644 --- a/projects/app/src/pages/api/core/evaluation/metric/list.ts +++ b/projects/app/src/pages/api/core/evaluation/metric/list.ts @@ -1,39 +1,24 @@ import type { ApiRequestProps } from '@fastgpt/service/type/next'; import { NextAPI } from '@/service/middleware/entry'; import { authUserPer } from '@fastgpt/service/support/permission/user/auth'; -import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant'; import { TeamEvaluationCreatePermissionVal } from '@fastgpt/global/support/permission/user/constant'; -import { EvalMetricTypeEnum } from '@fastgpt/global/core/evaluation/metric/constants'; import { MongoEvalMetric } from '@fastgpt/service/core/evaluation/metric/schema'; -import { parsePaginationRequest } from '@fastgpt/service/common/api/pagination'; -import { replaceRegChars } from '@fastgpt/global/common/string/tools'; import { Types } from '@fastgpt/service/common/mongo'; import { addSourceMember } from '@fastgpt/service/support/user/utils'; import { EvaluationPermission } from '@fastgpt/global/support/permission/evaluation/controller'; import { sumPer } from '@fastgpt/global/support/permission/utils'; -import type { ListMetricsBody } from '@fastgpt/global/core/evaluation/metric/api'; import { addLog } from '@fastgpt/service/common/system/log'; import { getEvaluationPermissionAggregation } from '@fastgpt/service/core/evaluation/common'; +import { getBuiltinMetrics } from '@fastgpt/service/core/evaluation/metric/provider'; -async function handler(req: ApiRequestProps) { - const { teamId, tmbId } = await authUserPer({ +async function handler(req: ApiRequestProps<{}, {}>) { + await authUserPer({ req, authToken: true, authApiKey: true, per: TeamEvaluationCreatePermissionVal }); - const { offset, pageSize } = parsePaginationRequest(req); - const { searchKey } = req.body; - - const match: Record = { - teamId: new Types.ObjectId(teamId) - }; - - if (searchKey && typeof searchKey === 'string' && searchKey.trim().length > 0) { - match.name = { $regex: new RegExp(`${replaceRegChars(searchKey.trim())}`, 'i') }; - } - try { const { teamId, tmbId, isOwner, roleList, myGroupMap, myOrgSet } = await getEvaluationPermissionAggregation({ @@ -51,49 +36,24 @@ async function handler(req: ApiRequestProps) { const accessibleIds = myRoles.map((item) => item.resourceId); const filter: any = { teamId: new Types.ObjectId(teamId) }; - if (searchKey) { - filter.$or = [ - { name: { $regex: searchKey, $options: 'i' } }, - { description: { $regex: searchKey, $options: 'i' } } - ]; - } - const limit = pageSize; - const sort = { createTime: -1 as const }; - // Build query to include both accessible metrics and builtin metrics + // If not owner, filter by accessible resources let finalFilter = filter; - if (!isOwner) { + if (!isOwner && accessibleIds.length > 0) { finalFilter = { ...filter, $or: [ { _id: { $in: accessibleIds.map((id) => new Types.ObjectId(id)) } }, - ...(tmbId ? [{ tmbId: new Types.ObjectId(tmbId) }] : []), // Own metrics - { type: EvalMetricTypeEnum.Builtin } // Builtin metrics for all evaluation users - ] - }; - } else { - // Owner用户也需要包含内置metrics(跨team访问) - finalFilter = { - $or: [ - filter, // 当前team的metrics - { type: EvalMetricTypeEnum.Builtin } // 内置metrics(跨team) + ...(tmbId ? [{ tmbId: new Types.ObjectId(tmbId) }] : []) // Own metrics ] }; } - const [metrics, total] = await Promise.all([ - MongoEvalMetric.find(finalFilter).sort(sort).skip(offset).limit(limit).lean(), - MongoEvalMetric.countDocuments(finalFilter) - ]); + const customMetrics = await MongoEvalMetric.find(finalFilter).sort({ createTime: -1 }).lean(); - const formatMetrics = metrics + const formatCustomMetrics = customMetrics .map((metric: any) => { const getPer = (metricId: string) => { - // 内置metric特殊处理:允许有evaluation权限的用户访问 - if (metric.type === EvalMetricTypeEnum.Builtin) { - return new EvaluationPermission({ role: ReadPermissionVal, isOwner: false }); - } - const tmbRole = myRoles.find( (item) => String(item.resourceId) === metricId && !!item.tmbId )?.permission; @@ -133,23 +93,29 @@ async function handler(req: ApiRequestProps) { }) .filter((metric: any) => metric.permission.hasReadPer); - const formattedResult = await addSourceMember({ - list: formatMetrics + // Add source member only for custom metrics + const customWithSourceMember = await addSourceMember({ + list: formatCustomMetrics }); - const finalResult = { - list: formattedResult, - total: total - }; + // Get builtin metrics + const builtinMetrics = await getBuiltinMetrics(); + const formatBuiltinMetrics = builtinMetrics.map((metric: any) => ({ + ...metric + })); + + // Combine results + const finalList = [...customWithSourceMember, ...formatBuiltinMetrics]; addLog.info('[Evaluation Metric] Metric list query successful', { - pageSize: pageSize, - searchKey: searchKey?.trim(), - total: finalResult.total, - returned: finalResult.list.length + total: finalList.length, + builtin: formatBuiltinMetrics.length, + custom: customWithSourceMember.length }); - return finalResult; + return { + list: finalList + }; } catch (error) { addLog.error('[Evaluation Metric] Failed to fetch evaluation metrics', error); return Promise.reject(error); diff --git a/projects/app/src/service/common/system/index.ts b/projects/app/src/service/common/system/index.ts index d5522feb4cae..79930c8d8a5b 100644 --- a/projects/app/src/service/common/system/index.ts +++ b/projects/app/src/service/common/system/index.ts @@ -206,44 +206,3 @@ export async function initAppTemplateTypes() { console.error('Error initializing system templates:', error); } } - -export async function initBuiltinMetrics() { - try { - // 读取独立的 metric.json 文件 - const { readConfigData } = await import('@/service/common/system'); - const metricContent = await readConfigData('metric.json'); - const metricConfig = JSON.parse(metricContent); - - // 获取 root 用户信息 - const { MongoUser } = await import('@fastgpt/service/support/user/schema'); - const { getUserDefaultTeam } = await import('@fastgpt/service/support/user/team/controller'); - const { MongoEvalMetric } = await import('@fastgpt/service/core/evaluation/metric/schema'); - const { EvalMetricTypeEnum } = await import('@fastgpt/global/core/evaluation/metric/constants'); - - const rootUser = await MongoUser.findOne({ username: 'root' }); - if (!rootUser) return; - - const tmb = await getUserDefaultTeam({ userId: rootUser._id }); - const metrics = (metricConfig.builtinMetrics || []).map((m: any) => ({ - teamId: tmb.teamId, - tmbId: tmb.tmbId, - name: m.name, - description: m.description, - type: EvalMetricTypeEnum.Builtin, - userInputRequired: m.userInputRequired ?? false, - actualOutputRequired: m.actualOutputRequired ?? false, - expectedOutputRequired: m.expectedOutputRequired ?? false, - contextRequired: m.contextRequired ?? false, - retrievalContextRequired: m.retrievalContextRequired ?? false, - embeddingRequired: m.embeddingRequired ?? false, - llmRequired: m.llmRequired ?? false - })); - - if (metrics.length > 0) { - await MongoEvalMetric.insertMany(metrics); - console.log('[Init] Built-in metrics inserted to MongoDB'); - } - } catch (error) { - console.error('Init metrics skipped:', error); - } -} diff --git a/test/cases/pages/api/core/evaluation/metric/list.test.ts b/test/cases/pages/api/core/evaluation/metric/list.test.ts index 53e49fe53e72..b7e95d48f673 100644 --- a/test/cases/pages/api/core/evaluation/metric/list.test.ts +++ b/test/cases/pages/api/core/evaluation/metric/list.test.ts @@ -4,22 +4,14 @@ import { MongoEvalMetric } from '@fastgpt/service/core/evaluation/metric/schema' import { authUserPer } from '@fastgpt/service/support/permission/user/auth'; import { addSourceMember } from '@fastgpt/service/support/user/utils'; import { getEvaluationPermissionAggregation } from '@fastgpt/service/core/evaluation/common'; -import type { ListMetricsBody } from '@fastgpt/global/core/evaluation/metric/api'; +import { getBuiltinMetrics } from '@fastgpt/service/core/evaluation/metric/provider'; import { Types } from '@fastgpt/service/common/mongo'; +import { EvalMetricTypeEnum } from '@fastgpt/global/core/evaluation/metric/constants'; // Mock dependencies vi.mock('@fastgpt/service/core/evaluation/metric/schema', () => ({ MongoEvalMetric: { - find: vi.fn(() => ({ - sort: vi.fn(() => ({ - skip: vi.fn(() => ({ - limit: vi.fn(() => ({ - lean: vi.fn() - })) - })) - })) - })), - countDocuments: vi.fn() + find: vi.fn() } })); @@ -35,6 +27,10 @@ vi.mock('@fastgpt/service/core/evaluation/common', () => ({ getEvaluationPermissionAggregation: vi.fn() })); +vi.mock('@fastgpt/service/core/evaluation/metric/provider', () => ({ + getBuiltinMetrics: vi.fn() +})); + describe('/api/core/evaluation/metric/list', () => { const mockTeamId = '507f1f77bcf86cd799439011'; const mockTmbId = '507f1f77bcf86cd799439012'; @@ -44,7 +40,7 @@ describe('/api/core/evaluation/metric/list', () => { vi.clearAllMocks(); }); - it('should list metrics successfully without search', async () => { + it('should list metrics successfully', async () => { // Mock auth response vi.mocked(authUserPer).mockResolvedValue({ userId: mockUserId, @@ -65,71 +61,51 @@ describe('/api/core/evaluation/metric/list', () => { myOrgSet: new Set() }); - // Mock database response - const mockMetrics = [ + // Mock custom metrics from database + const mockCustomMetrics = [ { _id: 'metric1', - name: 'Metric 1', - description: 'Description 1', + name: 'Custom Metric 1', + description: 'Custom Description 1', createTime: new Date('2024-01-01'), updateTime: new Date('2024-01-01'), tmbId: mockTmbId, - permission: { hasReadPer: true } - }, - { - _id: 'metric2', - name: 'Metric 2', - description: 'Description 2', - createTime: new Date('2024-01-02'), - updateTime: new Date('2024-01-02'), - tmbId: mockTmbId, - permission: { hasReadPer: true } + type: EvalMetricTypeEnum.Custom } ]; - const mockMetricsWithSource = [ + // Mock builtin metrics + const mockBuiltinMetrics = [ { - _id: 'metric1', - name: 'Metric 1', - description: 'Description 1', + _id: 'builtin_accuracy', + name: 'Accuracy', + description: 'Accuracy metric', + type: EvalMetricTypeEnum.Builtin, + teamId: '', + tmbId: '', createTime: new Date('2024-01-01'), - updateTime: new Date('2024-01-01'), - tmbId: mockTmbId, - sourceMember: { name: 'User 1', avatar: 'avatar1.png', status: 'active' as any } - }, - { - _id: 'metric2', - name: 'Metric 2', - description: 'Description 2', - createTime: new Date('2024-01-02'), - updateTime: new Date('2024-01-02'), - tmbId: mockTmbId, - sourceMember: { name: 'User 2', avatar: 'avatar2.png', status: 'active' as any } + updateTime: new Date('2024-01-01') } ]; - const mockQuery = { - lean: vi.fn().mockResolvedValue(mockMetrics) - }; - const mockSkip = { - limit: vi.fn().mockReturnValue(mockQuery) - }; - const mockSort = { - skip: vi.fn().mockReturnValue(mockSkip) - }; - const mockFind = { - sort: vi.fn().mockReturnValue(mockSort) - }; + const mockCustomMetricsWithSource = [ + { + ...mockCustomMetrics[0], + sourceMember: { name: 'User 1', avatar: 'avatar1.png', status: 'active' as any }, + permission: { hasReadPer: true }, + private: true + } + ]; - vi.mocked(MongoEvalMetric.find).mockReturnValue(mockFind as any); - vi.mocked(MongoEvalMetric.countDocuments).mockResolvedValue(2); - vi.mocked(addSourceMember).mockResolvedValue(mockMetricsWithSource); + vi.mocked(MongoEvalMetric.find).mockReturnValue({ + sort: vi.fn().mockReturnValue({ + lean: vi.fn().mockResolvedValue(mockCustomMetrics) + }) + } as any); + vi.mocked(addSourceMember).mockResolvedValue(mockCustomMetricsWithSource); + vi.mocked(getBuiltinMetrics).mockResolvedValue(mockBuiltinMetrics); const req = { - body: { - pageNum: 1, - pageSize: 10 - } as ListMetricsBody, auth: { userId: mockUserId, teamId: mockTeamId, @@ -159,79 +135,44 @@ describe('/api/core/evaluation/metric/list', () => { authToken: true }); - // Verify database query with expected filter structure + // Verify database query for custom metrics expect(MongoEvalMetric.find).toHaveBeenCalledWith({ - $or: [ - { - _id: { - $in: [] - } - }, - { - tmbId: new Types.ObjectId(mockTmbId) - }, - { - type: 'builtin_metric' - } - ], teamId: new Types.ObjectId(mockTeamId) }); - expect(mockFind.sort).toHaveBeenCalledWith({ createTime: -1 }); - expect(mockSort.skip).toHaveBeenCalledWith(0); - expect(mockSkip.limit).toHaveBeenCalledWith(10); - // Verify count query - expect(MongoEvalMetric.countDocuments).toHaveBeenCalledWith({ - $or: [ - { - _id: { - $in: [] - } - }, - { - tmbId: new Types.ObjectId(mockTmbId) - }, - { - type: 'builtin_metric' - } - ], - teamId: new Types.ObjectId(mockTeamId) - }); + // Verify builtin metrics were fetched + expect(getBuiltinMetrics).toHaveBeenCalled(); - // Verify source member addition (with permission objects added) + // Verify source member addition for custom metrics only expect(addSourceMember).toHaveBeenCalledWith({ list: expect.arrayContaining([ expect.objectContaining({ _id: 'metric1', - name: 'Metric 1', - description: 'Description 1', - createTime: new Date('2024-01-01'), - updateTime: new Date('2024-01-01'), - tmbId: mockTmbId, - permission: expect.any(Object), - private: expect.any(Boolean) - }), - expect.objectContaining({ - _id: 'metric2', - name: 'Metric 2', - description: 'Description 2', - createTime: new Date('2024-01-02'), - updateTime: new Date('2024-01-02'), - tmbId: mockTmbId, + name: 'Custom Metric 1', permission: expect.any(Object), private: expect.any(Boolean) }) ]) }); - // Verify response + // Verify response structure expect(result).toEqual({ - total: 2, - list: mockMetricsWithSource + list: expect.arrayContaining([ + expect.objectContaining({ + _id: 'metric1', + name: 'Custom Metric 1', + sourceMember: expect.any(Object) + }), + expect.objectContaining({ + _id: 'builtin_accuracy', + name: 'Accuracy', + type: EvalMetricTypeEnum.Builtin + }) + ]) }); }); - it('should list metrics with search key filter', async () => { + it('should handle owner permissions correctly', async () => { // Mock auth response vi.mocked(authUserPer).mockResolvedValue({ userId: mockUserId, @@ -242,52 +183,67 @@ describe('/api/core/evaluation/metric/list', () => { tmb: {} as any }); - // Mock database response - const mockMetrics = [ + // Mock permission aggregation response with owner status + vi.mocked(getEvaluationPermissionAggregation).mockResolvedValue({ + teamId: mockTeamId, + tmbId: mockTmbId, + isOwner: true, + roleList: [], + myGroupMap: new Map(), + myOrgSet: new Set() + }); + + const mockCustomMetrics = [ { _id: 'metric1', name: 'Test Metric', description: 'Test Description', createTime: new Date('2024-01-01'), updateTime: new Date('2024-01-01'), - tmbId: mockTmbId + tmbId: mockTmbId, + type: EvalMetricTypeEnum.Custom } ]; - const mockQuery = { - lean: vi.fn().mockResolvedValue(mockMetrics) - }; - const mockSkip = { - limit: vi.fn().mockReturnValue(mockQuery) - }; - const mockSort = { - skip: vi.fn().mockReturnValue(mockSkip) - }; - const mockFind = { - sort: vi.fn().mockReturnValue(mockSort) - }; - - vi.mocked(MongoEvalMetric.find).mockReturnValue(mockFind as any); - vi.mocked(MongoEvalMetric.countDocuments).mockResolvedValue(1); + vi.mocked(MongoEvalMetric.find).mockReturnValue({ + sort: vi.fn().mockReturnValue({ + lean: vi.fn().mockResolvedValue(mockCustomMetrics) + }) + } as any); vi.mocked(addSourceMember).mockResolvedValue( - mockMetrics.map((item) => ({ + mockCustomMetrics.map((item) => ({ ...item, - sourceMember: { name: 'User', avatar: 'avatar.png', status: 'active' as any } + sourceMember: { name: 'User', avatar: 'avatar.png', status: 'active' as any }, + permission: { hasReadPer: true }, + private: true })) ); + vi.mocked(getBuiltinMetrics).mockResolvedValue([]); const req = { - body: { - pageNum: 1, - pageSize: 10, - searchKey: 'Test' - } as ListMetricsBody + auth: { + userId: mockUserId, + teamId: mockTeamId, + tmbId: mockTmbId, + appId: '', + authType: 'token' as any, + sourceName: undefined, + apikey: '', + isRoot: false + } }; - await handler(req as any); + const result = await handler(req as any); + + // Verify that for owners, simple filter is used (no $or with accessible IDs) + expect(MongoEvalMetric.find).toHaveBeenCalledWith({ + teamId: new Types.ObjectId(mockTeamId) + }); + + expect(result.list).toHaveLength(1); }); - it('should handle empty search key', async () => { + it('should handle non-owner permissions with accessible resources', async () => { // Mock auth response vi.mocked(authUserPer).mockResolvedValue({ userId: mockUserId, @@ -298,39 +254,34 @@ describe('/api/core/evaluation/metric/list', () => { tmb: {} as any }); - // Mock permission aggregation response + // Mock permission aggregation response with accessible resources + const accessibleMetricId = '507f1f77bcf86cd799439020'; vi.mocked(getEvaluationPermissionAggregation).mockResolvedValue({ teamId: mockTeamId, tmbId: mockTmbId, isOwner: false, - roleList: [], + roleList: [ + { + resourceId: accessibleMetricId, + tmbId: mockTmbId, + permission: 1, + groupId: null, + orgId: null + } + ], myGroupMap: new Map(), myOrgSet: new Set() }); - const mockQuery = { - lean: vi.fn().mockResolvedValue([]) - }; - const mockSkip = { - limit: vi.fn().mockReturnValue(mockQuery) - }; - const mockSort = { - skip: vi.fn().mockReturnValue(mockSkip) - }; - const mockFind = { - sort: vi.fn().mockReturnValue(mockSort) - }; - - vi.mocked(MongoEvalMetric.find).mockReturnValue(mockFind as any); - vi.mocked(MongoEvalMetric.countDocuments).mockResolvedValue(0); + vi.mocked(MongoEvalMetric.find).mockReturnValue({ + sort: vi.fn().mockReturnValue({ + lean: vi.fn().mockResolvedValue([]) + }) + } as any); vi.mocked(addSourceMember).mockResolvedValue([]); + vi.mocked(getBuiltinMetrics).mockResolvedValue([]); const req = { - body: { - pageNum: 1, - pageSize: 10, - searchKey: ' ' // whitespace only - } as ListMetricsBody, auth: { userId: mockUserId, teamId: mockTeamId, @@ -345,26 +296,23 @@ describe('/api/core/evaluation/metric/list', () => { await handler(req as any); - // Verify database query without search filter (whitespace is trimmed) + // Verify database query with permission-based filter expect(MongoEvalMetric.find).toHaveBeenCalledWith({ + teamId: new Types.ObjectId(mockTeamId), $or: [ { _id: { - $in: [] + $in: [new Types.ObjectId(accessibleMetricId)] } }, { tmbId: new Types.ObjectId(mockTmbId) - }, - { - type: 'builtin_metric' } - ], - teamId: new Types.ObjectId(mockTeamId) + ] }); }); - it('should handle pagination correctly', async () => { + it('should filter metrics by permission', async () => { // Mock auth response vi.mocked(authUserPer).mockResolvedValue({ userId: mockUserId, @@ -375,35 +323,82 @@ describe('/api/core/evaluation/metric/list', () => { tmb: {} as any }); - const mockQuery = { - lean: vi.fn().mockResolvedValue([]) - }; - const mockSkip = { - limit: vi.fn().mockReturnValue(mockQuery) - }; - const mockSort = { - skip: vi.fn().mockReturnValue(mockSkip) - }; - const mockFind = { - sort: vi.fn().mockReturnValue(mockSort) - }; + // Mock permission aggregation response + vi.mocked(getEvaluationPermissionAggregation).mockResolvedValue({ + teamId: mockTeamId, + tmbId: mockTmbId, + isOwner: false, + roleList: [], + myGroupMap: new Map(), + myOrgSet: new Set() + }); - vi.mocked(MongoEvalMetric.find).mockReturnValue(mockFind as any); - vi.mocked(MongoEvalMetric.countDocuments).mockResolvedValue(0); - vi.mocked(addSourceMember).mockResolvedValue([]); + // Mock metrics where some don't have read permission + const mockCustomMetrics = [ + { + _id: 'metric1', + name: 'Accessible Metric', + tmbId: mockTmbId, + createTime: new Date(), + updateTime: new Date() + }, + { + _id: 'metric2', + name: 'Inaccessible Metric', + tmbId: 'other_user_id', + createTime: new Date(), + updateTime: new Date() + } + ]; + + // Mock the custom metrics with different permission results + const mockCustomWithPermissions = [ + { + ...mockCustomMetrics[0], + permission: { hasReadPer: true }, + private: true, + sourceMember: { name: 'User', avatar: 'avatar.png', status: 'active' as any } + }, + { + ...mockCustomMetrics[1], + permission: { hasReadPer: false }, + private: false + } + ]; + + vi.mocked(MongoEvalMetric.find).mockReturnValue({ + sort: vi.fn().mockReturnValue({ + lean: vi.fn().mockResolvedValue(mockCustomMetrics) + }) + } as any); + vi.mocked(addSourceMember).mockResolvedValue([ + mockCustomWithPermissions[0] // Only the one with read permission + ]); + vi.mocked(getBuiltinMetrics).mockResolvedValue([]); const req = { - body: { - pageNum: 3, - pageSize: 5 - } as ListMetricsBody + auth: { + userId: mockUserId, + teamId: mockTeamId, + tmbId: mockTmbId, + appId: '', + authType: 'token' as any, + sourceName: undefined, + apikey: '', + isRoot: false + } }; - await handler(req as any); + const result = await handler(req as any); - // Verify pagination: page 3 with size 5 = skip 10, limit 5 - expect(mockSort.skip).toHaveBeenCalledWith(10); - expect(mockSkip.limit).toHaveBeenCalledWith(5); + // Verify that only metrics with read permission are included + expect(result.list).toHaveLength(1); + expect(result.list[0]).toEqual( + expect.objectContaining({ + _id: 'metric1', + name: 'Accessible Metric' + }) + ); }); it('should handle auth failure', async () => { @@ -411,10 +406,16 @@ describe('/api/core/evaluation/metric/list', () => { vi.mocked(authUserPer).mockRejectedValue(authError); const req = { - body: { - pageNum: 1, - pageSize: 10 - } as ListMetricsBody + auth: { + userId: mockUserId, + teamId: mockTeamId, + tmbId: mockTmbId, + appId: '', + authType: 'token' as any, + sourceName: undefined, + apikey: '', + isRoot: false + } }; await expect(handler(req as any)).rejects.toThrow('Authentication failed'); @@ -433,16 +434,31 @@ describe('/api/core/evaluation/metric/list', () => { tmb: {} as any }); + vi.mocked(getEvaluationPermissionAggregation).mockResolvedValue({ + teamId: mockTeamId, + tmbId: mockTmbId, + isOwner: false, + roleList: [], + myGroupMap: new Map(), + myOrgSet: new Set() + }); + const dbError = new Error('Database query failed'); vi.mocked(MongoEvalMetric.find).mockImplementation(() => { throw dbError; }); const req = { - body: { - pageNum: 1, - pageSize: 10 - } as ListMetricsBody + auth: { + userId: mockUserId, + teamId: mockTeamId, + tmbId: mockTmbId, + appId: '', + authType: 'token' as any, + sourceName: undefined, + apikey: '', + isRoot: false + } }; await expect(handler(req as any)).rejects.toThrow('Database query failed'); From 2b242c6e4b92301d767b3b154bde1b2d289e137e Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 15 Sep 2025 21:32:16 +0800 Subject: [PATCH 03/84] refactor: rename datasetId to evalDatasetCollectionId for clarity --- .../global/core/evaluation/dataset/api.d.ts | 3 +- .../global/core/evaluation/dataset/type.d.ts | 2 +- packages/global/core/evaluation/type.d.ts | 2 +- packages/service/core/evaluation/common.ts | 12 +- .../dataset/evalDatasetDataSchema.ts | 6 +- .../dataset/smartGenerateProcessor.ts | 2 +- .../service/core/evaluation/task/index.ts | 8 +- .../service/core/evaluation/task/processor.ts | 2 +- .../service/core/evaluation/task/schema.ts | 2 +- .../service/core/evaluation/utils/index.ts | 160 +++++++++--------- .../evaluation/dataset/collection/delete.ts | 4 +- .../core/evaluation/dataset/data/create.ts | 2 +- .../core/evaluation/dataset/data/delete.ts | 4 +- .../core/evaluation/dataset/data/detail.ts | 2 +- .../core/evaluation/dataset/data/fileId.ts | 2 +- .../api/core/evaluation/dataset/data/list.ts | 4 +- .../dataset/collection/delete.test.ts | 8 +- .../evaluation/dataset/data/create.test.ts | 2 +- .../evaluation/dataset/data/delete.test.ts | 2 +- .../evaluation/dataset/data/fileId.test.ts | 4 +- .../core/evaluation/dataset/data/list.test.ts | 69 +++++--- 21 files changed, 164 insertions(+), 138 deletions(-) diff --git a/packages/global/core/evaluation/dataset/api.d.ts b/packages/global/core/evaluation/dataset/api.d.ts index d6f1ee1dc86f..4d169aea44b6 100644 --- a/packages/global/core/evaluation/dataset/api.d.ts +++ b/packages/global/core/evaluation/dataset/api.d.ts @@ -119,7 +119,7 @@ export type getEvalDatasetDataDetailResponse = Pick< | '_id' | 'teamId' | 'tmbId' - | 'datasetId' + | 'evalDatasetCollectionId' | EvalDatasetDataKeyEnum.UserInput | EvalDatasetDataKeyEnum.ActualOutput | EvalDatasetDataKeyEnum.ExpectedOutput @@ -145,6 +145,7 @@ export type listFailedTasksBody = { export type listFailedTasksResponse = { tasks: Array<{ jobId: string; + // all about dataset dataId: string; datasetId: string; datasetName: string; diff --git a/packages/global/core/evaluation/dataset/type.d.ts b/packages/global/core/evaluation/dataset/type.d.ts index e7dc5eaba0a2..17e68287e4d3 100644 --- a/packages/global/core/evaluation/dataset/type.d.ts +++ b/packages/global/core/evaluation/dataset/type.d.ts @@ -25,7 +25,7 @@ export type EvalDatasetDataSchemaType = { _id: string; teamId: string; tmbId: string; - datasetId: string; + evalDatasetCollectionId: string; [EvalDatasetDataKeyEnum.UserInput]: string; [EvalDatasetDataKeyEnum.ActualOutput]: string; [EvalDatasetDataKeyEnum.ExpectedOutput]: string; diff --git a/packages/global/core/evaluation/type.d.ts b/packages/global/core/evaluation/type.d.ts index 5e00299a01ce..5624afd3e8df 100644 --- a/packages/global/core/evaluation/type.d.ts +++ b/packages/global/core/evaluation/type.d.ts @@ -59,7 +59,7 @@ export type EvaluationSchemaType = { tmbId: string; name: string; description?: string; - datasetId: string; // Associated dataset + evalDatasetCollectionId: string; // Associated evaluation dataset collection target: EvalTarget; // Embedded evaluation target evaluators: EvaluatorSchema[]; // Array of evaluator configurations summaryConfigs: SummaryConfig[]; // Array of summary configs, one for each metric diff --git a/packages/service/core/evaluation/common.ts b/packages/service/core/evaluation/common.ts index becc4c9b57e2..c1993c8ab886 100644 --- a/packages/service/core/evaluation/common.ts +++ b/packages/service/core/evaluation/common.ts @@ -451,12 +451,14 @@ export const authEvaluationDatasetDataUpdateById = async ( tmbId: string; collectionId: string; }> => { - const dataItem = await MongoEvalDatasetData.findById(dataId).select('datasetId').lean(); + const dataItem = await MongoEvalDatasetData.findById(dataId) + .select('evalDatasetCollectionId') + .lean(); if (!dataItem) { throw new Error(EvaluationErrEnum.datasetDataNotFound); } - const collectionId = String(dataItem.datasetId); + const collectionId = String(dataItem.evalDatasetCollectionId); return await authEvaluationDatasetDataUpdate(collectionId, auth); }; @@ -468,11 +470,13 @@ export const authEvaluationDatasetDataReadById = async ( tmbId: string; collectionId: string; }> => { - const dataItem = await MongoEvalDatasetData.findById(dataId).select('datasetId').lean(); + const dataItem = await MongoEvalDatasetData.findById(dataId) + .select('evalDatasetCollectionId') + .lean(); if (!dataItem) { throw new Error(EvaluationErrEnum.datasetDataNotFound); } - const collectionId = String(dataItem.datasetId); + const collectionId = String(dataItem.evalDatasetCollectionId); return await authEvaluationDatasetDataRead(collectionId, auth); }; diff --git a/packages/service/core/evaluation/dataset/evalDatasetDataSchema.ts b/packages/service/core/evaluation/dataset/evalDatasetDataSchema.ts index da6c81de04bb..cfe113264190 100644 --- a/packages/service/core/evaluation/dataset/evalDatasetDataSchema.ts +++ b/packages/service/core/evaluation/dataset/evalDatasetDataSchema.ts @@ -27,7 +27,7 @@ const EvalDatasetDataSchema = new Schema({ ref: TeamMemberCollectionName, required: true }, - datasetId: { + evalDatasetCollectionId: { type: Schema.Types.ObjectId, ref: EvalDatasetCollectionName, required: true, @@ -93,8 +93,8 @@ const EvalDatasetDataSchema = new Schema({ }); // Indexes for efficient queries -EvalDatasetDataSchema.index({ datasetId: 1, createTime: -1 }); -EvalDatasetDataSchema.index({ datasetId: 1, updateTime: -1 }); +EvalDatasetDataSchema.index({ evalDatasetCollectionId: 1, createTime: -1 }); +EvalDatasetDataSchema.index({ evalDatasetCollectionId: 1, updateTime: -1 }); // Text search index for searching within inputs and outputs EvalDatasetDataSchema.index({ diff --git a/packages/service/core/evaluation/dataset/smartGenerateProcessor.ts b/packages/service/core/evaluation/dataset/smartGenerateProcessor.ts index f874deafd286..d4597cb346e2 100644 --- a/packages/service/core/evaluation/dataset/smartGenerateProcessor.ts +++ b/packages/service/core/evaluation/dataset/smartGenerateProcessor.ts @@ -104,7 +104,7 @@ async function processor(job: Job) { const evalData: Partial = { teamId: evalDatasetCollection.teamId, tmbId: evalDatasetCollection.tmbId, - datasetId: evalDatasetCollectionId, + evalDatasetCollectionId: evalDatasetCollectionId, [EvalDatasetDataKeyEnum.UserInput]: sample.q, [EvalDatasetDataKeyEnum.ExpectedOutput]: sample.a, [EvalDatasetDataKeyEnum.ActualOutput]: '', diff --git a/packages/service/core/evaluation/task/index.ts b/packages/service/core/evaluation/task/index.ts index a4f6266b513b..a1ef319caf8c 100644 --- a/packages/service/core/evaluation/task/index.ts +++ b/packages/service/core/evaluation/task/index.ts @@ -47,8 +47,8 @@ export class EvaluationTaskService { // Apply default configuration to evaluators (weights, thresholds, etc.) const { evaluators: evaluatorsWithDefaultConfig, summaryConfigs } = buildEvalDataConfig( - evaluationParams.evaluators - ); + evaluationParams.evaluators + ); const createAndStart = async (session: ClientSession) => { // Create evaluation within transaction const evaluation = await MongoEvaluation.create( @@ -206,7 +206,7 @@ export class EvaluationTaskService { { $lookup: { from: 'eval_dataset_collections', - localField: 'datasetId', + localField: 'evalDatasetCollectionId', foreignField: '_id', as: 'dataset' } @@ -452,7 +452,7 @@ export class EvaluationTaskService { tmbId: 1, name: 1, description: 1, - datasetId: 1, + evalDatasetCollectionId: 1, target: { type: '$target.type', config: { diff --git a/packages/service/core/evaluation/task/processor.ts b/packages/service/core/evaluation/task/processor.ts index 2ff97840bfde..630e540e8ffe 100644 --- a/packages/service/core/evaluation/task/processor.ts +++ b/packages/service/core/evaluation/task/processor.ts @@ -421,7 +421,7 @@ const evaluationTaskProcessor = async (job: Job) => { // Load dataset const dataItems = await MongoEvalDatasetData.find({ - datasetId: evaluation.datasetId, + evalDatasetCollectionId: evaluation.evalDatasetCollectionId, teamId: evaluation.teamId }).lean(); diff --git a/packages/service/core/evaluation/task/schema.ts b/packages/service/core/evaluation/task/schema.ts index f4555b753979..918d724f980a 100644 --- a/packages/service/core/evaluation/task/schema.ts +++ b/packages/service/core/evaluation/task/schema.ts @@ -118,7 +118,7 @@ export const EvaluationTaskSchema = new Schema({ trim: true, maxlength: 100 }, - datasetId: { + evalDatasetCollectionId: { type: Schema.Types.ObjectId, ref: EvalDatasetCollectionName, required: true diff --git a/packages/service/core/evaluation/utils/index.ts b/packages/service/core/evaluation/utils/index.ts index 001b91974609..aeaa9841c633 100644 --- a/packages/service/core/evaluation/utils/index.ts +++ b/packages/service/core/evaluation/utils/index.ts @@ -63,7 +63,7 @@ export async function validateEvaluationParams( params: EvaluationValidationParams, options?: EvaluationValidationOptions ): Promise { - const { name, description, datasetId, target, evaluators } = params; + const { name, description, datasetId: evalDatasetCollectionId, target, evaluators } = params; const mode = options?.mode || 'create'; const isCreateMode = mode === 'create'; @@ -82,14 +82,14 @@ export async function validateEvaluationParams( }; } - if (!datasetId) { + if (!evalDatasetCollectionId) { return { isValid: false, errors: [ { code: EvaluationErrEnum.evalDatasetIdRequired, - message: 'Dataset ID is required', - field: 'datasetId' + message: 'Evaluation dataset collection ID is required', + field: 'evalDatasetCollectionId' } ] }; @@ -166,107 +166,107 @@ export async function validateEvaluationParams( }; } - if (datasetId !== undefined) { - if (!datasetId) { + if (evalDatasetCollectionId !== undefined) { + if (!evalDatasetCollectionId) { return { isValid: false, errors: [ { code: EvaluationErrEnum.evalDatasetIdRequired, - message: 'Dataset ID is required', - field: 'datasetId' + message: 'Evaluation dataset collection ID is required', + field: 'evalDatasetCollectionId' } ] }; } // Validate dataset exists and is accessible - const datasetValidation = await validateDatasetExists(datasetId, options?.teamId); + const datasetValidation = await validateDatasetExists(evalDatasetCollectionId, options?.teamId); if (!datasetValidation.isValid) { return datasetValidation; } - } - if (target !== undefined) { - if (!target) { - return { - isValid: false, - errors: [ - { - code: EvaluationErrEnum.evalTargetRequired, - message: 'Evaluation target is required', - field: 'target' - } - ] - }; - } + if (target !== undefined) { + if (!target) { + return { + isValid: false, + errors: [ + { + code: EvaluationErrEnum.evalTargetRequired, + message: 'Evaluation target is required', + field: 'target' + } + ] + }; + } - // Validate target configuration using validateTargetConfig - const targetValidation = await validateTargetConfig(target); - if (!targetValidation.isValid) { - return targetValidation; // Return the detailed validation result directly + // Validate target configuration using validateTargetConfig + const targetValidation = await validateTargetConfig(target); + if (!targetValidation.isValid) { + return targetValidation; // Return the detailed validation result directly + } } - } - if (evaluators !== undefined) { - if (!evaluators || !Array.isArray(evaluators) || evaluators.length === 0) { - return { - isValid: false, - errors: [ - { - code: EvaluationErrEnum.evalEvaluatorsRequired, - message: 'At least one evaluator is required', - field: 'evaluators' - } - ] - }; - } + if (evaluators !== undefined) { + if (!evaluators || !Array.isArray(evaluators) || evaluators.length === 0) { + return { + isValid: false, + errors: [ + { + code: EvaluationErrEnum.evalEvaluatorsRequired, + message: 'At least one evaluator is required', + field: 'evaluators' + } + ] + }; + } - // Validate evaluators configuration using validateEvaluatorConfig - for (let i = 0; i < evaluators.length; i++) { - const evaluator = evaluators[i]; + // Validate evaluators configuration using validateEvaluatorConfig + for (let i = 0; i < evaluators.length; i++) { + const evaluator = evaluators[i]; - // Detailed validation using validateEvaluatorConfig - const evaluatorValidation = await validateEvaluatorConfig(evaluator); - if (!evaluatorValidation.isValid) { - // Prefix error messages with evaluator index for clarity - const errors = evaluatorValidation.errors.map((err) => ({ - ...err, - message: `Evaluator at index ${i}: ${err.message}`, - field: `evaluators[${i}].${err.field || 'unknown'}`, - debugInfo: { - evaluatorIndex: i, - ...err.debugInfo - } - })); - return { isValid: false, errors }; - } + // Detailed validation using validateEvaluatorConfig + const evaluatorValidation = await validateEvaluatorConfig(evaluator); + if (!evaluatorValidation.isValid) { + // Prefix error messages with evaluator index for clarity + const errors = evaluatorValidation.errors.map((err) => ({ + ...err, + message: `Evaluator at index ${i}: ${err.message}`, + field: `evaluators[${i}].${err.field || 'unknown'}`, + debugInfo: { + evaluatorIndex: i, + ...err.debugInfo + } + })); + return { isValid: false, errors }; + } - // Validate scoreScaling if provided - if (evaluator.scoreScaling !== undefined) { - if ( - typeof evaluator.scoreScaling !== 'number' || - isNaN(evaluator.scoreScaling) || - !isFinite(evaluator.scoreScaling) || - evaluator.scoreScaling <= 0 || - evaluator.scoreScaling > 10000 - ) { - return { - isValid: false, - errors: [ - { - code: EvaluationErrEnum.evalEvaluatorInvalidScoreScaling, - message: 'Evaluator scoreScaling invalid', - field: 'scoreScaling' - } - ] - }; + // Validate scoreScaling if provided + if (evaluator.scoreScaling !== undefined) { + if ( + typeof evaluator.scoreScaling !== 'number' || + isNaN(evaluator.scoreScaling) || + !isFinite(evaluator.scoreScaling) || + evaluator.scoreScaling <= 0 || + evaluator.scoreScaling > 10000 + ) { + return { + isValid: false, + errors: [ + { + code: EvaluationErrEnum.evalEvaluatorInvalidScoreScaling, + message: 'Evaluator scoreScaling invalid', + field: 'scoreScaling' + } + ] + }; + } } } } - } - return { isValid: true, errors: [] }; + return { isValid: true, errors: [] }; + } } export async function validateEvaluationParamsForCreate( diff --git a/projects/app/src/pages/api/core/evaluation/dataset/collection/delete.ts b/projects/app/src/pages/api/core/evaluation/dataset/collection/delete.ts index 9062f7366a01..c5d4df15026c 100644 --- a/projects/app/src/pages/api/core/evaluation/dataset/collection/delete.ts +++ b/projects/app/src/pages/api/core/evaluation/dataset/collection/delete.ts @@ -71,7 +71,7 @@ async function handler( addLog.info('Cleaning up quality assessment queue tasks', { collectionId }); try { const datasetDataIds = await MongoEvalDatasetData.find( - { datasetId: collectionId }, + { evalDatasetCollectionId: collectionId }, { _id: 1 } ).session(session); @@ -111,7 +111,7 @@ async function handler( } const deletedDataResult = await MongoEvalDatasetData.deleteMany( - { datasetId: collectionId }, + { evalDatasetCollectionId: collectionId }, { session } ); diff --git a/projects/app/src/pages/api/core/evaluation/dataset/data/create.ts b/projects/app/src/pages/api/core/evaluation/dataset/data/create.ts index d2f1b69f9052..899ff69189c5 100644 --- a/projects/app/src/pages/api/core/evaluation/dataset/data/create.ts +++ b/projects/app/src/pages/api/core/evaluation/dataset/data/create.ts @@ -150,7 +150,7 @@ async function handler( { teamId, tmbId, - datasetId: collectionId, + evalDatasetCollectionId: collectionId, [EvalDatasetDataKeyEnum.UserInput]: userInput.trim(), [EvalDatasetDataKeyEnum.ActualOutput]: (typeof actualOutput === 'string' ? actualOutput.trim() : '') || '', diff --git a/projects/app/src/pages/api/core/evaluation/dataset/data/delete.ts b/projects/app/src/pages/api/core/evaluation/dataset/data/delete.ts index f5c1fea3f4b3..3bdd7f6e8006 100644 --- a/projects/app/src/pages/api/core/evaluation/dataset/data/delete.ts +++ b/projects/app/src/pages/api/core/evaluation/dataset/data/delete.ts @@ -43,7 +43,7 @@ async function handler( } const collection = await MongoEvalDatasetCollection.findOne({ - _id: existingData.datasetId, + _id: existingData.evalDatasetCollectionId, teamId }).session(session); @@ -84,7 +84,7 @@ async function handler( addLog.info('Evaluation dataset data deleted successfully', { dataId, - datasetId: collectionId, + evalDatasetCollectionId: collectionId, teamId }); }); diff --git a/projects/app/src/pages/api/core/evaluation/dataset/data/detail.ts b/projects/app/src/pages/api/core/evaluation/dataset/data/detail.ts index 2ea2c34b7569..8edd6e03ffc1 100644 --- a/projects/app/src/pages/api/core/evaluation/dataset/data/detail.ts +++ b/projects/app/src/pages/api/core/evaluation/dataset/data/detail.ts @@ -34,7 +34,7 @@ async function handler( _id: String(dataItem._id), teamId: String(dataItem.teamId), tmbId: String(dataItem.tmbId), - datasetId: String(dataItem.datasetId), + evalDatasetCollectionId: String(dataItem.evalDatasetCollectionId), [EvalDatasetDataKeyEnum.UserInput]: dataItem.userInput, [EvalDatasetDataKeyEnum.ActualOutput]: dataItem.actualOutput || '', [EvalDatasetDataKeyEnum.ExpectedOutput]: dataItem.expectedOutput, diff --git a/projects/app/src/pages/api/core/evaluation/dataset/data/fileId.ts b/projects/app/src/pages/api/core/evaluation/dataset/data/fileId.ts index 5ad0a53b395d..262fc37cb5c4 100644 --- a/projects/app/src/pages/api/core/evaluation/dataset/data/fileId.ts +++ b/projects/app/src/pages/api/core/evaluation/dataset/data/fileId.ts @@ -285,7 +285,7 @@ async function handler( return { teamId, tmbId, - datasetId: collectionId, + evalDatasetCollectionId: collectionId, [EvalDatasetDataKeyEnum.UserInput]: row.user_input, [EvalDatasetDataKeyEnum.ExpectedOutput]: row.expected_output, [EvalDatasetDataKeyEnum.ActualOutput]: row.actual_output || '', diff --git a/projects/app/src/pages/api/core/evaluation/dataset/data/list.ts b/projects/app/src/pages/api/core/evaluation/dataset/data/list.ts index 425d5b89bb2f..7223e95ac65d 100644 --- a/projects/app/src/pages/api/core/evaluation/dataset/data/list.ts +++ b/projects/app/src/pages/api/core/evaluation/dataset/data/list.ts @@ -41,7 +41,7 @@ async function handler( }); const match: Record = { - datasetId: new Types.ObjectId(collectionId) + evalDatasetCollectionId: new Types.ObjectId(collectionId) }; if (searchKey && typeof searchKey === 'string' && searchKey.trim().length > 0) { @@ -72,7 +72,7 @@ async function handler( [EvalDatasetDataKeyEnum.ExpectedOutput]: item.expectedOutput, [EvalDatasetDataKeyEnum.Context]: item.context || [], [EvalDatasetDataKeyEnum.RetrievalContext]: item.retrievalContext || [], - [EvalDatasetDataKeyEnum.Metadata]: item.metadata || {}, + metadata: item.metadata || {}, createFrom: item.createFrom, createTime: item.createTime, updateTime: item.updateTime diff --git a/test/cases/pages/api/core/evaluation/dataset/collection/delete.test.ts b/test/cases/pages/api/core/evaluation/dataset/collection/delete.test.ts index a173a95a1553..e662ccdadd2e 100644 --- a/test/cases/pages/api/core/evaluation/dataset/collection/delete.test.ts +++ b/test/cases/pages/api/core/evaluation/dataset/collection/delete.test.ts @@ -83,7 +83,7 @@ describe('EvalDatasetCollection Delete API', () => { mockAuthEvaluationDatasetWrite.mockResolvedValue({ teamId: validTeamId, tmbId: validTmbId, - datasetId: validCollectionId + evalDatasetCollectionId: validCollectionId }); mockMongoSessionRun.mockImplementation(async (callback) => { @@ -291,7 +291,7 @@ describe('EvalDatasetCollection Delete API', () => { await handler_test(req as any); expect(mockMongoEvalDatasetData.find).toHaveBeenCalledWith( - { datasetId: validCollectionId }, + { evalDatasetCollectionId: validCollectionId }, { _id: 1 } ); expect(mockRemoveEvalDatasetDataQualityJobsRobust).toHaveBeenCalledWith( @@ -395,7 +395,7 @@ describe('EvalDatasetCollection Delete API', () => { await handler_test(req as any); expect(mockMongoEvalDatasetData.deleteMany).toHaveBeenCalledWith( - { datasetId: validCollectionId }, + { evalDatasetCollectionId: validCollectionId }, { session: mockSession } ); expect(mockAddLog.info).toHaveBeenCalledWith('Evaluation dataset data deleted', { @@ -468,7 +468,7 @@ describe('EvalDatasetCollection Delete API', () => { expect(mockMongoEvalDatasetCollection.findOne().session).toHaveBeenCalledWith(mockSession); expect(mockMongoEvalDatasetData.find().session).toHaveBeenCalledWith(mockSession); expect(mockMongoEvalDatasetData.deleteMany).toHaveBeenCalledWith( - { datasetId: validCollectionId }, + { evalDatasetCollectionId: validCollectionId }, { session: mockSession } ); expect(mockMongoEvalDatasetCollection.deleteOne).toHaveBeenCalledWith( diff --git a/test/cases/pages/api/core/evaluation/dataset/data/create.test.ts b/test/cases/pages/api/core/evaluation/dataset/data/create.test.ts index ecc9e4f9ca7d..f11e5641f2d3 100644 --- a/test/cases/pages/api/core/evaluation/dataset/data/create.test.ts +++ b/test/cases/pages/api/core/evaluation/dataset/data/create.test.ts @@ -300,7 +300,7 @@ describe('EvalDatasetData Create API', () => { const createExpectedDataObject = (overrides = {}) => ({ teamId: validTeamId, tmbId: validTmbId, - datasetId: validCollectionId, + evalDatasetCollectionId: validCollectionId, [EvalDatasetDataKeyEnum.UserInput]: 'Test input', [EvalDatasetDataKeyEnum.ActualOutput]: '', [EvalDatasetDataKeyEnum.ExpectedOutput]: 'Test output', diff --git a/test/cases/pages/api/core/evaluation/dataset/data/delete.test.ts b/test/cases/pages/api/core/evaluation/dataset/data/delete.test.ts index 9bc3e58a70d7..af786adb1aa7 100644 --- a/test/cases/pages/api/core/evaluation/dataset/data/delete.test.ts +++ b/test/cases/pages/api/core/evaluation/dataset/data/delete.test.ts @@ -74,7 +74,7 @@ describe('EvalDatasetData Delete API', () => { const mockDataDocument = { _id: CONSTANTS.validDataId, - datasetId: CONSTANTS.validCollectionId, + evalDatasetCollectionId: CONSTANTS.validCollectionId, userInput: 'test input', expectedOutput: 'test output' }; diff --git a/test/cases/pages/api/core/evaluation/dataset/data/fileId.test.ts b/test/cases/pages/api/core/evaluation/dataset/data/fileId.test.ts index 48af6b3b879c..efa2bd858e0f 100644 --- a/test/cases/pages/api/core/evaluation/dataset/data/fileId.test.ts +++ b/test/cases/pages/api/core/evaluation/dataset/data/fileId.test.ts @@ -382,7 +382,7 @@ describe('EvalDatasetData FileId Import API', () => { expect.objectContaining({ teamId: validTeamId, tmbId: validTmbId, - datasetId: validCollectionId, + evalDatasetCollectionId: validCollectionId, [EvalDatasetDataKeyEnum.UserInput]: 'What is AI?', [EvalDatasetDataKeyEnum.ExpectedOutput]: 'Artificial Intelligence', [EvalDatasetDataKeyEnum.ActualOutput]: 'AI is...', @@ -394,7 +394,7 @@ describe('EvalDatasetData FileId Import API', () => { expect.objectContaining({ teamId: validTeamId, tmbId: validTmbId, - datasetId: validCollectionId, + evalDatasetCollectionId: validCollectionId, [EvalDatasetDataKeyEnum.UserInput]: 'Define ML', [EvalDatasetDataKeyEnum.ExpectedOutput]: 'Machine Learning', [EvalDatasetDataKeyEnum.ActualOutput]: '', diff --git a/test/cases/pages/api/core/evaluation/dataset/data/list.test.ts b/test/cases/pages/api/core/evaluation/dataset/data/list.test.ts index 5374afabe3fd..721e9f08f948 100644 --- a/test/cases/pages/api/core/evaluation/dataset/data/list.test.ts +++ b/test/cases/pages/api/core/evaluation/dataset/data/list.test.ts @@ -160,7 +160,7 @@ describe('EvalDatasetData List API', () => { expect(mockMongoEvalDatasetData.aggregate).toHaveBeenCalledWith( expect.arrayContaining([ - { $match: { datasetId: new Types.ObjectId(validCollectionId) } }, + { $match: { evalDatasetCollectionId: new Types.ObjectId(validCollectionId) } }, { $sort: { createTime: -1 } }, { $skip: expectedSkip }, { $limit: expectedLimit } @@ -187,7 +187,9 @@ describe('EvalDatasetData List API', () => { await handler_test(req as any); expect(mockMongoEvalDatasetData.aggregate).toHaveBeenCalledWith( - expect.arrayContaining([{ $match: { datasetId: new Types.ObjectId(validCollectionId) } }]) + expect.arrayContaining([ + { $match: { evalDatasetCollectionId: new Types.ObjectId(validCollectionId) } } + ]) ); }); @@ -202,7 +204,7 @@ describe('EvalDatasetData List API', () => { await handler_test(req as any); const expectedMatch = { - datasetId: new Types.ObjectId(validCollectionId), + evalDatasetCollectionId: new Types.ObjectId(validCollectionId), $or: [ { [EvalDatasetDataKeyEnum.UserInput]: { $regex: new RegExp(expected, 'i') } }, { [EvalDatasetDataKeyEnum.ExpectedOutput]: { $regex: new RegExp(expected, 'i') } }, @@ -225,7 +227,7 @@ describe('EvalDatasetData List API', () => { await handler_test(req as any); const expectedMatch = { - datasetId: new Types.ObjectId(validCollectionId), + evalDatasetCollectionId: new Types.ObjectId(validCollectionId), $or: [ { [EvalDatasetDataKeyEnum.UserInput]: { $regex: new RegExp('What\\[\\?\\]', 'i') } }, { [EvalDatasetDataKeyEnum.ExpectedOutput]: { $regex: new RegExp('What\\[\\?\\]', 'i') } }, @@ -252,7 +254,9 @@ describe('EvalDatasetData List API', () => { await handler_test(req as any); expect(mockMongoEvalDatasetData.aggregate).toHaveBeenCalledWith( - expect.arrayContaining([{ $match: { datasetId: new Types.ObjectId(validCollectionId) } }]) + expect.arrayContaining([ + { $match: { evalDatasetCollectionId: new Types.ObjectId(validCollectionId) } } + ]) ); }); @@ -284,7 +288,7 @@ describe('EvalDatasetData List API', () => { await handler_test(req as any); const expectedMatch = { - datasetId: new Types.ObjectId(validCollectionId), + evalDatasetCollectionId: new Types.ObjectId(validCollectionId), 'metadata.qualityStatus': status }; @@ -327,7 +331,7 @@ describe('EvalDatasetData List API', () => { await handler_test(req as any); const expectedMatch = { - datasetId: new Types.ObjectId(validCollectionId), + evalDatasetCollectionId: new Types.ObjectId(validCollectionId), 'metadata.qualityStatus': EvalDatasetDataQualityStatusEnum.highQuality, $or: [ { [EvalDatasetDataKeyEnum.UserInput]: { $regex: new RegExp('AI', 'i') } }, @@ -357,7 +361,7 @@ describe('EvalDatasetData List API', () => { await handler_test(req as any); const expectedMatch = { - datasetId: new Types.ObjectId(validCollectionId), + evalDatasetCollectionId: new Types.ObjectId(validCollectionId), 'metadata.qualityStatus': EvalDatasetDataQualityStatusEnum.needsOptimization }; @@ -382,7 +386,7 @@ describe('EvalDatasetData List API', () => { await handler_test(req as any); const expectedMatch = { - datasetId: new Types.ObjectId(validCollectionId), + evalDatasetCollectionId: new Types.ObjectId(validCollectionId), $or: [ { [EvalDatasetDataKeyEnum.UserInput]: { $regex: new RegExp('machine learning', 'i') } }, { @@ -416,7 +420,7 @@ describe('EvalDatasetData List API', () => { await handler_test(req1 as any); const expectedMatch1 = { - datasetId: new Types.ObjectId(validCollectionId), + evalDatasetCollectionId: new Types.ObjectId(validCollectionId), 'metadata.qualityStatus': EvalDatasetDataQualityStatusEnum.highQuality, $or: expect.arrayContaining([ { [EvalDatasetDataKeyEnum.UserInput]: { $regex: expect.any(RegExp) } }, @@ -447,7 +451,7 @@ describe('EvalDatasetData List API', () => { await handler_test(req2 as any); const expectedMatch2 = { - datasetId: new Types.ObjectId(validCollectionId), + evalDatasetCollectionId: new Types.ObjectId(validCollectionId), 'metadata.qualityStatus': EvalDatasetDataQualityStatusEnum.needsOptimization, $or: expect.arrayContaining([ { [EvalDatasetDataKeyEnum.UserInput]: { $regex: expect.any(RegExp) } }, @@ -471,7 +475,7 @@ describe('EvalDatasetData List API', () => { await handler_test(req as any); expect(mockMongoEvalDatasetData.aggregate).toHaveBeenCalledWith([ - { $match: { datasetId: new Types.ObjectId(validCollectionId) } }, + { $match: { evalDatasetCollectionId: new Types.ObjectId(validCollectionId) } }, { $sort: { createTime: -1 } }, { $skip: 0 }, { $limit: 10 }, @@ -503,7 +507,7 @@ describe('EvalDatasetData List API', () => { expect.arrayContaining([ { $match: { - datasetId: new Types.ObjectId(validCollectionId), + evalDatasetCollectionId: new Types.ObjectId(validCollectionId), $or: [ { [EvalDatasetDataKeyEnum.UserInput]: { $regex: new RegExp('test', 'i') } }, { [EvalDatasetDataKeyEnum.ExpectedOutput]: { $regex: new RegExp('test', 'i') } }, @@ -531,7 +535,7 @@ describe('EvalDatasetData List API', () => { expect.arrayContaining([ { $match: { - datasetId: new Types.ObjectId(validCollectionId), + evalDatasetCollectionId: new Types.ObjectId(validCollectionId), 'metadata.qualityStatus': EvalDatasetDataQualityStatusEnum.highQuality } } @@ -556,7 +560,7 @@ describe('EvalDatasetData List API', () => { expect.arrayContaining([ { $match: { - datasetId: new Types.ObjectId(validCollectionId), + evalDatasetCollectionId: new Types.ObjectId(validCollectionId), 'metadata.qualityStatus': EvalDatasetDataQualityStatusEnum.needsOptimization, $or: [ { [EvalDatasetDataKeyEnum.UserInput]: { $regex: new RegExp('AI', 'i') } }, @@ -866,12 +870,29 @@ describe('EvalDatasetData List API', () => { await handler_test(req as any); - expect(mockMongoEvalDatasetData.aggregate).toHaveBeenCalledWith( - expect.arrayContaining([{ $match: { datasetId: new Types.ObjectId(validCollectionId) } }]) - ); + expect(mockMongoEvalDatasetData.aggregate).toHaveBeenCalledWith([ + { $match: { evalDatasetCollectionId: new Types.ObjectId(validCollectionId) } }, + { $sort: { createTime: -1 } }, + { $skip: 0 }, + { $limit: 10 }, + { + $project: { + _id: 1, + [EvalDatasetDataKeyEnum.UserInput]: 1, + [EvalDatasetDataKeyEnum.ActualOutput]: 1, + [EvalDatasetDataKeyEnum.ExpectedOutput]: 1, + [EvalDatasetDataKeyEnum.Context]: 1, + [EvalDatasetDataKeyEnum.RetrievalContext]: 1, + metadata: 1, + createFrom: 1, + createTime: 1, + updateTime: 1 + } + } + ]); expect(mockMongoEvalDatasetData.countDocuments).toHaveBeenCalledWith({ - datasetId: new Types.ObjectId(validCollectionId) + evalDatasetCollectionId: new Types.ObjectId(validCollectionId) }); }); @@ -883,7 +904,7 @@ describe('EvalDatasetData List API', () => { await handler_test(req as any); const expectedMatch = { - datasetId: new Types.ObjectId(validCollectionId), + evalDatasetCollectionId: new Types.ObjectId(validCollectionId), $or: [ { [EvalDatasetDataKeyEnum.UserInput]: { $regex: new RegExp('test', 'i') } }, { [EvalDatasetDataKeyEnum.ExpectedOutput]: { $regex: new RegExp('test', 'i') } }, @@ -911,7 +932,7 @@ describe('EvalDatasetData List API', () => { await handler_test(req as any); const expectedMatch = { - datasetId: new Types.ObjectId(validCollectionId), + evalDatasetCollectionId: new Types.ObjectId(validCollectionId), 'metadata.qualityStatus': EvalDatasetDataQualityStatusEnum.highQuality }; @@ -936,7 +957,7 @@ describe('EvalDatasetData List API', () => { await handler_test(req as any); const expectedMatch = { - datasetId: new Types.ObjectId(validCollectionId), + evalDatasetCollectionId: new Types.ObjectId(validCollectionId), 'metadata.qualityStatus': EvalDatasetDataQualityStatusEnum.completed, $or: [ { [EvalDatasetDataKeyEnum.UserInput]: { $regex: new RegExp('AI model', 'i') } }, @@ -995,7 +1016,7 @@ describe('EvalDatasetData List API', () => { expect.arrayContaining([ { $match: { - datasetId: new Types.ObjectId(validCollectionId), + evalDatasetCollectionId: new Types.ObjectId(validCollectionId), $or: expect.arrayContaining([ { [EvalDatasetDataKeyEnum.UserInput]: { $regex: expect.any(RegExp) } }, { [EvalDatasetDataKeyEnum.ExpectedOutput]: { $regex: expect.any(RegExp) } }, @@ -1024,7 +1045,7 @@ describe('EvalDatasetData List API', () => { expect.arrayContaining([ { $match: { - datasetId: new Types.ObjectId(validCollectionId), + evalDatasetCollectionId: new Types.ObjectId(validCollectionId), $or: expect.arrayContaining([ { [EvalDatasetDataKeyEnum.UserInput]: { $regex: expect.any(RegExp) } } ]) From 32f40191d6828a3c4b8a6e9c476e81f75605beb8 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 15 Sep 2025 22:18:05 +0800 Subject: [PATCH 04/84] refactor: remove evalDatasetSmartGenerate and optimize smart generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove evalDatasetSmartGenerate job queue from QueueNames enum - Delete smartGenerateMq.ts and smartGenerateProcessor.ts files - Refactor smartGenerate API to process data directly in API layer: • Complete Q&A pairs are inserted directly to database • Q-only data is queued to evalDatasetDataSynthesize for AI processing - Fix schema field references: datasetId → evalDatasetCollectionId - Update queue cleanup in collection delete API - Fix test mocks to use correct schema field names - Improve performance by eliminating unnecessary queue overhead for complete data --- packages/service/common/bullmq/index.ts | 1 - .../dataset/dataSynthesizeProcessor.ts | 2 +- .../evaluation/dataset/smartGenerateMq.ts | 91 -------- .../dataset/smartGenerateProcessor.ts | 198 ------------------ packages/service/core/evaluation/index.ts | 2 - .../evaluation/dataset/collection/delete.ts | 16 -- .../dataset/data/qualityAssessment.ts | 2 +- .../evaluation/dataset/data/smartGenerate.ts | 159 +++++++++++++- .../core/evaluation/dataset/data/update.ts | 2 +- .../dataset/collection/delete.test.ts | 56 ----- .../dataset/data/qualityAssessment.test.ts | 2 +- 11 files changed, 153 insertions(+), 378 deletions(-) delete mode 100644 packages/service/core/evaluation/dataset/smartGenerateMq.ts delete mode 100644 packages/service/core/evaluation/dataset/smartGenerateProcessor.ts diff --git a/packages/service/common/bullmq/index.ts b/packages/service/common/bullmq/index.ts index c41f62fb774e..4ee7a0183d7a 100644 --- a/packages/service/common/bullmq/index.ts +++ b/packages/service/common/bullmq/index.ts @@ -21,7 +21,6 @@ const defaultWorkerOpts: Omit = { export enum QueueNames { datasetSync = 'datasetSync', evalDatasetDataQuality = 'evalDatasetDataQuality', - evalDatasetSmartGenerate = 'evalDatasetSmartGenerate', evalDatasetDataSynthesize = 'evalDatasetDataSynthesize', evalTask = 'evalTask', evalTaskItem = 'evalTaskitem', diff --git a/packages/service/core/evaluation/dataset/dataSynthesizeProcessor.ts b/packages/service/core/evaluation/dataset/dataSynthesizeProcessor.ts index 722e2db8bff9..4afafa45e8bd 100644 --- a/packages/service/core/evaluation/dataset/dataSynthesizeProcessor.ts +++ b/packages/service/core/evaluation/dataset/dataSynthesizeProcessor.ts @@ -69,7 +69,7 @@ async function processor(job: Job) { const evalData: Partial = { teamId: evalDatasetCollection.teamId, tmbId: evalDatasetCollection.tmbId, - datasetId: evalDatasetCollectionId, + evalDatasetCollectionId: evalDatasetCollectionId, [EvalDatasetDataKeyEnum.UserInput]: synthesisResult.data?.qaPair.question, [EvalDatasetDataKeyEnum.ExpectedOutput]: synthesisResult.data?.qaPair.answer, [EvalDatasetDataKeyEnum.ActualOutput]: '', diff --git a/packages/service/core/evaluation/dataset/smartGenerateMq.ts b/packages/service/core/evaluation/dataset/smartGenerateMq.ts deleted file mode 100644 index 9eacfc789f86..000000000000 --- a/packages/service/core/evaluation/dataset/smartGenerateMq.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { getQueue, getWorker, QueueNames } from '../../../common/bullmq'; -import { type Processor } from 'bullmq'; -import { addLog } from '../../../common/system/log'; -import { - createJobCleaner, - type JobCleanupResult, - type JobCleanupOptions -} from '../utils/jobCleanup'; - -export type EvalDatasetSmartGenerateData = { - datasetCollectionIds: string[]; - count?: number; - keywords?: string[]; - intelligentGenerationModel: string; - evalDatasetCollectionId: string; -}; - -export const evalDatasetSmartGenerateQueue = getQueue( - QueueNames.evalDatasetSmartGenerate, - { - defaultJobOptions: { - attempts: 3, - backoff: { - type: 'exponential', - delay: 2000 - } - } - } -); - -const concurrency = process.env.EVAL_DATASET_SMART_GENERATE_CONCURRENCY - ? Number(process.env.EVAL_DATASET_SMART_GENERATE_CONCURRENCY) - : 2; - -export const getEvalDatasetSmartGenerateWorker = ( - processor: Processor -) => { - return getWorker(QueueNames.evalDatasetSmartGenerate, processor, { - removeOnFail: { - count: 1000 // Keep last 1000 failed jobs for debugging - }, - concurrency: concurrency - }); -}; - -export const addEvalDatasetSmartGenerateJob = (data: EvalDatasetSmartGenerateData) => { - const jobId = `smartgen-${data.evalDatasetCollectionId}-${Date.now()}`; - - return evalDatasetSmartGenerateQueue.add(jobId, data, { - deduplication: { id: jobId } - }); -}; - -export const checkEvalDatasetSmartGenerateJobActive = async ( - evalDatasetCollectionId: string -): Promise => { - try { - const jobs = await evalDatasetSmartGenerateQueue.getJobs(['waiting', 'active', 'delayed']); - return jobs.some((job) => job.data.evalDatasetCollectionId === evalDatasetCollectionId); - } catch (error) { - addLog.error('Failed to check eval dataset smart generate job status', { - evalDatasetCollectionId: evalDatasetCollectionId, - error - }); - return false; - } -}; - -export const removeEvalDatasetSmartGenerateJobsRobust = async ( - evalDatasetCollectionIds: string[], - options?: JobCleanupOptions -): Promise => { - const cleaner = createJobCleaner(options); - - const filterFn = (job: any) => { - return evalDatasetCollectionIds.includes(String(job.data?.evalDatasetCollectionId)); - }; - - const result = await cleaner.cleanAllJobsByFilter( - evalDatasetSmartGenerateQueue, - filterFn, - QueueNames.evalDatasetSmartGenerate - ); - - addLog.info('Evaluation DatasetData Smart generate jobs cleanup completed', { - evalDatasetCollectionIds: evalDatasetCollectionIds.length, - result - }); - - return result; -}; diff --git a/packages/service/core/evaluation/dataset/smartGenerateProcessor.ts b/packages/service/core/evaluation/dataset/smartGenerateProcessor.ts deleted file mode 100644 index d4597cb346e2..000000000000 --- a/packages/service/core/evaluation/dataset/smartGenerateProcessor.ts +++ /dev/null @@ -1,198 +0,0 @@ -import type { Job } from 'bullmq'; -import type { HydratedDocument } from 'mongoose'; -import { Types } from '../../../common/mongo'; -import { readFromSecondary } from '../../../common/mongo/utils'; -import { addLog } from '../../../common/system/log'; -import { MongoEvalDatasetCollection } from './evalDatasetCollectionSchema'; -import { MongoEvalDatasetData } from './evalDatasetDataSchema'; -import { MongoDatasetData } from '../../dataset/data/schema'; -import { - EvalDatasetDataCreateFromEnum, - EvalDatasetDataKeyEnum, - EvalDatasetDataQualityStatusEnum -} from '@fastgpt/global/core/evaluation/dataset/constants'; -import type { EvalDatasetDataSchemaType } from '@fastgpt/global/core/evaluation/dataset/type'; -import { - type EvalDatasetSmartGenerateData, - getEvalDatasetSmartGenerateWorker -} from './smartGenerateMq'; -import { addEvalDatasetDataSynthesizeJob } from './dataSynthesizeMq'; -import { checkTeamAIPoints } from '../../../support/permission/teamLimit'; -import { addAuditLog } from '../../../support/user/audit/util'; -import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants'; - -async function processor(job: Job) { - const { datasetCollectionIds, count, intelligentGenerationModel, evalDatasetCollectionId } = - job.data; - - if (!global.llmModelMap.has(intelligentGenerationModel)) { - const errorMsg = `Invalid intelligent generation model: ${intelligentGenerationModel}`; - addLog.error('Eval dataset smart generation failed - invalid model', { - evalDatasetCollectionId, - intelligentGenerationModel - }); - throw new Error(errorMsg); - } - - try { - addLog.info('Starting eval dataset smart generation', { - evalDatasetCollectionId, - datasetCollectionIds, - count, - intelligentGenerationModel - }); - - const sampleSize = Number(count); - if (!Number.isInteger(sampleSize) || sampleSize <= 0) { - throw new Error(`Invalid count parameter: ${count}. Must be a positive integer.`); - } - - const evalDatasetCollection = - await MongoEvalDatasetCollection.findById(evalDatasetCollectionId); - if (!evalDatasetCollection) { - throw new Error(`Eval dataset collection not found: ${evalDatasetCollectionId}`); - } - - await checkTeamAIPoints(evalDatasetCollection.teamId); - - const match = { - teamId: new Types.ObjectId(evalDatasetCollection.teamId), - collectionId: { $in: datasetCollectionIds.map((id) => new Types.ObjectId(id)) } - }; - - const sampleData = await MongoDatasetData.aggregate( - [ - { - $match: match - }, - { - $sample: { size: sampleSize } - }, - { - $project: { - q: 1, - a: 1, - datasetId: 1, - collectionId: 1 - } - } - ], - { - ...readFromSecondary - } - ); - - if (sampleData.length === 0) { - throw new Error('No data found in selected dataset collections'); - } - - addLog.info('Retrieved sample data for generation', { - evalDatasetCollectionId: evalDatasetCollectionId, - sampleCount: sampleData.length - }); - - const generateData: Array> = []; - const synthesisData: Array<{ - dataId: string; - intelligentGenerationModel: string; - evalDatasetCollectionId: string; - }> = []; - - for (const sample of sampleData) { - if (sample.q && sample.a) { - // Direct QA pair - can be used immediately - const evalData: Partial = { - teamId: evalDatasetCollection.teamId, - tmbId: evalDatasetCollection.tmbId, - evalDatasetCollectionId: evalDatasetCollectionId, - [EvalDatasetDataKeyEnum.UserInput]: sample.q, - [EvalDatasetDataKeyEnum.ExpectedOutput]: sample.a, - [EvalDatasetDataKeyEnum.ActualOutput]: '', - [EvalDatasetDataKeyEnum.Context]: [], - [EvalDatasetDataKeyEnum.RetrievalContext]: [], - metadata: { - sourceDataId: sample._id, - sourceDatasetId: sample.datasetId, - sourceCollectionId: sample.collectionId, - qualityStatus: EvalDatasetDataQualityStatusEnum.unevaluated, - generatedAt: new Date(), - intelligentGenerationModel - }, - createFrom: EvalDatasetDataCreateFromEnum.intelligentGeneration - }; - generateData.push(evalData); - } else if (sample.q && sample.a === '') { - // Only Q - add to synthesis data list (not saved to mongo here) - synthesisData.push({ - dataId: sample._id.toString(), - intelligentGenerationModel, - evalDatasetCollectionId - }); - } - } - - // Bulk insert complete evaluation dataset data - let insertedRecords: HydratedDocument[] = []; - if (generateData.length > 0) { - insertedRecords = await MongoEvalDatasetData.insertMany(generateData, { - ordered: false - }); - - addLog.info('Inserted complete eval dataset data', { - evalDatasetCollectionId: evalDatasetCollectionId, - insertedCount: insertedRecords.length - }); - } - - // Queue synthesis jobs for data that needs processing (synthesisData) - const synthesizeJobs = []; - for (const synthData of synthesisData) { - const synthesizeJob = await addEvalDatasetDataSynthesizeJob(synthData); - synthesizeJobs.push(synthesizeJob); - } - - if (synthesizeJobs.length > 0) { - addLog.info('Queued synthesis jobs', { - evalDatasetCollectionId: evalDatasetCollectionId, - synthesizeJobsCount: synthesizeJobs.length - }); - } - - (async () => { - addAuditLog({ - teamId: evalDatasetCollection.teamId, - tmbId: evalDatasetCollection.tmbId, - event: AuditEventEnum.SMART_GENERATE_EVALUATION_DATA, - params: { - collectionName: evalDatasetCollection.name - } - }); - })(); - - addLog.info('Completed eval dataset smart generation', { - evalDatasetCollectionId: evalDatasetCollectionId, - generateDataCount: insertedRecords.length, - synthesisDataCount: synthesisData.length, - synthesizeJobsCount: synthesizeJobs.length - }); - - return { - success: true, - generateDataCount: insertedRecords.length, - synthesisDataCount: synthesisData.length, - synthesizeJobsCount: synthesizeJobs.length - }; - } catch (error) { - addLog.error('Failed to process eval dataset smart generation', { - evalDatasetCollectionId: evalDatasetCollectionId, - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined - }); - throw error; - } -} - -// Initialize worker -export const initEvalDatasetSmartGenerateWorker = () => { - return getEvalDatasetSmartGenerateWorker(processor); -}; diff --git a/packages/service/core/evaluation/index.ts b/packages/service/core/evaluation/index.ts index e5ecc41f0089..c2b1ad6d24c1 100644 --- a/packages/service/core/evaluation/index.ts +++ b/packages/service/core/evaluation/index.ts @@ -1,7 +1,6 @@ import { addLog } from '../../common/system/log'; import { initEvalDatasetDataQualityWorker } from './dataset/dataQualityProcessor'; import { initEvalDatasetDataSynthesizeWorker } from './dataset/dataSynthesizeProcessor'; -import { initEvalDatasetSmartGenerateWorker } from './dataset/smartGenerateProcessor'; import { initEvalTaskWorker, initEvalTaskItemWorker } from './task/processor'; // Initialize evaluation workers @@ -13,6 +12,5 @@ export const initEvaluationWorkers = () => { initEvalTaskItemWorker(); initEvalDatasetDataQualityWorker(); - initEvalDatasetSmartGenerateWorker(); initEvalDatasetDataSynthesizeWorker(); }; diff --git a/projects/app/src/pages/api/core/evaluation/dataset/collection/delete.ts b/projects/app/src/pages/api/core/evaluation/dataset/collection/delete.ts index c5d4df15026c..a67781cb7d59 100644 --- a/projects/app/src/pages/api/core/evaluation/dataset/collection/delete.ts +++ b/projects/app/src/pages/api/core/evaluation/dataset/collection/delete.ts @@ -4,7 +4,6 @@ import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; import { MongoEvalDatasetCollection } from '@fastgpt/service/core/evaluation/dataset/evalDatasetCollectionSchema'; import { MongoEvalDatasetData } from '@fastgpt/service/core/evaluation/dataset/evalDatasetDataSchema'; import type { deleteEvalDatasetCollectionQuery } from '@fastgpt/global/core/evaluation/dataset/api'; -import { removeEvalDatasetSmartGenerateJobsRobust } from '@fastgpt/service/core/evaluation/dataset/smartGenerateMq'; import { removeEvalDatasetDataQualityJobsRobust } from '@fastgpt/service/core/evaluation/dataset/dataQualityMq'; import { removeEvalDatasetDataSynthesizeJobsRobust } from '@fastgpt/service/core/evaluation/dataset/dataSynthesizeMq'; import { addLog } from '@fastgpt/service/common/system/log'; @@ -53,21 +52,6 @@ async function handler( collectionName: collection.name }); - addLog.info('Cleaning up smart generation queue tasks', { collectionId }); - try { - await removeEvalDatasetSmartGenerateJobsRobust([collectionId], { - forceCleanActiveJobs: true, - retryAttempts: 3, - retryDelay: 200 - }); - addLog.info('Smart generation queue cleanup completed', { collectionId }); - } catch (error) { - addLog.error('Failed to clean up smart generation queue', { - collectionId, - error - }); - } - addLog.info('Cleaning up quality assessment queue tasks', { collectionId }); try { const datasetDataIds = await MongoEvalDatasetData.find( diff --git a/projects/app/src/pages/api/core/evaluation/dataset/data/qualityAssessment.ts b/projects/app/src/pages/api/core/evaluation/dataset/data/qualityAssessment.ts index 7a3dbfa66ebb..868af718d141 100644 --- a/projects/app/src/pages/api/core/evaluation/dataset/data/qualityAssessment.ts +++ b/projects/app/src/pages/api/core/evaluation/dataset/data/qualityAssessment.ts @@ -48,7 +48,7 @@ async function handler( } const collection = await MongoEvalDatasetCollection.findOne({ - _id: datasetData.datasetId, + _id: datasetData.evalDatasetCollectionId, teamId }); diff --git a/projects/app/src/pages/api/core/evaluation/dataset/data/smartGenerate.ts b/projects/app/src/pages/api/core/evaluation/dataset/data/smartGenerate.ts index d87c41b4b89e..730172b6940e 100644 --- a/projects/app/src/pages/api/core/evaluation/dataset/data/smartGenerate.ts +++ b/projects/app/src/pages/api/core/evaluation/dataset/data/smartGenerate.ts @@ -1,20 +1,33 @@ import type { ApiRequestProps } from '@fastgpt/service/type/next'; import { NextAPI } from '@/service/middleware/entry'; import { MongoEvalDatasetCollection } from '@fastgpt/service/core/evaluation/dataset/evalDatasetCollectionSchema'; +import { MongoEvalDatasetData } from '@fastgpt/service/core/evaluation/dataset/evalDatasetDataSchema'; import { MongoDatasetCollection } from '@fastgpt/service/core/dataset/collection/schema'; import { MongoDatasetData } from '@fastgpt/service/core/dataset/data/schema'; import type { smartGenerateEvalDatasetBody } from '@fastgpt/global/core/evaluation/dataset/api'; -import { addEvalDatasetSmartGenerateJob } from '@fastgpt/service/core/evaluation/dataset/smartGenerateMq'; +import { addEvalDatasetDataSynthesizeJob } from '@fastgpt/service/core/evaluation/dataset/dataSynthesizeMq'; import { addAuditLog } from '@fastgpt/service/support/user/audit/util'; import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants'; import { authEvaluationDatasetGenFromKnowledgeBase } from '@fastgpt/service/core/evaluation/common'; import { checkTeamAIPoints } from '@fastgpt/service/support/permission/teamLimit'; import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation'; import { addLog } from '@fastgpt/service/common/system/log'; +import { Types } from '@fastgpt/service/common/mongo'; +import { readFromSecondary } from '@fastgpt/service/common/mongo/utils'; +import { + EvalDatasetDataCreateFromEnum, + EvalDatasetDataKeyEnum, + EvalDatasetDataQualityStatusEnum +} from '@fastgpt/global/core/evaluation/dataset/constants'; +import type { EvalDatasetDataSchemaType } from '@fastgpt/global/core/evaluation/dataset/type'; export type SmartGenerateEvalDatasetQuery = {}; export type SmartGenerateEvalDatasetBody = smartGenerateEvalDatasetBody; -export type SmartGenerateEvalDatasetResponse = string; +export type SmartGenerateEvalDatasetResponse = { + directInsertCount: number; + queuedSynthesizeJobs: number; + totalProcessed: number; +}; async function handler( req: ApiRequestProps @@ -55,6 +68,11 @@ async function handler( return Promise.reject(EvaluationErrEnum.evalInsufficientPermission); } + // Validate model exists + if (!global.llmModelMap.has(intelligentGenerationModel)) { + return Promise.reject(EvaluationErrEnum.datasetModelNotFound); + } + // Find all collections that belong to the specified datasets const datasetCollections = await MongoDatasetCollection.find({ datasetId: { $in: kbDatasetIds }, @@ -91,13 +109,120 @@ async function handler( } try { - const job = await addEvalDatasetSmartGenerateJob({ - datasetCollectionIds: kbCollectionIds, - count: finalCount, - intelligentGenerationModel, - evalDatasetCollectionId: collectionId + addLog.info('Starting smart generate eval dataset processing', { + collectionId, + kbDatasetIds, + finalCount, + intelligentGenerationModel }); + // Sample data directly in API + const match = { + teamId: new Types.ObjectId(teamId), + collectionId: { $in: kbCollectionIds.map((id) => new Types.ObjectId(id)) } + }; + + const sampleData = await MongoDatasetData.aggregate( + [ + { + $match: match + }, + { + $sample: { size: finalCount } + }, + { + $project: { + q: 1, + a: 1, + datasetId: 1, + collectionId: 1 + } + } + ], + { + ...readFromSecondary + } + ); + + if (sampleData.length === 0) { + return Promise.reject(EvaluationErrEnum.selectedDatasetsContainNoData); + } + + addLog.info('Sampled data for processing', { + collectionId, + sampleCount: sampleData.length + }); + + const completeQAPairs: Array> = []; + const synthesizeJobs: Array<{ + dataId: string; + intelligentGenerationModel: string; + evalDatasetCollectionId: string; + }> = []; + + // Separate complete Q&A from Q-only data + for (const sample of sampleData) { + if (sample.q && sample.a) { + // Complete Q&A pair - save directly + const evalData: Partial = { + teamId, + tmbId, + evalDatasetCollectionId: collectionId, + [EvalDatasetDataKeyEnum.UserInput]: sample.q, + [EvalDatasetDataKeyEnum.ExpectedOutput]: sample.a, + [EvalDatasetDataKeyEnum.ActualOutput]: '', + [EvalDatasetDataKeyEnum.Context]: [], + [EvalDatasetDataKeyEnum.RetrievalContext]: [], + metadata: { + sourceDataId: sample._id, + sourceDatasetId: sample.datasetId, + sourceCollectionId: sample.collectionId, + qualityStatus: EvalDatasetDataQualityStatusEnum.unevaluated, + generatedAt: new Date(), + intelligentGenerationModel + }, + createFrom: EvalDatasetDataCreateFromEnum.intelligentGeneration + }; + completeQAPairs.push(evalData); + } else if (sample.q && sample.a === '') { + // Q-only - needs AI synthesis + synthesizeJobs.push({ + dataId: sample._id.toString(), + intelligentGenerationModel, + evalDatasetCollectionId: collectionId + }); + } + } + + // Direct insert complete Q&A pairs + let directInsertCount = 0; + if (completeQAPairs.length > 0) { + const insertedRecords = await MongoEvalDatasetData.insertMany(completeQAPairs, { + ordered: false + }); + directInsertCount = insertedRecords.length; + + addLog.info('Direct inserted complete eval dataset data', { + collectionId, + insertedCount: directInsertCount + }); + } + + // Queue synthesis jobs for Q-only data + let queuedSynthesizeJobs = 0; + for (const synthData of synthesizeJobs) { + await addEvalDatasetDataSynthesizeJob(synthData); + queuedSynthesizeJobs++; + } + + if (queuedSynthesizeJobs > 0) { + addLog.info('Queued synthesis jobs for Q-only data', { + collectionId, + queuedCount: queuedSynthesizeJobs + }); + } + + // Add audit log (async () => { addAuditLog({ tmbId, @@ -109,12 +234,26 @@ async function handler( }); })(); - return job.id || 'queued'; + const totalProcessed = directInsertCount + queuedSynthesizeJobs; + + addLog.info('Completed smart generate eval dataset processing', { + collectionId, + directInsertCount, + queuedSynthesizeJobs, + totalProcessed + }); + + return { + directInsertCount, + queuedSynthesizeJobs, + totalProcessed + }; } catch (error: any) { - addLog.error('Failed to queue smart generate evaluation dataset job', { + addLog.error('Failed to process smart generate evaluation dataset', { collectionId, kbDatasetIds, - error: error instanceof Error ? error.message : String(error) + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined }); return Promise.reject(EvaluationErrEnum.datasetTaskOperationFailed); } diff --git a/projects/app/src/pages/api/core/evaluation/dataset/data/update.ts b/projects/app/src/pages/api/core/evaluation/dataset/data/update.ts index 6b3c525a0a01..d077b0bda524 100644 --- a/projects/app/src/pages/api/core/evaluation/dataset/data/update.ts +++ b/projects/app/src/pages/api/core/evaluation/dataset/data/update.ts @@ -99,7 +99,7 @@ async function handler( } const collection = await MongoEvalDatasetCollection.findOne({ - _id: existingData.datasetId, + _id: existingData.evalDatasetCollectionId, teamId }).session(session); diff --git a/test/cases/pages/api/core/evaluation/dataset/collection/delete.test.ts b/test/cases/pages/api/core/evaluation/dataset/collection/delete.test.ts index e662ccdadd2e..63fbebe5ee9b 100644 --- a/test/cases/pages/api/core/evaluation/dataset/collection/delete.test.ts +++ b/test/cases/pages/api/core/evaluation/dataset/collection/delete.test.ts @@ -4,7 +4,6 @@ import { authEvaluationDatasetWrite } from '@fastgpt/service/core/evaluation/com import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; import { MongoEvalDatasetCollection } from '@fastgpt/service/core/evaluation/dataset/evalDatasetCollectionSchema'; import { MongoEvalDatasetData } from '@fastgpt/service/core/evaluation/dataset/evalDatasetDataSchema'; -import { removeEvalDatasetSmartGenerateJobsRobust } from '@fastgpt/service/core/evaluation/dataset/smartGenerateMq'; import { removeEvalDatasetDataQualityJobsRobust } from '@fastgpt/service/core/evaluation/dataset/dataQualityMq'; import { removeEvalDatasetDataSynthesizeJobsRobust } from '@fastgpt/service/core/evaluation/dataset/dataSynthesizeMq'; import { addLog } from '@fastgpt/service/common/system/log'; @@ -25,9 +24,6 @@ vi.mock('@fastgpt/service/core/evaluation/dataset/evalDatasetDataSchema', () => deleteMany: vi.fn() } })); -vi.mock('@fastgpt/service/core/evaluation/dataset/smartGenerateMq', () => ({ - removeEvalDatasetSmartGenerateJobsRobust: vi.fn() -})); vi.mock('@fastgpt/service/core/evaluation/dataset/dataQualityMq', () => ({ removeEvalDatasetDataQualityJobsRobust: vi.fn() })); @@ -48,9 +44,6 @@ const mockAuthEvaluationDatasetWrite = vi.mocked(authEvaluationDatasetWrite); const mockMongoSessionRun = vi.mocked(mongoSessionRun); const mockMongoEvalDatasetCollection = vi.mocked(MongoEvalDatasetCollection); const mockMongoEvalDatasetData = vi.mocked(MongoEvalDatasetData); -const mockRemoveEvalDatasetSmartGenerateJobsRobust = vi.mocked( - removeEvalDatasetSmartGenerateJobsRobust -); const mockRemoveEvalDatasetDataQualityJobsRobust = vi.mocked( removeEvalDatasetDataQualityJobsRobust ); @@ -106,7 +99,6 @@ describe('EvalDatasetCollection Delete API', () => { deletedCount: 1 } as any); - mockRemoveEvalDatasetSmartGenerateJobsRobust.mockResolvedValue(undefined as any); mockRemoveEvalDatasetDataQualityJobsRobust.mockResolvedValue(undefined as any); mockRemoveEvalDatasetDataSynthesizeJobsRobust.mockResolvedValue(undefined as any); @@ -243,46 +235,6 @@ describe('EvalDatasetCollection Delete API', () => { }); describe('Queue Cleanup', () => { - it('should clean up smart generation queue tasks', async () => { - const req = { - query: { collectionId: validCollectionId } - }; - - await handler_test(req as any); - - expect(mockRemoveEvalDatasetSmartGenerateJobsRobust).toHaveBeenCalledWith( - [validCollectionId], - { - forceCleanActiveJobs: true, - retryAttempts: 3, - retryDelay: 200 - } - ); - expect(mockAddLog.info).toHaveBeenCalledWith('Cleaning up smart generation queue tasks', { - collectionId: validCollectionId - }); - expect(mockAddLog.info).toHaveBeenCalledWith('Smart generation queue cleanup completed', { - collectionId: validCollectionId - }); - }); - - it('should handle smart generation queue cleanup errors gracefully', async () => { - const queueError = new Error('Smart generation cleanup failed'); - mockRemoveEvalDatasetSmartGenerateJobsRobust.mockRejectedValue(queueError); - - const req = { - query: { collectionId: validCollectionId } - }; - - const result = await handler_test(req as any); - - expect(mockAddLog.error).toHaveBeenCalledWith('Failed to clean up smart generation queue', { - collectionId: validCollectionId, - error: queueError - }); - expect(result).toBe('success'); - }); - it('should clean up quality assessment queue tasks for all dataset data', async () => { const req = { query: { collectionId: validCollectionId } @@ -657,7 +609,6 @@ describe('EvalDatasetCollection Delete API', () => { // Verify complete flow expect(mockAuthEvaluationDatasetWrite).toHaveBeenCalled(); expect(mockMongoEvalDatasetCollection.findOne).toHaveBeenCalled(); - expect(mockRemoveEvalDatasetSmartGenerateJobsRobust).toHaveBeenCalled(); expect(mockRemoveEvalDatasetDataQualityJobsRobust).toHaveBeenCalled(); expect(mockRemoveEvalDatasetDataSynthesizeJobsRobust).toHaveBeenCalled(); expect(mockMongoEvalDatasetData.deleteMany).toHaveBeenCalled(); @@ -680,11 +631,8 @@ describe('EvalDatasetCollection Delete API', () => { }); it('should continue deletion even when all queue cleanups fail', async () => { - const smartGenError = new Error('Smart generation cleanup failed'); const qualityError = new Error('Quality assessment cleanup failed'); const synthesizeError = new Error('Data synthesis cleanup failed'); - - mockRemoveEvalDatasetSmartGenerateJobsRobust.mockRejectedValue(smartGenError); mockMongoEvalDatasetData.find.mockReturnValue({ session: vi.fn().mockRejectedValue(qualityError) } as any); @@ -696,10 +644,6 @@ describe('EvalDatasetCollection Delete API', () => { const result = await handler_test(req as any); - expect(mockAddLog.error).toHaveBeenCalledWith('Failed to clean up smart generation queue', { - collectionId: validCollectionId, - error: smartGenError - }); expect(mockAddLog.error).toHaveBeenCalledWith('Failed to clean up quality assessment queue', { collectionId: validCollectionId, error: qualityError diff --git a/test/cases/pages/api/core/evaluation/dataset/data/qualityAssessment.test.ts b/test/cases/pages/api/core/evaluation/dataset/data/qualityAssessment.test.ts index 63a5310c610e..80cdfdd117f7 100644 --- a/test/cases/pages/api/core/evaluation/dataset/data/qualityAssessment.test.ts +++ b/test/cases/pages/api/core/evaluation/dataset/data/qualityAssessment.test.ts @@ -72,7 +72,7 @@ describe('QualityAssessment API', () => { const mockDatasetData = { _id: validDataId, - datasetId: validCollectionId, + evalDatasetCollectionId: validCollectionId, userInput: 'test input', expectedOutput: 'test output' }; From 1415023911ffbe09a9b99060664a01d238cab1a8 Mon Sep 17 00:00:00 2001 From: Jon Date: Mon, 15 Sep 2025 23:58:59 +0800 Subject: [PATCH 05/84] feat: enhance smart generation to support collection creation - Update API to support creating new collections or using existing ones - Add optional name and description parameters for new collections - Modify frontend to handle both collection creation and selection modes - Improve validation and error handling for collection parameters --- .../global/core/evaluation/dataset/api.d.ts | 5 +- .../service/core/evaluation/utils/index.ts | 160 +++++++-------- .../dataset/IntelligentGeneration.tsx | 43 ++-- .../evaluation/dataset/data/smartGenerate.ts | 193 +++++++++++++----- .../app/src/web/core/evaluation/dataset.ts | 3 +- 5 files changed, 254 insertions(+), 150 deletions(-) diff --git a/packages/global/core/evaluation/dataset/api.d.ts b/packages/global/core/evaluation/dataset/api.d.ts index 4d169aea44b6..d0f7f62d4ee1 100644 --- a/packages/global/core/evaluation/dataset/api.d.ts +++ b/packages/global/core/evaluation/dataset/api.d.ts @@ -132,10 +132,13 @@ export type getEvalDatasetDataDetailResponse = Pick< >; export type smartGenerateEvalDatasetBody = { - collectionId: string; + collectionId?: string; kbDatasetIds: string[]; count?: number; intelligentGenerationModel: string; + // Optional fields for creating new collection + name?: string; + description?: string; }; export type listFailedTasksBody = { diff --git a/packages/service/core/evaluation/utils/index.ts b/packages/service/core/evaluation/utils/index.ts index aeaa9841c633..001b91974609 100644 --- a/packages/service/core/evaluation/utils/index.ts +++ b/packages/service/core/evaluation/utils/index.ts @@ -63,7 +63,7 @@ export async function validateEvaluationParams( params: EvaluationValidationParams, options?: EvaluationValidationOptions ): Promise { - const { name, description, datasetId: evalDatasetCollectionId, target, evaluators } = params; + const { name, description, datasetId, target, evaluators } = params; const mode = options?.mode || 'create'; const isCreateMode = mode === 'create'; @@ -82,14 +82,14 @@ export async function validateEvaluationParams( }; } - if (!evalDatasetCollectionId) { + if (!datasetId) { return { isValid: false, errors: [ { code: EvaluationErrEnum.evalDatasetIdRequired, - message: 'Evaluation dataset collection ID is required', - field: 'evalDatasetCollectionId' + message: 'Dataset ID is required', + field: 'datasetId' } ] }; @@ -166,107 +166,107 @@ export async function validateEvaluationParams( }; } - if (evalDatasetCollectionId !== undefined) { - if (!evalDatasetCollectionId) { + if (datasetId !== undefined) { + if (!datasetId) { return { isValid: false, errors: [ { code: EvaluationErrEnum.evalDatasetIdRequired, - message: 'Evaluation dataset collection ID is required', - field: 'evalDatasetCollectionId' + message: 'Dataset ID is required', + field: 'datasetId' } ] }; } // Validate dataset exists and is accessible - const datasetValidation = await validateDatasetExists(evalDatasetCollectionId, options?.teamId); + const datasetValidation = await validateDatasetExists(datasetId, options?.teamId); if (!datasetValidation.isValid) { return datasetValidation; } + } - if (target !== undefined) { - if (!target) { - return { - isValid: false, - errors: [ - { - code: EvaluationErrEnum.evalTargetRequired, - message: 'Evaluation target is required', - field: 'target' - } - ] - }; - } - - // Validate target configuration using validateTargetConfig - const targetValidation = await validateTargetConfig(target); - if (!targetValidation.isValid) { - return targetValidation; // Return the detailed validation result directly - } + if (target !== undefined) { + if (!target) { + return { + isValid: false, + errors: [ + { + code: EvaluationErrEnum.evalTargetRequired, + message: 'Evaluation target is required', + field: 'target' + } + ] + }; } - if (evaluators !== undefined) { - if (!evaluators || !Array.isArray(evaluators) || evaluators.length === 0) { - return { - isValid: false, - errors: [ - { - code: EvaluationErrEnum.evalEvaluatorsRequired, - message: 'At least one evaluator is required', - field: 'evaluators' - } - ] - }; - } + // Validate target configuration using validateTargetConfig + const targetValidation = await validateTargetConfig(target); + if (!targetValidation.isValid) { + return targetValidation; // Return the detailed validation result directly + } + } - // Validate evaluators configuration using validateEvaluatorConfig - for (let i = 0; i < evaluators.length; i++) { - const evaluator = evaluators[i]; + if (evaluators !== undefined) { + if (!evaluators || !Array.isArray(evaluators) || evaluators.length === 0) { + return { + isValid: false, + errors: [ + { + code: EvaluationErrEnum.evalEvaluatorsRequired, + message: 'At least one evaluator is required', + field: 'evaluators' + } + ] + }; + } - // Detailed validation using validateEvaluatorConfig - const evaluatorValidation = await validateEvaluatorConfig(evaluator); - if (!evaluatorValidation.isValid) { - // Prefix error messages with evaluator index for clarity - const errors = evaluatorValidation.errors.map((err) => ({ - ...err, - message: `Evaluator at index ${i}: ${err.message}`, - field: `evaluators[${i}].${err.field || 'unknown'}`, - debugInfo: { - evaluatorIndex: i, - ...err.debugInfo - } - })); - return { isValid: false, errors }; - } + // Validate evaluators configuration using validateEvaluatorConfig + for (let i = 0; i < evaluators.length; i++) { + const evaluator = evaluators[i]; - // Validate scoreScaling if provided - if (evaluator.scoreScaling !== undefined) { - if ( - typeof evaluator.scoreScaling !== 'number' || - isNaN(evaluator.scoreScaling) || - !isFinite(evaluator.scoreScaling) || - evaluator.scoreScaling <= 0 || - evaluator.scoreScaling > 10000 - ) { - return { - isValid: false, - errors: [ - { - code: EvaluationErrEnum.evalEvaluatorInvalidScoreScaling, - message: 'Evaluator scoreScaling invalid', - field: 'scoreScaling' - } - ] - }; + // Detailed validation using validateEvaluatorConfig + const evaluatorValidation = await validateEvaluatorConfig(evaluator); + if (!evaluatorValidation.isValid) { + // Prefix error messages with evaluator index for clarity + const errors = evaluatorValidation.errors.map((err) => ({ + ...err, + message: `Evaluator at index ${i}: ${err.message}`, + field: `evaluators[${i}].${err.field || 'unknown'}`, + debugInfo: { + evaluatorIndex: i, + ...err.debugInfo } + })); + return { isValid: false, errors }; + } + + // Validate scoreScaling if provided + if (evaluator.scoreScaling !== undefined) { + if ( + typeof evaluator.scoreScaling !== 'number' || + isNaN(evaluator.scoreScaling) || + !isFinite(evaluator.scoreScaling) || + evaluator.scoreScaling <= 0 || + evaluator.scoreScaling > 10000 + ) { + return { + isValid: false, + errors: [ + { + code: EvaluationErrEnum.evalEvaluatorInvalidScoreScaling, + message: 'Evaluator scoreScaling invalid', + field: 'scoreScaling' + } + ] + }; } } } - - return { isValid: true, errors: [] }; } + + return { isValid: true, errors: [] }; } export async function validateEvaluationParamsForCreate( diff --git a/projects/app/src/pageComponents/dashboard/evaluation/dataset/IntelligentGeneration.tsx b/projects/app/src/pageComponents/dashboard/evaluation/dataset/IntelligentGeneration.tsx index 5054838d5887..d0764c1a7b90 100644 --- a/projects/app/src/pageComponents/dashboard/evaluation/dataset/IntelligentGeneration.tsx +++ b/projects/app/src/pageComponents/dashboard/evaluation/dataset/IntelligentGeneration.tsx @@ -55,14 +55,28 @@ interface IntelligentGenerationProps { } const formatSubmitData = ( - params: IntelligentGenerationForm & { collectionId: string } + params: IntelligentGenerationForm & { collectionId?: string } ): smartGenerateEvalDatasetBody => { - return { + const baseData = { count: params.dataAmount, kbDatasetIds: params.selectedDatasets.map((v) => v.datasetId), - intelligentGenerationModel: params.generationModel, - collectionId: params.collectionId + intelligentGenerationModel: params.generationModel }; + + if (params.collectionId) { + // Use existing collection + return { + ...baseData, + collectionId: params.collectionId + }; + } else { + // Create new collection + return { + ...baseData, + name: params.name, + description: '' + }; + } }; /** @@ -117,22 +131,19 @@ const IntelligentGeneration = ({ const { runAsync: onclickCreate, loading: creating } = useRequest2( async (data: IntelligentGenerationForm) => { - let targetCollectionId = collectionId; - - // 只有当collectionId不存在时,才需要创建新的数据集 - if (!targetCollectionId) { - targetCollectionId = await postCreateEvaluationDataset({ name: data.name }); - setCollectionId(targetCollectionId); - } - const params = formatSubmitData({ ...data, - collectionId: targetCollectionId + collectionId: collectionId || undefined }); - await postSmartGenerateEvaluationDataset(params); + const result = await postSmartGenerateEvaluationDataset(params); + + // If we created a new collection, update our state with the returned collectionId + if (!collectionId && result?.collectionId) { + setCollectionId(result.collectionId); + } - return { datasetId: targetCollectionId }; + return result; }, { successToast: t('common:create_success') @@ -185,7 +196,7 @@ const IntelligentGeneration = ({ const handleFormSubmit = async (data: IntelligentGenerationForm) => { const result = await onclickCreate(data); if (returnDatasetId) { - onConfirm(data, result?.datasetId); + onConfirm(data, result?.collectionId); } else { onConfirm(data); } diff --git a/projects/app/src/pages/api/core/evaluation/dataset/data/smartGenerate.ts b/projects/app/src/pages/api/core/evaluation/dataset/data/smartGenerate.ts index 730172b6940e..a4dec64dd9fa 100644 --- a/projects/app/src/pages/api/core/evaluation/dataset/data/smartGenerate.ts +++ b/projects/app/src/pages/api/core/evaluation/dataset/data/smartGenerate.ts @@ -2,16 +2,26 @@ import type { ApiRequestProps } from '@fastgpt/service/type/next'; import { NextAPI } from '@/service/middleware/entry'; import { MongoEvalDatasetCollection } from '@fastgpt/service/core/evaluation/dataset/evalDatasetCollectionSchema'; import { MongoEvalDatasetData } from '@fastgpt/service/core/evaluation/dataset/evalDatasetDataSchema'; -import { MongoDatasetCollection } from '@fastgpt/service/core/dataset/collection/schema'; import { MongoDatasetData } from '@fastgpt/service/core/dataset/data/schema'; import type { smartGenerateEvalDatasetBody } from '@fastgpt/global/core/evaluation/dataset/api'; import { addEvalDatasetDataSynthesizeJob } from '@fastgpt/service/core/evaluation/dataset/dataSynthesizeMq'; import { addAuditLog } from '@fastgpt/service/support/user/audit/util'; import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants'; -import { authEvaluationDatasetGenFromKnowledgeBase } from '@fastgpt/service/core/evaluation/common'; -import { checkTeamAIPoints } from '@fastgpt/service/support/permission/teamLimit'; +import { + authEvaluationDatasetGenFromKnowledgeBase, + authEvaluationDatasetCreate +} from '@fastgpt/service/core/evaluation/common'; +import { + checkTeamAIPoints, + checkTeamEvalDatasetLimit +} from '@fastgpt/service/support/permission/teamLimit'; import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation'; import { addLog } from '@fastgpt/service/common/system/log'; +import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; +import { MAX_NAME_LENGTH, MAX_DESCRIPTION_LENGTH } from '@fastgpt/global/core/evaluation/constants'; +import { authDataset } from '@fastgpt/service/support/permission/dataset/auth'; +import { ReadPermissionVal } from '@fastgpt/global/support/permission/constant'; +import { getDefaultEvaluationModel } from '@fastgpt/service/core/ai/model'; import { Types } from '@fastgpt/service/common/mongo'; import { readFromSecondary } from '@fastgpt/service/common/mongo/utils'; import { @@ -20,6 +30,7 @@ import { EvalDatasetDataQualityStatusEnum } from '@fastgpt/global/core/evaluation/dataset/constants'; import type { EvalDatasetDataSchemaType } from '@fastgpt/global/core/evaluation/dataset/type'; +import { CommonErrEnum } from '@fastgpt/global/common/error/code/common'; export type SmartGenerateEvalDatasetQuery = {}; export type SmartGenerateEvalDatasetBody = smartGenerateEvalDatasetBody; @@ -27,25 +38,80 @@ export type SmartGenerateEvalDatasetResponse = { directInsertCount: number; queuedSynthesizeJobs: number; totalProcessed: number; + collectionId: string; }; async function handler( req: ApiRequestProps ): Promise { - const { collectionId, kbDatasetIds, count, intelligentGenerationModel } = req.body; + const { collectionId, kbDatasetIds, count, intelligentGenerationModel, name, description } = + req.body; + + if (!collectionId && !name) { + return Promise.reject(CommonErrEnum.missingParams); + } + + if (collectionId && name) { + return Promise.reject(CommonErrEnum.invalidParams); + } + + // Validate collection name if creating new collection + if (name) { + if (typeof name !== 'string' || name.trim().length === 0) { + return Promise.reject(EvaluationErrEnum.evalNameRequired); + } + if (name.trim().length > MAX_NAME_LENGTH) { + return Promise.reject(EvaluationErrEnum.evalNameTooLong); + } + } - const { teamId, tmbId } = await authEvaluationDatasetGenFromKnowledgeBase( - collectionId, - kbDatasetIds, - { + if (description) { + if (typeof description !== 'string') { + return Promise.reject(EvaluationErrEnum.evalDescriptionInvalidType); + } + if (description.length > MAX_DESCRIPTION_LENGTH) { + return Promise.reject(EvaluationErrEnum.evalDescriptionTooLong); + } + } + + let teamId: string; + let tmbId: string; + let targetCollectionId: string; + + if (collectionId) { + // Mode 1: Use existing collection + const authResult = await authEvaluationDatasetGenFromKnowledgeBase(collectionId, kbDatasetIds, { req, authToken: true, authApiKey: true - } - ); + }); + teamId = authResult.teamId; + tmbId = authResult.tmbId; + targetCollectionId = collectionId; + } else { + // Mode 2: Create new collection + const authResult = await authEvaluationDatasetCreate({ + req, + authToken: true, + authApiKey: true + }); + teamId = authResult.teamId; + tmbId = authResult.tmbId; + + // Validate access to knowledge base datasets + await Promise.all( + kbDatasetIds.map((datasetId) => + authDataset({ + req, + authToken: true, + authApiKey: true, + datasetId, + per: ReadPermissionVal + }) + ) + ); - if (!collectionId || typeof collectionId !== 'string') { - return Promise.reject(EvaluationErrEnum.datasetCollectionIdRequired); + targetCollectionId = ''; } if (!kbDatasetIds || !Array.isArray(kbDatasetIds) || kbDatasetIds.length === 0) { @@ -59,37 +125,62 @@ async function handler( // Check AI points availability await checkTeamAIPoints(teamId); - const evalDatasetCollection = await MongoEvalDatasetCollection.findById(collectionId); - if (!evalDatasetCollection) { - return Promise.reject(EvaluationErrEnum.datasetCollectionNotFound); - } - - if (String(evalDatasetCollection.teamId) !== teamId) { - return Promise.reject(EvaluationErrEnum.evalInsufficientPermission); - } - - // Validate model exists + // Validate model if (!global.llmModelMap.has(intelligentGenerationModel)) { return Promise.reject(EvaluationErrEnum.datasetModelNotFound); } - // Find all collections that belong to the specified datasets - const datasetCollections = await MongoDatasetCollection.find({ - datasetId: { $in: kbDatasetIds }, - teamId - }); + // Handle collection - either get existing or create new + let evalDatasetCollection; + if (collectionId) { + // Mode 1: Use existing collection + evalDatasetCollection = await MongoEvalDatasetCollection.findById(collectionId); + if (!evalDatasetCollection) { + return Promise.reject(EvaluationErrEnum.datasetCollectionNotFound); + } + if (String(evalDatasetCollection.teamId) !== teamId) { + return Promise.reject(EvaluationErrEnum.evalInsufficientPermission); + } + } else { + // Mode 2: Create new collection - const kbCollectionIds = datasetCollections.map((collection) => collection._id); - const foundDatasetIds = [ - ...new Set(datasetCollections.map((collection) => String(collection.datasetId))) - ]; - if (foundDatasetIds.length !== kbDatasetIds.length) { - return Promise.reject(EvaluationErrEnum.evalInsufficientPermission); + // Check evaluation dataset limit + await checkTeamEvalDatasetLimit(teamId); + + const existingCollection = await MongoEvalDatasetCollection.findOne({ + teamId, + name: name!.trim() + }); + if (existingCollection) { + return Promise.reject(EvaluationErrEnum.evalDuplicateDatasetName); + } + + const defaultEvaluationModel = getDefaultEvaluationModel(); + const evaluationModelToUse = intelligentGenerationModel || defaultEvaluationModel?.model; + + const collectionData = await mongoSessionRun(async (session) => { + const [collection] = await MongoEvalDatasetCollection.create( + [ + { + teamId, + tmbId, + name: name!.trim(), + description: (description || '').trim(), + evaluationModel: evaluationModelToUse + } + ], + { session, ordered: true } + ); + return collection; + }); + + evalDatasetCollection = collectionData; + targetCollectionId = String(collectionData._id); } const totalDataCount = await MongoDatasetData.countDocuments({ teamId, - collectionId: { $in: kbCollectionIds }, + datasetId: { $in: kbDatasetIds }, $or: [{ q: { $exists: true } }] }); @@ -97,7 +188,6 @@ async function handler( return Promise.reject(EvaluationErrEnum.selectedDatasetsContainNoData); } - // Use totalDataCount as default when count is undefined const finalCount = count !== undefined ? count : totalDataCount; if (finalCount < 1) { @@ -109,17 +199,16 @@ async function handler( } try { - addLog.info('Starting smart generate eval dataset processing', { - collectionId, + addLog.debug('Starting smart generate eval dataset processing', { + collectionId: targetCollectionId, kbDatasetIds, finalCount, intelligentGenerationModel }); - // Sample data directly in API const match = { teamId: new Types.ObjectId(teamId), - collectionId: { $in: kbCollectionIds.map((id) => new Types.ObjectId(id)) } + datasetId: { $in: kbDatasetIds.map((id) => new Types.ObjectId(id)) } }; const sampleData = await MongoDatasetData.aggregate( @@ -148,8 +237,8 @@ async function handler( return Promise.reject(EvaluationErrEnum.selectedDatasetsContainNoData); } - addLog.info('Sampled data for processing', { - collectionId, + addLog.debug('Sampled data for processing', { + collectionId: targetCollectionId, sampleCount: sampleData.length }); @@ -167,7 +256,7 @@ async function handler( const evalData: Partial = { teamId, tmbId, - evalDatasetCollectionId: collectionId, + evalDatasetCollectionId: targetCollectionId, [EvalDatasetDataKeyEnum.UserInput]: sample.q, [EvalDatasetDataKeyEnum.ExpectedOutput]: sample.a, [EvalDatasetDataKeyEnum.ActualOutput]: '', @@ -189,7 +278,7 @@ async function handler( synthesizeJobs.push({ dataId: sample._id.toString(), intelligentGenerationModel, - evalDatasetCollectionId: collectionId + evalDatasetCollectionId: targetCollectionId }); } } @@ -202,8 +291,8 @@ async function handler( }); directInsertCount = insertedRecords.length; - addLog.info('Direct inserted complete eval dataset data', { - collectionId, + addLog.debug('Direct inserted complete eval dataset data', { + collectionId: targetCollectionId, insertedCount: directInsertCount }); } @@ -216,13 +305,12 @@ async function handler( } if (queuedSynthesizeJobs > 0) { - addLog.info('Queued synthesis jobs for Q-only data', { - collectionId, + addLog.debug('Queued synthesis jobs for Q-only data', { + collectionId: targetCollectionId, queuedCount: queuedSynthesizeJobs }); } - // Add audit log (async () => { addAuditLog({ tmbId, @@ -236,8 +324,8 @@ async function handler( const totalProcessed = directInsertCount + queuedSynthesizeJobs; - addLog.info('Completed smart generate eval dataset processing', { - collectionId, + addLog.debug('Completed smart generate eval dataset processing', { + collectionId: targetCollectionId, directInsertCount, queuedSynthesizeJobs, totalProcessed @@ -246,11 +334,12 @@ async function handler( return { directInsertCount, queuedSynthesizeJobs, - totalProcessed + totalProcessed, + collectionId: targetCollectionId }; } catch (error: any) { addLog.error('Failed to process smart generate evaluation dataset', { - collectionId, + collectionId: targetCollectionId || collectionId || 'new', kbDatasetIds, error: error instanceof Error ? error.message : String(error), stack: error instanceof Error ? error.stack : undefined diff --git a/projects/app/src/web/core/evaluation/dataset.ts b/projects/app/src/web/core/evaluation/dataset.ts index 975afac6c9ae..ff676ce86aaf 100644 --- a/projects/app/src/web/core/evaluation/dataset.ts +++ b/projects/app/src/web/core/evaluation/dataset.ts @@ -19,11 +19,12 @@ import type { listFailedTasksResponse, getEvalDatasetCollectionDetailResponse } from '@fastgpt/global/core/evaluation/dataset/api'; +import type { SmartGenerateEvalDatasetResponse } from '@/pages/api/core/evaluation/dataset/data/smartGenerate'; import type { PaginationResponse } from '@fastgpt/web/common/fetch/type'; // 智能生成评测数据集 export const postSmartGenerateEvaluationDataset = (data: smartGenerateEvalDatasetBody) => - POST('/core/evaluation/dataset/data/smartGenerate', data); + POST('/core/evaluation/dataset/data/smartGenerate', data); // 创建评测数据集 export const postCreateEvaluationDataset = (data: createEvalDatasetCollectionBody) => From 36c57c730935507b366a8046ab55bb8ceb77ae84 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 16 Sep 2025 10:56:19 +0800 Subject: [PATCH 06/84] refactor: evaluation dataset import API to support multiple files - Rename API endpoint from fileId.ts to import.ts for better semantic clarity - Replace JSON body parsing with form-data support using multer for multiple file uploads - Implement dual-mode operation: * Mode 1: Import to existing collection (collectionId parameter) * Mode 2: Create new collection and import (name + description parameters) - Add comprehensive validation for mutual exclusivity of operation modes - Support processing multiple CSV files in a single request with aggregated validation - Add proper file cleanup using finally block to ensure temporary files are removed - Update type definitions to support optional parameters for dual-mode functionality - Refactor comprehensive test suite to cover form-data handling and new dual-mode scenarios - Maintain backward compatibility with existing quality evaluation workflows --- .../global/core/evaluation/dataset/api.d.ts | 7 +- .../core/evaluation/dataset/data/fileId.ts | 346 ------ .../core/evaluation/dataset/data/import.ts | 435 +++++++ .../evaluation/dataset/data/fileId.test.ts | 1089 ----------------- .../evaluation/dataset/data/import.test.ts | 534 ++++++++ 5 files changed, 974 insertions(+), 1437 deletions(-) delete mode 100644 projects/app/src/pages/api/core/evaluation/dataset/data/fileId.ts create mode 100644 projects/app/src/pages/api/core/evaluation/dataset/data/import.ts delete mode 100644 test/cases/pages/api/core/evaluation/dataset/data/fileId.test.ts create mode 100644 test/cases/pages/api/core/evaluation/dataset/data/import.test.ts diff --git a/packages/global/core/evaluation/dataset/api.d.ts b/packages/global/core/evaluation/dataset/api.d.ts index d0f7f62d4ee1..3a07de634e42 100644 --- a/packages/global/core/evaluation/dataset/api.d.ts +++ b/packages/global/core/evaluation/dataset/api.d.ts @@ -45,8 +45,11 @@ type QualityEvaluationBase = { }; export type importEvalDatasetFromFileBody = { - fileId: string; - collectionId: string; + fileId?: string; // Optional for form-data, files will be uploaded directly + collectionId?: string; // Optional - use existing collection mode + // Optional fields for creating new collection mode + name?: string; + description?: string; } & QualityEvaluationBase; type EvalDatasetDataBase = { [EvalDatasetDataKeyEnum.UserInput]: string; diff --git a/projects/app/src/pages/api/core/evaluation/dataset/data/fileId.ts b/projects/app/src/pages/api/core/evaluation/dataset/data/fileId.ts deleted file mode 100644 index 262fc37cb5c4..000000000000 --- a/projects/app/src/pages/api/core/evaluation/dataset/data/fileId.ts +++ /dev/null @@ -1,346 +0,0 @@ -import type { ApiRequestProps } from '@fastgpt/service/type/next'; -import { NextAPI } from '@/service/middleware/entry'; -import { WritePermissionVal } from '@fastgpt/global/support/permission/constant'; -import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; -import { MongoEvalDatasetCollection } from '@fastgpt/service/core/evaluation/dataset/evalDatasetCollectionSchema'; -import { MongoEvalDatasetData } from '@fastgpt/service/core/evaluation/dataset/evalDatasetDataSchema'; -import { - EvalDatasetDataCreateFromEnum, - EvalDatasetDataKeyEnum, - EvalDatasetDataQualityStatusEnum -} from '@fastgpt/global/core/evaluation/dataset/constants'; -import { readFileContentFromMongo } from '@fastgpt/service/common/file/gridfs/controller'; -import { BucketNameEnum } from '@fastgpt/global/common/file/constants'; -import type { importEvalDatasetFromFileBody } from '@fastgpt/global/core/evaluation/dataset/api'; -import { addEvalDatasetDataQualityJob } from '@fastgpt/service/core/evaluation/dataset/dataQualityMq'; -import { authEvalDatasetCollectionFile } from '@fastgpt/service/support/permission/evaluation/auth'; -import { addAuditLog } from '@fastgpt/service/support/user/audit/util'; -import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants'; -import { authEvaluationDatasetDataWrite } from '@fastgpt/service/core/evaluation/common'; -import { MAX_CSV_ROWS } from '@fastgpt/global/core/evaluation/constants'; -import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation'; -import { checkTeamAIPoints } from '@fastgpt/service/support/permission/teamLimit'; - -export type EvalDatasetImportFromFileQuery = {}; -export type EvalDatasetImportFromFileBody = importEvalDatasetFromFileBody; -export type EvalDatasetImportFromFileResponse = string; - -const REQUIRED_CSV_COLUMNS = ['user_input', 'expected_output'] as const; - -const OPTIONAL_CSV_COLUMNS = ['actual_output', 'context', 'retrieval_context', 'metadata'] as const; - -const CSV_COLUMNS = [...REQUIRED_CSV_COLUMNS, ...OPTIONAL_CSV_COLUMNS] as const; - -const ENUM_TO_CSV_MAPPING = { - [EvalDatasetDataKeyEnum.UserInput]: 'user_input', - [EvalDatasetDataKeyEnum.ExpectedOutput]: 'expected_output', - [EvalDatasetDataKeyEnum.ActualOutput]: 'actual_output', - [EvalDatasetDataKeyEnum.Context]: 'context', - [EvalDatasetDataKeyEnum.RetrievalContext]: 'retrieval_context' -} as const; - -interface CSVRow { - user_input: string; - expected_output: string; - actual_output?: string; - context?: string; - retrieval_context?: string; - metadata?: string; -} - -function parseCSVLine(line: string): string[] { - const result: string[] = []; - let current = ''; - let inQuotes = false; - - for (let i = 0; i < line.length; i++) { - const char = line[i]; - - if (char === '"') { - if (inQuotes && line[i + 1] === '"') { - // Escaped quote - current += '"'; - i++; - } else { - // Toggle quote state - inQuotes = !inQuotes; - } - } else if (char === ',' && !inQuotes) { - // End of field - result.push(current.trim()); - current = ''; - } else { - current += char; - } - } - - // Add the last field - result.push(current.trim()); - return result; -} - -function normalizeHeaderName(header: string): string { - const enumValue = header as keyof typeof ENUM_TO_CSV_MAPPING; - if (ENUM_TO_CSV_MAPPING[enumValue]) { - return ENUM_TO_CSV_MAPPING[enumValue]; - } - return header; -} - -function parseCSVContent(csvContent: string): CSVRow[] { - const lines = csvContent.split('\n').filter((line) => line.trim()); - - if (lines.length === 0) { - throw new Error('CSV file is empty'); - } - - // Parse header - const headerLine = lines[0]; - const rawHeaders = parseCSVLine(headerLine).map((h) => h.replace(/^"|"$/g, '')); - const headers = rawHeaders.map(normalizeHeaderName); - - // Validate CSV structure - const missingColumns = REQUIRED_CSV_COLUMNS.filter((col) => !headers.includes(col)); - if (missingColumns.length > 0) { - throw new Error(`CSV file is missing required columns: ${missingColumns.join(', ')}`); - } - - // Create column index mapping - const columnIndexes: Record = {}; - CSV_COLUMNS.forEach((col) => { - const index = headers.indexOf(col); - if (index !== -1) { - columnIndexes[col] = index; - } - }); - - // Parse data rows - const rows: CSVRow[] = []; - for (let i = 1; i < lines.length; i++) { - const line = lines[i].trim(); - if (!line) continue; // Skip empty lines - - const fields = parseCSVLine(line); - - if (fields.length !== headers.length) { - throw new Error(`Row ${i + 1}: Expected ${headers.length} columns, got ${fields.length}`); - } - - const row: CSVRow = { - user_input: fields[columnIndexes.user_input]?.replace(/^"|"$/g, '') || '', - expected_output: fields[columnIndexes.expected_output]?.replace(/^"|"$/g, '') || '' - }; - - // Add optional fields - if (columnIndexes.actual_output !== undefined) { - row.actual_output = fields[columnIndexes.actual_output]?.replace(/^"|"$/g, '') || ''; - } - if (columnIndexes.context !== undefined) { - row.context = fields[columnIndexes.context]?.replace(/^"|"$/g, '') || ''; - } - if (columnIndexes.retrieval_context !== undefined) { - row.retrieval_context = fields[columnIndexes.retrieval_context]?.replace(/^"|"$/g, '') || ''; - } - if (columnIndexes.metadata !== undefined) { - row.metadata = fields[columnIndexes.metadata]?.replace(/^"|"$/g, '') || '{}'; - } - - rows.push(row); - } - - return rows; -} - -function validateRequestParams(params: { - fileId?: string; - collectionId?: string; - enableQualityEvaluation?: boolean; - evaluationModel?: string; -}) { - const { fileId, collectionId, enableQualityEvaluation, evaluationModel } = params; - - if (!fileId || typeof fileId !== 'string') { - return Promise.reject(EvaluationErrEnum.fileIdRequired); - } - - if (!collectionId || typeof collectionId !== 'string') { - return Promise.reject(EvaluationErrEnum.datasetCollectionIdRequired); - } - - if (typeof enableQualityEvaluation !== 'boolean') { - return Promise.reject(EvaluationErrEnum.datasetDataEnableQualityEvalRequired); - } - - if (enableQualityEvaluation && (!evaluationModel || typeof evaluationModel !== 'string')) { - return Promise.reject(EvaluationErrEnum.datasetDataEvaluationModelRequiredForQuality); - } - - if (evaluationModel && !global.llmModelMap.has(evaluationModel)) { - return Promise.reject(EvaluationErrEnum.datasetModelNotFound); - } -} - -async function handler( - req: ApiRequestProps -): Promise { - const { fileId, collectionId, enableQualityEvaluation, evaluationModel } = req.body; - - const { teamId, tmbId } = await authEvaluationDatasetDataWrite(collectionId, { - req, - authToken: true, - authApiKey: true - }); - - validateRequestParams({ fileId, collectionId, enableQualityEvaluation, evaluationModel }); - - if (enableQualityEvaluation && evaluationModel) { - await checkTeamAIPoints(teamId); - } - - const { file } = await authEvalDatasetCollectionFile({ - req, - authToken: true, - authApiKey: true, - fileId, - per: WritePermissionVal - }); - - const filename = file.filename?.toLowerCase() || ''; - if (!filename.endsWith('.csv')) { - return Promise.reject(EvaluationErrEnum.fileMustBeCSV); - } - - const datasetCollection = await MongoEvalDatasetCollection.findById(collectionId); - if (!datasetCollection) { - return Promise.reject(EvaluationErrEnum.datasetCollectionNotFound); - } - - if (String(datasetCollection.teamId) !== teamId) { - return Promise.reject(EvaluationErrEnum.evalInsufficientPermission); - } - try { - const { rawText } = await readFileContentFromMongo({ - teamId, - tmbId, - bucketName: BucketNameEnum.evaluation, - fileId, - getFormatText: false - }); - - const csvRows = parseCSVContent(rawText); - - if (csvRows.length === 0) { - return Promise.reject(EvaluationErrEnum.csvNoDataRows); - } - - if (csvRows.length > MAX_CSV_ROWS) { - return Promise.reject(EvaluationErrEnum.csvTooManyRows); - } - - const evalDatasetRecords = csvRows.map((row) => { - let contextArray: string[] = []; - let retrievalContextArray: string[] = []; - let metadataObj: Record = {}; - - if (row.context !== undefined && row.context) { - try { - const parsed = JSON.parse(row.context); - if (Array.isArray(parsed)) { - contextArray = parsed.filter((item) => typeof item === 'string'); - } else if (typeof parsed === 'string') { - contextArray = [parsed]; - } - } catch { - // If not JSON, treat as single string - contextArray = [row.context]; - } - } - - if (row.retrieval_context !== undefined && row.retrieval_context) { - try { - const parsed = JSON.parse(row.retrieval_context); - if (Array.isArray(parsed)) { - retrievalContextArray = parsed.filter((item) => typeof item === 'string'); - } else if (typeof parsed === 'string') { - retrievalContextArray = [parsed]; - } - } catch { - // If not JSON, treat as single string - retrievalContextArray = [row.retrieval_context]; - } - } - - if (row.metadata !== undefined && row.metadata) { - try { - const parsed = JSON.parse(row.metadata); - if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { - metadataObj = parsed; - } - } catch { - // Invalid JSON, use empty object - metadataObj = {}; - } - } - - return { - teamId, - tmbId, - evalDatasetCollectionId: collectionId, - [EvalDatasetDataKeyEnum.UserInput]: row.user_input, - [EvalDatasetDataKeyEnum.ExpectedOutput]: row.expected_output, - [EvalDatasetDataKeyEnum.ActualOutput]: row.actual_output || '', - [EvalDatasetDataKeyEnum.Context]: contextArray, - [EvalDatasetDataKeyEnum.RetrievalContext]: retrievalContextArray, - [EvalDatasetDataKeyEnum.Metadata]: { - ...metadataObj, - ...(enableQualityEvaluation - ? {} - : { qualityStatus: EvalDatasetDataQualityStatusEnum.unevaluated }) - }, - createFrom: EvalDatasetDataCreateFromEnum.fileImport - }; - }); - - const insertedRecords = await mongoSessionRun(async (session) => { - return await MongoEvalDatasetData.insertMany(evalDatasetRecords, { - session, - ordered: false // Continue if some documents fail - }); - }); - - if (enableQualityEvaluation && evaluationModel) { - const evaluationJobs = insertedRecords.map((record) => - addEvalDatasetDataQualityJob({ - dataId: record._id.toString(), - evaluationModel: evaluationModel - }) - ); - await Promise.allSettled(evaluationJobs); - } - - (async () => { - addAuditLog({ - tmbId, - teamId, - event: AuditEventEnum.IMPORT_EVALUATION_DATASET_DATA, - params: { - collectionName: datasetCollection.name, - recordCount: insertedRecords.length - } - }); - })(); - - return 'success'; - } catch (error: any) { - // Handle parsing errors - if (error.message && typeof error.message === 'string') { - return Promise.reject(EvaluationErrEnum.csvParsingError); - } - throw error; - } -} - -export default NextAPI(handler); - -// Export handler for testing -export const handler_test = process.env.NODE_ENV === 'test' ? handler : undefined; diff --git a/projects/app/src/pages/api/core/evaluation/dataset/data/import.ts b/projects/app/src/pages/api/core/evaluation/dataset/data/import.ts new file mode 100644 index 000000000000..8311741b8421 --- /dev/null +++ b/projects/app/src/pages/api/core/evaluation/dataset/data/import.ts @@ -0,0 +1,435 @@ +import { NextAPI } from '@/service/middleware/entry'; +import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; +import { MongoEvalDatasetCollection } from '@fastgpt/service/core/evaluation/dataset/evalDatasetCollectionSchema'; +import { MongoEvalDatasetData } from '@fastgpt/service/core/evaluation/dataset/evalDatasetDataSchema'; +import { + EvalDatasetDataCreateFromEnum, + EvalDatasetDataKeyEnum, + EvalDatasetDataQualityStatusEnum +} from '@fastgpt/global/core/evaluation/dataset/constants'; +import type { importEvalDatasetFromFileBody } from '@fastgpt/global/core/evaluation/dataset/api'; +import { addEvalDatasetDataQualityJob } from '@fastgpt/service/core/evaluation/dataset/dataQualityMq'; +import { addAuditLog } from '@fastgpt/service/support/user/audit/util'; +import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants'; +import { + authEvaluationDatasetDataWrite, + authEvaluationDatasetCreate +} from '@fastgpt/service/core/evaluation/common'; +import { + MAX_CSV_ROWS, + MAX_NAME_LENGTH, + MAX_DESCRIPTION_LENGTH +} from '@fastgpt/global/core/evaluation/constants'; +import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation'; +import { + checkTeamAIPoints, + checkTeamEvalDatasetLimit +} from '@fastgpt/service/support/permission/teamLimit'; +import { getUploadModel } from '@fastgpt/service/common/file/multer'; +import { removeFilesByPaths } from '@fastgpt/service/common/file/utils'; +import { getDefaultEvaluationModel } from '@fastgpt/service/core/ai/model'; +import { CommonErrEnum } from '@fastgpt/global/common/error/code/common'; +import type { NextApiRequest, NextApiResponse } from 'next'; + +export type EvalDatasetImportFromFileQuery = {}; +export type EvalDatasetImportFromFileBody = importEvalDatasetFromFileBody; +export type EvalDatasetImportFromFileResponse = string; + +const REQUIRED_CSV_COLUMNS = ['user_input', 'expected_output'] as const; + +const OPTIONAL_CSV_COLUMNS = ['actual_output', 'context', 'retrieval_context', 'metadata'] as const; + +const CSV_COLUMNS = [...REQUIRED_CSV_COLUMNS, ...OPTIONAL_CSV_COLUMNS] as const; + +const ENUM_TO_CSV_MAPPING = { + [EvalDatasetDataKeyEnum.UserInput]: 'user_input', + [EvalDatasetDataKeyEnum.ExpectedOutput]: 'expected_output', + [EvalDatasetDataKeyEnum.ActualOutput]: 'actual_output', + [EvalDatasetDataKeyEnum.Context]: 'context', + [EvalDatasetDataKeyEnum.RetrievalContext]: 'retrieval_context' +} as const; + +interface CSVRow { + user_input: string; + expected_output: string; + actual_output?: string; + context?: string; + retrieval_context?: string; + metadata?: string; +} + +function parseCSVLine(line: string): string[] { + const result: string[] = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + + if (char === '"') { + if (inQuotes && line[i + 1] === '"') { + // Escaped quote + current += '"'; + i++; + } else { + // Toggle quote state + inQuotes = !inQuotes; + } + } else if (char === ',' && !inQuotes) { + // End of field + result.push(current.trim()); + current = ''; + } else { + current += char; + } + } + + // Add the last field + result.push(current.trim()); + return result; +} + +function normalizeHeaderName(header: string): string { + const enumValue = header as keyof typeof ENUM_TO_CSV_MAPPING; + if (ENUM_TO_CSV_MAPPING[enumValue]) { + return ENUM_TO_CSV_MAPPING[enumValue]; + } + return header; +} + +function parseCSVContent(csvContent: string): CSVRow[] { + const lines = csvContent.split('\n').filter((line) => line.trim()); + + if (lines.length === 0) { + throw new Error('CSV file is empty'); + } + + // Parse header + const headerLine = lines[0]; + const rawHeaders = parseCSVLine(headerLine).map((h) => h.replace(/^"|"$/g, '')); + const headers = rawHeaders.map(normalizeHeaderName); + + // Validate CSV structure + const missingColumns = REQUIRED_CSV_COLUMNS.filter((col) => !headers.includes(col)); + if (missingColumns.length > 0) { + throw new Error(`CSV file is missing required columns: ${missingColumns.join(', ')}`); + } + + // Create column index mapping + const columnIndexes: Record = {}; + CSV_COLUMNS.forEach((col) => { + const index = headers.indexOf(col); + if (index !== -1) { + columnIndexes[col] = index; + } + }); + + // Parse data rows + const rows: CSVRow[] = []; + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; // Skip empty lines + + const fields = parseCSVLine(line); + + if (fields.length !== headers.length) { + throw new Error(`Row ${i + 1}: Expected ${headers.length} columns, got ${fields.length}`); + } + + const row: CSVRow = { + user_input: fields[columnIndexes.user_input]?.replace(/^"|"$/g, '') || '', + expected_output: fields[columnIndexes.expected_output]?.replace(/^"|"$/g, '') || '' + }; + + // Add optional fields + if (columnIndexes.actual_output !== undefined) { + row.actual_output = fields[columnIndexes.actual_output]?.replace(/^"|"$/g, '') || ''; + } + if (columnIndexes.context !== undefined) { + row.context = fields[columnIndexes.context]?.replace(/^"|"$/g, '') || ''; + } + if (columnIndexes.retrieval_context !== undefined) { + row.retrieval_context = fields[columnIndexes.retrieval_context]?.replace(/^"|"$/g, '') || ''; + } + if (columnIndexes.metadata !== undefined) { + row.metadata = fields[columnIndexes.metadata]?.replace(/^"|"$/g, '') || '{}'; + } + + rows.push(row); + } + + return rows; +} + +async function handler( + req: NextApiRequest, + res: NextApiResponse +): Promise { + const filePaths: string[] = []; + + try { + const upload = getUploadModel({ maxSize: global.feConfigs?.uploadFileMaxSize }); + const { files, data } = await upload.getUploadFiles(req, res); + + files.forEach((file) => filePaths.push(file.path)); + + if (!files || files.length === 0) { + return Promise.reject(EvaluationErrEnum.fileIdRequired); + } + + for (const file of files) { + const filename = file.originalname?.toLowerCase() || ''; + if (!filename.endsWith('.csv')) { + return Promise.reject(EvaluationErrEnum.fileMustBeCSV); + } + } + + const { collectionId, name, description, enableQualityEvaluation, evaluationModel } = data; + + if (!collectionId && !name) { + return Promise.reject(CommonErrEnum.missingParams); + } + if (collectionId && name) { + return Promise.reject(CommonErrEnum.invalidParams); + } + + if (typeof enableQualityEvaluation !== 'boolean') { + return Promise.reject(EvaluationErrEnum.datasetDataEnableQualityEvalRequired); + } + if (enableQualityEvaluation && (!evaluationModel || typeof evaluationModel !== 'string')) { + return Promise.reject(EvaluationErrEnum.datasetDataEvaluationModelRequiredForQuality); + } + if (evaluationModel && !global.llmModelMap.has(evaluationModel)) { + return Promise.reject(EvaluationErrEnum.datasetModelNotFound); + } + + let teamId: string; + let tmbId: string; + let targetCollectionId: string; + let datasetCollection: any; + + if (collectionId) { + // Mode 1: Use existing collection + const authResult = await authEvaluationDatasetDataWrite(collectionId, { + req, + authToken: true, + authApiKey: true + }); + teamId = authResult.teamId; + tmbId = authResult.tmbId; + targetCollectionId = collectionId; + + datasetCollection = await MongoEvalDatasetCollection.findById(collectionId); + if (!datasetCollection) { + return Promise.reject(EvaluationErrEnum.datasetCollectionNotFound); + } + if (String(datasetCollection.teamId) !== teamId) { + return Promise.reject(EvaluationErrEnum.evalInsufficientPermission); + } + } else { + // Mode 2: Create new collection + if (typeof name !== 'string' || name.trim().length === 0) { + return Promise.reject(EvaluationErrEnum.evalNameRequired); + } + if (name.trim().length > MAX_NAME_LENGTH) { + return Promise.reject(EvaluationErrEnum.evalNameTooLong); + } + if (description && typeof description !== 'string') { + return Promise.reject(EvaluationErrEnum.evalDescriptionInvalidType); + } + if (description && description.length > MAX_DESCRIPTION_LENGTH) { + return Promise.reject(EvaluationErrEnum.evalDescriptionTooLong); + } + + const authResult = await authEvaluationDatasetCreate({ + req, + authToken: true, + authApiKey: true + }); + teamId = authResult.teamId; + tmbId = authResult.tmbId; + + // Check evaluation dataset limit + await checkTeamEvalDatasetLimit(teamId); + + // Check for duplicate collection name + const existingCollection = await MongoEvalDatasetCollection.findOne({ + teamId, + name: name!.trim() + }); + if (existingCollection) { + return Promise.reject(EvaluationErrEnum.evalDuplicateDatasetName); + } + + // Create new collection + const defaultEvaluationModel = getDefaultEvaluationModel(); + const evaluationModelToUse = evaluationModel || defaultEvaluationModel?.model; + + const collectionData = await mongoSessionRun(async (session) => { + const [collection] = await MongoEvalDatasetCollection.create( + [ + { + teamId, + tmbId, + name: name!.trim(), + description: (description || '').trim(), + evaluationModel: evaluationModelToUse + } + ], + { session, ordered: true } + ); + return collection; + }); + + datasetCollection = collectionData; + targetCollectionId = String(collectionData._id); + } + + // Check AI points if quality evaluation is enabled + if (enableQualityEvaluation && evaluationModel) { + await checkTeamAIPoints(teamId); + } + + // Process all CSV files + let allEvalDatasetRecords: any[] = []; + let totalRows = 0; + + for (const file of files) { + // Read file content from disk (since it's uploaded via form-data) + const fs = require('fs'); + const rawText = fs.readFileSync(file.path, 'utf8'); + + const csvRows = parseCSVContent(rawText); + + if (csvRows.length === 0) { + continue; // Skip empty files + } + + totalRows += csvRows.length; + + // Convert CSV rows to dataset records + const evalDatasetRecords = csvRows.map((row) => { + let contextArray: string[] = []; + let retrievalContextArray: string[] = []; + let metadataObj: Record = {}; + + if (row.context !== undefined && row.context) { + try { + const parsed = JSON.parse(row.context); + if (Array.isArray(parsed)) { + contextArray = parsed.filter((item) => typeof item === 'string'); + } else if (typeof parsed === 'string') { + contextArray = [parsed]; + } + } catch { + contextArray = [row.context]; + } + } + + if (row.retrieval_context !== undefined && row.retrieval_context) { + try { + const parsed = JSON.parse(row.retrieval_context); + if (Array.isArray(parsed)) { + retrievalContextArray = parsed.filter((item) => typeof item === 'string'); + } else if (typeof parsed === 'string') { + retrievalContextArray = [parsed]; + } + } catch { + retrievalContextArray = [row.retrieval_context]; + } + } + + if (row.metadata !== undefined && row.metadata) { + try { + const parsed = JSON.parse(row.metadata); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + metadataObj = parsed; + } + } catch { + metadataObj = {}; + } + } + + return { + teamId, + tmbId, + evalDatasetCollectionId: targetCollectionId, + [EvalDatasetDataKeyEnum.UserInput]: row.user_input, + [EvalDatasetDataKeyEnum.ExpectedOutput]: row.expected_output, + [EvalDatasetDataKeyEnum.ActualOutput]: row.actual_output || '', + [EvalDatasetDataKeyEnum.Context]: contextArray, + [EvalDatasetDataKeyEnum.RetrievalContext]: retrievalContextArray, + [EvalDatasetDataKeyEnum.Metadata]: { + ...metadataObj, + importedFromFile: file.originalname, + ...(enableQualityEvaluation + ? {} + : { qualityStatus: EvalDatasetDataQualityStatusEnum.unevaluated }) + }, + createFrom: EvalDatasetDataCreateFromEnum.fileImport + }; + }); + + allEvalDatasetRecords.push(...evalDatasetRecords); + } + + // Validate total row count + if (totalRows === 0) { + return Promise.reject(EvaluationErrEnum.csvNoDataRows); + } + if (totalRows > MAX_CSV_ROWS) { + return Promise.reject(EvaluationErrEnum.csvTooManyRows); + } + + // Insert all records + const insertedRecords = await mongoSessionRun(async (session) => { + return await MongoEvalDatasetData.insertMany(allEvalDatasetRecords, { + session, + ordered: false + }); + }); + + // Queue quality evaluation jobs if enabled + if (enableQualityEvaluation && evaluationModel) { + const evaluationJobs = insertedRecords.map((record) => + addEvalDatasetDataQualityJob({ + dataId: record._id.toString(), + evaluationModel: evaluationModel + }) + ); + await Promise.allSettled(evaluationJobs); + } + + (async () => { + addAuditLog({ + tmbId, + teamId, + event: AuditEventEnum.IMPORT_EVALUATION_DATASET_DATA, + params: { + collectionName: datasetCollection.name, + recordCount: insertedRecords.length + } + }); + })(); + + return 'success'; + } catch (error: any) { + if (error.message && typeof error.message === 'string') { + return Promise.reject(EvaluationErrEnum.csvParsingError); + } + throw error; + } finally { + removeFilesByPaths(filePaths); + } +} + +export default NextAPI(handler); + +// Export handler for testing +export const handler_test = process.env.NODE_ENV === 'test' ? handler : undefined; + +export const config = { + api: { + bodyParser: false + } +}; diff --git a/test/cases/pages/api/core/evaluation/dataset/data/fileId.test.ts b/test/cases/pages/api/core/evaluation/dataset/data/fileId.test.ts deleted file mode 100644 index efa2bd858e0f..000000000000 --- a/test/cases/pages/api/core/evaluation/dataset/data/fileId.test.ts +++ /dev/null @@ -1,1089 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { handler_test } from '@/pages/api/core/evaluation/dataset/data/fileId'; -import { authEvalDatasetCollectionFile } from '@fastgpt/service/support/permission/evaluation/auth'; -import { authEvaluationDatasetDataWrite } from '@fastgpt/service/core/evaluation/common'; -import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; -import { MongoEvalDatasetData } from '@fastgpt/service/core/evaluation/dataset/evalDatasetDataSchema'; -import { MongoEvalDatasetCollection } from '@fastgpt/service/core/evaluation/dataset/evalDatasetCollectionSchema'; -import { readFileContentFromMongo } from '@fastgpt/service/common/file/gridfs/controller'; -import { WritePermissionVal } from '@fastgpt/global/support/permission/constant'; -import { BucketNameEnum } from '@fastgpt/global/common/file/constants'; -import { - EvalDatasetDataCreateFromEnum, - EvalDatasetDataKeyEnum -} from '@fastgpt/global/core/evaluation/dataset/constants'; -import { addEvalDatasetDataQualityJob } from '@fastgpt/service/core/evaluation/dataset/dataQualityMq'; - -vi.mock('@fastgpt/service/support/permission/evaluation/auth'); -vi.mock('@fastgpt/service/core/evaluation/common'); -vi.mock('@fastgpt/service/common/mongo/sessionRun'); -vi.mock('@fastgpt/service/core/evaluation/dataset/evalDatasetDataSchema', () => ({ - MongoEvalDatasetData: { - insertMany: vi.fn() - } -})); -vi.mock( - '@fastgpt/service/core/evaluation/dataset/evalDatasetCollectionSchema', - async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - MongoEvalDatasetCollection: { - findById: vi.fn() - } - }; - } -); -vi.mock('@fastgpt/service/common/file/gridfs/controller', () => ({ - readFileContentFromMongo: vi.fn() -})); -vi.mock('@fastgpt/service/core/evaluation/dataset/dataQualityMq', () => ({ - addEvalDatasetDataQualityJob: vi.fn() -})); -vi.mock('@fastgpt/service/support/user/audit/util', () => ({ - addAuditLog: vi.fn() -})); - -const mockAuthEvalDatasetCollectionFile = vi.mocked(authEvalDatasetCollectionFile); -const mockAuthEvaluationDatasetDataWrite = vi.mocked(authEvaluationDatasetDataWrite); -const mockMongoSessionRun = vi.mocked(mongoSessionRun); -const mockMongoEvalDatasetData = vi.mocked(MongoEvalDatasetData); -const mockMongoEvalDatasetCollection = vi.mocked(MongoEvalDatasetCollection); -const mockReadFileContentFromMongo = vi.mocked(readFileContentFromMongo); -const mockAddEvalDatasetDataQualityJob = vi.mocked(addEvalDatasetDataQualityJob); - -// Mock global.llmModelMap -beforeEach(() => { - global.llmModelMap = new Map([ - ['gpt-4', { model: 'gpt-4' }], - ['gpt-3.5-turbo', { model: 'gpt-3.5-turbo' }] - ]) as any; -}); - -describe('EvalDatasetData FileId Import API', () => { - const validTeamId = 'team123'; - const validTmbId = 'tmb123'; - const validFileId = 'file123'; - const validCollectionId = '65f5b5b5b5b5b5b5b5b5b5b5'; - const mockInsertedRecords = [ - { _id: '65f5b5b5b5b5b5b5b5b5b5b6' }, - { _id: '65f5b5b5b5b5b5b5b5b5b5b7' } - ]; - - const validCSVContent = `user_input,expected_output,actual_output,context,retrieval_context,metadata -"What is AI?","Artificial Intelligence","AI is...","[""tech"",""science""]","[""AI overview""]","{""category"":""tech""}" -"Define ML","Machine Learning","","","","{}"`; - - beforeEach(() => { - vi.clearAllMocks(); - - // Reset global.llmModelMap - global.llmModelMap = new Map([ - ['gpt-4', { model: 'gpt-4' }], - ['gpt-3.5-turbo', { model: 'gpt-3.5-turbo' }] - ]) as any; - - mockAuthEvalDatasetCollectionFile.mockResolvedValue({ - teamId: validTeamId, - tmbId: validTmbId, - file: { - _id: validFileId, - filename: 'test.csv', - metadata: { - teamId: validTeamId, - tmbId: validTmbId - } - } - } as any); - - mockAuthEvaluationDatasetDataWrite.mockResolvedValue({ - teamId: validTeamId, - tmbId: validTmbId, - collectionId: validCollectionId - }); - - // Don't set default CSV content - let each test set its own - mockReadFileContentFromMongo.mockResolvedValue({ - rawText: validCSVContent - }); - - mockMongoSessionRun.mockImplementation(async (callback) => { - return callback({} as any); - }); - - mockMongoEvalDatasetData.insertMany.mockResolvedValue(mockInsertedRecords as any); - mockMongoEvalDatasetCollection.findById.mockResolvedValue({ - _id: validCollectionId, - teamId: validTeamId, - name: 'Test Collection' - } as any); - mockAddEvalDatasetDataQualityJob.mockResolvedValue({} as any); - }); - - describe('Parameter Validation', () => { - const validationTests = [ - { - name: 'should reject when fileId is missing', - body: { collectionId: validCollectionId, enableQualityEvaluation: false }, - expectedError: 'evaluationFileIdRequired' - }, - { - name: 'should reject when fileId is not a string', - body: { fileId: 123, collectionId: validCollectionId, enableQualityEvaluation: false }, - expectedError: 'evaluationFileIdRequired' - }, - { - name: 'should reject when collectionId is missing', - body: { fileId: validFileId, enableQualityEvaluation: false }, - expectedError: 'evaluationDatasetCollectionIdRequired' - }, - { - name: 'should reject when collectionId is not a string', - body: { fileId: validFileId, collectionId: 123, enableQualityEvaluation: false }, - expectedError: 'evaluationDatasetCollectionIdRequired' - }, - { - name: 'should reject when enableQualityEvaluation is missing', - body: { fileId: validFileId, collectionId: validCollectionId }, - expectedError: 'evaluationDatasetDataEnableQualityEvalRequired' - }, - { - name: 'should reject when enableQualityEvaluation is not a boolean', - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: 'true' - }, - expectedError: 'evaluationDatasetDataEnableQualityEvalRequired' - }, - { - name: 'should reject when enableQualityEvaluation is true but evaluationModel is missing', - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: true - }, - expectedError: 'evaluationDatasetDataEvaluationModelRequiredForQuality' - }, - { - name: 'should reject when enableQualityEvaluation is true but evaluationModel is not a string', - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: true, - evaluationModel: 123 - }, - expectedError: 'evaluationDatasetDataEvaluationModelRequiredForQuality' - } - ]; - - it.skip('Parameter validation tests - handler has implementation bug where validation is not awaited', () => { - // These tests are skipped due to a bug in the handler where validateRequestParams - // returns Promise.reject() but is not awaited, causing validation to be bypassed - // The validation logic should be fixed in the handler to either: - // 1. await validateRequestParams(...) - // 2. or make validateRequestParams throw synchronously - }); - }); - - describe('Authentication and Authorization', () => { - it('should call authEvalCollectionFile with correct parameters', async () => { - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - await handler_test(req as any); - - expect(mockAuthEvalDatasetCollectionFile).toHaveBeenCalledWith({ - req, - authToken: true, - authApiKey: true, - fileId: validFileId, - per: WritePermissionVal - }); - }); - - it('should propagate authentication errors', async () => { - const authError = new Error('unAuthorization'); - mockAuthEvalDatasetCollectionFile.mockRejectedValue(authError); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - await expect(handler_test(req as any)).rejects.toThrow('unAuthorization'); - }); - - it('should propagate file authentication errors', async () => { - const fileAuthError = new Error('unAuthorization'); - mockAuthEvalDatasetCollectionFile.mockRejectedValue(fileAuthError); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - await expect(handler_test(req as any)).rejects.toThrow('unAuthorization'); - }); - }); - - describe('File Validation', () => { - it('should reject non-CSV files', async () => { - mockAuthEvalDatasetCollectionFile.mockResolvedValue({ - teamId: validTeamId, - tmbId: validTmbId, - file: { - _id: validFileId, - filename: 'test.txt', - metadata: { qualityStatus: 'unevaluated' } - } - } as any); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - await expect(handler_test(req as any)).rejects.toBe('evaluationFileMustBeCSV'); - }); - - it('should handle files with uppercase CSV extension', async () => { - mockAuthEvalDatasetCollectionFile.mockResolvedValue({ - teamId: validTeamId, - tmbId: validTmbId, - file: { - _id: validFileId, - filename: 'test.CSV', - metadata: { qualityStatus: 'unevaluated' } - } - } as any); - - const uppercaseCSV = `user_input,expected_output -"What is AI?","Artificial Intelligence"`; - - mockReadFileContentFromMongo.mockResolvedValue({ - rawText: uppercaseCSV - }); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - const result = await handler_test(req as any); - expect(result).toBe('success'); - }); - - it('should handle files without filename', async () => { - mockAuthEvalDatasetCollectionFile.mockResolvedValue({ - teamId: validTeamId, - tmbId: validTmbId, - file: { - _id: validFileId, - filename: undefined, - metadata: { qualityStatus: 'unevaluated' } - } - } as any); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - await expect(handler_test(req as any)).rejects.toBe('evaluationFileMustBeCSV'); - }); - }); - - describe('Dataset Collection Validation', () => { - it('should reject when dataset collection access is denied', async () => { - mockAuthEvaluationDatasetDataWrite.mockRejectedValue( - new Error('Dataset collection not found or access denied') - ); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - await expect(handler_test(req as any)).rejects.toThrow( - 'Dataset collection not found or access denied' - ); - }); - - it('should reject when dataset collection belongs to different team', async () => { - mockAuthEvaluationDatasetDataWrite.mockRejectedValue( - new Error('No permission to access this dataset collection') - ); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - await expect(handler_test(req as any)).rejects.toThrow( - 'No permission to access this dataset collection' - ); - }); - }); - - describe('CSV Parsing', () => { - it('should parse valid CSV with all columns', async () => { - // Override the mock to return the full CSV with all columns - mockReadFileContentFromMongo.mockResolvedValue({ - rawText: validCSVContent - }); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - const result = await handler_test(req as any); - - expect(mockReadFileContentFromMongo).toHaveBeenCalledWith({ - teamId: validTeamId, - tmbId: validTmbId, - bucketName: BucketNameEnum.evaluation, - fileId: validFileId, - getFormatText: false - }); - - expect(mockMongoEvalDatasetData.insertMany).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - teamId: validTeamId, - tmbId: validTmbId, - evalDatasetCollectionId: validCollectionId, - [EvalDatasetDataKeyEnum.UserInput]: 'What is AI?', - [EvalDatasetDataKeyEnum.ExpectedOutput]: 'Artificial Intelligence', - [EvalDatasetDataKeyEnum.ActualOutput]: 'AI is...', - [EvalDatasetDataKeyEnum.Context]: ['tech', 'science'], - [EvalDatasetDataKeyEnum.RetrievalContext]: ['AI overview'], - metadata: { category: 'tech', qualityStatus: 'unevaluated' }, - createFrom: EvalDatasetDataCreateFromEnum.fileImport - }), - expect.objectContaining({ - teamId: validTeamId, - tmbId: validTmbId, - evalDatasetCollectionId: validCollectionId, - [EvalDatasetDataKeyEnum.UserInput]: 'Define ML', - [EvalDatasetDataKeyEnum.ExpectedOutput]: 'Machine Learning', - [EvalDatasetDataKeyEnum.ActualOutput]: '', - [EvalDatasetDataKeyEnum.Context]: [], - [EvalDatasetDataKeyEnum.RetrievalContext]: [], - metadata: { qualityStatus: 'unevaluated' }, - createFrom: EvalDatasetDataCreateFromEnum.fileImport - }) - ]), - { - session: {}, - ordered: false - } - ); - - expect(result).toBe('success'); - }); - - it('should parse CSV with only required columns', async () => { - const minimalCSV = `user_input,expected_output -"What is AI?","Artificial Intelligence" -"Define ML","Machine Learning"`; - - mockReadFileContentFromMongo.mockResolvedValue({ - rawText: minimalCSV - }); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - const result = await handler_test(req as any); - - expect(mockMongoEvalDatasetData.insertMany).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - [EvalDatasetDataKeyEnum.UserInput]: 'What is AI?', - [EvalDatasetDataKeyEnum.ExpectedOutput]: 'Artificial Intelligence', - [EvalDatasetDataKeyEnum.ActualOutput]: '', - [EvalDatasetDataKeyEnum.Context]: [], - [EvalDatasetDataKeyEnum.RetrievalContext]: [], - metadata: { qualityStatus: 'unevaluated' } - }) - ]), - expect.any(Object) - ); - - expect(result).toBe('success'); - }); - - it('should reject CSV missing required columns', async () => { - const invalidCSV = `question,answer -"What is AI?","Artificial Intelligence"`; - - mockReadFileContentFromMongo.mockResolvedValue({ - rawText: invalidCSV - }); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - await expect(handler_test(req as any)).rejects.toBe('evaluationCSVParsingError'); - }); - - it('should reject empty CSV file', async () => { - mockReadFileContentFromMongo.mockResolvedValue({ - rawText: '' - }); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - await expect(handler_test(req as any)).rejects.toBe('evaluationCSVParsingError'); - }); - - it('should reject CSV with no data rows', async () => { - const headerOnlyCSV = 'user_input,expected_output'; - - mockReadFileContentFromMongo.mockResolvedValue({ - rawText: headerOnlyCSV - }); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - await expect(handler_test(req as any)).rejects.toBe('evaluationCSVNoDataRows'); - }); - - it('should reject CSV with too many rows', async () => { - const largeCSVHeader = 'user_input,expected_output\n'; - const largeCSVRows = Array.from( - { length: 10001 }, - (_, i) => `"Question ${i}","Answer ${i}"` - ).join('\n'); - const largeCSV = largeCSVHeader + largeCSVRows; - - mockReadFileContentFromMongo.mockResolvedValue({ - rawText: largeCSV - }); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - await expect(handler_test(req as any)).rejects.toBe('evaluationCSVTooManyRows'); - }); - - it('should handle CSV with inconsistent column count', async () => { - const inconsistentCSV = `user_input,expected_output -"What is AI?","Artificial Intelligence" -"Define ML","Machine Learning","Extra column"`; - - mockReadFileContentFromMongo.mockResolvedValue({ - rawText: inconsistentCSV - }); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - await expect(handler_test(req as any)).rejects.toBe('evaluationCSVParsingError'); - }); - - it('should handle CSV with quoted fields containing commas', async () => { - const quotedCSV = `user_input,expected_output -"What is AI, really?","Artificial Intelligence, a branch of computer science"`; - - mockReadFileContentFromMongo.mockResolvedValue({ - rawText: quotedCSV - }); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - const result = await handler_test(req as any); - - expect(mockMongoEvalDatasetData.insertMany).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - [EvalDatasetDataKeyEnum.UserInput]: 'What is AI, really?', - [EvalDatasetDataKeyEnum.ExpectedOutput]: - 'Artificial Intelligence, a branch of computer science' - }) - ]), - expect.any(Object) - ); - - expect(result).toBe('success'); - }); - - it('should handle CSV with escaped quotes', async () => { - const escapedQuotesCSV = `user_input,expected_output -"What is ""AI""?","It's ""Artificial Intelligence""."`; - - mockReadFileContentFromMongo.mockResolvedValue({ - rawText: escapedQuotesCSV - }); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - const result = await handler_test(req as any); - - expect(mockMongoEvalDatasetData.insertMany).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - [EvalDatasetDataKeyEnum.UserInput]: 'What is "AI"?', - [EvalDatasetDataKeyEnum.ExpectedOutput]: 'It\'s "Artificial Intelligence".' - }) - ]), - expect.any(Object) - ); - - expect(result).toBe('success'); - }); - - it('should handle CSV with enum column names', async () => { - const enumCSV = `userInput,expectedOutput -"What is AI?","Artificial Intelligence"`; - - mockReadFileContentFromMongo.mockResolvedValue({ - rawText: enumCSV - }); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - const result = await handler_test(req as any); - - expect(mockMongoEvalDatasetData.insertMany).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - [EvalDatasetDataKeyEnum.UserInput]: 'What is AI?', - [EvalDatasetDataKeyEnum.ExpectedOutput]: 'Artificial Intelligence' - }) - ]), - expect.any(Object) - ); - - expect(result).toBe('success'); - }); - }); - - describe('Context and Metadata Parsing', () => { - it('should parse JSON context arrays', async () => { - const contextCSV = `user_input,expected_output,context -"What is AI?","Artificial Intelligence","[""tech"", ""science""]"`; - - mockReadFileContentFromMongo.mockResolvedValue({ - rawText: contextCSV - }); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - await handler_test(req as any); - - expect(mockMongoEvalDatasetData.insertMany).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - [EvalDatasetDataKeyEnum.Context]: ['tech', 'science'] - }) - ]), - expect.any(Object) - ); - }); - - it('should parse single string context', async () => { - const contextCSV = `user_input,expected_output,context -"What is AI?","Artificial Intelligence","technology"`; - - mockReadFileContentFromMongo.mockResolvedValue({ - rawText: contextCSV - }); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - await handler_test(req as any); - - expect(mockMongoEvalDatasetData.insertMany).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - [EvalDatasetDataKeyEnum.Context]: ['technology'] - }) - ]), - expect.any(Object) - ); - }); - - it('should handle invalid JSON context gracefully', async () => { - const contextCSV = `user_input,expected_output,context -"What is AI?","Artificial Intelligence","invalid json"`; - - mockReadFileContentFromMongo.mockResolvedValue({ - rawText: contextCSV - }); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - await handler_test(req as any); - - expect(mockMongoEvalDatasetData.insertMany).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - [EvalDatasetDataKeyEnum.Context]: ['invalid json'] - }) - ]), - expect.any(Object) - ); - }); - - it('should parse metadata objects', async () => { - const metadataCSV = `user_input,expected_output,metadata -"What is AI?","Artificial Intelligence","{""category"": ""tech"", ""priority"": 1}"`; - - mockReadFileContentFromMongo.mockResolvedValue({ - rawText: metadataCSV - }); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - await handler_test(req as any); - - expect(mockMongoEvalDatasetData.insertMany).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - metadata: { category: 'tech', priority: 1, qualityStatus: 'unevaluated' } - }) - ]), - expect.any(Object) - ); - }); - - it('should handle invalid JSON metadata gracefully', async () => { - const metadataCSV = `user_input,expected_output,metadata -"What is AI?","Artificial Intelligence","invalid json"`; - - mockReadFileContentFromMongo.mockResolvedValue({ - rawText: metadataCSV - }); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - await handler_test(req as any); - - expect(mockMongoEvalDatasetData.insertMany).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - metadata: { qualityStatus: 'unevaluated' } - }) - ]), - expect.any(Object) - ); - }); - - it('should filter out non-string items from context arrays', async () => { - const contextCSV = `user_input,expected_output,context -"What is AI?","Artificial Intelligence","[""tech"", 123, ""science"", null, ""AI""]"`; - - mockReadFileContentFromMongo.mockResolvedValue({ - rawText: contextCSV - }); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - await handler_test(req as any); - - expect(mockMongoEvalDatasetData.insertMany).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - [EvalDatasetDataKeyEnum.Context]: ['tech', 'science', 'AI'] - }) - ]), - expect.any(Object) - ); - }); - }); - - describe('Quality Evaluation', () => { - it('should not trigger quality evaluation when disabled', async () => { - const simpleCSV = `user_input,expected_output -"What is AI?","Artificial Intelligence"`; - - mockReadFileContentFromMongo.mockResolvedValue({ - rawText: simpleCSV - }); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - await handler_test(req as any); - - expect(mockAddEvalDatasetDataQualityJob).not.toHaveBeenCalled(); - }); - - it('should trigger quality evaluation when enabled', async () => { - const evaluationModel = 'gpt-4'; - // Override the mock to return the full CSV with all columns - mockReadFileContentFromMongo.mockResolvedValue({ - rawText: validCSVContent - }); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: true, - evaluationModel - } - }; - - await handler_test(req as any); - - expect(mockAddEvalDatasetDataQualityJob).toHaveBeenCalledTimes(2); - expect(mockAddEvalDatasetDataQualityJob).toHaveBeenCalledWith({ - dataId: '65f5b5b5b5b5b5b5b5b5b5b6', - evaluationModel: evaluationModel - }); - expect(mockAddEvalDatasetDataQualityJob).toHaveBeenCalledWith({ - dataId: '65f5b5b5b5b5b5b5b5b5b5b7', - evaluationModel: evaluationModel - }); - }); - - it('should handle quality evaluation job failures gracefully', async () => { - const evaluationModel = 'gpt-4'; - mockAddEvalDatasetDataQualityJob.mockRejectedValueOnce(new Error('Queue error')); - - const simpleCSV = `user_input,expected_output -"What is AI?","Artificial Intelligence"`; - - mockReadFileContentFromMongo.mockResolvedValue({ - rawText: simpleCSV - }); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: true, - evaluationModel - } - }; - - const result = await handler_test(req as any); - - // Should still succeed even if some quality evaluation jobs fail - expect(result).toBe('success'); - }); - }); - - describe('Database Operations', () => { - it('should use session for database operations', async () => { - const simpleCSV = `user_input,expected_output -"What is AI?","Artificial Intelligence"`; - - mockReadFileContentFromMongo.mockResolvedValue({ - rawText: simpleCSV - }); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - await handler_test(req as any); - - expect(mockMongoSessionRun).toHaveBeenCalledWith(expect.any(Function)); - expect(mockMongoEvalDatasetData.insertMany).toHaveBeenCalledWith(expect.any(Array), { - session: {}, - ordered: false - }); - }); - - it('should handle database insertion errors', async () => { - const dbError = new Error('Database error'); - mockMongoSessionRun.mockRejectedValue(dbError); - - const simpleCSV = `user_input,expected_output -"What is AI?","Artificial Intelligence"`; - - mockReadFileContentFromMongo.mockResolvedValue({ - rawText: simpleCSV - }); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - await expect(handler_test(req as any)).rejects.toBe('evaluationCSVParsingError'); - }); - - it('should propagate file reading errors', async () => { - const fileError = new Error('File read error'); - mockReadFileContentFromMongo.mockRejectedValue(fileError); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - await expect(handler_test(req as any)).rejects.toBe('evaluationCSVParsingError'); - }); - }); - - describe('Edge Cases', () => { - it('should handle CSV with empty lines', async () => { - const csvWithEmptyLines = `user_input,expected_output - -"What is AI?","Artificial Intelligence" - -"Define ML","Machine Learning" -`; - - mockReadFileContentFromMongo.mockResolvedValue({ - rawText: csvWithEmptyLines - }); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - const result = await handler_test(req as any); - - expect(mockMongoEvalDatasetData.insertMany).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - [EvalDatasetDataKeyEnum.UserInput]: 'What is AI?' - }), - expect.objectContaining({ - [EvalDatasetDataKeyEnum.UserInput]: 'Define ML' - }) - ]), - expect.any(Object) - ); - - expect(result).toBe('success'); - }); - - it('should handle special characters in CSV content', async () => { - const specialCharCSV = `user_input,expected_output -"What is AI? 🤖","人工智能 (AI) is..." -"Définir ML","Machine Learning avec émojis 📊"`; - - mockReadFileContentFromMongo.mockResolvedValue({ - rawText: specialCharCSV - }); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - const result = await handler_test(req as any); - - expect(mockMongoEvalDatasetData.insertMany).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - [EvalDatasetDataKeyEnum.UserInput]: 'What is AI? 🤖', - [EvalDatasetDataKeyEnum.ExpectedOutput]: '人工智能 (AI) is...' - }) - ]), - expect.any(Object) - ); - - expect(result).toBe('success'); - }); - - it('should handle CSV with special characters in fields', async () => { - const specialCharCSV = `user_input,expected_output -"What is AI? (Define it)","Artificial Intelligence: a field of computer science" -"ML vs DL?","Machine Learning differs from Deep Learning"`; - - mockReadFileContentFromMongo.mockResolvedValue({ - rawText: specialCharCSV - }); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - const result = await handler_test(req as any); - - expect(mockMongoEvalDatasetData.insertMany).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - [EvalDatasetDataKeyEnum.UserInput]: 'What is AI? (Define it)', - [EvalDatasetDataKeyEnum.ExpectedOutput]: - 'Artificial Intelligence: a field of computer science' - }), - expect.objectContaining({ - [EvalDatasetDataKeyEnum.UserInput]: 'ML vs DL?', - [EvalDatasetDataKeyEnum.ExpectedOutput]: 'Machine Learning differs from Deep Learning' - }) - ]), - expect.any(Object) - ); - - expect(result).toBe('success'); - }); - }); - - describe('Return Values', () => { - it('should return success string on successful import', async () => { - const simpleCSV = `user_input,expected_output -"What is AI?","Artificial Intelligence"`; - - mockReadFileContentFromMongo.mockResolvedValue({ - rawText: simpleCSV - }); - - const req = { - body: { - fileId: validFileId, - collectionId: validCollectionId, - enableQualityEvaluation: false - } - }; - - const result = await handler_test(req as any); - expect(result).toBe('success'); - expect(typeof result).toBe('string'); - }); - - it.skip('should return error messages as strings - skipped due to validation bug', () => { - // This test is skipped because the handler validation bug prevents proper error testing - }); - }); -}); diff --git a/test/cases/pages/api/core/evaluation/dataset/data/import.test.ts b/test/cases/pages/api/core/evaluation/dataset/data/import.test.ts new file mode 100644 index 000000000000..14dd3043f9a8 --- /dev/null +++ b/test/cases/pages/api/core/evaluation/dataset/data/import.test.ts @@ -0,0 +1,534 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { handler_test } from '@/pages/api/core/evaluation/dataset/data/import'; +import { + authEvaluationDatasetDataWrite, + authEvaluationDatasetCreate +} from '@fastgpt/service/core/evaluation/common'; +import { mongoSessionRun } from '@fastgpt/service/common/mongo/sessionRun'; +import { MongoEvalDatasetData } from '@fastgpt/service/core/evaluation/dataset/evalDatasetDataSchema'; +import { MongoEvalDatasetCollection } from '@fastgpt/service/core/evaluation/dataset/evalDatasetCollectionSchema'; +import { getUploadModel } from '@fastgpt/service/common/file/multer'; +import { removeFilesByPaths } from '@fastgpt/service/common/file/utils'; +import { + checkTeamAIPoints, + checkTeamEvalDatasetLimit +} from '@fastgpt/service/support/permission/teamLimit'; +import { getDefaultEvaluationModel } from '@fastgpt/service/core/ai/model'; +import { + EvalDatasetDataCreateFromEnum, + EvalDatasetDataKeyEnum +} from '@fastgpt/global/core/evaluation/dataset/constants'; +import { addEvalDatasetDataQualityJob } from '@fastgpt/service/core/evaluation/dataset/dataQualityMq'; + +vi.mock('@fastgpt/service/core/evaluation/common'); +vi.mock('@fastgpt/service/common/mongo/sessionRun'); +vi.mock('@fastgpt/service/common/file/multer'); +vi.mock('@fastgpt/service/common/file/utils'); +vi.mock('@fastgpt/service/support/permission/teamLimit'); +vi.mock('@fastgpt/service/core/ai/model'); +vi.mock('@fastgpt/service/core/evaluation/dataset/evalDatasetDataSchema', () => ({ + MongoEvalDatasetData: { + insertMany: vi.fn() + } +})); +vi.mock( + '@fastgpt/service/core/evaluation/dataset/evalDatasetCollectionSchema', + async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + MongoEvalDatasetCollection: { + findById: vi.fn(), + findOne: vi.fn(), + create: vi.fn() + } + }; + } +); +vi.mock('@fastgpt/service/core/evaluation/dataset/dataQualityMq', () => ({ + addEvalDatasetDataQualityJob: vi.fn() +})); +vi.mock('@fastgpt/service/support/user/audit/util', () => ({ + addAuditLog: vi.fn() +})); + +const mockAuthEvaluationDatasetDataWrite = vi.mocked(authEvaluationDatasetDataWrite); +const mockAuthEvaluationDatasetCreate = vi.mocked(authEvaluationDatasetCreate); +const mockGetUploadModel = vi.mocked(getUploadModel); +const mockRemoveFilesByPaths = vi.mocked(removeFilesByPaths); +const mockCheckTeamAIPoints = vi.mocked(checkTeamAIPoints); +const mockCheckTeamEvalDatasetLimit = vi.mocked(checkTeamEvalDatasetLimit); +const mockGetDefaultEvaluationModel = vi.mocked(getDefaultEvaluationModel); +const mockMongoSessionRun = vi.mocked(mongoSessionRun); +const mockMongoEvalDatasetData = vi.mocked(MongoEvalDatasetData); +const mockMongoEvalDatasetCollection = vi.mocked(MongoEvalDatasetCollection); +const mockAddEvalDatasetDataQualityJob = vi.mocked(addEvalDatasetDataQualityJob); + +// Mock global.llmModelMap +beforeEach(() => { + global.llmModelMap = new Map([ + ['gpt-4', { model: 'gpt-4' }], + ['gpt-3.5-turbo', { model: 'gpt-3.5-turbo' }] + ]) as any; +}); + +describe('EvalDatasetData Import API', () => { + const validTeamId = 'team123'; + const validTmbId = 'tmb123'; + const validCollectionId = '65f5b5b5b5b5b5b5b5b5b5b5'; + const mockInsertedRecords = [ + { _id: '65f5b5b5b5b5b5b5b5b5b5b6' }, + { _id: '65f5b5b5b5b5b5b5b5b5b5b7' } + ]; + + const validCSVContent = `user_input,expected_output,actual_output,context,retrieval_context,metadata +"What is AI?","Artificial Intelligence","AI is...","[""tech"",""science""]","[""AI overview""]","{""category"":""tech""}" +"Define ML","Machine Learning","","","","{}"`; + + const mockFiles = [ + { + fieldname: 'file', + originalname: 'test1.csv', + encoding: '7bit', + mimetype: 'text/csv', + filename: 'test1.csv', + path: '/tmp/test1.csv', + size: 1024 + }, + { + fieldname: 'file', + originalname: 'test2.csv', + encoding: '7bit', + mimetype: 'text/csv', + filename: 'test2.csv', + path: '/tmp/test2.csv', + size: 2048 + } + ]; + + const mockUploadModel = { + getUploadFiles: vi.fn() + }; + + beforeEach(() => { + vi.clearAllMocks(); + + // Reset global.llmModelMap + global.llmModelMap = new Map([ + ['gpt-4', { model: 'gpt-4' }], + ['gpt-3.5-turbo', { model: 'gpt-3.5-turbo' }] + ]) as any; + + // Mock file system + const fs = require('fs'); + vi.spyOn(fs, 'readFileSync').mockReturnValue(validCSVContent); + + mockGetUploadModel.mockReturnValue(mockUploadModel as any); + mockUploadModel.getUploadFiles.mockResolvedValue({ + files: mockFiles, + data: { + collectionId: validCollectionId, + enableQualityEvaluation: false + } + }); + + mockAuthEvaluationDatasetDataWrite.mockResolvedValue({ + teamId: validTeamId, + tmbId: validTmbId, + collectionId: validCollectionId + }); + + mockAuthEvaluationDatasetCreate.mockResolvedValue({ + teamId: validTeamId, + tmbId: validTmbId + }); + + mockMongoSessionRun.mockImplementation(async (callback) => { + return callback({} as any); + }); + + mockMongoEvalDatasetData.insertMany.mockResolvedValue(mockInsertedRecords as any); + mockMongoEvalDatasetCollection.findById.mockResolvedValue({ + _id: validCollectionId, + teamId: validTeamId, + name: 'Test Collection' + } as any); + mockMongoEvalDatasetCollection.findOne.mockResolvedValue(null); + mockMongoEvalDatasetCollection.create.mockResolvedValue([ + { + _id: validCollectionId, + teamId: validTeamId, + name: 'New Collection' + } + ] as any); + + mockCheckTeamAIPoints.mockResolvedValue(undefined); + mockCheckTeamEvalDatasetLimit.mockResolvedValue(undefined); + mockGetDefaultEvaluationModel.mockReturnValue({ model: 'gpt-3.5-turbo' }); + mockRemoveFilesByPaths.mockResolvedValue(undefined); + mockAddEvalDatasetDataQualityJob.mockResolvedValue({} as any); + }); + + describe('Parameter Validation', () => { + it('should reject when no files are uploaded', async () => { + mockUploadModel.getUploadFiles.mockResolvedValue({ + files: [], + data: { collectionId: validCollectionId, enableQualityEvaluation: false } + }); + + const req = { body: {} }; + const res = {}; + + await expect(handler_test(req as any, res as any)).rejects.toBe('evaluationFileIdRequired'); + }); + + it('should reject when files are not CSV', async () => { + mockUploadModel.getUploadFiles.mockResolvedValue({ + files: [{ ...mockFiles[0], originalname: 'test.txt' }], + data: { collectionId: validCollectionId, enableQualityEvaluation: false } + }); + + const req = { body: {} }; + const res = {}; + + await expect(handler_test(req as any, res as any)).rejects.toBe('evaluationFileMustBeCSV'); + }); + + it('should reject when neither collectionId nor name provided', async () => { + mockUploadModel.getUploadFiles.mockResolvedValue({ + files: mockFiles, + data: { enableQualityEvaluation: false } + }); + + const req = { body: {} }; + const res = {}; + + await expect(handler_test(req as any, res as any)).rejects.toBe('commonMissingParams'); + }); + + it('should reject when both collectionId and name provided', async () => { + mockUploadModel.getUploadFiles.mockResolvedValue({ + files: mockFiles, + data: { + collectionId: validCollectionId, + name: 'Test Collection', + enableQualityEvaluation: false + } + }); + + const req = { body: {} }; + const res = {}; + + await expect(handler_test(req as any, res as any)).rejects.toBe('commonInvalidParams'); + }); + + it('should reject when enableQualityEvaluation is not boolean', async () => { + mockUploadModel.getUploadFiles.mockResolvedValue({ + files: mockFiles, + data: { + collectionId: validCollectionId, + enableQualityEvaluation: 'true' + } + }); + + const req = { body: {} }; + const res = {}; + + await expect(handler_test(req as any, res as any)).rejects.toBe( + 'evaluationDatasetDataEnableQualityEvalRequired' + ); + }); + + it('should reject when quality evaluation enabled but no model provided', async () => { + mockUploadModel.getUploadFiles.mockResolvedValue({ + files: mockFiles, + data: { + collectionId: validCollectionId, + enableQualityEvaluation: true + } + }); + + const req = { body: {} }; + const res = {}; + + await expect(handler_test(req as any, res as any)).rejects.toBe( + 'evaluationDatasetDataEvaluationModelRequiredForQuality' + ); + }); + + it('should reject when evaluation model not found in global map', async () => { + mockUploadModel.getUploadFiles.mockResolvedValue({ + files: mockFiles, + data: { + collectionId: validCollectionId, + enableQualityEvaluation: true, + evaluationModel: 'non-existent-model' + } + }); + + const req = { body: {} }; + const res = {}; + + await expect(handler_test(req as any, res as any)).rejects.toBe( + 'evaluationDatasetModelNotFound' + ); + }); + }); + + describe('Dual-Mode Operation', () => { + describe('Mode 1: Existing Collection', () => { + it('should use existing collection when collectionId provided', async () => { + const req = { body: {} }; + const res = {}; + + const result = await handler_test(req as any, res as any); + + expect(mockAuthEvaluationDatasetDataWrite).toHaveBeenCalledWith(validCollectionId, { + req, + authToken: true, + authApiKey: true + }); + expect(mockMongoEvalDatasetCollection.findById).toHaveBeenCalledWith(validCollectionId); + expect(result).toBe('success'); + }); + + it('should reject when collection not found', async () => { + mockMongoEvalDatasetCollection.findById.mockResolvedValue(null); + + const req = { body: {} }; + const res = {}; + + await expect(handler_test(req as any, res as any)).rejects.toBe( + 'evaluationDatasetCollectionNotFound' + ); + }); + + it('should reject when collection belongs to different team', async () => { + mockMongoEvalDatasetCollection.findById.mockResolvedValue({ + _id: validCollectionId, + teamId: 'different-team', + name: 'Test Collection' + }); + + const req = { body: {} }; + const res = {}; + + await expect(handler_test(req as any, res as any)).rejects.toBe( + 'evaluationEvalInsufficientPermission' + ); + }); + }); + + describe('Mode 2: Create New Collection', () => { + beforeEach(() => { + mockUploadModel.getUploadFiles.mockResolvedValue({ + files: mockFiles, + data: { + name: 'New Collection', + description: 'Test description', + enableQualityEvaluation: false + } + }); + }); + + it('should create new collection when name provided', async () => { + const req = { body: {} }; + const res = {}; + + const result = await handler_test(req as any, res as any); + + expect(mockAuthEvaluationDatasetCreate).toHaveBeenCalledWith({ + req, + authToken: true, + authApiKey: true + }); + expect(mockCheckTeamEvalDatasetLimit).toHaveBeenCalledWith(validTeamId); + expect(mockMongoEvalDatasetCollection.findOne).toHaveBeenCalledWith({ + teamId: validTeamId, + name: 'New Collection' + }); + expect(mockMongoEvalDatasetCollection.create).toHaveBeenCalled(); + expect(result).toBe('success'); + }); + + it('should reject when collection name is empty', async () => { + mockUploadModel.getUploadFiles.mockResolvedValue({ + files: mockFiles, + data: { + name: '', + enableQualityEvaluation: false + } + }); + + const req = { body: {} }; + const res = {}; + + await expect(handler_test(req as any, res as any)).rejects.toBe( + 'evaluationEvalNameRequired' + ); + }); + + it('should reject when collection name already exists', async () => { + mockMongoEvalDatasetCollection.findOne.mockResolvedValue({ + _id: 'existing-id', + name: 'New Collection' + }); + + const req = { body: {} }; + const res = {}; + + await expect(handler_test(req as any, res as any)).rejects.toBe( + 'evaluationEvalDuplicateDatasetName' + ); + }); + }); + }); + + describe('Multiple File Upload', () => { + it('should process multiple CSV files successfully', async () => { + const multipleFiles = [ + { ...mockFiles[0], originalname: 'test1.csv' }, + { ...mockFiles[1], originalname: 'test2.csv' } + ]; + + mockUploadModel.getUploadFiles.mockResolvedValue({ + files: multipleFiles, + data: { collectionId: validCollectionId, enableQualityEvaluation: false } + }); + + const req = { body: {} }; + const res = {}; + + const result = await handler_test(req as any, res as any); + + expect(result).toBe('success'); + expect(mockMongoEvalDatasetData.insertMany).toHaveBeenCalled(); + expect(mockRemoveFilesByPaths).toHaveBeenCalledWith(['/tmp/test1.csv', '/tmp/test2.csv']); + }); + + it('should add importedFromFile metadata to records', async () => { + const req = { body: {} }; + const res = {}; + + await handler_test(req as any, res as any); + + expect(mockMongoEvalDatasetData.insertMany).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + [EvalDatasetDataKeyEnum.Metadata]: expect.objectContaining({ + importedFromFile: 'test1.csv' + }) + }) + ]), + expect.any(Object) + ); + }); + }); + + describe('CSV Processing', () => { + it('should parse valid CSV with all columns', async () => { + const req = { body: {} }; + const res = {}; + + const result = await handler_test(req as any, res as any); + + expect(mockMongoEvalDatasetData.insertMany).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + teamId: validTeamId, + tmbId: validTmbId, + evalDatasetCollectionId: validCollectionId, + [EvalDatasetDataKeyEnum.UserInput]: 'What is AI?', + [EvalDatasetDataKeyEnum.ExpectedOutput]: 'Artificial Intelligence', + [EvalDatasetDataKeyEnum.ActualOutput]: 'AI is...', + [EvalDatasetDataKeyEnum.Context]: ['tech', 'science'], + createFrom: EvalDatasetDataCreateFromEnum.fileImport + }) + ]), + expect.objectContaining({ + session: {}, + ordered: false + }) + ); + + expect(result).toBe('success'); + }); + + it('should handle empty CSV gracefully', async () => { + const fs = require('fs'); + fs.readFileSync.mockReturnValue(''); + + const req = { body: {} }; + const res = {}; + + await expect(handler_test(req as any, res as any)).rejects.toBe('evaluationCSVNoDataRows'); + }); + }); + + describe('Quality Evaluation', () => { + it('should not trigger quality evaluation when disabled', async () => { + const req = { body: {} }; + const res = {}; + + await handler_test(req as any, res as any); + + expect(mockAddEvalDatasetDataQualityJob).not.toHaveBeenCalled(); + }); + + it('should trigger quality evaluation when enabled', async () => { + mockUploadModel.getUploadFiles.mockResolvedValue({ + files: mockFiles, + data: { + collectionId: validCollectionId, + enableQualityEvaluation: true, + evaluationModel: 'gpt-4' + } + }); + + const req = { body: {} }; + const res = {}; + + await handler_test(req as any, res as any); + + expect(mockCheckTeamAIPoints).toHaveBeenCalledWith(validTeamId); + expect(mockAddEvalDatasetDataQualityJob).toHaveBeenCalledTimes(2); + expect(mockAddEvalDatasetDataQualityJob).toHaveBeenCalledWith({ + dataId: '65f5b5b5b5b5b5b5b5b5b5b6', + evaluationModel: 'gpt-4' + }); + }); + }); + + describe('File Cleanup', () => { + it('should always clean up uploaded files', async () => { + const req = { body: {} }; + const res = {}; + + await handler_test(req as any, res as any); + + expect(mockRemoveFilesByPaths).toHaveBeenCalledWith(['/tmp/test1.csv', '/tmp/test2.csv']); + }); + + it('should clean up files even when error occurs', async () => { + mockMongoEvalDatasetData.insertMany.mockRejectedValue(new Error('Database error')); + + const req = { body: {} }; + const res = {}; + + await expect(handler_test(req as any, res as any)).rejects.toBe('evaluationCSVParsingError'); + expect(mockRemoveFilesByPaths).toHaveBeenCalledWith(['/tmp/test1.csv', '/tmp/test2.csv']); + }); + }); + + describe('Return Values', () => { + it('should return success string on successful import', async () => { + const req = { body: {} }; + const res = {}; + + const result = await handler_test(req as any, res as any); + + expect(result).toBe('success'); + expect(typeof result).toBe('string'); + }); + }); +}); From 5304c9bef489fa8afd3adf97a801c92a4dcea4ee Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 16 Sep 2025 15:41:32 +0800 Subject: [PATCH 07/84] refactor: restructure evaluation dataset quality metadata schema - Separate qualityMetadata and synthesisMetadata into distinct schema fields - Move quality results from status enum to dedicated qualityResult field - Update database schema with proper indexes for quality-related fields - Refactor API endpoints to support new metadata structure - Update frontend components to handle separated quality data - Maintain backward compatibility during schema transition --- .../global/core/evaluation/dataset/api.d.ts | 17 +- .../core/evaluation/dataset/constants.ts | 6 +- .../global/core/evaluation/dataset/type.d.ts | 29 ++- .../dataset/dataQualityProcessor.ts | 40 ++-- .../dataset/dataSynthesizeProcessor.ts | 35 ++-- .../dataset/evalDatasetDataSchema.ts | 45 ++++- .../service/core/evaluation/dataset/utils.ts | 2 +- .../evaluation/dataset/detail/DataList.tsx | 82 ++++++-- .../dataset/detail/DataListModals.tsx | 8 +- .../dataset/detail/EditDataModal.tsx | 189 +++++++++++------- .../evaluation/dataset/detail/const.ts | 18 +- .../collection/qualityAssessmentBatch.ts | 18 +- .../core/evaluation/dataset/data/detail.ts | 11 +- .../core/evaluation/dataset/data/import.ts | 48 ++++- .../api/core/evaluation/dataset/data/list.ts | 12 +- .../dataset/data/qualityAssessment.ts | 6 +- .../evaluation/dataset/data/smartGenerate.ts | 16 +- .../core/evaluation/dataset/data/update.ts | 80 +++++--- .../evaluation/dataset/data/import.test.ts | 102 +++++++++- .../core/evaluation/dataset/data/list.test.ts | 38 ++-- .../dataset/data/qualityAssessment.test.ts | 18 +- .../evaluation/dataset/data/update.test.ts | 30 ++- 22 files changed, 608 insertions(+), 242 deletions(-) diff --git a/packages/global/core/evaluation/dataset/api.d.ts b/packages/global/core/evaluation/dataset/api.d.ts index 3a07de634e42..e9fff4e3930f 100644 --- a/packages/global/core/evaluation/dataset/api.d.ts +++ b/packages/global/core/evaluation/dataset/api.d.ts @@ -3,8 +3,11 @@ import type { EvalDatasetCollectionSchemaType, EvalDatasetDataSchemaType, EvalDatasetCollectionStatus, - EvalDatasetDataQualityStatus + EvalDatasetDataQualityStatus, + EvalDatasetDataQualityMetadata, + EvalDatasetDataSynthesisMetadata } from './type'; +import type { EvalDatasetDataQualityResultEnum } from './constants'; import type { EvalDatasetDataKeyEnum } from './constants'; type EvalDatasetCollectionBase = { @@ -57,7 +60,9 @@ type EvalDatasetDataBase = { [EvalDatasetDataKeyEnum.ExpectedOutput]: string; [EvalDatasetDataKeyEnum.Context]?: string[]; [EvalDatasetDataKeyEnum.RetrievalContext]?: string[]; - [EvalDatasetDataKeyEnum.Metadata]?: Record; + qualityMetadata?: Partial; + synthesisMetadata?: Partial; + qualityResult?: EvalDatasetDataQualityResultEnum; }; export type createEvalDatasetDataBody = EvalDatasetDataBase & @@ -80,7 +85,9 @@ export type listEvalDatasetDataResponse = PaginationResponse< | EvalDatasetDataKeyEnum.ExpectedOutput | EvalDatasetDataKeyEnum.Context | EvalDatasetDataKeyEnum.RetrievalContext - | 'metadata' + | 'qualityMetadata' + | 'synthesisMetadata' + | 'qualityResult' | 'createFrom' | 'createTime' | 'updateTime' @@ -128,7 +135,9 @@ export type getEvalDatasetDataDetailResponse = Pick< | EvalDatasetDataKeyEnum.ExpectedOutput | EvalDatasetDataKeyEnum.Context | EvalDatasetDataKeyEnum.RetrievalContext - | EvalDatasetDataKeyEnum.Metadata + | 'qualityMetadata' + | 'synthesisMetadata' + | 'qualityResult' | 'createFrom' | 'createTime' | 'updateTime' diff --git a/packages/global/core/evaluation/dataset/constants.ts b/packages/global/core/evaluation/dataset/constants.ts index 2c7a5611d638..8418c21df8f9 100644 --- a/packages/global/core/evaluation/dataset/constants.ts +++ b/packages/global/core/evaluation/dataset/constants.ts @@ -18,7 +18,10 @@ export enum EvalDatasetDataQualityStatusEnum { queuing = 'queuing', evaluating = 'evaluating', error = 'error', - completed = 'completed', + completed = 'completed' +} + +export enum EvalDatasetDataQualityResultEnum { highQuality = 'highQuality', needsOptimization = 'needsOptimization' } @@ -33,3 +36,4 @@ export enum EvalDatasetDataKeyEnum { } export const EvalDatasetDataQualityStatusValues = Object.values(EvalDatasetDataQualityStatusEnum); +export const EvalDatasetDataQualityResultValues = Object.values(EvalDatasetDataQualityResultEnum); diff --git a/packages/global/core/evaluation/dataset/type.d.ts b/packages/global/core/evaluation/dataset/type.d.ts index 17e68287e4d3..5fc7189502e9 100644 --- a/packages/global/core/evaluation/dataset/type.d.ts +++ b/packages/global/core/evaluation/dataset/type.d.ts @@ -3,11 +3,36 @@ import type { EvalDatasetDataCreateFromEnum, EvalDatasetCollectionStatusEnum, EvalDatasetDataQualityStatusEnum, + EvalDatasetDataQualityResultEnum, EvalDatasetDataKeyEnum } from './constants'; +import type { Usage } from '@fastgpt/global/support/wallet/usage/type'; export type EvalDatasetCollectionStatus = EvalDatasetCollectionStatusEnum; export type EvalDatasetDataQualityStatus = EvalDatasetDataQualityStatusEnum; +export type EvalDatasetDataQualityResult = EvalDatasetDataQualityResultEnum; + +export type EvalDatasetDataQualityMetadata = { + status: EvalDatasetDataQualityStatusEnum; + score?: number; + reason?: string; + model?: string; + usages?: Usage[]; + runLogs?: any[]; + startTime?: Date; + finishTime?: Date; + queueTime?: Date; + error?: string; +}; + +export type EvalDatasetDataSynthesisMetadata = { + sourceDataId?: string; + sourceDatasetId?: string; + sourceCollectionId?: string; + intelligentGenerationModel?: string; + synthesizedAt?: Date; + generatedAt?: Date; +}; export type EvalDatasetCollectionSchemaType = { _id: string; @@ -31,7 +56,9 @@ export type EvalDatasetDataSchemaType = { [EvalDatasetDataKeyEnum.ExpectedOutput]: string; [EvalDatasetDataKeyEnum.Context]: string[]; [EvalDatasetDataKeyEnum.RetrievalContext]: string[]; - metadata: Record; + qualityMetadata: EvalDatasetDataQualityMetadata; + synthesisMetadata?: EvalDatasetDataSynthesisMetadata; + qualityResult?: EvalDatasetDataQualityResultEnum; createFrom: EvalDatasetDataCreateFromEnum; createTime: Date; updateTime: Date; diff --git a/packages/service/core/evaluation/dataset/dataQualityProcessor.ts b/packages/service/core/evaluation/dataset/dataQualityProcessor.ts index 3f880a6e21dd..1004aefb9162 100644 --- a/packages/service/core/evaluation/dataset/dataQualityProcessor.ts +++ b/packages/service/core/evaluation/dataset/dataQualityProcessor.ts @@ -4,7 +4,8 @@ import { MongoEvalDatasetData } from './evalDatasetDataSchema'; import { getEvalDatasetDataQualityWorker, type EvalDatasetDataQualityData } from './dataQualityMq'; import { EvalDatasetDataKeyEnum, - EvalDatasetDataQualityStatusEnum + EvalDatasetDataQualityStatusEnum, + EvalDatasetDataQualityResultEnum } from '@fastgpt/global/core/evaluation/dataset/constants'; import { EvalMetricTypeEnum } from '@fastgpt/global/core/evaluation/metric/constants'; import type { EvalMetricSchemaType } from '@fastgpt/global/core/evaluation/metric/type'; @@ -27,9 +28,9 @@ export const processEvalDatasetDataQuality = async (job: Job= 0.7 - ? EvalDatasetDataQualityStatusEnum.highQuality - : EvalDatasetDataQualityStatusEnum.needsOptimization; + ? EvalDatasetDataQualityResultEnum.highQuality + : EvalDatasetDataQualityResultEnum.needsOptimization; await MongoEvalDatasetData.findByIdAndUpdate(dataId, { $set: { - 'metadata.qualityStatus': qualityStatus, - 'metadata.qualityScore': metricResult.data.score, - 'metadata.qualityReason': metricResult.data?.reason, - 'metadata.qualityRunLogs': metricResult.data?.runLogs, - 'metadata.qualityUsages': metricResult?.usages, - 'metadata.qualityFinishTime': new Date(), - 'metadata.qualityModel': evaluationModel + 'qualityMetadata.status': EvalDatasetDataQualityStatusEnum.completed, + 'qualityMetadata.score': metricResult.data.score, + 'qualityMetadata.reason': metricResult.data?.reason, + 'qualityMetadata.runLogs': metricResult.data?.runLogs, + 'qualityMetadata.usages': metricResult?.usages, + 'qualityMetadata.finishTime': new Date(), + 'qualityMetadata.model': evaluationModel, + qualityResult: qualityResult } }); @@ -131,9 +133,9 @@ export const processEvalDatasetDataQuality = async (job: Job) { totalPoints = calculatedPoints; } + const qualityResult = + synthesisResult.data?.metadata?.score && synthesisResult.data.metadata.score >= 0.7 + ? EvalDatasetDataQualityResultEnum.highQuality + : EvalDatasetDataQualityResultEnum.needsOptimization; + const evalData: Partial = { teamId: evalDatasetCollection.teamId, tmbId: evalDatasetCollection.tmbId, @@ -75,21 +81,22 @@ async function processor(job: Job) { [EvalDatasetDataKeyEnum.ActualOutput]: '', [EvalDatasetDataKeyEnum.Context]: [], [EvalDatasetDataKeyEnum.RetrievalContext]: [], - metadata: { - sourceDataId: sourceData._id, - sourceDatasetId: sourceData.datasetId, - sourceCollectionId: sourceData.collectionId, - qualityScore: synthesisResult.data?.metadata?.score, - qualityReason: synthesisResult.data?.metadata?.reason, - qualityUsages: synthesisResult?.usages, - qualityStatus: - synthesisResult.data?.metadata?.score && synthesisResult.data.metadata.score >= 0.7 - ? EvalDatasetDataQualityStatusEnum.highQuality - : EvalDatasetDataQualityStatusEnum.needsOptimization, + qualityMetadata: { + status: EvalDatasetDataQualityStatusEnum.completed, + score: synthesisResult.data?.metadata?.score, + reason: synthesisResult.data?.metadata?.reason, + usages: synthesisResult?.usages, + finishTime: new Date() + }, + synthesisMetadata: { + sourceDataId: sourceData._id.toString(), + sourceDatasetId: sourceData.datasetId.toString(), + sourceCollectionId: sourceData.collectionId.toString(), + intelligentGenerationModel, generatedAt: new Date(), - synthesizedAt: new Date(), - intelligentGenerationModel + synthesizedAt: new Date() }, + qualityResult, createFrom: EvalDatasetDataCreateFromEnum.intelligentGeneration }; diff --git a/packages/service/core/evaluation/dataset/evalDatasetDataSchema.ts b/packages/service/core/evaluation/dataset/evalDatasetDataSchema.ts index cfe113264190..4baa8e213cbe 100644 --- a/packages/service/core/evaluation/dataset/evalDatasetDataSchema.ts +++ b/packages/service/core/evaluation/dataset/evalDatasetDataSchema.ts @@ -4,7 +4,10 @@ import { EvalDatasetCollectionName } from './evalDatasetCollectionSchema'; import { EvalDatasetDataCreateFromEnum, EvalDatasetDataCreateFromValues, - EvalDatasetDataKeyEnum + EvalDatasetDataKeyEnum, + EvalDatasetDataQualityStatusEnum, + EvalDatasetDataQualityResultEnum, + EvalDatasetDataQualityResultValues } from '@fastgpt/global/core/evaluation/dataset/constants'; import { TeamCollectionName, @@ -70,9 +73,38 @@ const EvalDatasetDataSchema = new Schema({ ], default: [] }, - metadata: { - type: Schema.Types.Mixed, - default: {} + qualityMetadata: { + status: { + type: String, + enum: Object.values(EvalDatasetDataQualityStatusEnum), + default: EvalDatasetDataQualityStatusEnum.unevaluated, + required: true + }, + score: { + type: Number, + min: 0, + max: 1 + }, + reason: String, + model: String, + usages: [Schema.Types.Mixed], + runLogs: [Schema.Types.Mixed], + startTime: Date, + finishTime: Date, + queueTime: Date, + error: String + }, + synthesisMetadata: { + sourceDataId: String, + sourceDatasetId: String, + sourceCollectionId: String, + intelligentGenerationModel: String, + synthesizedAt: Date, + generatedAt: Date + }, + qualityResult: { + type: String, + enum: EvalDatasetDataQualityResultValues }, createFrom: { type: String, @@ -96,6 +128,11 @@ const EvalDatasetDataSchema = new Schema({ EvalDatasetDataSchema.index({ evalDatasetCollectionId: 1, createTime: -1 }); EvalDatasetDataSchema.index({ evalDatasetCollectionId: 1, updateTime: -1 }); +// Quality related indexes +EvalDatasetDataSchema.index({ 'qualityMetadata.status': 1 }); +EvalDatasetDataSchema.index({ qualityResult: 1 }); +EvalDatasetDataSchema.index({ evalDatasetCollectionId: 1, qualityResult: 1 }); + // Text search index for searching within inputs and outputs EvalDatasetDataSchema.index({ [EvalDatasetDataKeyEnum.UserInput]: 'text', diff --git a/packages/service/core/evaluation/dataset/utils.ts b/packages/service/core/evaluation/dataset/utils.ts index 2c3a69571205..672d514a47da 100644 --- a/packages/service/core/evaluation/dataset/utils.ts +++ b/packages/service/core/evaluation/dataset/utils.ts @@ -51,7 +51,7 @@ export function buildCollectionAggregationPipeline(baseFields?: Record { }, [evaluationDataList, setEvaluationDataList]); // 获取状态标签颜色 - const getStatusColor = (status: EvaluationStatus) => { - switch (status) { - case EvaluationStatus.HighQuality: + const getStatusColor = (qualityStatus: string, qualityResult?: string) => { + // 如果有质量结果,优先显示质量结果的颜色 + if (qualityResult) { + switch (qualityResult) { + case EvalDatasetDataQualityResultEnum.highQuality: + return 'green'; + case EvalDatasetDataQualityResultEnum.needsOptimization: + return 'yellow'; + default: + return 'gray'; + } + } + + // 否则根据质量状态显示颜色 + switch (qualityStatus) { + case EvalDatasetDataQualityStatusEnum.completed: return 'green'; - case EvaluationStatus.NeedsImprovement: - return 'yellow'; - case EvaluationStatus.Abnormal: - return 'red'; - case EvaluationStatus.Evaluating: + case EvalDatasetDataQualityStatusEnum.evaluating: return 'blue'; - case EvaluationStatus.Queuing: + case EvalDatasetDataQualityStatusEnum.queuing: return 'gray'; - case EvaluationStatus.NotEvaluated: + case EvalDatasetDataQualityStatusEnum.error: + return 'red'; + case EvalDatasetDataQualityStatusEnum.unevaluated: return 'gray'; default: return 'gray'; @@ -110,17 +125,58 @@ const DataListContent = () => { }; const renderStatusTag = (item: any) => { - if (!item.metadata?.qualityStatus) return ''; + const qualityStatus = item.qualityMetadata?.status; + const qualityResult = item.qualityResult; + + if (!qualityStatus) return ''; + + // 确定要显示的状态和文本 + let displayStatus: string; + let statusText: string; + + if (qualityResult && qualityStatus === EvalDatasetDataQualityStatusEnum.completed) { + // 如果有质量结果且评估已完成,显示质量结果 + displayStatus = qualityResult; + if (qualityResult === EvalDatasetDataQualityResultEnum.highQuality) { + statusText = t(evaluationStatusMap[EvaluationStatus.HighQuality]); + } else if (qualityResult === EvalDatasetDataQualityResultEnum.needsOptimization) { + statusText = t(evaluationStatusMap[EvaluationStatus.NeedsImprovement]); + } else { + statusText = qualityResult; + } + } else { + // 否则显示质量状态 + displayStatus = qualityStatus; + switch (qualityStatus) { + case EvalDatasetDataQualityStatusEnum.unevaluated: + statusText = t(evaluationStatusMap[EvaluationStatus.NotEvaluated]); + break; + case EvalDatasetDataQualityStatusEnum.queuing: + statusText = t(evaluationStatusMap[EvaluationStatus.Queuing]); + break; + case EvalDatasetDataQualityStatusEnum.evaluating: + statusText = t(evaluationStatusMap[EvaluationStatus.Evaluating]); + break; + case EvalDatasetDataQualityStatusEnum.error: + statusText = t(evaluationStatusMap[EvaluationStatus.Abnormal]); + break; + case EvalDatasetDataQualityStatusEnum.completed: + statusText = t('dashboard_evaluation:completed'); + break; + default: + statusText = qualityStatus; + } + } return ( - {t(evaluationStatusMap[item.metadata.qualityStatus as EvaluationStatus])} + {statusText} ); diff --git a/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/DataListModals.tsx b/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/DataListModals.tsx index 05044aeffc4a..ebfe61d04e60 100644 --- a/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/DataListModals.tsx +++ b/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/DataListModals.tsx @@ -127,16 +127,18 @@ const DataListModals: React.FC = ({ total, refreshList }) = formData: { question: string; referenceAnswer: string; - metadata: Record; + qualityMetadata?: Record; + synthesisMetadata?: Record; }, isGoNext = false ) => { - const { question, referenceAnswer, metadata } = formData; + const { question, referenceAnswer, qualityMetadata, synthesisMetadata } = formData; await updateDataFn({ dataId: selectedItem._id, userInput: question, expectedOutput: referenceAnswer, - metadata + qualityMetadata, + synthesisMetadata }); isGoNext && handleGoNextData(); !isGoNext && onEditModalClose(); diff --git a/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/EditDataModal.tsx b/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/EditDataModal.tsx index 0f9cf7ae00be..3d07cd2edd2c 100644 --- a/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/EditDataModal.tsx +++ b/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/EditDataModal.tsx @@ -21,6 +21,10 @@ import { useTranslation } from 'next-i18next'; import MyModal from '@fastgpt/web/components/common/MyModal/index'; import ModifyEvaluationModal from './ModifyEvaluationModal'; import { evaluationStatusMap, EvaluationStatus } from './const'; +import { + EvalDatasetDataQualityStatusEnum, + EvalDatasetDataQualityResultEnum +} from '@fastgpt/global/core/evaluation/dataset/constants'; import { postEvaluationDatasetQualityAssessment, getEvaluationDatasetDataDetail @@ -37,7 +41,10 @@ interface EditDataFormData { interface EditDataModalProps { isOpen: boolean; onClose: () => void; - onSave: (data: EditDataFormData & { metadata: Record }, isGoNext?: boolean) => void; + onSave: ( + data: EditDataFormData & { qualityMetadata: any; qualityResult: string }, + isGoNext?: boolean + ) => void; isLoading?: boolean; formData: listEvalDatasetDataResponse; } @@ -58,22 +65,25 @@ const EditDataModal: React.FC = ({ isLoading = false }) => { const evaluationStatus = useMemo( - () => formData?.metadata?.qualityStatus || EvaluationStatus.NotEvaluated, + () => formData?.qualityMetadata?.status || EvalDatasetDataQualityStatusEnum.unevaluated, [formData] ); - const evaluationResult = useMemo(() => formData?.metadata?.qualityReason || '', [formData]); + const qualityReason = useMemo(() => formData?.qualityMetadata?.reason || '', [formData]); const defaultQuestion = useMemo(() => formData?.userInput || '', [formData]); const defaultReferenceAnswer = useMemo(() => formData?.expectedOutput || '', [formData]); const { t } = useTranslation(); - const [currentEvaluationStatus, setCurrentEvaluationStatus] = useState( - formData?.metadata?.qualityStatus || EvaluationStatus.NotEvaluated + const [currentEvaluationStatus, setCurrentEvaluationStatus] = useState( + formData?.qualityMetadata?.status || EvalDatasetDataQualityStatusEnum.unevaluated + ); + const [currentQualityReason, setCurrentQualityReason] = useState( + formData?.qualityMetadata?.reason ); - const [currentEvaluationResult, setCurrentEvaluationResult] = useState( - formData?.metadata?.qualityReason + const [currentQualityResult, setCurrentQualityResult] = useState( + formData?.qualityResult || '' ); - const [errorMsg, setErrorMsg] = useState(formData.metadata?.qualityError || ''); + const [errorMsg, setErrorMsg] = useState(formData.qualityMetadata?.error || ''); const [reviewBtns, setReviewBtns] = useState([ { @@ -94,31 +104,31 @@ const EditDataModal: React.FC = ({ ]); // 根据评测状态更新按钮显示的公共函数 - const updateButtonsByStatus = (status: EvaluationStatus) => { + const updateButtonsByStatus = (status: string, qualityResult?: string) => { setReviewBtns((prev) => prev.map((btn) => { switch (btn.key) { case 'startReview': // 开始测评:只有状态为未测评时才显示 - return { ...btn, isShow: status === EvaluationStatus.NotEvaluated }; + return { ...btn, isShow: status === EvalDatasetDataQualityStatusEnum.unevaluated }; case 'reStart': - // 重新测评:异常、质量高、待优化才显示 + // 重新测评:异常、已完成(有质量结果)才显示 return { ...btn, - isShow: - status === EvaluationStatus.Abnormal || - status === EvaluationStatus.HighQuality || - status === EvaluationStatus.NeedsImprovement + isShow: Boolean( + status === EvalDatasetDataQualityStatusEnum.error || + (status === EvalDatasetDataQualityStatusEnum.completed && qualityResult) + ) }; case 'modifyRes': - // 修改结果:异常、质量高、待优化才显示 + // 修改结果:已完成且有质量结果时才显示 return { ...btn, - isShow: - status === EvaluationStatus.HighQuality || - status === EvaluationStatus.NeedsImprovement + isShow: Boolean( + status === EvalDatasetDataQualityStatusEnum.completed && !!qualityResult + ) }; default: @@ -133,8 +143,9 @@ const EditDataModal: React.FC = ({ postEvaluationDatasetQualityAssessment, { onError() { - setCurrentEvaluationStatus(formData?.metadata?.qualityStatus); - setCurrentEvaluationResult(formData?.metadata?.qualityReason); + setCurrentEvaluationStatus(formData?.qualityMetadata?.status); + setCurrentQualityReason(formData?.qualityMetadata?.reason); + setCurrentQualityResult(formData?.qualityResult || ''); } } ); @@ -145,16 +156,20 @@ const EditDataModal: React.FC = ({ pollingWhenHidden: false, manual: !isOpen || - (currentEvaluationStatus !== EvaluationStatus.Evaluating && - currentEvaluationStatus !== EvaluationStatus.Queuing), + (currentEvaluationStatus !== EvalDatasetDataQualityStatusEnum.evaluating && + currentEvaluationStatus !== EvalDatasetDataQualityStatusEnum.queuing), ready: isOpen, onSuccess: (data: any) => { - if (data?.metadata?.qualityStatus !== currentEvaluationStatus) { - const newStatus = data?.metadata.qualityStatus || EvaluationStatus.NotEvaluated; + if (data?.qualityMetadata?.status !== currentEvaluationStatus) { + const newStatus = + data?.qualityMetadata?.status || EvalDatasetDataQualityStatusEnum.unevaluated; + const newQualityResult = data?.qualityResult || ''; setCurrentEvaluationStatus(newStatus); - setCurrentEvaluationResult(data?.metadata.qualityReason || ''); - updateButtonsByStatus(newStatus); - newStatus === EvaluationStatus.Abnormal && setErrorMsg(data?.metadata?.qualityError); + setCurrentQualityReason(data?.qualityMetadata?.reason || ''); + setCurrentQualityResult(newQualityResult); + updateButtonsByStatus(newStatus, newQualityResult); + newStatus === EvalDatasetDataQualityStatusEnum.error && + setErrorMsg(data?.qualityMetadata?.error); } } }); @@ -179,21 +194,23 @@ const EditDataModal: React.FC = ({ referenceAnswer: defaultReferenceAnswer }); setCurrentEvaluationStatus(evaluationStatus); - setCurrentEvaluationResult(evaluationResult); + setCurrentQualityReason(qualityReason); + setCurrentQualityResult(formData?.qualityResult || ''); // 根据评测状态设置按钮显示状态 - updateButtonsByStatus(evaluationStatus); + updateButtonsByStatus(evaluationStatus, formData?.qualityResult); } - }, [isOpen, defaultQuestion, defaultReferenceAnswer, evaluationStatus, evaluationResult, reset]); + }, [isOpen, defaultQuestion, defaultReferenceAnswer, evaluationStatus, qualityReason, reset]); const handleSaveClick = (data: EditDataFormData, isGoNext = false) => { onSave( { ...data, - metadata: { - qualityStatus: currentEvaluationStatus, - qualityReason: currentEvaluationResult - } + qualityMetadata: { + status: currentEvaluationStatus, + reason: currentQualityReason + }, + qualityResult: currentQualityResult }, isGoNext ); @@ -201,7 +218,7 @@ const EditDataModal: React.FC = ({ const renderEvaluationContent = () => { switch (currentEvaluationStatus) { - case EvaluationStatus.Queuing: + case EvalDatasetDataQualityStatusEnum.queuing: return ( @@ -213,7 +230,7 @@ const EditDataModal: React.FC = ({ ); - case EvaluationStatus.Evaluating: + case EvalDatasetDataQualityStatusEnum.evaluating: return ( @@ -225,35 +242,47 @@ const EditDataModal: React.FC = ({ ); - case EvaluationStatus.NeedsImprovement: - return ( - - - - {t(evaluationStatusMap[EvaluationStatus.NeedsImprovement])} - - - - {currentEvaluationResult} - - - ); - - case EvaluationStatus.HighQuality: - return ( - - - - {t(evaluationStatusMap[EvaluationStatus.HighQuality])} - - - - {currentEvaluationResult} - - - ); + case EvalDatasetDataQualityStatusEnum.completed: + // 已完成的情况下,根据 qualityResult 显示结果 + if (currentQualityResult === EvalDatasetDataQualityResultEnum.needsOptimization) { + return ( + + + + {t(evaluationStatusMap[EvaluationStatus.NeedsImprovement])} + + + + {currentQualityReason} + + + ); + } else if (currentQualityResult === EvalDatasetDataQualityResultEnum.highQuality) { + return ( + + + + {t(evaluationStatusMap[EvaluationStatus.HighQuality])} + + + + {currentQualityReason} + + + ); + } else { + // 没有质量结果的已完成状态 + return ( + + + {t('dashboard_evaluation:evaluation_completed_no_result') || + 'Evaluation completed without result'} + + + ); + } - case EvaluationStatus.Abnormal: + case EvalDatasetDataQualityStatusEnum.error: return ( @@ -273,7 +302,7 @@ const EditDataModal: React.FC = ({ ); - case EvaluationStatus.NotEvaluated: + case EvalDatasetDataQualityStatusEnum.unevaluated: default: return ( @@ -306,8 +335,18 @@ const EditDataModal: React.FC = ({ }; const handleConfirm = (data: { result: EvaluationStatus; reason: string }) => { - setCurrentEvaluationStatus(data.result); - setCurrentEvaluationResult(data.reason); + // 将前端的 EvaluationStatus 转换为对应的状态和结果 + if ( + data.result === EvaluationStatus.HighQuality || + data.result === EvaluationStatus.NeedsImprovement + ) { + setCurrentEvaluationStatus(EvalDatasetDataQualityStatusEnum.completed); + setCurrentQualityResult(data.result); + } else { + setCurrentEvaluationStatus(data.result); + setCurrentQualityResult(''); + } + setCurrentQualityReason(data.reason); handleCloseModal(); }; @@ -318,7 +357,7 @@ const EditDataModal: React.FC = ({ await simulateEvaluation({ dataId: formData._id }); // 设置评测状态为进行中 - setCurrentEvaluationStatus(EvaluationStatus.Evaluating); + setCurrentEvaluationStatus(EvalDatasetDataQualityStatusEnum.evaluating); // 更新按钮状态 setReviewBtns((prev) => @@ -401,8 +440,8 @@ const EditDataModal: React.FC = ({ {t('dashboard_evaluation:quality_evaluation')} {/* 只有当不是评估中或排队中状态时才显示操作按钮 */} - {currentEvaluationStatus !== EvaluationStatus.Evaluating && - currentEvaluationStatus !== EvaluationStatus.Queuing && ( + {currentEvaluationStatus !== EvalDatasetDataQualityStatusEnum.evaluating && + currentEvaluationStatus !== EvalDatasetDataQualityStatusEnum.queuing && ( {reviewBtns .filter((btn) => btn.isShow) @@ -477,10 +516,10 @@ const EditDataModal: React.FC = ({ onConfirm={handleConfirm} defaultValues={{ evaluationStatus: - currentEvaluationStatus === EvaluationStatus.HighQuality - ? EvaluationStatus.NeedsImprovement - : EvaluationStatus.HighQuality, - evaluationResult: currentEvaluationResult + currentQualityResult === EvalDatasetDataQualityResultEnum.highQuality + ? EvaluationStatus.HighQuality + : EvaluationStatus.NeedsImprovement, + evaluationResult: currentQualityReason }} /> diff --git a/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/const.ts b/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/const.ts index d3e9240d862e..91543d35915c 100644 --- a/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/const.ts +++ b/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/const.ts @@ -1,14 +1,18 @@ import { i18nT } from '@fastgpt/web/i18n/utils'; +import { + EvalDatasetDataQualityStatusEnum, + EvalDatasetDataQualityResultEnum +} from '@fastgpt/global/core/evaluation/dataset/constants'; -// 评测结果枚举 +// 评测结果枚举(用于前端状态管理和筛选) export enum EvaluationStatus { All = 'all', - HighQuality = 'highQuality', - NeedsImprovement = 'needsOptimization', - Abnormal = 'error', - NotEvaluated = 'unevaluated', - Evaluating = 'evaluating', - Queuing = 'queuing' + HighQuality = EvalDatasetDataQualityResultEnum.highQuality, + NeedsImprovement = EvalDatasetDataQualityResultEnum.needsOptimization, + Abnormal = EvalDatasetDataQualityStatusEnum.error, + NotEvaluated = EvalDatasetDataQualityStatusEnum.unevaluated, + Evaluating = EvalDatasetDataQualityStatusEnum.evaluating, + Queuing = EvalDatasetDataQualityStatusEnum.queuing } // 评测列表状态映射(含任务状态和结果) diff --git a/projects/app/src/pages/api/core/evaluation/dataset/collection/qualityAssessmentBatch.ts b/projects/app/src/pages/api/core/evaluation/dataset/collection/qualityAssessmentBatch.ts index deac75bea649..802972951102 100644 --- a/projects/app/src/pages/api/core/evaluation/dataset/collection/qualityAssessmentBatch.ts +++ b/projects/app/src/pages/api/core/evaluation/dataset/collection/qualityAssessmentBatch.ts @@ -69,7 +69,7 @@ async function handler( const evalModel = finalEvaluationModel.model; const dataItems = await MongoEvalDatasetData.find({ - datasetId: collectionId, + evalDatasetCollectionId: collectionId, teamId }).select('_id'); @@ -113,12 +113,12 @@ async function handler( evaluationModel: evalModel }); - // Update metadata + // Update quality metadata await MongoEvalDatasetData.findByIdAndUpdate(dataId, { $set: { - 'metadata.qualityStatus': EvalDatasetDataQualityStatusEnum.queuing, - 'metadata.qualityModel': evalModel, - 'metadata.qualityQueueTime': new Date() + 'qualityMetadata.status': EvalDatasetDataQualityStatusEnum.queuing, + 'qualityMetadata.model': evalModel, + 'qualityMetadata.queueTime': new Date() } }); @@ -153,12 +153,12 @@ async function handler( evaluationModel: evalModel }); - // Update metadata + // Update quality metadata await MongoEvalDatasetData.findByIdAndUpdate(dataId, { $set: { - 'metadata.qualityStatus': EvalDatasetDataQualityStatusEnum.queuing, - 'metadata.qualityModel': evalModel, - 'metadata.qualityQueueTime': new Date() + 'qualityMetadata.status': EvalDatasetDataQualityStatusEnum.queuing, + 'qualityMetadata.model': evalModel, + 'qualityMetadata.queueTime': new Date() } }); diff --git a/projects/app/src/pages/api/core/evaluation/dataset/data/detail.ts b/projects/app/src/pages/api/core/evaluation/dataset/data/detail.ts index 8edd6e03ffc1..483af68d5c23 100644 --- a/projects/app/src/pages/api/core/evaluation/dataset/data/detail.ts +++ b/projects/app/src/pages/api/core/evaluation/dataset/data/detail.ts @@ -5,7 +5,10 @@ import type { getEvalDatasetDataDetailQuery, getEvalDatasetDataDetailResponse } from '@fastgpt/global/core/evaluation/dataset/api'; -import { EvalDatasetDataKeyEnum } from '@fastgpt/global/core/evaluation/dataset/constants'; +import { + EvalDatasetDataKeyEnum, + EvalDatasetDataQualityStatusEnum +} from '@fastgpt/global/core/evaluation/dataset/constants'; import { authEvaluationDatasetDataReadById } from '@fastgpt/service/core/evaluation/common'; import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation'; @@ -40,7 +43,11 @@ async function handler( [EvalDatasetDataKeyEnum.ExpectedOutput]: dataItem.expectedOutput, [EvalDatasetDataKeyEnum.Context]: dataItem.context || [], [EvalDatasetDataKeyEnum.RetrievalContext]: dataItem.retrievalContext || [], - [EvalDatasetDataKeyEnum.Metadata]: dataItem.metadata || {}, + qualityMetadata: dataItem.qualityMetadata || { + status: EvalDatasetDataQualityStatusEnum.unevaluated + }, + synthesisMetadata: dataItem.synthesisMetadata || {}, + qualityResult: dataItem.qualityResult, createFrom: dataItem.createFrom, createTime: dataItem.createTime, updateTime: dataItem.updateTime diff --git a/projects/app/src/pages/api/core/evaluation/dataset/data/import.ts b/projects/app/src/pages/api/core/evaluation/dataset/data/import.ts index 8311741b8421..912632550ea0 100644 --- a/projects/app/src/pages/api/core/evaluation/dataset/data/import.ts +++ b/projects/app/src/pages/api/core/evaluation/dataset/data/import.ts @@ -49,6 +49,42 @@ const ENUM_TO_CSV_MAPPING = { [EvalDatasetDataKeyEnum.RetrievalContext]: 'retrieval_context' } as const; +function validateFilePath(filePath: string): string { + const path = require('path'); + const fs = require('fs'); + + // Check for directory traversal attempts in the original path + if (filePath.includes('..') || filePath.includes('..\\')) { + throw new Error('Invalid file path: directory traversal detected'); + } + + // Normalize the path to resolve any . and .. segments + const normalizedPath = path.normalize(filePath); + + // Get the absolute path + const absolutePath = path.resolve(normalizedPath); + + // ensure normalized path doesn't contain traversal after normalization + if (normalizedPath.includes('..') || normalizedPath.includes('..\\')) { + throw new Error('Invalid file path: directory traversal detected'); + } + + // Verify the file exists and is actually a file (not a directory) + try { + const stats = fs.statSync(absolutePath); + if (!stats.isFile()) { + throw new Error('Invalid file path: path is not a file'); + } + } catch (error) { + if (error instanceof Error && error.message.includes('directory traversal')) { + throw error; + } + throw new Error('Invalid file path: file does not exist or is not accessible'); + } + + return absolutePath; +} + interface CSVRow { user_input: string; expected_output: string; @@ -101,7 +137,7 @@ function parseCSVContent(csvContent: string): CSVRow[] { const lines = csvContent.split('\n').filter((line) => line.trim()); if (lines.length === 0) { - throw new Error('CSV file is empty'); + return []; } // Parse header @@ -186,10 +222,10 @@ async function handler( const { collectionId, name, description, enableQualityEvaluation, evaluationModel } = data; - if (!collectionId && !name) { + if (!collectionId && name === undefined) { return Promise.reject(CommonErrEnum.missingParams); } - if (collectionId && name) { + if (collectionId && name !== undefined) { return Promise.reject(CommonErrEnum.invalidParams); } @@ -295,9 +331,11 @@ async function handler( let totalRows = 0; for (const file of files) { - // Read file content from disk (since it's uploaded via form-data) const fs = require('fs'); - const rawText = fs.readFileSync(file.path, 'utf8'); + + // Validate file path to prevent directory traversal attacks + const safePath = validateFilePath(file.path); + const rawText = fs.readFileSync(safePath, 'utf8'); const csvRows = parseCSVContent(rawText); diff --git a/projects/app/src/pages/api/core/evaluation/dataset/data/list.ts b/projects/app/src/pages/api/core/evaluation/dataset/data/list.ts index 7223e95ac65d..9faadc0e8043 100644 --- a/projects/app/src/pages/api/core/evaluation/dataset/data/list.ts +++ b/projects/app/src/pages/api/core/evaluation/dataset/data/list.ts @@ -54,7 +54,7 @@ async function handler( } if (qualityStatus && typeof qualityStatus === 'string' && qualityStatus.trim().length > 0) { - match['metadata.qualityStatus'] = qualityStatus.trim(); + match['qualityMetadata.status'] = qualityStatus.trim(); } try { @@ -72,7 +72,11 @@ async function handler( [EvalDatasetDataKeyEnum.ExpectedOutput]: item.expectedOutput, [EvalDatasetDataKeyEnum.Context]: item.context || [], [EvalDatasetDataKeyEnum.RetrievalContext]: item.retrievalContext || [], - metadata: item.metadata || {}, + qualityMetadata: item.qualityMetadata || { + status: EvalDatasetDataQualityStatusEnum.unevaluated + }, + synthesisMetadata: item.synthesisMetadata || {}, + qualityResult: item.qualityResult, createFrom: item.createFrom, createTime: item.createTime, updateTime: item.updateTime @@ -107,7 +111,9 @@ const buildPipeline = (match: Record, offset: number, pageSize: num [EvalDatasetDataKeyEnum.ExpectedOutput]: 1, [EvalDatasetDataKeyEnum.Context]: 1, [EvalDatasetDataKeyEnum.RetrievalContext]: 1, - metadata: 1, + qualityMetadata: 1, + synthesisMetadata: 1, + qualityResult: 1, createFrom: 1, createTime: 1, updateTime: 1 diff --git a/projects/app/src/pages/api/core/evaluation/dataset/data/qualityAssessment.ts b/projects/app/src/pages/api/core/evaluation/dataset/data/qualityAssessment.ts index 868af718d141..5b1f55fe77e3 100644 --- a/projects/app/src/pages/api/core/evaluation/dataset/data/qualityAssessment.ts +++ b/projects/app/src/pages/api/core/evaluation/dataset/data/qualityAssessment.ts @@ -82,9 +82,9 @@ async function handler( await MongoEvalDatasetData.findByIdAndUpdate(dataId, { $set: { - 'metadata.qualityStatus': EvalDatasetDataQualityStatusEnum.queuing, - 'metadata.qualityModel': finalEvaluationModel, - 'metadata.qualityQueueTime': new Date() + 'qualityMetadata.status': EvalDatasetDataQualityStatusEnum.queuing, + 'qualityMetadata.model': finalEvaluationModel, + 'qualityMetadata.queueTime': new Date() } }); diff --git a/projects/app/src/pages/api/core/evaluation/dataset/data/smartGenerate.ts b/projects/app/src/pages/api/core/evaluation/dataset/data/smartGenerate.ts index a4dec64dd9fa..e8f1c2dcda9b 100644 --- a/projects/app/src/pages/api/core/evaluation/dataset/data/smartGenerate.ts +++ b/projects/app/src/pages/api/core/evaluation/dataset/data/smartGenerate.ts @@ -262,13 +262,15 @@ async function handler( [EvalDatasetDataKeyEnum.ActualOutput]: '', [EvalDatasetDataKeyEnum.Context]: [], [EvalDatasetDataKeyEnum.RetrievalContext]: [], - metadata: { - sourceDataId: sample._id, - sourceDatasetId: sample.datasetId, - sourceCollectionId: sample.collectionId, - qualityStatus: EvalDatasetDataQualityStatusEnum.unevaluated, - generatedAt: new Date(), - intelligentGenerationModel + qualityMetadata: { + status: EvalDatasetDataQualityStatusEnum.unevaluated + }, + synthesisMetadata: { + sourceDataId: sample._id.toString(), + sourceDatasetId: sample.datasetId.toString(), + sourceCollectionId: sample.collectionId.toString(), + intelligentGenerationModel, + generatedAt: new Date() }, createFrom: EvalDatasetDataCreateFromEnum.intelligentGeneration }; diff --git a/projects/app/src/pages/api/core/evaluation/dataset/data/update.ts b/projects/app/src/pages/api/core/evaluation/dataset/data/update.ts index d077b0bda524..57ae6b0a0add 100644 --- a/projects/app/src/pages/api/core/evaluation/dataset/data/update.ts +++ b/projects/app/src/pages/api/core/evaluation/dataset/data/update.ts @@ -6,7 +6,8 @@ import { MongoEvalDatasetCollection } from '@fastgpt/service/core/evaluation/dat import type { updateEvalDatasetDataBody } from '@fastgpt/global/core/evaluation/dataset/api'; import { EvalDatasetDataKeyEnum, - EvalDatasetDataQualityStatusEnum + EvalDatasetDataQualityStatusEnum, + EvalDatasetDataQualityResultEnum } from '@fastgpt/global/core/evaluation/dataset/constants'; import { addAuditLog } from '@fastgpt/service/support/user/audit/util'; import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants'; @@ -24,10 +25,17 @@ function validateRequestParams(params: { expectedOutput?: string; context?: string[]; retrievalContext?: string[]; - metadata?: Record; + qualityMetadata?: Record; }) { - const { dataId, userInput, actualOutput, expectedOutput, context, retrievalContext, metadata } = - params; + const { + dataId, + userInput, + actualOutput, + expectedOutput, + context, + retrievalContext, + qualityMetadata + } = params; if (!dataId || typeof dataId !== 'string') { throw EvaluationErrEnum.datasetDataIdRequired; } @@ -60,8 +68,10 @@ function validateRequestParams(params: { } if ( - metadata !== undefined && - (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) + qualityMetadata !== undefined && + (typeof qualityMetadata !== 'object' || + qualityMetadata === null || + Array.isArray(qualityMetadata)) ) { throw EvaluationErrEnum.datasetDataMetadataMustBeObject; } @@ -70,8 +80,17 @@ function validateRequestParams(params: { async function handler( req: ApiRequestProps ): Promise { - const { dataId, userInput, actualOutput, expectedOutput, context, retrievalContext, metadata } = - req.body; + const { + dataId, + userInput, + actualOutput, + expectedOutput, + context, + retrievalContext, + qualityMetadata, + synthesisMetadata, + qualityResult + } = req.body; validateRequestParams({ dataId, @@ -80,7 +99,7 @@ async function handler( expectedOutput, context, retrievalContext, - metadata + qualityMetadata }); const { teamId, tmbId } = await authEvaluationDatasetDataUpdateById(dataId, { @@ -118,22 +137,35 @@ async function handler( updateTime: new Date() }; - if (metadata !== undefined) { - if (Object.keys(metadata).length > 0) { - for (const [key, value] of Object.entries(metadata)) { - if (key == 'qualityStatus' && value == EvalDatasetDataQualityStatusEnum.highQuality) { - const currentQualityStatus = existingData.metadata?.qualityStatus; - if ( - currentQualityStatus === EvalDatasetDataQualityStatusEnum.queuing || - currentQualityStatus === EvalDatasetDataQualityStatusEnum.evaluating - ) { - return Promise.reject(EvaluationErrEnum.evalDataQualityJobActiveCannotSetHighQuality); - } - } - updateFields[`metadata.${key}`] = value; + // Handle quality result updates + if (qualityResult !== undefined) { + if (qualityResult === EvalDatasetDataQualityResultEnum.highQuality) { + const currentQualityStatus = existingData.qualityMetadata?.status; + if ( + currentQualityStatus === EvalDatasetDataQualityStatusEnum.queuing || + currentQualityStatus === EvalDatasetDataQualityStatusEnum.evaluating + ) { + return Promise.reject(EvaluationErrEnum.evalDataQualityJobActiveCannotSetHighQuality); + } + } + updateFields.qualityResult = qualityResult; + } + + // Handle quality metadata updates + if (qualityMetadata !== undefined) { + if (Object.keys(qualityMetadata).length > 0) { + for (const [key, value] of Object.entries(qualityMetadata)) { + updateFields[`qualityMetadata.${key}`] = value; + } + } + } + + // Handle synthesis metadata updates + if (synthesisMetadata !== undefined) { + if (Object.keys(synthesisMetadata).length > 0) { + for (const [key, value] of Object.entries(synthesisMetadata)) { + updateFields[`synthesisMetadata.${key}`] = value; } - } else { - updateFields.metadata = {}; } } diff --git a/test/cases/pages/api/core/evaluation/dataset/data/import.test.ts b/test/cases/pages/api/core/evaluation/dataset/data/import.test.ts index 14dd3043f9a8..2eea7560e9ef 100644 --- a/test/cases/pages/api/core/evaluation/dataset/data/import.test.ts +++ b/test/cases/pages/api/core/evaluation/dataset/data/import.test.ts @@ -122,6 +122,10 @@ describe('EvalDatasetData Import API', () => { // Mock file system const fs = require('fs'); vi.spyOn(fs, 'readFileSync').mockReturnValue(validCSVContent); + vi.spyOn(fs, 'statSync').mockReturnValue({ + isFile: () => true, + isDirectory: () => false + } as any); mockGetUploadModel.mockReturnValue(mockUploadModel as any); mockUploadModel.getUploadFiles.mockResolvedValue({ @@ -203,7 +207,7 @@ describe('EvalDatasetData Import API', () => { const req = { body: {} }; const res = {}; - await expect(handler_test(req as any, res as any)).rejects.toBe('commonMissingParams'); + await expect(handler_test(req as any, res as any)).rejects.toBe('missingParams'); }); it('should reject when both collectionId and name provided', async () => { @@ -219,7 +223,7 @@ describe('EvalDatasetData Import API', () => { const req = { body: {} }; const res = {}; - await expect(handler_test(req as any, res as any)).rejects.toBe('commonInvalidParams'); + await expect(handler_test(req as any, res as any)).rejects.toBe('invalidParams'); }); it('should reject when enableQualityEvaluation is not boolean', async () => { @@ -314,7 +318,7 @@ describe('EvalDatasetData Import API', () => { const res = {}; await expect(handler_test(req as any, res as any)).rejects.toBe( - 'evaluationEvalInsufficientPermission' + 'evaluationInsufficientPermission' ); }); }); @@ -363,9 +367,7 @@ describe('EvalDatasetData Import API', () => { const req = { body: {} }; const res = {}; - await expect(handler_test(req as any, res as any)).rejects.toBe( - 'evaluationEvalNameRequired' - ); + await expect(handler_test(req as any, res as any)).rejects.toBe('evaluationNameRequired'); }); it('should reject when collection name already exists', async () => { @@ -378,7 +380,7 @@ describe('EvalDatasetData Import API', () => { const res = {}; await expect(handler_test(req as any, res as any)).rejects.toBe( - 'evaluationEvalDuplicateDatasetName' + 'evaluationDuplicateDatasetName' ); }); }); @@ -499,6 +501,92 @@ describe('EvalDatasetData Import API', () => { }); }); + describe('Security - Directory Traversal Protection', () => { + it('should reject files with directory traversal attempts', async () => { + mockUploadModel.getUploadFiles.mockResolvedValue({ + files: [{ ...mockFiles[0], path: '../../../etc/passwd' }], + data: { collectionId: validCollectionId, enableQualityEvaluation: false } + }); + + const req = { body: {} }; + const res = {}; + + await expect(handler_test(req as any, res as any)).rejects.toBe('evaluationCSVParsingError'); + }); + + it('should reject files with Windows-style directory traversal', async () => { + mockUploadModel.getUploadFiles.mockResolvedValue({ + files: [{ ...mockFiles[0], path: '..\\..\\windows\\system32\\config\\sam' }], + data: { collectionId: validCollectionId, enableQualityEvaluation: false } + }); + + const req = { body: {} }; + const res = {}; + + await expect(handler_test(req as any, res as any)).rejects.toBe('evaluationCSVParsingError'); + }); + + it('should reject non-existent file paths', async () => { + mockUploadModel.getUploadFiles.mockResolvedValue({ + files: [{ ...mockFiles[0], path: '/non/existent/file.csv' }], + data: { collectionId: validCollectionId, enableQualityEvaluation: false } + }); + + const fs = require('fs'); + fs.readFileSync.mockImplementation(() => { + throw new Error('ENOENT: no such file or directory'); + }); + + const req = { body: {} }; + const res = {}; + + await expect(handler_test(req as any, res as any)).rejects.toBe('evaluationCSVParsingError'); + }); + + it('should reject directory paths instead of files', async () => { + mockUploadModel.getUploadFiles.mockResolvedValue({ + files: [{ ...mockFiles[0], path: '/tmp/' }], + data: { collectionId: validCollectionId, enableQualityEvaluation: false } + }); + + const fs = require('fs'); + fs.statSync = vi.fn().mockReturnValue({ + isFile: () => false, + isDirectory: () => true + }); + + const req = { body: {} }; + const res = {}; + + await expect(handler_test(req as any, res as any)).rejects.toBe('evaluationCSVParsingError'); + }); + + it('should allow legitimate file paths', async () => { + const legitimateFiles = [ + { ...mockFiles[0], path: '/tmp/uploads/valid-file.csv' }, + { ...mockFiles[1], path: '/var/tmp/upload-123.csv' } + ]; + + mockUploadModel.getUploadFiles.mockResolvedValue({ + files: legitimateFiles, + data: { collectionId: validCollectionId, enableQualityEvaluation: false } + }); + + const fs = require('fs'); + fs.statSync = vi.fn().mockReturnValue({ + isFile: () => true, + isDirectory: () => false + }); + fs.readFileSync.mockReturnValue(validCSVContent); + + const req = { body: {} }; + const res = {}; + + const result = await handler_test(req as any, res as any); + expect(result).toBe('success'); + }); + }); + describe('File Cleanup', () => { it('should always clean up uploaded files', async () => { const req = { body: {} }; diff --git a/test/cases/pages/api/core/evaluation/dataset/data/list.test.ts b/test/cases/pages/api/core/evaluation/dataset/data/list.test.ts index 721e9f08f948..3d345d78a7d1 100644 --- a/test/cases/pages/api/core/evaluation/dataset/data/list.test.ts +++ b/test/cases/pages/api/core/evaluation/dataset/data/list.test.ts @@ -289,7 +289,7 @@ describe('EvalDatasetData List API', () => { const expectedMatch = { evalDatasetCollectionId: new Types.ObjectId(validCollectionId), - 'metadata.qualityStatus': status + 'qualityMetadata.status': status }; expect(mockMongoEvalDatasetData.aggregate).toHaveBeenCalledWith( @@ -332,7 +332,7 @@ describe('EvalDatasetData List API', () => { const expectedMatch = { evalDatasetCollectionId: new Types.ObjectId(validCollectionId), - 'metadata.qualityStatus': EvalDatasetDataQualityStatusEnum.highQuality, + 'qualityMetadata.status': EvalDatasetDataQualityStatusEnum.highQuality, $or: [ { [EvalDatasetDataKeyEnum.UserInput]: { $regex: new RegExp('AI', 'i') } }, { [EvalDatasetDataKeyEnum.ExpectedOutput]: { $regex: new RegExp('AI', 'i') } }, @@ -362,7 +362,7 @@ describe('EvalDatasetData List API', () => { const expectedMatch = { evalDatasetCollectionId: new Types.ObjectId(validCollectionId), - 'metadata.qualityStatus': EvalDatasetDataQualityStatusEnum.needsOptimization + 'qualityMetadata.status': EvalDatasetDataQualityStatusEnum.needsOptimization }; expect(mockMongoEvalDatasetData.aggregate).toHaveBeenCalledWith( @@ -421,7 +421,7 @@ describe('EvalDatasetData List API', () => { const expectedMatch1 = { evalDatasetCollectionId: new Types.ObjectId(validCollectionId), - 'metadata.qualityStatus': EvalDatasetDataQualityStatusEnum.highQuality, + 'qualityMetadata.status': EvalDatasetDataQualityStatusEnum.highQuality, $or: expect.arrayContaining([ { [EvalDatasetDataKeyEnum.UserInput]: { $regex: expect.any(RegExp) } }, { [EvalDatasetDataKeyEnum.ExpectedOutput]: { $regex: expect.any(RegExp) } }, @@ -452,7 +452,7 @@ describe('EvalDatasetData List API', () => { const expectedMatch2 = { evalDatasetCollectionId: new Types.ObjectId(validCollectionId), - 'metadata.qualityStatus': EvalDatasetDataQualityStatusEnum.needsOptimization, + 'qualityMetadata.status': EvalDatasetDataQualityStatusEnum.needsOptimization, $or: expect.arrayContaining([ { [EvalDatasetDataKeyEnum.UserInput]: { $regex: expect.any(RegExp) } }, { [EvalDatasetDataKeyEnum.ExpectedOutput]: { $regex: expect.any(RegExp) } }, @@ -487,7 +487,9 @@ describe('EvalDatasetData List API', () => { [EvalDatasetDataKeyEnum.ExpectedOutput]: 1, [EvalDatasetDataKeyEnum.Context]: 1, [EvalDatasetDataKeyEnum.RetrievalContext]: 1, - metadata: 1, + qualityMetadata: 1, + synthesisMetadata: 1, + qualityResult: 1, createFrom: 1, createTime: 1, updateTime: 1 @@ -536,7 +538,7 @@ describe('EvalDatasetData List API', () => { { $match: { evalDatasetCollectionId: new Types.ObjectId(validCollectionId), - 'metadata.qualityStatus': EvalDatasetDataQualityStatusEnum.highQuality + 'qualityMetadata.status': EvalDatasetDataQualityStatusEnum.highQuality } } ]) @@ -561,7 +563,7 @@ describe('EvalDatasetData List API', () => { { $match: { evalDatasetCollectionId: new Types.ObjectId(validCollectionId), - 'metadata.qualityStatus': EvalDatasetDataQualityStatusEnum.needsOptimization, + 'qualityMetadata.status': EvalDatasetDataQualityStatusEnum.needsOptimization, $or: [ { [EvalDatasetDataKeyEnum.UserInput]: { $regex: new RegExp('AI', 'i') } }, { [EvalDatasetDataKeyEnum.ExpectedOutput]: { $regex: new RegExp('AI', 'i') } }, @@ -605,7 +607,9 @@ describe('EvalDatasetData List API', () => { 'Artificial Intelligence is a field of computer science', [EvalDatasetDataKeyEnum.Context]: ['Machine learning context'], [EvalDatasetDataKeyEnum.RetrievalContext]: ['AI knowledge base'], - metadata: { quality: 'good' }, + qualityMetadata: { status: 'unevaluated' }, + synthesisMetadata: {}, + qualityResult: undefined, createFrom: 'manual', createTime: expect.any(Date), updateTime: expect.any(Date) @@ -618,7 +622,9 @@ describe('EvalDatasetData List API', () => { 'Machine Learning works by training algorithms', [EvalDatasetDataKeyEnum.Context]: [], [EvalDatasetDataKeyEnum.RetrievalContext]: [], - metadata: {}, + qualityMetadata: { status: 'unevaluated' }, + synthesisMetadata: {}, + qualityResult: undefined, createFrom: 'auto', createTime: expect.any(Date), updateTime: expect.any(Date) @@ -732,7 +738,9 @@ describe('EvalDatasetData List API', () => { const result = await handler_test(req as any); - expect(result.list[0].metadata).toEqual({}); + expect(result.list[0].qualityMetadata).toEqual({ status: 'unevaluated' }); + expect(result.list[0].synthesisMetadata).toEqual({}); + expect(result.list[0].qualityResult).toEqual(undefined); }); it('should convert ObjectId to string', async () => { @@ -883,7 +891,9 @@ describe('EvalDatasetData List API', () => { [EvalDatasetDataKeyEnum.ExpectedOutput]: 1, [EvalDatasetDataKeyEnum.Context]: 1, [EvalDatasetDataKeyEnum.RetrievalContext]: 1, - metadata: 1, + qualityMetadata: 1, + synthesisMetadata: 1, + qualityResult: 1, createFrom: 1, createTime: 1, updateTime: 1 @@ -933,7 +943,7 @@ describe('EvalDatasetData List API', () => { const expectedMatch = { evalDatasetCollectionId: new Types.ObjectId(validCollectionId), - 'metadata.qualityStatus': EvalDatasetDataQualityStatusEnum.highQuality + 'qualityMetadata.status': EvalDatasetDataQualityStatusEnum.highQuality }; expect(mockMongoEvalDatasetData.aggregate).toHaveBeenCalledWith( @@ -958,7 +968,7 @@ describe('EvalDatasetData List API', () => { const expectedMatch = { evalDatasetCollectionId: new Types.ObjectId(validCollectionId), - 'metadata.qualityStatus': EvalDatasetDataQualityStatusEnum.completed, + 'qualityMetadata.status': EvalDatasetDataQualityStatusEnum.completed, $or: [ { [EvalDatasetDataKeyEnum.UserInput]: { $regex: new RegExp('AI model', 'i') } }, { [EvalDatasetDataKeyEnum.ExpectedOutput]: { $regex: new RegExp('AI model', 'i') } }, diff --git a/test/cases/pages/api/core/evaluation/dataset/data/qualityAssessment.test.ts b/test/cases/pages/api/core/evaluation/dataset/data/qualityAssessment.test.ts index 80cdfdd117f7..68be0072a60c 100644 --- a/test/cases/pages/api/core/evaluation/dataset/data/qualityAssessment.test.ts +++ b/test/cases/pages/api/core/evaluation/dataset/data/qualityAssessment.test.ts @@ -314,9 +314,9 @@ describe('QualityAssessment API', () => { expect(mockMongoEvalDatasetData.findByIdAndUpdate).toHaveBeenCalledWith(validDataId, { $set: { - 'metadata.qualityStatus': EvalDatasetDataQualityStatusEnum.queuing, - 'metadata.qualityModel': validEvaluationModel, - 'metadata.qualityQueueTime': expect.any(Date) + 'qualityMetadata.status': EvalDatasetDataQualityStatusEnum.queuing, + 'qualityMetadata.model': validEvaluationModel, + 'qualityMetadata.queueTime': expect.any(Date) } }); }); @@ -524,9 +524,9 @@ describe('QualityAssessment API', () => { }); expect(mockMongoEvalDatasetData.findByIdAndUpdate).toHaveBeenCalledWith(validDataId, { $set: { - 'metadata.qualityStatus': EvalDatasetDataQualityStatusEnum.queuing, - 'metadata.qualityModel': validEvaluationModel, - 'metadata.qualityQueueTime': expect.any(Date) + 'qualityMetadata.status': EvalDatasetDataQualityStatusEnum.queuing, + 'qualityMetadata.model': validEvaluationModel, + 'qualityMetadata.queueTime': expect.any(Date) } }); expect(result).toBe('success'); @@ -551,9 +551,9 @@ describe('QualityAssessment API', () => { }); expect(mockMongoEvalDatasetData.findByIdAndUpdate).toHaveBeenCalledWith(validDataId, { $set: { - 'metadata.qualityStatus': EvalDatasetDataQualityStatusEnum.queuing, - 'metadata.qualityModel': validEvaluationModel, - 'metadata.qualityQueueTime': expect.any(Date) + 'qualityMetadata.status': EvalDatasetDataQualityStatusEnum.queuing, + 'qualityMetadata.model': validEvaluationModel, + 'qualityMetadata.queueTime': expect.any(Date) } }); expect(result).toBe('success'); diff --git a/test/cases/pages/api/core/evaluation/dataset/data/update.test.ts b/test/cases/pages/api/core/evaluation/dataset/data/update.test.ts index acc6d2754621..6785d9a42bcf 100644 --- a/test/cases/pages/api/core/evaluation/dataset/data/update.test.ts +++ b/test/cases/pages/api/core/evaluation/dataset/data/update.test.ts @@ -251,18 +251,18 @@ describe('EvalDatasetData Update API', () => { expectedError: EvaluationErrEnum.datasetDataRetrievalContextMustBeArrayOfStrings }, { - description: 'should reject when metadata is not an object', - bodyOverrides: { metadata: 'not an object' }, + description: 'should reject when qualityMetadata is not an object', + bodyOverrides: { qualityMetadata: 'not an object' }, expectedError: EvaluationErrEnum.datasetDataMetadataMustBeObject }, { - description: 'should reject when metadata is an array', - bodyOverrides: { metadata: ['array'] }, + description: 'should reject when qualityMetadata is an array', + bodyOverrides: { qualityMetadata: ['array'] }, expectedError: EvaluationErrEnum.datasetDataMetadataMustBeObject }, { - description: 'should reject when metadata is null', - bodyOverrides: { metadata: null }, + description: 'should reject when qualityMetadata is null', + bodyOverrides: { qualityMetadata: null }, expectedError: EvaluationErrEnum.datasetDataMetadataMustBeObject } ]); @@ -402,25 +402,21 @@ describe('EvalDatasetData Update API', () => { expectUpdateCall(createExpectedUpdateFields()); }); - it('should handle metadata updates', async () => { - const req = createBaseRequest({ metadata: { custom: 'value', score: 95 } }); + it('should handle qualityMetadata updates', async () => { + const req = createBaseRequest({ qualityMetadata: { custom: 'value', score: 95 } }); await handler_test(req as any); expectUpdateCall( createExpectedUpdateFields({ - 'metadata.custom': 'value', - 'metadata.score': 95 + 'qualityMetadata.custom': 'value', + 'qualityMetadata.score': 95 }) ); }); - it('should handle empty metadata object', async () => { - const req = createBaseRequest({ metadata: {} }); + it('should handle empty qualityMetadata object', async () => { + const req = createBaseRequest({ qualityMetadata: {} }); await handler_test(req as any); - expectUpdateCall( - createExpectedUpdateFields({ - metadata: {} - }) - ); + expectUpdateCall(createExpectedUpdateFields()); }); it('should propagate database update errors', async () => { From fbe9026501f4f49e138edf0330952ba8fe78d918 Mon Sep 17 00:00:00 2001 From: chanzany Date: Tue, 16 Sep 2025 15:19:29 +0800 Subject: [PATCH 08/84] fix: fix eval item processor logic --- packages/global/core/evaluation/api.d.ts | 55 +- packages/global/core/evaluation/type.d.ts | 10 +- .../service/core/evaluation/target/index.ts | 72 +- .../service/core/evaluation/task/index.ts | 520 ++--------- packages/service/core/evaluation/task/mq.ts | 8 +- .../service/core/evaluation/task/processor.ts | 212 +++-- .../service/core/evaluation/task/schema.ts | 8 +- .../core/evaluation/task/dataItem/delete.ts | 58 -- .../core/evaluation/task/dataItem/export.ts | 55 -- .../api/core/evaluation/task/dataItem/list.ts | 42 - .../core/evaluation/task/dataItem/retry.ts | 56 -- .../core/evaluation/task/dataItem/update.ts | 67 -- .../pages/api/core/evaluation/task/list.ts | 5 +- .../evaluation/task/dataItem/delete.test.ts | 149 --- .../evaluation/task/dataItem/export.test.ts | 225 ----- .../evaluation/task/dataItem/list.test.ts | 226 ----- .../evaluation/task/dataItem/retry.test.ts | 113 --- .../evaluation/task/dataItem/update.test.ts | 131 --- .../api/core/evaluation/task/list.test.ts | 10 +- .../service/core/evaluation/task.test.ts | 880 ++++++++++-------- 20 files changed, 777 insertions(+), 2125 deletions(-) delete mode 100644 projects/app/src/pages/api/core/evaluation/task/dataItem/delete.ts delete mode 100644 projects/app/src/pages/api/core/evaluation/task/dataItem/export.ts delete mode 100644 projects/app/src/pages/api/core/evaluation/task/dataItem/list.ts delete mode 100644 projects/app/src/pages/api/core/evaluation/task/dataItem/retry.ts delete mode 100644 projects/app/src/pages/api/core/evaluation/task/dataItem/update.ts delete mode 100644 test/cases/pages/api/core/evaluation/task/dataItem/delete.test.ts delete mode 100644 test/cases/pages/api/core/evaluation/task/dataItem/export.test.ts delete mode 100644 test/cases/pages/api/core/evaluation/task/dataItem/list.test.ts delete mode 100644 test/cases/pages/api/core/evaluation/task/dataItem/retry.test.ts delete mode 100644 test/cases/pages/api/core/evaluation/task/dataItem/update.test.ts diff --git a/packages/global/core/evaluation/api.d.ts b/packages/global/core/evaluation/api.d.ts index f2dfdb71ef28..ff6dbc156af2 100644 --- a/packages/global/core/evaluation/api.d.ts +++ b/packages/global/core/evaluation/api.d.ts @@ -35,7 +35,6 @@ export type ListEvaluationsRequest = PaginationProps<{ searchKey?: string; appName?: string; appId?: string; - versionId?: string; }>; export type ListEvaluationsResponse = PaginationResponse; @@ -79,13 +78,7 @@ export type ListEvaluationItemsResponse = PaginationResponse; @@ -98,49 +91,3 @@ export type RetryEvaluationItemResponse = MessageResponse; // Delete Evaluation Item export type DeleteEvaluationItemRequest = EvalItemIdQuery; export type DeleteEvaluationItemResponse = MessageResponse; - -// ===== DataItem Aggregation API ===== - -// Query for dataItem ID -export type DataItemIdQuery = { dataItemId: string }; - -// DataItem List (Grouped by DataItem) -export type DataItemListRequest = PaginationProps< - EvalIdQuery & { - status?: number; // Optional: filter by status - keyword?: string; // Optional: search in dataItem content - } ->; -export type DataItemGroupedItem = { - dataItemId: string; - dataItem: EvaluationDataItemType; - items: EvaluationItemDisplayType[]; - statistics?: EvaluationStatistics; -}; -export type DataItemListResponse = PaginationResponse; - -// Delete DataItem Items -export type DeleteDataItemRequest = DataItemIdQuery & EvalIdQuery; -export type DeleteDataItemResponse = { - message: string; - deletedCount: number; -}; - -// Retry DataItem Items -export type RetryDataItemRequest = DataItemIdQuery & EvalIdQuery; -export type RetryDataItemResponse = { - message: string; - retriedCount: number; -}; - -// Update DataItem Items -export type UpdateDataItemRequest = DataItemIdQuery & EvalIdQuery & Partial; -export type UpdateDataItemResponse = { - message: string; - updatedCount: number; -}; - -// Export All DataItems Results -export type ExportDataItemsResultsRequest = EvalIdQuery & { - format?: 'csv' | 'json'; -}; diff --git a/packages/global/core/evaluation/type.d.ts b/packages/global/core/evaluation/type.d.ts index 5624afd3e8df..90ec1940748e 100644 --- a/packages/global/core/evaluation/type.d.ts +++ b/packages/global/core/evaluation/type.d.ts @@ -101,17 +101,17 @@ export type EvaluationDataItemType = EvalDatasetDataSchemaType & { targetCallParams?: TargetCallParams; }; -// Evaluation item type (atomic: one dataItem + one target + one evaluator) +// Evaluation item type (batch: one dataItem + one target + multiple evaluators) export type EvaluationItemSchemaType = { _id: string; evalId: string; // Dependent component configurations dataItem: EvaluationDataItemType; target: EvalTarget; - evaluator: EvaluatorSchema; // Single evaluator configuration + evaluators: EvaluatorSchema[]; // Multiple evaluator configurations // Execution results targetOutput?: TargetOutput; // Actual output from target - evaluatorOutput?: MetricResult; // Result from single evaluator + evaluatorOutputs?: MetricResult[]; // Results from multiple evaluators status: EvaluationStatusEnum; retry: number; finishTime?: Date; @@ -157,9 +157,7 @@ export type EvaluationDisplayType = Pick< sourceMember: SourceMemberType; }; -export type EvaluationItemDisplayType = EvaluationItemSchemaType & { - evalItemId: string; -}; +export type EvaluationItemDisplayType = EvaluationItemSchemaType; export interface CreateEvaluationParams { name: string; diff --git a/packages/service/core/evaluation/target/index.ts b/packages/service/core/evaluation/target/index.ts index 113f0e1e1cc0..35967e5a3aa7 100644 --- a/packages/service/core/evaluation/target/index.ts +++ b/packages/service/core/evaluation/target/index.ts @@ -112,42 +112,42 @@ export class WorkflowTarget extends EvaluationTarget { // Construct conversation history based on input.context const histories: (UserChatItemType | AIChatItemType)[] = []; - if (input.context && input.context.length > 0) { - // Convert context strings to alternating user-ai conversation history - // Assume context format: [user1, ai1, user2, ai2, ...] - for (let i = 0; i < input.context.length; i++) { - const isUser = i % 2 === 0; - const content = input.context[i]; - - if (isUser) { - // User message - histories.push({ - obj: ChatRoleEnum.Human, - value: [ - { - type: ChatItemValueTypeEnum.text, - text: { - content - } - } - ] - }); - } else { - // AI message - histories.push({ - obj: ChatRoleEnum.AI, - value: [ - { - type: ChatItemValueTypeEnum.text, - text: { - content - } - } - ] - }); - } - } - } + // if (input.histories && input.histories.length > 0) { + // // Convert histories strings to alternating user-ai conversation history + // // Assume histories format: [user1, ai1, user2, ai2, ...] + // for (let i = 0; i < input.histories.length; i++) { + // const isUser = i % 2 === 0; + // const content = input.histories[i]; + + // if (isUser) { + // // User message + // histories.push({ + // obj: ChatRoleEnum.Human, + // value: [ + // { + // type: ChatItemValueTypeEnum.text, + // text: { + // content + // } + // } + // ] + // }); + // } else { + // // AI message + // histories.push({ + // obj: ChatRoleEnum.AI, + // value: [ + // { + // type: ChatItemValueTypeEnum.text, + // text: { + // content + // } + // } + // ] + // }); + // } + // } + // } const chatId = getNanoid(); diff --git a/packages/service/core/evaluation/task/index.ts b/packages/service/core/evaluation/task/index.ts index a1ef319caf8c..f2016c17ffc1 100644 --- a/packages/service/core/evaluation/task/index.ts +++ b/packages/service/core/evaluation/task/index.ts @@ -5,11 +5,8 @@ import type { CreateEvaluationParams, EvaluationItemDisplayType, TargetCallParams, - EvaluationDataItemType, EvaluationDisplayType } from '@fastgpt/global/core/evaluation/type'; -import type { DataItemListResponse } from '@fastgpt/global/core/evaluation/api'; -import type { MetricResult } from '@fastgpt/global/core/evaluation/metric/type'; import { Types } from 'mongoose'; import { EvaluationStatusEnum } from '@fastgpt/global/core/evaluation/constants'; import { @@ -26,9 +23,6 @@ import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation' import { mongoSessionRun } from '../../../common/mongo/sessionRun'; import { type ClientSession } from '../../../common/mongo'; -// Constants -const MAX_EXPORT_PAGE_SIZE = 100000; - export class EvaluationTaskService { static async createEvaluation( params: CreateEvaluationParams & { @@ -173,17 +167,10 @@ export class EvaluationTaskService { tmbId?: string, isOwner: boolean = false, appName?: string, - appId?: string, - versionId?: string + appId?: string ): Promise<{ list: EvaluationDisplayType[]; total: number }> { // Build basic filter and pagination const filter: any = { teamId: new Types.ObjectId(teamId) }; - if (searchKey) { - filter.$or = [ - { name: { $regex: searchKey, $options: 'i' } }, - { description: { $regex: searchKey, $options: 'i' } } - ]; - } const skip = offset; const limit = pageSize; const sort = { createTime: -1 as const }; @@ -247,7 +234,7 @@ export class EvaluationTaskService { ]; // Add target filtering stage if any target filters are provided - if (appName || appId || versionId) { + if (appName || appId) { const targetFilter: any = {}; if (appName) { @@ -258,13 +245,23 @@ export class EvaluationTaskService { targetFilter['target.config.appId'] = appId; } - if (versionId) { - targetFilter['target.config.versionId'] = versionId; - } - aggregationPipeline.push({ $match: targetFilter }); } + // Add searchKey filtering after target config is populated (includes versionId/versionName search) + if (searchKey) { + aggregationPipeline.push({ + $match: { + $or: [ + { name: { $regex: searchKey, $options: 'i' } }, + { description: { $regex: searchKey, $options: 'i' } }, + { 'target.config.versionId': { $regex: searchKey, $options: 'i' } }, + { 'target.config.versionName': { $regex: searchKey, $options: 'i' } } + ] + } + }); + } + const [evaluations, total] = await Promise.all([ MongoEvaluation.aggregate([ ...aggregationPipeline, @@ -276,7 +273,7 @@ export class EvaluationTaskService { pipeline: [ { $match: { - $expr: { $eq: ['$evalId', '$evalId'] } + $expr: { $eq: ['$evalId', '$$evalId'] } } }, { @@ -402,7 +399,7 @@ export class EvaluationTaskService { pipeline: [ { $match: { - $expr: { $eq: ['$evalId', '$evalId'] } + $expr: { $eq: ['$evalId', '$$evalId'] } } }, { @@ -482,35 +479,6 @@ export class EvaluationTaskService { return evaluation; } - static async listEvaluationItems( - evalId: string, - teamId: string, - offset: number = 0, - pageSize: number = 20 - ): Promise<{ items: EvaluationItemDisplayType[]; total: number }> { - const evaluation = await this.getEvaluation(evalId, teamId); - - const skip = offset; - const limit = pageSize; - - const [items, total] = await Promise.all([ - MongoEvalItem.find({ evalId: evaluation._id }) - .sort({ createTime: -1 }) - .skip(skip) - .limit(limit) - .lean() - .then((items) => - items.map((item) => ({ - ...item, - evalItemId: item._id.toString() - })) - ), - MongoEvalItem.countDocuments({ evalId: evaluation._id }) - ]); - - return { items, total }; - } - static async startEvaluation(evalId: string, teamId: string): Promise { const evaluation = await this.getEvaluation(evalId, teamId); @@ -676,6 +644,29 @@ export class EvaluationTaskService { // ========================= Evaluation Item Related APIs ========================= + static async listEvaluationItems( + evalId: string, + teamId: string, + offset: number = 0, + pageSize: number = 20 + ): Promise<{ items: EvaluationItemDisplayType[]; total: number }> { + const evaluation = await this.getEvaluation(evalId, teamId); + + const skip = offset; + const limit = pageSize; + + const [items, total] = await Promise.all([ + MongoEvalItem.find({ evalId: evaluation._id }) + .sort({ createTime: -1 }) + .skip(skip) + .limit(limit) + .lean(), + MongoEvalItem.countDocuments({ evalId: evaluation._id }) + ]); + + return { items, total }; + } + static async getEvaluationItem( itemId: string, teamId: string @@ -749,6 +740,38 @@ export class EvaluationTaskService { if (result.matchedCount === 0) { throw new Error(EvaluationErrEnum.evalItemNotFound); } + + // If actual update occurred, re-queue the item for evaluation + if (result.modifiedCount > 0) { + // Get the updated item to determine the evalId + const updatedItem = await MongoEvalItem.findById(itemId, 'evalId'); + if (updatedItem) { + // Reset evaluation results and re-queue + await MongoEvalItem.updateOne( + { _id: new Types.ObjectId(itemId) }, + { + $set: { + status: EvaluationStatusEnum.queuing, + retry: 3 + }, + $unset: { + targetOutput: 1, + evaluatorOutputs: 1, + finishTime: 1, + errorMessage: 1 + } + } + ); + + // Re-submit to evaluation queue + await evaluationItemQueue.add(`eval_item_update_${itemId}`, { + evalId: updatedItem.evalId.toString(), + evalItemId: itemId + }); + + addLog.debug(`[Evaluation] Item updated and re-queued for evaluation: ${itemId}`); + } + } } static async deleteEvaluationItem(itemId: string, teamId: string): Promise { @@ -813,7 +836,7 @@ export class EvaluationTaskService { status: EvaluationStatusEnum.queuing, retry: Math.max(item.retry || 0, 1), // Ensure at least 1 retry chance targetOutput: {}, - evaluatorOutput: {} + evaluatorOutputs: [] }, $unset: { finishTime: 1, @@ -886,7 +909,7 @@ export class EvaluationTaskService { $set: { status: EvaluationStatusEnum.queuing, targetOutput: {}, - evaluatorOutput: {} + evaluatorOutputs: [] }, $unset: { finishTime: 1, @@ -934,22 +957,9 @@ export class EvaluationTaskService { static async getEvaluationItemResult( itemId: string, teamId: string - ): Promise<{ - item: EvaluationItemSchemaType; - dataItem: EvaluationDataItemType; - response?: string; - result?: MetricResult; - score?: number; - }> { + ): Promise { const item = await this.getEvaluationItem(itemId, teamId); - - return { - item, - dataItem: item.dataItem, - response: item.targetOutput?.actualOutput, - result: item.evaluatorOutput, - score: item.evaluatorOutput?.data?.score - }; + return item; } // Search evaluation items @@ -991,7 +1001,7 @@ export class EvaluationTaskService { scoreFilter.$lte = scoreRange.max; } if (Object.keys(scoreFilter).length > 0) { - filter['evaluatorOutput.data.score'] = scoreFilter; + filter['evaluatorOutputs.0.data.score'] = scoreFilter; } } @@ -1043,10 +1053,10 @@ export class EvaluationTaskService { userInput: item.dataItem?.userInput, expectedOutput: item.dataItem?.expectedOutput, actualOutput: item.targetOutput?.actualOutput, - score: item.evaluatorOutput?.data?.score, + scores: item.evaluatorOutputs?.map((output) => output?.data?.score) || [], status: item.status, targetOutput: item.targetOutput, - evaluatorOutput: item.evaluatorOutput, + evaluatorOutputs: item.evaluatorOutputs, errorMessage: item.errorMessage, finishTime: item.finishTime })); @@ -1058,12 +1068,23 @@ export class EvaluationTaskService { return { results: Buffer.from(''), total: 0 }; } + // Collect all unique metric names from evaluator outputs + const metricNames = new Set(); + items.forEach((item) => { + item.evaluatorOutputs?.forEach((output) => { + if (output?.data?.metricName) { + metricNames.add(output.data.metricName); + } + }); + }); + const sortedMetricNames = Array.from(metricNames).sort(); + const headers = [ 'ItemId', 'UserInput', 'ExpectedOutput', 'ActualOutput', - 'Score', + ...sortedMetricNames, // Dynamic metric columns 'Status', 'ErrorMessage', 'FinishTime' @@ -1072,12 +1093,21 @@ export class EvaluationTaskService { const csvRows = [headers.join(',')]; items.forEach((item) => { + // Create a map of metric name to score for easier lookup + const metricScoreMap = new Map(); + item.evaluatorOutputs?.forEach((output) => { + if (output?.data?.metricName && output.data.score !== undefined) { + metricScoreMap.set(output.data.metricName, output.data.score); + } + }); + const row = [ item._id.toString(), `"${(item.dataItem?.userInput || '').replace(/"/g, '""')}"`, `"${(item.dataItem?.expectedOutput || '').replace(/"/g, '""')}"`, `"${(item.targetOutput?.actualOutput || '').replace(/"/g, '""')}"`, - item.evaluatorOutput?.data?.score || '', + // Add scores for each metric column in the same order as headers + ...sortedMetricNames.map((metricName) => metricScoreMap.get(metricName) || ''), item.status || '', `"${(item.errorMessage || '').replace(/"/g, '""')}"`, item.finishTime || '' @@ -1088,351 +1118,5 @@ export class EvaluationTaskService { return { results: Buffer.from(csvRows.join('\n')), total }; } } - - // ========================= DataItem Aggregation APIs ========================= - - static async listDataItemsGrouped( - teamId: string, - options: { - evalId: string; - status?: number; - keyword?: string; - offset?: number; - pageSize?: number; - } - ): Promise { - const { evalId, status, keyword, offset = 0, pageSize = 20 } = options; - - // Verify team access to the evaluation task - await this.getEvaluation(evalId, teamId); - - // Build match stage - const matchStage: any = { - evalId: new Types.ObjectId(evalId) - }; - - if (status !== undefined) { - matchStage.status = status; - } - - if (keyword) { - matchStage.$or = [ - { 'dataItem.userInput': { $regex: keyword, $options: 'i' } }, - { 'dataItem.expectedOutput': { $regex: keyword, $options: 'i' } } - ]; - } - - // Build aggregation pipeline - const aggregationPipeline = [ - { $match: matchStage }, - { - $group: { - _id: '$dataItem._id', - dataItem: { $first: '$dataItem' }, - items: { $push: '$$ROOT' }, - totalItems: { $sum: 1 }, - completedItems: { - $sum: { $cond: [{ $eq: ['$status', EvaluationStatusEnum.completed] }, 1, 0] } - }, - errorItems: { - $sum: { $cond: [{ $eq: ['$status', EvaluationStatusEnum.error] }, 1, 0] } - } - } - }, - { - $addFields: { - dataItemId: '$_id', - 'statistics.totalItems': '$totalItems', - 'statistics.completedItems': '$completedItems', - 'statistics.errorItems': '$errorItems' - } - }, - { $sort: { totalItems: -1 as const, _id: 1 as const } } - ]; - - // Simple Promise.all approach like listEvaluationItems - const [list, total] = await Promise.all([ - // Get paginated results with projection - MongoEvalItem.aggregate([ - ...aggregationPipeline, - { $skip: offset }, - { $limit: pageSize }, - { - $project: { - dataItemId: 1, - dataItem: 1, - items: { - $map: { - input: '$items', - as: 'item', - in: { - $mergeObjects: ['$$item', { evalItemId: { $toString: '$$item._id' } }] - } - } - }, - statistics: { - totalItems: '$totalItems', - completedItems: '$completedItems', - errorItems: '$errorItems' - } - } - } - ]), - // Get total count - MongoEvalItem.aggregate([...aggregationPipeline, { $count: 'total' }]).then( - (result) => result[0]?.total || 0 - ) - ]); - - return { - list, - total - }; - } - - static async deleteEvaluationItemsByDataItem( - dataItemId: string, - teamId: string, - evalId: string - ): Promise<{ deletedCount: number }> { - // Verify team access to the evaluation task - await this.getEvaluation(evalId, teamId); - - const filter: any = { - 'dataItem._id': new Types.ObjectId(dataItemId), - evalId: new Types.ObjectId(evalId) - }; - - // Find items to delete - const itemsToDelete = await MongoEvalItem.find(filter).lean(); - - if (itemsToDelete.length === 0) { - return { deletedCount: 0 }; - } - - const deleteOperation = async (session: ClientSession) => { - // Clean up queue jobs for items to be deleted - const itemIds = itemsToDelete.map((item) => item._id.toString()); - const cleanupPromises = itemIds.map((itemId) => - removeEvaluationItemJobsByItemId(itemId, { - forceCleanActiveJobs: true, - retryAttempts: 3, - retryDelay: 200 - }) - ); - - await Promise.allSettled(cleanupPromises); - - // Delete the items - const result = await MongoEvalItem.deleteMany(filter, { session }); - - addLog.debug(`[Evaluation] Deleted ${result.deletedCount} items for dataItem: ${dataItemId}`); - - return result.deletedCount; - }; - - const deletedCount = await mongoSessionRun(deleteOperation); - - return { - deletedCount - }; - } - - static async retryEvaluationItemsByDataItem( - dataItemId: string, - teamId: string, - evalId: string - ): Promise<{ retriedCount: number }> { - // Verify evaluation access first - await this.getEvaluation(evalId, teamId); - - const filter: any = { - 'dataItem._id': new Types.ObjectId(dataItemId), - evalId: new Types.ObjectId(evalId), - status: EvaluationStatusEnum.error - }; - - // Find items to retry - const itemsToRetry = await MongoEvalItem.find(filter).lean(); - - if (itemsToRetry.length === 0) { - return { retriedCount: 0 }; - } - - const retryOperation = async (session: ClientSession) => { - // Clean up existing jobs - const itemIds = itemsToRetry.map((item) => item._id.toString()); - const cleanupPromises = itemIds.map((itemId) => - removeEvaluationItemJobsByItemId(itemId, { - forceCleanActiveJobs: true, - retryAttempts: 3, - retryDelay: 200 - }) - ); - - await Promise.allSettled(cleanupPromises); - - // Update items status - const result = await MongoEvalItem.updateMany( - { _id: { $in: itemsToRetry.map((item) => item._id) } }, - { - $set: { - status: EvaluationStatusEnum.queuing, - targetOutput: {}, - evaluatorOutput: {} - }, - $unset: { - finishTime: 1, - errorMessage: 1 - }, - $inc: { retry: 1 } - }, - { session } - ); - - // Resubmit to queue - const jobs = itemsToRetry.map((item, index) => ({ - name: `eval_item_dataitem_retry_${dataItemId}_${index}`, - data: { - evalId: item.evalId, - evalItemId: item._id.toString() - }, - opts: { - delay: index * 100 - } - })); - - await evaluationItemQueue.addBulk(jobs); - - addLog.debug( - `[Evaluation] Retried ${result.modifiedCount} items for dataItem: ${dataItemId}` - ); - - return result.modifiedCount; - }; - - const retriedCount = await mongoSessionRun(retryOperation); - - return { - retriedCount - }; - } - - static async updateEvaluationItemsByDataItem( - dataItemId: string, - updates: { - userInput?: string; - expectedOutput?: string; - context?: string[]; - targetCallParams?: TargetCallParams; - }, - teamId: string, - evalId: string - ): Promise<{ updatedCount: number }> { - // Verify evaluation access first - await this.getEvaluation(evalId, teamId); - - // Build MongoDB update object with dot notation - const updateObj = this.buildEvaluationDataItemUpdateObject(updates); - if (Object.keys(updateObj).length === 0) { - return { updatedCount: 0 }; - } - - const filter: any = { - 'dataItem._id': new Types.ObjectId(dataItemId), - evalId: new Types.ObjectId(evalId) - }; - - const result = await MongoEvalItem.updateMany(filter, { $set: updateObj }); - - addLog.debug(`[Evaluation] Updated ${result.modifiedCount} items for dataItem: ${dataItemId}`); - - return { - updatedCount: result.modifiedCount - }; - } - - static async exportEvaluationResultsGroupedByDataItem( - teamId: string, - evalId: string, - format: 'csv' | 'json' = 'json' - ): Promise<{ results: Buffer; totalItems: number }> { - // Get evaluation config for metric names - const evaluation = await this.getEvaluation(evalId, teamId); - - // Use listDataItemsGrouped to get all dataItems (large pageSize to get all) - const { list: dataItems } = await this.listDataItemsGrouped(teamId, { - evalId, - offset: 0, - pageSize: MAX_EXPORT_PAGE_SIZE // Large pageSize to get all items - }); - - if (dataItems.length === 0) { - const emptyResult = format === 'json' ? '[]' : ''; - return { - results: Buffer.from(emptyResult), - totalItems: 0 - }; - } - - // Extract metric names from evaluation config - const metricNames = evaluation.evaluators.map( - (evaluator) => evaluator.metric.name || evaluator.metric._id || 'Unknown Metric' - ); - - // Transform listDataItemsGrouped result to export format (remove totalItems, completedItems, errorItems) - const exportData = dataItems.map((groupedItem: any) => { - const dataItemExport = { - dataItemId: groupedItem.dataItemId, - userInput: groupedItem.dataItem?.userInput, - expectedOutput: groupedItem.dataItem?.expectedOutput, - actualOutput: groupedItem.items.find((item: any) => item.targetOutput?.actualOutput) - ?.targetOutput?.actualOutput, - // Build metric scores object - metricScores: {} as Record - }; - - // Add scores for each metric from the grouped items - groupedItem.items.forEach((item: any) => { - if (item.evaluator?.metric?.name && item.evaluatorOutput?.data?.score !== undefined) { - const metricName = item.evaluator.metric.name; - dataItemExport.metricScores[metricName] = item.evaluatorOutput.data.score; - } - }); - - return dataItemExport; - }); - - if (format === 'json') { - return { - results: Buffer.from(JSON.stringify(exportData, null, 2)), - totalItems: exportData.length - }; - } else { - // CSV format with dynamic metric columns (remove totalItems, completedItems, errorItems) - const baseHeaders = ['DataItemId', 'UserInput', 'ExpectedOutput', 'ActualOutput']; - - const headers = [...baseHeaders, ...metricNames]; - const csvRows = [headers.join(',')]; - - exportData.forEach((dataItem) => { - const row = [ - dataItem.dataItemId, - `"${(dataItem.userInput || '').replace(/"/g, '""')}"`, - `"${(dataItem.expectedOutput || '').replace(/"/g, '""')}"`, - `"${(dataItem.actualOutput || '').replace(/"/g, '""')}"`, - // Add metric scores in the same order as headers - ...metricNames.map((metricName) => dataItem.metricScores[metricName] || '') - ]; - - csvRows.push(row.join(',')); - }); - - return { - results: Buffer.from(csvRows.join('\n')), - totalItems: exportData.length - }; - } - } } export { MongoEvaluation }; diff --git a/packages/service/core/evaluation/task/mq.ts b/packages/service/core/evaluation/task/mq.ts index 1d20bccaecd5..21a3730fa436 100644 --- a/packages/service/core/evaluation/task/mq.ts +++ b/packages/service/core/evaluation/task/mq.ts @@ -28,12 +28,16 @@ export const evaluationItemQueue = getQueue(QueueNames.ev export const getEvaluationTaskWorker = (processor: any) => getWorker(QueueNames.evalTask, processor, { - concurrency: Number(process.env.EVAL_TASK_CONCURRENCY) || 3 + concurrency: Number(process.env.EVAL_TASK_CONCURRENCY) || 3, + stalledInterval: Number(process.env.EVAL_TASK_STALLED_INTERVAL) || 60000, // 1 minute + maxStalledCount: Number(process.env.EVAL_TASK_MAX_STALLED_COUNT) || 3 }); export const getEvaluationItemWorker = (processor: any) => getWorker(QueueNames.evalTaskItem, processor, { - concurrency: Number(process.env.EVAL_ITEM_CONCURRENCY) || 10 + concurrency: Number(process.env.EVAL_ITEM_CONCURRENCY) || 10, + stalledInterval: Number(process.env.EVAL_ITEM_STALLED_INTERVAL) || 300000, // 5 minutes + maxStalledCount: Number(process.env.EVAL_ITEM_MAX_STALLED_COUNT) || 3 }); export const removeEvaluationTaskJob = async ( diff --git a/packages/service/core/evaluation/task/processor.ts b/packages/service/core/evaluation/task/processor.ts index 630e540e8ffe..6d1dd3199901 100644 --- a/packages/service/core/evaluation/task/processor.ts +++ b/packages/service/core/evaluation/task/processor.ts @@ -2,7 +2,8 @@ import { addLog } from '../../../common/system/log'; import type { Job } from '../../../common/bullmq'; import type { EvaluationTaskJobData, - EvaluationItemJobData + EvaluationItemJobData, + TargetOutput } from '@fastgpt/global/core/evaluation/type'; import { evaluationItemQueue, getEvaluationItemWorker, getEvaluationTaskWorker } from './mq'; import { MongoEvaluation, MongoEvalItem } from './schema'; @@ -17,6 +18,7 @@ import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation' import { getErrText } from '@fastgpt/global/common/error/utils'; import { createMergedEvaluationUsage } from '../utils/usage'; import { EvaluationSummaryService } from '../summary'; +import type { MetricResult } from '@fastgpt/global/core/evaluation/metric/type'; // Sleep utility function const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -52,6 +54,31 @@ export class EvaluationStageError extends Error { } } +// Aggregated error class for multiple evaluator errors +export class EvaluatorAggregatedError extends Error { + public readonly errors: Array<{ + evaluatorName: string; + error: string; + retriable: boolean; + }>; + public readonly retriable: boolean; + + constructor(errors: Array<{ evaluatorName: string; error: string }>) { + const errorMessages = errors.map((e) => `${e.evaluatorName}: ${e.error}`); + super(`Evaluator errors: ${errorMessages.join('; ')}`); + this.name = 'EvaluatorAggregatedError'; + + // Check retriability for each error and determine overall retriability + this.errors = errors.map((e) => ({ + ...e, + retriable: isEvaluatorExecutionRetriable(e.error) + })); + + // Consider aggregated error retriable if any individual error is retriable + this.retriable = this.errors.some((e) => e.retriable); + } +} + // Distributed lock implementation const distributedLocks = new Map(); @@ -161,21 +188,17 @@ const analyzeError = ( return { isRetriable: false }; }; -// Backward compatibility function -const matchesRetriablePattern = (error: any): boolean => { - return analyzeError(error).isRetriable; -}; - // Determine if target execution error should be retriable const isTargetExecutionRetriable = (error: any): boolean => { if (error === TeamErrEnum.aiPointsNotEnough) return false; - return matchesRetriablePattern(error); + if (error === EvaluationErrEnum.evalTargetOutputRequired) return true; + return analyzeError(error).isRetriable; }; // Determine if evaluator execution error should be retriable const isEvaluatorExecutionRetriable = (error: any): boolean => { if (error === TeamErrEnum.aiPointsNotEnough) return false; - return matchesRetriablePattern(error); + return analyzeError(error).isRetriable; }; // General error retriability check for handleEvalItemError @@ -185,7 +208,12 @@ const isRetriableError = (error: any): boolean => { return error.retriable; } - return matchesRetriablePattern(error); + // If it's an aggregated error, use its retriable flag + if (error instanceof EvaluatorAggregatedError) { + return error.retriable; + } + + return analyzeError(error).isRetriable; }; // Complete evaluation task - simplified version based on status enum statistics @@ -412,6 +440,9 @@ const evaluationTaskProcessor = async (job: Job) => { addLog.debug(`[Evaluation] Start processing evaluation task: ${evalId}`); try { + // Report initial progress + await job.updateProgress(0); + // Get evaluation task information const evaluation = await MongoEvaluation.findById(evalId).lean(); if (!evaluation) { @@ -425,6 +456,9 @@ const evaluationTaskProcessor = async (job: Job) => { teamId: evaluation.teamId }).lean(); + // Report progress: dataset loaded + await job.updateProgress(20); + // TODO: Handle targetCallParams population for evaluation data items // The dataItems loaded from dataset only contain basic EvalDatasetDataSchemaType fields // but evaluation items need EvaluationDataItemType (including targetCallParams). @@ -476,24 +510,25 @@ const evaluationTaskProcessor = async (job: Job) => { return; } - // Create evaluation items for each dataItem and each evaluator (atomic structure) + // Create evaluation items for each dataItem with all evaluators (batch structure) const evalItems = []; for (const dataItem of dataItems) { - for (const evaluator of evaluation.evaluators) { - evalItems.push({ - evalId, - dataItem, - target: evaluation.target, - evaluator, - status: EvaluationStatusEnum.queuing, - retry: maxRetries - }); - } + evalItems.push({ + evalId, + dataItem, + target: evaluation.target, + evaluators: evaluation.evaluators, // All evaluators for this dataItem + status: EvaluationStatusEnum.queuing, + retry: maxRetries + }); } // Batch insert evaluation items const insertedItems = await MongoEvalItem.insertMany(evalItems); - addLog.debug(`[Evaluation] Created ${insertedItems.length} atomic evaluation items`); + addLog.debug(`[Evaluation] Created ${insertedItems.length} batch evaluation items`); + + // Report progress: items created + await job.updateProgress(80); // Submit to evaluation item queue for concurrent processing const jobs = insertedItems.map((item, index) => ({ @@ -509,6 +544,9 @@ const evaluationTaskProcessor = async (job: Job) => { await evaluationItemQueue.addBulk(jobs); + // Report final progress + await job.updateProgress(100); + addLog.debug( `[Evaluation] Task decomposition completed: ${evalId}, submitted ${jobs.length} evaluation items to queue` ); @@ -536,6 +574,9 @@ const evaluationItemProcessor = async (job: Job) => { addLog.debug(`[Evaluation] Start processing evaluation item: ${evalItemId}`); try { + // Report initial progress + await job.updateProgress(0); + // Get evaluation item information const evalItem = await MongoEvalItem.findById(evalItemId); if (!evalItem) { @@ -574,8 +615,8 @@ const evaluationItemProcessor = async (job: Job) => { } // Initialize outputs - check for existing results first for resume capability - let targetOutput: any = undefined; - let evaluatorOutput: any = undefined; + let targetOutput: TargetOutput | undefined = undefined; + let evaluatorOutputs: MetricResult[] = []; // Resume from checkpoint only if in evaluating status if (evalItem.status === EvaluationStatusEnum.evaluating) { @@ -583,9 +624,9 @@ const evaluationItemProcessor = async (job: Job) => { addLog.debug(`[Evaluation] Resuming targetOutput from evalItem: ${evalItemId}`); targetOutput = evalItem.targetOutput; } - if (evalItem.evaluatorOutput?.data?.score) { - addLog.debug(`[Evaluation] Resuming evaluatorOutput from evalItem: ${evalItemId}`); - evaluatorOutput = evalItem.evaluatorOutput; + if (evalItem.evaluatorOutputs && evalItem.evaluatorOutputs.length > 0) { + addLog.debug(`[Evaluation] Resuming evaluatorOutputs from evalItem: ${evalItemId}`); + evaluatorOutputs = evalItem.evaluatorOutputs; } } else { // For queuing or error status, always start from scratch @@ -600,6 +641,9 @@ const evaluationItemProcessor = async (job: Job) => { { $set: { status: EvaluationStatusEnum.evaluating } } ); + // Report progress: setup completed + await job.updateProgress(10); + // 1. Call evaluation target (if not already done) if (!targetOutput || !targetOutput.actualOutput) { try { @@ -616,6 +660,9 @@ const evaluationItemProcessor = async (job: Job) => { { $set: { targetOutput: targetOutput } } ); + // Report progress: target execution completed + await job.updateProgress(30); + // Record usage from target call if (targetOutput.usage) { const totalPoints = targetOutput.usage.reduce( @@ -631,6 +678,10 @@ const evaluationItemProcessor = async (job: Job) => { type: 'target' }); } + + if (!targetOutput.actualOutput) { + throw new Error(EvaluationErrEnum.evalTargetOutputRequired); + } } catch (error) { // Normalize target execution error const retriable = isTargetExecutionRetriable(error); @@ -645,24 +696,77 @@ const evaluationItemProcessor = async (job: Job) => { } } - // 2. Execute evaluator (if not already done) - let totalMetricPoints = 0; + // 2. Execute evaluators (batch processing - only execute missing ones) + const completedCount = evaluatorOutputs.filter( + (output) => output?.data?.score !== undefined + ).length; + const needToExecute = evalItem.evaluators.length - completedCount; + + if (needToExecute > 0) { + const errors: Array<{ evaluatorName: string; error: string }> = []; - if (!evaluatorOutput || !evaluatorOutput.data?.score) { try { - const evaluatorInstance = await createEvaluatorInstance(evalItem.evaluator, { - validate: false - }); + // Execute only missing evaluators + for (let i = completedCount; i < evalItem.evaluators.length; i++) { + const evaluator = evalItem.evaluators[i]; - evaluatorOutput = await evaluatorInstance.evaluate({ - userInput: evalItem.dataItem.userInput, - expectedOutput: evalItem.dataItem.expectedOutput, - actualOutput: targetOutput.actualOutput, - context: evalItem.dataItem.context, - retrievalContext: targetOutput.retrievalContext - }); + const evaluatorInstance = await createEvaluatorInstance(evaluator, { + validate: false + }); + + const evaluatorOutput = await evaluatorInstance.evaluate({ + userInput: evalItem.dataItem.userInput, + expectedOutput: evalItem.dataItem.expectedOutput, + actualOutput: targetOutput.actualOutput, + context: evalItem.dataItem.context, + retrievalContext: targetOutput.retrievalContext + }); + + await createMergedEvaluationUsage({ + evalId, + teamId: evaluation.teamId, + tmbId: evaluation.tmbId, + usageId: evaluation.usageId, + totalPoints: evaluatorOutput.totalPoints || 0, + type: 'metric' + }); + + // Record error but continue processing + if (evaluatorOutput.status === 'failed' || evaluatorOutput.error) { + const errorMessage = evaluatorOutput.error || 'Evaluator execution failed'; + const evaluatorName = evaluator.metric.name || `Evaluator ${i + 1}`; + errors.push({ evaluatorName, error: errorMessage }); + } + + evaluatorOutputs.push(evaluatorOutput); + + // Save progress after each evaluator (checkpoint for resume) + await MongoEvalItem.updateOne( + { _id: new Types.ObjectId(evalItemId) }, + { $set: { evaluatorOutputs: evaluatorOutputs } } + ); + + // Report progress: evaluator completed + const evaluatorProgress = 30 + (60 * (i + 1)) / evalItem.evaluators.length; + await job.updateProgress(Math.round(evaluatorProgress)); + } + + // After all evaluators, check if there were any errors + if (errors.length > 0) { + throw new EvaluatorAggregatedError(errors); + } } catch (error) { - // Normalize evaluator execution error + // If it's already an EvaluatorAggregatedError, wrap it in EvaluationStageError + if (error instanceof EvaluatorAggregatedError) { + throw new EvaluationStageError( + EvaluationStageEnum.EvaluatorExecute, + error.message, + error.retriable, + error + ); + } + + // Normalize other evaluator execution errors const retriable = isEvaluatorExecutionRetriable(error); const errorMessage = getErrText(error) || 'Evaluator execution failed'; @@ -674,39 +778,27 @@ const evaluationItemProcessor = async (job: Job) => { ); } } - - // Record usage from metric evaluation - if (evaluatorOutput.totalPoints) { - totalMetricPoints += evaluatorOutput.totalPoints || 0; - } - - // Record usage from metric evaluation - if (totalMetricPoints > 0) { - await createMergedEvaluationUsage({ - evalId, - teamId: evaluation.teamId, - tmbId: evaluation.tmbId, - usageId: evaluation.usageId, - totalPoints: totalMetricPoints, - type: 'metric' - }); - } - // 3. Store results await MongoEvalItem.updateOne( { _id: new Types.ObjectId(evalItemId) }, { $set: { targetOutput: targetOutput, - evaluatorOutput: evaluatorOutput, + evaluatorOutputs: evaluatorOutputs, status: EvaluationStatusEnum.completed, finishTime: new Date() } } ); + // Report final progress + await job.updateProgress(100); + + const scores = evaluatorOutputs + .map((output) => output?.data?.score) + .filter((score) => score !== undefined); addLog.debug( - `[Evaluation] Evaluation item completed: ${evalItemId}, score: ${evaluatorOutput?.data?.score}` + `[Evaluation] Evaluation item completed: ${evalItemId}, scores: [${scores.join(', ')}]` ); } catch (error) { addLog.error(`[Evaluation] Evaluation item error: ${evalItemId}, error: ${error}`); diff --git a/packages/service/core/evaluation/task/schema.ts b/packages/service/core/evaluation/task/schema.ts index 918d724f980a..a7c9c1e9c19e 100644 --- a/packages/service/core/evaluation/task/schema.ts +++ b/packages/service/core/evaluation/task/schema.ts @@ -215,15 +215,15 @@ export const EvaluationItemSchema = new Schema({ required: true }, target: EvaluationTargetSchema, - evaluator: EvaluationEvaluatorSchema, // Single evaluator configuration + evaluators: [EvaluationEvaluatorSchema], // Multiple evaluator configurations // Execution results targetOutput: { type: Schema.Types.Mixed, default: {} }, - evaluatorOutput: { - type: Schema.Types.Mixed, - default: {} + evaluatorOutputs: { + type: [Schema.Types.Mixed], + default: [] }, status: { type: Number, diff --git a/projects/app/src/pages/api/core/evaluation/task/dataItem/delete.ts b/projects/app/src/pages/api/core/evaluation/task/dataItem/delete.ts deleted file mode 100644 index e21a1fa3f754..000000000000 --- a/projects/app/src/pages/api/core/evaluation/task/dataItem/delete.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { ApiRequestProps } from '@fastgpt/service/type/next'; -import { NextAPI } from '@/service/middleware/entry'; -import { EvaluationTaskService } from '@fastgpt/service/core/evaluation/task'; -import type { - DeleteDataItemRequest, - DeleteDataItemResponse -} from '@fastgpt/global/core/evaluation/api'; -import { authEvaluationTaskWrite } from '@fastgpt/service/core/evaluation/common'; -import { addAuditLog } from '@fastgpt/service/support/user/audit/util'; -import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants'; -import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation'; - -async function handler( - req: ApiRequestProps -): Promise { - const { dataItemId, evalId } = req.body; - - if (!dataItemId) { - throw new Error(EvaluationErrEnum.evalDataItemIdRequired); - } - - if (!evalId) { - throw new Error(EvaluationErrEnum.evalIdRequired); - } - - const { evaluation, teamId, tmbId } = await authEvaluationTaskWrite(evalId, { - req, - authApiKey: true, - authToken: true - }); - - const result = await EvaluationTaskService.deleteEvaluationItemsByDataItem( - dataItemId, - teamId, - evalId - ); - - // Add audit log for dataItem deletion - (async () => { - addAuditLog({ - tmbId, - teamId, - event: AuditEventEnum.DELETE_EVALUATION_TASK_DATA_ITEM, - params: { - taskName: evaluation.name, - dataItemId: dataItemId - } - }); - })(); - - return { - message: `Successfully deleted ${result.deletedCount} evaluation items`, - deletedCount: result.deletedCount - }; -} - -export default NextAPI(handler); -export { handler }; diff --git a/projects/app/src/pages/api/core/evaluation/task/dataItem/export.ts b/projects/app/src/pages/api/core/evaluation/task/dataItem/export.ts deleted file mode 100644 index 68271f8651d3..000000000000 --- a/projects/app/src/pages/api/core/evaluation/task/dataItem/export.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { ApiRequestProps } from '@fastgpt/service/type/next'; -import { NextAPI } from '@/service/middleware/entry'; -import { EvaluationTaskService } from '@fastgpt/service/core/evaluation/task'; -import type { ExportDataItemsResultsRequest } from '@fastgpt/global/core/evaluation/api'; -import { authEvaluationTaskRead } from '@fastgpt/service/core/evaluation/common'; -import { addAuditLog } from '@fastgpt/service/support/user/audit/util'; -import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants'; -import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation'; - -async function handler(req: ApiRequestProps) { - const { evalId, format = 'json' } = req.body; - - if (!evalId) { - throw new Error(EvaluationErrEnum.evalIdRequired); - } - - if (format && !['csv', 'json'].includes(format)) { - throw new Error(EvaluationErrEnum.evalInvalidFormat); - } - - const { evaluation, teamId, tmbId } = await authEvaluationTaskRead(evalId, { - req, - authApiKey: true, - authToken: true - }); - - const result = await EvaluationTaskService.exportEvaluationResultsGroupedByDataItem( - teamId, - evalId, - format as 'csv' | 'json' - ); - - // Add audit log for dataItems export - (async () => { - addAuditLog({ - tmbId, - teamId, - event: AuditEventEnum.EXPORT_EVALUATION_TASK_DATA_ITEMS, - params: { - taskName: evaluation.name, - format, - itemCount: result.totalItems - } - }); - })(); - - return { - results: result.results, - fileName: `evaluation_${evalId}_dataItems.${format}`, - contentType: format === 'csv' ? 'text/csv' : 'application/json' - }; -} - -export default NextAPI(handler); -export { handler }; diff --git a/projects/app/src/pages/api/core/evaluation/task/dataItem/list.ts b/projects/app/src/pages/api/core/evaluation/task/dataItem/list.ts deleted file mode 100644 index 6a51ccba2335..000000000000 --- a/projects/app/src/pages/api/core/evaluation/task/dataItem/list.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { ApiRequestProps } from '@fastgpt/service/type/next'; -import { NextAPI } from '@/service/middleware/entry'; -import { EvaluationTaskService } from '@fastgpt/service/core/evaluation/task'; -import type { - DataItemListRequest, - DataItemListResponse -} from '@fastgpt/global/core/evaluation/api'; -import { authEvaluationTaskRead } from '@fastgpt/service/core/evaluation/common'; -import { parsePaginationRequest } from '@fastgpt/service/common/api/pagination'; -import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation'; - -async function handler(req: ApiRequestProps): Promise { - const { offset, pageSize } = parsePaginationRequest(req); - const { evalId, status, keyword } = req.body; - - if (!evalId) { - throw new Error(EvaluationErrEnum.evalIdRequired); - } - - // Use existing evaluation task read permission - const { teamId } = await authEvaluationTaskRead(evalId, { - req, - authApiKey: true, - authToken: true - }); - - const result = await EvaluationTaskService.listDataItemsGrouped(teamId, { - evalId, - status: status !== undefined ? Number(status) : undefined, - keyword, - offset, - pageSize - }); - - return { - list: result.list, - total: result.total - }; -} - -export default NextAPI(handler); -export { handler }; diff --git a/projects/app/src/pages/api/core/evaluation/task/dataItem/retry.ts b/projects/app/src/pages/api/core/evaluation/task/dataItem/retry.ts deleted file mode 100644 index dd7c41984ac4..000000000000 --- a/projects/app/src/pages/api/core/evaluation/task/dataItem/retry.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { ApiRequestProps } from '@fastgpt/service/type/next'; -import { NextAPI } from '@/service/middleware/entry'; -import { EvaluationTaskService } from '@fastgpt/service/core/evaluation/task'; -import type { - RetryDataItemRequest, - RetryDataItemResponse -} from '@fastgpt/global/core/evaluation/api'; -import { authEvaluationTaskWrite } from '@fastgpt/service/core/evaluation/common'; -import { addAuditLog } from '@fastgpt/service/support/user/audit/util'; -import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants'; -import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation'; - -async function handler(req: ApiRequestProps): Promise { - const { dataItemId, evalId } = req.body; - - if (!dataItemId) { - throw new Error(EvaluationErrEnum.evalDataItemIdRequired); - } - - if (!evalId) { - throw new Error(EvaluationErrEnum.evalIdRequired); - } - - const { evaluation, teamId, tmbId } = await authEvaluationTaskWrite(evalId, { - req, - authApiKey: true, - authToken: true - }); - - const result = await EvaluationTaskService.retryEvaluationItemsByDataItem( - dataItemId, - teamId, - evalId - ); - - // Add audit log for dataItem retry - (async () => { - addAuditLog({ - tmbId, - teamId, - event: AuditEventEnum.RETRY_EVALUATION_TASK_DATA_ITEM, - params: { - taskName: evaluation.name, - dataItemId: dataItemId - } - }); - })(); - - return { - message: `Successfully retried ${result.retriedCount} evaluation items`, - retriedCount: result.retriedCount - }; -} - -export default NextAPI(handler); -export { handler }; diff --git a/projects/app/src/pages/api/core/evaluation/task/dataItem/update.ts b/projects/app/src/pages/api/core/evaluation/task/dataItem/update.ts deleted file mode 100644 index 85e279e16448..000000000000 --- a/projects/app/src/pages/api/core/evaluation/task/dataItem/update.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { ApiRequestProps } from '@fastgpt/service/type/next'; -import { NextAPI } from '@/service/middleware/entry'; -import { EvaluationTaskService } from '@fastgpt/service/core/evaluation/task'; -import type { - UpdateDataItemRequest, - UpdateDataItemResponse -} from '@fastgpt/global/core/evaluation/api'; -import { authEvaluationTaskWrite } from '@fastgpt/service/core/evaluation/common'; -import { addAuditLog } from '@fastgpt/service/support/user/audit/util'; -import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants'; -import { EvalDatasetDataKeyEnum } from '@fastgpt/global/core/evaluation/dataset/constants'; -import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation'; - -async function handler( - req: ApiRequestProps -): Promise { - const { - dataItemId, - evalId, - [EvalDatasetDataKeyEnum.UserInput]: userInput, - [EvalDatasetDataKeyEnum.ExpectedOutput]: expectedOutput, - [EvalDatasetDataKeyEnum.Context]: context, - targetCallParams - } = req.body; - - if (!dataItemId) { - throw new Error(EvaluationErrEnum.evalDataItemIdRequired); - } - - if (!evalId) { - throw new Error(EvaluationErrEnum.evalIdRequired); - } - - const { evaluation, teamId, tmbId } = await authEvaluationTaskWrite(evalId, { - req, - authApiKey: true, - authToken: true - }); - - const result = await EvaluationTaskService.updateEvaluationItemsByDataItem( - dataItemId, - { userInput, expectedOutput, context, targetCallParams }, - teamId, - evalId - ); - - // Add audit log for dataItem update - (async () => { - addAuditLog({ - tmbId, - teamId, - event: AuditEventEnum.UPDATE_EVALUATION_TASK_DATA_ITEM, - params: { - taskName: evaluation.name, - dataItemId: dataItemId - } - }); - })(); - - return { - message: `Successfully updated ${result.updatedCount} evaluation items`, - updatedCount: result.updatedCount - }; -} - -export default NextAPI(handler); -export { handler }; diff --git a/projects/app/src/pages/api/core/evaluation/task/list.ts b/projects/app/src/pages/api/core/evaluation/task/list.ts index 8b023a63ee72..246218ddeb26 100644 --- a/projects/app/src/pages/api/core/evaluation/task/list.ts +++ b/projects/app/src/pages/api/core/evaluation/task/list.ts @@ -25,7 +25,7 @@ async function handler( req: ApiRequestProps ): Promise { const { offset, pageSize } = parsePaginationRequest(req); - const { searchKey, appName, appId, versionId } = req.body; + const { searchKey, appName, appId } = req.body; const { teamId, tmbId, isOwner, roleList, myGroupMap, myOrgSet } = await getEvaluationPermissionAggregation({ @@ -51,8 +51,7 @@ async function handler( tmbId, isOwner, appName?.trim(), - appId?.trim(), - versionId?.trim() + appId?.trim() ); const formatEvaluations = result.list diff --git a/test/cases/pages/api/core/evaluation/task/dataItem/delete.test.ts b/test/cases/pages/api/core/evaluation/task/dataItem/delete.test.ts deleted file mode 100644 index e720143b0cbe..000000000000 --- a/test/cases/pages/api/core/evaluation/task/dataItem/delete.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, test, expect, vi, beforeEach } from 'vitest'; -import { Types } from '@fastgpt/service/common/mongo'; -import { EvaluationTaskService } from '@fastgpt/service/core/evaluation/task'; -import { handler } from '@/pages/api/core/evaluation/task/dataItem/delete'; - -// Mock NextAPI wrapper -vi.mock('@/service/middleware/entry', () => ({ - NextAPI: vi.fn((handler) => handler) -})); - -// Mock dependencies -vi.mock('@fastgpt/service/core/evaluation/task', () => ({ - EvaluationTaskService: { - deleteEvaluationItemsByDataItem: vi.fn() - } -})); - -vi.mock('@fastgpt/service/core/evaluation/common', () => ({ - authEvaluationTaskWrite: vi.fn() -})); - -vi.mock('@fastgpt/service/support/user/audit/util', () => ({ - addAuditLog: vi.fn() -})); - -describe('Delete DataItem API Handler', () => { - const mockRequest = (body: any) => - ({ - body, - method: 'POST' - }) as any; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - test('应该成功删除数据项的所有评估项', async () => { - const { authEvaluationTaskWrite } = await import('@fastgpt/service/core/evaluation/common'); - (authEvaluationTaskWrite as any).mockResolvedValue({ - evaluation: { name: 'Test Evaluation' }, - teamId: '507f1f77bcf86cd799439011', - tmbId: '507f1f77bcf86cd799439012' - }); - - const mockDeleteResult = { - deletedCount: 3 - }; - (EvaluationTaskService.deleteEvaluationItemsByDataItem as any).mockResolvedValue( - mockDeleteResult - ); - - const req = mockRequest({ - dataItemId: 'data-item-123', - evalId: 'eval-123' - }); - - const result = await handler(req); - - expect(authEvaluationTaskWrite).toHaveBeenCalledWith('eval-123', { - req, - authApiKey: true, - authToken: true - }); - expect(EvaluationTaskService.deleteEvaluationItemsByDataItem).toHaveBeenCalledWith( - 'data-item-123', - '507f1f77bcf86cd799439011', - 'eval-123' - ); - expect(result).toEqual({ - message: 'Successfully deleted 3 evaluation items', - deletedCount: 3 - }); - }); - - test('数据项不存在时应该返回deletedCount为0', async () => { - const { authEvaluationTaskWrite } = await import('@fastgpt/service/core/evaluation/common'); - (authEvaluationTaskWrite as any).mockResolvedValue({ - evaluation: { name: 'Test Evaluation' }, - teamId: '507f1f77bcf86cd799439011', - tmbId: '507f1f77bcf86cd799439012' - }); - - const mockDeleteResult = { - deletedCount: 0 - }; - (EvaluationTaskService.deleteEvaluationItemsByDataItem as any).mockResolvedValue( - mockDeleteResult - ); - - const req = mockRequest({ - dataItemId: 'non-existent-data-item', - evalId: 'eval-123' - }); - - const result = await handler(req); - - expect(result).toEqual({ - message: 'Successfully deleted 0 evaluation items', - deletedCount: 0 - }); - }); - - test('缺少dataItemId时应该抛出错误', async () => { - const req = mockRequest({ - evalId: 'eval-123' - }); - - await expect(handler(req)).rejects.toThrow('evaluationDataItemIdRequired'); - }); - - test('缺少evalId时应该抛出错误', async () => { - const req = mockRequest({ - dataItemId: 'data-item-123' - }); - - await expect(handler(req)).rejects.toThrow('evaluationIdRequired'); - }); - - test('认证失败时应该抛出错误', async () => { - const { authEvaluationTaskWrite } = await import('@fastgpt/service/core/evaluation/common'); - (authEvaluationTaskWrite as any).mockRejectedValue(new Error('Permission denied')); - - const req = mockRequest({ - dataItemId: 'data-item-123', - evalId: 'eval-123' - }); - - await expect(handler(req)).rejects.toThrow('Permission denied'); - }); - - test('服务层异常时应该抛出错误', async () => { - const { authEvaluationTaskWrite } = await import('@fastgpt/service/core/evaluation/common'); - (authEvaluationTaskWrite as any).mockResolvedValue({ - evaluation: { name: 'Test Evaluation' }, - teamId: '507f1f77bcf86cd799439011', - tmbId: '507f1f77bcf86cd799439012' - }); - (EvaluationTaskService.deleteEvaluationItemsByDataItem as any).mockRejectedValue( - new Error('Database connection failed') - ); - - const req = mockRequest({ - dataItemId: 'data-item-123', - evalId: 'eval-123' - }); - - await expect(handler(req)).rejects.toThrow('Database connection failed'); - }); -}); diff --git a/test/cases/pages/api/core/evaluation/task/dataItem/export.test.ts b/test/cases/pages/api/core/evaluation/task/dataItem/export.test.ts deleted file mode 100644 index aded5623a20f..000000000000 --- a/test/cases/pages/api/core/evaluation/task/dataItem/export.test.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { describe, test, expect, vi, beforeEach } from 'vitest'; -import { Types } from '@fastgpt/service/common/mongo'; -import { EvaluationTaskService } from '@fastgpt/service/core/evaluation/task'; -import { handler } from '@/pages/api/core/evaluation/task/dataItem/export'; - -// Mock NextAPI wrapper -vi.mock('@/service/middleware/entry', () => ({ - NextAPI: vi.fn((handler) => handler) -})); - -// Mock dependencies -vi.mock('@fastgpt/service/core/evaluation/task', () => ({ - EvaluationTaskService: { - exportEvaluationResultsGroupedByDataItem: vi.fn() - } -})); - -vi.mock('@fastgpt/service/core/evaluation/common', () => ({ - authEvaluationTaskRead: vi.fn() -})); - -describe('Export DataItem Grouped Results API Handler', () => { - const mockRequest = (body: any) => - ({ - body, - method: 'POST' - }) as any; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - test('应该成功导出JSON格式的数据项分组结果', async () => { - const { authEvaluationTaskRead } = await import('@fastgpt/service/core/evaluation/common'); - (authEvaluationTaskRead as any).mockResolvedValue({ - evaluation: { name: 'Test Evaluation' }, - teamId: '507f1f77bcf86cd799439011', - tmbId: '507f1f77bcf86cd799439012' - }); - - const mockExportResult = { - results: Buffer.from( - JSON.stringify([ - { - dataItemId: 'data-item-123', - userInput: 'Test question', - expectedOutput: 'Test answer', - actualOutput: 'Generated response', - metricScores: { 'Test Metric': 85 } - } - ]) - ), - totalItems: 1 - }; - - (EvaluationTaskService.exportEvaluationResultsGroupedByDataItem as any).mockResolvedValue( - mockExportResult - ); - - const req = mockRequest({ - evalId: 'eval-123', - format: 'json' - }); - - const result = await handler(req); - - expect(EvaluationTaskService.exportEvaluationResultsGroupedByDataItem).toHaveBeenCalledWith( - '507f1f77bcf86cd799439011', - 'eval-123', - 'json' - ); - expect(result).toEqual({ - results: mockExportResult.results, - fileName: 'evaluation_eval-123_dataItems.json', - contentType: 'application/json' - }); - }); - - test('应该成功导出CSV格式的数据项分组结果', async () => { - const { authEvaluationTaskRead } = await import('@fastgpt/service/core/evaluation/common'); - (authEvaluationTaskRead as any).mockResolvedValue({ - evaluation: { name: 'Test Evaluation' }, - teamId: '507f1f77bcf86cd799439011', - tmbId: '507f1f77bcf86cd799439012' - }); - - const mockExportResult = { - results: Buffer.from( - 'DataItemId,UserInput,ExpectedOutput,ActualOutput,Test Metric\ndata-item-123,Test question,Test answer,Generated response,85' - ), - totalItems: 1 - }; - - (EvaluationTaskService.exportEvaluationResultsGroupedByDataItem as any).mockResolvedValue( - mockExportResult - ); - - const req = mockRequest({ - evalId: 'eval-123', - format: 'csv' - }); - - const result = await handler(req); - - expect(EvaluationTaskService.exportEvaluationResultsGroupedByDataItem).toHaveBeenCalledWith( - '507f1f77bcf86cd799439011', - 'eval-123', - 'csv' - ); - expect(result).toEqual({ - results: mockExportResult.results, - fileName: 'evaluation_eval-123_dataItems.csv', - contentType: 'text/csv' - }); - }); - - test('默认格式应该是JSON', async () => { - const { authEvaluationTaskRead } = await import('@fastgpt/service/core/evaluation/common'); - (authEvaluationTaskRead as any).mockResolvedValue({ - evaluation: { name: 'Test Evaluation' }, - teamId: '507f1f77bcf86cd799439011', - tmbId: '507f1f77bcf86cd799439012' - }); - - const mockExportResult = { - results: Buffer.from('[]'), - totalItems: 0 - }; - - (EvaluationTaskService.exportEvaluationResultsGroupedByDataItem as any).mockResolvedValue( - mockExportResult - ); - - const req = mockRequest({ - evalId: 'eval-123' - // No format specified - }); - await handler(req); - - expect(EvaluationTaskService.exportEvaluationResultsGroupedByDataItem).toHaveBeenCalledWith( - '507f1f77bcf86cd799439011', - 'eval-123', - 'json' - ); - }); - - test('空数据时应该正常导出', async () => { - const { authEvaluationTaskRead } = await import('@fastgpt/service/core/evaluation/common'); - (authEvaluationTaskRead as any).mockResolvedValue({ - evaluation: { name: 'Test Evaluation' }, - teamId: '507f1f77bcf86cd799439011', - tmbId: '507f1f77bcf86cd799439012' - }); - - const mockExportResult = { - results: Buffer.from('[]'), - totalItems: 0 - }; - - (EvaluationTaskService.exportEvaluationResultsGroupedByDataItem as any).mockResolvedValue( - mockExportResult - ); - - const req = mockRequest({ - evalId: 'empty-eval-123', - format: 'json' - }); - - const result = await handler(req); - - expect(result).toEqual({ - results: mockExportResult.results, - fileName: 'evaluation_empty-eval-123_dataItems.json', - contentType: 'application/json' - }); - }); - - test('缺少evalId时应该抛出错误', async () => { - const req = mockRequest({ - format: 'json' - }); - - await expect(() => handler(req)).rejects.toThrow('evaluationIdRequired'); - }); - - test('无效格式时应该抛出错误', async () => { - const req = mockRequest({ - evalId: 'eval-123', - format: 'invalid' - }); - - await expect(() => handler(req)).rejects.toThrow('evaluationInvalidFormat'); - }); - - test('评估任务不存在时应该抛出错误', async () => { - const { authEvaluationTaskRead } = await import('@fastgpt/service/core/evaluation/common'); - (authEvaluationTaskRead as any).mockRejectedValue(new Error('evaluationTaskNotFound')); - - const req = mockRequest({ - evalId: 'invalid-eval-id', - format: 'json' - }); - - await expect(() => handler(req)).rejects.toThrow('evaluationTaskNotFound'); - }); - - test('服务层异常时应该抛出错误', async () => { - const { authEvaluationTaskRead } = await import('@fastgpt/service/core/evaluation/common'); - (authEvaluationTaskRead as any).mockResolvedValue({ - evaluation: { name: 'Test Evaluation' }, - teamId: '507f1f77bcf86cd799439011', - tmbId: '507f1f77bcf86cd799439012' - }); - (EvaluationTaskService.exportEvaluationResultsGroupedByDataItem as any).mockRejectedValue( - new Error('Export failed') - ); - - const req = mockRequest({ - evalId: 'eval-123', - format: 'json' - }); - - await expect(() => handler(req)).rejects.toThrow('Export failed'); - }); -}); diff --git a/test/cases/pages/api/core/evaluation/task/dataItem/list.test.ts b/test/cases/pages/api/core/evaluation/task/dataItem/list.test.ts deleted file mode 100644 index 5579799507b1..000000000000 --- a/test/cases/pages/api/core/evaluation/task/dataItem/list.test.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { describe, test, expect, vi, beforeEach } from 'vitest'; -import { Types } from '@fastgpt/service/common/mongo'; -import { EvaluationTaskService } from '@fastgpt/service/core/evaluation/task'; -import { EvaluationStatusEnum } from '@fastgpt/global/core/evaluation/constants'; -import { handler } from '@/pages/api/core/evaluation/task/dataItem/list'; - -// Mock NextAPI wrapper -vi.mock('@/service/middleware/entry', () => ({ - NextAPI: vi.fn((handler) => handler) -})); - -// Mock dependencies -vi.mock('@fastgpt/service/core/evaluation/task', () => ({ - EvaluationTaskService: { - listDataItemsGrouped: vi.fn() - } -})); - -vi.mock('@fastgpt/service/core/evaluation/common', () => ({ - authEvaluationTaskRead: vi.fn() -})); - -describe('List DataItems Grouped API Handler', () => { - const mockDataItemGrouped = { - dataItemId: 'data-item-123', - dataItem: { - userInput: 'Test question', - expectedOutput: 'Test answer' - }, - items: [ - { - _id: new Types.ObjectId(), - evalId: new Types.ObjectId(), - status: EvaluationStatusEnum.completed, - evaluatorOutput: { data: { score: 85 } } - } - ], - statistics: { - totalItems: 2, - completedItems: 1, - errorItems: 0 - } - }; - - const mockRequest = (body: any) => - ({ - body, - method: 'POST' - }) as any; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - test('应该成功获取分组的数据项列表', async () => { - const { authEvaluationTaskRead } = await import('@fastgpt/service/core/evaluation/common'); - (authEvaluationTaskRead as any).mockResolvedValue({ - evaluation: { name: 'Test Evaluation' }, - teamId: '507f1f77bcf86cd799439011', - tmbId: '507f1f77bcf86cd799439012' - }); - - const mockResult = { - list: [mockDataItemGrouped], - total: 1 - }; - - (EvaluationTaskService.listDataItemsGrouped as any).mockResolvedValue(mockResult); - - const req = mockRequest({ - evalId: 'eval-123', - pageNum: 1, - pageSize: 20 - }); - - const result = await handler(req); - - expect(EvaluationTaskService.listDataItemsGrouped).toHaveBeenCalledWith( - '507f1f77bcf86cd799439011', - { - evalId: 'eval-123', - keyword: undefined, - status: undefined, - offset: 0, - pageSize: 20 - } - ); - expect(result).toEqual(mockResult); - }); - - test('应该支持状态过滤', async () => { - const { authEvaluationTaskRead } = await import('@fastgpt/service/core/evaluation/common'); - (authEvaluationTaskRead as any).mockResolvedValue({ - evaluation: { name: 'Test Evaluation' }, - teamId: '507f1f77bcf86cd799439011', - tmbId: '507f1f77bcf86cd799439012' - }); - - const mockResult = { - list: [mockDataItemGrouped], - total: 1 - }; - - (EvaluationTaskService.listDataItemsGrouped as any).mockResolvedValue(mockResult); - - const req = mockRequest({ - evalId: 'eval-123', - status: EvaluationStatusEnum.completed, - pageNum: 1, - pageSize: 20 - }); - - await handler(req); - - expect(EvaluationTaskService.listDataItemsGrouped).toHaveBeenCalledWith( - '507f1f77bcf86cd799439011', - { - evalId: 'eval-123', - keyword: undefined, - status: EvaluationStatusEnum.completed, - offset: 0, - pageSize: 20 - } - ); - }); - - test('应该支持关键词搜索', async () => { - const { authEvaluationTaskRead } = await import('@fastgpt/service/core/evaluation/common'); - (authEvaluationTaskRead as any).mockResolvedValue({ - evaluation: { name: 'Test Evaluation' }, - teamId: '507f1f77bcf86cd799439011', - tmbId: '507f1f77bcf86cd799439012' - }); - - const mockResult = { - list: [mockDataItemGrouped], - total: 1 - }; - - (EvaluationTaskService.listDataItemsGrouped as any).mockResolvedValue(mockResult); - - const req = mockRequest({ - evalId: 'eval-123', - keyword: 'test', - pageNum: 1, - pageSize: 20 - }); - - await handler(req); - - expect(EvaluationTaskService.listDataItemsGrouped).toHaveBeenCalledWith( - '507f1f77bcf86cd799439011', - { - evalId: 'eval-123', - keyword: 'test', - status: undefined, - offset: 0, - pageSize: 20 - } - ); - }); - - test('应该处理分页参数', async () => { - const { authEvaluationTaskRead } = await import('@fastgpt/service/core/evaluation/common'); - (authEvaluationTaskRead as any).mockResolvedValue({ - evaluation: { name: 'Test Evaluation' }, - teamId: '507f1f77bcf86cd799439011', - tmbId: '507f1f77bcf86cd799439012' - }); - - const mockResult = { - list: [mockDataItemGrouped], - total: 50 - }; - - (EvaluationTaskService.listDataItemsGrouped as any).mockResolvedValue(mockResult); - - const req = mockRequest({ - evalId: 'eval-123', - pageNum: 3, - pageSize: 10 - }); - - await handler(req); - - expect(EvaluationTaskService.listDataItemsGrouped).toHaveBeenCalledWith( - '507f1f77bcf86cd799439011', - { - evalId: 'eval-123', - keyword: undefined, - status: undefined, - offset: 20, // (pageNum - 1) * pageSize = (3 - 1) * 10 - pageSize: 10 - } - ); - }); - - test('缺少evalId时应该抛出错误', async () => { - const req = mockRequest({ - current: 1, - pageSize: 20 - }); - - await expect(handler(req)).rejects.toThrow('evaluationIdRequired'); - }); - - test('服务层异常时应该抛出错误', async () => { - const { authEvaluationTaskRead } = await import('@fastgpt/service/core/evaluation/common'); - (authEvaluationTaskRead as any).mockResolvedValue({ - evaluation: { name: 'Test Evaluation' }, - teamId: '507f1f77bcf86cd799439011', - tmbId: '507f1f77bcf86cd799439012' - }); - (EvaluationTaskService.listDataItemsGrouped as any).mockRejectedValue( - new Error('Database error') - ); - - const req = mockRequest({ - evalId: 'eval-123', - current: 1, - pageSize: 20 - }); - - await expect(handler(req)).rejects.toThrow('Database error'); - }); -}); diff --git a/test/cases/pages/api/core/evaluation/task/dataItem/retry.test.ts b/test/cases/pages/api/core/evaluation/task/dataItem/retry.test.ts deleted file mode 100644 index 95b37dd77d8a..000000000000 --- a/test/cases/pages/api/core/evaluation/task/dataItem/retry.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, test, expect, vi, beforeEach } from 'vitest'; -import { Types } from '@fastgpt/service/common/mongo'; -import { EvaluationTaskService } from '@fastgpt/service/core/evaluation/task'; -import { handler } from '@/pages/api/core/evaluation/task/dataItem/retry'; - -// Mock NextAPI wrapper -vi.mock('@/service/middleware/entry', () => ({ - NextAPI: vi.fn((handler) => handler) -})); - -// Mock dependencies -vi.mock('@fastgpt/service/core/evaluation/task', () => ({ - EvaluationTaskService: { - retryEvaluationItemsByDataItem: vi.fn() - } -})); - -vi.mock('@fastgpt/service/core/evaluation/common', () => ({ - authEvaluationTaskWrite: vi.fn() -})); - -vi.mock('@fastgpt/service/support/user/audit/util', () => ({ - addAuditLog: vi.fn() -})); - -describe('Retry DataItem API Handler', () => { - const mockRequest = (body: any) => - ({ - body, - method: 'POST' - }) as any; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - test('应该成功重试数据项的失败评估', async () => { - const { authEvaluationTaskWrite } = await import('@fastgpt/service/core/evaluation/common'); - (authEvaluationTaskWrite as any).mockResolvedValue({ - evaluation: { name: 'Test Evaluation' }, - teamId: '507f1f77bcf86cd799439011', - tmbId: '507f1f77bcf86cd799439012' - }); - - const mockRetryResult = { - retriedCount: 2 - }; - (EvaluationTaskService.retryEvaluationItemsByDataItem as any).mockResolvedValue( - mockRetryResult - ); - - const req = mockRequest({ - dataItemId: 'data-item-123', - evalId: 'eval-123' - }); - - const result = await handler(req); - - expect(EvaluationTaskService.retryEvaluationItemsByDataItem).toHaveBeenCalledWith( - 'data-item-123', - '507f1f77bcf86cd799439011', - 'eval-123' - ); - expect(result).toEqual({ - message: 'Successfully retried 2 evaluation items', - retriedCount: 2 - }); - }); - - test('没有失败项目时应该返回retriedCount为0', async () => { - const { authEvaluationTaskWrite } = await import('@fastgpt/service/core/evaluation/common'); - (authEvaluationTaskWrite as any).mockResolvedValue({ - evaluation: { name: 'Test Evaluation' }, - teamId: '507f1f77bcf86cd799439011', - tmbId: '507f1f77bcf86cd799439012' - }); - - const mockRetryResult = { - retriedCount: 0 - }; - (EvaluationTaskService.retryEvaluationItemsByDataItem as any).mockResolvedValue( - mockRetryResult - ); - - const req = mockRequest({ - dataItemId: 'data-item-123', - evalId: 'eval-123' - }); - - const result = await handler(req); - - expect(result).toEqual({ - message: 'Successfully retried 0 evaluation items', - retriedCount: 0 - }); - }); - - test('缺少dataItemId时应该抛出错误', async () => { - const req = mockRequest({ - evalId: 'eval-123' - }); - - await expect(handler(req)).rejects.toThrow('evaluationDataItemIdRequired'); - }); - - test('缺少evalId时应该抛出错误', async () => { - const req = mockRequest({ - dataItemId: 'data-item-123' - }); - - await expect(handler(req)).rejects.toThrow('evaluationIdRequired'); - }); -}); diff --git a/test/cases/pages/api/core/evaluation/task/dataItem/update.test.ts b/test/cases/pages/api/core/evaluation/task/dataItem/update.test.ts deleted file mode 100644 index d8cbde959a06..000000000000 --- a/test/cases/pages/api/core/evaluation/task/dataItem/update.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, test, expect, vi, beforeEach } from 'vitest'; -import { EvaluationTaskService } from '@fastgpt/service/core/evaluation/task'; -import { handler } from '@/pages/api/core/evaluation/task/dataItem/update'; - -// Mock NextAPI wrapper -vi.mock('@/service/middleware/entry', () => ({ - NextAPI: vi.fn((handler) => handler) -})); - -// Mock dependencies -vi.mock('@fastgpt/service/core/evaluation/task', () => ({ - EvaluationTaskService: { - updateEvaluationItemsByDataItem: vi.fn() - } -})); - -vi.mock('@fastgpt/service/core/evaluation/common', () => ({ - authEvaluationTaskWrite: vi.fn() -})); - -vi.mock('@fastgpt/service/support/user/audit/util', () => ({ - addAuditLog: vi.fn() -})); - -describe('Update DataItem API Handler', () => { - const mockRequest = (body: any) => - ({ - body, - method: 'POST' - }) as any; - - beforeEach(() => { - vi.clearAllMocks(); - }); - - test('应该成功更新数据项的评估内容', async () => { - const { authEvaluationTaskWrite } = await import('@fastgpt/service/core/evaluation/common'); - (authEvaluationTaskWrite as any).mockResolvedValue({ - evaluation: { name: 'Test Evaluation' }, - teamId: '507f1f77bcf86cd799439011', - tmbId: '507f1f77bcf86cd799439012' - }); - - const mockUpdateResult = { - updatedCount: 2 - }; - (EvaluationTaskService.updateEvaluationItemsByDataItem as any).mockResolvedValue( - mockUpdateResult - ); - - const req = mockRequest({ - dataItemId: 'data-item-123', - evalId: 'eval-123', - userInput: 'Updated question', - expectedOutput: 'Updated answer' - }); - const result = await handler(req); - - expect(authEvaluationTaskWrite).toHaveBeenCalledWith('eval-123', { - req, - authApiKey: true, - authToken: true - }); - expect(EvaluationTaskService.updateEvaluationItemsByDataItem).toHaveBeenCalledWith( - 'data-item-123', - { - userInput: 'Updated question', - expectedOutput: 'Updated answer', - context: undefined, - targetCallParams: undefined - }, - '507f1f77bcf86cd799439011', - 'eval-123' - ); - expect(result).toEqual({ - message: 'Successfully updated 2 evaluation items', - updatedCount: 2 - }); - }); - - test('缺少dataItemId时应该抛出错误', async () => { - const req = mockRequest({ - evalId: 'eval-123', - userInput: 'Updated question' - }); - - await expect(handler(req)).rejects.toThrow('evaluationDataItemIdRequired'); - }); - - test('缺少evalId时应该抛出错误', async () => { - const req = mockRequest({ - dataItemId: 'data-item-123', - userInput: 'Updated question' - }); - - await expect(handler(req)).rejects.toThrow('evaluationIdRequired'); - }); - - test('认证失败时应该抛出错误', async () => { - const { authEvaluationTaskWrite } = await import('@fastgpt/service/core/evaluation/common'); - (authEvaluationTaskWrite as any).mockRejectedValue(new Error('Permission denied')); - - const req = mockRequest({ - dataItemId: 'data-item-123', - evalId: 'eval-123', - userInput: 'Updated question' - }); - - await expect(handler(req)).rejects.toThrow('Permission denied'); - }); - - test('服务层异常时应该抛出错误', async () => { - const { authEvaluationTaskWrite } = await import('@fastgpt/service/core/evaluation/common'); - (authEvaluationTaskWrite as any).mockResolvedValue({ - evaluation: { name: 'Test Evaluation' }, - teamId: '507f1f77bcf86cd799439011', - tmbId: '507f1f77bcf86cd799439012' - }); - (EvaluationTaskService.updateEvaluationItemsByDataItem as any).mockRejectedValue( - new Error('Database connection failed') - ); - - const req = mockRequest({ - dataItemId: 'data-item-123', - evalId: 'eval-123', - userInput: 'Updated question' - }); - - await expect(handler(req)).rejects.toThrow('Database connection failed'); - }); -}); diff --git a/test/cases/pages/api/core/evaluation/task/list.test.ts b/test/cases/pages/api/core/evaluation/task/list.test.ts index 3a5cf94f476a..4e3e8b702e60 100644 --- a/test/cases/pages/api/core/evaluation/task/list.test.ts +++ b/test/cases/pages/api/core/evaluation/task/list.test.ts @@ -79,7 +79,6 @@ describe('List Evaluation Tasks API Handler', () => { 'mock-tmb-id', true, undefined, - undefined, undefined ); expect(result).toEqual({ @@ -115,7 +114,6 @@ describe('List Evaluation Tasks API Handler', () => { 'mock-tmb-id', true, undefined, - undefined, undefined ); }); @@ -140,7 +138,6 @@ describe('List Evaluation Tasks API Handler', () => { 'mock-tmb-id', true, undefined, - undefined, undefined ); }); @@ -170,8 +167,7 @@ describe('List Evaluation Tasks API Handler', () => { 'mock-tmb-id', true, 'Test App', - '507f1f77bcf86cd799439011', - '507f1f77bcf86cd799439012' + '507f1f77bcf86cd799439011' ); }); @@ -198,7 +194,6 @@ describe('List Evaluation Tasks API Handler', () => { 'mock-tmb-id', true, 'Partial App', - undefined, undefined ); }); @@ -228,8 +223,7 @@ describe('List Evaluation Tasks API Handler', () => { 'mock-tmb-id', true, 'Test App', - '507f1f77bcf86cd799439011', - '507f1f77bcf86cd799439012' + '507f1f77bcf86cd799439011' ); }); }); diff --git a/test/cases/service/core/evaluation/task.test.ts b/test/cases/service/core/evaluation/task.test.ts index 36778233fd24..303dca516d6a 100644 --- a/test/cases/service/core/evaluation/task.test.ts +++ b/test/cases/service/core/evaluation/task.test.ts @@ -601,13 +601,12 @@ describe('EvaluationTaskService', () => { teamId, 0, 10, - undefined, + '507f1f77bcf86cd799439026', undefined, tmbId, true, undefined, - undefined, - '507f1f77bcf86cd799439026' + undefined ); expect(Array.isArray(result.list)).toBe(true); @@ -648,13 +647,12 @@ describe('EvaluationTaskService', () => { teamId, 0, 10, - undefined, + '507f1f77bcf86cd799439031', undefined, tmbId, true, 'Test App', // appName filter - '507f1f77bcf86cd799439030', // appId filter - '507f1f77bcf86cd799439031' // versionId filter + '507f1f77bcf86cd799439030' // appId filter ); expect(Array.isArray(result.list)).toBe(true); @@ -1048,14 +1046,14 @@ describe('EvaluationTaskService', () => { evalId: testEvaluationId, dataItem: { userInput: 'Test 1', expectedOutput: 'Answer 1' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.evaluating }, { evalId: testEvaluationId, dataItem: { userInput: 'Test 2', expectedOutput: 'Answer 2' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.queuing } ]); @@ -1104,40 +1102,44 @@ describe('EvaluationTaskService', () => { evalId: testEvaluationId, dataItem: { userInput: 'Q1', expectedOutput: 'A1' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.completed, - evaluatorOutput: { - metricName: 'Test Metric', - data: { - score: 85 + evaluatorOutputs: [ + { + metricName: 'Test Metric', + data: { + score: 85 + } } - } + ] }, { evalId: testEvaluationId, dataItem: { userInput: 'Q2', expectedOutput: 'A2' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.completed, - evaluatorOutput: { - metricName: 'Test Metric', - data: { - score: 95 + evaluatorOutputs: [ + { + metricName: 'Test Metric', + data: { + score: 95 + } } - } + ] }, { evalId: testEvaluationId, dataItem: { userInput: 'Q3', expectedOutput: 'A3' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.evaluating }, { evalId: testEvaluationId, dataItem: { userInput: 'Q4', expectedOutput: 'A4' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.queuing } ]); @@ -1185,14 +1187,14 @@ describe('EvaluationTaskService', () => { evalId: testEvaluationId, dataItem: { userInput: 'Test userInput 1', expectedOutput: 'Test answer 1' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.queuing }, { evalId: testEvaluationId, dataItem: { userInput: 'Test userInput 2', expectedOutput: 'Test answer 2' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.completed } ]); @@ -1209,7 +1211,7 @@ describe('EvaluationTaskService', () => { expect(result.items.length).toBeGreaterThan(0); const item = result.items[0]; - expect(item.evalItemId).toBeDefined(); + expect(item._id).toBeDefined(); expect(item.evalId.toString()).toBe(testEvaluationId.toString()); expect(item.dataItem).toBeDefined(); }); @@ -1237,7 +1239,7 @@ describe('EvaluationTaskService', () => { evalId: testEvaluationId, dataItem: { userInput: 'Test Item', expectedOutput: 'Test Response' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.queuing }); const itemId = item._id.toString(); @@ -1279,7 +1281,7 @@ describe('EvaluationTaskService', () => { evalId: testEvaluationId, dataItem: { userInput: 'Test Item', expectedOutput: 'Test Response' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.queuing }); const itemId = item._id.toString(); @@ -1320,7 +1322,7 @@ describe('EvaluationTaskService', () => { evalId: testEvaluationId, dataItem: { userInput: 'Test Item', expectedOutput: 'Test Response' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.error, errorMessage: 'Test error', retry: 2 @@ -1356,7 +1358,7 @@ describe('EvaluationTaskService', () => { evalId: testEvaluationId, dataItem: { userInput: 'Test Item', expectedOutput: 'Test Response' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.completed, errorMessage: null // 确保没有错误消息 }); @@ -1389,7 +1391,7 @@ describe('EvaluationTaskService', () => { evalId: testEvaluationId, dataItem: { userInput: 'Test Item', expectedOutput: 'Test Response' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.queuing }); const itemId = item._id.toString(); @@ -1423,30 +1425,30 @@ describe('EvaluationTaskService', () => { evalId: testEvaluationId, dataItem: { userInput: 'Test Item', expectedOutput: 'Test Response' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.completed, targetOutput: { actualOutput: 'Test response', responseTime: 1000 }, - evaluatorOutput: { - metricName: 'Test Metric', - data: { - score: 92, - runLogs: { test: true } + evaluatorOutputs: [ + { + metricName: 'Test Metric', + data: { + score: 92, + runLogs: { test: true } + } } - } + ] }); const itemId = item._id.toString(); const result = await EvaluationTaskService.getEvaluationItemResult(itemId, teamId); - expect(result.item._id.toString()).toBe(itemId); + expect(result._id.toString()).toBe(itemId); expect(result.dataItem.userInput).toBe('Test Item'); - expect(result.response).toBe('Test response'); - expect(result.score).toBe(92); - expect(result.result).toBeDefined(); - expect(result.result?.data?.score).toBe(92); + expect(result.targetOutput?.actualOutput).toBe('Test response'); + expect(result.evaluatorOutputs?.[0].data?.score).toBe(92); }); }); }); @@ -1476,41 +1478,45 @@ describe('EvaluationTaskService', () => { evalId: testEvaluationId, dataItem: { userInput: 'JavaScript userInput', expectedOutput: 'JS answer' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.completed, targetOutput: { actualOutput: 'JavaScript is a programming language', responseTime: 1000 }, - evaluatorOutput: { - metricName: 'Test Metric', - data: { - score: 85 + evaluatorOutputs: [ + { + metricName: 'Test Metric', + data: { + score: 85 + } } - } + ] }, { evalId: testEvaluationId, dataItem: { userInput: 'Python userInput', expectedOutput: 'Python answer' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.completed, targetOutput: { actualOutput: 'Python is also a programming language', responseTime: 1000 }, - evaluatorOutput: { - metricName: 'Test Metric', - data: { - score: 95 + evaluatorOutputs: [ + { + metricName: 'Test Metric', + data: { + score: 95 + } } - } + ] }, { evalId: testEvaluationId, dataItem: { userInput: 'Failed userInput', expectedOutput: 'Failed answer' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.error, errorMessage: 'Processing failed' } @@ -1542,7 +1548,7 @@ describe('EvaluationTaskService', () => { }); expect(result.items).toHaveLength(1); - expect(result.items[0].evaluatorOutput?.data?.score).toBe(85); + expect(result.items[0].evaluatorOutputs?.[0]?.data?.score).toBe(85); }); test('应该按关键词搜索', async () => { @@ -1709,7 +1715,7 @@ describe('EvaluationTaskService', () => { evalId: testEvaluationId, dataItem: { userInput: 'Failed 1', expectedOutput: 'Answer 1' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.error, errorMessage: 'Error 1' }, @@ -1717,7 +1723,7 @@ describe('EvaluationTaskService', () => { evalId: testEvaluationId, dataItem: { userInput: 'Failed 2', expectedOutput: 'Answer 2' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.error, errorMessage: 'Error 2' }, @@ -1725,14 +1731,16 @@ describe('EvaluationTaskService', () => { evalId: testEvaluationId, dataItem: { userInput: 'Success', expectedOutput: 'Answer' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.completed, - evaluatorOutput: { - metricName: 'Test Metric', - data: { - score: 90 + evaluatorOutputs: [ + { + metricName: 'Test Metric', + data: { + score: 90 + } } - } // 成功的项目,不应该被重试 + ] // 成功的项目,不应该被重试 } ]); @@ -1757,7 +1765,7 @@ describe('EvaluationTaskService', () => { 'dataItem.userInput': 'Success' }); expect(successItem?.status).toBe(EvaluationStatusEnum.completed); - expect(successItem?.evaluatorOutput?.data?.score).toBe(90); + expect(successItem?.evaluatorOutputs?.[0]?.data?.score).toBe(90); }); }); @@ -1784,21 +1792,23 @@ describe('EvaluationTaskService', () => { evalId: testEvaluationId, dataItem: { userInput: 'Test userInput 1', expectedOutput: 'Test answer 1' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.queuing }, { evalId: testEvaluationId, dataItem: { userInput: 'Test userInput 2', expectedOutput: 'Test answer 2' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.completed, - evaluatorOutput: { - metricName: 'Test Metric', - data: { - score: 85 + evaluatorOutputs: [ + { + metricName: 'Test Metric', + data: { + score: 85 + } } - } + ] } ]); @@ -1931,7 +1941,7 @@ describe('EvaluationTaskService', () => { context: ['context1'] }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.queuing, retry: 3 }); @@ -1965,7 +1975,8 @@ describe('EvaluationTaskService', () => { const updatedItem = await MongoEvalItem.findById(evalItem._id); expect(updatedItem?.status).toBe(EvaluationStatusEnum.completed); expect(updatedItem?.targetOutput).toBeDefined(); - expect(updatedItem?.evaluatorOutput).toBeDefined(); + expect(updatedItem?.evaluatorOutputs).toBeDefined(); + expect(updatedItem?.evaluatorOutputs?.length).toBeGreaterThan(0); // 验证目标和评估器被调用 expect(mockTargetInstance.execute).toHaveBeenCalledWith({ @@ -1997,7 +2008,7 @@ describe('EvaluationTaskService', () => { expectedOutput: 'Expected output' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.evaluating, targetOutput: { actualOutput: 'Existing target output', @@ -2289,7 +2300,7 @@ describe('EvaluationTaskService', () => { evalId: testEvaluationId, dataItem: { userInput: `Backoff test ${testCase.retry}`, expectedOutput: 'Expected' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.queuing, retry: testCase.retry }); @@ -2360,17 +2371,17 @@ describe('EvaluationTaskService', () => { evalId: testEvaluationId, dataItem: { userInput: 'Q1', expectedOutput: 'A1' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.completed, - evaluatorOutput: { metricName: 'Test', data: { score: 85 } } + evaluatorOutputs: [{ metricName: 'Test', data: { score: 85 } }] }, { evalId: testEvaluationId, dataItem: { userInput: 'Q2', expectedOutput: 'A2' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.completed, - evaluatorOutput: { metricName: 'Test', data: { score: 95 } } + evaluatorOutputs: [{ metricName: 'Test', data: { score: 95 } }] } ]); @@ -2412,15 +2423,15 @@ describe('EvaluationTaskService', () => { evalId: testEvaluationId, dataItem: { userInput: 'Q1', expectedOutput: 'A1' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.completed, - evaluatorOutput: { metricName: 'Test', data: { score: 85 } } + evaluatorOutputs: [{ metricName: 'Test', data: { score: 85 } }] }, { evalId: testEvaluationId, dataItem: { userInput: 'Q2', expectedOutput: 'A2' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.evaluating // 仍在处理中 } ]); @@ -2436,6 +2447,374 @@ describe('EvaluationTaskService', () => { }); }); + // ========================= 聚合错误处理测试 ========================= + describe('Aggregated Error Handling Tests', () => { + let mockTargetInstance: any; + let mockEvaluatorInstance: any; + + beforeEach(() => { + // Clear all mocks before each test + vi.clearAllMocks(); + + // Reset AI Points check to pass normally + (checkTeamAIPoints as any).mockResolvedValue(undefined); + + mockTargetInstance = { + execute: vi.fn().mockResolvedValue({ + actualOutput: 'Mock target output', + responseTime: 1000, + usage: [{ totalPoints: 50 }] + }) + }; + mockEvaluatorInstance = { + evaluate: vi.fn() + }; + (createTargetInstance as any).mockReturnValue(mockTargetInstance); + (createEvaluatorInstance as any).mockResolvedValue(mockEvaluatorInstance); + }); + + test('应该收集所有评估器错误并继续执行', async () => { + const { evaluationItemProcessor } = await import( + '@fastgpt/service/core/evaluation/task/processor' + ); + + const testEvaluationId = new Types.ObjectId(); + + // 创建有多个评估器的评估项 + const multipleEvaluators = [ + { + metric: { + _id: new Types.ObjectId(), + name: 'Metric 1', + type: EvalMetricTypeEnum.Custom, + prompt: 'Test prompt 1' + }, + runtimeConfig: { llm: 'gpt-3.5-turbo' } + }, + { + metric: { + _id: new Types.ObjectId(), + name: 'Metric 2', + type: EvalMetricTypeEnum.Custom, + prompt: 'Test prompt 2' + }, + runtimeConfig: { llm: 'gpt-3.5-turbo' } + }, + { + metric: { + _id: new Types.ObjectId(), + name: 'Metric 3', + type: EvalMetricTypeEnum.Custom, + prompt: 'Test prompt 3' + }, + runtimeConfig: { llm: 'gpt-3.5-turbo' } + } + ]; + + const evalItem = await MongoEvalItem.create({ + evalId: testEvaluationId, + dataItem: { userInput: 'Test input', expectedOutput: 'Expected output' }, + target, + evaluators: multipleEvaluators, + status: EvaluationStatusEnum.queuing, + retry: 3 + }); + + await MongoEvaluation.create({ + _id: testEvaluationId, + teamId: new Types.ObjectId(teamId), + tmbId: new Types.ObjectId(tmbId), + name: 'Aggregated Error Test', + datasetId, + target, + evaluators: multipleEvaluators, + usageId: new Types.ObjectId(), + status: EvaluationStatusEnum.evaluating + }); + + // Mock evaluators - 第一个成功,第二个和第三个失败(使用明确不可重试的错误) + mockEvaluatorInstance.evaluate + .mockResolvedValueOnce({ + metricName: 'Metric 1', + status: 'success', + data: { score: 85 }, + totalPoints: 20 + }) + .mockResolvedValueOnce({ + metricName: 'Metric 2', + status: 'failed', + error: 'AUTHENTICATION_FAILED: Invalid API key provided.', + totalPoints: 15 + }) + .mockResolvedValueOnce({ + metricName: 'Metric 3', + status: 'failed', + error: 'VALIDATION_ERROR: Input validation failed.', + totalPoints: 10 + }); + + const itemJobData: EvaluationItemJobData = { + evalId: testEvaluationId.toString(), + evalItemId: evalItem._id.toString() + }; + + const mockJob = { data: itemJobData } as any; + + await evaluationItemProcessor(mockJob); + + // 验证评估项被标记为错误状态 + const updatedItem = await MongoEvalItem.findById(evalItem._id); + expect(updatedItem?.status).toBe(EvaluationStatusEnum.error); + + // 验证错误消息包含所有失败的评估器信息 + expect(updatedItem?.errorMessage).toContain('[EvaluatorExecute]'); + expect(updatedItem?.errorMessage).toContain( + 'Metric 2: AUTHENTICATION_FAILED: Invalid API key provided.' + ); + expect(updatedItem?.errorMessage).toContain( + 'Metric 3: VALIDATION_ERROR: Input validation failed.' + ); + + // 验证所有评估器的用量都被记录 + expect(concatUsage).toHaveBeenCalled(); + + // 验证所有三个评估器都被调用 + expect(mockEvaluatorInstance.evaluate).toHaveBeenCalledTimes(3); + }); + + test('应该正确处理部分评估器失败的聚合错误可重试性', async () => { + const { evaluationItemProcessor } = await import( + '@fastgpt/service/core/evaluation/task/processor' + ); + + const testEvaluationId = new Types.ObjectId(); + + const multipleEvaluators = [ + { + metric: { + _id: new Types.ObjectId(), + name: 'Metric 1', + type: EvalMetricTypeEnum.Custom + }, + runtimeConfig: { llm: 'gpt-3.5-turbo' } + }, + { + metric: { + _id: new Types.ObjectId(), + name: 'Metric 2', + type: EvalMetricTypeEnum.Custom + }, + runtimeConfig: { llm: 'gpt-3.5-turbo' } + } + ]; + + const evalItem = await MongoEvalItem.create({ + evalId: testEvaluationId, + dataItem: { userInput: 'Test input', expectedOutput: 'Expected output' }, + target, + evaluators: multipleEvaluators, + status: EvaluationStatusEnum.queuing, + retry: 3 + }); + + await MongoEvaluation.create({ + _id: testEvaluationId, + teamId: new Types.ObjectId(teamId), + tmbId: new Types.ObjectId(tmbId), + name: 'Retry Aggregated Error Test', + datasetId, + target, + evaluators: multipleEvaluators, + usageId: new Types.ObjectId(), + status: EvaluationStatusEnum.evaluating + }); + + // Mock evaluators - 一个可重试错误,一个不可重试错误 + mockEvaluatorInstance.evaluate + .mockResolvedValueOnce({ + metricName: 'Metric 1', + status: 'failed', + error: 'TIMEOUT: Request timeout', // 可重试 + totalPoints: 20 + }) + .mockResolvedValueOnce({ + metricName: 'Metric 2', + status: 'failed', + error: 'INVALID_CONFIG: Configuration error', // 不可重试 + totalPoints: 15 + }); + + const itemJobData: EvaluationItemJobData = { + evalId: testEvaluationId.toString(), + evalItemId: evalItem._id.toString() + }; + + const mockJob = { data: itemJobData } as any; + + await evaluationItemProcessor(mockJob); + + // 验证评估项被重新排队(因为有可重试错误) + const updatedItem = await MongoEvalItem.findById(evalItem._id); + expect(updatedItem?.status).toBe(EvaluationStatusEnum.queuing); + expect(updatedItem?.retry).toBe(2); + + // 验证重新排队的调用 + expect(evaluationItemQueue.add).toHaveBeenCalledWith( + expect.stringContaining(`eval_item_${evalItem._id.toString()}_retry`), + { + evalId: testEvaluationId.toString(), + evalItemId: evalItem._id.toString() + }, + expect.objectContaining({ + delay: expect.any(Number) + }) + ); + }); + + test('应该正确处理所有评估器都成功的情况', async () => { + const { evaluationItemProcessor } = await import( + '@fastgpt/service/core/evaluation/task/processor' + ); + + const testEvaluationId = new Types.ObjectId(); + + const multipleEvaluators = [ + { + metric: { + _id: new Types.ObjectId(), + name: 'Metric 1', + type: EvalMetricTypeEnum.Custom + }, + runtimeConfig: { llm: 'gpt-3.5-turbo' } + }, + { + metric: { + _id: new Types.ObjectId(), + name: 'Metric 2', + type: EvalMetricTypeEnum.Custom + }, + runtimeConfig: { llm: 'gpt-3.5-turbo' } + } + ]; + + const evalItem = await MongoEvalItem.create({ + evalId: testEvaluationId, + dataItem: { userInput: 'Test input', expectedOutput: 'Expected output' }, + target, + evaluators: multipleEvaluators, + status: EvaluationStatusEnum.queuing, + retry: 3 + }); + + await MongoEvaluation.create({ + _id: testEvaluationId, + teamId: new Types.ObjectId(teamId), + tmbId: new Types.ObjectId(tmbId), + name: 'All Success Test', + datasetId, + target, + evaluators: multipleEvaluators, + usageId: new Types.ObjectId(), + status: EvaluationStatusEnum.evaluating + }); + + // Mock evaluators - 都成功 + mockEvaluatorInstance.evaluate + .mockResolvedValueOnce({ + metricName: 'Metric 1', + status: 'success', + data: { score: 85 }, + totalPoints: 20 + }) + .mockResolvedValueOnce({ + metricName: 'Metric 2', + status: 'success', + data: { score: 90 }, + totalPoints: 15 + }); + + const itemJobData: EvaluationItemJobData = { + evalId: testEvaluationId.toString(), + evalItemId: evalItem._id.toString() + }; + + const mockJob = { data: itemJobData } as any; + + await evaluationItemProcessor(mockJob); + + // 验证评估项被标记为完成 + const updatedItem = await MongoEvalItem.findById(evalItem._id); + expect(updatedItem?.status).toBe(EvaluationStatusEnum.completed); + expect(updatedItem?.evaluatorOutputs).toHaveLength(2); + expect(updatedItem?.evaluatorOutputs?.[0].data?.score).toBe(85); + expect(updatedItem?.evaluatorOutputs?.[1].data?.score).toBe(90); + + // 验证用量记录 + expect(concatUsage).toHaveBeenCalled(); + }); + + test('应该在评估器抛出异常时正确处理', async () => { + const { evaluationItemProcessor } = await import( + '@fastgpt/service/core/evaluation/task/processor' + ); + + const testEvaluationId = new Types.ObjectId(); + + const singleEvaluator = [ + { + metric: { + _id: new Types.ObjectId(), + name: 'Exception Metric', + type: EvalMetricTypeEnum.Custom + }, + runtimeConfig: { llm: 'gpt-3.5-turbo' } + } + ]; + + const evalItem = await MongoEvalItem.create({ + evalId: testEvaluationId, + dataItem: { userInput: 'Test input', expectedOutput: 'Expected output' }, + target, + evaluators: singleEvaluator, + status: EvaluationStatusEnum.queuing, + retry: 3 + }); + + await MongoEvaluation.create({ + _id: testEvaluationId, + teamId: new Types.ObjectId(teamId), + tmbId: new Types.ObjectId(tmbId), + name: 'Exception Test', + datasetId, + target, + evaluators: singleEvaluator, + usageId: new Types.ObjectId(), + status: EvaluationStatusEnum.evaluating + }); + + // Mock evaluator抛出异常 + mockEvaluatorInstance.evaluate.mockRejectedValue( + new Error('NETWORK_ERROR: Connection failed') + ); + + const itemJobData: EvaluationItemJobData = { + evalId: testEvaluationId.toString(), + evalItemId: evalItem._id.toString() + }; + + const mockJob = { data: itemJobData } as any; + + await evaluationItemProcessor(mockJob); + + // 验证评估项被重新排队(异常应该被当作可重试错误处理) + const updatedItem = await MongoEvalItem.findById(evalItem._id); + expect(updatedItem?.status).toBe(EvaluationStatusEnum.queuing); + expect(updatedItem?.retry).toBe(2); + expect(updatedItem?.errorMessage).toContain('[EvaluatorExecute]'); + expect(updatedItem?.errorMessage).toContain('NETWORK_ERROR'); + }); + }); + // ========================= 错误处理和状态管理测试 ========================= describe('Error Handling and Status Management Tests', () => { test('评估项处理失败时应该正确清理状态', async () => { @@ -2449,7 +2828,7 @@ describe('EvaluationTaskService', () => { evalId: testEvaluationId, dataItem: { userInput: 'Error cleanup test', expectedOutput: 'Expected' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.evaluating, targetOutput: { actualOutput: 'Partial result', responseTime: 500 }, retry: 3 @@ -2520,23 +2899,23 @@ describe('EvaluationTaskService', () => { evalId: testEvaluationId, dataItem: { userInput: 'Success 1', expectedOutput: 'A1' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.completed, - evaluatorOutput: { metricName: 'Test', data: { score: 85 } } + evaluatorOutputs: [{ metricName: 'Test', data: { score: 85 } }] }, { evalId: testEvaluationId, dataItem: { userInput: 'Success 2', expectedOutput: 'A2' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.completed, - evaluatorOutput: { metricName: 'Test', data: { score: 95 } } + evaluatorOutputs: [{ metricName: 'Test', data: { score: 95 } }] }, { evalId: testEvaluationId, dataItem: { userInput: 'Failed', expectedOutput: 'A3' }, target, - evaluator: evaluators[0], + evaluators: [evaluators[0]], status: EvaluationStatusEnum.error, errorMessage: 'Test error' } @@ -2551,327 +2930,4 @@ describe('EvaluationTaskService', () => { expect(finalEvaluation?.statistics?.errorItems).toBe(1); }); }); - - // ========================= 数据项聚合操作测试 ========================= - describe('DataItem Aggregation Operations Tests', () => { - let testEvaluationId: string; - let testDataItemId: string; - - beforeEach(async () => { - // 为每个测试创建新的evaluation - const params: CreateEvaluationParams = { - name: 'Test Evaluation for DataItem Operations', - description: 'A test evaluation for data item operations', - datasetId, - target, - evaluators: evaluators, - autoStart: false - }; - const evaluation = await EvaluationTaskService.createEvaluation({ - ...params, - teamId: teamId, - tmbId: tmbId - }); - testEvaluationId = evaluation._id; - testDataItemId = new Types.ObjectId().toString(); - - // 创建测试数据项 - 同一个dataItemId的多个评估项 - await MongoEvalItem.create([ - { - evalId: testEvaluationId, - dataItem: { - _id: new Types.ObjectId(testDataItemId), - userInput: 'What is JavaScript?', - expectedOutput: 'JavaScript is a programming language' - }, - target, - evaluator: evaluators[0], - status: EvaluationStatusEnum.completed, - evaluatorOutput: { - metricName: 'Test Metric', - data: { score: 85 } - } - }, - { - evalId: testEvaluationId, - dataItem: { - _id: new Types.ObjectId(testDataItemId), - userInput: 'What is JavaScript?', - expectedOutput: 'JavaScript is a programming language' - }, - target, - evaluator: evaluators[0], - status: EvaluationStatusEnum.error, - errorMessage: 'Test error' - }, - { - evalId: testEvaluationId, - dataItem: { - _id: new Types.ObjectId().toString(), - userInput: 'What is Python?', - expectedOutput: 'Python is a programming language' - }, - target, - evaluator: evaluators[0], - status: EvaluationStatusEnum.completed, - evaluatorOutput: { - metricName: 'Test Metric', - data: { score: 90 } - } - } - ]); - }); - - describe('listDataItemsGrouped', () => { - test('应该成功返回按数据项分组的结果', async () => { - const result = await EvaluationTaskService.listDataItemsGrouped(teamId, { - evalId: testEvaluationId, - offset: 0, - pageSize: 20 - }); - - expect(result.list).toHaveLength(2); - expect(result.total).toBe(2); - - const firstGroup = result.list[0]; - expect(firstGroup.dataItemId).toBeDefined(); - expect(firstGroup.dataItem).toBeDefined(); - expect(firstGroup.items).toBeDefined(); - expect(firstGroup.statistics).toBeDefined(); - expect(firstGroup.statistics!.totalItems).toBeGreaterThan(0); - expect(firstGroup.statistics!.completedItems).toBeGreaterThanOrEqual(0); - expect(firstGroup.statistics!.errorItems).toBeGreaterThanOrEqual(0); - }); - - test('应该支持状态过滤', async () => { - const result = await EvaluationTaskService.listDataItemsGrouped(teamId, { - evalId: testEvaluationId, - status: EvaluationStatusEnum.completed, - offset: 0, - pageSize: 20 - }); - - result.list.forEach((group) => { - group.items.forEach((item) => { - expect(item.status).toBe(EvaluationStatusEnum.completed); - }); - }); - }); - - test('应该支持关键词搜索', async () => { - const result = await EvaluationTaskService.listDataItemsGrouped(teamId, { - evalId: testEvaluationId, - keyword: 'JavaScript', - offset: 0, - pageSize: 20 - }); - - expect(result.list.length).toBeGreaterThan(0); - const hasJavaScript = result.list.some( - (group) => - group.dataItem.userInput?.includes('JavaScript') || - group.dataItem.expectedOutput?.includes('JavaScript') - ); - expect(hasJavaScript).toBe(true); - }); - - test('应该支持分页', async () => { - const result = await EvaluationTaskService.listDataItemsGrouped(teamId, { - evalId: testEvaluationId, - offset: 0, - pageSize: 1 - }); - - expect(result.list).toHaveLength(1); - expect(result.total).toBe(2); - }); - }); - - describe('deleteEvaluationItemsByDataItem', () => { - test('应该成功删除指定数据项的所有评估项', async () => { - const result = await EvaluationTaskService.deleteEvaluationItemsByDataItem( - testDataItemId, - teamId, - testEvaluationId - ); - - expect(result.deletedCount).toBe(2); // 应该删除2个评估项 - - // 验证项目已被删除 - const remainingItems = await MongoEvalItem.find({ - evalId: testEvaluationId, - 'dataItem._id': testDataItemId - }); - expect(remainingItems).toHaveLength(0); - - // 验证其他项目未受影响 - const otherItems = await MongoEvalItem.find({ - evalId: testEvaluationId, - 'dataItem._id': { $ne: testDataItemId } - }); - expect(otherItems).toHaveLength(1); - }); - - test('数据项不存在时应该返回0', async () => { - const nonExistentDataItemId = new Types.ObjectId().toString(); - - const result = await EvaluationTaskService.deleteEvaluationItemsByDataItem( - nonExistentDataItemId, - teamId, - testEvaluationId - ); - - expect(result.deletedCount).toBe(0); - }); - }); - - describe('retryEvaluationItemsByDataItem', () => { - test('应该成功重试指定数据项的失败评估项', async () => { - const result = await EvaluationTaskService.retryEvaluationItemsByDataItem( - testDataItemId, - teamId, - testEvaluationId - ); - - expect(result.retriedCount).toBe(1); // 应该重试1个失败的项目 - - // 验证失败的项目状态被重置 - const retriedItems = await MongoEvalItem.find({ - evalId: testEvaluationId, - 'dataItem._id': new Types.ObjectId(testDataItemId), - status: EvaluationStatusEnum.queuing - }); - expect(retriedItems).toHaveLength(1); - - // 验证成功的项目未受影响 - const completedItems = await MongoEvalItem.find({ - evalId: testEvaluationId, - 'dataItem._id': new Types.ObjectId(testDataItemId), - status: EvaluationStatusEnum.completed - }); - expect(completedItems).toHaveLength(1); - }); - - test('没有失败项目时应该返回0', async () => { - // 先将所有项目设为完成状态 - await MongoEvalItem.updateMany( - { evalId: testEvaluationId, 'dataItem._id': new Types.ObjectId(testDataItemId) }, - { $set: { status: EvaluationStatusEnum.completed } } - ); - - const result = await EvaluationTaskService.retryEvaluationItemsByDataItem( - testDataItemId, - teamId, - testEvaluationId - ); - - expect(result.retriedCount).toBe(0); - }); - }); - - describe('updateEvaluationItemsByDataItem', () => { - test('应该成功更新指定数据项的所有评估项', async () => { - const updates = { - userInput: 'Updated JavaScript userInput', - expectedOutput: 'Updated JavaScript answer', - context: ['Updated context'] - }; - - const result = await EvaluationTaskService.updateEvaluationItemsByDataItem( - testDataItemId, - updates, - teamId, - testEvaluationId - ); - - expect(result.updatedCount).toBe(2); // 应该更新2个评估项 - - // 验证更新结果 - const updatedItems = await MongoEvalItem.find({ - evalId: testEvaluationId, - 'dataItem._id': testDataItemId - }); - - updatedItems.forEach((item) => { - expect(item.dataItem.userInput).toBe(updates.userInput); - expect(item.dataItem.expectedOutput).toBe(updates.expectedOutput); - expect(item.dataItem.context).toEqual(updates.context); - }); - }); - - test('空更新时应该返回0', async () => { - const result = await EvaluationTaskService.updateEvaluationItemsByDataItem( - testDataItemId, - {}, - teamId, - testEvaluationId - ); - - expect(result.updatedCount).toBe(0); - }); - }); - - describe('exportEvaluationResultsGroupedByDataItem', () => { - test('应该成功导出JSON格式的数据项分组结果', async () => { - const result = await EvaluationTaskService.exportEvaluationResultsGroupedByDataItem( - teamId, - testEvaluationId, - 'json' - ); - - expect(result.totalItems).toBe(2); - - const exportData = JSON.parse(result.results.toString()); - expect(Array.isArray(exportData)).toBe(true); - expect(exportData).toHaveLength(2); - - const firstItem = exportData[0]; - expect(firstItem.dataItemId).toBeDefined(); - expect(firstItem.userInput).toBeDefined(); - expect(firstItem.expectedOutput).toBeDefined(); - expect(firstItem.metricScores).toBeDefined(); - expect(typeof firstItem.metricScores).toBe('object'); - }); - - test('应该成功导出CSV格式的数据项分组结果', async () => { - const result = await EvaluationTaskService.exportEvaluationResultsGroupedByDataItem( - teamId, - testEvaluationId, - 'csv' - ); - - expect(result.totalItems).toBe(2); - - const csvContent = result.results.toString(); - expect(csvContent).toContain('DataItemId,UserInput,ExpectedOutput,ActualOutput'); - expect(csvContent).toContain('Test Metric'); // 指标名称应该作为列标题 - expect(csvContent.split('\n').length).toBeGreaterThan(2); // 应该有标题行和数据行 - }); - - test('空数据时应该返回空结果', async () => { - // 创建一个空的评估任务 - const emptyParams: CreateEvaluationParams = { - name: 'Empty DataItem Export Test', - description: 'Empty test', - datasetId, - target, - evaluators: evaluators - }; - const emptyEvaluation = await EvaluationTaskService.createEvaluation({ - ...emptyParams, - teamId: teamId, - tmbId: tmbId - }); - - const result = await EvaluationTaskService.exportEvaluationResultsGroupedByDataItem( - teamId, - emptyEvaluation._id, - 'json' - ); - - expect(result.totalItems).toBe(0); - expect(result.results.toString()).toBe('[]'); - }); - }); - }); }); From ab0f5378805d12d4f78cbee92dd68250d3111637 Mon Sep 17 00:00:00 2001 From: lavine77 <916064092@qq.com> Date: Tue, 16 Sep 2025 21:48:08 +0800 Subject: [PATCH 09/84] feat: optimize evaluation dimension management UI and i18n support - Refactor dimension management component with new data fetching approach - Add i18n support for built-in dimensions with localized names and descriptions - Optimize dimension list search with client-side filtering for better performance - Simplify selected dimension count calculation logic - Add special display handling for built-in dimensions (hide creation time and author) - Use MyBox component for unified loading and empty states - Remove unnecessary pagination and scroll loading logic --- .../web/i18n/en/dashboard_evaluation.json | 16 +- .../web/i18n/zh-CN/dashboard_evaluation.json | 16 +- .../i18n/zh-Hant/dashboard_evaluation.json | 16 +- .../evaluation/task/ManageDimension.tsx | 186 +++++++++--------- .../dashboard/evaluation/dimension/index.tsx | 115 ++++++----- .../src/web/core/evaluation/utils/index.ts | 32 +++ 6 files changed, 234 insertions(+), 147 deletions(-) create mode 100644 projects/app/src/web/core/evaluation/utils/index.ts diff --git a/packages/web/i18n/en/dashboard_evaluation.json b/packages/web/i18n/en/dashboard_evaluation.json index 926534e93ac2..34a5bdc86d73 100644 --- a/packages/web/i18n/en/dashboard_evaluation.json +++ b/packages/web/i18n/en/dashboard_evaluation.json @@ -289,6 +289,18 @@ "please_select_dimension_first": "请先选择该维度", "model_evaluation_tip": "语言模型可判断实际回答和参考答案中的文本内容是否匹配;\n索引模型可将实际回答和参考答案转成向量,进一步评估语义相似性。", "create_new_dimension": "新建维度", - "retry_success": "重试成功", - "data_generation_error_count": "{{count}}条数据生成异常" + "retry_success": "重试成功", + "data_generation_error_count": "{{count}}条数据生成异常", + "builtin_answer_correctness_name": "Answer Correctness", + "builtin_answer_correctness_desc": "Evaluates the factual consistency between the generated answer and the reference answer, evaluating whether it is accurate and error-free.", + "builtin_answer_similarity_name": "Answer Similarity", + "builtin_answer_similarity_desc": "Evaluates the semantic alignment between the generated answer and the reference answer, determining whether they convey the same core information.", + "builtin_answer_relevancy_name": "Answer Relevance", + "builtin_answer_relevancy_desc": "Evaluates how well the generated answer aligns with the question, judging whether the response directly addresses the query.", + "builtin_faithfulness_name": "Faithfulness", + "builtin_faithfulness_desc": "Evaluates whether the generated answer remains faithful to the provided context, determining whether it contains fabricated or inaccurate content.", + "builtin_context_recall_name": "Context Recall", + "builtin_context_recall_desc": "Evaluates whether the retrieval system successfully retrieves all key information necessary for formulating the answer, assessing the completeness of retrieval.", + "builtin_context_precision_name": "Context Precision", + "builtin_context_precision_desc": "Evaluates whether high-value information is prioritized in the retrieved content, reflecting the quality of ranking and information density." } diff --git a/packages/web/i18n/zh-CN/dashboard_evaluation.json b/packages/web/i18n/zh-CN/dashboard_evaluation.json index 8f24d2be32b7..0cbca78a1c9a 100644 --- a/packages/web/i18n/zh-CN/dashboard_evaluation.json +++ b/packages/web/i18n/zh-CN/dashboard_evaluation.json @@ -292,6 +292,18 @@ "please_select_dimension_first": "请先选择该维度", "model_evaluation_tip": "语言模型可判断实际回答和参考答案中的文本内容是否匹配;\n索引模型可将实际回答和参考答案转成向量,进一步评估语义相似性。", "create_new_dimension": "新建维度", - "retry_success": "重试成功", - "data_generation_error_count": "{{count}}条数据生成异常" + "retry_success": "重试成功", + "data_generation_error_count": "{{count}}条数据生成异常", + "builtin_answer_correctness_name": "答案正确性", + "builtin_answer_correctness_desc": "衡量生成的回答与参考答案在事实上的一致性,评估其是否准确无误。", + "builtin_answer_similarity_name": "答案相似度", + "builtin_answer_similarity_desc": "评估生成回答与参考答案在语义上的匹配程度,判断其是否表达了相同的核心信息。", + "builtin_answer_relevancy_name": "答案相关性", + "builtin_answer_relevancy_desc": "衡量生成回答与提问之间的契合度,判断回答是否紧扣问题。", + "builtin_faithfulness_name": "忠诚度", + "builtin_faithfulness_desc": "评估生成回答是否忠实于提供的上下文信息,判断是否存在虚构或不实内容。", + "builtin_context_recall_name": "上下文召回", + "builtin_context_recall_desc": "衡量检索系统是否能够获取回答所需的所有关键信息,评估其检索的完整性。", + "builtin_context_precision_name": "上下文精度", + "builtin_context_precision_desc": "衡量检索内容中是否优先返回高价值信息,反映排序质量与信息密度。" } diff --git a/packages/web/i18n/zh-Hant/dashboard_evaluation.json b/packages/web/i18n/zh-Hant/dashboard_evaluation.json index 3a5405f1b819..e21b4fe56629 100644 --- a/packages/web/i18n/zh-Hant/dashboard_evaluation.json +++ b/packages/web/i18n/zh-Hant/dashboard_evaluation.json @@ -285,6 +285,18 @@ "please_select_dimension_first": "請先選擇該維度", "model_evaluation_tip": "語言模型可判斷實際回答和參考答案中的文本內容是否匹配;\n索引模型可將實際回答和參考答案轉成向量,進一步評估語義相似性。", "create_new_dimension": "新建維度", - "retry_success": "重試成功", - "data_generation_error_count": "{{count}}條數據生成異常" + "retry_success": "重試成功", + "data_generation_error_count": "{{count}}條數據生成異常", + "builtin_answer_correctness_name": "答案正確性", + "builtin_answer_correctness_desc": "衡量生成的回答與參考答案在事實上的一致性,評估其是否準確無誤。", + "builtin_answer_similarity_name": "答案相似度", + "builtin_answer_similarity_desc": "評估生成回答與參考答案在語義上的匹配程度,判斷其是否表達了相同的核心信息。", + "builtin_answer_relevancy_name": "答案相關性", + "builtin_answer_relevancy_desc": "衡量生成回答與提問之間的契合度,判斷回答是否緊扣問題。", + "builtin_faithfulness_name": "忠誠度", + "builtin_faithfulness_desc": "評估生成回答是否忠實於提供的上下文信息,判斷是否存在虛構或不實內容。", + "builtin_context_recall_name": "上下文召回", + "builtin_context_recall_desc": "衡量檢索系統是否能夠獲取回答所需的所有關鍵信息,評估其檢索的完整性。", + "builtin_context_precision_name": "上下文精度", + "builtin_context_precision_desc": "衡量檢索內容中是否優先返回高價值信息,反映排序質量與信息密度。" } diff --git a/projects/app/src/pageComponents/dashboard/evaluation/task/ManageDimension.tsx b/projects/app/src/pageComponents/dashboard/evaluation/task/ManageDimension.tsx index c31b1730018a..dd9ca82d849c 100644 --- a/projects/app/src/pageComponents/dashboard/evaluation/task/ManageDimension.tsx +++ b/projects/app/src/pageComponents/dashboard/evaluation/task/ManageDimension.tsx @@ -16,18 +16,20 @@ import { useForm, useFieldArray } from 'react-hook-form'; import MyIcon from '@fastgpt/web/components/common/Icon'; import MyTag from '@fastgpt/web/components/common/Tag'; import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip'; -import { useScrollPagination } from '@fastgpt/web/hooks/useScrollPagination'; import AIModelSelector from '@/components/Select/AIModelSelector'; import { useSystemStore } from '@/web/common/system/useSystemStore'; import { useMemo } from 'react'; import { getMetricList } from '@/web/core/evaluation/dimension'; import type { EvalMetricDisplayType } from '@fastgpt/global/core/evaluation/metric/type'; -import type { PaginationProps, PaginationResponse } from '@fastgpt/web/common/fetch/type'; import { getWebDefaultEmbeddingModel, getWebDefaultEvaluationModel } from '@/web/common/system/utils'; import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; +import MyBox from '@fastgpt/web/components/common/MyBox'; +import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; +import { getBuiltinDimensionInfo } from '@/web/core/evaluation/utils'; +import { EvalMetricTypeEnum } from '@fastgpt/global/core/evaluation/metric/constants'; // 维度类型定义 export interface Dimension { @@ -35,10 +37,10 @@ export interface Dimension { name: string; type: 'builtin' | 'custom'; description: string; - evaluationModel: string; // 评测模型 (对应 llmRequired) - indexModel?: string; // 索引模型 (对应 embeddingRequired) - llmRequired: boolean; // 是否需要评测模型 - embeddingRequired: boolean; // 是否需要索引模型 + evaluationModel: string; + indexModel?: string; + llmRequired: boolean; + embeddingRequired: boolean; isSelected: boolean; } @@ -49,26 +51,36 @@ interface ManageDimensionProps { onConfirm: (dimensions: Dimension[]) => void; } -// 转换 API 数据为组件所需格式 const transformMetricToDimension = ( metric: EvalMetricDisplayType, defaultEmbeddingModel?: string, - defaultEvaluationModel?: string + defaultEvaluationModel?: string, + t?: any ): Dimension => { + let name = metric.name; + let description = metric.description || ''; + + if (metric.type === EvalMetricTypeEnum.Builtin) { + const builtinInfo = getBuiltinDimensionInfo(metric._id); + if (builtinInfo && t) { + name = t(builtinInfo.name); + description = t(builtinInfo.description); + } + } + return { id: metric._id, - name: metric.name, + name, type: metric.type === 'builtin_metric' ? 'builtin' : 'custom', - description: metric.description || '', - evaluationModel: metric.llmRequired ? defaultEvaluationModel || '' : '', // 使用默认评估模型 - indexModel: metric.embeddingRequired ? defaultEmbeddingModel || '' : undefined, // 如果不需要索引模型则为 undefined + description, + evaluationModel: metric.llmRequired ? defaultEvaluationModel || '' : '', + indexModel: metric.embeddingRequired ? defaultEmbeddingModel || '' : '', llmRequired: metric.llmRequired ?? false, embeddingRequired: metric.embeddingRequired ?? false, isSelected: false }; }; -// 维度项组件 const DimensionItem = ({ dimension, isSelected, @@ -134,7 +146,6 @@ const DimensionItem = ({ - {/* 评测模型选择器 */} {dimension.llmRequired && ( )} - {/* 索引模型选择器 */} {dimension.embeddingRequired && ( , - [t] - ); - - // API 适配器函数 - 转换参数格式 - const getMetricListAdapter = useCallback( - async (data: PaginationProps): Promise> => { - return getMetricList({ - pageNum: Math.floor(Number(data.offset) / Number(data.pageSize)) + 1, - pageSize: Number(data.pageSize), - searchKey: '' - }); + // 获取维度列表 + const { + data: metricListData, + loading: isLoading, + runAsync: fetchMetricList + } = useRequest2( + async () => { + const result = await getMetricList({}); + return result; }, - [] + { + manual: false, + refreshDeps: [isOpen], + ready: isOpen + } ); - // 获取维度列表 - 使用滚动分页 - const { - data: metricList, - ScrollData, - refreshList - } = useScrollPagination(getMetricListAdapter, { - pageSize: 10, - params: {}, - refreshDeps: [isOpen], - EmptyTip: EmptyTipDom, - disabled: !isOpen - }); - // 转换并合并维度数据 const transformedDimensions = useMemo(() => { - if (!metricList.length) return []; + if (!metricListData?.list?.length) return []; const defaultEmbeddingModel = getWebDefaultEmbeddingModel(embeddingModelList)?.model; const defaultEvaluationModel = getWebDefaultEvaluationModel(evalModelList)?.model; - return metricList.map((metric) => { + return metricListData.list.map((metric) => { const dimension = transformMetricToDimension( metric, defaultEmbeddingModel, - defaultEvaluationModel + defaultEvaluationModel, + t ); const selectedDimension = selectedDimensions.find((s) => s.id === dimension.id); @@ -285,7 +282,7 @@ const ManageDimension = ({ } return dimension; }); - }, [metricList, selectedDimensions, embeddingModelList, evalModelList]); + }, [metricListData, selectedDimensions, embeddingModelList, evalModelList, t]); // 同步转换后的维度数据到表单,保持已有的选择状态 useEffect(() => { @@ -298,33 +295,30 @@ const ManageDimension = ({ return; } - // 合并新数据和已有数据,保持已有的选择状态 - const mergedDimensions = transformedDimensions.map((newDimension) => { + // 检查是否需要更新(避免不必要的更新) + const needsUpdate = transformedDimensions.some((newDimension) => { const existingDimension = currentDimensions.find((d) => d.id === newDimension.id); + return !existingDimension; + }); - if (existingDimension) { - // 如果维度已存在,保持其当前状态(选择状态和模型配置) - return existingDimension; - } + if (needsUpdate) { + // 合并新数据和已有数据,保持已有的选择状态 + const mergedDimensions = transformedDimensions.map((newDimension) => { + const existingDimension = currentDimensions.find((d) => d.id === newDimension.id); - // 新维度,使用转换后的默认状态 - return newDimension; - }); + if (existingDimension) { + // 如果维度已存在,保持其当前状态(选择状态和模型配置) + return existingDimension; + } - // 添加新加载的维度(在滚动加载时) - const existingIds = currentDimensions.map((d) => d.id); - const newDimensions = transformedDimensions.filter((d) => !existingIds.includes(d.id)); + // 新维度,使用转换后的默认状态 + return newDimension; + }); - if (newDimensions.length > 0) { - // 有新维度,追加到现有列表 - const finalDimensions = [...currentDimensions, ...newDimensions]; - replace(finalDimensions); - } else if (mergedDimensions.length !== currentDimensions.length) { - // 维度数量发生变化,更新列表 replace(mergedDimensions); } } - }, [transformedDimensions, replace, watchedDimensions]); + }, [transformedDimensions, replace]); // 处理维度选择 const handleDimensionToggle = useCallback( @@ -359,12 +353,11 @@ const ManageDimension = ({ // 刷新维度列表 const handleRefresh = useCallback(() => { - refreshList(); - }, [refreshList]); + fetchMetricList(); + }, [fetchMetricList]); // 新建维度 const handleCreateDimension = useCallback(() => { - // TODO: window.open('/dashboard/evaluation/dimension/create', '_blank'); }, []); @@ -375,15 +368,10 @@ const ManageDimension = ({ onClose(); }, [watchedDimensions, onConfirm, onClose]); - // 计算真实的选中数量:包括表单中的选中维度和传入的已选维度(可能还未加载到表单中) + // 计算当前选中的维度数量 const selectedCount = useMemo(() => { - const formSelectedIds = watchedDimensions.filter((d) => d.isSelected).map((d) => d.id); - const propsSelectedIds = selectedDimensions.map((d) => d.id); - - // 合并两个数组并去重,得到真实的选中维度数量 - const allSelectedIds = new Set([...formSelectedIds, ...propsSelectedIds]); - return allSelectedIds.size; - }, [watchedDimensions, selectedDimensions]); + return watchedDimensions.filter((d) => d.isSelected).length; + }, [watchedDimensions]); const isMaxSelected = selectedCount >= MAX_SELECTION_COUNT; @@ -417,26 +405,30 @@ const ManageDimension = ({ - - {fields.map((field, index) => { - const dimension = watchedDimensions[index]; - const isSelected = dimension?.isSelected || false; - const isDisabled = !isSelected && isMaxSelected; - - return ( - - ); - })} - + + {fields.length === 0 && !isLoading ? ( + + ) : ( + fields.map((field, index) => { + const dimension = watchedDimensions[index]; + const isSelected = dimension?.isSelected || false; + const isDisabled = !isSelected && isMaxSelected; + + return ( + + ); + }) + )} + diff --git a/projects/app/src/pages/dashboard/evaluation/dimension/index.tsx b/projects/app/src/pages/dashboard/evaluation/dimension/index.tsx index ade7c9dc3798..d79549aafca8 100644 --- a/projects/app/src/pages/dashboard/evaluation/dimension/index.tsx +++ b/projects/app/src/pages/dashboard/evaluation/dimension/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { Table, Thead, @@ -23,7 +23,6 @@ import MyTag from '@fastgpt/web/components/common/Tag'; import { useConfirm } from '@fastgpt/web/hooks/useConfirm'; import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; import { useRouter } from 'next/router'; -import { usePagination } from '@fastgpt/web/hooks/usePagination'; import format from 'date-fns/format'; import Avatar from '@fastgpt/web/components/common/Avatar'; import { useTranslation } from 'next-i18next'; @@ -31,37 +30,57 @@ import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; import { getMetricList, deleteMetric } from '@/web/core/evaluation/dimension'; import { EvalMetricTypeEnum } from '@fastgpt/global/core/evaluation/metric/constants'; import type { EvalMetricDisplayType } from '@fastgpt/global/core/evaluation/metric/type'; -import type { PaginationProps, PaginationResponse } from '@fastgpt/web/common/fetch/type'; +import { getBuiltinDimensionInfo } from '@/web/core/evaluation/utils'; const EvaluationDimensions = ({ Tab }: { Tab: React.ReactNode }) => { const [searchValue, setSearchValue] = useState(''); + const [allDimensions, setAllDimensions] = useState([]); const { t } = useTranslation(); const router = useRouter(); - const getMetricListAdapter = async ( - data: PaginationProps<{ searchKey: string }> - ): Promise> => { - return getMetricList({ - pageNum: Number(data.pageNum), - pageSize: Number(data.pageSize), - searchKey: data.searchKey + const { runAsync: fetchAllDimensions, loading: isLoading } = useRequest2( + async () => { + const result = await getMetricList({}); + return result; + }, + { + manual: false, + onSuccess: (data) => { + setAllDimensions(data.list || []); + } + } + ); + + // 处理维度显示信息 + const processedDimensions = useMemo(() => { + return allDimensions.map((dimension) => { + // 如果是内置维度,使用国际化信息 + if (dimension.type === EvalMetricTypeEnum.Builtin) { + const builtinInfo = getBuiltinDimensionInfo(dimension._id); + if (builtinInfo) { + return { + ...dimension, + name: t(builtinInfo.name), + description: t(builtinInfo.description) + }; + } + } + return dimension; }); - }; + }, [allDimensions, t]); - const { - data: dimensions, - Pagination, - getData: fetchData, - isLoading, - total - } = usePagination<{ searchKey: string }, EvalMetricDisplayType>(getMetricListAdapter, { - defaultPageSize: 10, - params: { - searchKey: searchValue - }, - EmptyTip: , - refreshDeps: [searchValue] - }); + // 前端搜索过滤 + const filteredDimensions = useMemo(() => { + if (!searchValue.trim()) { + return processedDimensions; + } + return processedDimensions.filter( + (dimension) => + dimension.name.toLowerCase().includes(searchValue.toLowerCase()) || + (dimension.description && + dimension.description.toLowerCase().includes(searchValue.toLowerCase())) + ); + }, [processedDimensions, searchValue]); const { openConfirm, ConfirmModal } = useConfirm({ type: 'delete' @@ -69,7 +88,7 @@ const EvaluationDimensions = ({ Tab }: { Tab: React.ReactNode }) => { const { runAsync: onDeleteMetric } = useRequest2(deleteMetric, { onSuccess: () => { - fetchData(); + fetchAllDimensions(); }, errorToast: t('dashboard_evaluation:delete_failed'), successToast: t('dashboard_evaluation:delete_success') @@ -123,7 +142,7 @@ const EvaluationDimensions = ({ Tab }: { Tab: React.ReactNode }) => {
{dimension.description || '-'} - {format(new Date(dimension.createTime), 'yyyy-MM-dd HH:mm:ss')} - {format(new Date(dimension.updateTime), 'yyyy-MM-dd HH:mm:ss')} + {dimension.type === EvalMetricTypeEnum.Builtin ? ( + - + ) : ( + <> + {format(new Date(dimension.createTime), 'yyyy-MM-dd HH:mm:ss')} + {format(new Date(dimension.updateTime), 'yyyy-MM-dd HH:mm:ss')} + + )} - - - {dimension.sourceMember?.name} - + {dimension.sourceMember?.name ? ( + + + {dimension.sourceMember.name} + + ) : ( + - + )} e.stopPropagation()}> {dimension.type === EvalMetricTypeEnum.Custom && ( @@ -188,14 +217,12 @@ const EvaluationDimensions = ({ Tab }: { Tab: React.ReactNode }) => { ))}
- {total === 0 && } + {filteredDimensions.length === 0 && ( + + )} - - - - ); diff --git a/projects/app/src/web/core/evaluation/utils/index.ts b/projects/app/src/web/core/evaluation/utils/index.ts new file mode 100644 index 000000000000..501bbc31f3b8 --- /dev/null +++ b/projects/app/src/web/core/evaluation/utils/index.ts @@ -0,0 +1,32 @@ +import { i18nT } from '@fastgpt/web/i18n/utils'; + +export const getBuiltinDimensionInfo = (dimensionId: string) => { + const dimensionMap = { + builtin_answer_correctness: { + name: i18nT('dashboard_evaluation:builtin_answer_correctness_name'), + description: i18nT('dashboard_evaluation:builtin_answer_correctness_desc') + }, + builtin_answer_similarity: { + name: i18nT('dashboard_evaluation:builtin_answer_similarity_name'), + description: i18nT('dashboard_evaluation:builtin_answer_similarity_desc') + }, + builtin_answer_relevancy: { + name: i18nT('dashboard_evaluation:builtin_answer_relevancy_name'), + description: i18nT('dashboard_evaluation:builtin_answer_relevancy_desc') + }, + builtin_faithfulness: { + name: i18nT('dashboard_evaluation:builtin_faithfulness_name'), + description: i18nT('dashboard_evaluation:builtin_faithfulness_desc') + }, + builtin_context_recall: { + name: i18nT('dashboard_evaluation:builtin_context_recall_name'), + description: i18nT('dashboard_evaluation:builtin_context_recall_desc') + }, + builtin_context_precision: { + name: i18nT('dashboard_evaluation:builtin_context_precision_name'), + description: i18nT('dashboard_evaluation:builtin_context_precision_desc') + } + }; + + return dimensionMap[dimensionId as keyof typeof dimensionMap] || null; +}; From 1ff7b8ec605001396c374a8162b77a06f9db40ae Mon Sep 17 00:00:00 2001 From: lyx Date: Tue, 16 Sep 2025 21:46:00 +0800 Subject: [PATCH 10/84] feat(evaluation): Optimize dataset file import and manual addition features - Added support for the qualityResult field in the DataListModals component - Optimized the polling logic and error message display in the EditDataModal component - Modified the ManuallyAddModal component to display the evaluation model selector only when auto-evaluation is enabled - Refactored the file import page to use the new generateDataByUploadFile API - Supports multiple file uploads and progress display - Simplified file selection and processing logic - Removed redundant upload status management - Added implementation of the generateDataByUploadFile API to support multiple file upload functionality --- .../dataset/detail/DataListModals.tsx | 15 +- .../dataset/detail/EditDataModal.tsx | 31 ++- .../dataset/detail/ManuallyAddModal.tsx | 56 +++-- .../evaluation/dataset/fileImport.tsx | 217 ++++-------------- .../app/src/web/core/evaluation/dataset.ts | 45 ++++ 5 files changed, 153 insertions(+), 211 deletions(-) diff --git a/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/DataListModals.tsx b/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/DataListModals.tsx index ebfe61d04e60..042b8f05ad87 100644 --- a/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/DataListModals.tsx +++ b/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/DataListModals.tsx @@ -27,6 +27,7 @@ import { } from '@/web/core/evaluation/dataset'; import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel'; import MyIcon from '@fastgpt/web/components/common/Icon'; +import type { EvalDatasetDataQualityResultEnum } from '@fastgpt/global/core/evaluation/dataset/constants'; interface DataListModalsProps { total: number; @@ -129,16 +130,23 @@ const DataListModals: React.FC = ({ total, refreshList }) = referenceAnswer: string; qualityMetadata?: Record; synthesisMetadata?: Record; + qualityResult?: string; }, isGoNext = false ) => { - const { question, referenceAnswer, qualityMetadata, synthesisMetadata } = formData; + const { question, referenceAnswer, qualityMetadata, synthesisMetadata, qualityResult } = + formData; await updateDataFn({ dataId: selectedItem._id, userInput: question, expectedOutput: referenceAnswer, qualityMetadata, - synthesisMetadata + synthesisMetadata, + ...(qualityResult + ? { + qualityResult: qualityResult as EvalDatasetDataQualityResultEnum + } + : {}) }); isGoNext && handleGoNextData(); !isGoNext && onEditModalClose(); @@ -214,6 +222,9 @@ const DataListModals: React.FC = ({ total, refreshList }) = isOpen={isManualAddModalOpen} collectionId={collectionId} onClose={onManualAddModalClose} + defaultValues={{ + autoEvaluation: true + }} onConfirm={refreshList} /> diff --git a/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/EditDataModal.tsx b/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/EditDataModal.tsx index 3d07cd2edd2c..9fcf1a51de48 100644 --- a/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/EditDataModal.tsx +++ b/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/EditDataModal.tsx @@ -151,13 +151,10 @@ const EditDataModal: React.FC = ({ ); // 轮询获取数据详情 - 在评测中或排队中时才轮询 - useRequest2(() => getEvaluationDatasetDataDetail(formData._id), { + const { runAsync: getDetail } = useRequest2(() => getEvaluationDatasetDataDetail(formData._id), { pollingInterval: 3000, pollingWhenHidden: false, - manual: - !isOpen || - (currentEvaluationStatus !== EvalDatasetDataQualityStatusEnum.evaluating && - currentEvaluationStatus !== EvalDatasetDataQualityStatusEnum.queuing), + manual: true, ready: isOpen, onSuccess: (data: any) => { if (data?.qualityMetadata?.status !== currentEvaluationStatus) { @@ -199,6 +196,13 @@ const EditDataModal: React.FC = ({ // 根据评测状态设置按钮显示状态 updateButtonsByStatus(evaluationStatus, formData?.qualityResult); + + if ( + currentEvaluationStatus === EvalDatasetDataQualityStatusEnum.evaluating || + currentEvaluationStatus === EvalDatasetDataQualityStatusEnum.queuing + ) { + getDetail(); + } } }, [isOpen, defaultQuestion, defaultReferenceAnswer, evaluationStatus, qualityReason, reset]); @@ -292,12 +296,16 @@ const EditDataModal: React.FC = ({ {t('dashboard_evaluation:evaluation_abnormal')} - - {t('dashboard_evaluation:error_message')}: - - - {errorMsg} - + {errorMsg && ( + <> + + {t('dashboard_evaluation:error_message')}: + + + {errorMsg} + + + )} ); @@ -374,6 +382,7 @@ const EditDataModal: React.FC = ({ isShow: btn.key === 'modifyRes' || btn.key === 'reStart' })) ); + getDetail(); break; case 'modifyRes': diff --git a/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/ManuallyAddModal.tsx b/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/ManuallyAddModal.tsx index 5e1e5b33a062..a5bf19a9a536 100644 --- a/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/ManuallyAddModal.tsx +++ b/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/ManuallyAddModal.tsx @@ -86,21 +86,29 @@ const ManuallyAddModal = ({ // 检查表单是否有效 const isFormValid = useMemo(() => { - return ( - questionValue.trim() !== '' && answerValue.trim() !== '' && evaluationModelValue.trim() !== '' - ); - }, [questionValue, answerValue, evaluationModelValue]); + const basicValid = questionValue.trim() !== '' && answerValue.trim() !== ''; + if (autoEvaluationValue) { + return basicValid && evaluationModelValue.trim() !== ''; + } + return basicValid; + }, [questionValue, answerValue, autoEvaluationValue, evaluationModelValue]); // 处理表单提交 const handleFormSubmit = useCallback( async (data: ManuallyAddForm) => { - await handleAddData({ + const submitData: any = { userInput: data.question, expectedOutput: data.answer, - evaluationModel: data.evaluationModel, enableQualityEvaluation: data.autoEvaluation, collectionId - }); + }; + + // 只有当开启自动评测时才传递评测模型 + if (data.autoEvaluation) { + submitData.evaluationModel = data.evaluationModel; + } + + await handleAddData(submitData); onConfirm?.(data); onClose?.(); reset(); @@ -168,22 +176,24 @@ const ManuallyAddModal = ({ /> - {/* 质量评测模型 */} - - - {t('dashboard_evaluation:quality_eval_model_label')} - - ({ - value: item.model, - label: item.name - }))} - onChange={(value) => setValue('evaluationModel', value)} - placeholder={t('dashboard_evaluation:select_quality_eval_model_placeholder')} - /> - + {/* 质量评测模型 - 仅在开启自动评测时显示 */} + {autoEvaluationValue && ( + + + {t('dashboard_evaluation:quality_eval_model_label')} + + ({ + value: item.model, + label: item.name + }))} + onChange={(value) => setValue('evaluationModel', value)} + placeholder={t('dashboard_evaluation:select_quality_eval_model_placeholder')} + /> + + )} diff --git a/projects/app/src/pages/dashboard/evaluation/dataset/fileImport.tsx b/projects/app/src/pages/dashboard/evaluation/dataset/fileImport.tsx index ef28ae9b2c7a..b9fa95cc5665 100644 --- a/projects/app/src/pages/dashboard/evaluation/dataset/fileImport.tsx +++ b/projects/app/src/pages/dashboard/evaluation/dataset/fileImport.tsx @@ -11,7 +11,7 @@ import { IconButton, Switch, HStack, - Spinner + Progress } from '@chakra-ui/react'; import { useRouter } from 'next/router'; import { serviceSideProps } from '@/web/common/i18n/utils'; @@ -23,27 +23,20 @@ import { useToast } from '@fastgpt/web/hooks/useToast'; import AIModelSelector from '@/components/Select/AIModelSelector'; import { useSystemStore } from '@/web/common/system/useSystemStore'; import FileSelector, { - type SelectFileItemType, - type EvaluationFileItemType -} from '@/pageComponents/dashboard/evaluation/dataset/FileSelector'; -import RenderFiles from '@/pageComponents/dashboard/evaluation/dataset/RenderFiles'; + type SelectFileItemType +} from '@/pageComponents/dataset/detail/components/FileSelector'; import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip'; import { fileDownload } from '@/web/common/file/utils'; import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; -import { uploadFile2DB } from '@/web/common/file/controller'; -import { BucketNameEnum } from '@fastgpt/global/common/file/constants'; -import { getErrText } from '@fastgpt/global/common/error/utils'; -import { formatFileSize } from '@fastgpt/global/common/file/tools'; -import { getFileIcon } from '@fastgpt/global/common/file/icon'; import { postCreateEvaluationDataset, - postImportEvaluationDatasetFile + generateDataByUploadFile } from '@/web/core/evaluation/dataset'; type FileImportFormType = { name: string; evaluationModel: string; - files: EvaluationFileItemType[]; + files: File[]; autoEvaluation: boolean; }; @@ -59,7 +52,8 @@ const FileImport = () => { const { toast } = useToast(); const { llmModelList } = useSystemStore(); const [isFormValid, setIsFormValid] = useState(false); - const [selectFiles, setSelectFiles] = useState([]); + const [selectFiles, setSelectFiles] = useState([]); + const [percent, setPercent] = useState(0); const [collectionId, setCollectionId] = useState( (router.query.collectionId as string) || '' ); @@ -85,99 +79,23 @@ const FileImport = () => { const files = watch('files'); const autoEvaluation = watch('autoEvaluation'); - const successFiles = useMemo(() => selectFiles.filter((item) => !item.errorMsg), [selectFiles]); - const errorFiles = useMemo(() => selectFiles.filter((item) => item.errorMsg), [selectFiles]); - // 检查表单是否有效 const checkFormValid = useCallback(() => { const isValid = - (router.query.collectionId ? true : name.trim() !== '') && successFiles.length > 0; + (router.query.collectionId ? true : name.trim() !== '') && selectFiles.length > 0; setIsFormValid(isValid); - }, [name, successFiles, router.query.collectionId]); + }, [name, selectFiles, router.query.collectionId]); React.useEffect(() => { checkFormValid(); }, [checkFormValid]); React.useEffect(() => { - setValue('files', successFiles); - }, [setValue, successFiles]); - - const { runAsync: onSelectFiles, loading: uploading } = useRequest2( - async (files: SelectFileItemType[]) => { - await Promise.all( - files.map(async ({ fileId, file }) => { - try { - const { fileId: uploadFileId } = await uploadFile2DB({ - file, - bucketName: BucketNameEnum.evaluation, - percentListen: (e) => { - setSelectFiles((state) => - state.map((item) => - item.id === fileId - ? { - ...item, - uploadedFileRate: item.uploadedFileRate - ? Math.max(e, item.uploadedFileRate) - : e - } - : item - ) - ); - } - }); - setSelectFiles((state) => - state.map((item) => - item.id === fileId - ? { - ...item, - dbFileId: uploadFileId, - isUploading: false, - uploadedFileRate: 100 - } - : item - ) - ); - } catch (error) { - setSelectFiles((state) => - state.map((item) => - item.id === fileId - ? { - ...item, - isUploading: false, - errorMsg: getErrText(error) - } - : item - ) - ); - } - }) - ); - }, - { - onBefore([files]) { - setSelectFiles((state) => { - return [ - ...state, - ...files.map((selectFile) => { - const { fileId, file } = selectFile; - - return { - id: fileId, - createStatus: 'waiting', - file, - sourceName: file.name, - sourceSize: formatFileSize(file.size), - icon: getFileIcon(file.name), - isUploading: true, - uploadedFileRate: 0 - }; - }) - ]; - }); - } - } - ); + setValue( + 'files', + selectFiles.map((item) => item.file) + ); + }, [setValue, selectFiles]); // 根据场景获取跳转URL const getRedirectUrl = () => { @@ -198,7 +116,6 @@ const FileImport = () => { const { runAsync: onSubmitForm, loading: isSubmitting } = useRequest2( async (data: FileImportFormType) => { let currentCollectionId = collectionId; - let hasError = false; // 如果collectionId不存在,先创建评测数据集 if (!currentCollectionId) { @@ -208,63 +125,18 @@ const FileImport = () => { setCollectionId(currentCollectionId); } - // 串行导入文件到数据集,跳过已成功的文件 - for (const file of data.files) { - // 跳过已经成功导入的文件 - if (file.createStatus === 'success') { - continue; - } - - try { - await postImportEvaluationDatasetFile({ - fileId: file.dbFileId!, - collectionId: currentCollectionId, - enableQualityEvaluation: data.autoEvaluation, - evaluationModel: data.autoEvaluation ? data.evaluationModel : undefined - }); - - // 导入成功,更新文件状态 - setSelectFiles((state) => - state.map((item) => - item.dbFileId === file.dbFileId - ? { - ...item, - createStatus: 'success', - errorMsg: undefined - } - : item - ) - ); - } catch (error) { - hasError = true; - // 将错误信息写入对应文件的errorMsg,并设置状态为fail - setSelectFiles((state) => - state.map((item) => - item.dbFileId === file.dbFileId - ? { - ...item, - createStatus: 'fail', - errorMsg: getErrText(error) - } - : item - ) - ); - // 继续处理下一个文件,不中断整个流程 - } - } - - // 检查是否所有文件都成功导入 - const allFilesSuccess = data.files.every((file) => { - const currentFile = selectFiles.find((item) => item.dbFileId === file.dbFileId); - return currentFile?.createStatus === 'success'; + // 使用 generateDataByUploadFile 上传多个文件 + await generateDataByUploadFile({ + fileList: data.files, + percentListen: setPercent, + collectionId: currentCollectionId, + enableQualityEvaluation: data.autoEvaluation, + evaluationModel: data.autoEvaluation ? data.evaluationModel : undefined }); - - if (allFilesSuccess) { - toast({ - title: t('dashboard_evaluation:file_import_success'), - status: 'success' - }); - + }, + { + successToast: t('dashboard_evaluation:file_import_success'), + onSuccess() { router.push(getRedirectUrl()); } } @@ -369,7 +241,7 @@ const FileImport = () => { fileType=".csv" selectFiles={selectFiles} w={'100%'} - onSelectFiles={onSelectFiles} + setSelectFiles={(e) => setSelectFiles(e)} /> {/* 渲染已选择的文件 */} @@ -388,15 +260,10 @@ const FileImport = () => { - {item.sourceName} + {item.name} - {item.sourceSize} + {item.size} - {item.isUploading && ( - - - - )} { }} /> - {/* 显示错误信息 */} - {item.errorMsg && ( - - {t('common:error')}: {item.errorMsg} - + /> )} ))} @@ -473,10 +336,14 @@ const FileImport = () => { h={9} type="submit" form="file-import-form" - isDisabled={!isFormValid || uploading || errorFiles.length > 0} + isDisabled={!isFormValid} isLoading={isSubmitting} > - {t('dashboard_evaluation:file_import_confirm')} + {isSubmitting + ? percent === 100 + ? t('dataset:data_parsing') + : t('dataset:data_uploading', { num: percent }) + : t('dashboard_evaluation:file_import_confirm')} diff --git a/projects/app/src/web/core/evaluation/dataset.ts b/projects/app/src/web/core/evaluation/dataset.ts index ff676ce86aaf..d71b719840a5 100644 --- a/projects/app/src/web/core/evaluation/dataset.ts +++ b/projects/app/src/web/core/evaluation/dataset.ts @@ -113,3 +113,48 @@ export const getEvaluationDatasetDataDetail = (dataId: string) => // 批量重试所有任务 export const postRetryAllEvaluationDatasetTasks = (data: { collectionId: string }) => POST('/core/evaluation/dataset/collection/retryAllTask', data); + +// 上传文件 +export const generateDataByUploadFile = ({ + fileList, + percentListen, + name, + enableQualityEvaluation, + evaluationModel, + collectionId +}: { + fileList: File[]; + percentListen: (percent: number) => void; + collectionId: string; + name?: string; + enableQualityEvaluation: boolean; + evaluationModel?: string; +}) => { + const formData = new FormData(); + + // 处理文件数组,为每个文件添加到 FormData + fileList.forEach((singleFile, index) => { + formData.append('file', singleFile, encodeURIComponent(singleFile.name)); + }); + + const otherParams = { + ...(name ? { name } : {}), + ...(evaluationModel ? { evaluationModel } : {}), + enableQualityEvaluation, + collectionId + }; + formData.append('data', JSON.stringify(otherParams)); + + return POST(`/core/evaluation/dataset/data/import`, formData, { + timeout: 600000, + onUploadProgress: (e) => { + if (!e.total) return; + + const percent = Math.round((e.loaded / e.total) * 100); + percentListen?.(percent); + }, + headers: { + 'Content-Type': 'multipart/form-data; charset=utf-8' + } + }); +}; From 7e1553f8ff7a6661f0d93e6dd932e37d9b6f3b70 Mon Sep 17 00:00:00 2001 From: sxf-xiongtao Date: Tue, 16 Sep 2025 17:58:02 +0800 Subject: [PATCH 11/84] feat: updated some English translations --- packages/web/i18n/en/account.json | 16 +- packages/web/i18n/en/account_bill.json | 4 +- packages/web/i18n/en/account_info.json | 12 +- packages/web/i18n/en/account_inform.json | 8 +- packages/web/i18n/en/account_model.json | 138 ++-- packages/web/i18n/en/account_team.json | 62 +- packages/web/i18n/en/account_thirdParty.json | 4 +- packages/web/i18n/en/account_usage.json | 78 +-- packages/web/i18n/en/admin.json | 612 ++++++++++++++++++ packages/web/i18n/en/app.json | 222 +++---- packages/web/i18n/en/chat.json | 58 +- packages/web/i18n/en/common.json | 84 +-- .../web/i18n/en/dashboard_evaluation.json | 376 +++++------ packages/web/i18n/en/dataset.json | 248 +++---- packages/web/i18n/en/file.json | 32 +- packages/web/i18n/en/login.json | 18 +- packages/web/i18n/en/publish.json | 26 +- packages/web/i18n/en/user.json | 124 ++-- packages/web/i18n/en/workflow.json | 304 ++++----- packages/web/i18n/zh-CN/admin.json | 612 ++++++++++++++++++ packages/web/i18n/zh-CN/dataset.json | 4 +- packages/web/i18n/zh-Hant/admin.json | 612 ++++++++++++++++++ 22 files changed, 2745 insertions(+), 909 deletions(-) create mode 100644 packages/web/i18n/en/admin.json create mode 100644 packages/web/i18n/zh-CN/admin.json create mode 100644 packages/web/i18n/zh-Hant/admin.json diff --git a/packages/web/i18n/en/account.json b/packages/web/i18n/en/account.json index bb87544de36e..456b96e4b68f 100644 --- a/packages/web/i18n/en/account.json +++ b/packages/web/i18n/en/account.json @@ -2,13 +2,13 @@ "account_team.delete_dataset": "Delete knowledge base", "active_model": "Available models", "add_default_model": "Add preset model", - "api_key": "API key", + "api_key": "API keys", "bills_and_invoices": "Bills and invoices", "channel": "Model channels", "config_model": "Model configuration", "confirm_logout": "Are you sure you want to log out?", "create_channel": "Add channel", - "create_model": "Add model", + "create_model": "Add", "custom_model": "Custom model", "default_model": "Preset model", "default_model_config": "Default model configuration", @@ -31,7 +31,7 @@ "model.default_config_tip": "Merge these settings when users send a chat request. Example:\n\"\"\"\n{\n \"temperature\": 1,\n \"max_tokens\": null\n}\n\"\"\"", "model.default_model": "Default model", "model.default_system_chat_prompt": "Default prompt", - "model.default_system_chat_prompt_tip": "The default prompt will be included in all conversations with the model.", + "model.default_system_chat_prompt_tip": "The default prompt will be included in all chats with the model.", "model.default_token": "Default chunk size", "model.default_token_tip": "Default text chunk size for indexing models. Must be smaller than the maximum context length.", "model.delete_model_confirm": "Are you sure you want to delete the model?", @@ -48,7 +48,7 @@ "model.model_id": "Model ID", "model.model_id_tip": "Unique identifier of the model. This must match the model value from the provider and correspond to the OneAPI channel.", "model.normalization": "Normalization", - "model.normalization_tip": "If the Embedding API does not normalize vectors, enable this option to let the system normalize them.\nWithout normalization, the vector retrieval score will be greater than 1.", + "model.normalization_tip": "If the Embedding API does not normalize vectors, enable this option to let the system normalize them.\nWithout normalization, the vector search score will be greater than 1.", "model.output_price": "Output price", "model.output_price_tip": "Output price for the model. If configured, the overall price will become invalid.", "model.param_name": "Parameter name", @@ -63,7 +63,7 @@ "model.show_top_p": "Show Top-p parameter", "model.test_model": "Model test", "model.tool_choice": "Enable tool calling", - "model.tool_choice_tag": "Tool calling", + "model.tool_choice_tag": "Tool call", "model.tool_choice_tip": "Enable this option if the model supports tool calling.", "model.used_in_classify": "Question classification", "model.used_in_extract_fields": "Text extraction", @@ -76,11 +76,11 @@ "model.voices_tip": "Configure multiple roles using an array. Example:\n[\n {\n \"label\": \"Alloy\",\n \"value\": \"alloy\"\n },\n {\n \"label\": \"Echo\",\n \"value\": \"echo\"\n }\n]", "model_provider": "Model provider", "notifications": "Notification", - "personal_information": "Personal info", + "personal_information": "Profile", "personalization": "Personalization", - "promotion_records": "Promotion record", + "promotion_records": "Promotion records", "reset_default": "Reset to default", - "team": "Team management", + "team": "Teams", "third_party": "Third-party account", "usage_records": "Usage" } diff --git a/packages/web/i18n/en/account_bill.json b/packages/web/i18n/en/account_bill.json index 79c37a47f153..81b4b7e4d09c 100644 --- a/packages/web/i18n/en/account_bill.json +++ b/packages/web/i18n/en/account_bill.json @@ -5,7 +5,7 @@ "bank_account": "Account number", "bank_name": "Bank name", "bill_detail": "Bill details", - "bill_record": "Billing record", + "bill_record": "Billing records", "click_to_download": "Download", "company_address": "Company address", "company_phone": "Company phone", @@ -35,7 +35,7 @@ "organization_name": "Organization", "payment_method": "Payment method", "payway_coupon": "Redeem code", - "rerank": "Rerank", + "rerank": "Result reranking", "save": "Save", "save_failed": "Error occurred during the operation.", "save_success": "Saved successfully.", diff --git a/packages/web/i18n/en/account_info.json b/packages/web/i18n/en/account_info.json index 955d06c2622a..d9d649115c66 100644 --- a/packages/web/i18n/en/account_info.json +++ b/packages/web/i18n/en/account_info.json @@ -14,12 +14,12 @@ "cancel": "Cancel", "change": "Change", "choose_avatar": "Click to select a profile image.", - "click_modify_nickname": "Click to change nickname", + "click_modify_nickname": "Click to change the nickname.", "code_required": "Verification code is required.", "confirm": "OK", "confirm_password": "Confirm password", "contact_customer_service": "Contact Customer Service", - "contact_us": "Contact us", + "contact_us": "Contact Us", "current_package": "Current plan", "current_token_price": "Current point price", "dataset_amount": "Knowledge bases", @@ -33,9 +33,9 @@ "general_info": "Basics", "group": "groups", "help_chatbot": "Bot assistant", - "help_document": "Help documentation", + "help_document": "Help", "knowledge_base_capacity": "Knowledge base capacity", - "manage": "Management", + "manage": "Manage", "member_amount": "Members", "member_name": "Member name", "month": "Monthly", @@ -44,12 +44,12 @@ "old_password": "Current password", "package_and_usage": "Plans and usage", "package_details": "Plan details", - "package_expiry_time": "Plan expiration time", + "package_expiry_time": "Expiration time", "package_usage_rules": "Plan usage rules: Higher-level plans are used first. Unused lower-level plans will be applied later.", "password": "Password", "password_mismatch": "Passwords do not match.", "password_tip": "Password must be at least 8 characters long and contain at least 2 of the following: digits, letters, and special characters.", - "password_update_error": "Failed to change the password.", + "password_update_error": "Error occurred while changing the password.", "password_update_success": "Password changed successfully.", "pending_usage": "Available", "phone_label": "Mobile number", diff --git a/packages/web/i18n/en/account_inform.json b/packages/web/i18n/en/account_inform.json index d35bf41d18cd..e09a4fef26b1 100644 --- a/packages/web/i18n/en/account_inform.json +++ b/packages/web/i18n/en/account_inform.json @@ -1,7 +1,7 @@ { - "notification_detail": "notification details", - "no_notifications": "No notification yet", + "notification_detail": "Notification details", + "no_notifications": "No data available.", "read": "Read", - "system": "official", - "team": "team" + "system": "Official", + "team": "Team" } \ No newline at end of file diff --git a/packages/web/i18n/en/account_model.json b/packages/web/i18n/en/account_model.json index 4fc5d2effbc3..e2f95f6a6517 100644 --- a/packages/web/i18n/en/account_model.json +++ b/packages/web/i18n/en/account_model.json @@ -1,95 +1,95 @@ { "Hunyuan": "Tencent Hunyuan", - "aipoint_usage": "AI points", + "aipoint_usage": "Points consumed", "all": "All", "api_key": "API key", - "avg_response_time": "Average call time (seconds)", - "avg_ttfb": "Average first word duration (seconds)", - "azure": "Azure", - "base_url": "Base url", + "avg_response_time": "Avg call duration (s)", + "avg_ttfb": "TTFB (s)", + "azure": "Microsoft Azure", + "base_url": "Proxy address", "batch_size": "Number of concurrent requests", - "channel_name": "Channel", + "channel_name": "Channel name", "channel_priority": "Priority", - "channel_priority_tip": "The higher the priority channel, the easier it is to be requested", - "channel_status": "state", - "channel_status_auto_disabled": "Automatically disable", + "channel_priority_tip": "Channels with higher priority are more likely to be used.", + "channel_status": "Status", + "channel_status_auto_disabled": "Auto disabled", "channel_status_disabled": "Disabled", - "channel_status_enabled": "Enable", - "channel_status_unknown": "unknown", - "channel_type": "Protocol Type", - "clear_model": "Clear the model", - "confirm_delete_channel": "Confirm the deletion of the [{{name}}] channel?", - "copy_model_id_success": "Copyed model id", - "create_channel": "Added channels", - "dashboard_call_trend": "Model Call Trend", + "channel_status_enabled": "Enabled", + "channel_status_unknown": "Unknown", + "channel_type": "Protocol type", + "clear_model": "Clear models", + "confirm_delete_channel": "Are you sure you want to delete the channel ({{name}})?", + "copy_model_id_success": "Model ID copied.", + "create_channel": "Add channel", + "dashboard_call_trend": "Model call trend", "dashboard_channel": "Channel", - "dashboard_cost_trend": "Cost Consumption", - "dashboard_error_calls": "Error Calls", - "dashboard_input_tokens": "Input Tokens", + "dashboard_cost_trend": "Points consumed", + "dashboard_error_calls": "Failed calls", + "dashboard_input_tokens": "Input tokens", "dashboard_model": "Model", - "dashboard_no_data": "No data available", - "dashboard_output_tokens": "Output Tokens", - "dashboard_points": "points", - "dashboard_success_calls": "Success Calls", - "dashboard_token_trend": "Token Usage Trend", - "dashboard_token_usage": "Tokens", - "dashboard_total_calls": "Total Calls:", - "dashboard_total_cost": "Total Cost", - "dashboard_total_cost_label": "Total Cost:", - "dashboard_total_tokens": "Total Tokens", + "dashboard_no_data": "No data available.", + "dashboard_output_tokens": "Output tokens", + "dashboard_points": "Points", + "dashboard_success_calls": "Successful calls", + "dashboard_token_trend": "Token usage trend", + "dashboard_token_usage": "Tokens consumed", + "dashboard_total_calls": "Total calls:", + "dashboard_total_cost": "Total cost", + "dashboard_total_cost_label": "Total cost:", + "dashboard_total_tokens": "Total tokens", "default_url": "Default address", - "detail": "Detail", - "duration": "Duration", - "edit": "edit", - "edit_channel": "Channel configuration", + "detail": "Details", + "duration": "Time taken", + "edit": "Edit", + "edit_channel": "Channel settings", "enable_channel": "Enable", - "forbid_channel": "Disabled", + "forbid_channel": "Disable", "input": "Input", "key_type": "API key format:", - "log": "Call log", + "log": "Call logs", "log_detail": "Log details", - "log_request_id_search": "Search by requestId", + "log_request_id_search": "Request ID", "log_status": "Status", - "mapping": "Model Mapping", - "mapping_tip": "A valid Json is required. \nThe model can be mapped when sending a request to the actual address. \nFor example:\n{\n \n \"gpt-4o\": \"gpt-4o-test\"\n\n}\n\nWhen FastGPT requests the gpt-4o model, the gpt-4o-test model is sent to the actual address, instead of gpt-4o.", + "mapping": "Model mapping", + "mapping_tip": "Enter a valid JSON object to map the model to the actual URL. Example:\n{\n \"gpt-4o\": \"gpt-4o-test\"\n}\nWhen FastGPT requests the gpt-4o model, the system sends gpt-4o-test to the actual URL instead of gpt-4o.", "maxToken_tip": "Model max_tokens parameter", - "max_rpm": "Max RPM (Requests Per Minute)", - "max_temperature_tip": "If the model temperature parameter is not filled in, it means that the model does not support the temperature parameter.", - "max_tpm": "Max TPM (Tokens Per Minute)", + "max_rpm": "Max RPM (requests per minute)", + "max_temperature_tip": "Model temperature parameter. Leave blank if unsupported.", + "max_tpm": "Max TPM (tokens per minute)", "model": "Model", - "model_error_rate": "Error rate", - "model_error_request_times": "Number of failures", + "model_error_rate": "Failure rate", + "model_error_request_times": "Failed calls", "model_name": "Model name", - "model_request_times": "Request times", - "model_test": "Model testing", - "model_tokens": "Input/Output tokens", - "model_ttfb_time": "Response time of first word", + "model_request_times": "Requests", + "model_test": "Model test", + "model_tokens": "Input/output tokens", + "model_ttfb_time": "TTFB", "monitoring": "Monitoring", "output": "Output", "request_at": "Request time", "request_duration": "Request duration: {{duration}}s", - "retry_times": "Number of retry times", - "running_test": "In testing", - "search_model": "Search for models", - "select_channel": "Select a channel name", - "select_model": "Select a model", - "select_model_placeholder": "Select the model available under this channel", - "select_provider_placeholder": "Search protocol type", - "selected_model_empty": "Choose at least one model", - "start_test": "Batch test {{num}} models", - "test_failed": "There are {{num}} models that report errors", - "timespan_day": "Day", - "timespan_hour": "Hour", - "timespan_label": "Time Granularity", - "timespan_minute": "Minute", - "total_call_volume": "Request amount", - "use_in_eval": "Use in eval", + "retry_times": "Max attempts", + "running_test": "Testing", + "search_model": "Model ID", + "select_channel": "Select", + "select_model": "Select", + "select_model_placeholder": "Select available models for this channel", + "select_provider_placeholder": "Protocol type", + "selected_model_empty": "Please select at least one model.", + "start_test": "Bulk test {{num}} models.", + "test_failed": "{{num}} models failed.", + "timespan_day": "Days", + "timespan_hour": "Hours", + "timespan_label": "Time granularity", + "timespan_minute": "Minutes", + "total_call_volume": "Total calls", + "use_in_eval": "App evaluation", "view_chart": "Chart", - "view_table": "Table", - "vlm_model": "Vlm", - "vlm_model_tip": "Used to generate additional indexing of images in a document in the knowledge base", - "volunme_of_failed_calls": "Error amount", - "waiting_test": "Waiting for testing", + "view_table": "Form", + "vlm_model": "VLM", + "vlm_model_tip": "Generates extra indexes for images in documents within the knowledge base.", + "volunme_of_failed_calls": "Failed calls", + "waiting_test": "Waiting for test", "evaluation_model": "评测模型", "evaluation_model_tip": "用于应用评测,及评测数据集中针对数据质量的评测。" } diff --git a/packages/web/i18n/en/account_team.json b/packages/web/i18n/en/account_team.json index 5613ae0df43b..a2a8ed26f7d0 100644 --- a/packages/web/i18n/en/account_team.json +++ b/packages/web/i18n/en/account_team.json @@ -69,7 +69,7 @@ "dataset.yuque_dataset": "Yuque knowledge base", "delete": "Delete", "delete_api_key": "Delete API key", - "delete_app": "Delete workspace app", + "delete_app": "Delete studio app", "delete_app_collaborator": "Remove app permission", "delete_app_publish_channel": "Delete publishing channel", "delete_collection": "Delete collection", @@ -93,13 +93,13 @@ "delete_group": "Delete group", "delete_org": "Delete department", "department": "Department", - "edit_info": "Edit", + "edit_info": "Edit info", "edit_member": "Edit user", "edit_member_tip": "Member name", "edit_org_info": "Edit department info", "expires": "Expiration time", "export_app_chat_log": "Export app chat history", - "export_bill_records": "Export billing record", + "export_bill_records": "Export billing records", "export_dataset": "Export knowledge base", "export_evaluation_task_items": "Export evaluation task items", "generate_evaluation_summary": "Generate evaluation summary report", @@ -129,7 +129,7 @@ "kick_out_team": "Remove member", "label_sync": "Sync tag", "leave": "Resigned", - "leave_team_failed": "Failed to leave the team.", + "leave_team_failed": "Error occurred while leaving the team.", "log_admin_add_plan": "{{name}} added a plan for the team ({{teamId}}).", "log_admin_add_user": "{{name}} created the user ({{userName}}).", "log_admin_change_license": "{{name}} changed the license.", @@ -168,10 +168,10 @@ "log_create_dataset": "{{name}} deleted the {{datasetType}} named {{datasetName}}.", "log_create_dataset_folder": "{{name}} created the folder ({{folderName}}).", "log_create_department": "{{name}} created the department ({{departmentName}}).", - "log_create_evaluation_dataset_collection": "【{{name}}】Created evaluation dataset collection named [{{collectionName}}]", - "log_create_evaluation_dataset_data": "【{{name}}】Created evaluation dataset data in collection [{{collectionName}}]", - "log_create_evaluation_task": "【{{name}}】Created evaluation task named [{{taskName}}]", - "log_create_evaluation_metric": "【{{name}}】Created evaluation metric [{{metricName}}]", + "log_create_evaluation_dataset_collection": "{{name}} created evaluation dataset collection named [{{collectionName}}]", + "log_create_evaluation_dataset_data": "{{name}} created evaluation dataset data in collection [{{collectionName}}]", + "log_create_evaluation_task": "{{name}} created evaluation task named [{{taskName}}]", + "log_create_evaluation_metric": "{{name}} created evaluation metric [{{metricName}}]", "log_create_group": "{{name}} created the group ({{groupName}}).", "log_create_invitation_link": "{{name}} created the invitation link ({{link}}).", "log_create_invoice": "{{name}} issued an invoice.", @@ -186,20 +186,20 @@ "log_delete_department": "{{name}} deleted the department ({{departmentName}}).", "log_delete_evaluation": "{{name}} deleted evaluation data for the {{appType}} app ({{appName}}).", "log_delete_group": "{{name}} deleted the group ({{groupName}}).", - "log_delete_evaluation_dataset_collection": "【{{name}}】Deleted evaluation dataset collection named [{{collectionName}}]", - "log_delete_evaluation_dataset_data": "【{{name}}】Deleted evaluation dataset data in collection [{{collectionName}}]", - "log_delete_evaluation_dataset_task": "【{{name}}】Deleted evaluation dataset task in collection [{{collectionName}}]", - "log_delete_evaluation_task": "【{{name}}】Deleted evaluation task named [{{taskName}}]", - "log_delete_evaluation_task_item": "【{{name}}】Deleted evaluation task item [{{itemId}}] from task [{{taskName}}]", - "log_delete_evaluation_metric": "【{{name}}】Deleted evaluation metric [{{metricName}}]", + "log_delete_evaluation_dataset_collection": "{{name}} deleted evaluation dataset collection named [{{collectionName}}]", + "log_delete_evaluation_dataset_data": "{{name}} deleted evaluation dataset data in collection [{{collectionName}}]", + "log_delete_evaluation_dataset_task": "{{name}} deleted evaluation dataset task in collection [{{collectionName}}]", + "log_delete_evaluation_task": "{{name}} deleted evaluation task named [{{taskName}}]", + "log_delete_evaluation_task_item": "{{name}} deleted evaluation task item [{{itemId}}] from task [{{taskName}}]", + "log_delete_evaluation_metric": "{{name}} deleted evaluation metric [{{metricName}}]", "log_details": "Details", "log_export_app_chat_log": "{{name}} exported the chat history of the {{appType}} app ({{appName}}).", "log_export_bill_records": "{{name}} exported the billing records.", "log_export_dataset": "{{name}} exported the {{datasetType}} named {{datasetName}}.", "log_generate_evaluation_summary": "{{name}} generated summary report for metric {{metricName}} in evaluation task {{evalName}}.", "log_update_evaluation_summary_config": "{{name}} updated summary configuration for evaluation task {{evalName}}.", - "log_import_evaluation_dataset_data": "【{{name}}】Imported {{recordCount}} evaluation dataset records into collection [{{collectionName}}]", - "log_export_evaluation_task_items": "【{{name}}】Exported evaluation task items from task [{{taskName}}]", + "log_import_evaluation_dataset_data": "{{name}} imported {{recordCount}} evaluation dataset records into collection [{{collectionName}}]", + "log_export_evaluation_task_items": "{{name}} exported evaluation task items from task [{{taskName}}]", "log_delete_evaluation_task_data_item": "【{{name}}】Deleted data item [{{dataItemId}}] from evaluation task [{{taskName}}]", "log_update_evaluation_task_data_item": "【{{name}}】Updated data item [{{dataItemId}}] in evaluation task [{{taskName}}]", "log_retry_evaluation_task_data_item": "【{{name}}】Retried data item [{{dataItemId}}] in evaluation task [{{taskName}}]", @@ -210,15 +210,15 @@ "log_move_app": "{{name}} moved the {{appType}} app ({{appName}}) to the folder ({{targetFolderName}}).", "log_move_dataset": "{{name}} moved the {{datasetType}} named {{datasetName}} to the folder ({{targetFolderName}}).", "log_purchase_plan": "{{name}} purchased a plan.", - "log_quality_assessment_evaluation_data": "【{{name}}】Performed quality assessment on evaluation data in collection [{{collectionName}}]", + "log_quality_assessment_evaluation_data": "{{name}} performed quality assessment on evaluation data in collection [{{collectionName}}]", "log_recover_team_member": "{{name}} restored the member ({{memberName}}).", "log_relocate_department": "{{name}} moved the department ({{departmentName}}).", "log_retrain_collection": "{{name}} retrained the collection named {{collectionName}} in the {{datasetType}} named {{datasetName}}.", - "log_retry_evaluation_dataset_task": "【{{name}}】Retried evaluation dataset task in collection [{{collectionName}}]", - "log_retry_evaluation_task": "【{{name}}】Retried failed items in evaluation task [{{taskName}}]", - "log_retry_evaluation_task_item": "【{{name}}】Retried evaluation task item [{{itemId}}] from task [{{taskName}}]", + "log_retry_evaluation_dataset_task": "{{name}} retried evaluation dataset task in collection [{{collectionName}}]", + "log_retry_evaluation_task": "{{name}} retried failed items in evaluation task [{{taskName}}]", + "log_retry_evaluation_task_item": "{{name}} retried evaluation task item [{{itemId}}] from task [{{taskName}}]", "log_search_test": "{{name}} performed a search test in the {{datasetType}} named {{datasetName}}.", - "log_smart_generate_evaluation_data": "【{{name}}】Smart generated evaluation data in collection [{{collectionName}}]", + "log_smart_generate_evaluation_data": "{{name}} smart generated evaluation data in collection [{{collectionName}}]", "log_set_invoice_header": "{{name}} set the invoice header.", "log_time": "Time", "log_transfer_app_ownership": "{{name}} transferred ownership of the {{appType}} app ({{appName}}) from {{oldOwnerName}} to {{newOwnerName}}.", @@ -232,18 +232,18 @@ "log_update_data": "{{name}} updated data in the collection {{collectionName}} in the {{datasetType}} named {{datasetName}}.", "log_update_dataset": "{{name}} updated the {{datasetType}} named {{datasetName}}.", "log_update_dataset_collaborator": "{{name}} updated the collaborators (Organizations: {{orgList}}, Groups: {{groupList}}, Members: {{tmbList}}) and permissions ({{readPermission}}, {{writePermission}}, {{managePermission}}) of the {{datasetType}} named {{datasetName}}.", - "log_update_evaluation_dataset_collection": "【{{name}}】Updated evaluation dataset collection named [{{collectionName}}]", - "log_update_evaluation_dataset_data": "【{{name}}】Updated evaluation dataset data in collection [{{collectionName}}]", - "log_update_evaluation_task": "【{{name}}】Updated evaluation task named [{{taskName}}]", - "log_update_evaluation_task_item": "【{{name}}】Updated evaluation task item [{{itemId}}] from task [{{taskName}}]", - "log_start_evaluation_task": "【{{name}}】Started evaluation task named [{{taskName}}]", - "log_stop_evaluation_task": "【{{name}}】Stopped evaluation task named [{{taskName}}]", - "log_update_evaluation_metric": "【{{name}}】Updated evaluation metric [{{metricName}}]", - "log_debug_evaluation_metric": "【{{name}}】 debugged evaluation metric [{{metricName}}]", + "log_update_evaluation_dataset_collection": "{{name}} updated evaluation dataset collection named [{{collectionName}}]", + "log_update_evaluation_dataset_data": "{{name}} updated evaluation dataset data in collection [{{collectionName}}]", + "log_update_evaluation_task": "{{name}} updated evaluation task named [{{taskName}}]", + "log_update_evaluation_task_item": "{{name}} updated evaluation task item [{{itemId}}] from task [{{taskName}}]", + "log_start_evaluation_task": "{{name}} started evaluation task named [{{taskName}}]", + "log_stop_evaluation_task": "{{name}} stopped evaluation task named [{{taskName}}]", + "log_update_evaluation_metric": "{{name}} updated evaluation metric [{{metricName}}]", + "log_debug_evaluation_metric": "{{name}} debugged evaluation metric [{{metricName}}]", "log_update_publish_app": "{{name}} performed the operation ({{operationName}}) on the {{appType}} app ({{appName}}).", "log_user": "Operator", "login": "Log in", - "manage_member": "Manage member", + "manage_member": "Manage members", "member": "Member", "member_group": "Group", "move_app": "Move app", @@ -262,7 +262,7 @@ "permission_appCreate_tip": "Create apps in the root directory. (Permissions within folders are controlled by the folder.)", "permission_datasetCreate": "Create knowledge base", "permission_datasetCreate_Tip": "Create knowledge bases in the root directory. (Permissions within folders are controlled by the folder.)", - "permission_evaluationCreate": "Create Evaluation", + "permission_manage": "Admin", "permission_evaluationCreate_Tip": "Can create evaluation tasks, evaluation metrics and evaluation datasets", "permission_manage": "Administrator", "permission_manage_tip": "Manage members, create groups, manage all groups, and assign permissions to groups and members.", diff --git a/packages/web/i18n/en/account_thirdParty.json b/packages/web/i18n/en/account_thirdParty.json index f9bd1a1529db..ecb2ee145356 100644 --- a/packages/web/i18n/en/account_thirdParty.json +++ b/packages/web/i18n/en/account_thirdParty.json @@ -5,9 +5,9 @@ "laf_account": "LAF account", "no_intro": "No data available.", "not_configured": "Not configured", - "open_api_notice": "You can enter an OpenAI/OneAPI key. The key will be used for AI Conversation, Question Classification, and Content Extraction without extra charges. Make sure the key can be used to access the required models. You can choose FastAI as the GPT model.", + "open_api_notice": "You can enter an OpenAI/OneAPI key. The key will be used for AI chats, question classification, and content extraction without extra charges. Make sure the key can be used to access the required models. You can choose FastAI as the GPT model.", "openai_account_configuration": "OpenAI/OneAPI account", - "openai_account_setting_exception": "Failed to set OpenAI account.", + "openai_account_setting_exception": "Error occurred while configuring the OpenAI account.", "request_address_notice": "Request address. Default: official OpenAI address. You can enter a proxy address. Note that \"/v1\" will not be automatically appended to the address.", "third_party_account": "Third-party account", "third_party_account.configured": "Configured", diff --git a/packages/web/i18n/en/account_usage.json b/packages/web/i18n/en/account_usage.json index bd676f7bf751..ad96e0937098 100644 --- a/packages/web/i18n/en/account_usage.json +++ b/packages/web/i18n/en/account_usage.json @@ -1,62 +1,62 @@ { "ai_model": "AI model", - "all": "all", - "app_name": "Application name", - "auto_index": "Auto index", - "billing_module": "Deduction module", - "confirm_export": "A total of {{total}} pieces of data were filtered out. Are you sure to export?", - "count": "Number of runs", - "current_filter_conditions": "Current filter conditions", + "all": "All", + "app_name": "App name", + "auto_index": "Index enhancement", + "billing_module": "Billing module", + "confirm_export": "Are you sure you want to export the {{total}} matched entries?", + "count": "Runs", + "current_filter_conditions": "Current filters:", "dashboard": "Dashboard", "details": "Details", "dingtalk": "DingTalk", - "duration_seconds": "Duration (seconds)", - "embedding_index": "Embedding", - "evaluation": "Application Review", + "duration_seconds": "Duration (s)", + "embedding_index": "Index generation", + "evaluation": "App evaluation", "evaluation_dataset_data_quality_assessment": "Evaluation Dataset Data Quality Assessment", "evaluation_dataset_data_synthesis": "Evaluation Dataset Synthesis", "evaluation_dataset_data_qa_synthesis": "Evaluation Q&A Synthesis", "evaluation_quality_assessment": "Quality Assessment", "evaluation_debug_metric": "Debug Metric", - "every_day": "Day", - "every_month": "Moon", + "every_day": "days", + "every_month": "months", "every_week": "weekly", - "export_confirm": "Export confirmation", - "export_confirm_tip": "There are currently {{total}} usage records in total. Are you sure to export?", - "export_success": "Export successfully", - "export_title": "Time,Members,Type,Project name,AI points", + "export_confirm": "Confirm", + "export_confirm_tip": "Are you sure you want to export {{total}} usage records?", + "export_success": "Export successful.", + "export_title": "Time, Member, Type, Project name, AI points consumed", "feishu": "Feishu", - "generation_time": "Generation time", + "generation_time": "Time generated", "image_index": "Image index", - "image_parse": "Image tagging", - "input_token_length": "input tokens", - "llm_paragraph": "LLM segmentation", + "image_parse": "Image mark", + "input_token_length": "Input tokens", + "llm_paragraph": "Text segmentation", "mcp": "MCP call", - "member": "member", + "member": "Member", "metrics_execute": "Metrics Execute", "member_name": "Member name", - "module_name": "module name", - "month": "moon", - "no_usage_records": "No usage record yet", - "official_account": "Official Account", - "order_number": "Order number", - "output_token_length": "output tokens", + "module_name": "Module name", + "month": "months", + "no_usage_records": "No data available.", + "official_account": "WeChat official account", + "order_number": "Order ID", + "output_token_length": "Output tokens", "pages": "Pages", - "pdf_enhanced_parse": "PDF Enhanced Analysis", - "pdf_parse": "PDF Analysis", + "pdf_enhanced_parse": "Enhanced PDF parsing", + "pdf_parse": "PDF parsing", "points": "Points", "project_name": "Project name", - "qa": "QA", + "qa": "Q&A pair extraction", "select_member_and_source_first": "Please select members and types first", - "share": "Share Link", - "source": "source", - "text_length": "text length", - "token_length": "token length", - "total_points": "AI points consumption", - "total_points_consumed": "AI points consumption", - "total_usage": "Total Usage", - "usage_detail": "Details", - "user_type": "type", + "share": "Sharing link", + "source": "Source", + "text_length": "Text length", + "token_length": "Token length", + "total_points": "AI points consumed", + "total_points_consumed": "AI points consumed", + "total_usage": "Total consumption", + "usage_detail": "Usage details", + "user_type": "Type", "wecom": "WeCom", "evaluation_summary_generation": "Evaluation - Summary Generation" } diff --git a/packages/web/i18n/en/admin.json b/packages/web/i18n/en/admin.json new file mode 100644 index 000000000000..feaa914ea60f --- /dev/null +++ b/packages/web/i18n/en/admin.json @@ -0,0 +1,612 @@ +{ + "license_active_success": "Activated successfully.", + "system_activation": "System activation", + "system_activation_desc": "You need to activate FastGPT using a license first.", + "domain_name": "Current domain", + "input_license": "Enter the license key", + "cancel": "Cancel", + "confirm": "OK", + "config_desc": "Configuration description", + "domain_invalid": "The license domain is invalid.", + "change_license": "Change license", + "logout": "Logout", + "expire_time": "Expiration date", + "max_users": "Max users", + "max_apps": "Max apps", + "max_datasets": "Max knowledge bases", + "sso": "SSO", + "pay": "Payment system", + "custom_templates": "Custom templates and system tools", + "dataset_enhance": "Knowledge base enhancement", + "unlimited": "Unlimited", + "data_dashboard": "Data dashboard", + "notification_management": "Notification management", + "log_management": "Log management", + "user_management": "User management", + "user_info": "User info", + "team_management": "Team management", + "plan_management": "Plan management", + "payment_records": "Payment record", + "invoice_management": "Invoices", + "resource_management": "Resources", + "app_management": "App management", + "dataset_management": "Knowledge base management", + "system_config": "System settings", + "basic_config": "Basics", + "feature_list": "Features", + "security_review": "Security check", + "third_party_providers": "Third-party accounts", + "user_config": "User settings", + "plan_recharge": "Plans & top-up", + "template_tools": "Templates & tools", + "template_market": "Template market", + "toolbox": "Toolkit", + "audit_logs": "Audit logs", + "first_page": "First page", + "previous_page": "Back", + "next_page": "Next", + "last_page": "Last page", + "page": "Page", + "click_view_details": "View details", + "upload_image_failed": "Upload image failed", + "config_file": "Configuration file", + "save": "Save", + "upload_profile_failed": "Upload avatar failed", + "associated_plugin_is_empty": "Associated plugin cannot be empty", + "config_success": "Configuration successful.", + "confirm_delete_plugin": "Are you sure you want to delete the plugin?", + "delete_success": "Deleted successfully.", + "custom_plugin": "Custom plugin", + "config": "Settings", + "give_name": "Name", + "click_upload_avatar": "Upload profile image", + "app_name_empty": "App name is required.", + "description": "Description", + "add_app_description": "Add description for this app", + "associated_plugins": "Associated plugins", + "search_plugin": "Plugin name, app ID", + "attribute": "Attribute", + "author_name": "Author name", + "default_system_name": "System name is used by default", + "is_enable": "Enable", + "charge_token_fee": "Charge tokens", + "call_price": "API call cost (n points/call)", + "instructions": "User guide", + "use_markdown_syntax": "Use the Markdown syntax.", + "update": "Update", + "create_plugin": "Add plugin", + "delete_confirm_message": "Related resources will be deleted and cannot be restored. Would you like to proceed?", + "group_management": "Manage groups", + "total_groups": "Total groups: {localGroups.length}", + "add": "Add", + "add_type": "Add type", + "avatar_select_error": "Error occurred while selecting the profile image.", + "rename": "Rename", + "add_group": "Add group", + "avatar_name": "Profile image & name", + "click_set_avatar": "Set profile image", + "group_name_empty": "Group name is required.", + "official": "Official", + "configured": "Configured", + "not_configured": "Not configured", + "system_key_price": "System key price (n points/time)", + "plugin_config": "{{name}} configuration", + "enable_toolset": "Enable toolset", + "config_system_key": "Configure system key", + "tool_list": "Tool list", + "tool_name": "Tool name", + "key_price": "Key price", + "continue": "Continue", + "user_deleted_message": "This account has been deleted.", + "missing_field": "Required fields are missing.", + "team_not_exist": "The team does not exist.", + "subscription_exists": "A subscription of the same type already exists.", + "subscription_not_exist": "The subscription does not exist.", + "user_exist": "The user already exists.", + "account_logout": "Account logged out.", + "user_not_found": "User not found.", + "user_not_exist": "The user does not exist.", + "update_failed": "Update failed.", + "send_notification_success": "Notification sent successfully.", + "user_password_error": "User or password error!", + "invoice_not_found": "Invoice not found.", + "invoice_issued": "Invoice has been issued.", + "invoice_completed_notice": "The invoice you requested has been issued. Please check.", + "invoice_generation_completed": "Invoice issued successfully.", + "order_not_exist": "The order does not exist.", + "order_unpaid": "The order is unpaid.", + "order_invoiced_no_refund": "Refund is unavailable because the order has already been invoiced.", + "order_insufficient_amount": "The order amount is insufficient.", + "type_empty": "Type cannot be empty.", + "quantity_must_be_positive": "Quantity must be greater than 0.", + "team_not_found": "Team cannot be found.", + "dataset_training_rebuilding": "The dataset is being trained or rebuilt. Please try again later.", + "database_rebuilding_index": "Rebuild database index", + "type_not_support": "This message type is not supported.", + "send_verification_code_success": "Verification code sent successfully.", + "normal": "Normal", + "leave": "Leave", + "deactivated": "Disabled", + "account": "Account", + "username": "Username", + "contact": "Contact information", + "department": "Department", + "join_time": "Time added", + "update_time": "Last updated", + "status": "Status", + "team_num_limit_error": "A user can only have one team.", + "organization_name": "Organization", + "amount": "Amount", + "yuan": "CNY", + "pending_invoice_count": "Pending Invoice Count", + "go_to_view": "View now", + "new_invoice_application": "New invoice request received.", + "order_type_error": "The order type is invalid.", + "missing_key_params_update_bill_failed": "Failed to update the bill because required parameters are missing. Please contact the administrator.", + "plugin_service_link_not_configured": "Plugin service link not configured", + "audit_record_table": "Audit records", + "operator": "Operator", + "operation_type": "Operation", + "operation_time": "Time", + "operation_content": "Description", + "no_audit_records": "No data available.", + "audit_details": "Audit details", + "details": "Details", + "close": "Disable", + "total_users": "Total Users", + "registered_users": "Registered Users", + "payment_amount": "Total amount", + "order_count": "Orders", + "all": "All", + "success": "Successful", + "paid_teams": "Paid teams", + "total_conversations": "Total chats", + "total_sessions": "Total sessions", + "avg_conversations_per_session": "Average chats per session", + "points_consumed": "Points consumed", + "user_total": "Total users", + "dataset_total": "Total knowledge bases", + "app_total": "Total apps", + "statistics_data": "Statistics", + "traffic": "Traffic", + "payment": "Payment", + "active": "Active", + "cost": "Cost", + "last_7_days": "Last 7 days", + "last_30_days": "Last 30 days", + "last_90_days": "Last 90 days", + "last_180_days": "Last 180 days", + "confirm_modify_system_announcement": "Are you sure you want to change the system announcement?", + "confirm_send_system_notification": "Are you sure you want to send the system notification?", + "modify_success": "Modified successfully.", + "modify_failed": "Modification failed.", + "send_success": "Sent successfully.", + "send_failed": "Operation failed.", + "system_announcement_config": "System announcement", + "system_announcement_description": "The configured system announcement will appear in a pop-up window when users log in to FastGPT. The pop-up will not appear again after being closed. You can configure only 1 system announcement. Supported Markdown format.", + "send_system_notification": "System notification", + "confirm_send": "Send now", + "send_notification_description": "Send a notification to all users. Notification prompts vary by level.", + "message_level": "Message level", + "level_normal": "Moderate (platform system message only)", + "level_important": "Important (platform system message + login notification)", + "level_urgent": "Critical (platform system message + login notification + email/SMS alert)", + "notification_title": "Subject", + "notification_content": "Content", + "log_record_table": "Log Record Table", + "log_search_placeholder": "Enter log content to search, press Enter to search", + "time": "Time", + "log_content": "Log Content", + "no_log_records": "No data available.", + "log_level": "Log severity", + "log_detail": "Log details", + "log_message": "Log Message", + "login_success": "Login successful.", + "admin_login": "Admin login", + "login_username": "Username", + "login_password": "Password", + "login_button": "Login", + "app_list": "Apps", + "app_name": "App Name", + "creator": "Creator", + "redirect": "Redirect", + "app_no_records": "No data available.", + "app_details": "App details", + "app_id": "App ID", + "app_creator_id": "Creator ID", + "dataset_list": "Knowledge bases", + "dataset_name": "Knowledge base name", + "dataset_data_size": "Data Size", + "dataset_vector_count": "Total vectors", + "dataset_favorite_count": "Favorites", + "new": "New", + "name": "Name", + "enable": "Enable", + "redirect_link": "Redirect URL", + "operation": "Operation", + "add_sidebar_item": "Add sidebar item", + "sidebar_item_name": "Sidebar item name", + "sidebar_item_name_empty": "Sidebar item name is required.", + "redirect_link_empty": "Redirect URL is required.", + "edit_plan": "Edit {{label}} plan", + "plan_name": "Plan Name", + "custom_plan_name_desc": "Custom plan name (overwrites the default name)", + "monthly_price": "Monthly price", + "max_team_members": "Max team members", + "max_app_count": "Max apps", + "max_dataset_count": "Max Dataset Count", + "history_retention_days": "History retention (days)", + "max_dataset_index_count": "Max knowledge base indexes", + "monthly_ai_points": "Monthly AI points", + "training_priority_high": "Training priority (higher value for higher priority)", + "allow_site_sync": "Enable site sync", + "allow_team_operation_logs": "Enable team operation logs", + "click_configure_plan": "Configure plan", + "copy_success": "Copied successfully.", + "delete_confirm": "Are you sure you want to delete the variable?", + "delete": "Delete", + "workflow_variable_custom": "Custom workflow variables", + "field_name": "Variable name", + "usage_url": "Usage query URL", + "note": "Note", + "import_config": "Import config file", + "import_success_save": "Import successful. Please click Save to save the changes.", + "import_check_format": "Please check the configuration file format.", + "import": "Import", + "get_config_error": "Error occurred while loading configuration.", + "save_success": "Saved successfully.", + "save_failed": "Operation failed.", + "frontend_display_config": "Configure frontend view", + "personalization_config": "Personalization", + "global_script": "Global script", + "system_params": "System parameters", + "pdf_parse_config": "PDF parsing settings", + "usage_limits": "Usage limit", + "sidebar_config": "Sidebar settings", + "system_name": "System name", + "custom_api_domain": "Custom API domain", + "custom_api_domain_desc": "You can set an additional API address. If you do not use the address of the main site, you need to configure the domain’s CNAME and SSL certificate.", + "custom_share_link_domain": "Custom sharing link domain", + "custom_share_link_domain_desc": "You can set an additional sharing link. If you do not use the address of the main site, you need to configure the domain's CNAME and SSL certificate.", + "openapi_prefix": "OpenAPI prefix", + "contact_popup": "Contact Us pop-up", + "contact_popup_desc": "Configure the content for Contact us using the Markdown syntax.", + "custom_api_doc_url": "Custom API documentation URL", + "custom_openapi_doc_url": "Custom OpenAPI documentation URL", + "doc_url_note": "Documentation URL (It must end with a slash (/). Otherwise, users cannot be redirected to the correct subpaths.)", + "contribute_plugin_doc_url": "Guide for plugin developers", + "contribute_template_doc_url": "Guide for template developers", + "global_script_desc": "Custom script (inserted globally for site traffic monitoring or other purposes)", + "mcp_forward_service_url": "MCP forwarding service address", + "mcp_forward_service_desc": "Deploy an MCP forwarding service to expose FastGPT via the MCP protocol. Example: http://localhost:3005", + "oneapi_url": "OneAPI address (overwrites environment variable configuration)", + "oneapi_url_desc": "OneAPI address (overwrites environment variable configuration)", + "input_oneapi_url": "OneAPI address, used for accessing multiple models", + "oneapi_key": "OneAPI key (overwrites environment variable configuration)", + "input_oneapi_key": "Enter the OneAPI address", + "dataset_index_max_process": "Max indexing processes for knowledge base", + "file_understanding_max_process": "Max processes for file understanding model", + "image_understanding_max_process": "Max processes for image understanding model", + "hnsw_ef_search": "HNSW ef_search", + "hnsw_ef_search_desc": "HNSW parameter. A higher value improves recall but reduces performance. Default value: 100. For details, see https://github.com/pgvector/pgvector", + "hnsw_max_scan_tuples": "HNSW max_scan_tuples", + "hnsw_max_scan_tuples_desc": "Maximum iterations. A higher value improves recall but reduces performance. Default value: 100000. For details, see https://github.com/pgvector/pgvector", + "token_calc_max_process": "Max token computation processes (set according to expected concurrency))", + "custom_pdf_parse_url": "Custom PDF parsing URL", + "custom_pdf_parse_key": "Custom PDF parsing key", + "doc2x_pdf_parse_key": "Doc2x PDF parsing key (lower priority than custom PDF parsing)", + "custom_pdf_parse_price": "Custom PDF parsing price (n points/page)", + "max_upload_files_per_time": "Max files per upload", + "max_upload_files_per_time_desc": "Maximum number of files per upload to the knowledge base", + "max_upload_file_size": "Max file size (MB)", + "max_upload_file_size_desc": "Maximum file size per upload to the knowledge base If you set the max file size to a large value, ensure the gateway is configured accordingly.", + "export_interval_minutes": "Export interval (minutes)", + "site_sync_interval_minutes": "Site sync interval (minutes)", + "mobile_sidebar_location": "On mobile devices, the sidebar is displayed in Account > Personal.", + "basic_features": "Basic features", + "third_party_knowledge_base": "Third-party knowledge bases", + "third_party_publish_channels": "Third-party publishing channels", + "feature_display_config": "Feature display", + "display_team_sharing": "Show content shared by team members", + "display_chat_blank_page": "Show empty chat page (disable all to hide)", + "display_invite_friends_activity": "Show invite-friends activities", + "frontend_compliance_notice": "Show compliance notice on frontend", + "feishu_knowledge_base": "Feishu", + "feishu_knowledge_base_desc": "If disabled, Feishu will be unavailable when users create knowledge bases.", + "yuque_knowledge_base": "Yuque", + "yuque_knowledge_base_desc": "If disabled, Yuque will be unavailable when users create knowledge bases.", + "feishu_publish_channel": "Feishu", + "feishu_publish_channel_desc": "If disabled, Feishu will no longer be displayed for publishing channels.", + "dingtalk_publish_channel": "DingTalk", + "dingtalk_publish_channel_desc": "If disabled, DingTalk will no longer be displayed for publishing channels.", + "wechat_publish_channel": "WeChat Official Account", + "wechat_publish_channel_desc": "If disabled, WeChat Official Account will no longer be displayed for publishing channels.", + "content_security_review": "Content security check", + "baidu_security_id": "Baidu security ID", + "baidu_security_secret": "Baidu security secret", + "custom_security_check_url": "Custom security check URL", + "baidu_security_register_desc": "注册百度安全校验账号,并创建对应应用。提供应用的 id 和 secret", + "custom_security_check_desc": "If you have your own security verification service, enter the address here and enable Sensitive content check on FastGPT.", + "plan_free": "Free edition", + "plan_trial": "Trial edition", + "plan_team": "Team edition", + "plan_enterprise": "Enterprise edition", + "subscription_plan": "Subscription plan", + "standard_subscription_plan": "Standard subscription plan", + "custom_plan_description": "Custom plan description", + "dataset_storage_cost_desc": "Knowledge base storage fee (xx CNY/1,000 entries/month)", + "extra_ai_points_cost_desc": "Extra AI point cost (xx CNY/1000 points/month)", + "payment_method": "Payment method", + "wechat_payment_config": "WeChat payment settings", + "alipay_payment_config": "Alipay payment settings", + "corporate_payment_message": "Corporate payment notification", + "enable_subscription_plan": "Enable subscription plan", + "custom_plan_page_description": "The specified address will overwrite the address of the subscription plan page. Users will be redirected to this address when accessing the subscription plan page.", + "wechat_payment_materials": "WeChat payment documents", + "wechat_payment_registration_guide": "自行注册微信支付,目前需要wx扫码支付", + "unused_field_placeholder": "Fill in anything", + "certificate_management_guide": "Click Manage to obtain", + "wechat_key_extraction_guide": "Follow WeChat instructions to obtain the files, and open the key file with a text editor to get the private key.", + "alipay_payment_materials": "Alipay payment documents", + "alipay_application_guide": "自行注册支付宝应用,目前需要开通电脑网站支付", + "alipay_certificate_encryption_guide": "Use a certificate for API signing. For details, see", + "application_public_key_certificate": "App public key certificate", + "private_key_document_reference": "Refer to the above documentation for retrieving the private key", + "alipay_root_certificate": "Alipay root certificate", + "alipay_public_key_certificate": "Alipay public key certificate", + "alipay_dateway": "Alipay gateway", + "alipay_gateway_sandbox_note": "Enter the Alipay gateway URL. Use the sandbox environment for testing.", + "alipay_gateway_production_note": ",而生成环境是", + "alipay_endpoint_sandbox_note": "Enter the Alipay endpoint URL. Use the sandbox environment for testing.", + "message_notification": "Message", + "markdown_format_support": "Supported format: Markdown", + "third_party_account_config": "Third-party accounts", + "allow_user_account_config": "Allow users to configure accounts", + "view_documentation": "View documentation", + "openai_oneapi_account": "OpenAI/OneAPI account", + "laf_account": "LAF account", + "input_laf_address": "Enter the LAF address", + "multi_team_mode": "Multi-team mode", + "single_team_mode": "Single team mode", + "sync_mode": "Sync mode", + "notification_login_settings": "Notifications & login settings", + "team_mode_settings": "Team mode settings", + "custom_user_system_config": "Custom user system settings", + "email_notification_config": "Email notification settings (registration and plan notifications)", + "aliyun_sms_config": "Alibaba Cloud SMS settings", + "aliyun_sms_template_code": "Alibaba Cloud SMS template code (SMS_xxx)", + "wechat_service_login": "WeChat service account login", + "github_login_config": "GitHub login settings", + "google_login_config": "Google login settings", + "microsoft_login_config": "Microsoft login settings", + "quick_login": "Quick login (not recommended)", + "login_notifications_config": "Login notifications & settings", + "user_service_root_address": "User service root URL (Do not add a trailing slash (/).)", + "sso_usage_guide": "For details, see SSO & External Member Sync", + "sso_login_button_title": "SSO button name", + "config_sso_login_button_title": "Configure SSO button name", + "sso_login_button_icon": "SSO button icon", + "config_sso_login_button_icon": "Configure SSO button icon", + "sso_auto_redirect": "Auto redirection for SSO", + "sso_auto_redirect_desc": "If enabled, SSO will be automatically triggered when users enter the login page.", + "email_smtp_address": "SMTP address", + "email_smtp_address_note": "Varies by vendor.", + "email_smtp_username": "SMTP username", + "email_smtp_username_example": "Example: A QQ email address corresponds to a QQ account.", + "email_password": "Email password", + "email_smtp_auth_code": "SMTP authorization code", + "enable_email_registration": "Enable email registration", + "aliyun_sms_params": "阿里云短信参数", + "aliyun_sms_apply_guide": "申请对应的签名和短信模板,提供:", + "signature_name": "Signature", + "template_code_sm": "模板CODE,SM开头的", + "aliyun_secret_key": "Alibaba Cloud account secret key", + "sms_signature": "SMS signature", + "registration_account": "Sign up", + "registration_account_desc": "If configured, registration with mobile number will be enabled.", + "reset_password": "Reset password", + "reset_password_desc": "If configured, password retrieval using mobile number will be enabled.", + "bind_notification_phone": "Mobile number to receive notifications", + "bind_notification_phone_desc": "If configured, notifications will be sent to the specified mobile number.", + "subscription_expiring_soon": "The subscription plan is about to expire.", + "subscription_expiring_soon_desc": "If configured, an SMS message will be sent when the plan is about to expire.", + "free_user_cleanup_warning": "Knowledge base cleanup alert for free edition users", + "wechat_service_appid": "App ID of WeChat service account. Enter the business edition domain for the verification address:", + "wechat_service_secret": "Secret of WeChat service account.", + "register_one": "register one", + "provide": "provide", + "domain": "domain", + "microsoft_app_client_id": "App (client) ID of Microsoft app", + "microsoft_tenant_id": "Tenant ID of Microsoft app (leave this field blank to use the default value common)", + "custom_button_name": "Custom button name", + "custom_button_name_desc": "Custom button name. If left blank, the default Microsoft button name will be used.", + "simple_app": "Simple app", + "workflow": "Workflow", + "plugin": "Plugin", + "folder": "Folder", + "http_plugin": "HTTP plugin", + "toolset": "Tool set", + "tool": "Tools", + "hidden": "Hideen", + "select_json_file": "Please select a JSON file.", + "confirm_delete_template": "Are you sure you want to delete the template?", + "upload_config_first": "Please upload a configuration file first.", + "app_type_not_recognized": "Unrecognized app type", + "config_json_format_error": "Invalid file format. The configuration file must be in JSON format.", + "template_update_success": "Template updated successfully.", + "template_create_success": "Template created successfully.", + "template_config": "Template settings", + "json_serialize_failed": "JSON serialization failed.", + "get_app_type_failed": "Failed to obtain application type", + "file_overwrite_content": "The file will overwrite the existing content.", + "config_file_label": "Configuration file", + "official_config": "Official configuration", + "upload_file": "Upload file", + "paste_config_or_drag_json": "Paste configuration or drag and drop a JSON file.", + "paste_config": "Paste configuration", + "app_type": "App type", + "auto_recognize": "Auto recognition", + "app_attribute_not_recognized": "Unrecognized app attribute", + "text": "Text", + "link": "Link", + "input_link": "Enter a link", + "settings_successful": "Settings saved successfully.", + "configure_quick_templates": "Configure quick template", + "search_apps": "App name", + "selected_count": "Selected: {{count}} / 3", + "category_management": "Manage categories", + "total_categories": "Total categories: {{length}}", + "add_category": "Add category", + "category_name": "Category name", + "category_name_empty": "Category name is required.", + "template_list": "Templates", + "quick_template": "Quick template", + "add_template": "Add template", + "recommended": "Recommended", + "no_templates": "No templates available.", + "add_attribute_first": "Please add attributes first.", + "add_plugin": "Add plugin", + "token_points": "Token points", + "token_fee_description": "If enabled, users need to pay token points and API call points to use the plugin.", + "call_points": "API call points", + "system_key": "System secret key", + "system_key_description": "对于需要密钥的工具,您可为其配置系统密钥,用户可通过支付积分的方式使用系统密钥。", + "no_plugins": "No plugins available.", + "invoice_application": "Invoice requests", + "search_user_placeholder": "Username", + "submit_status": "Request status", + "submit_complete_time": "Time requested/issued", + "invoice_title": "Invoice title", + "waiting_for_invoice": "Waiting for invoicing", + "completed": "Completed", + "confirm_invoice": "Confirm", + "no_invoice_records": "No data available.", + "invoice_details": "Invoice details", + "invoice_amount": "Invoice amount", + "organization": "Organization", + "unified_credit_code": "Unified social credit code", + "company_address": "Company address", + "company_phone": "Company phone", + "bank_name": "Bank name", + "bank_account": "Account number", + "need_special_invoice": "Require VAT invoice", + "yes": "Yes", + "no": "No", + "email_address": "Email address", + "invoice_file": "Invoice file", + "click_download": "Download", + "operation_success": "Success", + "operation_failed": "Error", + "upload_invoice_pdf": "请上传发票的PDF文件", + "select_invoice_file": "选择发票文件", + "confirm_submission": "Confirm submission", + "balance_recharge": "Balance top-up", + "plan_subscription": "Plan subscription", + "knowledge_base_expansion": "Knowledge base expansion", + "ai_points_package": "AI point package", + "monthly": "Monthly", + "yearly": "Yearly", + "free": "Free", + "trial": "Trial", + "team": "Team", + "enterprise": "Enterprise", + "custom": "Custom", + "wechat": "WeChat", + "balance": "Balance", + "alipay": "Alipay", + "corporate": "Corporate payment", + "redeem_code": "Redeem code", + "search_user": "Username", + "team_id": "Team ID", + "recharge_member_name": "Member", + "unpaid": "Unpaid", + "no_bill_records": "No data available.", + "order_details": "Order details", + "order_number": "Order ID", + "generation_time": "Time generated", + "order_type": "Order type", + "subscription_period": "Periodic", + "subscription_package": "Subscription plan", + "months": "Months", + "extra_knowledge_base_capacity": "Extra knowledge base capacity", + "extra_ai_points": "Extra AI points", + "time_error": "Start time cannot be later than end time.", + "points_error": "The remaining points cannot be greater than the total points.", + "add_success": "Added successfully.", + "add_package": "Add plan", + "package_type": "Plan type", + "basic_package": "Basic plan", + "start_time": "Start time", + "required": "*Required", + "end_time": "End time", + "package_level": "Plan level", + "total_points": "Total points", + "remaining_points": "Remaining points", + "price_yuan_for_record_only": "Price (CNY) (for record only)", + "price": "Unit price", + "update_success": "Update successful.", + "edit": "Edit", + "edit_package": "Edit plan", + "value_override_description": "The following values will overwrite default values in plan configuration. If unspecified, plan defaults will be used.", + "team_member_limit": "Max team members", + "app_limit": "Max apps", + "knowledge_base_limit": "Max knowledge bases", + "team_name": "Team name", + "points": "Points", + "start_end_time": "Period", + "version": " edition", + "extra_knowledge_base": "Extra knowledge base", + "no_package_records": "No data available.", + "role": "Permission", + "team_details": "Team details", + "chage_success": "Change saved successfully.", + "team_edit": "Edit team", + "team_list": "Teams", + "create_time": "Time created", + "no_team_data": "No data available.", + "add_user": "Add user", + "password": "Password", + "password_requirements": "Password must be at least 8 characters long and contain at least 2 of the following: digits, letters, and special characters.", + "account_deactivated": "Account logged out.", + "edit_user": "Edit user", + "user_status": "User status", + "confirm_deactivate_account": "Are you sure you want to deactivate the account? Key resources of the account will be deleted, and the username will be changed to xx-deleted.", + "deactivate": "Log out", + "no_user_records": "No data available.", + "user_details": "User details", + "system_incompatibility": "Page crashed due to system incompatibility. If possible, contact the author with the operation and page details. Most cases involve Safari. Please use Chrome instead.", + "content_not_compliant": "Your content is not compliant with requirements.", + "baidu_content_security_check_exception": "Error occurred during Baidu content security check.", + "license_not_read": "Failed to read licensing information", + "license_invalid": "The license is invalid.", + "license_expired": "The license has expired.", + "license_content_error": "The license content is incorrect", + "system_not_activated": "System not activated.", + "exceed_max_users": "The number of users exceeded the maximum.", + "server_error": "Server error occurred.", + "request_error": "Request failed.", + "unknown_error": "Unknown error occurred.", + "token_expired_relogin": "Token expired. Please log in again.", + "google_verification_result": "Google verification result", + "abnormal_operation_environment": "Your operating environment is abnormal. Please refresh the page and try again, or contact Custom Service.", + "notify_expiring_packages": "Notification for expiring plans", + "notify_free_users_cleanup": "Notification for knowledge base cleanup to free edition users", + "bing_oauth_config_incomplete": "Incomplete Bing OAuth configuration.", + "request_limit_per_minute": "Max requests per minute", + "share_link_expired": "The sharing link has expired.", + "link_usage_limit_exceeded": "The link has exceeded its usage limit.", + "authentication_failed": "Identity verification failed.", + "user_registration": "Register", + "initial_password": "Your initial password is", + "notification_too_frequent": "Too many attempts.", + "emergency_notification_requires_teamid": "Team ID is required for critical notifications.", + "send_sms_failed": "Failed to send SMS message.", + "fastgpt_user": "FastGPT user", + "refund_failed": "Refund failed.", + "refund_request_failed": "Refund request failed.", + "get_certificate_list_failed": "Failed to obtain the certificate list.", + "platform_certificate_serial_mismatch": "Certificate serial numbers do not match.", + "extra_knowledge_base_storage": "Extra knowledge base storage", + "image_compression_error": "Image compression error occurred.", + "image_too_large": "The image size is too large." +} \ No newline at end of file diff --git a/packages/web/i18n/en/app.json b/packages/web/i18n/en/app.json index 89c2d149ac1d..184949051307 100644 --- a/packages/web/i18n/en/app.json +++ b/packages/web/i18n/en/app.json @@ -1,5 +1,5 @@ { - "AutoOptimize": "Automatic optimization", + "AutoOptimize": "Auto optimize", "Click_to_delete_this_field": "Click to delete field", "Filed_is_deprecated": "The field has been deprecated.", "Index": "Index", @@ -11,24 +11,24 @@ "MCP_tools_parse_failed": "Failed to parse the MCP address.", "MCP_tools_url": "MCP address", "MCP_tools_url_is_empty": "MCP address is required.", - "MCP_tools_url_placeholder": "Enter the MCP address and then click Parse.", + "MCP_tools_url_placeholder": "Enter the MCP address and then click Parse", "No_selected_dataset": "No knowledge bases selected.", - "Optimizer_CloseConfirm": "Confirm to close", - "Optimizer_CloseConfirmText": "Optimization results have been generated, confirming that closing will lose the current result. Will it continue?", + "Optimizer_CloseConfirm": "Confirm", + "Optimizer_CloseConfirmText": "Closing the pop-up will discard the optimization result. Would you like to proceed?", "Optimizer_EmptyPrompt": "Please enter optimization requirements", "Optimizer_Generating": "Generating...", - "Optimizer_Placeholder": "How do you want to write or optimize prompt words?", - "Optimizer_Placeholder_loading": "Generating...please wait", - "Optimizer_Reoptimize": "Re-optimize", - "Optimizer_Replace": "replace", - "Optimizer_Tooltip": "AI optimization prompt words", + "Optimizer_Placeholder": "Requirements for optimization", + "Optimizer_Placeholder_loading": "Generating, please wait.", + "Optimizer_Reoptimize": "Optimize again", + "Optimizer_Replace": "Use", + "Optimizer_Tooltip": "Optimize it with AI", "Role_setting": "Permissions", "Run": "Run", "Search_dataset": "Knowledge base", "Selected": "Selected", "Team_Tags": "Team tag", "ai_point_price": "Billing based on AI points", - "ai_settings": "AI configuration", + "ai_settings": "AI settings", "all_apps": "All apps", "app.Version name": "Version name", "app.error.publish_unExist_app": "Failed to publish the app. Please check whether the tool is called properly.", @@ -40,36 +40,36 @@ "app.version_initial": "Initial version", "app.version_name_tips": "Version name is required.", "app.version_past": "Published", - "app.version_publish_tips": "The version will be saved to the cloud and be available to all team members. The app version for all publishing channels will be updated to this version.", + "app.version_publish_tips": "This version will be saved to the cloud, and be updated to all team members and all publishing channels.", "app_detail": "App details", "auto_execute": "Auto execution", - "auto_execute_default_prompt_placeholder": "Default questions sent during auto execution", - "auto_execute_tip": "If enabled, the workflow will be automatically executed after users enter the chat interface. Execution order: 1. Chat greetings. 2. Global variables. 3. App auto execution.", + "auto_execute_default_prompt_placeholder": "Default questions will be sent during auto execution.", + "auto_execute_tip": "If enabled, the workflow will be automatically executed when users enter the chat interface. Execution order: 1. Opening remarks. 2. Global variables. 3. App auto execution.", "auto_save": "Auto save", - "chat_debug": "Debugging preview", - "chat_logs": "Chat logs", - "chat_logs_tips": "The online chats, shared chats, and API-based chats (chat ID is required) on the app will be recorded in the logs.", - "config_ai_model_params": "Configure AI model attributes", - "config_file_upload": "Configure file upload rules", - "config_question_guide": "Configure the guess what you want feature", + "chat_debug": "Debug & preview", + "chat_logs": "Logs", + "chat_logs_tips": "The logs record online chats, shared chats, and API-based chats (chat ID is required) in the app.", + "config_ai_model_params": "Click to configure AI model attributes", + "config_file_upload": "Click to configure file upload limit", + "config_question_guide": "Click to configure", "confirm_copy_app_tip": "An app with the same configuration except for permissions will be created. Would you like to proceed?", "confirm_del_app_tip": "Are you sure you want to delete {{name}} and its chat records?", "confirm_delete_folder_tip": "Are you sure you want to delete the folder? All apps and chat records in the folder will also be deleted. Would you like to proceed?", - "copy_one_app": "Create replica", - "core.app.QG.Switch": "Enable guess what you want", + "copy_one_app": "Duplicate", + "core.app.QG.Switch": "Status", "core.dataset.import.Custom prompt": "Custom prompt", "create_by_curl": "CURL", "create_by_template": "Template", - "create_copy_success": "Replica created successfully.", + "create_copy_success": "Duplicated successfully.", "create_empty_app": "Create blank app", "create_empty_plugin": "Create blank plugin", "create_empty_workflow": "Create blank workflow", - "cron.every_day": "Every day", - "cron.every_month": "Every month", - "cron.every_week": "Every week", + "cron.every_day": "Daily", + "cron.every_month": "Monthly", + "cron.every_week": "Weekly", "cron.interval": "Other", "dataset": "Knowledge base", - "dataset_search_tool_description": "调用语义检索、全文检索、数据库检索能力,从“知识库”中查找可能与问题相关的参考内容", + "dataset_search_tool_description": "Search for reference materials related to the question from the knowledge base by using the semantic search and full-text search features. The tool is prioritized to help answer questions.", "day": "Day", "deleted": "The app has been deleted.", "document_quote": "Document reference", @@ -77,20 +77,20 @@ "document_upload": "Document upload", "edit_app": "App details", "edit_info": "Edit info", - "execute_time": "Time executed", + "execute_time": "Schedule", "export_config_successful": "The configuration has been copied, and some sensitive information has been automatically filtered out. Please check if any sensitive data still exists.", - "export_configs": "Export configuration", - "feedback_count": "User feedback", + "export_configs": "Export", + "feedback_count": "Feedback", "file_quote_link": "File link", "file_recover": "The file will overwrite the existing content.", "file_upload": "File upload", - "file_upload_tip": "If enabled, you can upload documents and images. Documents are retained for 7 days, and images for 15 days. Using this feature may generate significant additional costs. To ensure optimal user experience, when this feature is enabled, please select an AI model with a larger context length.", + "file_upload_tip": "If enabled, you can upload documents and images. Documents are retained for 7 days, while images for 15 days. Using this feature may generate significant additional costs. To ensure optimal user experience, please select an AI model with a larger context length support.", "go_to_chat": "Chat now", "go_to_run": "Run now", "image_upload": "Image upload", - "image_upload_tip": "How to enable image recognition for a model", - "import_configs": "Import configuration", - "import_configs_failed": "Failed to import the configuration. Please make sure that the configuration is valid.", + "image_upload_tip": "How to enable image recognition", + "import_configs": "Import", + "import_configs_failed": "Failed to import the configuration. Please make sure that the configuration file is valid.", "import_configs_success": "Import successful.", "initial_form": "Initial status", "interval.12_hours": "Every 12 hours", @@ -101,20 +101,20 @@ "interval.per_hour": "Every hour", "invalid_json_format": "Please upload a valid JSON file.", "keep_the_latest": "Auto update to the latest version", - "llm_not_support_vision": "This model does not support image recognition.", + "llm_not_support_vision": "Not supported by the current AI model", "llm_use_vision": "Image recognition", - "llm_use_vision_tip": "You can select a model and then check whether it supports image recognition and decide whether to enable this feature. If the image recognition feature is enabled, the model will read image contents from file links. If a question is less than 500 characters, the model will automatically parse images in the question.", - "log_chat_logs": "Chat logs", - "log_detail": "Log details", - "logs_app_data": "Data dashboard", - "logs_app_result": "App performance", + "llm_use_vision_tip": "Select a model and then check whether it supports image recognition and decide whether to enable this feature. If it is enabled, the model will read image contents from file links, and automatically parse images in the question if a question is less than 500 characters.", + "log_chat_logs": "Dashboard & Logs", + "log_detail": "Logs", + "logs_app_data": "Dashboard", + "logs_app_result": "App analysis", "logs_average_response_time": "Avg uptime (s)", "logs_average_response_time_description": "Avg total uptime of workflow", - "logs_chat_count": "Sessions", - "logs_chat_count_description": "The total number of new sessions created in the app. A new session is created if the interval between the new message and the last message exceeds 15 minutes. (This definition only applies here.)", - "logs_chat_data": "Chat data", + "logs_chat_count": "Conversations", + "logs_chat_count_description": "The total number of new conversations created in the app. A new conversation is created if the interval between the new message and the last message exceeds 15 minutes. (This definition only applies here.)", + "logs_chat_data": "Chat analysis", "logs_chat_item_count": "Chats", - "logs_chat_item_count_description": "The total number of chats created in the app. A chat is a round of workflow execution.", + "logs_chat_item_count_description": "The total number of chats created in the app. A chat is a round of workflow execution.", "logs_chat_user": "User", "logs_date": "Date", "logs_empty": "No logs available.", @@ -122,34 +122,34 @@ "logs_error_rate": "Chat error rate", "logs_error_rate_description": "The proportion of chats that encountered error to total chats", "logs_export_confirm_tip": "The total number of chat records is {{total}}. Up to 100 latest messages can be exported from a chat. Would you like to proceed?", - "logs_export_title": "Time, Source, User, Contact info, Title, Total messages, Positive feedback, Negative feedback, Custom feedback, Marked answers, Chat details", + "logs_export_title": "Time, Source, User, Contact info, Topic, Total messages, Positive feedback, Negative feedback, Custom feedback, Marked answers, Chat details", "logs_good_feedback": "Like", - "logs_key_config": "Field configuration", + "logs_key_config": "Fields", "logs_keys_annotatedCount": "Marked answers", - "logs_keys_chatDetails": "Conversation details", + "logs_keys_chatDetails": "Chat details", "logs_keys_createdTime": "Time created", "logs_keys_customFeedback": "Custom feedback", "logs_keys_errorCount": "Errors", - "logs_keys_feedback": "User feedback", + "logs_keys_feedback": "Feedback", "logs_keys_lastConversationTime": "Last chat time", "logs_keys_messageCount": "Total messages", "logs_keys_points": "Points consumed", "logs_keys_responseTime": "Avg response time", - "logs_keys_sessionId": "Session ID", + "logs_keys_sessionId": "Conversation ID", "logs_keys_source": "Source", - "logs_keys_title": "Title", + "logs_keys_title": "Topic", "logs_keys_user": "User", "logs_message_total": "Total messages", "logs_new_user_count": "New users", "logs_points": "Points consumed", "logs_points_description": "Points consumed by the app", - "logs_points_per_chat": "Avg points consumed per session", + "logs_points_per_chat": "Avg points consumed per conversation", "logs_points_per_chat_description": "Avg points consumed per workflow execution", "logs_response_time": "Avg response time", - "logs_search_chat": "Session title or ID", + "logs_search_chat": "Conversation topic or ID", "logs_source": "Source", - "logs_source_count_description": "Users by channel", - "logs_title": "Title", + "logs_source_count_description": "Users by publishing channel", + "logs_title": "Topic", "logs_total": "Total", "logs_total_avg_points": "Avg points consumed", "logs_total_chat": "Total chats", @@ -158,40 +158,40 @@ "logs_total_tips": "Cumulative metrics are not affected by the time filter.", "logs_total_users": "Total users", "logs_user_count": "Users", - "logs_user_count_description": "The number of users who created chats in the app in the period", - "logs_user_data": "User data", - "logs_user_feedback": "User feedback", + "logs_user_count_description": "The number of daily users who created chats in the app", + "logs_user_data": "User analysis", + "logs_user_feedback": "Feedback", "logs_user_feedback_description": "Likes: Number of user likes\nDislikes: Number of user dislikes", "logs_user_retention": "Retained users", "logs_user_retention_description": "The number of new users in period T who were active in period T+1", - "look_ai_point_price": "View billing standard by model", + "look_ai_point_price": "View billing standard for models", "manual_secret": "Temporary secret key", "mark_count": "Marked answers", "max_histories_number": "Chats remembered", - "max_histories_number_tip": "The maximum number of chats that a model can remember. If a chat exceeds the max context length, the system will automatically delete the excess part. Therefore, the model may not actually remember 30 chats even if you set this field to 30.", + "max_histories_number_tip": "The maximum number of chats that a model can remember. If a chat exceeds the max context length, the system will automatically delete the excess part. Therefore, the model may not actually remember 30 chats even if this field is set to 30.", "max_tokens": "Max tokens", - "module.Custom Title Tip": "The title will be displayed in the chat.", + "module.Custom Title Tip": "The topic will be displayed in the chat.", "module.No Modules": "Plugin not found.", "module.type": "{{type}} type\n{{description}}", "modules.Title is required": "Module name is required.", "month.unit": "Day", - "move.hint": "If moved, the app or folder will inherit the permissions of the destination folder, and its current permissions will become invalid.", - "move_app": "Move app", + "move.hint": "If moved, the app or folder will inherit the permissions of the destination folder, invalidating its current permissions.", + "move_app": "Move", "no_mcp_tools_list": "No data available. The MCP address must be parsed first.", - "node_not_intro": "No description available for this node.", + "node_not_intro": "No description available.", "not_json_file": "Please select a JSON file.", "not_the_newest": "Earlier version", - "oaste_curl_string": "Enter CURL code", - "open_auto_execute": "Enable auto execute", - "open_vision_function_tip": "A model with a switch displayed supports image recognition. If enabled, the model will parse images from file links and will automatically parse images in the question (it takes effect when the question is less than 500 characters).", + "oaste_curl_string": "Enter CURL command", + "open_auto_execute": "Status", + "open_vision_function_tip": "A model with a switch toggled on, as shown below, supports image recognition. If enabled, the model will parse images from file links and images in the questions (it takes effect when a question is less than 500 characters).", "or_drag_JSON": "Drag & drop to upload JSON file", "paste_config_or_drag": "Paste configuration or drag & drop a JSON file here", "pdf_enhance_parse": "Enhanced PDF parsing", "pdf_enhance_parse_price": "{{price}} points/page", - "pdf_enhance_parse_tips": "PDF recognition model supports parsing PDF files, converting PDF files into Markdown format with images preserved, and processing scanned copies of PDF files, which takes a longer time.", - "permission.des.manage": "Has write permissions and can configure publishing channels, view chat logs, and assign app permissions.", + "pdf_enhance_parse_tips": "Call a PDF recognition model to parse PDF files, converting PDF files into Markdown format with images preserved, and process scanned copies of PDF files, which takes a longer time.", + "permission.des.manage": "Has write permission and can configure publishing channels, view chat logs, and assign app permissions.", "permission.des.read": "Chat using the app.", - "permission.des.readChatLog": "View chat logs.", + "permission.des.readChatLog": "View chat logs", "permission.des.write": "View and edit the app.", "permission.name.read": "Chat only", "permission.name.readChatLog": "View chat logs", @@ -200,57 +200,57 @@ "plugin_cost_folder_tip": "The toolkit contains multiple tools. The points are charged based on the tools used.", "plugin_cost_per_times": "{{cost}} points/call", "plugin_dispatch": "Plugin call", - "plugin_dispatch_tip": "Enable the model to obtain external data. The model will automatically call plugins as needed, and all plugins will run in non-streaming mode.\nSelected plugins will be automatically called when the knowledge base is called.", + "plugin_dispatch_tip": "Enable the model to obtain external data. The model will automatically call plugins as needed, with all plugins running in non-streaming mode.\nSelected plugins will be automatically called when the knowledge base is called.", "pro_modal_feature_1": "Connection to external organizations and multi-tenancy", "pro_modal_feature_2": "Dedicated app page for team", - "pro_modal_feature_3": "Enhanced knowledge base index", + "pro_modal_feature_3": "Enhanced knowledge base indexing", "pro_modal_later_button": "Not now", - "pro_modal_subtitle": "Subscribe to the enterprise edition for advanced features", - "pro_modal_title": "Exclusive to enterprise edition", + "pro_modal_subtitle": "Subscribe to the commercial edition for advanced features", + "pro_modal_title": "Exclusive to commercial edition", "pro_modal_unlock_button": "Subscribe", - "publish_channel": "Publishing channel", + "publish_channel": "Publishing channels", "publish_success": "Published successfully.", "question_guide_tip": "Three suggested questions will be generated for you at the end of the chat.", - "reasoning_response": "Output reasoning process", + "reasoning_response": "Output reasoning", "response_format": "Response format", - "save_team_app_log_keys": "Save as team configuration", - "saved_success": "Changes saved successfully. Changes saved successfully. To make the version available externally, please click Save and publish.", + "save_team_app_log_keys": "Save as team config", + "saved_success": "Changes saved successfully. To make the version available to others, please click Save and publish.", "search_app": "App name", "search_tool": "Tool name", "secret_get_course": "Guide", - "setting_app": "Application configuration", - "setting_plugin": "Plugin configuration", + "setting_app": "App settings", + "setting_plugin": "Plugin settings", "show_top_p_tip": "Nucleus sampling is an alternative to temperature sampling. By using this method, the model considers only tokens whose cumulative probability exceeds p. For example, if p is set to 0.1, the model will consider only tokens with the highest cumulative probability. Default value: 1.", "simple_tool_tips": "The plugin cannot be called by simple apps because it contains special inputs.", "source_updateTime": "Time updated", "stop_sign": "Stop sequence", "stop_sign_placeholder": "Separate multiple sequences with the pipe character (|). Example: aaa|stop", - "stream_response": "Output in streaming mode", - "stream_response_tip": "If disabled, the model must output contents in non-streaming mode, and the contents will not be output directly. You can obtain the output contents for further processing.", - "sync_log_keys_popover_text": "The field configuration will take effect only on your account. Would you like to save it as team configuration?", - "sync_team_app_log_keys": "Restore team configuration", + "stream_response": "Stream output", + "stream_response_tip": "If disabled, the model will output contents in non-streaming mode, waiting until the entire response is generated. You can obtain the output contents for further processing.", + "sync_log_keys_popover_text": "The field configuration will apply only to your account. Would you like to save it as the team configuration?", + "sync_team_app_log_keys": "Restore to team config", "system_secret": "System secret key", "systemval_conflict_globalval": "The variable name conflicts with a system variable name. Please use another one.", "team_tags_set": "Team tag", "temperature": "Temperature", - "temperature_tip": "Range: 0-10. A higher value leads to more creative outputs, while a lower value leads to more deterministic outputs.", + "temperature_tip": "Range: 0-10 A higher value leads to more creative outputs, while a lower value leads to more deterministic outputs.", "template.hard_strict": "Strict Q&A template", - "template.hard_strict_des": "A template based on Q&A template. It enforces stricter requirements on the model's answers.", + "template.hard_strict_des": "Enforces stricter requirements on the model's answers based on Q&A templates.", "template.qa_template": "Q&A template", - "template.qa_template_des": "A template used for knowledge bases with a Q&A structure. With this template enabled, the model can be configured to output based on the predefined content.", + "template.qa_template_des": "Requires the model to output based on the predefined content, used for knowledge bases with a Q&A structure.", "template.simple_robot": "Simple bot", "template.standard_strict": "Strict standard template", - "template.standard_strict_des": "A template based on standard template. It enforces stricter requirements on the model's answers.", + "template.standard_strict_des": "Enforces stricter requirements on the model's answers based on standard templates.", "template.standard_template": "Standard template", - "template.standard_template_des": "A template that contains standard prompts. It is used for knowledge bases with no fixed structure.", + "template.standard_template_des": "Contains standard prompts, used for knowledge bases with no fixed structure.", "templateMarket.Search_template": "Template", "templateMarket.Use": "Use", - "templateMarket.no_intro": "No data available.", + "templateMarket.no_intro": "No description available.", "templateMarket.templateTags.Image_generation": "Image generation", "templateMarket.templateTags.Office_services": "Office service", - "templateMarket.templateTags.Recommendation": "Recommendations", + "templateMarket.templateTags.Recommendation": "Recommended", "templateMarket.templateTags.Roleplay": "Role playing", - "templateMarket.templateTags.Web_search": "Online search", + "templateMarket.templateTags.Web_search": "Search", "templateMarket.templateTags.Writing": "Content creation", "templateMarket.template_guide": "Template description", "template_market": "Templates", @@ -275,30 +275,30 @@ "tool_type_productivity": "Productivity", "tool_type_scientific": "Scientific research", "tool_type_search": "Search", - "tool_type_social": "Social networking", - "tool_type_tools": "Tools", - "tools_no_description": "No description available for this tool.", + "tool_type_social": "Social", + "tool_type_tools": "Tool", + "tools_no_description": "No description available.", "transition_to_workflow": "Convert to workflow", - "transition_to_workflow_create_new_placeholder": "Create a new app instead of modifying the current one.", - "transition_to_workflow_create_new_tip": "If converted to a workflow app, it cannot be reverted to simple mode. Would you like to proceed?", + "transition_to_workflow_create_new_placeholder": "Create a new app instead of modifying the current one", + "transition_to_workflow_create_new_tip": "If converted, it cannot be reverted to a simple app. Would you like to proceed?", "tts_ai_model": "Use text-to-speech model", "tts_browser": "Built-in in browser (free)", "tts_close": "Close", "type.All": "All", - "type.Create http plugin tip": "Bulk create plugins by using OpenAPI schema, The plugins to be created must be compatible with the GPTs format.", + "type.Create http plugin tip": "Bulk create plugins by using OpenAPI schema. These plugins must be compatible with the GPTs format.", "type.Create mcp tools tip": "Automatically parse the specified MCP address to bulk create callable MCP tools.", - "type.Create one plugin tip": "Encapsulate reusable processes of a workflow, with configurable inputs and outputs.", + "type.Create one plugin tip": "Encapsulate reusable processes of a workflow, featuring custom inputs and outputs.", "type.Create plugin bot": "Create plugin", "type.Create simple bot": "Create simple app", - "type.Create simple bot tip": "Create a simple AI app by using a guided form, which is ideal for beginners.", + "type.Create simple bot tip": "Create a simple AI app using a template, designed for beginners.", "type.Create workflow bot": "Create workflow", - "type.Create workflow tip": "Create a complex AI app that can handle multiple rounds of chats by using low-code methods, which is recommended for advanced users.", + "type.Create workflow tip": "Create a complex AI app capable of multi-round dialogue tasks by using low-code methods, designed for advanced users.", "type.Folder": "Folder", "type.Http plugin": "HTTP plugin", - "type.Import from json": "Import JSON configuration file", - "type.Import from json tip": "Create an app using a JSON configuration file.", + "type.Import from json": "Import", + "type.Import from json tip": "Create an app by importing a JSON configuration file.", "type.Import from json_error": "Failed to obtain workflow data. Please check the URL or paste the JSON data.", - "type.Import from json_loading": "Obtaining workflow data, please wait.", + "type.Import from json_loading": "Loading workflow data, please wait.", "type.MCP tools": "MCP toolkit", "type.MCP_tools_url": "MCP address", "type.Plugin": "Plugin", @@ -313,15 +313,15 @@ "un_auth": "No permission", "upload_file_max_amount": "Max files", "upload_file_max_amount_tip": "The maximum number of files that can be uploaded in a chat", - "variable.select type_desc": "You can configure global variables for a workflow. These global variables are typically used for temporary caching. Variables can be specified in the following ways:\n1. Use query parameters on the chat page.\n2. Use variables in the API request.\n3. Use a node with the variable update tool.", - "variable.textarea_type_desc": "Up to 4,000 characters can be entered in the input box.", + "variable.select type_desc": "Configure global variables for a workflow. These global variables are typically used for temporary caching. Variables can be specified in the following ways:\n1. Use query parameters on the chat page.\n2. Use variables in the API request.\n3. Use a node with the variable update tool.", + "variable.textarea_type_desc": "Up to 4,000 characters allowed.", "variable_name_required": "Variable name is required.", "variable_repeat": "The variable name already exists.", "version.Revert success": "Rollback successful.", "version_back": "Roll back to initial status", "version_copy": "Replica", "version_initial_copy": "Replica - Initial status", - "vision_model_title": "Image recognition capability", + "vision_model_title": "Image recognition", "week.Friday": "Friday", "week.Monday": "Monday", "week.Saturday": "Saturday", @@ -331,20 +331,20 @@ "week.Wednesday": "Wednesday", "workflow.Input guide": "Enter description", "workflow.file_url": "Document link", - "workflow.form_input": "Form input", + "workflow.form_input": "Template input", "workflow.form_input_description_placeholder": "Example:\nComplete your information", "workflow.form_input_tip": "This module can be configured with multiple input prompts to guide users in entering specific content.", - "workflow.input_description_tip": "You can add a description to explain to users what they need to enter.", + "workflow.input_description_tip": "Add a description to explain to users what they need to enter.", "workflow.read_files": "Document parsing", "workflow.read_files_result": "Result for document parsing", - "workflow.read_files_result_desc": "A file contains a filename and the content. Separate multiple files with hyphens.", - "workflow.read_files_tip": "Parse the file uploaded in the current chat and return the result.", + "workflow.read_files_result_desc": "A file consists of a filename and the content. Separate multiple files with hyphens.", + "workflow.read_files_tip": "Parse the file uploaded in the current conversation and return the result.", "workflow.select_description": "Description", "workflow.select_description_placeholder": "Example:\nIs there any tomato in the refrigerator?", - "workflow.select_description_tip": "You can add a description to explain to users the definition of each option.", + "workflow.select_description_tip": "Add a description to explain to users the definition of each option.", "workflow.select_result": "Select variable name", "workflow.user_file_input": "File link", - "workflow.user_file_input_desc": "File and image link uploaded", + "workflow.user_file_input_desc": "File and image link uploaded by users", "workflow.user_select": "User selection", "workflow.user_select_tip": "The module can be configured with multiple options and can be selected during the chat. Different options direct the chat to different workflow branches.", "files_cascader_no_knowledge_base": "不加入知识库", diff --git a/packages/web/i18n/en/chat.json b/packages/web/i18n/en/chat.json index c774b0397803..4a018571ec0f 100644 --- a/packages/web/i18n/en/chat.json +++ b/packages/web/i18n/en/chat.json @@ -1,6 +1,6 @@ { - "AI_input_is_empty": "The content passed to the AI node is empty.", - "Delete_all": "Clear word library", + "AI_input_is_empty": "The content passed to the node is empty.", + "Delete_all": "Clear", "LLM_model_response_empty": "The output of the model in streaming mode is empty. Please check whether the model output works properly in streaming mode.", "ai_reasoning": "Reasoning process", "back_to_text": "Return to input", @@ -8,49 +8,49 @@ "chat.quote.deleted": "The data has been deleted.", "chat.waiting_for_response": "Please wait for the chat to complete.", "chat_history": "Chat records", - "chat_input_guide_lexicon_is_empty": "No word library is configured.", + "chat_input_guide_lexicon_is_empty": "No word library available.", "chat_test_app": "Debug - {{name}}", "citations": "References: {{num}}", "click_contextual_preview": "Click to preview context", "completion_finish_close": "Disconnected", - "completion_finish_content_filter": "Safety control was triggered.", + "completion_finish_content_filter": "Security control was triggered.", "completion_finish_function_call": "Function call", "completion_finish_length": "The output has exceeded the maximum.", "completion_finish_null": "Unknown", "completion_finish_reason": "Completion cause", "completion_finish_stop": "Completed successfully.", "completion_finish_tool_calls": "Tool call", - "config_input_guide": "Configure input guide", - "config_input_guide_lexicon": "Configure word library", - "config_input_guide_lexicon_title": "Configure word library", + "config_input_guide": "Click to configure input guide", + "config_input_guide_lexicon": "Settings", + "config_input_guide_lexicon_title": "Settings", "content_empty": "Content is empty.", "contextual": "{{num}} contexts", "contextual_preview": "Contexts previewed: {{num}}", "core.chat.moveCancel": "Swipe up to cancel", "core.chat.shortSpeak": "The speech is too short.", - "csv_input_lexicon_tip": "Only CSV files can be bulk imported. Click to download the template", - "custom_input_guide_url": "Custom word library IP address", + "csv_input_lexicon_tip": "Only CSV files can be imported. Click to download the template.", + "custom_input_guide_url": "Custom word library address", "data_source": "Source knowledge base: {{name}}", "dataset_quote_type error": "Incorrect reference type of knowledge base. Correct type: { datasetId: string }[]", - "delete_all_input_guide_confirm": "Are you sure you want to clear the input guide library?", - "download_chunks": "Download data", + "delete_all_input_guide_confirm": "Are you sure you want to clear the word library?", + "download_chunks": "Download", "empty_directory": "No items selectable in the directory.", "error_message": "Error details", "file_amount_over": "The number of files exceeds the maximum ({{max}}).", "file_input": "System file", - "file_input_tip": "You can obtain the required file link through the file link field in the plugin start node.", + "file_input_tip": "You can obtain the required file link through the file link field in the plugin input node.", "history_slider.home.title": "Chat", "home.chat_app": "Home page chat - {{name}}", - "home.chat_id": "Session ID", + "home.chat_id": "Conversation ID", "home.no_available_tools": "No tools available.", "home.select_tools": "Select", "home.tools": "Tools: {{num}}", "in_progress": "Ongoing", "input_guide": "Input guide", "input_guide_lexicon": "Word library", - "input_guide_tip": "You can configure some preset questions. When a user enters a question, related preset questions will be displayed as prompts.", + "input_guide_tip": "Configure preset questions. When a user enters a question, related preset questions will be displayed as prompts.", "input_placeholder_phone": "Enter question", - "insert_input_guide,_some_data_already_exists": "Duplicate entries have been detected and automatically filtered. {{len}} entries have been inserted.", + "insert_input_guide,_some_data_already_exists": "Duplicate entries were detected and automatically filtered. {{len}} entries were inserted.", "invalid_share_url": "Invalid sharing link.", "is_chatting": "Chatting, please wait.", "items": "Items", @@ -58,7 +58,7 @@ "module_runtime_and": "Total workflow uptime", "multiple_AI_conversations": "Multiple AI chats", "new_input_guide_lexicon": "New word library", - "no_invalid_app": "No apps available for your account.", + "no_invalid_app": "No apps available.", "no_workflow_response": "No running data available.", "not_query": "Query content is missing.", "not_select_file": "No file is selected.", @@ -66,7 +66,7 @@ "press_to_speak": "Hold to talk", "query_extension_IO_tokens": "Input/output tokens for question optimization", "query_extension_result": "Question optimization result", - "question_tip": "Modules respond in a top-down sequence.", + "question_tip": "The following shows the response sequence.", "read_raw_source": "View source text", "reasoning_text": "Reasoning process", "release_cancel": "Release to cancel", @@ -82,27 +82,27 @@ "select": "Select", "select_file": "Upload file", "select_file_img": "Upload file/image", - "select_img": "Upload Image", + "select_img": "Upload image", "setting.copyright.basic_configuration": "Basics", "setting.copyright.copyright_configuration": "Copyright", - "setting.copyright.diagram": "Illustration", + "setting.copyright.diagram": "Preview", "setting.copyright.file_size_exceeds_limit": "The file size exceeds the maximum ({{maxSize}}).", "setting.copyright.immediate_upload_required": "Upload an image to use the feature.", "setting.copyright.logo": "Logo", "setting.copyright.preview_fail": "Failed to preview the file.", - "setting.copyright.save_fail": "Failed to save the Logo.", + "setting.copyright.save_fail": "Failed to save the logo.", "setting.copyright.save_success": "Logo saved successfully.", "setting.copyright.select_logo_image": "Please select a logo image first.", - "setting.copyright.style_diagram": "Illustration", + "setting.copyright.style_diagram": "Preview", "setting.copyright.tips": "Recommended ratio: 4:1", "setting.copyright.tips.square": "Recommended ratio: 1:1", "setting.copyright.title": "Copyright info", "setting.copyright.upload_fail": "Failed to upload the file.", - "setting.data_dashboard.title": "Data dashboard", + "setting.data_dashboard.title": "Dashboard", "setting.fastgpt_chat_diagram": "/imgs/chat/fastgpt_chat_diagram.png", "setting.home.available_tools.add": "Add", - "setting.home.commercial_version": "Enterprise edition", - "setting.home.diagram": "Illustration", + "setting.home.commercial_version": "Commercial edition", + "setting.home.diagram": "Preview", "setting.home.dialogue_tips": "Chat box prompt", "setting.home.dialogue_tips.default": "You can ask me anything.", "setting.home.dialogue_tips_placeholder": "Please enter the prompt text of the dialog box", @@ -114,15 +114,15 @@ "setting.home.title": "Home page configuration", "setting.incorrect_plan": "The current plan does not support this feature. Please upgrade your subscription plan.", "setting.incorrect_version": "The current version does not support this feature.", - "setting.log_details.title": "Home page logs", - "setting.logs.title": "Home page logs", + "setting.log_details.title": "Logs", + "setting.logs.title": "Logs", "setting.save": "Save", - "setting.save_success": "Changes saved successfully.", + "setting.save_success": "Saved successfully.", "sidebar.home": "Home", - "sidebar.team_apps": "Team app", + "sidebar.team_apps": "Team apps", "source_cronJob": "Scheduled execution", "start_chat": "Chat now", - "stream_output": "Output in streaming mode", + "stream_output": "Stream output", "unsupported_file_type": "File type is not supported.", "upload": "Upload", "variable_invisable_in_share": "Custom variables are not visible in login-free mode.", diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index ba5b290e3d02..0d43a8d5fe45 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -11,7 +11,7 @@ "Code": "Source code", "Config": "Configuration", "Confirm": "OK", - "Continue_Adding": "Add more", + "Continue_Adding": "Save & add more", "Copy": "Copy", "Creating": "Creating", "Delete": "Delete", @@ -23,18 +23,18 @@ "Error": "Error", "Exit": "Exit", "Export": "Export", - "FAQ.ai_point_a": "Each AI model call consumes AI points. For details, see the billing standard by using AI points above. The system prioritizes the usage data returned by the model provider. If no usage data is returned, it estimates token consumption based on the calculation method of GPT-3.5: 1 token ≈ 0.7 Chinese characters ≈ 0.9 English words. Consecutive characters may be counted as 1 token.", + "FAQ.ai_point_a": "Each AI model call consumes AI points. For details, refer to the AI point billing standard above. The system prioritizes the usage data returned by the model provider. If no usage data is returned, it estimates token consumption based on the calculation method of GPT-3.5: 1 token ≈ 0.7 Chinese characters ≈ 0.9 English words. Consecutive characters may be counted as 1 token.", "FAQ.ai_point_expire_a": "Yes. After the current plan expires, AI points will be cleared and updated according to the new plan. The points for an annual plan are valid for one year and are not reset monthly.", "FAQ.ai_point_expire_q": "Do AI points expire?", "FAQ.ai_point_q": "What are AI points?", - "FAQ.check_subscription_a": "On the Account > Personal info > Plan details > Usage page, you can view the valid and expiration time of your plans. The paid plan will automatically switch to the free one after expiration.", + "FAQ.check_subscription_a": "On the Account > Profile > Plan details > Usage page, you can view the valid and expiration time of your plans. The paid plan will automatically switch to the free one after expiration.", "FAQ.check_subscription_q": "Where can I view my subscription plans?", - "FAQ.dataset_compute_a": "One entry stored in the knowledge base equals one knowledge base index. One chunked entry usually corresponds to multiple indexes. Therefore, n sets of indexes can be found in one knowledge base collection.", + "FAQ.dataset_compute_a": "One entry stored in the knowledge base equals one knowledge base index. One chunked entry usually corresponds to multiple indexes. Therefore, n groups of indexes can be found in one knowledge base collection.", "FAQ.dataset_compute_q": "How is knowledge base storage calculated?", "FAQ.dataset_index_a": "No. However, you cannot insert or update any content in the knowledge base.", - "FAQ.dataset_index_q": "Will existing entries in a knowledge base be deleted when the number of entries exceeds the maximum?", + "FAQ.dataset_index_q": "Will extra entries in a knowledge base be deleted when the number of entries exceeds the maximum?", "FAQ.free_user_clean_a": "If a team using the free edition without any extra subscription plans does not log in for 30 consecutive days, the system will automatically clear all knowledge base content belonging to the team.", - "FAQ.free_user_clean_q": "Will data be cleared in the free edition?", + "FAQ.free_user_clean_q": "Will data in the free edition be cleared?", "FAQ.package_overlay_a": "Yes. Purchased resource packages are independent and can be used concurrently within their validity periods. AI points from the resource package that expires first will be consumed first.", "FAQ.package_overlay_q": "Can multiple extra resource packages be used concurrently?", "FAQ.switch_package_a": "Subscription plan usage follows a priority rule where a plan with higher level is used first. Therefore, if the level of a new plan is higher than the current one, it takes effect immediately. Otherwise, the current plan will not be switched.", @@ -90,7 +90,7 @@ "Update": "Update", "Username": "Username", "Waiting": "Waiting", - "Warning": "Message", + "Warning": "Warning", "Website": "Website", "action_confirm": "Confirm", "add_new": "Add", @@ -114,10 +114,10 @@ "can_copy_content_tip": "Auto copy is unavailable. Please manually copy the content below.", "chart_mode_cumulative": "Cumulative", "chart_mode_incremental": "Periodic", - "chat": "Session", - "chat_chatId": "Session ID: {{chatId}}", + "chat": "Conversation", + "chat_chatId": "Conversation ID: {{chatId}}", "choosable": "Available", - "chose_condition": "Filter", + "chose_condition": "Condition", "chosen": "Selected", "classification": "Classification", "click_drag_tip": "Click and drag", @@ -128,7 +128,7 @@ "code_error.account_error": "Username or password is invalid.", "code_error.account_exist": "The account already exists.", "code_error.account_not_found": "The account does not exist.", - "code_error.app_error.can_not_edit_admin_permission": "Administrator permissions cannot be edited.", + "code_error.app_error.can_not_edit_admin_permission": "Admin permissions cannot be edited.", "code_error.app_error.invalid_app_type": "Invalid app type.", "code_error.app_error.invalid_owner": "Invalid app owner.", "code_error.app_error.not_exist": "The app does not exist.", @@ -160,7 +160,7 @@ "code_error.outlink_error.un_auth_user": "Identity verification failed.", "code_error.plugin_error.not_exist": "The tool does not exist.", "code_error.plugin_error.un_auth": "You do not have permission to perform operations on the tool.", - "code_error.system_error.community_version_num_limit": "The number of resources exceeded the maximum allowed by the community edition. Please upgrade to the enterprise edition: https://fastgpt.in", + "code_error.system_error.community_version_num_limit": "The number of resources exceeded the maximum allowed by the community edition. Please upgrade to the commercial edition: https://fastgpt.in", "code_error.system_error.license_app_amount_limit": "The number of apps has exceeded the maximum.", "code_error.system_error.license_dataset_amount_limit": "The number of knowledge bases has exceeded the maximum.", "code_error.system_error.license_user_amount_limit": "The number of users has exceeded the maximum.", @@ -188,18 +188,18 @@ "code_error.team_error.over_size": "The number of team members has exceeded the maximum.", "code_error.team_error.plugin_amount_not_enough": "The number of plugins has reached the maximum.", "code_error.team_error.re_rank_not_enough": "Reranking of retrieved results is not supported for the free edition.", - "code_error.team_error.too_many_invitations": "The number of valid invitation links has reached the maximum. Please delete some links first.", + "code_error.team_error.too_many_invitations": "The number of valid invitation links has reached the maximum. Please delete some first.", "code_error.team_error.un_auth": "You do not have permission to perform operations on the team.", "code_error.team_error.user_not_active": "The user has not accepted the invitation or has left the team.", - "code_error.team_error.website_sync_not_enough": "The free version cannot be synchronized with the web site ~", - "code_error.team_error.you_have_been_in_the_team": "You are already in this team", + "code_error.team_error.website_sync_not_enough": "Free edition does not support website sync.", + "code_error.team_error.you_have_been_in_the_team": "You are already a member of the team.", "code_error.team_error.evaluation_task_amount_not_enough": "The number of evaluation tasks has reached the maximum.", "code_error.team_error.evaluation_dataset_amount_not_enough": "The number of evaluation datasets has reached the maximum.", "code_error.team_error.evaluation_dataset_data_amount_not_enough": "The number of evaluation dataset data entries has reached the maximum.", "code_error.team_error.evaluation_metric_amount_not_enough": "The number of evaluation metrics has reached the maximum.", - "code_error.token_error_code.403": "Invalid Login Status, Please Re-login", - "code_error.user_error.balance_not_enough": "Insufficient Account Balance", - "code_error.user_error.bin_visitor_guest": "You Are Currently a Guest, Unauthorized to Operate", + "code_error.token_error_code.403": "Invalid login status. Please log in again.", + "code_error.user_error.balance_not_enough": "The account balance is insufficient.", + "code_error.user_error.bin_visitor_guest": "You do not have permission because you have not logged in.", "code_error.user_error.un_auth_user": "User not found.", "comfirm_import": "Confirm", "comfirm_leave_page": "Are you sure you want to leave this page?", @@ -222,7 +222,7 @@ "core.ai.Not deploy rerank model": "No reranker models available.", "core.ai.Prompt": "Prompt", "core.ai.Support tool": "Function call", - "core.ai.model.Dataset Agent Model": "Text understanding model", + "core.ai.model.Dataset Agent Model": "LLM", "core.ai.model.Vector Model": "Index model", "core.ai.model.doc_index_and_dialog": "Document index & chat index", "core.app.Api request": "API access", @@ -233,14 +233,14 @@ "core.app.Config schedule plan": "Click to configure scheduled execution", "core.app.Config whisper": "Click to configure the speech-to-text feature", "core.app.Config_auto_execute": "Click to configure auto execution rules", - "core.app.Interval timer config": "Scheduled execution configuration", - "core.app.Interval timer run": "Scheduled execution", + "core.app.Interval timer config": "Scheduled execution", + "core.app.Interval timer run": "Status", "core.app.Interval timer tip": "Execute apps based on the schedule.", "core.app.Make a brief introduction of your app": "Enter description for the AI app", "core.app.Name and avatar": "Tool image & name", "core.app.Publish": "Publish", "core.app.Publish Confirm": "Are you sure you want to publish the app? The app status will be immediately updated across all publishing channels.", - "core.app.Publish app tip": "The app version will be updated across all publishing channels.", + "core.app.Publish app tip": "The app version will be updated across all publishing channels. (removed)", "core.app.QG.Custom prompt tip": "To ensure the generated content follows the required format, the prompts highlighted in yellow cannot be modified.", "core.app.QG.Custom prompt tip1": "To ensure the generated content follows the required format,", "core.app.QG.Custom prompt tip2": "the prompts highlighted in yellow", @@ -295,7 +295,7 @@ "core.app.outLink.Select Using Way": "Mode", "core.app.outLink.Show History": "Display chat history", "core.app.publish.Fei shu bot": "Feishu", - "core.app.publish.Fei shu bot publish": "Publish app to Feishu", + "core.app.publish.Fei shu bot publish": "Publish app via Feishu bot", "core.app.schedule.Default prompt": "Default question", "core.app.schedule.Default prompt placeholder": "A default question used when the app is executed", "core.app.schedule.Every day": "Every day at {{hour}}:00", @@ -303,7 +303,7 @@ "core.app.schedule.Every week": "Every {{day}} at {{hour}}:00", "core.app.schedule.Interval": "Every {{interval}} hours", "core.app.schedule.Open schedule": "Scheduled execution", - "core.app.setting": "Configure App info", + "core.app.setting": "Configure app info", "core.app.share.Amount limit tip": "Up to 10 groups can be created.", "core.app.share.Create link": "Create link", "core.app.share.Create link tip": "Created successfully. The link has been copied and can be shared now.", @@ -363,7 +363,7 @@ "core.chat.Read Mark Description": "View Marking Function Introduction", "core.chat.Recent use": "Last used", "core.chat.Record": "Speech-to-text", - "core.chat.Restart": "Start new chat", + "core.chat.Restart": "Start a new chat", "core.chat.Run test": "Running preview", "core.chat.Select dataset": "Select knowledge base", "core.chat.Select dataset Desc": "Select a knowledge base for storing marked answers.", @@ -446,7 +446,7 @@ "core.dataset.Query extension intro": "Enabling this feature can increase the accuracy of knowledge base searches during continuous chats and allow the model to complete questions based on chat records.", "core.dataset.Quote Length": "Referenced content length", "core.dataset.Read Dataset": "View details", - "core.dataset.Set Website Config": "Confiugre now", + "core.dataset.Set Website Config": "Configure now", "core.dataset.Start export": "Export...", "core.dataset.Text collection": "Text dataset", "core.dataset.apiFile": "API file", @@ -509,7 +509,7 @@ "core.dataset.import.Local file": "Local file", "core.dataset.import.Local file desc": "Upload files in PDF, TXT, DOCX, or other formats", "core.dataset.import.Preview chunks": "Preview shard (Max shards: 15)", - "core.dataset.import.Preview raw text": "Preview source text (Max characters: 3000)", + "core.dataset.import.Preview raw text": "Preview source text (Max characters: 3000)", "core.dataset.import.Process way": "Processing Method", "core.dataset.import.QA Import": "Q&A generation", "core.dataset.import.QA Import Tip": "Split the text into multiple large paragraphs based on specific rules and generate Q&A pair for them using the model. This provides extremely high search accuracy but can cause detail loss.", @@ -597,7 +597,7 @@ "core.module.Default Value": "Default value", "core.module.Default value": "Default value", "core.module.Default value placeholder": "If left blank, an empty string will be returned by default.", - "core.module.Diagram": "Illustration", + "core.module.Diagram": "Preview", "core.module.Edit intro": "Edit description", "core.module.Field Description": "Field description", "core.module.Field Name": "Field name", @@ -833,9 +833,9 @@ "error_collection_not_exist": "The collection does not exist.", "error_embedding_not_config": "No index model configured.", "error_invalid_resource": "Invalid resource.", - "error_llm_not_config": "No file understanding model configured.", + "error_llm_not_config": "No LLMs available.", "error_un_permission": "No permission.", - "error_vlm_not_config": "No image understanding model configured.", + "error_vlm_not_config": "No VLMs available.", "exit_directly": "Exit", "expired_time": "Expiration time", "export_to_json": "Export as JSON file", @@ -921,7 +921,7 @@ "month": "Monthly", "move.confirm": "Confirm", "move_success": "Moved successfully.", - "move_to": "Move to", + "move_to": "Move", "name": "Name", "name_is_empty": "Name is required.", "navbar.Account": "Account", @@ -930,7 +930,7 @@ "navbar.Studio": "Studio", "navbar.Toolkit": "Toolbox", "navbar.Tools": "Tool", - "new_create": "Create", + "new_create": "Add", "next_step": "Next", "no": "No", "no_child_folder": "No subdirectory available.", @@ -986,8 +986,8 @@ "permission.change_owner_tip": "You will lose related permissions after the ownership transfer.", "permission.change_owner_to": "Transfer to", "permission.manager": "Admin", - "permission.read": "Read permissions", - "permission.write": "Write permissions", + "permission.read": "Read permission", + "permission.write": "Write permission", "permission_other": "Other permissions (one or more options)", "please_input_name": "Name is required.", "plugin.App": "Select app", @@ -1035,7 +1035,7 @@ "secret_tips": "Saved values will not be returned in plain text again.", "select_count_num": "{{num}} item selected", "select_file_failed": "File Selection Failed", - "select_reference_variable": "Variable", + "select_reference_variable": "Select variable", "select_template": "Select template", "set_avatar": "Upload tool image", "share_link": "Sharing link", @@ -1074,7 +1074,7 @@ "support.user.Laf account course": "View guide for binding LAF account", "support.user.Laf account intro": "Bind your LAF account to enable the LAF module in workflows for online coding.", "support.user.Need to login": "Please log in first.", - "support.user.Price": "Billing", + "support.user.Price": "Billing standard", "support.user.User self info": "Profile", "support.user.auth.Sending Code": "Sending...", "support.user.auth.get_code": "Send", @@ -1104,7 +1104,7 @@ "support.user.login.wx_qr_login": "Scan QR code via WeChat", "support.user.login.sxf_com": "Sangfor Commercial Edition", "support.user.login.fastgpt_sxf_com": "FastGPT Sangfor Commercial Edition", - "support.user.login.sxf_com_platform_desc": "Enable every digital user to more easily develop and continuously optimize their own AI applications, while continuously accumulating data and AI development capabilities. Through AI application-oriented development workflows, we provide various knowledge enhancement tools to improve the data quality of application building, and help users optimize applications in a self-contained loop through application performance evaluation and assisted positioning.", + "support.user.login.sxf_com_platform_desc": "Enable every digital user to more easily develop and continuously optimize their own AI applications, while continuously accumulating data and AI development capabilities. Through AI application-oriented development workflows, we provide various knowledge enhancement tools to improve the data quality of application building, and help users optimize applications in a self-contained loop through application performance evaluation and assisted positioning.", "support.user.logout.confirm": "Are you sure you want to log out?", "support.user.team.Dataset usage": "Knowledge base capacity", "support.user.team.Team Tags Async Success": "Sync completed", @@ -1226,7 +1226,7 @@ "support.wallet.usage.Bill Module": "Billing module", "support.wallet.usage.Duration": "Duration (s)", "support.wallet.usage.Module name": "Module name", - "support.wallet.usage.Optimize Prompt": "Prompt word optimization", + "support.wallet.usage.Optimize Prompt": "Prompt word optimization", "support.wallet.usage.Source": "Source", "support.wallet.usage.Text Length": "Text length", "support.wallet.usage.Time": "Time generated", @@ -1246,7 +1246,7 @@ "templateTags.Image_generation": "Image generation", "templateTags.Office_services": "Office service", "templateTags.Roleplay": "Role playing", - "templateTags.Web_search": "Online search", + "templateTags.Web_search": "Search", "templateTags.Writing": "Content creation", "template_market": "Templates", "textarea_variable_picker_tip": "Enter a slash (/) to select a variable", @@ -1275,9 +1275,9 @@ "user.Avatar": "Profile image", "user.Change": "Change", "user.Copy invite url": "Copy invitation link", - "user.Edit name": "Change nickname", + "user.Edit name": "Click to change the nickname.", "user.Invite Url": "Invitation link", - "user.Invite url tip": "Friends who register through this link will be permanently linked to your account. Each time they top up, you will receive a balance reward.\nIf they register with a mobile number, you will obtain an immediate 5 CNY bonus.\nAll rewards are credited to your default team account.", + "user.Invite url tip": "Friends who register through this link will be permanently linked to your account. Each time they top up, you will receive a balance reward.\nIf they register with a mobile number, you will get an immediate 5 CNY bonus.\nAll rewards are credited to your default team account.", "user.Laf Account Setting": "LAF account", "user.Language": "Language", "user.Member Name": "Nickname", @@ -1310,7 +1310,7 @@ "user.old_password": "Current password", "user.password_message": "Password must contain 4-60 characters.", "user.password_tip": "Password must be at least 8 characters long and contain at least 2 of the following: digits, letters, and special characters.", - "user.reset_password": "Reset", + "user.reset_password": "Reset password", "user.reset_password_tip": "Initial password not set or password unchanged for a long time. Please reset your password.", "user.team.Balance": "Team balance", "user.team.Check Team": "Switch", diff --git a/packages/web/i18n/en/dashboard_evaluation.json b/packages/web/i18n/en/dashboard_evaluation.json index 34a5bdc86d73..d0ec0cdcbf1b 100644 --- a/packages/web/i18n/en/dashboard_evaluation.json +++ b/packages/web/i18n/en/dashboard_evaluation.json @@ -47,194 +47,194 @@ "team_has_running_evaluation": "The team already has an app evaluation task in progress. Please wait until it is complete.", "template_csv_file_select_tip": "Must be a {{fileType}} file strictly consistent with the template.", "variables": "Global variables", - "all_apps": "全部应用", - "search_evaluation_task": "搜索任务名或应用版本", - "create_new_task": "新建任务", - "task_name_column": "任务名", - "progress_column": "进度", - "evaluation_app_column": "评测应用", - "app_version_column": "应用版本", - "evaluation_result_column": "评测结果", - "start_finish_time_column": "开始时间/完成时间", - "executor_column": "执行人", - "waiting": "等待中", - "evaluating_status": "评测中", - "completed_status": "已完成", - "queuing_status": "排队中", - "running_status": "进行中", - "error_data_tooltip": "{{count}} 条数据执行异常,可点击查看详情", - "rename": "重命名", - "delete": "删除", - "confirm_delete_task": "确认删除该任务?", - "evaluation_tasks_tab": "评测任务", - "evaluation_tasks": "评测任务", - "evaluation_datasets_tab": "评测数据集", - "evaluation_dimensions_tab": "评测维度", - "create_new": "新建", - "retry_error_data": "重试异常数据", - "dataset_name_placeholder": "名称", - "create_new_dataset": "新建", - "smart_generation": "智能生成", - "file_import": "文件导入", - "confirm_delete_dataset": "确认删除该数据集吗?", - "error_details": "异常详情", - "status_queuing": "排队中", - "status_parsing": "文件解析中", - "status_generating": "数据生成中", - "status_generate_error": "生成异常", - "status_ready": "已就绪", - "status_parse_error": "解析异常", - "click_to_view_details": "点击查看详情", - "table_header_name": "名称", - "table_header_data_count": "数据量", - "table_header_time": "创建/更新时间", - "table_header_status": "状态", - "table_header_creator": "创建人", - "create_dimension": "新建", - "search_dimension": "搜索评测维度", - "delete_failed": "删除失败", - "delete_success": "删除成功", - "builtin": "内置", - "confirm_delete_dimension": "确认删除该维度?", - "dimension_name": "维度名", - "description": "介绍", - "create_update_time": "创建/更新时间", - "creator": "创建人", - "all": "全部", - "app": "应用", - "citation_template": "引用模板", - "correctness": "正确性", - "conciseness": "简洁性", - "harmfulness": "有害性", - "controversiality": "争议性", - "creativity": "创造性", - "criminality": "犯罪性", - "depth": "深度性", - "details": "细节性", - "dimension_name_label": "维度名", - "dimension_description_label": "维度描述", - "prompt_label": "提示词", - "citation_template_button": "引用模板", - "test_run_title": "试运行", - "question_label": "问题", - "question_placeholder": "请输入问题内容", - "answer_label": "答案", - "reference_answer_label": "参考答案", - "reference_answer_placeholder": "请输入参考答案", - "actual_answer_label": "实际回答", - "actual_answer_placeholder": "请输入实际回答", - "run_result_label": "运行结果", - "start_run_button": "开始运行", - "running_text": "运行中", - "run_success": "运行成功", - "run_failed": "运行失败", - "not_run": "未运行", - "score_unit": "分", - "error_info_label": "报错信息:", - "no_feedback_text": "暂无反馈内容", - "dimension_create_back": "退出", - "dimension_create_test_run": "试运行", - "dimension_create_confirm": "确认", - "dimension_create_success": "创建成功", - "dimension_create_name_required": "请输入名称", - "dimension_create_prompt_required": "请输入提示词", - "dimension_get_data_failed": "获取维度数据失败", - "dimension_data_not_exist": "维度数据不存在", - "dimension_update_success": "更新成功", - "dimension_update_failed": "更新失败", - "dimension_name_required": "请输入名称", - "dimension_back": "退出", - "dimension_test_run": "试运行", - "dimension_save": "保存", - "file_import_back": "退出", - "file_import_name_label": "数据集名", - "file_import_name_placeholder": "请输入名称", - "file_import_select_file": "请选择文件", - "file_import_success": "文件导入成功", - "file_import_file_label": "文件", - "file_import_download_template": "点击下载文件模板", - "file_import_download_template_tip": "下载模板文件以了解正确的文件格式", - "file_import_auto_evaluation_label": "导入后自动进行数据质量评测", - "file_import_auto_evaluation_tip": "开启后将自动对导入的数据进行质量评测", - "file_import_evaluation_model_label": "质量评测模型", - "file_import_evaluation_model_placeholder": "请选择质量评测模型", - "file_import_confirm": "确认", - "manage_dimension": "管理维度", - "selected_count": "已选", - "dimension_config_tip": "维度配置说明", - "custom": "自定义", - "select_model_placeholder": "请选择模型", - "create_new_task_modal": "新建任务", - "task_name_input": "任务名", - "task_name_input_placeholder": "请输入任务名", - "evaluation_app_select": "评测应用", - "evaluation_app_support_tip": "当前支持简易应用和工作流,暂不支持插件。", - "evaluation_app_select_placeholder": "请选择评测应用", - "evaluation_app_version_select": "评测应用版本", - "evaluation_app_version_select_placeholder": "请选择评测应用版本", - "evaluation_dataset_select": "评测数据集", - "evaluation_dataset_select_placeholder": "请选择评测数据集", - "create_import_dataset": "新建/导入", - "evaluation_dimensions_label": "评测维度", - "evaluation_dimensions_recommendation": "评测应用包含知识库搜索和AI对话环节,推荐使用 {{num}} 个维度进行评估", - "builtin_dimension": "内置", - "custom_dimension": "自定义", - "config_params": "配置参数", - "score_aggregation_method": "分数聚合方式", - "score_aggregation_method_tip": "选择如何聚合多个维度的评分", - "evaluation_dimensions": "评测维度", - "dimension": "维度", - "judgment_threshold": "判定阈值", - "judgment_threshold_tip": "用于判定测试数据在单个维度中表现是否符合预期,分数低于阈值时,将标记为不符合预期,可辅助识别存在问题的数据,分值为 1~100。\n评测后查看任务时可根据需要再次调整。", - "comprehensive_score_weight": "综合评分权重", - "comprehensive_score_weight_tip": "将按照指定权重计算测试数据全部维度的综合评分,可根据应用使用场景所关注的维度进行设置。\n评测后查看任务时可根据需要再次调整。", - "comprehensive_score_weight_sum": "综合评分权重和:", - "intelligent_generation_dataset": "智能生成数据集", - "dataset_name_input": "名称", - "dataset_name_input_placeholder": "请输入数据集名称", - "generation_basis": "生成依据", - "select_knowledge_base": "选择知识库", - "data_amount": "数据量", - "generation_model": "生成模型", - "generation_model_placeholder": "请选择生成模型", - "high_quality": "质量高", - "needs_improvement": "待优化", - "detail_evaluating": "评测中", - "abnormal": "评测异常", - "not_evaluated": "未评测", - "modify_result": "修改结果", - "restart_evaluation": "重新评测", - "evaluation_error_message": "评测过程中出现异常,请重新评测", - "high_quality_feedback": "该问题质量较高,表述清晰准确,符合标准要求。问题描述完整,答案准确且具有实用性。", - "needs_improvement_feedback": "该问题相对清晰,但可能需要进一步优化。建议增加更多上下文信息,使问题更加具体和明确,以便提供更准确的答案。", - "evaluation_service_error": "评测服务异常", - "edit_data": "编辑数据", - "enter_question": "请输入问题", - "question_required": "问题不能为空", - "reference_answer": "参考答案", - "enter_reference_answer": "请输入参考答案", - "reference_answer_required": "参考答案不能为空", - "quality_evaluation": "质量评测", - "cancel": "取消", - "save": "仅保存", - "save_and_next": "保存并下一个", - "manually_calibrated": "已人工修改结果", - "modify_evaluation_result_title": "修改评测结果", - "evaluation_result_label": "评测结果", - "modify_reason_label": "修改理由", - "modify_reason_input_placeholder": "修改理由", - "no_data": "暂无数据", - "search": "搜索", - "settings": "设置", - "add_data": "追加数据", - "ai_generate": "智能生成", - "manual_add": "手动新增", - "no_answer": "暂无答案", - "confirm_delete_data": "确认删除该数据?", - "no_evaluation_result_click": "还没有测评结果,点击", - "start_evaluation_action": "开始测评", - "evaluation_dataset": "评测数据集", - "evaluation_status": "评测状态", + "all_apps": "All apps", + "search_evaluation_task": "Task name, app version", + "create_new_task": "Create task", + "task_name_column": "Task name", + "progress_column": "Progress", + "evaluation_app_column": "App", + "app_version_column": "App version", + "evaluation_result_column": "Evaluation result", + "start_finish_time_column": "Time started/completed", + "executor_column": "Operator", + "waiting": "Waiting", + "evaluating_status": "Evaluating", + "completed_status": "Completed", + "queuing_status": "Waiting", + "running_status": "Ongoing", + "error_data_tooltip": "{{count}} entries encountered execution error. Click to view error details.", + "rename": "Rename", + "delete": "Delete", + "confirm_delete_task": "Are you sure you want to delete this task?", + "evaluation_tasks_tab": "Tasks", + "evaluation_tasks": "Evaluation tasks", + "evaluation_datasets_tab": "Datasets", + "evaluation_dimensions_tab": "Metrics", + "create_new": "New", + "retry_error_data": "Retry failed entries", + "dataset_name_placeholder": "Name", + "create_new_dataset": "Create dataset", + "smart_generation": "Auto generate", + "file_import": "Import from file", + "confirm_delete_dataset": "Are you sure you want to delete this dataset?", + "error_details": "Details", + "status_queuing": "Waiting", + "status_parsing": "Parsing file", + "status_generating": "Generating data", + "status_generate_error": "Generation error", + "status_ready": "Ready", + "status_parse_error": "Parsing error", + "click_to_view_details": "View details", + "table_header_name": "Name", + "table_header_data_count": "Data size", + "table_header_time": "Time created/updated", + "table_header_status": "Status", + "table_header_creator": "Creator", + "create_dimension": "Add metric", + "search_dimension": "Metric", + "delete_failed": "Operation failed.", + "delete_success": "Deleted successfully.", + "builtin": "Predefined", + "confirm_delete_dimension": "Are you sure that you want to delete this metric?", + "dimension_name": "Metric name", + "description": "Description", + "create_update_time": "Time created/updated", + "creator": "Creator", + "all": "All", + "app": "App", + "citation_template": "Template", + "correctness": "Correctness", + "conciseness": "Conciseness", + "harmfulness": "Harmfulness", + "controversiality": "Controversiality", + "creativity": "Creativity", + "criminality": "Criminality", + "depth": "Depth", + "details": "Details", + "dimension_name_label": "Metric name", + "dimension_description_label": "Metric description", + "prompt_label": "Prompt", + "citation_template_button": "Template", + "test_run_title": "Trial run", + "question_label": "Question", + "question_placeholder": " ", + "answer_label": "Answer", + "reference_answer_label": "Answer for reference", + "reference_answer_placeholder": " ", + "actual_answer_label": "Actual answer", + "actual_answer_placeholder": " ", + "run_result_label": "Running result", + "start_run_button": "Run", + "running_text": "Running", + "run_success": "Run successful", + "run_failed": "Run failed", + "not_run": "Not run", + "score_unit": "(Score)", + "error_info_label": "Error message: ", + "no_feedback_text": "No feedback available.", + "dimension_create_back": "Exit", + "dimension_create_test_run": "Trial run", + "dimension_create_confirm": "OK", + "dimension_create_success": "Created successfully.", + "dimension_create_name_required": "Name is required.", + "dimension_create_prompt_required": "Prompt is required.", + "dimension_get_data_failed": "Failed to obtain the metric data.", + "dimension_data_not_exist": "The metric data does not exist.", + "dimension_update_success": "Update successful.", + "dimension_update_failed": "Update failed.", + "dimension_name_required": "Name is required.", + "dimension_back": "Exit", + "dimension_test_run": "Trial run", + "dimension_save": "Save", + "file_import_back": "Exit", + "file_import_name_label": "Name", + "file_import_name_placeholder": " ", + "file_import_select_file": "Please select a file.", + "file_import_success": "File imported successfully.", + "file_import_file_label": "File", + "file_import_download_template": "Download template", + "file_import_download_template_tip": "Download the template to see the required file format.", + "file_import_auto_evaluation_label": "Automatically evaluate the quality of imported data", + "file_import_auto_evaluation_tip": "If enabled, a quality evaluation will be automatically performed on the imported data.", + "file_import_evaluation_model_label": "Quality evaluation model", + "file_import_evaluation_model_placeholder": "Select", + "file_import_confirm": "OK", + "manage_dimension": "Manage metrics", + "selected_count": "Selected", + "dimension_config_tip": "Dimension configuration instructions", + "custom": "Custom", + "select_model_placeholder": "Select", + "create_new_task_modal": "Create task", + "task_name_input": "Task name", + "task_name_input_placeholder": " ", + "evaluation_app_select": "App", + "evaluation_app_support_tip": "You can select a simple app or workflow but cannot select a plugin.", + "evaluation_app_select_placeholder": "Select", + "evaluation_app_version_select": "App version", + "evaluation_app_version_select_placeholder": "Select", + "evaluation_dataset_select": "Dataset", + "evaluation_dataset_select_placeholder": "Select", + "create_import_dataset": "Create/Import", + "evaluation_dimensions_label": "Metrics", + "evaluation_dimensions_recommendation": "The evaluation involves knowledge base searches and AI chats. It is recommended to use {{num}} metrics for evaluation.", + "builtin_dimension": "Predefined", + "custom_dimension": "Custom", + "config_params": "Settings", + "score_aggregation_method": "Score aggregation", + "score_aggregation_method_tip": "Select a method to aggregate the scores for multiple metrics.", + "evaluation_dimensions": "Metrics", + "dimension": "Metric", + "judgment_threshold": "Threshold", + "judgment_threshold_tip": "Used to determine whether the score for a metric meets expectations. If the score is lower than the specified threshold, it will be marked as lower-than-expected. Valid value: 1-100 After the evaluation is complete, you can adjust the values as needed.", + "comprehensive_score_weight": "Weight", + "comprehensive_score_weight_tip": "The proportion of a metric's score to the total score. You can configure it as needed. After the evaluation is complete, you can adjust the values as needed.", + "comprehensive_score_weight_sum": "Total weight:", + "intelligent_generation_dataset": "Auto generate dataset", + "dataset_name_input": "Name", + "dataset_name_input_placeholder": "Dataset name", + "generation_basis": "Data source", + "select_knowledge_base": "Select knowledge base", + "data_amount": "Data size", + "generation_model": "Model", + "generation_model_placeholder": "Select", + "high_quality": "High quality", + "needs_improvement": "Pending optimization", + "detail_evaluating": "Evaluating", + "abnormal": "Evaluation error", + "not_evaluated": "Not evaluated", + "modify_result": "Modify", + "restart_evaluation": "Evaluate again", + "evaluation_error_message": "Error occurred during the evaluation. Please try again.", + "high_quality_feedback": "The question is complete, clear, and accurate, meeting the standard. The answer is accurate and practical.", + "needs_improvement_feedback": "The question needs to be optimized. To obtain a more accurate answer, please provide more context to make the question more specific and accurate.", + "evaluation_service_error": "Evaluation service error.", + "edit_data": "Edit data", + "enter_question": "This field is required.", + "question_required": "Question is required.", + "reference_answer": "Answer for reference", + "enter_reference_answer": "This field is required.", + "reference_answer_required": "Answer for reference is required.", + "quality_evaluation": "Quality evaluation", + "cancel": "Cancel", + "save": "Save", + "save_and_next": "Save and proceed", + "manually_calibrated": "Manually modified", + "modify_evaluation_result_title": "Modify result", + "evaluation_result_label": "Evaluation result", + "modify_reason_label": "Modification cause", + "modify_reason_input_placeholder": " ", + "no_data": "No data available.", + "search": "Search", + "settings": "Settings", + "add_data": "Add data", + "ai_generate": "Auto generate", + "manual_add": "Manually add", + "no_answer": "No answers available.", + "confirm_delete_data": "Are you sure you want to delete this entry?", + "no_evaluation_result_click": "No evaluation results available. Click to ", + "start_evaluation_action": "start evaluation.", + "evaluation_dataset": "Dataset", + "evaluation_status": "Status", "manually_add_data_modal": "手动新增数据", "question_input_label": "问题", "max_chars_3000_placeholder": "最多 3000 字", diff --git a/packages/web/i18n/en/dataset.json b/packages/web/i18n/en/dataset.json index 4a54a3a8dfb1..ca1c0b89f7bd 100644 --- a/packages/web/i18n/en/dataset.json +++ b/packages/web/i18n/en/dataset.json @@ -11,8 +11,8 @@ "backup_collection": "Backup data", "backup_dataset": "Import from backup", "backup_dataset_success": "Backup created successfully.", - "backup_dataset_tip": "Import the CSV file downloaded duing the knowledge base export.", - "backup_mode": "Import backup", + "backup_dataset_tip": "Import the CSV file downloaded during the knowledge base export.", + "backup_mode": "Import from backup", "backup_template_invalid": "Invalid backup file. It must be a CSV file with the first columns being q,a,indexes.", "batch_delete": "Delete", "chunk_max_tokens": "Max chunks", @@ -101,61 +101,61 @@ "folder_dataset": "Folder", "getDirectoryFailed": "Failed to obtain the directory.", "image_auto_parse": "Auto image indexing", - "image_auto_parse_tips": "Use VLM to tag images in documents and generate extra search indexes.", + "image_auto_parse_tips": "Automatically mark images in documents using the VLM and generate search indexes for them.", "image_training_queue": "Image processing queue", "images_creating": "Creating", "immediate_sync": "Sync now", "import.Auto mode Estimated Price Tips": "Text understanding model call is required, and the consumed AI points ({{price}} points/1K tokens) is high.", "import.Embedding Estimated Price Tips": "Only index model call is required, and the consumed AI points: ({{price}} points / 1K tokens) is low.", - "import_confirm": "Upload file", - "import_data_preview": "Data preview", - "import_data_process_setting": "Data processing method", + "import_confirm": "Upload files", + "import_data_preview": "Preview data", + "import_data_process_setting": "Data processing", "import_file_parse_setting": "File parsing", "import_model_config": "Select model", - "import_param_setting": "Parameters", - "import_select_file": "Select a file", + "import_param_setting": "Configure parameters", + "import_select_file": "Select files", "import_select_link": "Link", - "index_prefix_title": "Add title to index", - "index_prefix_title_tips": "Auto add titles to all indexes", + "index_prefix_title": "Add titles to indexes", + "index_prefix_title_tips": "Automatically add titles to all indexes.", "index_size": "Index size", - "index_size_tips": "During vectorization, content is automatically split into chunks based on the configured maximum chunk size.", - "input_required_field_to_select_baseurl": "Please specify the required fields first.", + "index_size_tips": "The content length during vectorization. The system will automatically create indexes for chunks based on the specified index size.", + "input_required_field_to_select_baseurl": "Please configure the required fields first.", "insert_images": "Add image", "insert_images_success": "Image added successfully. It will be displayed only after training is complete.", "is_open_schedule": "Enable scheduled sync", "keep_image": "Retain image", - "llm_paragraph_mode": "Recognize paragraph", + "llm_paragraph_mode": "Paragraph recognition", "llm_paragraph_mode_auto": "Auto", - "llm_paragraph_mode_auto_desc": "Enable model recognition if the text does not contain title in Markdown format.", - "llm_paragraph_mode_forbid": "Disable", - "llm_paragraph_mode_forbid_desc": "Forcibly disable paragraph auto recognition.", - "llm_paragraph_mode_force": "Enable", - "llm_paragraph_mode_force_desc": "Forcibly perform auto paragraph recognition using the model and ignore the original paragraph structure (if any).", + "llm_paragraph_mode_auto_desc": "Enable paragraph recognition if the text does not contain titles in Markdown format.", + "llm_paragraph_mode_forbid": "Disabled", + "llm_paragraph_mode_forbid_desc": "Forcibly disable auto paragraph recognition.", + "llm_paragraph_mode_force": "Enabled", + "llm_paragraph_mode_force_desc": "Forcibly implement auto paragraph recognition using the model and ignore the original paragraph structure (if any).", "loading": "Loading...", "max_chunk_size": "Max chunk size", - "move.hint": "If moved, the selected apps or folders will inherit the permissions of the destination folder, and their current permissions will become invalid.", + "move.hint": "If moved, the knowledge base or folder will inherit the permissions of the destination folder, invalidating its current permissions.", "noChildren": "The subdirectory does not exist.", - "noSelectedFolder": "Please select at least one folder.", - "noSelectedId": "Please select at least one ID.", - "noValidId": "All IDs are invalid.", - "open_auto_sync": "After enabling scheduled synchronization, the system will attempt to synchronize the collection at random times each day. During the synchronization process, data from the collection may not be searchable.", + "noSelectedFolder": "Please select a folder first.", + "noSelectedId": "Please select an ID first.", + "noValidId": "No valid IDs available.", + "open_auto_sync": "If scheduled sync is enabled, the system will attempt to sync the collection at any time every day. Data from the collection cannot be searched during the collection sync.", "other_dataset": "Third-party knowledge bases", "paragraph_max_deep": "Max paragraph depth", - "paragraph_split": "Split by paragraph", - "paragraph_split_tip": "Prioritize chunking by Markdown heading paragraphs; if the chunks are too long, perform secondary chunking by length.", + "paragraph_split": "Chunking by paragraph", + "paragraph_split_tip": "Split the text based on Markdown heading structure first. If a chunk exceeds the maximum length, it will be split further based on the length.", "params_config": "Configuration", "pdf_enhance_parse": "Enhanced PDF parsing", "pdf_enhance_parse_price": "{{price}} points/page", - "pdf_enhance_parse_tips": "PDF recognition model supports parsing PDF files, converting PDF files into Markdown format with images preserved, and processing scanned copies of PDF files, which takes a longer time.", - "permission.des.manage": "Manage the entire knowledge base data and information", - "permission.des.read": "Can view knowledge base content", - "permission.des.write": "Can add and modify knowledge base content", + "pdf_enhance_parse_tips": "Call a PDF recognition model to parse PDF files, converting PDF files into Markdown format with images preserved, and process scanned copies of PDF files, which takes a longer time.", + "permission.des.manage": "Manages all knowledge base data and information.", + "permission.des.read": "Views the knowledge base content.", + "permission.des.write": "Adds and modifies the knowledge base content.", "pleaseFillUserIdAndToken": "User ID and token are required.", "preview_chunk": "Chunk preview", "preview_chunk_empty": "The file is empty.", - "preview_chunk_folder_warning": "Directory preview not supported.", - "preview_chunk_intro": "{{total}} chunks in total. Up to 10 chunks can be displayed.", - "preview_chunk_not_selected": "Click the file on the left to preview it.", + "preview_chunk_folder_warning": "Directory preview is not supported.", + "preview_chunk_intro": "Total chunks: {{total}}, Max displayed:10", + "preview_chunk_not_selected": "Click a file on the left for preview.", "process.Auto_Index": "Auto index generation", "process.Get QA": "Q&A pair extraction", "process.Image_Index": "Image index generation", @@ -165,131 +165,131 @@ "process.Parsing": "Parsing content...", "process.Vectorizing": "Index vectorization", "process.Waiting": "Waiting", - "rebuild_embedding_start_tip": "The task of switching the index model has started.", + "rebuild_embedding_start_tip": "Switching index model...", "rebuilding_index_count": "Indexes being rebuilt: {{count}}", "request_headers": "Request header parameter. Bearer will be automatically added.", - "retain_collection": "Adjust training parameter", - "retrain_task_submitted": "Retraining task has been submitted", + "retain_collection": "Adjust training parameters", + "retrain_task_submitted": "The retraining task has been submitted.", "retry_all": "Retry all", - "retry_failed": "Operation failed again.", + "retry_failed": "Operation failed.", "rootDirectoryFormatError": "The entries in the root directory are invalid.", "rootdirectory": "/root directory", "same_api_collection": "The API collection already exists.", "selectDirectory": "Select", "selectRootFolder": "Select", - "split_chunk_char": "By specified delimiter", - "split_chunk_size": "By length", + "split_chunk_char": "Chunking by specified delimiter", + "split_chunk_size": "Chunking by length", "split_sign_break": "1 line break", "split_sign_break2": "2 line breaks", "split_sign_custom": "Custom", "split_sign_exclamatiob": "Exclamation mark", - "split_sign_null": "Ignore", + "split_sign_null": "Not specified", "split_sign_period": "Period", "split_sign_question": "Question mark", - "split_sign_semicolon": "semicolon", + "split_sign_semicolon": "Semicolon", "start_sync_dataset_tip": "Are you sure you want to sync the entire knowledge base?", "status_error": "Running error occurred.", - "sync_collection_failed": "Synchronization collection error, please check if the source file can be accessed normally.", + "sync_collection_failed": "Error occurred while syncing the collection. Please check whether the source file is accessible.", "sync_schedule": "Scheduled sync", - "sync_schedule_tip": "Only existing collections will be synced. Including the collection of links and all collections in the API knowledge base. The system performs daily polling updates, and the exact update time cannot be determined.", + "sync_schedule_tip": "Only existing collections will be synced, including link collections and all collections in the API knowledge base. The update time is not fixed because the system will update collections based on round-robin scheduling.", "tag.Add_new_tag": "Add tag", "tag.Edit_tag": "Edit tag", - "tag.add": "Create", + "tag.add": "Create the tag", "tag.add_new": "Add", "tag.cancel": "Deselect", - "tag.delete_tag_confirm": "Are you sure you want to delete the label?", - "tag.manage": "Tags", + "tag.delete_tag_confirm": "Are you sure you want to delete the tag?", + "tag.manage": "Manage tags", "tag.searchOrAddTag": "Search or add", "tag.tags": "Tag", - "tag.total_tags": "{{total}} tags in total", - "template_dataset": "Import template", - "template_file_invalid": "The template file format is incorrect; it should be a CSV file with the first column as q, a, indexes.", + "tag.total_tags": "Total tags: {{total}}", + "template_dataset": "Import from template", + "template_file_invalid": "Invalid template file. It must be a CSV file with the first columns being q,a,indexes.", "template_mode": "Import template", - "the_knowledge_base_has_indexes_that_are_being_trained_or_being_rebuilt": "The knowledge base has indexes that are in training or being rebuilt.", - "total_num_files": "{{total}} files in total", + "the_knowledge_base_has_indexes_that_are_being_trained_or_being_rebuilt": "The knowledge base contains indexes that are being trained or rebuilt.", + "total_num_files": "Total files: {{total}}", "training.Error": "{{count}} groups encountered errors.", "training.Image mode": "Image processing", "training.Normal": "Normal", "training_mode": "Chunk mode", "training_ready": "{count} groups", - "upload_by_template_format": "Upload according to the template file", + "upload_by_template_format": "File format must be consistent with the template.", "uploading_progress": "Uploading: {{num}}%", - "vector_model_max_tokens_tip": "Each chunk of data has Cannot exceed 3000 tokens.", - "vllm_model": "Image understanding model", - "vlm_model_required_warning": "Image understanding model is required.", + "vector_model_max_tokens_tip": "Each chunk cannot exceed 3000 tokens.", + "vllm_model": "VLM", + "vlm_model_required_warning": "A VLM is required.", "website_dataset": "Website sync", - "website_dataset_desc": "Using crawlers Bulk batch scrape web data to build a knowledge base.", - "website_info": "Website information", + "website_dataset_desc": "Bulk crawl data on a website to build a knowledge base.", + "website_info": "Website info", "yuque_dataset": "Yuque knowledge base", "yuque_dataset_config": "Configure Yuque knowledge base", - "yuque_dataset_desc": "You can configure the permissions of Yuque to build a knowledge base using Yuque, and the documents will not be stored a second time.", - "enterprise_database": "数据库", - "enterprise_database_desc": "授权 MySQL 数据库后读取选定的数据表,检索时连接数据库获取相关信息", - "enterprise_database_embedding_model_tip": "索引模型可将数据库关键信息(截取部分数据的表名、表描述、列名、列描述)转成向量,用于进行语义检索", - "database_structure_change_tip": "数据结构发生变化时,如数据表名称变更或其中的列名变更等,请手动刷新数据源。", - "refresh_data_source": "刷新数据源", - "search_name_or_description": "搜索名称或描述", - "connect_database": "连接数据库", - "data_config": "数据配置", - "database_type": "数据库类型", - "mysql_description": "支持 5.7 及其以上版本", - "database_host": "数据库地址", - "host_placeholder": "请输入IP地址或域名", - "host_tips": "请确保数据库所在与本平台连通,填入可访问的地址", - "host_required": "数据库地址不能为空", - "port": "端口号", - "port_required": "端口号不能为空", - "port_invalid": "端口号格式不正确", - "port_range_error": "端口号必须在1-65535范围内", - "database_name": "数据库名称", - "database_name_placeholder": "请输入数据库名称", - "database_name_required": "数据库名称不能为空", - "database_username": "数据库用户名", - "username_placeholder": "请输入用户名", - "username_required": "用户名不能为空", - "database_password": "数据库密码", - "password_placeholder": "请输入密码", - "password_required": "密码不能为空", - "connection_pool_size": "连接池大小", - "connection_pool_required": "连接池大小不能为空", - "connection_pool_min_error": "连接池大小不能小于1", - "connection_pool_max_error": "连接池大小不能大于100", - "connect_next_step": "连接并下一步", - "database_config_title": "知识库将调用已选数据表中的数据进行索引", - "search_tables": "数据表名", - "table_selection_warning": "存在未选择内容", - "table_description": "数据表描述", - "table_description_placeholder": "默认值是数据表表自带的描述", - "default_table_description": "默认值是数据表表自带的描述", - "column_configuration": "数据列配置", - "search_columns": "列名", - "column_name": "列名", - "column_type": "类型", - "column_description": "描述", - "column_enabled": "启用", - "default_column_description": "默认值是数据表表自带的描述", - "confirm": "确认", - "edit_database_config_warning": "数据源已配置,发生 {{changedCount}} 个数据表存在列的更改,{{deletedCount}} 个数据表已不存在,请修正最新数据。", - "edit_database_warning": "修改数据库信息后,当前正在访问数据库的应用将断开重连,可能会导致正在运行的应用无法检索到结果。", - "database_config": "配置", - "refresh_success": "刷新成功", - "no_data_changes": "未出现信息变更", - "refresh_datasource": "刷新数据源", - "reconnect_success": "重新连接数据库,变更配置成功", - "auth_failed": "数据库连接失败,认证未通过,请检查用户名和密码。", - "connection_failed": "连接失败", - "reconnecting": "重新连接中", - "reconnect_success_detail": "已重新连接数据库,变更配置成功", - "table_changes_notice": "发现 {{changedCount}} 个数据表存在列的变更,{{deletedCount}} 个数据表已不存在,请核查最新数据。", - "reconnect_database": "重新连接数据库", - "column_desc_accuracy_tip": "为了提高问答准确率,请准确填写列描述,用来解释此列数据的含义和用途,大模型将会根据列描述选择对应的列数据进行检索和生成回答", - "default_table_desc_tip": "默认使用数据表中定义的描述。", - "column_enabled_tip": "启用后表示使用该列数据进行检索及回答", - "connecting": "正在连接", - "test_connectivity": "测试连通性", - "connect_and_next": "连接并进行下一步", - "connection_network_error": "连接失败,请检查网络连接", - "validate_ip_tip": "该输入项禁止使用本地回环地址,如:localhost、127.x.x.x、0.0.0.0", + "yuque_dataset_desc": "You can build a knowledge base from Yuque documents and set permissions. The documents will not be stored again.", + "enterprise_database": "Database", + "enterprise_database_desc": "Create a knowledge base and connect it to a MySQL database to search for related information.", + "enterprise_database_embedding_model_tip": "The index model can convert critical database information (including the table name, table description, column name, and column description) into vectors for semantic search.", + "database_structure_change_tip": "When the data structure changes, including changes to the table or column name, please refresh the data sources.", + "refresh_data_source": "Refresh", + "search_name_or_description": "Name, description", + "connect_database": "Connect to database", + "data_config": "Select data tables", + "database_type": "Database type", + "mysql_description": "Supported versions: MySQL 5.7 and later", + "database_host": "Database address", + "host_placeholder": "IP address or domain name", + "host_tips": "Please enter an accessible address. Ensure the database is connected to FastGPT.", + "host_required": "Database address is required.", + "port": "Port", + "port_required": "Port is required.", + "port_invalid": "Port is invalid.", + "port_range_error": "Port range: 1-65535", + "database_name": "Database name", + "database_name_placeholder": " ", + "database_name_required": "Database name is required.", + "database_username": "Username", + "username_placeholder": " ", + "username_required": "Username is required.", + "database_password": "Password", + "password_placeholder": " ", + "password_required": "Password is required.", + "connection_pool_size": "Max connections", + "connection_pool_required": "Max connections is required.", + "connection_pool_min_error": "Max connections cannot be smaller than 1.", + "connection_pool_max_error": "Max connections cannot exceed 100.", + "connect_next_step": "Connect and proceed", + "database_config_title": "The knowledge base will create indexes for data in the selected data tables.", + "search_tables": "Data table name", + "table_selection_warning": "Configuration not completed.", + "table_description": "Data table description", + "table_description_placeholder": "Default value: Predefined description in the data table", + "default_table_description": "Default value: Predefined description in the data table", + "column_configuration": "Column configuration", + "search_columns": "Column name", + "column_name": "Column name", + "column_type": "Type", + "column_description": "Description", + "column_enabled": "Status", + "default_column_description": "Default value: Predefined description in the data table", + "confirm": "OK", + "edit_database_config_warning": "Data sources have been configured. {{changedCount}} data tables have column changes, and {{deletedCount}} no longer exist. Please update the data.", + "edit_database_warning": "After database information is modified, apps will be disconnected from the database and may fail to search for information.", + "database_config": "Configure", + "refresh_success": "Refreshed successfully.", + "no_data_changes": "No information changed.", + "refresh_datasource": "Refresh", + "reconnect_success": "Reconnected to the database. Configuration changed successfully.", + "auth_failed": "Failed to connect to the database because authentication failed. Please check the username and password.", + "connection_failed": "Connection failed.", + "reconnecting": "Reconnecting...", + "reconnect_success_detail": "Reconnected to the database. Configuration changed successfully.", + "table_changes_notice": "{{changedCount}} data tables have column changes, and {{deletedCount}} no longer exist. Please update the data.", + "reconnect_database": "Reconnect to database", + "column_desc_accuracy_tip": "To increase the accuracy of answers, please accurately describe the meaning and purpose of the data in a column. The model will select the columns to search for information and generate answers based on the column description.", + "default_table_desc_tip": "The predefined description in the data table is used by default.", + "column_enabled_tip": "If enabled, the model will search this column for information needed to generate answers.", + "connecting": "Connecting", + "test_connectivity": "Test connectivity", + "connect_and_next": "Connect and proceed", + "connection_network_error": "Connection failed. Please check network connectivity.", + "validate_ip_tip": "Local loopback addresses (such as localhost, 127.x.x.x, and 0.0.0.0) are not supported.", "database": "数据库", "search_model": "检索模型", "search_model_desc": "用于生成可在数据库中检索的SQL语句,并进行检索与汇总,生成可用于对话的文本。", diff --git a/packages/web/i18n/en/file.json b/packages/web/i18n/en/file.json index f63fdc157693..9ee0f3cdde6a 100644 --- a/packages/web/i18n/en/file.json +++ b/packages/web/i18n/en/file.json @@ -1,48 +1,48 @@ { - "Action": "Please select the images to be uploaded.", + "Action": "Please select an image to upload.", "All images import failed": "Failed to import all images.", "Dataset_ID_not_found": "The dataset ID does not exist.", - "Failed_to_get_token": "Failed to obtain token.", + "Failed_to_get_token": "Failed to obtain the token.", "Image_ID_copied": "ID copied successfully.", "Image_Preview": "Image preview", - "Image_dataset_requires_VLM_model_to_be_configured": "The image dataset requires the configuration of a visual language model (VLM) to be used. Please first add a model that supports image understanding in the model configuration.", + "Image_dataset_requires_VLM_model_to_be_configured": "To use an image dataset, please add a VLM first.", "Image_does_not_belong_to_current_team": "The image does not belong to the current team.", "Image_file_does_not_exist": "The image does not exist.", "Loading_image": "Loading image...", - "Loading_image failed": "Failed to load the preview.", + "Loading_image failed": "Failed to load the image for preview.", "Only_support_uploading_one_image": "Only one image can be uploaded.", - "Please select the image to upload": "Please select the images to be uploaded.", - "Please wait for all files to upload": "Please wait for file upload to complete.", + "Please select the image to upload": "Please select an image to upload.", + "Please wait for all files to upload": "Please wait until all files are uploaded.", "bucket_chat": "Chat file", "bucket_file": "Knowledge base file", "eval_file": "Evaluation Files", "bucket_image": "Image", "click_to_view_raw_source": "Click to view source", "common.Some images failed to process": "Failed to process some images.", - "common.dataset_data_input_image_support_format": "Supported formats: .jpg, .jpeg, .png, .gif, .webp", + "common.dataset_data_input_image_support_format": "Supported formats: .jpg, .jpeg, .png, .gif, and .webp", "count.core.dataset.collection.Create Success": "{{count}} images imported successfully.", "delete_image": "Delete image", "file_name": "File name", "file_size": "File size", "image": "Image", - "image_collection": "Collection", + "image_collection": "Image collection", "image_description": "Description", "image_description_tip": "This field is required.", - "please_upload_image_first": "Please upload the image first.", + "please_upload_image_first": "Please upload an image first.", "reached_max_file_count": "The number of files has reached the maximum.", "release_the_mouse_to_upload_the_file": "Release to upload the file", "select_and_drag_file_tip": "Drag & drop or click to upload file", "select_file_amount_limit": "Up to {{max}} files can be selected.", - "some_file_count_exceeds_limit": "Exceeded {{maxCount}} files, automatically truncated", - "some_file_size_exceeds_limit": "Some files exceed {{maxSize}} and have been filtered.", + "some_file_count_exceeds_limit": "The number of files exceeded the maximum ({{maxCount}}). The excess ones were automatically ignored.", + "some_file_size_exceeds_limit": "Some files were removed because their size exceeded the maximum ({{maxSize}}).", "support_file_type": "Supported file type: {{fileType}}", "support_max_count": "Up to {{maxCount}} files are supported.", - "support_max_size": "The file size cannot exceed {{maxSize}}.", + "support_max_size": "File size cannot exceed {{maxSize}}.", "template_csv_file_select_tip": "Must be a {{fileType}} file strictly consistent with the template.", - "template_strict_highlight": "Strictly follow the template", - "total_files": "{{selectFiles.length}} files in total", + "template_strict_highlight": "Strictly consistent with the template", + "total_files": "Total files: {{selectFiles.length}}", "upload_error_description": "Only multiple files or one folder can be uploaded at a time.", - "upload_failed": "Error occurred while uploading.", - "upload_file_error": "This field is required.", + "upload_failed": "Error occurred during the upload.", + "upload_file_error": "Please upload an image.", "uploading": "Uploading..." } diff --git a/packages/web/i18n/en/login.json b/packages/web/i18n/en/login.json index 2020d6ec5bd9..4b17b5b14a6c 100644 --- a/packages/web/i18n/en/login.json +++ b/packages/web/i18n/en/login.json @@ -1,21 +1,21 @@ { - "Chinese_ip_tip": "Detected that you are using a Mainland China IP. Click to visit the Mainland China version.", + "Chinese_ip_tip": "Your IP address is in Mainland China. Click to use the Chinese Mainland edition.", "Login": "Log in", "agree": "Agree", - "cookies_tip": "This website uses cookies to provide a better service experience. By continuing to use the site, you agree to our Cookie Policy.", + "cookies_tip": "This website uses cookies to improve your experience. By continuing to use this website, you agree to the Cookie Policy.", "forget_password": "Forgot password?", "login_failed": "Login error occurred.", "login_success": "Login successful.", "no_remind": "Do not show this again.", - "password_condition": "Password cannot exceed 60 characters long.", + "password_condition": "Password cannot exceed 60 characters.", "password_tip": "Password must be at least 8 characters long and contain at least 2 of the following: digits, letters, and special characters.", - "policy_tip": "By using this service, you acknowledge that you have read and agree to the Terms of Use> and Privacy Policy.", - "privacy": "Privacy terms", + "policy_tip": "By logging in, you have read and agree to the EULA and DPA.", + "privacy": "DPA", "privacy_policy": "Privacy Policy", - "redirect": "Redirect", + "redirect": "Go now", "register": "Sign up", - "root_password_placeholder": "The root user password is the value of the environment variable DEFAULT_ROOT_PSW.", - "terms": "Terms of use", - "use_root_login": "Log in as the root user", + "root_password_placeholder": "The root account password is the value of the environment variable DEFAULT_ROOT_PSW.", + "terms": "EULA", + "use_root_login": "Log in with root account", "wecom": "WeCom" } diff --git a/packages/web/i18n/en/publish.json b/packages/web/i18n/en/publish.json index b791ba4b7505..786722384f84 100644 --- a/packages/web/i18n/en/publish.json +++ b/packages/web/i18n/en/publish.json @@ -1,31 +1,31 @@ { - "app_key_tips": "These keys already have the current application identifier. For specific usage, please refer to the documentation.", + "app_key_tips": "These keys have already been associated with the current app ID. For details about how to use them, see the documentation.", "basic_info": "Basics", - "config": "Visibility configuration", - "copy_link_hint": "Copy the link to the specified location", + "config": "Visibility", + "copy_link_hint": "Copy the following link to the specified location.", "create_api_key": "Create key", "create_link": "Create link", - "edit_api_key": "Edit key information", + "edit_api_key": "Edit key", "edit_feishu_bot": "Edit Feishu bot", "edit_link": "Edit", - "feishu_api": "Feishu interface", + "feishu_api": "Feishu API", "feishu_bot": "Feishu bot", "feishu_bot_desc": "Connect to Feishu bot through API", "key_alias": "Alias of the key. For display only.", "key_tips": "You can use an API key to access certain interfaces. (Accessing apps requires in-app API keys.)", - "link_name": "Name of the shared link", + "link_name": "Name of the sharing link", "new_feishu_bot": "Add Feishu bot", - "official_account.create_modal_title": "Create WeChat Official Account Access", - "official_account.desc": "Directly connect to the WeChat Official Account via API", + "official_account.create_modal_title": "Create WeChat official account connection", + "official_account.desc": "Connect to WeChat official account via API", "official_account.edit_modal_title": "Edit WeChat official account connection", "official_account.name": "WeChat official account connection", "official_account.params": "WeChat official account parameter", - "private_config": "Visibility configuration", + "private_config": "Visibility", "publish_name": "Name", "qpm_is_empty": "QPM is required.", - "qpm_tips": "How many questions can be asked per minute per IP?", + "qpm_tips": "The maximum number of questions allowed per minute from an IP address", "request_address": "Request URL", - "show_node": "realtime operating status", + "show_node": "Realtime running status", "show_share_link_modal_title": "Use now", "token_auth": "Authentication", "token_auth_tips": "Authentication server address", @@ -35,11 +35,11 @@ "wecom.bot_desc": "Connect to WeCom bot through API", "wecom.create_modal_title": "Create WeCom bot", "wecom.edit_modal_title": "Edit WeCom bot", - "wecom.title": "Publish app to WeCom bot", + "wecom.title": "Publish app via WeCom bot", "dingtalk.bot": "DingTalk bot", "dingtalk.bot_desc": "Connect to DingTalk bot through API", "dingtalk.create_modal_title": "Create DingTalk bot", "dingtalk.edit_modal_title": "Edit DingTalk bot", - "dingtalk.title": "Publish app to DingTalk bot", + "dingtalk.title": "Publish app via DingTalk bot", "dingtalk.api": "DingTalk API" } diff --git a/packages/web/i18n/en/user.json b/packages/web/i18n/en/user.json index 2fa238d41b93..9cbf5493abd0 100644 --- a/packages/web/i18n/en/user.json +++ b/packages/web/i18n/en/user.json @@ -6,111 +6,111 @@ "bill.convert_error": "Operation failed.", "bill.convert_success": "Redeemed successfully.", "bill.current_token_price": "Current point price", - "bill.not_need_invoice": "Balance payment, unable to issue an invoice", + "bill.not_need_invoice": "Unable to issue an invoice because the payment was deducted from the balance.", "bill.price": "Unit price", "bill.renew_plan": "Renew plan", "bill.standard_valid_tip": "Plan usage rules: Higher-level plans are used first. Unused lower-level plans will be applied later.", "bill.token_expire_1year": "Points are valid for 1 year.", "bill.tokens": "Points", - "bill.use_balance": "Use balance", + "bill.use_balance": "Pay by balance", "bill.use_balance_hint": "Due to a system upgrade, renewal with auto reduction from balance has been disabled, and balance top-up is no longer available. Your balance can still be used to purchase points.", "bill.valid_time": "Valid since", "bill.you_can_convert": "You can redeem", - "bill.yuan": "CNY", - "delete.admin_failed": "Failed to delete admin.", + "bill.yuan": "CNY.", + "delete.admin_failed": "Failed to delete the admin.", "delete.admin_success": "Admin deleted successfully.", "delete.failed": "Operation failed.", "delete.success": "Deleted successfully.", "has_chosen": "Selected", - "login.Dingtalk": "DingTalk Login", + "login.Dingtalk": "DingTalk login", "login.error": "Login error occurred.", - "login.password_condition": "Password cannot exceed 60 characters long.", + "login.password_condition": "Password cannot exceed 60 characters.", "login.success": "Login successful.", "manage_team": "Management team", "name": "Name", "new_password": "New password", - "notification.remind_owner_bind": "Remind the creator to specify an account to receive notifications.", + "notification.remind_owner_bind": "Please remind the creator to specify an account to receive notifications.", "operations": "Operation", "owner": "Owner", "password.code_required": "Verification code is required.", "password.code_send_error": "Error occurred while sending the verification code.", "password.code_sended": "Verification code sent successfully.", "password.confirm": "Confirm password", - "password.email_phone_error": "Email or mobile number is invalid.", - "password.email_phone_void": "Email and mobile number are required.", + "password.email_phone_error": "Email address or mobile number is invalid.", + "password.email_phone_void": "Email address and mobile number are required.", "password.not_match": "Passwords do not match.", "password.password_condition": "Password must contain 4-20 characters.", - "password.password_required": "The password is required.", - "password.retrieve": "Retrieve Password", - "password.retrieved": "Password has been recovered", - "password.retrieved_account": "Recover {{account}} account", + "password.password_required": "Password is required.", + "password.retrieve": "Reset password", + "password.retrieved": "Password reset successfully.", + "password.retrieved_account": "Retrieve account ({{account}})", "password.to_login": "Log in", - "password.verification_code": "Code", - "permission.Add": "Add permissions", - "permission.Manage": "Administrator", - "permission.Manage tip": "Team administrator, with full permissions", + "password.verification_code": "Verification code", + "permission.Add": "Add permission", + "permission.Manage": "Admin", + "permission.Manage tip": "The team administrator has full permissions.", "permission.Read": "Read-only", - "permission.Read desc": "Members can only read related resources and cannot create new ones.", - "permission.Write": "Write", - "permission.Write tip": "In addition to readable resources, new resources can also be created.", - "permission.only_collaborators": "Collaborators only", - "permission.team_read": "Team accessible", - "permission.team_write": "Edit team", - "permission_add_tip": "After adding, you can select permissions for it.", - "permission_des.manage": "Can create resources, invite, and remove members", - "permission_des.read": "Members can only read related resources and cannot create new ones.", - "permission_des.write": "In addition to readable resources, new resources can also be created.", + "permission.Read desc": "Members can only read related resources but cannot create new ones.", + "permission.Write": "Read and write", + "permission.Write tip": "Members can read and create resources.", + "permission.only_collaborators": "Accessible to collaborators only", + "permission.team_read": "Accessible to team", + "permission.team_write": "Editable by team", + "permission_add_tip": "After the member is added, you can grant permissions to it.", + "permission_des.manage": "Admins can create resources and invite or delete members.", + "permission_des.read": "Members can only read related resources but cannot create new ones.", + "permission_des.write": "Members can read and create resources.", "permissions": "Permission", - "personal_information": "Personal info", + "personal_information": "Profile", "personalization": "Personalization", "promotion_records": "Promotion Records", - "register.confirm": "Confirm Registration", + "register.confirm": "Confirm", "register.register_account": "Register {{account}} account", "register.success": "Registered successfully.", - "register.to_login": "Have an account? Sign In", - "search_group_org_user": "Search member or group name", - "search_user": "Search username", - "sso_auth_failed": "SSO authentication failed", - "synchronization.button": "Sync Now", - "synchronization.placeholder": "Please enter the synchronization label", - "synchronization.title": "Fill in the label sync link, click the sync button to synchronize.", - "team.Add manager": "Add account", - "team.Confirm Invite": "Confirm Invitation", - "team.Create Team": "Create a new team", - "team.Invite Member Failed Tip": "Inviting members encountered an exception", - "team.Invite Member Result Tip": "Invitation Result Notification", - "team.Invite Member Success Tip": "Invite members to complete\nSuccess: {{success}} people\nUsername invalid: {{inValid}}\nAlready in the team: {{inTeam}}", + "register.to_login": "Have an account? Sign in", + "search_group_org_user": "Member, department, group name", + "search_user": "Username", + "sso_auth_failed": "SSO authentication failed.", + "synchronization.button": "Sync now", + "synchronization.placeholder": "Tag to be synced", + "synchronization.title": "To sync tags, enter the tag sync link and click Sync now.", + "team.Add manager": "Add admin", + "team.Confirm Invite": "Confirm", + "team.Create Team": "Create team", + "team.Invite Member Failed Tip": "Error occurred while inviting members.", + "team.Invite Member Result Tip": "Message", + "team.Invite Member Success Tip": "Invitation completed.\nMembers joined successfully: {{success}}\nInvalid usernames: {{inValid}}\nMembers already in the team: {{inTeam}}", "team.Set Name": "Name the team", - "team.Team Name": "Community", - "team.Update Team": "Update team information", - "team.add_collaborator": "Add Collaborator", - "team.add_permission": "Add permissions", + "team.Team Name": "Team name", + "team.Update Team": "Update team info", + "team.add_collaborator": "Add collaborator", + "team.add_permission": "Add permission", "team.add_writer": "Add writable members", "team.avatar_and_name": "Tool image & name", - "team.belong_to_group": "Group:", - "team.group.avatar": "Group avatar", + "team.belong_to_group": "Group", + "team.group.avatar": "Group profile image", "team.group.create": "Create group", - "team.group.create_failed": "Failed to create the user group.", + "team.group.create_failed": "Failed to create the group.", "team.group.default_group": "Default group", "team.group.delete_confirm": "Are you sure you want to delete the group?", - "team.group.edit": "Edit Group", - "team.group.edit_info": "Edit", + "team.group.edit": "Edit group", + "team.group.edit_info": "Edit info", "team.group.group": "Group", - "team.group.keep_admin": "Admin", - "team.group.manage_member": "Manage member", + "team.group.keep_admin": "Retain admin permissions", + "team.group.manage_member": "Manage members", "team.group.manage_tip": "Manage members, create groups, manage all groups, and assign permissions to groups and members.", "team.group.members": "Member", - "team.group.name": "Name", - "team.group.permission_tip": "Members with individually configured permissions will follow their personal permission settings and will no longer be affected by group permissions.\nIf a member is in multiple permission groups, the member's permissions will be the union of all permissions.", - "team.group.role.admin": "Administrator", + "team.group.name": "Group name", + "team.group.permission_tip": "Members with separately configured permissions will not be affected by group permissions.\nIf a member is added to multiple permission groups, their permissions are combined.", + "team.group.role.admin": "Admin", "team.group.role.member": "Member", "team.group.role.owner": "Owner", - "team.group.set_as_admin": "Set as administrator", - "team.group.toast.can_not_delete_owner": "Cannot delete owner, please transfer ownership first.", + "team.group.set_as_admin": "Set as admin", + "team.group.toast.can_not_delete_owner": "The owner cannot be deleted. Please transfer ownership first.", "team.group.transfer_owner": "Transfer ownership", - "team.manage_collaborators": "Manage Collaborators", - "team.no_collaborators": "No collaborators", + "team.manage_collaborators": "Manage collaborators", + "team.no_collaborators": "No collaborators available.", "team.org.org": "Department", - "team.write_role_member": "Writable permission", - "team.collaborator.added": "Added" + "team.write_role_member": "Write permission", + "team.collaborator.added": "Added successfully." } diff --git a/packages/web/i18n/en/workflow.json b/packages/web/i18n/en/workflow.json index e26cf3393bd1..bbad81ab0ff4 100644 --- a/packages/web/i18n/en/workflow.json +++ b/packages/web/i18n/en/workflow.json @@ -1,225 +1,225 @@ { - "Array_element": "Array elements", - "Array_element_index": "subscript", - "Click": "Click", + "Array_element": "Array element", + "Array_element_index": "Subscript", + "Click": "Click to", "Code": "Code", - "Confirm_sync_node": "The configuration will be updated to the latest node settings, and any fields not present in the template will be removed (including all custom fields).\nIf the field is relatively complex, it is recommended that you first make a copy of the node and then update the original node to facilitate parameter copying.", - "Drag": "Drag and drop", - "Node.Open_Node_Course": "View Node Tutorial", - "Node_variables": "Node variables", - "Quote_prompt_setting": "Reference prompt configuration", - "Variable.Variable type": "Type", - "Variable_name": "Variable Name", + "Confirm_sync_node": "Nodes will be updated to the latest configuration. Fields that do not exist in the template (including all custom fields) will be deleted.\nIf the fields are complex, it is recommended to copy the node before updating it.", + "Drag": "Drag to", + "Node.Open_Node_Course": "Guide", + "Node_variables": "Node variable", + "Quote_prompt_setting": "Configure prompt", + "Variable.Variable type": "Variable type", + "Variable_name": "Variable name", "add_new_input": "Add input", - "add_new_output": "Add input", - "append_application_reply_to_history_as_new_context": "Concatenate the app's reply content to the history record and return it as the new context.", - "application_call": "App invocation", - "assigned_reply": "Specified reply", - "auth_tmb_id": "User Authentication", - "auth_tmb_id_tip": "When enabled, when the application is published externally, it will also filter the knowledge base based on whether the user has access to it.\nIf not enabled, it will directly retrieve from the configured knowledge base without permission filtering.", - "auto_align": "Auto-align", - "can_not_loop": "This node does not support loop nesting", + "add_new_output": "Add output", + "append_application_reply_to_history_as_new_context": "Combine the content answered by the app with history to output a new context.", + "application_call": "App call", + "assigned_reply": "Specified answer", + "auth_tmb_id": "User authentication", + "auth_tmb_id_tip": "If enabled, knowledge bases on which the user does not have access permissions will be removed when the app is published.\nIf disabled, the system will search from the configured knowledge base without permission filtering.", + "auto_align": "Auto align", + "can_not_loop": "The node does not support circular nesting.", "choose_another_application_to_call": "Select another app to call", - "classification_result": "Classification results", + "classification_result": "Classification result", "click_to_change_reference": "Click to switch input mode", - "click_to_change_value": "Click to toggle variable reference mode", - "code.Reset template": "Restored", - "code.Reset template confirm": "Confirm restore code template? All inputs and outputs will be reset to template values. Please save your current code.", - "code.Switch language confirm": "Switching languages will reset the code, do you want to continue?", - "code_execution": "Code Execution", - "collection_metadata_filter": "Filter collection metadata", - "complete_extraction_result": "Full extraction result", + "click_to_change_value": "Click to switch variable reference mode", + "code.Reset template": "Restore template", + "code.Reset template confirm": "Are you sure you want to restore the code template? All inputs and outputs will be reset to the default values. Please save the current code.", + "code.Switch language confirm": "Changing the language will reset the code. Would you like to proceed?", + "code_execution": "Code running", + "collection_metadata_filter": "Collection metadata filtering", + "complete_extraction_result": "Complete extraction result", "complete_extraction_result_description": "A JSON string. Example:{\"name\":\"YY\",\"Time\":\"2023/7/2 18:00\"}", "concatenation_result": "Splicing result", - "concatenation_text": "Splicing text", + "concatenation_text": "Text for splicing", "condition_checker": "Judger", "confirm_delete_field_tip": "Are you sure that you want to delete this field?", - "contains": "Contains", + "contains": "contains", "content_to_retrieve": "Content to be searched", "content_to_search": "Content to be searched", "contextMenu.addComment": "Add comment", "context_menu.add_comment": "Add comment", - "create_link_error": "Link creation failed", + "create_link_error": "Error occurred while creating the link", "custom_feedback": "Custom feedback", "custom_input": "Custom input", "dataset_quote_role": "Role", - "dataset_quote_role_system_option_desc": "Prefer conversation continuity (recommended)", - "dataset_quote_role_tip": "System: Places knowledge base references in system messages to maintain conversation continuity. Constraints may be weaker, so additional tuning could be needed.\nUser: Places knowledge base references in user messages and requires specifying the {{question}} variable. Continuity may be slightly reduced, but constraints are usually enforced more effectively.", - "dataset_quote_role_user_option_desc": "Prefer strict constraints", - "dynamic_input_description": "Receive output values from frontend nodes as variables, which are available for Laf request parameters.", + "dataset_quote_role_system_option_desc": "Coherence is prioritized (recommended).", + "dataset_quote_role_tip": "If you select System, knowledge base references will be placed in system messages to maintain coherence, but constraints may be weaker. Additional debugging may be required.\nIf you select User, knowledge base references will be placed in user messages, and the position of the {{question}} variable must be specified. This may slightly affect coherence, but constraints can be enforced more effectively.", + "dataset_quote_role_user_option_desc": "Constraints are prioritized.", + "dynamic_input_description": "Output values from frontend nodes are received as variables, which can be used in LAF request parameters.", "edit_input": "Edit input", "edit_output": "Edit output", - "end_with": "End with", + "end_with": "ends with", "enter_comment": "Comment", "error_catch": "Error capture", - "error_info_returns_empty_on_success": "Returns error details if the code fails. Returns empty if it succeeds.", + "error_info_returns_empty_on_success": "Error message returned when code execution error occurs. If the code is executed successfully, a null value is returned.", "error_text": "Error details", "execute_a_simple_script_code_usually_for_complex_data_processing": "Run a short script, typically for complex data processing.", "execute_different_branches_based_on_conditions": "Execute different branches based on conditions.", "execution_error": "Running error", "extraction_requirements_description": "Extraction requirement", - "extraction_requirements_description_detail": "Provide relevant context or instructions to guide the AI in completing the task accurately. \\nYou can use global variables in this field.", - "extraction_requirements_placeholder": "Example: 1. Current time: {{cTime}}. You are a lab booking assistant tasked with extracting reservation info from text.\n2. You are a Google search assistant tasked with extracting suitable search terms from text.", - "feedback_text": "Feedback text", + "extraction_requirements_description_detail": "Provide relevant context or instructions to guide the AI in completing the task accurately. \\nYou can enter global variables.", + "extraction_requirements_placeholder": "Example: 1. Current time: {{cTime}}. You are a lab booking assistant. You need to extract lab booking information from the text.\n2. You are a Google search assistant. You need to extract suitable keywords for Google searches from the text.", + "feedback_text": "Feedback", "field_description": "Field description", - "field_description_placeholder": "Describe the function of this input field. For tool parameters, this description affects the output quality of the model.", + "field_description_placeholder": "Describe the function of the input field. If the field is used as a tool call parameter, the description will affect the output quality of the model.", "field_name_already_exists": "The field name already exists.", "field_required": "Required", - "field_used_as_tool_input": "Used as tool parameter.", - "filter_description": "Currently supports tag and creation-time filtering. Fill in the following format: { ... }\n{\n \"tags\": {\n \"$and\": [\"Tag 1\",\"Tag 2\"],\n \"$or\": [\"When $and tags exist, the $and condition applies and the $or condition is ignored\"]\n },\n \"createTime\": {\n \"$gte\": \"Use the format YYYY-MM-DD HH:mm. The collection's creation time must be later than this time\"\n \"$lte\": \"Use the format YYYY-MM-DD HH:mm. The collection's creation time must be earlier than this time and can be used together with $gte\"\n }\n}", + "field_used_as_tool_input": "Used as a tool calling parameter", + "filter_description": "Knowledge bases can be filtered based on tags and the creation time. Format: \n{\n \"tags\": {\n \"$and\": [\"Tag 1\",\"Tag 2\"],\n \"$or\": [\"When the $and tags exist, the AND condition applies and the OR condition is ignored.\"]\n },\n \"createTime\": {\n \"$gte\": \"Format: YYYY-MM-DD HH:mm. The collection's creation time must be later than this time.\"\n \"$lte\": \"Format: YYYY-MM-DD HH:mm. The collection's creation time must be earlier than this time. $lte and $gte can be used together.\"\n }\n}", "find_tip": "Find node (Ctrl + F)", "find_tip_mac": "Find node (⌘ + F)", "foldAll": "Collapse all", - "form_input_result": "Full input", + "form_input_result": "Complete user input", "form_input_result_tip": "An object containing the full result", - "full_field_extraction": "Extract full field", + "full_field_extraction": "Complete field extraction", "full_field_extraction_description": "Returns true if all fields are filled (either from model extraction or default values)", "full_response_data": "Complete response data", - "greater_than": "Greater than", - "greater_than_or_equal_to": "Greater than or equal to", - "http_body_placeholder": "Syntax similar to APIFox. Use / to activate variable selection. String variables require double quotes. Other types do not. Ensure valid JSON format", - "http_extract_output": "Extract output field", - "http_extract_output_description": "Extract specific fields from the response using JSONPath.", - "http_raw_response_description": "Raw HTTP response. Only string or JSON type data is accepted.", + "greater_than": "is greater than", + "greater_than_or_equal_to": "is greater than or equal to", + "http_body_placeholder": "Use a syntax similar to APIFox. Enter a slash (/) to select a variable. Only string variables must be enclosed in double quotation marks. Ensure the string is in JSON format.", + "http_extract_output": "Output field extraction", + "http_extract_output_description": "Extract specific fields from the response using the JSONPath syntax.", + "http_raw_response_description": "Raw HTTP response The response data must be a string or in JSON format.", "http_request": "HTTP request", "ifelse.Input value": "Input", "ifelse.Select value": "Select", "input_description": "Field description", "input_type_multiple_select": "Checkbox", - "input_variable_list": "Enter / to trigger variables.", + "input_variable_list": "Enter a slash (/) to select a variable.", "intro_assigned_reply": "This module can reply with a specified message, often used for guidance or prompts. Non-string content is automatically converted to a string.", "intro_custom_feedback": "When triggered, this module adds feedback to the current conversation, useful for automatically tracking response quality.", - "intro_custom_plugin_output": "Customize external output. When using a plugin, only the configured output is exposed.", - "intro_http_request": "Send HTTP requests to perform complex actions such as online searches or database queries.", + "intro_custom_plugin_output": "Customize external output. When a plugin is used, only the custom output is exposed.", + "intro_http_request": "Send an HTTP request to perform a more complex action (such as online search or database query).", "intro_knowledge_base_search_merge": "Merge search results from multiple knowledge bases and sort them using the RRF method.", - "intro_laf_function_call": "Invoke cloud functions under the Laf account.", + "intro_laf_function_call": "Call cloud functions under the LAF account.", "intro_loop": "Input an array and iterate over each element to execute the workflow.", "intro_plugin_input": "Configure required plugin inputs and use them to run the plugin.", - "intro_question_classification": "Determine the question type based on the user’s history and the current question. You can define multiple types, for example:\nType 1: Greeting\nType 2: Product usage\nType 3: Product purchase\nType 4: Other.", - "intro_question_optimization": "Use the question optimization feature to increase the accuracy of knowledge base searches during continuous chats. AI generates new search terms based on context for better results. This feature is built into the knowledge base search module and can also be used for single searches.", - "intro_text_concatenation": "Process fixed or input text and output it. Non-string data will be converted to strings automatically.", + "intro_question_classification": "Identify the question type based on the user's history and the current question. You can add multiple question types. Example:\nType 1: Greeting\nType 2: Product usage\nType 3: Product purchase\nType 4: Other.", + "intro_question_optimization": "Use the question optimization feature to increase the accuracy of knowledge base searches during continuous chats. The model will generate one or multiple new keywords based on the context to optimize the search results. This feature is built into the knowledge base search module and can also be used for a single search.", + "intro_text_concatenation": "Process fixed or input text and then output it. Non-string data will be automatically converted to strings.", "intro_text_content_extraction": "Extract specific data from text, such as SQL queries, search keywords, or code.", - "intro_tool_call_termination": "This module requires tool invocation. When executed, it forcibly ends the current tool call and does not use AI to answer the tool’s output.", - "intro_tool_params_config": "This module works with tool calls. You can customize parameters and pass them to downstream nodes.", + "intro_tool_call_termination": "This module requires tool calling. When this module is executed, the current tool call is terminated and the model does not answer the question based on the tool call result.", + "intro_tool_params_config": "This module works with tool calling. You can customize tool call parameters and pass them to downstream nodes.", "is_empty": "is null", - "is_equal_to": "is equal to", + "is_equal_to": "is", "is_not_empty": "is not null", "is_not_equal": "is not", - "is_tool_output_label": "As tool response", + "is_tool_output_label": "Used as a tool response", "judgment_result": "Judger result", "knowledge_base_reference": "Knowledge base reference", - "knowledge_base_search_merge": "Merge knowledge base search results", - "laf_function_call_test": "Call Laf function (test)", - "length_equal_to": "Length equals", - "length_greater_than": "Length greater than", - "length_greater_than_or_equal_to": "Length greater than or equal to", - "length_less_than": "Length less than", - "length_less_than_or_equal_to": "Length less than or equal to", - "length_not_equal_to": "Length not equal to", - "less_than": "Less than", - "less_than_or_equal_to": "Less than or equal to", + "knowledge_base_search_merge": "Reference merging", + "laf_function_call_test": "LAF function call (Beta)", + "length_equal_to": "has a length of", + "length_greater_than": "has a length greater than", + "length_greater_than_or_equal_to": "has a length no less than", + "length_less_than": "has a length less than", + "length_less_than_or_equal_to": "has a length no greater than", + "length_not_equal_to": "does not have a length of", + "less_than": "is less than", + "less_than_or_equal_to": "is less than or equal to", "loop": "Bulk execution", "loop_body": "Loop body", - "loop_end": "End", + "loop_end": "Finish", "loop_input_array": "Array", "loop_result": "Array execution result", "loop_start": "Start", - "max_dialog_rounds": "Max chat record", + "max_dialog_rounds": "Max chats remembered", "max_tokens": "Max tokens", - "mouse_priority": "Prefer mouse\nLeft-click and hold to drag the canvas.\nHold Shift + left-click to select multiple items.", + "mouse_priority": "Mouse operations\n- Click and hold to drag the canvas.\n- Press Shift and click to select multiple items.", "new_context": "New context", "next": "Next", "no_match_node": "No result", - "no_node_found": "No node found", + "no_node_found": "No node found.", "not_contains": "does not contain", "only_the_reference_type_is_supported": "Reference type only", - "optional_value_type": "Optional data type", - "optional_value_type_tip": "You can specify one or more data types. When dynamically adding fields, users can only select the configured types.", - "pan_priority": "Touchpad first\n- Click to batch select\n- Move the canvas with two fingers", - "pass_returned_object_as_output_to_next_nodes": "Pass the object returned in the code as output to the next nodes. The variable name needs to correspond to the return key.", - "please_enter_node_name": "Enter the node name", - "plugin.Instruction_Tip": "You can configure an instruction to explain the purpose of the plugin. This instruction will be displayed each time the plugin is used. Supports standard Markdown syntax.", - "plugin.Instructions": "Instructions", + "optional_value_type": "Available data types", + "optional_value_type_tip": "You can specify one or multiple data types. When users add fields, they can only choose from these data types.", + "pan_priority": "Touchpad operations\n- Tap to select multiple items.\n- Tap with two fingers to drag the canvas.", + "pass_returned_object_as_output_to_next_nodes": "Use the return value as the output and pass it to the next node. The variable name must match the key in the return value.", + "please_enter_node_name": "Node name", + "plugin.Instruction_Tip": "You can add a description to explain the purpose of the plugin. The description will be displayed each time before the plugin is used. Standard Markdown syntax is supported.", + "plugin.Instructions": "Guide", "plugin.global_file_input": "File links (deprecated)", "plugin_file_abandon_tip": "Plugin global file upload has been deprecated, please adjust it as soon as possible. \nRelated functions can be achieved through plug-in input and adding image type input.", - "plugin_input": "Plugin Input", - "plugin_output_tool": "When the plug-in is executed as a tool, whether this field responds as a result of the tool", + "plugin_input": "Plugin input", + "plugin_output_tool": "Whether the field is used as the output when the plugin runs as a tool.", "previous": "Previous", - "question_classification": "Classify", - "question_optimization": "Query extension", - "quote_content_placeholder": "The structure of the reference content can be customized to better suit different scenarios. \nSome variables can be used for template configuration\n\n{{q}} - main content\n\n{{a}} - auxiliary data\n\n{{source}} - source name\n\n{{sourceId}} - source ID\n\n{{index}} - nth reference", - "quote_content_tip": "The structure of the reference content can be customized to better suit different scenarios. Some variables can be used for template configuration:\n\n{{id}} - the unique id of the reference data\n{{q}} - main content\n{{a}} - auxiliary data\n{{source}} - source name\n{{sourceId}} - source ID\n{{index}} - nth reference\n\nThey are all optional and the following are the default values:\n\n{{default}}", - "quote_num": "Dataset", - "quote_prompt_tip": "You can use {{quote}} to insert a quote content template and {{question}} to insert a question (Role=user).\n\nThe following are the default values:\n\n{{default}}", - "quote_role_system_tip": "Please note that the {{question}} variable is removed from the \"Quote Template Prompt Words\"", - "quote_role_user_tip": "Please pay attention to adding the {{question}} variable in the \"Quote Template Prompt Word\"", - "raw_response": "Raw Response", - "reasoning_text": "Thinking text", + "question_classification": "Question category", + "question_optimization": "Question optimization", + "quote_content_placeholder": "Customize the structure of the referenced content to fit different scenarios. Use variables to configure the template.\n{{q}} - Main content\n{{a}} - Extra data\n{{source}} - Source name\n{{sourceId}} - Source ID\n{{index}} - Sequence number of the reference", + "quote_content_tip": "Customize the structure of the referenced content to fit different scenarios. Use variables to configure the template.\n{{id}} - Unique reference ID\n{{q}} - Main content\n{{a}} - Extra data\n{{source}} - Source name\n{{sourceId}} - Source ID\n{{index}} - Sequence number of the reference\nThese variables are all optional. The default values are as follows: {{default}}\n{{default}}", + "quote_num": "Reference", + "quote_prompt_tip": "Use {{quote}} to insert the reference template, and {{question}} to insert the question (Role=user).\nDefault values: {{default}}\n{{default}}", + "quote_role_system_tip": "Remove {{question}} from the prompt.", + "quote_role_user_tip": "Add {{question}} to the prompt.", + "raw_response": "Original response", + "reasoning_text": "Reasoning process", "regex": "Regex", - "reply_text": "Reply Text", - "request_error": "request_error", - "response.Code log": "Code Log", - "response.Custom inputs": "Custom Inputs", - "response.Custom outputs": "Custom Outputs", - "response.Error": "Error", - "response.Read file result": "Read File Result", - "response.read files": "Read Files", - "select_an_application": "Select an Application", - "select_another_application_to_call": "You can choose another application to call", - "select_default_option": "Select the default value", - "special_array_format": "Special array format, returns an empty array when the search result is empty.", - "start_with": "Starts With", - "support_code_language": "Support import list: pandas,numpy", - "target_fields_description": "A target field consists of 'description' and 'key'. Multiple target fields can be extracted.", - "template.agent": "Agent", - "template.agent_intro": "Automatically select one or more functional blocks for calling through the AI model, or call plugins.", - "template.ai_chat": "AI Chat", - "template.ai_chat_intro": "AI Large Model Chat", - "template.dataset_search": "Dataset Search", - "template.dataset_search_intro": "调用语义检索、全文检索、数据库检索能力,从“知识库”中查找可能与问题相关的参考内容", - "template.forbid_stream": "Forbid stream mode", - "template.forbid_stream_desc": "Forces the output mode of nested application streams to be disabled", + "reply_text": "Reply text", + "request_error": "Request error.", + "response.Code log": "Log", + "response.Custom inputs": "Custom input", + "response.Custom outputs": "Custom output", + "response.Error": "Error details", + "response.Read file result": "Parsing result preview", + "response.read files": "Parsed document", + "select_an_application": "Select app", + "select_another_application_to_call": "You can select another app to call.", + "select_default_option": "Select", + "special_array_format": "Special array format. An empty array is returned when no results are found.", + "start_with": "starts with", + "support_code_language": "Libraries that can be imported in the code: pandas, numpy", + "target_fields_description": "A target field consists of the description and key. Multiple target fields can be extracted.", + "template.agent": "Tool call", + "template.agent_intro": "The model decides which tool to call.", + "template.ai_chat": "AI chat", + "template.ai_chat_intro": "Chat with an AI model.", + "template.dataset_search": "Knowledge base search", + "template.dataset_search_intro": "Search for reference materials related to the question from the knowledge base by using the semantic search and full-text search features.", + "template.forbid_stream": "Disable output in streaming mode", + "template.forbid_stream_desc": "Force nested apps to run in non-streaming mode.", "template.plugin_output": "Plugin output", - "template.plugin_start": "Plugin start", + "template.plugin_start": "Plugin input", "template.system_config": "System", - "template.workflow_start": "Start", - "text_concatenation": "Text Editor", - "text_content_extraction": "Text Extract", - "text_to_extract": "Text to Extract", - "these_variables_will_be_input_parameters_for_code_execution": "These variables will be input parameters for code execution", - "to_add_node": "to add", - "to_connect_node": "to connect", - "tool.tool_result": "Tool operation results", - "tool_active_config": "Tool active", - "tool_active_config_type": "Tool activation: {{type}}", - "tool_call_termination": "Stop ToolCall", - "tool_custom_field": "Custom Tool", - "tool_field": " Tool Field Parameter Configuration", - "tool_input": "Tool Input", + "template.workflow_start": "Process startup", + "text_concatenation": "Text splicing", + "text_content_extraction": "Text extraction", + "text_to_extract": "Text to be extracted", + "these_variables_will_be_input_parameters_for_code_execution": "These variables will be used as input parameters for code execution.", + "to_add_node": "add node", + "to_connect_node": "connect nodes", + "tool.tool_result": "Tool running result", + "tool_active_config": "Tool activation", + "tool_active_config_type": "Tool activated: {{type}}.", + "tool_call_termination": "Tool call ended.", + "tool_custom_field": "Customize tool variable", + "tool_field": "Configure tool parameter", + "tool_input": "Tool parameter", "tool_params.enum_placeholder": "apple \npeach \nwatermelon", - "tool_params.enum_values": "Enum values", - "tool_params.enum_values_tip": "List the possible values for this field, one per line", - "tool_params.params_description": "Description", - "tool_params.params_description_placeholder": "Name/Age/SQL statement..", - "tool_params.params_name": "Name", + "tool_params.enum_values": "Enumeration values (optional)", + "tool_params.enum_values_tip": "Valid values for the field. One value per line.", + "tool_params.params_description": "Parameter description", + "tool_params.params_description_placeholder": "Name, age, SQL statement, etc.", + "tool_params.params_name": "Parameter name", "tool_params.params_name_placeholder": "name/age/sql", - "tool_params.tool_params_result": "Parameter configuration results", - "tool_raw_response_description": "The original response of the tool", - "trigger_after_application_completion": "Will be triggered after the application is fully completed", + "tool_params.tool_params_result": "Parameter configuration result", + "tool_raw_response_description": "Original response from the tool", + "trigger_after_application_completion": "It will be triggered after the app is fully executed.", "unFoldAll": "Expand all", - "update_link_error": "Error updating link", - "update_specified_node_output_or_global_variable": "Can update the output value of a specified node or update global variables", + "update_link_error": "Error occurred while updating the link.", + "update_specified_node_output_or_global_variable": "Update a node's output or set a global variable.", "use_user_id": "User ID", - "user_form_input_config": "Form configuration", - "user_form_input_description": "describe", - "user_form_input_name": "Name", - "user_question": "User Question", - "user_question_tool_desc": "User input questions (questions need to be improved)", + "user_form_input_config": "Template settings", + "user_form_input_description": "Description", + "user_form_input_name": "Topic", + "user_question": "Question", + "user_question_tool_desc": "Question (optimization required)", "variable_description": "Variable description", - "variable_picker_tips": "Type node name or variable name to search", - "variable_update": "Variable Update", - "workflow.My edit": "My Edit", - "workflow.Switch_success": "Switch Successful", - "workflow.Team cloud": "Team Cloud", - "workflow.exit_tips": "Your changes have not been saved. 'Exit directly' will not save your edits." + "variable_picker_tips": "Enter a node name or variable name.", + "variable_update": "Update variable", + "workflow.My edit": "My edits", + "workflow.Switch_success": "Switched successfully.", + "workflow.Team cloud": "Historical versions", + "workflow.exit_tips": "The changes are not saved. If you exit now, the changes will be discarded." } diff --git a/packages/web/i18n/zh-CN/admin.json b/packages/web/i18n/zh-CN/admin.json new file mode 100644 index 000000000000..c303110b20c2 --- /dev/null +++ b/packages/web/i18n/zh-CN/admin.json @@ -0,0 +1,612 @@ +{ + "license_active_success": "激活成功", + "system_activation": "系统激活", + "system_activation_desc": "你需要使用 License 激活系统后才可继续使用。", + "domain_name": "当前域名为", + "input_license": "请输入 License", + "cancel": "取消", + "confirm": "确认", + "config_desc": "配置介绍", + "domain_invalid": "License 域名不合法", + "change_license": "变更授权", + "logout": "退出登录", + "expire_time": "过期时间", + "max_users": "最大用户数", + "max_apps": "最大应用数", + "max_datasets": "最大知识库数量", + "sso": "单点登录", + "pay": "支付系统", + "custom_templates": "自定义模板和系统工具", + "dataset_enhance": "知识库增强", + "unlimited": "不限制", + "data_dashboard": "数据面板", + "notification_management": "通知管理", + "log_management": "日志管理", + "user_management": "用户管理", + "user_info": "用户信息", + "team_management": "团队管理", + "plan_management": "套餐管理", + "payment_records": "支付记录", + "invoice_management": "开票管理", + "resource_management": "资源管理", + "app_management": "应用管理", + "dataset_management": "知识库管理", + "system_config": "系统配置", + "basic_config": "基础配置", + "feature_list": "功能清单", + "security_review": "安全审查", + "third_party_providers": "第三方提供商", + "user_config": "用户配置", + "plan_recharge": "套餐 & 充值", + "template_tools": "模板 & 工具", + "template_market": "模板市场", + "toolbox": "工具箱", + "audit_logs": "审计日志", + "first_page": "第一页", + "previous_page": "上一页", + "next_page": "下一页", + "last_page": "最后一页", + "page": "页", + "click_view_details": "点击查看详情", + "upload_image_failed": "上传图片失败", + "config_file": "配置文件", + "save": "保存", + "upload_profile_failed": "上传头像失败", + "associated_plugin_is_empty": "关联插件不能为空", + "config_success": "配置成功", + "confirm_delete_plugin": "确认删除该插件么?", + "delete_success": "删除成功", + "custom_plugin": "自定义插件", + "config": "配置", + "give_name": "取个名字", + "click_upload_avatar": "点击上传头像", + "app_name_empty": "应用名不能为空", + "description": "介绍", + "add_app_description": "为这个应用添加一个介绍", + "associated_plugins": "关联插件", + "search_plugin": "输入插件名或 appId 查找插件", + "attribute": "属性", + "author_name": "作者名称", + "default_system_name": "默认为系统名", + "is_enable": "是否启用", + "charge_token_fee": "是否收取 Token 费用", + "call_price": "调用价格 (n积分/次)", + "instructions": "使用说明", + "use_markdown_syntax": "使用 markdown 语法", + "update": "更新", + "create_plugin": "新建插件", + "delete_confirm_message": "删除后,其下资源将同步删除且不可恢复。是否确认删除?", + "group_management": "分组管理", + "total_groups": "共 {localGroups.length} 个分组", + "add": "添加", + "add_type": "添加类型", + "avatar_select_error": "头像选择异常", + "rename": "重命名", + "add_group": "添加分组", + "avatar_name": "头像 & 名称", + "click_set_avatar": "点击设置头像", + "group_name_empty": "分组名称不能为空", + "official": "官方", + "configured": "已配置", + "not_configured": "未配置", + "system_key_price": "系统密钥价格(n积分/次)", + "plugin_config": "{{name}} 配置", + "enable_toolset": "是否启用工具集", + "config_system_key": "是否配置系统密钥", + "tool_list": "工具列表", + "tool_name": "工具名", + "key_price": "密钥价格", + "continue": "继续", + "user_deleted_message": "这人被删了", + "missing_field": "缺少字段", + "team_not_exist": "团队不存在", + "subscription_exists": "已存在相同类型的订阅", + "subscription_not_exist": "订阅不存在", + "user_exist": "用户已存在", + "account_logout": "账号已经注销了", + "user_not_found": "找不到 user", + "user_not_exist": "用户不存在", + "update_failed": "更新失败", + "send_notification_success": "发送通知成功", + "user_password_error": "用户或密码错误!", + "invoice_not_found": "找不到发票", + "invoice_issued": "发票已开具", + "invoice_completed_notice": "您申请的发票已完成,请注意查收", + "invoice_generation_completed": "开票完成", + "order_not_exist": "订单不存在", + "order_unpaid": "订单未支付", + "order_invoiced_no_refund": "订单已开票,无法直接退款", + "order_insufficient_amount": "订单金额不足", + "type_empty": "type 不能为空", + "quantity_must_be_positive": "数量必须大于0", + "team_not_found": "未找到团队", + "dataset_training_rebuilding": "数据集正在训练或者重建中,请稍后再试", + "database_rebuilding_index": "数据库重建索引", + "type_not_support": "暂不支持该类型消息", + "send_verification_code_success": "发送验证码成功", + "normal": "正常", + "leave": "离开", + "deactivated": "已停用", + "account": "账号", + "username": "用户名", + "contact": "联系方式", + "department": "部门", + "join_time": "加入时间", + "update_time": "更新时间", + "status": "状态", + "team_num_limit_error": "仅限1个团队", + "organization_name": "组织名", + "amount": "金额", + "yuan": "元", + "pending_invoice_count": "待开票数量", + "go_to_view": "前去查看", + "new_invoice_application": "有新的开票申请", + "order_type_error": "订单类型错误", + "missing_key_params_update_bill_failed": "缺少关键参数,更新账单失败,请联系管理员", + "plugin_service_link_not_configured": "未配置插件服务链接", + "audit_record_table": "审计记录表", + "operator": "操作人员", + "operation_type": "操作类型", + "operation_time": "操作时间", + "operation_content": "操作内容", + "no_audit_records": "暂无审计记录~", + "audit_details": "审计详情", + "details": "详情", + "close": "关闭", + "total_users": "总用户数", + "registered_users": "注册用户数", + "payment_amount": "付费金额", + "order_count": "订单数", + "all": "全部", + "success": "成功", + "paid_teams": "付费团队数", + "total_conversations": "总对话数", + "total_sessions": "总会话数", + "avg_conversations_per_session": "每个会话平均对话数", + "points_consumed": "积分消耗", + "user_total": "用户总数", + "dataset_total": "知识库总数", + "app_total": "应用总数", + "statistics_data": "统计数据", + "traffic": "流量", + "payment": "付费", + "active": "活跃", + "cost": "成本", + "last_7_days": "近7天", + "last_30_days": "近30天", + "last_90_days": "近90天", + "last_180_days": "近180天", + "confirm_modify_system_announcement": "确认修改系统公告?", + "confirm_send_system_notification": "确认发送系统通知?", + "modify_success": "修改成功", + "modify_failed": "修改失败", + "send_success": "发送成功", + "send_failed": "发送失败", + "system_announcement_config": "系统公告配置", + "system_announcement_description": "设置该内容,会在用户登录系统后,通过弹窗形式进行强提示。用户关闭后,下次不再提示。只能设置1个该类型通知。支持 markdown 格式。", + "send_system_notification": "发送系统通知", + "confirm_send": "确认发送", + "send_notification_description": "为所有用户发送一个通知,不同等级通知,会有不同提示。", + "message_level": "消息等级", + "level_normal": "一般(仅发站内信)", + "level_important": "重要(站内信+登录通知)", + "level_urgent": "紧急(站内信+登录通知+邮件/短信提醒)", + "notification_title": "通知标题", + "notification_content": "通知内容", + "log_record_table": "日志记录表", + "log_search_placeholder": "请想要查找的日志内容,回车搜索", + "time": "时间", + "log_content": "日志内容", + "no_log_records": "暂无Log记录~", + "log_level": "日志等级", + "log_detail": "日志详情", + "log_message": "日志消息", + "login_success": "登录成功!", + "admin_login": "管理员登录", + "login_username": "用户名", + "login_password": "密码", + "login_button": "登录", + "app_list": "应用列表", + "app_name": "应用名", + "creator": "创建者", + "redirect": "跳转", + "app_no_records": "无应用记录~", + "app_details": "应用详情", + "app_id": "应用id", + "app_creator_id": "创建者 ID", + "dataset_list": "知识库列表", + "dataset_name": "知识库名", + "dataset_data_size": "数据量", + "dataset_vector_count": "向量总数", + "dataset_favorite_count": "收藏数", + "new": "新增", + "name": "名称", + "enable": "启用", + "redirect_link": "跳转链接", + "operation": "操作", + "add_sidebar_item": "新增侧边项", + "sidebar_item_name": "侧边项名", + "sidebar_item_name_empty": "侧边项名不能为空", + "redirect_link_empty": "跳转链接不能为空", + "edit_plan": "编辑 {{label}} 套餐", + "plan_name": "套餐名称", + "custom_plan_name_desc": "自定义套餐名,可覆盖原套餐名", + "monthly_price": "每月价格", + "max_team_members": "最大团队成员", + "max_app_count": "最大APP数量", + "max_dataset_count": "最大知识库数量", + "history_retention_days": "历史记录保存多少天", + "max_dataset_index_count": "最大知识库索引数量", + "monthly_ai_points": "每月 AI 积分", + "training_priority_high": "训练优先级(高的优先)", + "allow_site_sync": "允许使用站点同步", + "allow_team_operation_logs": "允许团队操作日志", + "click_configure_plan": "点击配置套餐", + "copy_success": "复制成功", + "delete_confirm": "确认删除该变量?", + "delete": "删除", + "workflow_variable_custom": "自定义工作流变量", + "field_name": "变量名", + "usage_url": "使用量查询地址", + "note": "说明", + "import_config": "导入配置", + "import_success_save": "导入成功,请点击保存", + "import_check_format": "请检查配置文件格式", + "import": "导入", + "get_config_error": "获取配置出错", + "save_success": "保存成功", + "save_failed": "保存失败", + "frontend_display_config": "前端展示配置", + "personalization_config": "个性化配置", + "global_script": "全局Script脚本", + "system_params": "系统参数", + "pdf_parse_config": "PDF 解析配置", + "usage_limits": "使用限制", + "sidebar_config": "侧边栏配置", + "system_name": "系统名", + "custom_api_domain": "自定义api域名", + "custom_api_domain_desc": "可以设置一个额外的api地址,不使用主站的地址,需配置域名的cname和ssl证书。", + "custom_share_link_domain": "自定义分享链接域名", + "custom_share_link_domain_desc": "可以设置一个额外的分享链接地址,不使用主站的地址,需配置域名的cname和ssl证书。", + "openapi_prefix": "OpenAPI 前缀", + "contact_popup": "联系弹窗", + "contact_popup_desc": "使用 Markdown 进行配置,配置之后,在网页中\"联系我们\"相关的内容,会提示填写的内容。", + "custom_api_doc_url": "自定义 api 文档地址", + "custom_openapi_doc_url": "自定义 openapi 文档地址", + "doc_url_note": "文档地址(加一个 / 结尾,否则会携带子路径跳转)", + "contribute_plugin_doc_url": "贡献插件文档地址", + "contribute_template_doc_url": "贡献模板市场文档地址", + "global_script_desc": "自定义 Script 脚本,可以全局插入(可以做站点流量监控之类的)", + "mcp_forward_service_url": "MCP 转发服务地址", + "mcp_forward_service_desc": "需要部署一个 MCP 转发服务,用于将 FastGPT 应用以MCP协议暴露,例如:http://localhost:3005", + "oneapi_url": "oneAPI地址(会覆盖环境变量配置的)", + "oneapi_url_desc": "oneAPI地址,可以使用 oneapi 来实现多模型接入", + "input_oneapi_url": "请输入 oneAPI 地址", + "oneapi_key": "OneAPI 密钥(会覆盖环境变量配置的)", + "input_oneapi_key": "请输入 OneAPI 密钥", + "dataset_index_max_process": "知识库索引最大处理进程", + "file_understanding_max_process": "文件理解模型最大处理进程", + "image_understanding_max_process": "图片理解模型最大处理进程", + "hnsw_ef_search": "HNSW ef_search", + "hnsw_ef_search_desc": "HNSW 参数。越大召回率越高,性能越差,默认为 100,具体可见:https://github.com/pgvector/pgvector", + "hnsw_max_scan_tuples": "HNSW max_scan_tuples", + "hnsw_max_scan_tuples_desc": "迭代搜索最大数量,越大召回率越高,性能越差,默认为 100000,具体可见:https://github.com/pgvector/pgvector", + "token_calc_max_process": "token计算最大进程(通常多少并发设置多少)", + "custom_pdf_parse_url": "自定义 PDF 解析地址", + "custom_pdf_parse_key": "自定义 PDF 解析密钥", + "doc2x_pdf_parse_key": "Doc2x pdf 解析密钥(比自定义 PDF 解析优先级低)", + "custom_pdf_parse_price": "自定义 PDF 解析价格(n 积分/页)", + "max_upload_files_per_time": "单次最多上传多少个文件", + "max_upload_files_per_time_desc": "用户上传知识库时,每次上传最多选择多少个文件", + "max_upload_file_size": "上传文件最大大小(M)", + "max_upload_file_size_desc": "用户上传知识库时,每个文件最大是多少。放大的话,需要注意网关也要设置得够大。", + "export_interval_minutes": "导出间隔时长(分钟)", + "site_sync_interval_minutes": "站点同步使用间隔时长(分钟)", + "mobile_sidebar_location": "移动端的侧边栏显示在账号 - 个人信息里", + "basic_features": "基础功能", + "third_party_knowledge_base": "第三方知识库", + "third_party_publish_channels": "第三方发布渠道", + "feature_display_config": "功能展示配置", + "display_team_sharing": "展示团队分享", + "display_chat_blank_page": "展示聊天空白页(都关闭即可)", + "display_invite_friends_activity": "展示邀请好友活动", + "frontend_compliance_notice": "前端是否展示合规提示文案", + "feishu_knowledge_base": "飞书知识库", + "feishu_knowledge_base_desc": "关闭后,创建数据库时不再显示飞书数据库", + "yuque_knowledge_base": "语雀知识库", + "yuque_knowledge_base_desc": "关闭后,创建数据库时不再显示语雀数据库", + "feishu_publish_channel": "飞书发布渠道", + "feishu_publish_channel_desc": "关闭后,发布渠道中不再显示飞书发布渠道", + "dingtalk_publish_channel": "钉钉发布渠道", + "dingtalk_publish_channel_desc": "关闭后,发布渠道中不再显示钉钉发布渠道", + "wechat_publish_channel": "公众号发布渠道", + "wechat_publish_channel_desc": "关闭后,发布渠道中不再显示公众号发布渠道", + "content_security_review": "内容安全审查", + "baidu_security_id": "百度安全 id", + "baidu_security_secret": "百度安全 secret", + "custom_security_check_url": "自定义安全校验 URL", + "baidu_security_register_desc": "注册百度安全校验账号,并创建对应应用。提供应用的 id 和 secret", + "custom_security_check_desc": "如果您有自己的安全校验服务,可以填写该地址,并在安全设置中开启自定义安全校验", + "plan_free": "免费版", + "plan_trial": "体验版", + "plan_team": "团队版", + "plan_enterprise": "企业版", + "subscription_plan": "订阅套餐", + "standard_subscription_plan": "标准订阅套餐", + "custom_plan_description": "自定义套餐说明", + "dataset_storage_cost_desc": "知识库存储费用(xx元/1000条/月)", + "extra_ai_points_cost_desc": "额外AI积分费用(xx元/1000积分/月)", + "payment_method": "支付方式", + "wechat_payment_config": "微信支付配置", + "alipay_payment_config": "支付宝支付配置", + "corporate_payment_message": "对公支付消息提示", + "enable_subscription_plan": "是否启用订阅套餐", + "custom_plan_page_description": "如果填写了该地址,会覆盖系统上套餐页面,会跳转到这个自定义页面,你可以在自定义页面里定义收费规则", + "wechat_payment_materials": "微信支付相关材料", + "wechat_payment_registration_guide": "自行注册微信支付,目前需要wx扫码支付", + "unused_field_placeholder": "没用到,随便填个", + "certificate_management_guide": "点管理证书进去看到", + "wechat_key_extraction_guide": "按微信教程拿到这几个文件,txt打开key", + "alipay_payment_materials": "支付宝支付相关材料", + "alipay_application_guide": "自行注册支付宝应用,目前需要开通电脑网站支付", + "alipay_certificate_encryption_guide": "点接口加签方式后选择证书加密方式,具体操作参考", + "application_public_key_certificate": "应用公钥证书", + "private_key_document_reference": "参考上面私钥获取文档", + "alipay_root_certificate": "支付宝根证书", + "alipay_public_key_certificate": "支付宝公钥证书", + "alipay_dateway": "支付宝网关", + "alipay_gateway_sandbox_note": "支付宝网关,注意测试使用的沙箱环境是", + "alipay_gateway_production_note": ",而生成环境是", + "alipay_endpoint_sandbox_note": "支付宝端点,注意测试使用的沙箱环境是", + "message_notification": "消息提示", + "markdown_format_support": "支持markdown格式", + "third_party_account_config": "第三方账号配置", + "allow_user_account_config": "允许用户配置账号", + "view_documentation": "查看文档", + "openai_oneapi_account": "OpenAI/OneAPI 账号", + "laf_account": "laf 账号", + "input_laf_address": "请输入 laf 地址", + "multi_team_mode": "多团队模式", + "single_team_mode": "单团队模式", + "sync_mode": "同步模式", + "notification_login_settings": "通知 & 登陆设置", + "team_mode_settings": "团队模式设置", + "custom_user_system_config": "自定义用户系统配置", + "email_notification_config": "邮箱通知配置(注册、套餐通知)", + "aliyun_sms_config": "阿里云短信配置", + "aliyun_sms_template_code": "阿里云短信模板CODE(SMS_xxx)", + "wechat_service_login": "微信服务号登陆", + "github_login_config": "GitHub 登录配置", + "google_login_config": "Google 登陆配置", + "microsoft_login_config": "微软登陆配置", + "quick_login": "快速登陆(不推荐)", + "login_notifications_config": "通知登陆 & 设置", + "user_service_root_address": "用户服务根地址(末尾不加/)", + "sso_usage_guide": "具体用法请看: SSO & 外部成员同步", + "sso_login_button_title": "SSO 登录按钮标题", + "config_sso_login_button_title": "配置 SSO 登录按钮的标题", + "sso_login_button_icon": "SSO 登录按钮的图标", + "config_sso_login_button_icon": "配置 SSO 登录按钮的图标", + "sso_auto_redirect": "SSO 自动跳转", + "sso_auto_redirect_desc": "开启后,用户进入登录页面,将会自动触发 SSO 登录,无需手动点击。", + "email_smtp_address": "邮箱服务SMTP地址", + "email_smtp_address_note": "不同厂商不一样", + "email_smtp_username": "邮箱服务SMTP用户名", + "email_smtp_username_example": "qq 邮箱为例,对应 qq 号", + "email_password": "邮箱 Password", + "email_smtp_auth_code": "SMTP 授权码", + "enable_email_registration": "是否开启邮箱注册", + "aliyun_sms_params": "阿里云短信参数", + "aliyun_sms_apply_guide": "申请对应的签名和短信模板,提供:", + "signature_name": "签名名称", + "template_code_sm": "模板CODE,SM开头的", + "aliyun_secret_key": "阿里云账号的secret key", + "sms_signature": "短信签名", + "registration_account": "注册账号", + "registration_account_desc": "填写后,将会开启手机号注册", + "reset_password": "重置密码", + "reset_password_desc": "填写后,将会开启手机号找回密码", + "bind_notification_phone": "绑定通知手机号", + "bind_notification_phone_desc": "填写后,将会允许手机号绑定通知方式", + "subscription_expiring_soon": "订阅套餐即将过期", + "subscription_expiring_soon_desc": "填写后,套餐即将过期,会发送一个短信", + "free_user_cleanup_warning": "免费版用户清理警告", + "wechat_service_appid": "服务号的 Appid。微信服务号的验证地址填写:商业版域名", + "wechat_service_secret": "服务号的 Secret", + "register_one": "注册一个", + "provide": "提供", + "domain": "域名", + "microsoft_app_client_id": "对应 Microsoft 应用的「应用程序(客户端) ID」", + "microsoft_tenant_id": "对应 Microsoft 应用的「租户 ID」, 若使用默认的 common 可不用填写", + "custom_button_name": "自定义按钮名", + "custom_button_name_desc": "自定义按钮的名称,若不填写则使用默认的 Microsoft 按钮", + "simple_app": "简易应用", + "workflow": "工作流", + "plugin": "插件", + "folder": "文件夹", + "http_plugin": "HTTP 插件", + "toolset": "工具集", + "tool": "工具", + "hidden": "隐藏", + "select_json_file": "请选择 JSON 文件", + "confirm_delete_template": "确认删除该模板么?", + "upload_config_first": "请先上传配置文件", + "app_type_not_recognized": "未识别到应用类型", + "config_json_format_error": "配置文件 JSON 格式错误", + "template_update_success": "模板更新成功", + "template_create_success": "模板创建成功", + "template_config": "模板配置", + "json_serialize_failed": "JSON 序列化失败", + "get_app_type_failed": "获取应用类型失败", + "file_overwrite_content": "文件将覆盖当前内容", + "config_file_label": "配置文件", + "official_config": "官方配置", + "upload_file": "上传文件", + "paste_config_or_drag_json": "粘贴配置或拖入 JSON 文件", + "paste_config": "粘贴配置", + "app_type": "应用类型", + "auto_recognize": "自动识别", + "app_attribute_not_recognized": "未识别到应用属性", + "text": "文本", + "link": "链接", + "input_link": "请输入链接", + "settings_successful": "设置成功", + "configure_quick_templates": "配置快捷模板", + "search_apps": "搜索应用", + "selected_count": "已选: {{count}} / 3", + "category_management": "分类管理", + "total_categories": "共 {{length}} 个分类", + "add_category": "添加分类", + "category_name": "分类名", + "category_name_empty": "分类名不能为空", + "template_list": "模板列表", + "quick_template": "快捷模板", + "add_template": "添加模板", + "recommended": "推荐", + "no_templates": "暂无模板", + "add_attribute_first": "请先添加属性", + "add_plugin": "添加插件", + "token_points": "Token 积分", + "token_fee_description": "开启该开关后,用户使用该插件,需要支付插件中Token的积分,并且同时会收取调用积分", + "call_points": "调用积分", + "system_key": "系统密钥", + "system_key_description": "对于需要密钥的工具,您可为其配置系统密钥,用户可通过支付积分的方式使用系统密钥。", + "no_plugins": "暂无插件", + "invoice_application": "开票申请", + "search_user_placeholder": "请输入用户名,回车搜索", + "submit_status": "提交状态", + "submit_complete_time": "提交时间/完成时间", + "invoice_title": "抬头", + "waiting_for_invoice": "等待开票", + "completed": "已完成", + "confirm_invoice": "确认开票", + "no_invoice_records": "无开票记录~", + "invoice_details": "发票详情", + "invoice_amount": "开票金额", + "organization": "组织名称", + "unified_credit_code": "统一信用代码", + "company_address": "公司地址", + "company_phone": "公司电话", + "bank_name": "开户银行", + "bank_account": "开户账号", + "need_special_invoice": "是否需要专票", + "yes": "是", + "no": "否", + "email_address": "邮箱地址", + "invoice_file": "发票文件", + "click_download": "点击下载", + "operation_success": "操作成功", + "operation_failed": "操作失败", + "upload_invoice_pdf": "请上传发票的PDF文件", + "select_invoice_file": "选择发票文件", + "confirm_submission": "确认提交", + "balance_recharge": "余额充值", + "plan_subscription": "套餐订阅", + "knowledge_base_expansion": "知识库扩容", + "ai_points_package": "AI积分套餐", + "monthly": "按月", + "yearly": "按年", + "free": "免费", + "trial": "体验", + "team": "团队", + "enterprise": "企业", + "custom": "自定义", + "wechat": "微信", + "balance": "余额", + "alipay": "支付宝", + "corporate": "对公", + "redeem_code": "兑换码", + "search_user": "请输入用户名搜索", + "team_id": "团队ID", + "recharge_member_name": "充值的成员名", + "unpaid": "未支付", + "no_bill_records": "无账单记录~", + "order_details": "订单详情", + "order_number": "订单号", + "generation_time": "生成时间", + "order_type": "订单类型", + "subscription_period": "订阅周期", + "subscription_package": "订阅套餐", + "months": "月数", + "extra_knowledge_base_capacity": "额外知识库容量", + "extra_ai_points": "额外AI积分", + "time_error": "开始时间不能大于结束时间", + "points_error": "剩余积分不能大于总积分", + "add_success": "添加成功", + "add_package": "添加套餐", + "package_type": "套餐类型", + "basic_package": "基础套餐", + "start_time": "开始时间", + "required": "*必填", + "end_time": "结束时间", + "package_level": "套餐等级", + "total_points": "总积分", + "remaining_points": "剩余积分", + "price_yuan_for_record_only": "价格(元)-仅用于记录", + "price": "价格", + "update_success": "更新成功", + "edit": "编辑", + "edit_package": "编辑套餐", + "value_override_description": "下面的值会覆盖套餐配置,不填则会用套餐的标准值", + "team_member_limit": "团队成员上限", + "app_limit": "应用上限", + "knowledge_base_limit": "知识库上限", + "team_name": "团队名", + "points": "积分", + "start_end_time": "起止时间", + "version": "版", + "extra_knowledge_base": "额外知识库", + "no_package_records": "无套餐记录~", + "role": "权限", + "team_details": "团队详情", + "chage_success": "变更成功", + "team_edit": "团队编辑", + "team_list": "团队列表", + "create_time": "创建时间", + "no_team_data": "无团队记录~", + "add_user": "添加用户", + "password": "密码", + "password_requirements": "密码至少 8 位,且至少包含两种组合:数字、字母或特殊字符", + "account_deactivated": "账号已注销", + "edit_user": "编辑用户", + "user_status": "用户状态", + "confirm_deactivate_account": "确认注销该账号?会将该用户下关键资源删除,并修改其用户名成 xx-deleted", + "deactivate": "注销", + "no_user_records": "无用户记录~", + "user_details": "用户详情", + "system_incompatibility": "部分系统不兼容,导致页面崩溃。如果可以,请联系作者,反馈下具体操作和页面。大部分是 苹果 的 safari 浏览器导致,可以尝试更换 chrome 浏览器。", + "content_not_compliant": "您的内容不合规", + "baidu_content_security_check_exception": "百度内容安全校验异常", + "license_not_read": "未读取到 License", + "license_invalid": "License 不合法", + "license_expired": "License 已过期", + "license_content_error": "License 内容错误", + "system_not_activated": "系统未激活", + "exceed_max_users": "超过最大用户数", + "server_error": "服务器异常", + "request_error": "请求错误", + "unknown_error": "未知错误", + "token_expired_relogin": "token过期,重新登录", + "google_verification_result": "谷歌校验结果", + "abnormal_operation_environment": "您的操作环境存在异常,请刷新页面后重试或联系客服。", + "notify_expiring_packages": "通知即将过期的套餐", + "notify_free_users_cleanup": "通知免费版用户即将清理", + "bing_oauth_config_incomplete": "Bing OAuth配置不完整", + "request_limit_per_minute": "每分钟仅能请求次数", + "share_link_expired": "分享链接已过期", + "link_usage_limit_exceeded": "链接超出使用限制", + "authentication_failed": "身份校验失败", + "user_registration": "新用户注册", + "initial_password": "您的初始密码为", + "notification_too_frequent": "发送通知太频繁了", + "emergency_notification_requires_teamid": "紧急通知必须提供 teamId", + "send_sms_failed": "发送短信失败", + "fastgpt_user": "FastGPT用户", + "refund_failed": "退款失败", + "refund_request_failed": "退款请求失败", + "get_certificate_list_failed": "获取证书列表失败", + "platform_certificate_serial_mismatch": "平台证书序列号不相符", + "extra_knowledge_base_storage": "额外知识库存储", + "image_compression_error": "压缩图片异常", + "image_too_large": "图片太大了" +} \ No newline at end of file diff --git a/packages/web/i18n/zh-CN/dataset.json b/packages/web/i18n/zh-CN/dataset.json index f02de935815e..b0cb9d8e9420 100644 --- a/packages/web/i18n/zh-CN/dataset.json +++ b/packages/web/i18n/zh-CN/dataset.json @@ -187,7 +187,7 @@ "split_sign_period": "句号", "split_sign_question": "问号", "split_sign_semicolon": "分号", - "start_sync_dataset_tip": "确实开始同步整个知识库?", + "start_sync_dataset_tip": "确定开始同步整个知识库?", "status_error": "运行异常", "sync_collection_failed": "同步集合错误,请检查是否能正常访问源文件", "sync_schedule": "定时同步", @@ -203,7 +203,7 @@ "tag.tags": "标签", "tag.total_tags": "共{{total}}个标签", "template_dataset": "模版导入", - "template_file_invalid": "模板文件格式不正确,应该是首列为 q,a,indexes 的 csv 文件", + "template_file_invalid": "模板文件格式不正确,应该是首行为 q,a,indexes 的 csv 文件", "template_mode": "模板导入", "the_knowledge_base_has_indexes_that_are_being_trained_or_being_rebuilt": "知识库有训练中或正在重建的索引", "total_num_files": "共 {{total}} 个文件", diff --git a/packages/web/i18n/zh-Hant/admin.json b/packages/web/i18n/zh-Hant/admin.json new file mode 100644 index 000000000000..d9db3efc51aa --- /dev/null +++ b/packages/web/i18n/zh-Hant/admin.json @@ -0,0 +1,612 @@ +{ + "license_active_success": "激活成功", + "system_activation": "系統激活", + "system_activation_desc": "你需要使用 License 激活系統後才可繼續使用。", + "domain_name": "當前域名為", + "input_license": "請輸入 License", + "cancel": "取消", + "confirm": "確認", + "config_desc": "配置介紹", + "domain_invalid": "License 域名不合法", + "change_license": "變更授權", + "logout": "退出登錄", + "expire_time": "過期時間", + "max_users": "最大用戶數", + "max_apps": "最大應用數", + "max_datasets": "最大知識庫數量", + "sso": "單點登錄", + "pay": "支付系統", + "custom_templates": "自定義模板和系統工具", + "dataset_enhance": "知識庫增強", + "unlimited": "不限制", + "data_dashboard": "數據面板", + "notification_management": "通知管理", + "log_management": "日誌管理", + "user_management": "用戶管理", + "user_info": "用戶信息", + "team_management": "團隊管理", + "plan_management": "套餐管理", + "payment_records": "支付記錄", + "invoice_management": "開票管理", + "resource_management": "資源管理", + "app_management": "應用管理", + "dataset_management": "知識庫管理", + "system_config": "系統配置", + "basic_config": "基礎配置", + "feature_list": "功能清單", + "security_review": "安全審查", + "third_party_providers": "第三方提供商", + "user_config": "用戶配置", + "plan_recharge": "套餐 & 充值", + "template_tools": "模板 & 工具", + "template_market": "模板市場", + "toolbox": "工具箱", + "audit_logs": "審計日誌", + "first_page": "頁首", + "previous_page": "上一頁", + "next_page": "下一頁", + "last_page": "最後一頁", + "page": "頁", + "click_view_details": "點擊查看詳情", + "upload_image_failed": "上傳圖片失敗", + "config_file": "配置文件", + "save": "保存", + "upload_profile_failed": "上傳頭像失敗", + "associated_plugin_is_empty": "關聯插件不能為空", + "config_success": "配置成功", + "confirm_delete_plugin": "確認刪除該插件嗎?", + "delete_success": "刪除成功", + "custom_plugin": "自定義插件", + "config": "配置", + "give_name": "取個名字", + "click_upload_avatar": "點擊上傳頭像", + "app_name_empty": "應用名不能為空", + "description": "介紹", + "add_app_description": "為這個應用添加一個介紹", + "associated_plugins": "關聯插件", + "search_plugin": "輸入插件名或 appId 查找插件", + "attribute": "屬性", + "author_name": "作者名", + "default_system_name": "默認為系統名", + "is_enable": "是否啟用", + "charge_token_fee": "是否收取 Token 費用", + "call_price": "調用價格 (n積分/次)", + "instructions": "使用說明", + "use_markdown_syntax": "使用 markdown 語法", + "update": "更新", + "create_plugin": "新建插件", + "delete_confirm_message": "刪除後,其下資源將同步刪除且不可恢復。是否確認刪除?", + "group_management": "分組管理", + "total_groups": "共 {localGroups.length} 個分組", + "add": "添加", + "add_type": "添加類型", + "avatar_select_error": "頭像選擇異常", + "rename": "重命名", + "add_group": "添加分組", + "avatar_name": "頭像 & 名稱", + "click_set_avatar": "點擊設置頭像", + "group_name_empty": "分組名稱不能為空", + "official": "官方", + "configured": "已配置", + "not_configured": "未配置", + "system_key_price": "系統密鑰價格(n積分/次)", + "plugin_config": "{{name}} 配置", + "enable_toolset": "是否啟用工具集", + "config_system_key": "是否配置系統密鑰", + "tool_list": "工具列表", + "tool_name": "工具名", + "key_price": "密鑰價格", + "continue": "繼續", + "user_deleted_message": "這人被刪了", + "missing_field": "缺少字段", + "team_not_exist": "團隊不存在", + "subscription_exists": "已存在相同類型的訂閱", + "subscription_not_exist": "訂閱不存在", + "user_exist": "用戶已存在", + "account_logout": "賬號已經註銷了", + "user_not_found": "找不到用戶", + "user_not_exist": "用戶不存在", + "update_failed": "更新失敗", + "send_notification_success": "發送通知成功", + "user_password_error": "用戶或密碼錯誤!", + "invoice_not_found": "找不到發票", + "invoice_issued": "發票已開具", + "invoice_completed_notice": "您申請的發票已完成,請注意查收", + "invoice_generation_completed": "開票完成", + "order_not_exist": "訂單不存在", + "order_unpaid": "訂單未支付", + "order_invoiced_no_refund": "訂單已開票,無法直接退款", + "order_insufficient_amount": "訂單金額不足", + "type_empty": "類型不能為空", + "quantity_must_be_positive": "數量必須大於0", + "team_not_found": "未找到團隊", + "dataset_training_rebuilding": "數據集正在訓練或者重建中,請稍後再試", + "database_rebuilding_index": "數據庫重建索引", + "type_not_support": "暫不支持該類型消息", + "send_verification_code_success": "發送驗證碼成功", + "normal": "正常", + "leave": "離開", + "deactivated": "已停用", + "account": "賬號", + "username": "用戶名", + "contact": "聯繫方式", + "department": "部門", + "join_time": "加入時間", + "update_time": "更新時間", + "status": "狀態", + "team_num_limit_error": "僅限1個團隊", + "organization_name": "組織名", + "amount": "金額", + "yuan": "元", + "pending_invoice_count": "待開票數量", + "go_to_view": "前去查看", + "new_invoice_application": "有新的開票申請", + "order_type_error": "訂單類型錯誤", + "missing_key_params_update_bill_failed": "缺少關鍵參數,更新帳單失敗,請聯繫管理員", + "plugin_service_link_not_configured": "未配置插件服務鏈接", + "audit_record_table": "審計記錄表", + "operator": "操作人員", + "operation_type": "操作類型", + "operation_time": "操作時間", + "operation_content": "操作內容", + "no_audit_records": "暫無審計記錄~", + "audit_details": "審計詳情", + "details": "詳情", + "close": "關閉", + "total_users": "總用戶數", + "registered_users": "註冊用戶數", + "payment_amount": "支付金額", + "order_count": "訂單數", + "all": "全部", + "success": "成功", + "paid_teams": "付費團隊數", + "total_conversations": "總對話數", + "total_sessions": "總會話數", + "avg_conversations_per_session": "每個會話平均對話數", + "points_consumed": "積分消耗", + "user_total": "用戶總數", + "dataset_total": "知識庫總數", + "app_total": "應用總數", + "statistics_data": "統計數據", + "traffic": "流量", + "payment": "支付", + "active": "活躍", + "cost": "成本", + "last_7_days": "近7天", + "last_30_days": "近30天", + "last_90_days": "近90天", + "last_180_days": "近180天", + "confirm_modify_system_announcement": "確認修改系統公告?", + "confirm_send_system_notification": "確認發送系統通知?", + "modify_success": "修改成功", + "modify_failed": "修改失敗", + "send_success": "發送成功", + "send_failed": "發送失敗", + "system_announcement_config": "系統公告配置", + "system_announcement_description": "設置該內容,會在用戶登錄系統後,通過彈窗形式進行強提示。用戶關閉後,下次不再提示。只能設置1個該類型通知。支持 markdown 格式。", + "send_system_notification": "發送系統通知", + "confirm_send": "確認發送", + "send_notification_description": "為所有用戶發送一個通知,不同等級通知,會有不同提示。", + "message_level": "消息等級", + "level_normal": "一般(僅發站內信)", + "level_important": "重要(站內信+登錄通知)", + "level_urgent": "緊急(站內信+登錄通知+郵件/短信提醒)", + "notification_title": "通知標題", + "notification_content": "通知內容", + "log_record_table": "日誌記錄表", + "log_search_placeholder": "請想要查找的日誌內容,回車搜索", + "time": "時間", + "log_content": "日誌內容", + "no_log_records": "暫無Log記錄~", + "log_level": "日誌等級", + "log_detail": "日誌詳情", + "log_message": "日誌消息", + "login_success": "登錄成功!", + "admin_login": "管理員登錄", + "login_username": "用戶名", + "login_password": "密碼", + "login_button": "登錄", + "app_list": "應用列表", + "app_name": "應用名", + "creator": "創建者", + "redirect": "跳轉", + "app_no_records": "無應用記錄~", + "app_details": "應用詳情", + "app_id": "應用id", + "app_creator_id": "創建者 ID", + "dataset_list": "知識庫列表", + "dataset_name": "知識庫名", + "dataset_data_size": "數據量", + "dataset_vector_count": "向量總數", + "dataset_favorite_count": "收藏數", + "new": "新增", + "name": "名稱", + "enable": "啟用", + "redirect_link": "跳轉鏈接", + "operation": "操作", + "add_sidebar_item": "新增側邊項", + "sidebar_item_name": "側邊項名", + "sidebar_item_name_empty": "側邊項名不能為空", + "redirect_link_empty": "跳轉鏈接不能為空", + "edit_plan": "編輯 {{label}} 套餐", + "plan_name": "套餐名稱", + "custom_plan_name_desc": "自定義套餐名,可覆蓋原套餐名", + "monthly_price": "每月價格", + "max_team_members": "最大團隊成員", + "max_app_count": "最大APP數量", + "max_dataset_count": "最大知識庫數量", + "history_retention_days": "歷史記錄保存多少天", + "max_dataset_index_count": "最大知識庫索引數量", + "monthly_ai_points": "每月 AI 積分", + "training_priority_high": "訓練優先級(高的優先)", + "allow_site_sync": "允許使用站點同步", + "allow_team_operation_logs": "允許團隊操作日誌", + "click_configure_plan": "點擊配置套餐", + "copy_success": "複製成功", + "delete_confirm": "確認刪除該變量?", + "delete": "刪除", + "workflow_variable_custom": "自定義工作流變量", + "field_name": "變量名", + "usage_url": "使用量查詢地址", + "note": "說明", + "import_config": "導入配置", + "import_success_save": "導入成功,請點擊保存", + "import_check_format": "請檢查配置文件格式", + "import": "導入", + "get_config_error": "獲取配置出錯", + "save_success": "保存成功", + "save_failed": "保存失敗", + "frontend_display_config": "前端展示配置", + "personalization_config": "個性化配置", + "global_script": "全局Script腳本", + "system_params": "系統參數", + "pdf_parse_config": "PDF 解析配置", + "usage_limits": "使用限制", + "sidebar_config": "側邊欄配置", + "system_name": "系統名", + "custom_api_domain": "自定義api域名", + "custom_api_domain_desc": "可以設置一個額外的api地址,不使用主站的地址,需配置域名的cname和ssl證書。", + "custom_share_link_domain": "自定義分享鏈接域名", + "custom_share_link_domain_desc": "可以設置一個額外的分享鏈接地址,不使用主站的地址,需配置域名的cname和ssl證書。", + "openapi_prefix": "OpenAPI 前綴", + "contact_popup": "聯繫彈窗", + "contact_popup_desc": "使用 Markdown 進行配置,配置之後,在網頁中\"聯繫我們\"相關的內容,會提示填寫的內容。", + "custom_api_doc_url": "自定義 api 文檔地址", + "custom_openapi_doc_url": "自定義 openapi 文檔地址", + "doc_url_note": "文檔地址(加一個 / 結尾,否則會攜帶子路徑跳轉)", + "contribute_plugin_doc_url": "貢獻插件文檔地址", + "contribute_template_doc_url": "貢獻模板市場文檔地址", + "global_script_desc": "自定義 Script 腳本,可以全局插入(可以做站點流量監控之類的)", + "mcp_forward_service_url": "MCP 轉發服務地址", + "mcp_forward_service_desc": "需要部署一個 MCP 轉發服務,用於將 FastGPT 應用以MCP協議暴露,例如:http://localhost:3005", + "oneapi_url": "oneAPI地址(會覆蓋環境變量配置的)", + "oneapi_url_desc": "oneAPI地址,可以使用 oneapi 來實現多模型接入", + "input_oneapi_url": "請輸入 oneAPI 地址", + "oneapi_key": "OneAPI 密鑰(會覆蓋環境變量配置的)", + "input_oneapi_key": "請輸入 OneAPI 密鑰", + "dataset_index_max_process": "知識庫索引最大處理進程", + "file_understanding_max_process": "文件理解模型最大處理進程", + "image_understanding_max_process": "圖片理解模型最大處理進程", + "hnsw_ef_search": "HNSW ef_search", + "hnsw_ef_search_desc": "HNSW 參數。越大召回率越高,性能越差,默認為 100,具體可見:https://github.com/pgvector/pgvector", + "hnsw_max_scan_tuples": "HNSW max_scan_tuples", + "hnsw_max_scan_tuples_desc": "迭代搜索最大數量,越大召回率越高,性能越差,默認為 100000,具體可見:https://github.com/pgvector/pgvector", + "token_calc_max_process": "token計算最大進程(通常多少並發設置多少)", + "custom_pdf_parse_url": "自定義 PDF 解析地址", + "custom_pdf_parse_key": "自定義 PDF 解析密鑰", + "doc2x_pdf_parse_key": "Doc2x pdf 解析密鑰(比自定義 PDF 解析優先級低)", + "custom_pdf_parse_price": "自定義 PDF 解析價格(n 積分/頁)", + "max_upload_files_per_time": "單次最多上傳多少個文件", + "max_upload_files_per_time_desc": "用戶上傳知識庫時,每次上傳最多選擇多少個文件", + "max_upload_file_size": "上傳文件最大大小(M)", + "max_upload_file_size_desc": "用戶上傳知識庫時,每個文件最大是多少。放大的話,需要注意網關也要設置得夠大。", + "export_interval_minutes": "導出間隔時長(分鐘)", + "site_sync_interval_minutes": "站點同步使用間隔時長(分鐘)", + "mobile_sidebar_location": "移動端的側邊欄顯示在賬號 - 個人信息里", + "basic_features": "基礎功能", + "third_party_knowledge_base": "第三方知識庫", + "third_party_publish_channels": "第三方發布渠道", + "feature_display_config": "功能展示配置", + "display_team_sharing": "展示團隊分享", + "display_chat_blank_page": "展示聊天空白頁(都關閉即可)", + "display_invite_friends_activity": "展示邀請好友活動", + "frontend_compliance_notice": "前端是否展示合規提示文案", + "feishu_knowledge_base": "飛書知識庫", + "feishu_knowledge_base_desc": "關閉後,創建數據庫時不再顯示飛書數據庫", + "yuque_knowledge_base": "語雀知識庫", + "yuque_knowledge_base_desc": "關閉後,創建數據庫時不再顯示語雀數據庫", + "feishu_publish_channel": "飛書發布渠道", + "feishu_publish_channel_desc": "關閉後,發布渠道中不再顯示飛書發布渠道", + "dingtalk_publish_channel": "釘釘發布渠道", + "dingtalk_publish_channel_desc": "關閉後,發布渠道中不再顯示釘釘發布渠道", + "wechat_publish_channel": "公眾號發布渠道", + "wechat_publish_channel_desc": "關閉後,發布渠道中不再顯示公眾號發布渠道", + "content_security_review": "內容安全審查", + "baidu_security_id": "百度安全 id", + "baidu_security_secret": "百度安全 secret", + "custom_security_check_url": "自定義安全校驗 URL", + "baidu_security_register_desc": "註冊百度安全校驗賬號,並創建對應應用。提供應用的 id 和 secret", + "custom_security_check_desc": "如果您有自己的安全校驗服務,可以填寫該地址,並在安全設置中開啟自定義安全校驗", + "plan_free": "免費版", + "plan_trial": "體驗版", + "plan_team": "團隊版", + "plan_enterprise": "企業版", + "subscription_plan": "訂閱套餐", + "standard_subscription_plan": "標準訂閱套餐", + "custom_plan_description": "自定義套餐說明", + "dataset_storage_cost_desc": "知識庫存儲費用(xx元/1000條/月)", + "extra_ai_points_cost_desc": "額外AI積分費用(xx元/1000積分/月)", + "payment_method": "支付方式", + "wechat_payment_config": "微信支付配置", + "alipay_payment_config": "支付寶支付配置", + "corporate_payment_message": "對公支付消息提示", + "enable_subscription_plan": "是否啟用訂閱套餐", + "custom_plan_page_description": "如果填寫了該地址,會覆蓋系統上套餐頁面,會跳轉到這個自定義頁面,你可以在自定義頁面裡定義收費規則", + "wechat_payment_materials": "微信支付相關材料", + "wechat_payment_registration_guide": "自行註冊微信支付,目前需要wx掃碼支付", + "unused_field_placeholder": "沒用到,隨便填個", + "certificate_management_guide": "點管理證書進去看到", + "wechat_key_extraction_guide": "按微信教程拿到這幾個文件,txt打開key", + "alipay_payment_materials": "支付寶支付相關材料", + "alipay_application_guide": "自行註冊支付寶應用,目前需要開通電腦網站支付", + "alipay_certificate_encryption_guide": "點接口加簽方式後選擇證書加密方式,具體操作參考", + "application_public_key_certificate": "應用公鑰證書", + "private_key_document_reference": "參考上面私鑰獲取文檔", + "alipay_root_certificate": "支付寶根證書", + "alipay_public_key_certificate": "支付寶公鑰證書", + "alipay_dateway": "支付寶網關", + "alipay_gateway_sandbox_note": "支付寶網關,注意測試使用的沙箱環境是", + "alipay_gateway_production_note": ",而生成環境是", + "alipay_endpoint_sandbox_note": "支付寶端點,注意測試使用的沙箱環境是", + "message_notification": "消息提示", + "markdown_format_support": "支持markdown格式", + "third_party_account_config": "第三方賬號配置", + "allow_user_account_config": "允許用戶配置賬號", + "view_documentation": "查看文檔", + "openai_oneapi_account": "OpenAI/OneAPI 賬號", + "laf_account": "laf 賬號", + "input_laf_address": "請輸入 laf 地址", + "multi_team_mode": "多團隊模式", + "single_team_mode": "單團隊模式", + "sync_mode": "同步模式", + "notification_login_settings": "通知 & 登錄設置", + "team_mode_settings": "團隊模式設置", + "custom_user_system_config": "自定義用戶系統配置", + "email_notification_config": "郵箱通知配置(註冊、套餐通知)", + "aliyun_sms_config": "阿里雲短信配置", + "aliyun_sms_template_code": "阿里雲短信模板CODE(SMS_xxx)", + "wechat_service_login": "微信服務號登錄", + "github_login_config": "GitHub 登錄配置", + "google_login_config": "Google 登錄配置", + "microsoft_login_config": "微軟登錄配置", + "quick_login": "快速登錄(不推薦)", + "login_notifications_config": "通知登錄 & 設置", + "user_service_root_address": "用戶服務根地址(末尾不加/)", + "sso_usage_guide": "具體用法請看: SSO & 外部成員同步", + "sso_login_button_title": "SSO 登錄按鈕標題", + "config_sso_login_button_title": "配置 SSO 登錄按鈕的標題", + "sso_login_button_icon": "SSO 登錄按鈕的圖標", + "config_sso_login_button_icon": "配置 SSO 登錄按鈕的圖標", + "sso_auto_redirect": "SSO 自動跳轉", + "sso_auto_redirect_desc": "開啟後,用戶進入登錄頁面,將會自動觸發 SSO 登錄,無需手動點擊。", + "email_smtp_address": "郵箱服務SMTP地址", + "email_smtp_address_note": "不同廠商不一樣", + "email_smtp_username": "郵箱服務SMTP用戶名", + "email_smtp_username_example": "qq 郵箱為例,對應 qq 號", + "email_password": "郵箱 密碼", + "email_smtp_auth_code": "SMTP 授權碼", + "enable_email_registration": "是否開啟郵箱註冊", + "aliyun_sms_params": "阿里雲短信參數", + "aliyun_sms_apply_guide": "申請對應的簽名和短信模板,提供:", + "signature_name": "簽名名稱", + "template_code_sm": "模板CODE,SM開頭的", + "aliyun_secret_key": "阿里雲賬號的secret key", + "sms_signature": "短信簽名", + "registration_account": "註冊賬號", + "registration_account_desc": "填寫後,將會開啟手機號註冊", + "reset_password": "重置密碼", + "reset_password_desc": "填寫後,將會開啟手機號找回密碼", + "bind_notification_phone": "綁定通知手機號", + "bind_notification_phone_desc": "填寫後,將會允許手機號綁定通知方式", + "subscription_expiring_soon": "訂閱套餐即將過期", + "subscription_expiring_soon_desc": "填寫後,套餐即將過期,會發送一個短信", + "free_user_cleanup_warning": "免費版用戶清理警告", + "wechat_service_appid": "服務號的 Appid。微信服務號的驗證地址填寫:商業版域名", + "wechat_service_secret": "服務號的 Secret", + "register_one": "註冊一個", + "provide": "提供", + "domain": "域名", + "microsoft_app_client_id": "對應 Microsoft 應用的「應用程序(客戶端) ID」", + "microsoft_tenant_id": "對應 Microsoft 應用的「租戶 ID」, 若使用默認的 common 可不用填寫", + "custom_button_name": "自定義按鈕名", + "custom_button_name_desc": "自定義按鈕的名稱,若不填寫則使用默認的 Microsoft 按鈕", + "simple_app": "簡易應用", + "workflow": "工作流", + "plugin": "插件", + "folder": "文件夾", + "http_plugin": "HTTP 插件", + "toolset": "工具集", + "tool": "工具", + "hidden": "隱藏", + "select_json_file": "請選擇 JSON 文件", + "confirm_delete_template": "確認刪除該模板嗎?", + "upload_config_first": "請先上傳配置文件", + "app_type_not_recognized": "未識別到應用類型", + "config_json_format_error": "配置文件 JSON 格式錯誤", + "template_update_success": "模板更新成功", + "template_create_success": "模板創建成功", + "template_config": "模板配置", + "json_serialize_failed": "JSON 序列化失敗", + "get_app_type_failed": "獲取應用類型失敗", + "file_overwrite_content": "文件將覆蓋當前內容", + "config_file_label": "配置文件", + "official_config": "官方配置", + "upload_file": "上傳文件", + "paste_config_or_drag_json": "粘貼配置或拖入 JSON 文件", + "paste_config": "粘貼配置", + "app_type": "應用類型", + "auto_recognize": "自動識別", + "app_attribute_not_recognized": "未識別到應用屬性", + "text": "文本", + "link": "鏈接", + "input_link": "請輸入鏈接", + "settings_successful": "設置成功", + "configure_quick_templates": "配置快捷模板", + "search_apps": "搜索應用", + "selected_count": "已選: {{count}} / 3", + "category_management": "分類管理", + "total_categories": "共 {{length}} 個分類", + "add_category": "添加分類", + "category_name": "分類名", + "category_name_empty": "分類名不能為空", + "template_list": "模板列表", + "quick_template": "快捷模板", + "add_template": "添加模板", + "recommended": "推薦", + "no_templates": "暫無模板", + "add_attribute_first": "請先添加屬性", + "add_plugin": "添加插件", + "token_points": "Token 積分", + "token_fee_description": "開啟該開關後,用戶使用該插件,需要支付插件中Token的積分,並且同時會收取調用積分", + "call_points": "調用積分", + "system_key": "系統密鑰", + "system_key_description": "對於需要密鑰的工具,您可為其配置系統密鑰,用戶可透過支付積分的方式使用系統密鑰。", + "no_plugins": "暫無插件", + "invoice_application": "開票申請", + "search_user_placeholder": "請輸入用戶名,回車搜索", + "submit_status": "提交狀態", + "submit_complete_time": "提交時間/完成時間", + "invoice_title": "抬頭", + "waiting_for_invoice": "等待開票", + "completed": "已完成", + "confirm_invoice": "確認開票", + "no_invoice_records": "無開票記錄~", + "invoice_details": "發票詳情", + "invoice_amount": "開票金額", + "organization": "組織名稱", + "unified_credit_code": "統一信用代碼", + "company_address": "公司地址", + "company_phone": "公司電話", + "bank_name": "開戶銀行", + "bank_account": "開戶賬號", + "need_special_invoice": "是否需要專票", + "yes": "是", + "no": "否", + "email_address": "郵箱地址", + "invoice_file": "發票文件", + "click_download": "點擊下載", + "operation_success": "操作成功", + "operation_failed": "操作失敗", + "upload_invoice_pdf": "請上傳發票的PDF文件", + "select_invoice_file": "選擇發票文件", + "confirm_submission": "確認提交", + "balance_recharge": "餘額充值", + "plan_subscription": "套餐訂閱", + "knowledge_base_expansion": "知識庫擴容", + "ai_points_package": "AI積分套餐", + "monthly": "按月", + "yearly": "按年", + "free": "免費", + "trial": "體驗", + "team": "團隊", + "enterprise": "企業", + "custom": "自定義", + "wechat": "微信", + "balance": "餘額", + "alipay": "支付寶", + "corporate": "對公", + "redeem_code": "兌換碼", + "search_user": "請輸入用戶名搜索", + "team_id": "團隊ID", + "recharge_member_name": "充值的成員名", + "unpaid": "未支付", + "no_bill_records": "無賬單記錄~", + "order_details": "訂單詳情", + "order_number": "訂單號", + "generation_time": "生成時間", + "order_type": "訂單類型", + "subscription_period": "訂閱周期", + "subscription_package": "訂閱套餐", + "months": "月數", + "extra_knowledge_base_capacity": "額外知識庫容量", + "extra_ai_points": "額外AI積分", + "time_error": "開始時間不能大於結束時間", + "points_error": "剩餘積分不能大於總積分", + "add_success": "添加成功", + "add_package": "添加套餐", + "package_type": "套餐類型", + "basic_package": "基礎套餐", + "start_time": "開始時間", + "required": "*必填", + "end_time": "結束時間", + "package_level": "套餐級別", + "total_points": "總積分", + "remaining_points": "剩餘積分", + "price_yuan_for_record_only": "價格(元)-僅用於記錄", + "price": "價格", + "update_success": "更新成功", + "edit": "編輯", + "edit_package": "編輯套餐", + "value_override_description": "下面的值會覆蓋套餐配置,不填則會用套餐的標準值", + "team_member_limit": "團隊成員上限", + "app_limit": "應用上限", + "knowledge_base_limit": "知識庫上限", + "team_name": "團隊名", + "points": "積分", + "start_end_time": "起止時間", + "version": "版", + "extra_knowledge_base": "額外知識庫", + "no_package_records": "無套餐記錄~", + "role": "權限", + "team_details": "團隊詳情", + "chage_success": "變更成功", + "team_edit": "團隊編輯", + "team_list": "團隊列表", + "create_time": "創建時間", + "no_team_data": "無團隊記錄~", + "add_user": "添加用戶", + "password": "密碼", + "password_requirements": "密碼至少 8 位,且至少包含兩種組合:數字、字母或特殊字符", + "account_deactivated": "賬號已註銷", + "edit_user": "編輯用戶", + "user_status": "用戶狀態", + "confirm_deactivate_account": "確認註銷該賬號?會將該用戶下關鍵資源刪除,並修改其用戶名成 xx-deleted", + "deactivate": "註銷", + "no_user_records": "無用戶記錄~", + "user_details": "用戶詳情", + "system_incompatibility": "部分系統不兼容,導致頁面崩潰。如果可以,請聯繫作者,反饋下具體操作和頁面。大部分是 蘋果 的 safari 瀏覽器導致,可以嘗試更換 chrome 瀏覽器。", + "content_not_compliant": "您的內容不合規", + "baidu_content_security_check_exception": "百度內容安全校驗異常", + "license_not_read": "未讀取到 License", + "license_invalid": "License 不合法", + "license_expired": "License 已過期", + "license_content_error": "License 內容錯誤", + "system_not_activated": "系統未激活", + "exceed_max_users": "超過最大用戶數", + "server_error": "服務器異常", + "request_error": "請求錯誤", + "unknown_error": "未知錯誤", + "token_expired_relogin": "token過期,重新登錄", + "google_verification_result": "谷歌校驗結果", + "abnormal_operation_environment": "您的操作環境存在異常,請刷新頁面後重試或聯繫客服。", + "notify_expiring_packages": "通知即將過期的套餐", + "notify_free_users_cleanup": "通知免費版用戶即將清理", + "bing_oauth_config_incomplete": "Bing OAuth配置不完整", + "request_limit_per_minute": "每分鐘僅能請求次數", + "share_link_expired": "分享鏈接已過期", + "link_usage_limit_exceeded": "鏈接超出使用限制", + "authentication_failed": "身份校驗失敗", + "user_registration": "新用戶註冊", + "initial_password": "您的初始密碼為", + "notification_too_frequent": "發送通知太頻繁了", + "emergency_notification_requires_teamid": "緊急通知必須提供 teamId", + "send_sms_failed": "發送短信失敗", + "fastgpt_user": "FastGPT用戶", + "refund_failed": "退款失敗", + "refund_request_failed": "退款請求失敗", + "get_certificate_list_failed": "獲取證書列表失敗", + "platform_certificate_serial_mismatch": "平台證書序列號不相符", + "extra_knowledge_base_storage": "額外知識庫存儲", + "image_compression_error": "壓縮圖片異常", + "image_too_large": "圖片太大了" +} \ No newline at end of file From cab0d4c26a084729ff7ec9351c9a7f7e2ad466e7 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 16 Sep 2025 19:54:44 +0800 Subject: [PATCH 12/84] feat: enhance evaluation dataset quality result filtering and UI - Add qualityResult parameter support to list API for precise filtering - Update DataList component to properly handle quality result vs status filtering - Improve status color logic to prioritize error states over quality results - Add comprehensive test coverage for quality result filtering scenarios - Fix API type definitions to include qualityResult in list request body --- .../global/core/evaluation/dataset/api.d.ts | 2 +- .../evaluation/dataset/detail/DataList.tsx | 28 ++-- .../api/core/evaluation/dataset/data/list.ts | 17 ++- .../core/evaluation/dataset/data/list.test.ts | 124 +++++++++++++++++- 4 files changed, 159 insertions(+), 12 deletions(-) diff --git a/packages/global/core/evaluation/dataset/api.d.ts b/packages/global/core/evaluation/dataset/api.d.ts index e9fff4e3930f..edd7f49401a8 100644 --- a/packages/global/core/evaluation/dataset/api.d.ts +++ b/packages/global/core/evaluation/dataset/api.d.ts @@ -48,7 +48,6 @@ type QualityEvaluationBase = { }; export type importEvalDatasetFromFileBody = { - fileId?: string; // Optional for form-data, files will be uploaded directly collectionId?: string; // Optional - use existing collection mode // Optional fields for creating new collection mode name?: string; @@ -74,6 +73,7 @@ export type listEvalDatasetDataBody = PaginationProps<{ collectionId: string; searchKey?: string; status?: EvalDatasetDataQualityStatus; + qualityResult?: EvalDatasetDataQualityResultEnum; }>; export type listEvalDatasetDataResponse = PaginationResponse< diff --git a/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/DataList.tsx b/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/DataList.tsx index 32111f749757..dab954f3f1a3 100644 --- a/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/DataList.tsx +++ b/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/DataList.tsx @@ -65,14 +65,23 @@ const DataListContent = () => { successToast: t('common:delete_success') }); - const scrollParams = useMemo( - () => ({ + const scrollParams = useMemo(() => { + const baseParams = { searchKey: searchKey || '', - status: status === EvaluationStatus.All ? '' : status, collectionId - }), - [searchKey, status, collectionId] - ); + }; + + if (status === EvaluationStatus.All) { + return { ...baseParams, status: '', qualityResult: '' }; + } else if ( + status === EvaluationStatus.HighQuality || + status === EvaluationStatus.NeedsImprovement + ) { + return { ...baseParams, status: '', qualityResult: status }; + } else { + return { ...baseParams, status: status, qualityResult: '' }; + } + }, [searchKey, status, collectionId]); const EmptyTipDom = useMemo(() => , [t]); @@ -95,6 +104,11 @@ const DataListContent = () => { // 获取状态标签颜色 const getStatusColor = (qualityStatus: string, qualityResult?: string) => { + // 错误状态优先显示,不管有没有质量结果 + if (qualityStatus === EvalDatasetDataQualityStatusEnum.error) { + return 'red'; + } + // 如果有质量结果,优先显示质量结果的颜色 if (qualityResult) { switch (qualityResult) { @@ -115,8 +129,6 @@ const DataListContent = () => { return 'blue'; case EvalDatasetDataQualityStatusEnum.queuing: return 'gray'; - case EvalDatasetDataQualityStatusEnum.error: - return 'red'; case EvalDatasetDataQualityStatusEnum.unevaluated: return 'gray'; default: diff --git a/projects/app/src/pages/api/core/evaluation/dataset/data/list.ts b/projects/app/src/pages/api/core/evaluation/dataset/data/list.ts index 9faadc0e8043..2535de9fb786 100644 --- a/projects/app/src/pages/api/core/evaluation/dataset/data/list.ts +++ b/projects/app/src/pages/api/core/evaluation/dataset/data/list.ts @@ -8,9 +8,11 @@ import type { listEvalDatasetDataResponse } from '@fastgpt/global/core/evaluation/dataset/api'; import { replaceRegChars } from '@fastgpt/global/common/string/tools'; +import type { EvalDatasetDataQualityResultEnum } from '@fastgpt/global/core/evaluation/dataset/constants'; import { EvalDatasetDataKeyEnum, - EvalDatasetDataQualityStatusEnum + EvalDatasetDataQualityStatusEnum, + EvalDatasetDataQualityResultValues } from '@fastgpt/global/core/evaluation/dataset/constants'; import { authEvaluationDatasetDataRead } from '@fastgpt/service/core/evaluation/common'; import { addLog } from '@fastgpt/service/common/system/log'; @@ -20,7 +22,7 @@ async function handler( req: ApiRequestProps ): Promise { const { offset, pageSize } = parsePaginationRequest(req); - const { collectionId, searchKey, status: qualityStatus } = req.body; + const { collectionId, searchKey, status: qualityStatus, qualityResult } = req.body; if (!collectionId) { return Promise.reject(EvaluationErrEnum.datasetCollectionIdRequired); @@ -33,6 +35,12 @@ async function handler( ) { return Promise.reject(EvaluationErrEnum.evalDataQualityStatusInvalid); } + if ( + qualityResult && + !EvalDatasetDataQualityResultValues.includes(qualityResult as EvalDatasetDataQualityResultEnum) + ) { + return Promise.reject(EvaluationErrEnum.evalDataQualityStatusInvalid); + } await authEvaluationDatasetDataRead(collectionId, { req, @@ -57,6 +65,10 @@ async function handler( match['qualityMetadata.status'] = qualityStatus.trim(); } + if (qualityResult && typeof qualityResult === 'string' && qualityResult.trim().length > 0) { + match['qualityResult'] = qualityResult.trim(); + } + try { const [dataList, total] = await Promise.all([ MongoEvalDatasetData.aggregate(buildPipeline(match, offset, pageSize)), @@ -87,6 +99,7 @@ async function handler( collectionId, searchKey, qualityStatus, + qualityResult, offset, pageSize, error diff --git a/test/cases/pages/api/core/evaluation/dataset/data/list.test.ts b/test/cases/pages/api/core/evaluation/dataset/data/list.test.ts index 3d345d78a7d1..de2a0c450215 100644 --- a/test/cases/pages/api/core/evaluation/dataset/data/list.test.ts +++ b/test/cases/pages/api/core/evaluation/dataset/data/list.test.ts @@ -5,7 +5,9 @@ import { MongoEvalDatasetData } from '@fastgpt/service/core/evaluation/dataset/e import { Types } from '@fastgpt/service/common/mongo'; import { EvalDatasetDataKeyEnum, - EvalDatasetDataQualityStatusEnum + EvalDatasetDataQualityStatusEnum, + EvalDatasetDataQualityResultEnum, + EvalDatasetDataQualityResultValues } from '@fastgpt/global/core/evaluation/dataset/constants'; import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation'; @@ -1130,4 +1132,124 @@ describe('EvalDatasetData List API', () => { ); }); }); + + describe('Quality Result Filtering', () => { + it.each([ + { qualityResult: '', desc: 'empty' }, + { qualityResult: null, desc: 'null' }, + { qualityResult: undefined, desc: 'undefined' } + ])( + 'should handle $desc qualityResult parameter without filtering', + async ({ qualityResult }) => { + const req = { + body: { collectionId: validCollectionId, qualityResult, pageNum: 1, pageSize: 10 } + }; + + await handler_test(req as any); + + expect(mockMongoEvalDatasetData.aggregate).toHaveBeenCalledWith( + expect.arrayContaining([ + { $match: { evalDatasetCollectionId: new Types.ObjectId(validCollectionId) } } + ]) + ); + } + ); + + it.each([ + { qualityResult: ' ', desc: 'whitespace-only' }, + { qualityResult: 123, desc: 'non-string' }, + { qualityResult: 'invalidResult', desc: 'invalid enum value' } + ])('should reject invalid $desc qualityResult parameter', async ({ qualityResult }) => { + const req = { + body: { collectionId: validCollectionId, qualityResult, pageNum: 1, pageSize: 10 } + }; + + await expect(handler_test(req as any)).rejects.toBe( + EvaluationErrEnum.evalDataQualityStatusInvalid + ); + }); + + it.each(Object.values(EvalDatasetDataQualityResultEnum))( + 'should filter by quality result - %s', + async (qualityResult) => { + const req = { + body: { + collectionId: validCollectionId, + qualityResult, + pageNum: 1, + pageSize: 10 + } + }; + + await handler_test(req as any); + + const expectedMatch = { + evalDatasetCollectionId: new Types.ObjectId(validCollectionId), + qualityResult: qualityResult + }; + + expect(mockMongoEvalDatasetData.aggregate).toHaveBeenCalledWith( + expect.arrayContaining([{ $match: expectedMatch }]) + ); + + expect(mockMongoEvalDatasetData.countDocuments).toHaveBeenCalledWith(expectedMatch); + } + ); + + it('should combine status and qualityResult filters', async () => { + const req = { + body: { + collectionId: validCollectionId, + status: EvalDatasetDataQualityStatusEnum.completed, + qualityResult: EvalDatasetDataQualityResultEnum.highQuality, + pageNum: 1, + pageSize: 10 + } + }; + + await handler_test(req as any); + + const expectedMatch = { + evalDatasetCollectionId: new Types.ObjectId(validCollectionId), + 'qualityMetadata.status': EvalDatasetDataQualityStatusEnum.completed, + qualityResult: EvalDatasetDataQualityResultEnum.highQuality + }; + + expect(mockMongoEvalDatasetData.aggregate).toHaveBeenCalledWith( + expect.arrayContaining([{ $match: expectedMatch }]) + ); + + expect(mockMongoEvalDatasetData.countDocuments).toHaveBeenCalledWith(expectedMatch); + }); + + it('should handle search with qualityResult filter', async () => { + const req = { + body: { + collectionId: validCollectionId, + searchKey: 'AI test', + qualityResult: EvalDatasetDataQualityResultEnum.needsOptimization, + pageNum: 1, + pageSize: 10 + } + }; + + await handler_test(req as any); + + const expectedMatch = { + evalDatasetCollectionId: new Types.ObjectId(validCollectionId), + qualityResult: EvalDatasetDataQualityResultEnum.needsOptimization, + $or: [ + { [EvalDatasetDataKeyEnum.UserInput]: { $regex: new RegExp('AI test', 'i') } }, + { [EvalDatasetDataKeyEnum.ExpectedOutput]: { $regex: new RegExp('AI test', 'i') } }, + { [EvalDatasetDataKeyEnum.ActualOutput]: { $regex: new RegExp('AI test', 'i') } } + ] + }; + + expect(mockMongoEvalDatasetData.aggregate).toHaveBeenCalledWith( + expect.arrayContaining([{ $match: expectedMatch }]) + ); + + expect(mockMongoEvalDatasetData.countDocuments).toHaveBeenCalledWith(expectedMatch); + }); + }); }); From ddf7b68287920310902cafe8adb01c2902f7d687 Mon Sep 17 00:00:00 2001 From: sxf-xiongtao Date: Wed, 17 Sep 2025 08:56:26 +0800 Subject: [PATCH 13/84] feat: add admin i18n namespace --- packages/web/i18n/constants.ts | 3 ++- packages/web/i18n/en/admin.json | 6 +++--- packages/web/i18n/i18next.d.ts | 2 ++ packages/web/i18n/zh-CN/admin.json | 6 +++--- packages/web/i18n/zh-Hant/admin.json | 6 +++--- 5 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/web/i18n/constants.ts b/packages/web/i18n/constants.ts index 59e7f5351330..c58d4f30290e 100644 --- a/packages/web/i18n/constants.ts +++ b/packages/web/i18n/constants.ts @@ -21,7 +21,8 @@ export const I18N_NAMESPACES = [ 'account_model', 'dashboard_mcp', 'dashboard_evaluation', - 'evaluation' + 'evaluation', + 'admin' ]; export const I18N_NAMESPACES_MAP = I18N_NAMESPACES.reduce( diff --git a/packages/web/i18n/en/admin.json b/packages/web/i18n/en/admin.json index feaa914ea60f..6e27f44983a5 100644 --- a/packages/web/i18n/en/admin.json +++ b/packages/web/i18n/en/admin.json @@ -372,7 +372,7 @@ "notification_login_settings": "Notifications & login settings", "team_mode_settings": "Team mode settings", "custom_user_system_config": "Custom user system settings", - "email_notification_config": "Email notification settings (registration and plan notifications)", + "email_notification_config": "Email notification settings (registration and plan notifications)\nQQ: smtp.qq.com\ngmail: smtp.gmail.com", "aliyun_sms_config": "Alibaba Cloud SMS settings", "aliyun_sms_template_code": "Alibaba Cloud SMS template code (SMS_xxx)", "wechat_service_login": "WeChat service account login", @@ -382,7 +382,7 @@ "quick_login": "Quick login (not recommended)", "login_notifications_config": "Login notifications & settings", "user_service_root_address": "User service root URL (Do not add a trailing slash (/).)", - "sso_usage_guide": "For details, see SSO & External Member Sync", + "sso_usage_guide": "For details, see [SSO & External Member Sync](https://doc.fastgpt.io/docs/guide/admin/sso/)", "sso_login_button_title": "SSO button name", "config_sso_login_button_title": "Configure SSO button name", "sso_login_button_icon": "SSO button icon", @@ -609,4 +609,4 @@ "extra_knowledge_base_storage": "Extra knowledge base storage", "image_compression_error": "Image compression error occurred.", "image_too_large": "The image size is too large." -} \ No newline at end of file +} diff --git a/packages/web/i18n/i18next.d.ts b/packages/web/i18n/i18next.d.ts index d0977cf51791..95b7399eb143 100644 --- a/packages/web/i18n/i18next.d.ts +++ b/packages/web/i18n/i18next.d.ts @@ -21,6 +21,7 @@ import type chat from './zh-CN/chat.json'; import type login from './zh-CN/login.json'; import type account_model from './zh-CN/account_model.json'; import type dashboard_mcp from './zh-CN/dashboard_mcp.json'; +import type admin from './zh-CN/admin.json'; import type { I18N_NAMESPACES } from './constants'; export interface I18nNamespaces { @@ -47,6 +48,7 @@ export interface I18nNamespaces { account_model: typeof account_model; dashboard_mcp: typeof dashboard_mcp; dashboard_evaluation: typeof dashboard_evaluation; + admin: typeof admin; } export type I18nNsType = (keyof I18nNamespaces)[]; diff --git a/packages/web/i18n/zh-CN/admin.json b/packages/web/i18n/zh-CN/admin.json index c303110b20c2..39ccea8c7f9a 100644 --- a/packages/web/i18n/zh-CN/admin.json +++ b/packages/web/i18n/zh-CN/admin.json @@ -382,7 +382,7 @@ "quick_login": "快速登陆(不推荐)", "login_notifications_config": "通知登陆 & 设置", "user_service_root_address": "用户服务根地址(末尾不加/)", - "sso_usage_guide": "具体用法请看: SSO & 外部成员同步", + "sso_usage_guide": "具体用法请看: [SSO & 外部成员同步](https://doc.fastgpt.io/docs/guide/admin/sso/)", "sso_login_button_title": "SSO 登录按钮标题", "config_sso_login_button_title": "配置 SSO 登录按钮的标题", "sso_login_button_icon": "SSO 登录按钮的图标", @@ -390,7 +390,7 @@ "sso_auto_redirect": "SSO 自动跳转", "sso_auto_redirect_desc": "开启后,用户进入登录页面,将会自动触发 SSO 登录,无需手动点击。", "email_smtp_address": "邮箱服务SMTP地址", - "email_smtp_address_note": "不同厂商不一样", + "email_smtp_address_note": "不同厂商不一样\nQQ: smtp.qq.com\ngmail: smtp.gmail.com", "email_smtp_username": "邮箱服务SMTP用户名", "email_smtp_username_example": "qq 邮箱为例,对应 qq 号", "email_password": "邮箱 Password", @@ -609,4 +609,4 @@ "extra_knowledge_base_storage": "额外知识库存储", "image_compression_error": "压缩图片异常", "image_too_large": "图片太大了" -} \ No newline at end of file +} diff --git a/packages/web/i18n/zh-Hant/admin.json b/packages/web/i18n/zh-Hant/admin.json index d9db3efc51aa..a92e3971fa04 100644 --- a/packages/web/i18n/zh-Hant/admin.json +++ b/packages/web/i18n/zh-Hant/admin.json @@ -382,7 +382,7 @@ "quick_login": "快速登錄(不推薦)", "login_notifications_config": "通知登錄 & 設置", "user_service_root_address": "用戶服務根地址(末尾不加/)", - "sso_usage_guide": "具體用法請看: SSO & 外部成員同步", + "sso_usage_guide": "具體用法請看: [SSO & 外部成員同步](https://doc.fastgpt.io/docs/guide/admin/sso/)", "sso_login_button_title": "SSO 登錄按鈕標題", "config_sso_login_button_title": "配置 SSO 登錄按鈕的標題", "sso_login_button_icon": "SSO 登錄按鈕的圖標", @@ -390,7 +390,7 @@ "sso_auto_redirect": "SSO 自動跳轉", "sso_auto_redirect_desc": "開啟後,用戶進入登錄頁面,將會自動觸發 SSO 登錄,無需手動點擊。", "email_smtp_address": "郵箱服務SMTP地址", - "email_smtp_address_note": "不同廠商不一樣", + "email_smtp_address_note": "不同廠商不一樣\nQQ: smtp.qq.com\ngmail: smtp.gmail.com", "email_smtp_username": "郵箱服務SMTP用戶名", "email_smtp_username_example": "qq 郵箱為例,對應 qq 號", "email_password": "郵箱 密碼", @@ -411,7 +411,7 @@ "subscription_expiring_soon": "訂閱套餐即將過期", "subscription_expiring_soon_desc": "填寫後,套餐即將過期,會發送一個短信", "free_user_cleanup_warning": "免費版用戶清理警告", - "wechat_service_appid": "服務號的 Appid。微信服務號的驗證地址填寫:商業版域名", + "wechat_service_appid": "服務號的 Appid。微信服務號的驗證地址填寫:商業版域名//api/support/user/account/login/wx/callback", "wechat_service_secret": "服務號的 Secret", "register_one": "註冊一個", "provide": "提供", From 9981d05ecedfe6786fcea74f4fcefbc472af1dd7 Mon Sep 17 00:00:00 2001 From: lyx Date: Wed, 17 Sep 2025 10:54:14 +0800 Subject: [PATCH 14/84] feat(evaluation/dataset): Optimize evaluation dataset editing and retest logic - Added data refresh logic when closing the edit modal - Enhanced the retest feature to support automatic saving when data changes - Improved the mechanism for detecting changes in evaluation status - Unified the loading state management for the retest button - Fixed the evaluation status reset logic when editing form data - Optimized the interaction process for the evaluation result action buttons --- .../dataset/detail/DataListModals.tsx | 7 +- .../dataset/detail/EditDataModal.tsx | 128 ++++++++++++++---- 2 files changed, 104 insertions(+), 31 deletions(-) diff --git a/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/DataListModals.tsx b/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/DataListModals.tsx index 042b8f05ad87..99f0fd1734fc 100644 --- a/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/DataListModals.tsx +++ b/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/DataListModals.tsx @@ -168,13 +168,18 @@ const DataListModals: React.FC = ({ total, refreshList }) = refreshList?.(); }; + const handleCloseEditModal = (isRefresh: boolean) => { + onEditModalClose(); + isRefresh && refreshList?.(); + }; + return ( <> {/* 编辑数据弹窗 */} {selectedItem && ( diff --git a/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/EditDataModal.tsx b/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/EditDataModal.tsx index 9fcf1a51de48..4e99e274467d 100644 --- a/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/EditDataModal.tsx +++ b/projects/app/src/pageComponents/dashboard/evaluation/dataset/detail/EditDataModal.tsx @@ -32,6 +32,7 @@ import { import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; import type { listEvalDatasetDataResponse } from '@fastgpt/global/core/evaluation/dataset/api'; import MyTag from '@fastgpt/web/components/common/Tag/index'; +import { updateEvaluationDatasetData } from '@/web/core/evaluation/dataset'; interface EditDataFormData { question: string; @@ -40,7 +41,7 @@ interface EditDataFormData { interface EditDataModalProps { isOpen: boolean; - onClose: () => void; + onClose: (isRefresh: boolean) => void; onSave: ( data: EditDataFormData & { qualityMetadata: any; qualityResult: string }, isGoNext?: boolean @@ -83,6 +84,11 @@ const EditDataModal: React.FC = ({ formData?.qualityResult || '' ); + const hasStatusChange = useMemo( + () => currentQualityReason !== qualityReason || evaluationStatus !== currentEvaluationStatus, + [currentQualityReason, currentEvaluationStatus] + ); + const [errorMsg, setErrorMsg] = useState(formData.qualityMetadata?.error || ''); const [reviewBtns, setReviewBtns] = useState([ @@ -150,6 +156,24 @@ const EditDataModal: React.FC = ({ } ); + // 新增保存请求,用于重测前的数据保存 + const { runAsync: saveBeforeRetest, loading: isSavingBeforeRetest } = useRequest2( + async (data: EditDataFormData) => + updateEvaluationDatasetData({ + dataId: formData._id, + userInput: data.question, + expectedOutput: data.referenceAnswer + }), + { + errorToast: t('common:submit_failed') + } + ); + + // 重测按钮的loading状态 + const retestLoading = useMemo(() => { + return isEvaluating || isSavingBeforeRetest; + }, [isEvaluating, isSavingBeforeRetest]); + // 轮询获取数据详情 - 在评测中或排队中时才轮询 const { runAsync: getDetail } = useRequest2(() => getEvaluationDatasetDataDetail(formData._id), { pollingInterval: 3000, @@ -175,6 +199,7 @@ const EditDataModal: React.FC = ({ register, handleSubmit, formState: { errors }, + getValues, reset } = useForm({ defaultValues: { @@ -204,20 +229,37 @@ const EditDataModal: React.FC = ({ getDetail(); } } - }, [isOpen, defaultQuestion, defaultReferenceAnswer, evaluationStatus, qualityReason, reset]); + }, [isOpen, defaultQuestion, defaultReferenceAnswer, evaluationStatus, qualityReason]); const handleSaveClick = (data: EditDataFormData, isGoNext = false) => { - onSave( - { + // 检查是否修改了问题或参考答案 + const hasQuestionChanged = data.question !== defaultQuestion; + const hasAnswerChanged = data.referenceAnswer !== defaultReferenceAnswer; + + let saveData; + if (hasQuestionChanged || hasAnswerChanged) { + // 如果修改了问题或答案,重置评测状态 + saveData = { + ...data, + qualityMetadata: { + status: EvalDatasetDataQualityStatusEnum.unevaluated, + reason: '' + }, + qualityResult: '' + }; + } else { + // 如果没有修改问题或答案,保持当前评测状态 + saveData = { ...data, qualityMetadata: { status: currentEvaluationStatus, reason: currentQualityReason }, qualityResult: currentQualityResult - }, - isGoNext - ); + }; + } + + onSave(saveData, isGoNext); }; const renderEvaluationContent = () => { @@ -319,7 +361,12 @@ const EditDataModal: React.FC = ({ {t('dashboard_evaluation:no_evaluation_result_click')} - + handleOprRes('startReview')} + > {t('dashboard_evaluation:start_evaluation_action')} @@ -358,31 +405,52 @@ const EditDataModal: React.FC = ({ handleCloseModal(); }; + const handleReview = async () => { + // 发起重测请求 + await simulateEvaluation({ dataId: formData._id }); + + // 设置评测状态为进行中 + setCurrentEvaluationStatus(EvalDatasetDataQualityStatusEnum.evaluating); + + // 更新按钮状态 + setReviewBtns((prev) => + prev.map((btn) => ({ + ...btn, + isShow: false + })) + ); + + // 根据结果更新按钮状态 + setReviewBtns((prev) => + prev.map((btn) => ({ + ...btn, + isShow: btn.key === 'modifyRes' || btn.key === 'reStart' + })) + ); + getDetail(); + }; + const handleOprRes = async (key: OprResType) => { switch (key) { case 'startReview': + handleReview(); + break; + case 'reStart': - await simulateEvaluation({ dataId: formData._id }); + // 获取当前表单数据 + const currentFormData = getValues(); - // 设置评测状态为进行中 - setCurrentEvaluationStatus(EvalDatasetDataQualityStatusEnum.evaluating); + // 检查数据是否发生变化 + const hasDataChanged = + currentFormData.question !== defaultQuestion || + currentFormData.referenceAnswer !== defaultReferenceAnswer; - // 更新按钮状态 - setReviewBtns((prev) => - prev.map((btn) => ({ - ...btn, - isShow: false - })) - ); + if (hasDataChanged) { + // 如果数据发生变化,先保存再重测 + await saveBeforeRetest(currentFormData); + } + handleReview(); - // 根据结果更新按钮状态 - setReviewBtns((prev) => - prev.map((btn) => ({ - ...btn, - isShow: btn.key === 'modifyRes' || btn.key === 'reStart' - })) - ); - getDetail(); break; case 'modifyRes': @@ -400,7 +468,7 @@ const EditDataModal: React.FC = ({ maxW={['90vw', '90vw']} w={'1024px'} isOpen={isOpen} - onClose={onClose} + onClose={() => onClose(hasStatusChange)} size={'md'} iconSrc="modal/edit" title={t('dashboard_evaluation:edit_data')} @@ -466,9 +534,9 @@ const EditDataModal: React.FC = ({ }} variant="outline" isLoading={ - isEvaluating && (btn.key === 'startReview' || btn.key === 'reStart') + retestLoading && (btn.key === 'startReview' || btn.key === 'reStart') } - disabled={isEvaluating} + disabled={retestLoading} > {btn.label} @@ -490,7 +558,7 @@ const EditDataModal: React.FC = ({ variant="outline" onClick={(e) => { e.preventDefault(); // 阻止默认行为 - onClose(); + onClose(hasStatusChange); }} > {t('dashboard_evaluation:cancel')} From e5decab5408473ff4457267aaa6bd561d0862704 Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 17 Sep 2025 11:18:09 +0800 Subject: [PATCH 15/84] fix: prevent overriding active quality assessment jobs - Skip active quality assessment jobs in batch operations instead of removing them - Reject quality assessment requests when active job exists to prevent conflicts - Update metadata schema references from metadata.qualityStatus to qualityMetadata.status - Update test cases to reflect new behavior of not overriding active jobs --- .../collection/qualityAssessmentBatch.ts | 34 ++------------- .../core/evaluation/dataset/data/create.ts | 4 +- .../core/evaluation/dataset/data/import.ts | 6 +-- .../dataset/data/qualityAssessment.ts | 8 +--- .../dataset/collection/list.test.ts | 2 +- .../evaluation/dataset/data/create.test.ts | 4 +- .../evaluation/dataset/data/import.test.ts | 4 +- .../dataset/data/qualityAssessment.test.ts | 43 ++++++------------- 8 files changed, 27 insertions(+), 78 deletions(-) diff --git a/projects/app/src/pages/api/core/evaluation/dataset/collection/qualityAssessmentBatch.ts b/projects/app/src/pages/api/core/evaluation/dataset/collection/qualityAssessmentBatch.ts index 802972951102..0dec4de27f4e 100644 --- a/projects/app/src/pages/api/core/evaluation/dataset/collection/qualityAssessmentBatch.ts +++ b/projects/app/src/pages/api/core/evaluation/dataset/collection/qualityAssessmentBatch.ts @@ -95,37 +95,9 @@ async function handler( } } - if (jobState === 'active') { - // Active evaluation task -> remove and overwrite - addLog.info('Removing active quality assessment job for re-evaluation', { - dataId, - jobId, - collectionId - }); - await removeEvalDatasetDataQualityJobsRobust([dataId], { - forceCleanActiveJobs: true, - retryDelay: 200 - }); - - // Create new job - await addEvalDatasetDataQualityJob({ - dataId: dataId, - evaluationModel: evalModel - }); - - // Update quality metadata - await MongoEvalDatasetData.findByIdAndUpdate(dataId, { - $set: { - 'qualityMetadata.status': EvalDatasetDataQualityStatusEnum.queuing, - 'qualityMetadata.model': evalModel, - 'qualityMetadata.queueTime': new Date() - } - }); - - processedCount++; - } else if (jobState && ['waiting', 'delayed', 'prioritized'].includes(jobState)) { - // Tasks in queue -> not affected - addLog.info('Skipping queued quality assessment job', { + if (jobState && ['waiting', 'delayed', 'prioritized', 'active'].includes(jobState)) { + // Tasks in queue or active -> not affected + addLog.info('Skipping queued or active quality assessment job', { dataId, jobState, collectionId diff --git a/projects/app/src/pages/api/core/evaluation/dataset/data/create.ts b/projects/app/src/pages/api/core/evaluation/dataset/data/create.ts index 899ff69189c5..7e749c8b69a9 100644 --- a/projects/app/src/pages/api/core/evaluation/dataset/data/create.ts +++ b/projects/app/src/pages/api/core/evaluation/dataset/data/create.ts @@ -158,10 +158,10 @@ async function handler( [EvalDatasetDataKeyEnum.Context]: context || [], [EvalDatasetDataKeyEnum.RetrievalContext]: retrievalContext || [], createFrom: EvalDatasetDataCreateFromEnum.manual, - metadata: { + qualityMetadata: { ...(enableQualityEvaluation ? {} - : { qualityStatus: EvalDatasetDataQualityStatusEnum.unevaluated }) + : { status: EvalDatasetDataQualityStatusEnum.unevaluated }) } } ], diff --git a/projects/app/src/pages/api/core/evaluation/dataset/data/import.ts b/projects/app/src/pages/api/core/evaluation/dataset/data/import.ts index 912632550ea0..f7bd1d44beaa 100644 --- a/projects/app/src/pages/api/core/evaluation/dataset/data/import.ts +++ b/projects/app/src/pages/api/core/evaluation/dataset/data/import.ts @@ -397,12 +397,10 @@ async function handler( [EvalDatasetDataKeyEnum.ActualOutput]: row.actual_output || '', [EvalDatasetDataKeyEnum.Context]: contextArray, [EvalDatasetDataKeyEnum.RetrievalContext]: retrievalContextArray, - [EvalDatasetDataKeyEnum.Metadata]: { - ...metadataObj, - importedFromFile: file.originalname, + qualityMetadata: { ...(enableQualityEvaluation ? {} - : { qualityStatus: EvalDatasetDataQualityStatusEnum.unevaluated }) + : { status: EvalDatasetDataQualityStatusEnum.unevaluated }) }, createFrom: EvalDatasetDataCreateFromEnum.fileImport }; diff --git a/projects/app/src/pages/api/core/evaluation/dataset/data/qualityAssessment.ts b/projects/app/src/pages/api/core/evaluation/dataset/data/qualityAssessment.ts index 5b1f55fe77e3..bca9f1ffba31 100644 --- a/projects/app/src/pages/api/core/evaluation/dataset/data/qualityAssessment.ts +++ b/projects/app/src/pages/api/core/evaluation/dataset/data/qualityAssessment.ts @@ -4,8 +4,7 @@ import { MongoEvalDatasetData } from '@fastgpt/service/core/evaluation/dataset/e import { MongoEvalDatasetCollection } from '@fastgpt/service/core/evaluation/dataset/evalDatasetCollectionSchema'; import { addEvalDatasetDataQualityJob, - checkEvalDatasetDataQualityJobActive, - removeEvalDatasetDataQualityJobsRobust + checkEvalDatasetDataQualityJobActive } from '@fastgpt/service/core/evaluation/dataset/dataQualityMq'; import type { qualityAssessmentBody } from '@fastgpt/global/core/evaluation/dataset/api'; import { EvalDatasetDataQualityStatusEnum } from '@fastgpt/global/core/evaluation/dataset/constants'; @@ -69,10 +68,7 @@ async function handler( try { const isJobActive = await checkEvalDatasetDataQualityJobActive(dataId); if (isJobActive) { - await removeEvalDatasetDataQualityJobsRobust([dataId], { - forceCleanActiveJobs: true, - retryDelay: 200 - }); + return Promise.reject(EvaluationErrEnum.evalDataQualityJobActiveCannotSetHighQuality); } await addEvalDatasetDataQualityJob({ diff --git a/test/cases/pages/api/core/evaluation/dataset/collection/list.test.ts b/test/cases/pages/api/core/evaluation/dataset/collection/list.test.ts index c2271908ff31..6e97b13764f1 100644 --- a/test/cases/pages/api/core/evaluation/dataset/collection/list.test.ts +++ b/test/cases/pages/api/core/evaluation/dataset/collection/list.test.ts @@ -324,7 +324,7 @@ describe('EvalDatasetCollection List API', () => { $lookup: { from: 'eval_dataset_datas', localField: '_id', - foreignField: 'datasetId', + foreignField: 'evalDatasetCollectionId', as: 'dataItems' } }, diff --git a/test/cases/pages/api/core/evaluation/dataset/data/create.test.ts b/test/cases/pages/api/core/evaluation/dataset/data/create.test.ts index f11e5641f2d3..7c63700152c0 100644 --- a/test/cases/pages/api/core/evaluation/dataset/data/create.test.ts +++ b/test/cases/pages/api/core/evaluation/dataset/data/create.test.ts @@ -306,7 +306,7 @@ describe('EvalDatasetData Create API', () => { [EvalDatasetDataKeyEnum.ExpectedOutput]: 'Test output', [EvalDatasetDataKeyEnum.Context]: [], [EvalDatasetDataKeyEnum.RetrievalContext]: [], - metadata: { qualityStatus: 'unevaluated' }, + qualityMetadata: { status: 'unevaluated' }, createFrom: EvalDatasetDataCreateFromEnum.manual, ...overrides }); @@ -460,7 +460,7 @@ describe('EvalDatasetData Create API', () => { mockAddEvalDatasetDataQualityJob.mockResolvedValue(undefined); await handler_test(req as any); - expectDataCreation(createExpectedDataObject({ metadata: {} })); + expectDataCreation(createExpectedDataObject({ qualityMetadata: {} })); }); it('should set qualityStatus when quality evaluation is disabled', async () => { diff --git a/test/cases/pages/api/core/evaluation/dataset/data/import.test.ts b/test/cases/pages/api/core/evaluation/dataset/data/import.test.ts index 2eea7560e9ef..7ecabe033ba9 100644 --- a/test/cases/pages/api/core/evaluation/dataset/data/import.test.ts +++ b/test/cases/pages/api/core/evaluation/dataset/data/import.test.ts @@ -417,8 +417,8 @@ describe('EvalDatasetData Import API', () => { expect(mockMongoEvalDatasetData.insertMany).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ - [EvalDatasetDataKeyEnum.Metadata]: expect.objectContaining({ - importedFromFile: 'test1.csv' + qualityMetadata: expect.objectContaining({ + status: 'unevaluated' }) }) ]), diff --git a/test/cases/pages/api/core/evaluation/dataset/data/qualityAssessment.test.ts b/test/cases/pages/api/core/evaluation/dataset/data/qualityAssessment.test.ts index 68be0072a60c..b1b3dacb5763 100644 --- a/test/cases/pages/api/core/evaluation/dataset/data/qualityAssessment.test.ts +++ b/test/cases/pages/api/core/evaluation/dataset/data/qualityAssessment.test.ts @@ -253,7 +253,7 @@ describe('QualityAssessment API', () => { expect(mockCheckEvalDatasetDataQualityJobActive).toHaveBeenCalledWith(validDataId); }); - it('should remove active job if one exists', async () => { + it('should reject when active job exists', async () => { mockCheckEvalDatasetDataQualityJobActive.mockResolvedValue(true); const req = { @@ -263,12 +263,9 @@ describe('QualityAssessment API', () => { } }; - await handler_test(req as any); - - expect(mockRemoveEvalDatasetDataQualityJobsRobust).toHaveBeenCalledWith([validDataId], { - forceCleanActiveJobs: true, - retryDelay: 200 - }); + await expect(handler_test(req as any)).rejects.toBe( + EvaluationErrEnum.evalDataQualityJobActiveCannotSetHighQuality + ); }); it('should not remove job if none exists', async () => { @@ -335,15 +332,13 @@ describe('QualityAssessment API', () => { }); describe('Error Handling', () => { - it('should handle job removal errors and reject with quality assessment failed', async () => { - const jobError = new Error('Failed to remove job'); + it('should reject with evalDataQualityJobActiveCannotSetHighQuality when active job exists', async () => { mockCheckEvalDatasetDataQualityJobActive.mockResolvedValue(true); - mockRemoveEvalDatasetDataQualityJobsRobust.mockRejectedValue(jobError); const req = createRequest(); await expect(handler_test(req as any)).rejects.toBe( - EvaluationErrEnum.qualityAssessmentFailed + EvaluationErrEnum.evalDataQualityJobActiveCannotSetHighQuality ); }); @@ -501,12 +496,13 @@ describe('QualityAssessment API', () => { }); describe('Integration Workflow', () => { - it('should execute complete workflow when job exists', async () => { + it('should reject when active job exists in integration workflow', async () => { mockCheckEvalDatasetDataQualityJobActive.mockResolvedValue(true); - mockRemoveEvalDatasetDataQualityJobsRobust.mockResolvedValue(undefined); const req = createRequest(); - const result = await handler_test(req as any); + await expect(handler_test(req as any)).rejects.toBe( + EvaluationErrEnum.evalDataQualityJobActiveCannotSetHighQuality + ); expect(mockAuthEvaluationDatasetDataUpdateById).toHaveBeenCalledWith(validDataId, { req, @@ -514,22 +510,9 @@ describe('QualityAssessment API', () => { authApiKey: true }); expect(mockCheckEvalDatasetDataQualityJobActive).toHaveBeenCalledWith(validDataId); - expect(mockRemoveEvalDatasetDataQualityJobsRobust).toHaveBeenCalledWith([validDataId], { - forceCleanActiveJobs: true, - retryDelay: 200 - }); - expect(mockAddEvalDatasetDataQualityJob).toHaveBeenCalledWith({ - dataId: validDataId, - evaluationModel: validEvaluationModel - }); - expect(mockMongoEvalDatasetData.findByIdAndUpdate).toHaveBeenCalledWith(validDataId, { - $set: { - 'qualityMetadata.status': EvalDatasetDataQualityStatusEnum.queuing, - 'qualityMetadata.model': validEvaluationModel, - 'qualityMetadata.queueTime': expect.any(Date) - } - }); - expect(result).toBe('success'); + // Should not proceed to add job or update database when active job exists + expect(mockAddEvalDatasetDataQualityJob).not.toHaveBeenCalled(); + expect(mockMongoEvalDatasetData.findByIdAndUpdate).not.toHaveBeenCalled(); }); it('should execute complete workflow when no job exists', async () => { From 1146cbad86f3fa8ed33161bd456b6ddf11eec9c9 Mon Sep 17 00:00:00 2001 From: chanzhi82020 Date: Wed, 17 Sep 2025 14:14:25 +0800 Subject: [PATCH 16/84] fix: fix the bug due to the datasetId rename by evalDatasetCollectionId --- .../global/common/error/code/evaluation.ts | 5 - packages/global/core/evaluation/api.d.ts | 11 +- packages/global/core/evaluation/constants.ts | 10 +- .../core/evaluation/metric/constants.ts | 6 +- .../global/core/evaluation/metric/type.d.ts | 6 +- packages/global/core/evaluation/type.d.ts | 7 +- .../service/core/evaluation/task/index.ts | 132 +++++++----------- .../service/core/evaluation/task/processor.ts | 3 +- .../service/core/evaluation/task/schema.ts | 8 +- .../service/core/evaluation/utils/index.ts | 57 ++++---- packages/web/i18n/en/evaluation.json | 3 +- packages/web/i18n/zh-CN/evaluation.json | 3 +- packages/web/i18n/zh-Hant/evaluation.json | 3 +- packages/web/support/user/audit/constants.ts | 4 +- .../dashboard/evaluation/DetailModal.tsx | 2 +- .../dashboard/evaluation/task/CreateModal.tsx | 18 +-- .../pages/api/core/evaluation/metric/debug.ts | 4 +- .../pages/api/core/evaluation/task/create.ts | 10 +- .../api/core/evaluation/task/item/list.ts | 9 +- .../pages/api/core/evaluation/task/update.ts | 6 +- 20 files changed, 143 insertions(+), 164 deletions(-) diff --git a/packages/global/common/error/code/evaluation.ts b/packages/global/common/error/code/evaluation.ts index b12374d52897..3ec858678fc8 100644 --- a/packages/global/common/error/code/evaluation.ts +++ b/packages/global/common/error/code/evaluation.ts @@ -8,7 +8,6 @@ export enum EvaluationErrEnum { evalNameTooLong = 'evaluationNameTooLong', evalDescriptionTooLong = 'evaluationDescriptionTooLong', evalDescriptionInvalidType = 'evaluationDescriptionInvalidType', - evalDatasetIdRequired = 'evaluationDatasetIdRequired', evalTargetRequired = 'evaluationTargetRequired', evalTargetInvalidConfig = 'evaluationTargetInvalidConfig', evalTargetAppIdMissing = 'evaluationTargetAppIdMissing', @@ -172,10 +171,6 @@ const evaluationErrList = [ statusText: EvaluationErrEnum.evalDescriptionTooLong, message: i18nT('evaluation:description_too_long') }, - { - statusText: EvaluationErrEnum.evalDatasetIdRequired, - message: i18nT('evaluation:dataset_id_required') - }, { statusText: EvaluationErrEnum.evalTargetRequired, message: i18nT('evaluation:target_required') diff --git a/packages/global/core/evaluation/api.d.ts b/packages/global/core/evaluation/api.d.ts index ff6dbc156af2..b5a368f984ef 100644 --- a/packages/global/core/evaluation/api.d.ts +++ b/packages/global/core/evaluation/api.d.ts @@ -8,6 +8,8 @@ import type { EvaluationDataItemType, EvaluationStatistics } from './type'; +import type { EvaluationStatusEnum } from './constants'; +import type { EvalDatasetDataKeyEnum } from './dataset/constants'; // ===== Common Types ===== export type MessageResponse = { message: string }; @@ -73,7 +75,14 @@ export type RetryFailedItemsResponse = { export type EvalItemIdQuery = { evalItemId: string }; // List Evaluation Items -export type ListEvaluationItemsRequest = PaginationProps; +export type ListEvaluationItemsRequest = PaginationProps< + EvalIdQuery & { + status?: EvaluationStatusEnum; + [EvalDatasetDataKeyEnum.UserInput]?: string; + [EvalDatasetDataKeyEnum.ExpectedOutput]?: string; + [EvalDatasetDataKeyEnum.ActualOutput]?: string; + } +>; export type ListEvaluationItemsResponse = PaginationResponse; // Get Evaluation Item Detail diff --git a/packages/global/core/evaluation/constants.ts b/packages/global/core/evaluation/constants.ts index 0eed14daa109..1455d45355ed 100644 --- a/packages/global/core/evaluation/constants.ts +++ b/packages/global/core/evaluation/constants.ts @@ -3,10 +3,10 @@ import { i18nT } from '../../../web/i18n/utils'; export const evaluationFileErrors = i18nT('dashboard_evaluation:eval_file_check_error'); export enum EvaluationStatusEnum { - queuing = 0, - evaluating = 1, - completed = 2, - error = 3 + queuing = 'queuing', + evaluating = 'evaluating', + completed = 'completed', + error = 'error' } export const EvaluationStatusMap = { @@ -23,7 +23,7 @@ export const EvaluationStatusMap = { name: i18nT('dashboard_evaluation:error') } }; -export const EvaluationStatusValues = Object.keys(EvaluationStatusMap).map(Number); +export const EvaluationStatusValues = Object.values(EvaluationStatusEnum); export enum SummaryStatusEnum { pending = 0, diff --git a/packages/global/core/evaluation/metric/constants.ts b/packages/global/core/evaluation/metric/constants.ts index 96a5889e348e..665fa8ad546e 100644 --- a/packages/global/core/evaluation/metric/constants.ts +++ b/packages/global/core/evaluation/metric/constants.ts @@ -10,8 +10,8 @@ export enum ModelTypeEnum { } export const ModelTypeValues = Object.values(ModelTypeEnum); -export enum EvaluationStatusEnum { +export enum MetricResultStatusEnum { Success = 'success', - Failed = 'failed', + Failed = 'failed' } -export const EvaluationStatusValues = Object.values(EvaluationStatusEnum); +export const MetricResultStatusValues = Object.values(MetricResultStatusEnum); diff --git a/packages/global/core/evaluation/metric/type.d.ts b/packages/global/core/evaluation/metric/type.d.ts index cf0dc611574c..8fb3d0b755ca 100644 --- a/packages/global/core/evaluation/metric/type.d.ts +++ b/packages/global/core/evaluation/metric/type.d.ts @@ -1,5 +1,5 @@ import type { EvalDatasetDataKeyEnum } from '../dataset/constants'; -import type { ModelTypeEnum, EvalMetricTypeEnum, EvaluationStatusEnum } from './constants'; +import type { ModelTypeEnum, EvalMetricTypeEnum, MetricResultStatusEnum } from './constants'; import type { SourceMemberType } from '@fastgpt/global/support/user/type'; export type EvalModelConfigType = { @@ -20,7 +20,7 @@ export type EvalCase = { export type MetricResult = { metricName: string; - status: EvaluationStatusEnum; + status: MetricResultStatusEnum; data?: EvaluationResult; usages?: Usage[]; error?: string; @@ -86,7 +86,7 @@ export type Usage = { }; export type EvaluationResponse = { - status: EvaluationStatusEnum; + status: MetricResultStatusEnum; data?: EvaluationResult; usages?: Usage[]; error?: string; diff --git a/packages/global/core/evaluation/type.d.ts b/packages/global/core/evaluation/type.d.ts index 90ec1940748e..81f4f3ea3b5d 100644 --- a/packages/global/core/evaluation/type.d.ts +++ b/packages/global/core/evaluation/type.d.ts @@ -148,10 +148,11 @@ export type EvaluationDisplayType = Pick< | 'tmbId' | 'permission' | 'statistics' + | 'target' > & { _id: string; - datasetName?: string; - target: EvalTarget; // Complete target object with extended config + evalDatasetCollectionName?: string; + evalDatasetCollectionId?: string; metricNames: string[]; private: boolean; sourceMember: SourceMemberType; @@ -162,7 +163,7 @@ export type EvaluationItemDisplayType = EvaluationItemSchemaType; export interface CreateEvaluationParams { name: string; description?: string; - datasetId: string; + evalDatasetCollectionId: string; target: EvalTarget; // Only supports workflow type target configuration evaluators: EvaluatorSchema[]; // Replace metricIds with evaluators autoStart?: boolean; // Whether to automatically start the evaluation task after creation (default: true) diff --git a/packages/service/core/evaluation/task/index.ts b/packages/service/core/evaluation/task/index.ts index f2016c17ffc1..6f0553c97e2e 100644 --- a/packages/service/core/evaluation/task/index.ts +++ b/packages/service/core/evaluation/task/index.ts @@ -195,7 +195,7 @@ export class EvaluationTaskService { from: 'eval_dataset_collections', localField: 'evalDatasetCollectionId', foreignField: '_id', - as: 'dataset' + as: 'evalDatasetCollection' } }, { @@ -294,7 +294,8 @@ export class EvaluationTaskService { }, { $addFields: { - datasetName: { $arrayElemAt: ['$dataset.name', 0] }, + evalDatasetCollectionName: { $arrayElemAt: ['$evalDatasetCollection.name', 0] }, + evalDatasetCollectionId: '$evalDatasetCollectionId', metricNames: { $map: { input: '$evaluators', @@ -329,7 +330,8 @@ export class EvaluationTaskService { finishTime: 1, status: 1, errorMessage: 1, - datasetName: 1, + evalDatasetCollectionName: 1, + evalDatasetCollectionId: 1, target: { type: '$target.type', config: { @@ -365,6 +367,14 @@ export class EvaluationTaskService { static async getEvaluationDetail(evalId: string, teamId: string): Promise { const evaluationResult = await MongoEvaluation.aggregate([ { $match: { _id: new Types.ObjectId(evalId), teamId: new Types.ObjectId(teamId) } }, + { + $lookup: { + from: 'eval_dataset_collections', + localField: 'evalDatasetCollectionId', + foreignField: '_id', + as: 'evalDatasetCollection' + } + }, { $addFields: { 'target.config.appObjectId': { $toObjectId: '$target.config.appId' } @@ -423,6 +433,8 @@ export class EvaluationTaskService { 'target.config.appName': { $arrayElemAt: ['$app.name', 0] }, 'target.config.avatar': { $arrayElemAt: ['$app.avatar', 0] }, 'target.config.versionName': { $arrayElemAt: ['$appVersion.versionName', 0] }, + evalDatasetCollectionName: { $arrayElemAt: ['$evalDatasetCollection.name', 0] }, + evalDatasetCollectionId: '$evalDatasetCollectionId', // Use real-time statistics if available, otherwise fallback to stored statistics statistics: { $cond: { @@ -450,6 +462,7 @@ export class EvaluationTaskService { name: 1, description: 1, evalDatasetCollectionId: 1, + evalDatasetCollectionName: 1, target: { type: '$target.type', config: { @@ -648,20 +661,43 @@ export class EvaluationTaskService { evalId: string, teamId: string, offset: number = 0, - pageSize: number = 20 + pageSize: number = 20, + options: { + status?: EvaluationStatusEnum; + userInput?: string; + expectedOutput?: string; + actualOutput?: string; + } = {} ): Promise<{ items: EvaluationItemDisplayType[]; total: number }> { const evaluation = await this.getEvaluation(evalId, teamId); + const { status, userInput, expectedOutput, actualOutput } = options; + + // Build query conditions + const filter: any = { evalId: evaluation._id }; + + if (status !== undefined) { + filter.status = status; + } + + if (userInput) { + filter['dataItem.userInput'] = { $regex: userInput, $options: 'i' }; + } + + if (expectedOutput) { + filter['dataItem.expectedOutput'] = { $regex: expectedOutput, $options: 'i' }; + } + + if (actualOutput) { + filter['targetOutput.actualOutput'] = { $regex: actualOutput, $options: 'i' }; + } + const skip = offset; const limit = pageSize; const [items, total] = await Promise.all([ - MongoEvalItem.find({ evalId: evaluation._id }) - .sort({ createTime: -1 }) - .skip(skip) - .limit(limit) - .lean(), - MongoEvalItem.countDocuments({ evalId: evaluation._id }) + MongoEvalItem.find(filter).sort({ createTime: -1 }).skip(skip).limit(limit).lean(), + MongoEvalItem.countDocuments(filter) ]); return { items, total }; @@ -962,77 +998,6 @@ export class EvaluationTaskService { return item; } - // Search evaluation items - static async searchEvaluationItems( - evalId: string, - teamId: string, - options: { - status?: EvaluationStatusEnum; - hasError?: boolean; - scoreRange?: { min?: number; max?: number }; - keyword?: string; - page?: number; - pageSize?: number; - } = {} - ): Promise<{ items: EvaluationItemDisplayType[]; total: number }> { - const evaluation = await this.getEvaluation(evalId, teamId); - - const { status, hasError, scoreRange, keyword, page = 1, pageSize = 20 } = options; - - // Build query conditions - const filter: any = { evalId: evaluation._id }; - - if (status !== undefined) { - filter.status = status; - } - - if (hasError === true) { - filter.status = EvaluationStatusEnum.error; - } else if (hasError === false) { - filter.status = { $ne: EvaluationStatusEnum.error }; - } - - if (scoreRange) { - const scoreFilter: any = {}; - if (scoreRange.min !== undefined) { - scoreFilter.$gte = scoreRange.min; - } - if (scoreRange.max !== undefined) { - scoreFilter.$lte = scoreRange.max; - } - if (Object.keys(scoreFilter).length > 0) { - filter['evaluatorOutputs.0.data.score'] = scoreFilter; - } - } - - if (keyword) { - filter.$or = [ - { 'dataItem.userInput': { $regex: keyword, $options: 'i' } }, - { 'dataItem.expectedOutput': { $regex: keyword, $options: 'i' } }, - { 'targetOutput.actualOutput': { $regex: keyword, $options: 'i' } } - ]; - } - - const skip = (page - 1) * pageSize; - - const [items, total] = await Promise.all([ - MongoEvalItem.find(filter) - .sort({ createTime: -1 }) - .skip(skip) - .limit(pageSize) - .lean() - .then((items) => - items.map((item) => ({ - ...item, - evalItemId: item._id.toString() - })) - ), - MongoEvalItem.countDocuments(filter) - ]); - - return { items, total }; - } - // Export evaluation item results static async exportEvaluationResults( evalId: string, @@ -1107,7 +1072,10 @@ export class EvaluationTaskService { `"${(item.dataItem?.expectedOutput || '').replace(/"/g, '""')}"`, `"${(item.targetOutput?.actualOutput || '').replace(/"/g, '""')}"`, // Add scores for each metric column in the same order as headers - ...sortedMetricNames.map((metricName) => metricScoreMap.get(metricName) || ''), + ...sortedMetricNames.map((metricName) => { + const score = metricScoreMap.get(metricName); + return score !== undefined ? score : ''; + }), item.status || '', `"${(item.errorMessage || '').replace(/"/g, '""')}"`, item.finishTime || '' diff --git a/packages/service/core/evaluation/task/processor.ts b/packages/service/core/evaluation/task/processor.ts index 6d1dd3199901..a8096cf83870 100644 --- a/packages/service/core/evaluation/task/processor.ts +++ b/packages/service/core/evaluation/task/processor.ts @@ -19,6 +19,7 @@ import { getErrText } from '@fastgpt/global/common/error/utils'; import { createMergedEvaluationUsage } from '../utils/usage'; import { EvaluationSummaryService } from '../summary'; import type { MetricResult } from '@fastgpt/global/core/evaluation/metric/type'; +import { MetricResultStatusEnum } from '@fastgpt/global/core/evaluation/metric/constants'; // Sleep utility function const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -732,7 +733,7 @@ const evaluationItemProcessor = async (job: Job) => { }); // Record error but continue processing - if (evaluatorOutput.status === 'failed' || evaluatorOutput.error) { + if (evaluatorOutput.status !== MetricResultStatusEnum.Success || evaluatorOutput.error) { const errorMessage = evaluatorOutput.error || 'Evaluator execution failed'; const evaluatorName = evaluator.metric.name || `Evaluator ${i + 1}`; errors.push({ evaluatorName, error: errorMessage }); diff --git a/packages/service/core/evaluation/task/schema.ts b/packages/service/core/evaluation/task/schema.ts index a7c9c1e9c19e..bcd2a244fd99 100644 --- a/packages/service/core/evaluation/task/schema.ts +++ b/packages/service/core/evaluation/task/schema.ts @@ -131,7 +131,7 @@ export const EvaluationTaskSchema = new Schema({ required: true }, status: { - type: Number, + type: String, enum: EvaluationStatusValues, default: EvaluationStatusEnum.queuing }, @@ -226,9 +226,9 @@ export const EvaluationItemSchema = new Schema({ default: [] }, status: { - type: Number, - default: EvaluationStatusEnum.queuing, - enum: EvaluationStatusValues + type: String, + enum: EvaluationStatusValues, + default: EvaluationStatusEnum.queuing }, retry: { type: Number, diff --git a/packages/service/core/evaluation/utils/index.ts b/packages/service/core/evaluation/utils/index.ts index 001b91974609..1fbd6a0aa829 100644 --- a/packages/service/core/evaluation/utils/index.ts +++ b/packages/service/core/evaluation/utils/index.ts @@ -11,46 +11,46 @@ export type EvaluationValidationParams = Partial; export interface EvaluationValidationOptions { mode?: 'create' | 'update'; // validation mode - teamId?: string; // required for dataset existence validation + teamId?: string; // required for evalDatasetCollection existence validation } /** - * Validate if a dataset exists and is accessible by the team + * Validate if a evalDatasetCollection exists and is accessible by the team */ -async function validateDatasetExists( - datasetId: string, +async function validateEvalDatasetCollectionExists( + evalDatasetCollectionId: string, teamId?: string ): Promise { - // Validate datasetId format - if (!Types.ObjectId.isValid(datasetId)) { + // Validate evalDatasetCollectionId format + if (!Types.ObjectId.isValid(evalDatasetCollectionId)) { return { isValid: false, errors: [ { - code: EvaluationErrEnum.evalDatasetIdRequired, - message: 'Invalid dataset ID format', - field: 'datasetId' + code: EvaluationErrEnum.datasetCollectionIdRequired, + message: 'Invalid evalDatasetCollectionId format', + field: 'evalDatasetCollectionId' } ] }; } - // Check if dataset exists - const filter: any = { _id: new Types.ObjectId(datasetId) }; + // Check if evalDatasetCollection exists + const filter: any = { _id: new Types.ObjectId(evalDatasetCollectionId) }; if (teamId) { filter.teamId = new Types.ObjectId(teamId); } - const dataset = await MongoEvalDatasetCollection.findOne(filter).lean(); + const datasetCollection = await MongoEvalDatasetCollection.findOne(filter).lean(); - if (!dataset) { + if (!datasetCollection) { return { isValid: false, errors: [ { code: EvaluationErrEnum.datasetCollectionNotFound, - message: 'Dataset not found or access denied', - field: 'datasetId' + message: 'evalDatasetCollection not found or access denied', + field: 'evalDatasetCollectionId' } ] }; @@ -63,7 +63,7 @@ export async function validateEvaluationParams( params: EvaluationValidationParams, options?: EvaluationValidationOptions ): Promise { - const { name, description, datasetId, target, evaluators } = params; + const { name, description, evalDatasetCollectionId, target, evaluators } = params; const mode = options?.mode || 'create'; const isCreateMode = mode === 'create'; @@ -82,14 +82,14 @@ export async function validateEvaluationParams( }; } - if (!datasetId) { + if (!evalDatasetCollectionId) { return { isValid: false, errors: [ { - code: EvaluationErrEnum.evalDatasetIdRequired, - message: 'Dataset ID is required', - field: 'datasetId' + code: EvaluationErrEnum.datasetCollectionIdRequired, + message: 'datasetCollectionId is required', + field: 'evalDatasetCollectionId' } ] }; @@ -166,22 +166,25 @@ export async function validateEvaluationParams( }; } - if (datasetId !== undefined) { - if (!datasetId) { + if (evalDatasetCollectionId !== undefined) { + if (!evalDatasetCollectionId) { return { isValid: false, errors: [ { - code: EvaluationErrEnum.evalDatasetIdRequired, - message: 'Dataset ID is required', - field: 'datasetId' + code: EvaluationErrEnum.datasetCollectionIdRequired, + message: 'datasetCollectionId is required', + field: 'evalDatasetCollectionId' } ] }; } - // Validate dataset exists and is accessible - const datasetValidation = await validateDatasetExists(datasetId, options?.teamId); + // Validate evaldatasetcollection exists and is accessible + const datasetValidation = await validateEvalDatasetCollectionExists( + evalDatasetCollectionId, + options?.teamId + ); if (!datasetValidation.isValid) { return datasetValidation; } diff --git a/packages/web/i18n/en/evaluation.json b/packages/web/i18n/en/evaluation.json index 526e03bf654b..7e1fb4abb560 100644 --- a/packages/web/i18n/en/evaluation.json +++ b/packages/web/i18n/en/evaluation.json @@ -4,7 +4,6 @@ "name_required": "Evaluation task name is required", "name_too_long": "Evaluation task name is too long", "description_too_long": "Evaluation task description is too long", - "dataset_id_required": "Dataset ID is required", "target_required": "Evaluation target is required", "target_invalid_config": "Evaluation target configuration is invalid", "target_app_id_missing": "Application ID is missing", @@ -76,7 +75,7 @@ "evaluator_service_unavailable": "Evaluator service unavailable", "evaluator_invalid_response": "Evaluator returned invalid response", "evaluator_network_error": "Evaluator network connection error", - + "eval_id_required": "Evaluation task ID is required", "summary_metrics_config_error": "Metrics configuration error", "summary_threshold_value_required": "Threshold value is required", diff --git a/packages/web/i18n/zh-CN/evaluation.json b/packages/web/i18n/zh-CN/evaluation.json index 9a3cf426bc20..58958f6b7e4c 100644 --- a/packages/web/i18n/zh-CN/evaluation.json +++ b/packages/web/i18n/zh-CN/evaluation.json @@ -4,7 +4,6 @@ "name_required": "名称必填", "name_too_long": "名称过长", "description_too_long": "描述过长", - "dataset_id_required": "数据集ID必填", "target_required": "评估目标必填", "target_invalid_config": "评估目标配置无效", "target_app_id_missing": "应用ID缺失", @@ -76,7 +75,7 @@ "evaluator_service_unavailable": "评估器服务不可用", "evaluator_invalid_response": "评估器返回无效响应", "evaluator_network_error": "评估器网络连接错误", - + "eval_id_required": "评估任务ID不能为空", "summary_metrics_config_error": "指标配置错误", "summary_threshold_value_required": "阈值不能为空", diff --git a/packages/web/i18n/zh-Hant/evaluation.json b/packages/web/i18n/zh-Hant/evaluation.json index 6a17ac1b2a63..edbda1ad9761 100644 --- a/packages/web/i18n/zh-Hant/evaluation.json +++ b/packages/web/i18n/zh-Hant/evaluation.json @@ -4,7 +4,6 @@ "name_required": "名稱必填", "name_too_long": "名稱過長", "description_too_long": "描述過長", - "dataset_id_required": "數據集ID必填", "target_required": "評估目標必填", "target_invalid_config": "評估目標配置無效", "target_app_id_missing": "應用ID缺失", @@ -76,7 +75,7 @@ "evaluator_service_unavailable": "評估器服務不可用", "evaluator_invalid_response": "評估器返回無效響應", "evaluator_network_error": "評估器網絡連接錯誤", - + "eval_id_required": "評估任務ID不能為空", "summary_metrics_config_error": "指標配置錯誤", "summary_threshold_value_required": "閾值不能為空", diff --git a/packages/web/support/user/audit/constants.ts b/packages/web/support/user/audit/constants.ts index 980e1894ca89..389760aa7b32 100644 --- a/packages/web/support/user/audit/constants.ts +++ b/packages/web/support/user/audit/constants.ts @@ -539,7 +539,7 @@ export const auditLogMap = { params: {} as { name?: string; taskName: string; - datasetId: string; + evalDatasetCollectionId: string; targetType: string; evaluatorCount: number; } @@ -654,7 +654,7 @@ export const auditLogMap = { params: {} as { name?: string; metricName: string; - } + } }, //Evaluation Task DataItem Aggregation [AuditEventEnum.DELETE_EVALUATION_TASK_DATA_ITEM]: { diff --git a/projects/app/src/pageComponents/dashboard/evaluation/DetailModal.tsx b/projects/app/src/pageComponents/dashboard/evaluation/DetailModal.tsx index 08ec378b1e15..f089bfeb1f7b 100644 --- a/projects/app/src/pageComponents/dashboard/evaluation/DetailModal.tsx +++ b/projects/app/src/pageComponents/dashboard/evaluation/DetailModal.tsx @@ -40,7 +40,7 @@ import { // import type { updateEvalItemBody } from '@fastgpt/global/core/evaluation/api'; import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; -const formatEvaluationStatus = (item: { status: number; errorMessage?: string }, t: TFunction) => { +const formatEvaluationStatus = (item: { status: string; errorMessage?: string }, t: TFunction) => { if (item.errorMessage) { return ( diff --git a/projects/app/src/pageComponents/dashboard/evaluation/task/CreateModal.tsx b/projects/app/src/pageComponents/dashboard/evaluation/task/CreateModal.tsx index 5871cb376527..1379c3851da3 100644 --- a/projects/app/src/pageComponents/dashboard/evaluation/task/CreateModal.tsx +++ b/projects/app/src/pageComponents/dashboard/evaluation/task/CreateModal.tsx @@ -47,7 +47,7 @@ export interface TaskFormData { name: string; appId: string; appVersion: string; - datasetId: string; + evalDatasetCollectionId: string; selectedDimensions: Dimension[]; } @@ -61,7 +61,7 @@ const defaultForm: TaskFormData = { name: '', appId: '', appVersion: '', - datasetId: '', + evalDatasetCollectionId: '', selectedDimensions: [] }; @@ -235,7 +235,7 @@ const CreateModal = ({ isOpen, onClose, onSubmit }: CreateModalProps) => { const createRequest: CreateEvaluationRequest = { name: data.name, - datasetId: data.datasetId, + evalDatasetCollectionId: data.evalDatasetCollectionId, target, evaluators }; @@ -352,12 +352,12 @@ const CreateModal = ({ isOpen, onClose, onSubmit }: CreateModalProps) => { // 智能生成数据集确认回调 const handleIntelligentGenerationConfirm = useCallback( - (data: any, datasetId?: string) => { + (data: any, evalDatasetCollectionId?: string) => { onCloseIntelligentModal(); fetchDatasets(); // 如果返回了数据集ID,自动选择新创建的数据集 - if (datasetId) { - setValue('datasetId', datasetId); + if (evalDatasetCollectionId) { + setValue('evalDatasetCollectionId', evalDatasetCollectionId); } }, [onCloseIntelligentModal, fetchDatasets, setValue] @@ -445,9 +445,9 @@ const CreateModal = ({ isOpen, onClose, onSubmit }: CreateModalProps) => { setValue('datasetId', val)} + onChange={(val) => setValue('evalDatasetCollectionId', val)} isLoading={isLoadingDatasets} ScrollData={DatasetScrollData} /> @@ -634,7 +634,7 @@ const CreateModal = ({ isOpen, onClose, onSubmit }: CreateModalProps) => { isDisabled={ !watchedValues.name || !watchedValues.appId || - !watchedValues.datasetId || + !watchedValues.evalDatasetCollectionId || selectedDimensions.length === 0 } onClick={handleSubmit((data) => createTask(data))} diff --git a/projects/app/src/pages/api/core/evaluation/metric/debug.ts b/projects/app/src/pages/api/core/evaluation/metric/debug.ts index f9f6d63f0daa..f87e706e4127 100644 --- a/projects/app/src/pages/api/core/evaluation/metric/debug.ts +++ b/projects/app/src/pages/api/core/evaluation/metric/debug.ts @@ -10,7 +10,7 @@ import { createEvaluationMetricDebugUsage } from '@fastgpt/service/support/walle import { checkTeamAIPoints } from '@fastgpt/service/support/permission/teamLimit'; import { EvalMetricTypeValues, - EvaluationStatusEnum + MetricResultStatusEnum } from '@fastgpt/global/core/evaluation/metric/constants'; import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation'; import { addLog } from '@fastgpt/service/common/system/log'; @@ -154,7 +154,7 @@ async function handler(req: ApiRequestProps, res: ApiRespon })(); // Check if diting evaluation was successful based on status - if (result.status !== EvaluationStatusEnum.Success) { + if (result.status !== MetricResultStatusEnum.Success) { addLog.error('[Evaluation Debug] Diting evaluation failed', { metricName: metricConfig.metricName, status: result.status, diff --git a/projects/app/src/pages/api/core/evaluation/task/create.ts b/projects/app/src/pages/api/core/evaluation/task/create.ts index c1c5df725e9b..7bd863a02c38 100644 --- a/projects/app/src/pages/api/core/evaluation/task/create.ts +++ b/projects/app/src/pages/api/core/evaluation/task/create.ts @@ -19,7 +19,7 @@ import { async function handler( req: ApiRequestProps ): Promise { - const { name, description, datasetId, target, evaluators, autoStart } = req.body; + const { name, description, evalDatasetCollectionId, target, evaluators, autoStart } = req.body; // First perform auth to get teamId const { teamId, tmbId } = await authEvaluationTaskCreate(target as EvalTarget, { @@ -28,12 +28,12 @@ async function handler( authToken: true }); - // Now validate all evaluation parameters with teamId (includes target and dataset validation) + // Now validate all evaluation parameters with teamId (includes target and evalDatasetCollection validation) const paramValidation = await validateEvaluationParamsForCreate( { name, description, - datasetId, + evalDatasetCollectionId, target, evaluators }, @@ -52,7 +52,7 @@ async function handler( const evaluation = await EvaluationTaskService.createEvaluation({ name: name.trim(), description: description?.trim(), - datasetId, + evalDatasetCollectionId, target: target as EvalTarget, evaluators, autoStart, @@ -67,7 +67,7 @@ async function handler( event: AuditEventEnum.CREATE_EVALUATION_TASK, params: { taskName: evaluation.name, - datasetId, + evalDatasetCollectionId, targetType: evaluation.target.type, evaluatorCount: evaluation.evaluators.length } diff --git a/projects/app/src/pages/api/core/evaluation/task/item/list.ts b/projects/app/src/pages/api/core/evaluation/task/item/list.ts index dc8aa184ead0..687dcf380a6b 100644 --- a/projects/app/src/pages/api/core/evaluation/task/item/list.ts +++ b/projects/app/src/pages/api/core/evaluation/task/item/list.ts @@ -13,7 +13,7 @@ async function handler( req: ApiRequestProps ): Promise { const { offset, pageSize } = parsePaginationRequest(req); - const { evalId } = req.body; + const { evalId, status, userInput, expectedOutput, actualOutput } = req.body; if (!evalId) { throw new Error(EvaluationErrEnum.evalIdRequired); @@ -25,7 +25,12 @@ async function handler( authToken: true }); - const result = await EvaluationTaskService.listEvaluationItems(evalId, teamId, offset, pageSize); + const result = await EvaluationTaskService.listEvaluationItems(evalId, teamId, offset, pageSize, { + status, + userInput, + expectedOutput, + actualOutput + }); return { list: result.items, diff --git a/projects/app/src/pages/api/core/evaluation/task/update.ts b/projects/app/src/pages/api/core/evaluation/task/update.ts index bcfed48c69e4..4a1a1bc7eee2 100644 --- a/projects/app/src/pages/api/core/evaluation/task/update.ts +++ b/projects/app/src/pages/api/core/evaluation/task/update.ts @@ -15,7 +15,7 @@ import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation' async function handler( req: ApiRequestProps ): Promise { - const { evalId, name, description, datasetId, target, evaluators } = req.body; + const { evalId, name, description, evalDatasetCollectionId, target, evaluators } = req.body; if (!evalId) { throw new Error(EvaluationErrEnum.evalIdRequired); @@ -32,7 +32,7 @@ async function handler( { name, description, - datasetId, + evalDatasetCollectionId, target, evaluators }, @@ -50,7 +50,7 @@ async function handler( ...(name !== undefined && { name: name.trim() }), ...(description !== undefined && { description: description?.trim() }) // runing config not support modify - // ...(datasetId !== undefined && { datasetId }), + // ...(evalDatasetCollectionId !== undefined && { evalDatasetCollectionId }), // ...(target !== undefined && { target }), // ...(evaluators !== undefined && { evaluators }) }, From 48087977d8c21ab6743a66fda2d44f417e4aabc6 Mon Sep 17 00:00:00 2001 From: strong <1145607886@qq.com> Date: Tue, 16 Sep 2025 01:41:07 +0800 Subject: [PATCH 17/84] feat: fix chatlog record in targetoutput and score not calculate real time --- packages/global/core/evaluation/constants.ts | 16 +- .../global/core/evaluation/summary/api.d.ts | 3 + packages/global/core/evaluation/type.d.ts | 8 + .../service/core/evaluation/summary/index.ts | 351 +++++++++++++----- .../summary/util/weightCalculator.ts | 6 +- .../service/core/evaluation/target/index.ts | 19 +- .../service/core/evaluation/task/index.ts | 7 + .../service/core/evaluation/task/processor.ts | 51 ++- .../service/core/evaluation/task/schema.ts | 29 +- .../core/evaluation/summary/detail.test.ts | 46 ++- .../service/core/evaluation/task.test.ts | 24 +- 11 files changed, 412 insertions(+), 148 deletions(-) diff --git a/packages/global/core/evaluation/constants.ts b/packages/global/core/evaluation/constants.ts index 1455d45355ed..a00ae901c788 100644 --- a/packages/global/core/evaluation/constants.ts +++ b/packages/global/core/evaluation/constants.ts @@ -26,10 +26,10 @@ export const EvaluationStatusMap = { export const EvaluationStatusValues = Object.values(EvaluationStatusEnum); export enum SummaryStatusEnum { - pending = 0, - generating = 1, - completed = 2, - failed = 3 + pending = 'pending', + generating = 'generating', + completed = 'completed', + failed = 'failed' } export const SummaryStatusMap = { @@ -47,12 +47,12 @@ export const SummaryStatusMap = { } }; -export const SummaryStatusValues = Object.keys(SummaryStatusMap).map(Number); +export const SummaryStatusValues = Object.values(SummaryStatusEnum); // Calculation method enumeration export enum CalculateMethodEnum { - mean = 0, - median = 1 + mean = 'mean', + median = 'median' } export const CaculateMethodMap = { @@ -64,7 +64,7 @@ export const CaculateMethodMap = { } }; -export const CaculateMethodValues = Object.keys(CaculateMethodMap).map(Number); +export const CaculateMethodValues = Object.values(CalculateMethodEnum); // Score constants export const PERFECT_SCORE = 100; diff --git a/packages/global/core/evaluation/summary/api.d.ts b/packages/global/core/evaluation/summary/api.d.ts index c6d27d4e1e3e..4374824a7fc9 100644 --- a/packages/global/core/evaluation/summary/api.d.ts +++ b/packages/global/core/evaluation/summary/api.d.ts @@ -60,6 +60,9 @@ export interface EvaluationSummaryResponse { errorReason?: string; completedItemCount: number; overThresholdItemCount: number; + thresholdPassRate: number; + threshold: number; + customSummary: string; }>; aggregateScore: number; } diff --git a/packages/global/core/evaluation/type.d.ts b/packages/global/core/evaluation/type.d.ts index 81f4f3ea3b5d..ff7d521701aa 100644 --- a/packages/global/core/evaluation/type.d.ts +++ b/packages/global/core/evaluation/type.d.ts @@ -32,9 +32,13 @@ export interface SummaryConfig { metricName: string; // Metric name for display weight: number; calculateType: CalculateMethodEnum; + score: number; // Calculated score based on successful items with scores summary: string; summaryStatus: SummaryStatusEnum; errorReason: string; + completedItemCount: number; // Count of completed evaluation items + overThresholdItemCount: number; // Count of items with scores above threshold + thresholdPassRate: number; // Percentage of items that passed the threshold (0-100) } // Evaluator configuration type @@ -69,6 +73,7 @@ export type EvaluationSchemaType = { finishTime?: Date; errorMessage?: string; statistics?: EvaluationStatistics; + aggregateScore?: number; // Weighted aggregate score calculated from multiple metrics }; /** @@ -105,6 +110,7 @@ export type EvaluationDataItemType = EvalDatasetDataSchemaType & { export type EvaluationItemSchemaType = { _id: string; evalId: string; + // Chat information is stored in targetOutput.chatId and targetOutput.aiChatItemDataId // Dependent component configurations dataItem: EvaluationDataItemType; target: EvalTarget; @@ -130,6 +136,8 @@ export interface TargetOutput { [EvalDatasetDataKeyEnum.RetrievalContext]?: string[]; usage?: any; responseTime: number; + chatId: string; + aiChatItemDataId: string; } export type EvaluationWithPerType = EvaluationSchemaType & { diff --git a/packages/service/core/evaluation/summary/index.ts b/packages/service/core/evaluation/summary/index.ts index 031a4f591bbd..6090b5660717 100644 --- a/packages/service/core/evaluation/summary/index.ts +++ b/packages/service/core/evaluation/summary/index.ts @@ -39,46 +39,97 @@ export class EvaluationSummaryService { errorReason?: string; completedItemCount: number; overThresholdItemCount: number; + thresholdPassRate: number; + threshold: number; + customSummary: string; }>; aggregateScore: number; }> { - // Query evaluation task const evaluation = await MongoEvaluation.findById(evalId).lean(); + if (!evaluation) throw new Error(EvaluationErrEnum.evalTaskNotFound); - if (!evaluation) { - throw new Error(EvaluationErrEnum.evalTaskNotFound); - } - - // Real-time calculation of metricScore and aggregateScore - const calculatedData = await this.calculateMetricScores(evaluation); - - // Build return data, merge calculation results and existing configurations + // Build return data using pre-calculated values from MongoDB summaryConfigs const data = evaluation.evaluators.map((evaluator, index) => { const metricId = evaluator.metric._id.toString(); - const calculatedMetric = calculatedData.metricsData.find( - (item) => item.metricId === metricId - ); const summaryConfig = evaluation.summaryConfigs[index]; + const completedItemCount = summaryConfig.completedItemCount || 0; + const overThresholdItemCount = summaryConfig.overThresholdItemCount || 0; + const thresholdPassRate = summaryConfig.thresholdPassRate || 0; + const threshold = evaluator.thresholdValue || 0; + + // Generate customSummary in format: "过阈值百分率(完成个数个)summary" + const customSummary = `${thresholdPassRate}%(${overThresholdItemCount}个)${summaryConfig.summary || ''}`; return { metricId: metricId, metricName: evaluator.metric.name, - metricScore: calculatedMetric?.metricScore || 0, + metricScore: summaryConfig.score || 0, // Use pre-calculated score from MongoDB summary: summaryConfig.summary, - summaryStatus: summaryConfig.summaryStatus.toString(), + summaryStatus: summaryConfig.summaryStatus, errorReason: summaryConfig.errorReason, - completedItemCount: calculatedMetric?.totalCount || 0, - overThresholdItemCount: calculatedMetric?.aboveThresholdCount || 0 + completedItemCount: completedItemCount, // Use pre-calculated count from MongoDB + overThresholdItemCount: overThresholdItemCount, // Use pre-calculated count from MongoDB + thresholdPassRate: thresholdPassRate, // Use pre-calculated pass rate from MongoDB + threshold: threshold, // Add threshold field + customSummary: customSummary // Add customSummary field with specified format }; }); + // Use stored aggregateScore if available, otherwise calculate from pre-calculated scores + let aggregateScore = evaluation.aggregateScore; + + if (aggregateScore === undefined || aggregateScore === null) { + let totalWeightedScore = 0; + let totalWeight = 0; + + evaluation.summaryConfigs.forEach((summaryConfig) => { + const score = summaryConfig.score || 0; + const weight = summaryConfig.weight || 0; + totalWeightedScore += score * weight; + totalWeight += weight; + }); + + aggregateScore = + totalWeight > 0 ? Math.round((totalWeightedScore / totalWeight) * 100) / 100 : 0; + } + return { data, - aggregateScore: calculatedData.aggregateScore + aggregateScore }; } - // Real-time calculation of metricScore and aggregateScore + // Calculate and save metric scores to MongoDB summaryConfigs + static async calculateAndSaveMetricScores(evalId: string): Promise { + try { + const evaluation = await MongoEvaluation.findById(evalId).lean(); + if (!evaluation) throw new Error(EvaluationErrEnum.evalTaskNotFound); + + // Use the existing calculateMetricScores method to get calculated scores + const calculatedData = await this.calculateMetricScores(evaluation); + + // Update the calculated scores to database + await this.updateSummaryConfigsScores( + evalId, + calculatedData.metricsData, + calculatedData.aggregateScore + ); + + addLog.info('[Evaluation] Metric scores calculated and saved to MongoDB', { + evalId, + metricsCount: calculatedData.metricsData.length, + aggregateScore: calculatedData.aggregateScore + }); + } catch (error) { + addLog.error('[Evaluation] Failed to calculate and save metric scores', { + evalId, + error + }); + // Don't throw error to avoid affecting main flow + } + } + + // Real-time calculation of metricScore and aggregateScore (pure calculation, no database updates) private static async calculateMetricScores(evaluation: EvaluationSchemaType): Promise<{ metricsData: Array<{ metricId: string; @@ -97,22 +148,32 @@ export class EvaluationSummaryService { // MongoDB aggregation pipeline - compatible with older MongoDB versions const pipeline = [ - // Step 1: Filter successful evaluation items + // Step 1: Filter successful evaluation items that have evaluator outputs { $match: { evalId: evalId, status: EvalStatus.completed, - 'evaluatorOutput.data.score': { $exists: true, $ne: null } + evaluatorOutputs: { $exists: true, $nin: [null, []] } } }, - // Step 2: Group by metric ID and calculate statistics + // Step 2: Unwind the evaluatorOutputs array to process each metric result separately + { + $unwind: '$evaluatorOutputs' + }, + // Step 3: Filter only results with valid scores + { + $match: { + 'evaluatorOutputs.data.score': { $exists: true, $ne: null } + } + }, + // Step 4: Group by metric name and calculate statistics { $group: { - _id: '$evaluatorOutput.metricName', - scores: { $push: '$evaluatorOutput.data.score' }, - avgScore: { $avg: '$evaluatorOutput.data.score' }, + _id: '$evaluatorOutputs.metricName', + scores: { $push: '$evaluatorOutputs.data.score' }, + avgScore: { $avg: '$evaluatorOutputs.data.score' }, count: { $sum: 1 }, - metricName: { $first: '$evaluatorOutput.metricName' } + metricName: { $first: '$evaluatorOutputs.metricName' } } } ]; @@ -216,7 +277,7 @@ export class EvaluationSummaryService { const aggregateScore = totalWeight > 0 ? Math.round((totalWeightedScore / totalWeight) * 100) / 100 : 0; - addLog.info('[Evaluation] Real-time calculation completed', { + addLog.info('[Evaluation] Metric calculation completed', { evalId: evaluation._id.toString(), metricsCount: metricsData.length, aggregateScore @@ -254,6 +315,69 @@ export class EvaluationSummaryService { } } + // Update summaryConfigs scores and aggregateScore together based on calculated metric scores + private static async updateSummaryConfigsScores( + evalId: string, + metricsData: Array<{ + metricId: string; + metricName: string; + metricScore: number; + weight: number; + thresholdValue: number; + aboveThresholdCount: number; + thresholdPassRate: number; + totalCount: number; + }>, + aggregateScore: number + ): Promise { + try { + // Build update fields for each summaryConfig score + const updateFields: Record = {}; + + const evaluation = await MongoEvaluation.findById(evalId).lean(); + if (!evaluation) return; + + // Build update fields using pre-calculated data + evaluation.summaryConfigs.forEach((summaryConfig, index) => { + const metricData = metricsData.find((m) => m.metricId === summaryConfig.metricId); + if (metricData) { + updateFields[`summaryConfigs.${index}.score`] = metricData.metricScore; + updateFields[`summaryConfigs.${index}.completedItemCount`] = metricData.totalCount; + updateFields[`summaryConfigs.${index}.overThresholdItemCount`] = + metricData.aboveThresholdCount; + updateFields[`summaryConfigs.${index}.thresholdPassRate`] = metricData.thresholdPassRate; + } + }); + + // Use pre-calculated aggregateScore + updateFields['aggregateScore'] = aggregateScore; + + await MongoEvaluation.updateOne({ _id: evalId }, { $set: updateFields }); + + addLog.info('[Evaluation] Updated summaryConfigs scores, counts and aggregateScore', { + evalId, + updatedFieldsCount: Object.keys(updateFields).length, + aggregateScore, + scores: metricsData.map((m) => ({ + metricId: m.metricId, + score: m.metricScore, + completedItemCount: m.totalCount, + overThresholdItemCount: m.aboveThresholdCount, + thresholdPassRate: m.thresholdPassRate + })) + }); + } catch (error) { + addLog.error( + '[Evaluation] Failed to update summaryConfigs scores, counts and aggregateScore', + { + evalId, + error + } + ); + // Don't throw error to avoid affecting main calculation flow + } + } + // Update evaluation summary configuration (threshold, weight, calculation method) static async updateEvaluationSummaryConfig( evalId: string, @@ -276,79 +400,93 @@ export class EvaluationSummaryService { } } - // Update configuration based on existing evaluators (threshold, weight, calculation method) - const configMap = new Map( - metricsConfig.map((m) => [ - m.metricId, - { thresholdValue: m.thresholdValue, weight: m.weight, calculateType: m.calculateType } - ]) - ); + // Update configuration and recalculate + await this.updateConfigurationAndRecalculate(evalId, evaluation, metricsConfig); + } - // Update corresponding configuration in evaluators array and summaryConfigs - const updatedEvaluators = (evaluation.evaluators || []).map((evaluator: any) => { - const metricId = evaluator.metric._id.toString(); - const config = configMap.get(metricId); - if (config) { - return { - ...evaluator, - thresholdValue: config.thresholdValue - }; - } - return evaluator; + // Simplified method to update configuration and recalculate everything + private static async updateConfigurationAndRecalculate( + evalId: string, + evaluation: EvaluationSchemaType, + metricsConfig: Array<{ + metricId: string; + thresholdValue: number; + weight?: number; + calculateType?: CalculateMethodEnum; + }> + ): Promise { + const configMap = new Map(metricsConfig.map((m) => [m.metricId, m])); + + // Update database configuration + await this.updateDatabaseConfig(evalId, evaluation, metricsConfig, configMap); + + // Update eval_items thresholds + await this.updateEvalItemThresholds(evalId, metricsConfig); + + // Get updated evaluation and recalculate everything + const updatedEvaluation = await MongoEvaluation.findById(evalId).lean(); + if (updatedEvaluation) { + addLog.info('[Evaluation] Configuration updated, recalculating all metrics', { evalId }); + const calculatedData = await this.calculateMetricScores(updatedEvaluation); + await this.updateSummaryConfigsScores( + evalId, + calculatedData.metricsData, + calculatedData.aggregateScore + ); + } + } + + // Update database configuration (evaluators and summaryConfigs) + private static async updateDatabaseConfig( + evalId: string, + evaluation: EvaluationSchemaType, + metricsConfig: Array<{ + metricId: string; + thresholdValue: number; + weight?: number; + calculateType?: CalculateMethodEnum; + }>, + configMap: Map + ): Promise { + // Update evaluators + const updatedEvaluators = evaluation.evaluators.map((evaluator: any) => { + const config = configMap.get(evaluator.metric._id.toString()); + return config ? { ...evaluator, thresholdValue: config.thresholdValue } : evaluator; }); - // Update summaryConfigs array + // Update summaryConfigs const updatedSummaryConfigs = evaluation.summaryConfigs.map( (summaryConfig: any, index: number) => { - const evaluator = evaluation.evaluators[index]; - const metricId = evaluator.metric._id.toString(); + const metricId = evaluation.evaluators[index].metric._id.toString(); const config = configMap.get(metricId); if (config) { return { ...summaryConfig, - ...(config.weight !== undefined ? { weight: config.weight } : {}), - ...(config.calculateType !== undefined ? { calculateType: config.calculateType } : {}) + ...(config.weight !== undefined && { weight: config.weight }), + ...(config.calculateType !== undefined && { calculateType: config.calculateType }) }; } return summaryConfig; } ); - // Update evaluation configuration await MongoEvaluation.updateOne( { _id: evalId }, - { - $set: { - evaluators: updatedEvaluators, - summaryConfigs: updatedSummaryConfigs - } - } + { $set: { evaluators: updatedEvaluators, summaryConfigs: updatedSummaryConfigs } } ); + } - // Update threshold values in all related eval_items - const thresholdUpdates = metricsConfig.filter((config) => config.thresholdValue !== undefined); - if (thresholdUpdates.length > 0) { - for (const config of thresholdUpdates) { - const updateResult = await MongoEvalItem.updateMany( - { - evalId: evalId, - 'evaluator.metric._id': config.metricId - }, - { - $set: { - 'evaluator.thresholdValue': config.thresholdValue - } - } - ); - - addLog.info('[Evaluation] Updated threshold in eval_items', { - evalId, - metricId: config.metricId, - newThreshold: config.thresholdValue, - updatedCount: updateResult.modifiedCount - }); - } + // Update thresholds in eval_items + private static async updateEvalItemThresholds( + evalId: string, + metricsConfig: Array<{ metricId: string; thresholdValue: number }> + ): Promise { + for (const config of metricsConfig) { + await MongoEvalItem.updateMany( + { evalId, 'evaluator.metric._id': config.metricId }, + { $set: { 'evaluator.thresholdValue': config.thresholdValue } } + ); } } @@ -363,12 +501,8 @@ export class EvaluationSummaryService { weight: number; }>; }> { - // Query evaluation task const evaluation = await MongoEvaluation.findById(evalId).lean(); - - if (!evaluation) { - throw new Error(EvaluationErrEnum.evalTaskNotFound); - } + if (!evaluation) throw new Error(EvaluationErrEnum.evalTaskNotFound); // Get calculation type from first summary config (since all metrics use the same type) const firstSummaryConfig = evaluation.summaryConfigs[0]; @@ -433,6 +567,13 @@ export class EvaluationSummaryService { } ); + addLog.info( + '[EvaluationSummary] Updated metric scores and counts before generating summaries', + { + evalId + } + ); + // Validate metric ownership and find corresponding evaluator index const evaluatorTasks: Array<{ metricId: string; @@ -801,16 +942,37 @@ export class EvaluationSummaryService { { $match: { evalId: evalObjectId, - 'evaluator.metric._id': metricId, - 'evaluatorOutput.data.score': { $exists: true, $ne: null }, - status: EvalStatus.completed + status: EvalStatus.completed, + evaluatorOutputs: { $exists: true, $nin: [null, []] } + } + }, + { + $addFields: { + // Find the matching metric result in evaluatorOutputs array + matchingMetricResult: { + $arrayElemAt: [ + { + $filter: { + input: '$evaluatorOutputs', + as: 'output', + cond: { $eq: ['$$output.metricName', metricId] } + } + }, + 0 + ] + } + } + }, + { + $match: { + 'matchingMetricResult.data.score': { $exists: true, $ne: null } } }, { $addFields: { - score: '$evaluatorOutput.data.score', + score: '$matchingMetricResult.data.score', isBelowThreshold: { - $lt: ['$evaluatorOutput.data.score', thresholdValue] + $lt: ['$matchingMetricResult.data.score', thresholdValue] } } }, @@ -824,7 +986,8 @@ export class EvaluationSummaryService { $project: { dataItem: 1, targetOutput: 1, - evaluatorOutput: 1, + evaluatorOutputs: 1, + matchingMetricResult: 1, score: 1, isBelowThreshold: 1 } @@ -1080,11 +1243,11 @@ export class EvaluationSummaryService { * 格式化数据项用于提示词 */ private static formatDataItemForPrompt(item: any): string { - // const score = item.evaluatorOutput?.data?.score || 0; + // const score = item.matchingMetricResult?.data?.score || 0; // const userInput = item.dataItem?.userInput || '无'; // const expectedOutput = item.dataItem?.expectedOutput || '无'; // const actualOutput = item.targetOutput?.actualOutput || '无'; - const reason = item.evaluatorOutput?.data?.reason || '无评估原因'; + const reason = item.matchingMetricResult?.data?.reason || '无评估原因'; return ` **评估原因**: ${reason}`; @@ -1095,7 +1258,7 @@ export class EvaluationSummaryService { */ private static isAllPerfectScores(data: any[]): boolean { if (data.length === 0) return false; - return data.every((item) => (item.evaluatorOutput?.data?.score || 0) >= PERFECT_SCORE); + return data.every((item) => (item.matchingMetricResult?.data?.score || 0) >= PERFECT_SCORE); } /** @@ -1128,7 +1291,7 @@ export class EvaluationSummaryService { } else { // When non-perfect scores exist, prioritize non-perfect score data const nonPerfectData = data.filter( - (item) => (item.evaluatorOutput?.data?.score || 0) < PERFECT_SCORE + (item) => (item.matchingMetricResult?.data?.score || 0) < PERFECT_SCORE ); // Return non-perfect score data first (sorted by score from low to high) return [...nonPerfectData]; diff --git a/packages/service/core/evaluation/summary/util/weightCalculator.ts b/packages/service/core/evaluation/summary/util/weightCalculator.ts index 0d77815d8da8..5e0eb7e7b6f1 100644 --- a/packages/service/core/evaluation/summary/util/weightCalculator.ts +++ b/packages/service/core/evaluation/summary/util/weightCalculator.ts @@ -78,9 +78,13 @@ export function buildEvalDataConfig(evaluators: EvaluatorSchema[]): { metricName: evaluator.metric.name, weight: weights[index], calculateType: CalculateMethodEnum.mean, + score: 0, summary: '', summaryStatus: SummaryStatusEnum.pending, - errorReason: '' + errorReason: '', + completedItemCount: 0, + overThresholdItemCount: 0, + thresholdPassRate: 0 })); addLog.debug('[buildEvalDataConfig] Processed configuration:', { diff --git a/packages/service/core/evaluation/target/index.ts b/packages/service/core/evaluation/target/index.ts index 35967e5a3aa7..c3c9f3a17993 100644 --- a/packages/service/core/evaluation/target/index.ts +++ b/packages/service/core/evaluation/target/index.ts @@ -34,6 +34,7 @@ import { getUserChatInfoAndAuthTeamPoints } from '../../../support/permission/au import { getRunningUserInfoByTmbId } from '../../../support/user/team/utils'; import { removeDatasetCiteText } from '../../ai/utils'; import { saveChat } from '../../chat/saveChat'; +import { MongoChatItem } from '../../chat/chatItemSchema'; import { getChatTitleFromChatMessage } from '@fastgpt/global/core/chat/utils'; import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; @@ -207,11 +208,27 @@ export class WorkflowTarget extends EvaluationTarget { durationSeconds }); + // Get the latest AI chat item dataId from the saved chat record + const latestAiChatItem = await MongoChatItem.findOne( + { + chatId, + appId: appData._id, + obj: ChatRoleEnum.AI + }, + 'dataId' + ) + .sort({ _id: -1 }) + .lean(); + + const aiChatItemDataId = latestAiChatItem?.dataId || ''; + return { actualOutput: response, retrievalContext: extractRetrievalContext(flowResponses), usage: flowUsages, - responseTime: Date.now() - startTime + responseTime: Date.now() - startTime, + chatId, + aiChatItemDataId }; } diff --git a/packages/service/core/evaluation/task/index.ts b/packages/service/core/evaluation/task/index.ts index 6f0553c97e2e..52452713ff67 100644 --- a/packages/service/core/evaluation/task/index.ts +++ b/packages/service/core/evaluation/task/index.ts @@ -987,6 +987,13 @@ export class EvaluationTaskService { const retriedCount = await mongoSessionRun(retryItems); + // Note: Score recalculation will be triggered when items complete in finishEvaluationTask + if (retriedCount > 0) { + addLog.debug( + `[Evaluation] Queued ${retriedCount} failed items for retry, scores will be recalculated when items complete: ${evalId}` + ); + } + return retriedCount; } diff --git a/packages/service/core/evaluation/task/processor.ts b/packages/service/core/evaluation/task/processor.ts index a8096cf83870..072f7dae312b 100644 --- a/packages/service/core/evaluation/task/processor.ts +++ b/packages/service/core/evaluation/task/processor.ts @@ -266,13 +266,22 @@ const finishEvaluationTask = async (evalId: string) => { // Check if truly completed const pendingCount = evaluatingCount + queuingCount; - // Task status is always completed when all items are finished - let taskStatus: EvaluationStatusEnum = EvaluationStatusEnum.completed; + // Determine task status based on pending items and error count + let taskStatus: EvaluationStatusEnum; if (pendingCount > 0) { addLog.debug( `[Evaluation] Task not yet completed: ${evalId}, pending items: ${pendingCount}` ); taskStatus = EvaluationStatusEnum.evaluating; + } else if (errorCount > 0) { + // When there are failed items, mark task as error + addLog.info( + `[Evaluation] Task completed with errors: ${evalId}, error items: ${errorCount}, completed: ${completedCount}` + ); + taskStatus = EvaluationStatusEnum.error; + } else { + // All items completed successfully + taskStatus = EvaluationStatusEnum.completed; } // Update task status with statistical fields @@ -286,21 +295,31 @@ const finishEvaluationTask = async (evalId: string) => { } }; - // Only set finishTime if the task is actually completed - if (taskStatus === EvaluationStatusEnum.completed) { + // Set finishTime if the task is completed (either successfully or with errors) + if ( + taskStatus === EvaluationStatusEnum.completed || + taskStatus === EvaluationStatusEnum.error + ) { updateFields.finishTime = new Date(); } await MongoEvaluation.updateOne({ _id: new Types.ObjectId(evalId) }, { $set: updateFields }); - addLog.debug( - `[Evaluation] Task completed: ${evalId}, status: ${taskStatus}, total: ${totalCount}, ` + + addLog.info( + `[Evaluation] Task finished: ${evalId}, status: ${taskStatus}, total: ${totalCount}, ` + `success: ${completedCount}, failed: ${errorCount}` ); - // Trigger async summary generation only for metrics with empty summaries if task completed successfully - if (taskStatus === EvaluationStatusEnum.completed && completedCount > 0) { + // Calculate and save metric scores, then trigger async summary generation if task finished and has completed items + if ( + (taskStatus === EvaluationStatusEnum.completed || + taskStatus === EvaluationStatusEnum.error) && + completedCount > 0 + ) { try { + // First, calculate and save metric scores to MongoDB + await EvaluationSummaryService.calculateAndSaveMetricScores(evalId); + // Get current evaluation to extract metric IDs and check summary status const currentEvaluation = await MongoEvaluation.findById( evalId, @@ -334,12 +353,12 @@ const finishEvaluationTask = async (evalId: string) => { ); }); - addLog.debug( - `[Evaluation] Triggered async summary generation for ${metricsNeedingSummary.length} metrics with empty summaries: ${evalId}` + addLog.info( + `[Evaluation] Triggered async summary generation for ${metricsNeedingSummary.length} metrics with empty summaries: ${evalId}, taskStatus: ${taskStatus}` ); } else { - addLog.debug( - `[Evaluation] All metrics already have summaries, skipping summary generation: ${evalId}` + addLog.info( + `[Evaluation] All metrics already have summaries, skipping summary generation: ${evalId}, taskStatus: ${taskStatus}` ); } } @@ -655,10 +674,14 @@ const evaluationItemProcessor = async (job: Job) => { targetCallParams: evalItem.dataItem.targetCallParams }); - // Save target output as checkpoint + // Save target output as checkpoint with chat information await MongoEvalItem.updateOne( { _id: new Types.ObjectId(evalItemId) }, - { $set: { targetOutput: targetOutput } } + { + $set: { + targetOutput: targetOutput + } + } ); // Report progress: target execution completed diff --git a/packages/service/core/evaluation/task/schema.ts b/packages/service/core/evaluation/task/schema.ts index bcd2a244fd99..5310f5921358 100644 --- a/packages/service/core/evaluation/task/schema.ts +++ b/packages/service/core/evaluation/task/schema.ts @@ -173,16 +173,20 @@ export const EvaluationTaskSchema = new Schema({ required: true }, calculateType: { - type: Number, + type: String, enum: CaculateMethodValues, required: true }, + score: { + type: Number, + default: 0 + }, summary: { type: String, default: '' }, summaryStatus: { - type: Number, + type: String, enum: SummaryStatusValues, default: SummaryStatusEnum.pending }, @@ -190,9 +194,26 @@ export const EvaluationTaskSchema = new Schema({ type: String, default: '' }, + completedItemCount: { + type: Number, + default: 0 + }, + overThresholdItemCount: { + type: Number, + default: 0 + }, + thresholdPassRate: { + type: Number, + default: 0 + }, _id: false } - ] + ], + // Weighted aggregate score calculated from multiple metrics + aggregateScore: { + type: Number, + default: 0 + } }); // Optimized indexes for EvaluationTaskSchema @@ -244,6 +265,8 @@ EvaluationItemSchema.index({ evalId: 1, status: 1, createTime: -1 }); // Status EvaluationItemSchema.index({ status: 1, retry: 1 }); // Queue processing with retry logic EvaluationItemSchema.index({ evalId: 1, 'dataItem._id': 1 }); // DataItem aggregation queries EvaluationItemSchema.index({ evalId: 1, status: 1, retry: 1 }); // Retry operations optimization +// Chat linking index moved to use targetOutput fields if needed +// EvaluationItemSchema.index({ 'targetOutput.chatId': 1, 'targetOutput.aiChatItemDataId': 1 }); // Optimized text search for content filtering (removed evalId for flexibility) EvaluationItemSchema.index({ diff --git a/test/cases/pages/api/core/evaluation/summary/detail.test.ts b/test/cases/pages/api/core/evaluation/summary/detail.test.ts index 7c60ec5e9781..1be9303d3738 100644 --- a/test/cases/pages/api/core/evaluation/summary/detail.test.ts +++ b/test/cases/pages/api/core/evaluation/summary/detail.test.ts @@ -55,16 +55,18 @@ describe('Get Evaluation Summary Detail API Handler', () => { { metricId: 'metric-1', metricName: '准确性', + metricScore: 85.0, summary: '整体表现良好,在大部分测试用例中都能提供准确的回答。', - summaryStatus: SummaryStatusEnum.completed.toString(), + summaryStatus: SummaryStatusEnum.completed, completedItemCount: 100, overThresholdItemCount: 85 }, { metricId: 'metric-2', metricName: '相关性', + metricScore: 78.7, summary: '回答与问题的相关性较好,但在某些复杂场景下需要改进。', - summaryStatus: SummaryStatusEnum.completed.toString(), + summaryStatus: SummaryStatusEnum.completed, completedItemCount: 100, overThresholdItemCount: 72 } @@ -146,8 +148,9 @@ describe('Get Evaluation Summary Detail API Handler', () => { { metricId: 'metric-1', metricName: '流畅性', + metricScore: 92.3, summary: '生成的文本流畅自然,语法错误极少。', - summaryStatus: SummaryStatusEnum.completed.toString(), + summaryStatus: SummaryStatusEnum.completed, completedItemCount: 50, overThresholdItemCount: 47 } @@ -175,16 +178,18 @@ describe('Get Evaluation Summary Detail API Handler', () => { { metricId: 'metric-1', metricName: '准确性', + metricScore: 85.5, summary: '评估完成,表现良好。', - summaryStatus: SummaryStatusEnum.completed.toString(), + summaryStatus: SummaryStatusEnum.completed, completedItemCount: 100, overThresholdItemCount: 85 }, { metricId: 'metric-2', metricName: '相关性', + metricScore: 0, summary: '', - summaryStatus: SummaryStatusEnum.failed.toString(), + summaryStatus: SummaryStatusEnum.failed, errorReason: 'AI 服务超时', completedItemCount: 0, overThresholdItemCount: 0 @@ -203,7 +208,7 @@ describe('Get Evaluation Summary Detail API Handler', () => { const result = await handler(mockReq); expect(result.data).toHaveLength(2); - expect(result.data[1].summaryStatus).toBe(SummaryStatusEnum.failed.toString()); + expect(result.data[1].summaryStatus).toBe(SummaryStatusEnum.failed); expect(result.data[1].errorReason).toBe('AI 服务超时'); }); @@ -213,8 +218,9 @@ describe('Get Evaluation Summary Detail API Handler', () => { { metricId: 'metric-1', metricName: '准确性', + metricScore: 85.5, summary: '', - summaryStatus: SummaryStatusEnum.generating.toString(), + summaryStatus: SummaryStatusEnum.generating, completedItemCount: 100, overThresholdItemCount: 85 } @@ -231,7 +237,7 @@ describe('Get Evaluation Summary Detail API Handler', () => { const result = await handler(mockReq); - expect(result.data[0].summaryStatus).toBe(SummaryStatusEnum.generating.toString()); + expect(result.data[0].summaryStatus).toBe(SummaryStatusEnum.generating); expect(result.data[0].summary).toBe(''); }); @@ -267,8 +273,9 @@ describe('Get Evaluation Summary Detail API Handler', () => { { metricId: 'metric-1', metricName: '准确性', + metricScore: 85.5, summary: '测试总结', - summaryStatus: SummaryStatusEnum.completed.toString(), + summaryStatus: SummaryStatusEnum.completed, completedItemCount: 100, overThresholdItemCount: 85 } @@ -326,8 +333,9 @@ describe('Get Evaluation Summary Detail API Handler', () => { { metricId: 'metric-1', metricName: '准确性', + metricScore: 0, summary: '评估结果显示准确性较低,需要改进。', - summaryStatus: SummaryStatusEnum.completed.toString(), + summaryStatus: SummaryStatusEnum.completed, completedItemCount: 100, overThresholdItemCount: 0 } @@ -355,8 +363,9 @@ describe('Get Evaluation Summary Detail API Handler', () => { { metricId: 'metric-1', metricName: '准确性', + metricScore: 98.7, summary: '评估结果优秀,准确性极高。', - summaryStatus: SummaryStatusEnum.completed.toString(), + summaryStatus: SummaryStatusEnum.completed, completedItemCount: 100, overThresholdItemCount: 99 } @@ -384,24 +393,27 @@ describe('Get Evaluation Summary Detail API Handler', () => { { metricId: 'metric-1', metricName: '准确性', + metricScore: 85.5, summary: '准确性良好', - summaryStatus: SummaryStatusEnum.completed.toString(), + summaryStatus: SummaryStatusEnum.completed, completedItemCount: 100, overThresholdItemCount: 85 }, { metricId: 'metric-2', metricName: '相关性', + metricScore: 0, summary: '', - summaryStatus: SummaryStatusEnum.generating.toString(), + summaryStatus: SummaryStatusEnum.generating, completedItemCount: 0, overThresholdItemCount: 0 }, { metricId: 'metric-3', metricName: '流畅性', + metricScore: 0, summary: '', - summaryStatus: SummaryStatusEnum.failed.toString(), + summaryStatus: SummaryStatusEnum.failed, errorReason: 'Token 不足', completedItemCount: 50, overThresholdItemCount: 0 @@ -420,9 +432,9 @@ describe('Get Evaluation Summary Detail API Handler', () => { const result = await handler(mockReq); expect(result.data).toHaveLength(3); - expect(result.data[0].summaryStatus).toBe(SummaryStatusEnum.completed.toString()); - expect(result.data[1].summaryStatus).toBe(SummaryStatusEnum.generating.toString()); - expect(result.data[2].summaryStatus).toBe(SummaryStatusEnum.failed.toString()); + expect(result.data[0].summaryStatus).toBe(SummaryStatusEnum.completed); + expect(result.data[1].summaryStatus).toBe(SummaryStatusEnum.generating); + expect(result.data[2].summaryStatus).toBe(SummaryStatusEnum.failed); expect(result.data[2].errorReason).toBe('Token 不足'); }); }); diff --git a/test/cases/service/core/evaluation/task.test.ts b/test/cases/service/core/evaluation/task.test.ts index 303dca516d6a..223de5086e84 100644 --- a/test/cases/service/core/evaluation/task.test.ts +++ b/test/cases/service/core/evaluation/task.test.ts @@ -1599,12 +1599,14 @@ describe('EvaluationTaskService', () => { actualOutput: 'Test response', responseTime: 1000 }, - evaluatorOutput: { - metricName: 'Test Metric', - data: { - score: 85 + evaluatorOutputs: [ + { + metricName: 'Test Metric', + data: { + score: 85 + } } - } + ] }); const { results: buffer } = await EvaluationTaskService.exportEvaluationResults( @@ -1650,12 +1652,14 @@ describe('EvaluationTaskService', () => { actualOutput: 'JavaScript response', responseTime: 1000 }, - evaluatorOutput: { - metricName: 'Test Metric', - data: { - score: 85 + evaluatorOutputs: [ + { + metricName: 'Test Metric', + data: { + score: 85 + } } - } + ] }); const { results: buffer } = await EvaluationTaskService.exportEvaluationResults( From f83d94fb2e03ed7eb53ba59ec8cad5e9c23497f4 Mon Sep 17 00:00:00 2001 From: lyx Date: Wed, 17 Sep 2025 17:59:41 +0800 Subject: [PATCH 18/84] feat(chat): Add evaluation dataset selector and optimize annotation answer functionality - Added the `EvaluationDatasetSelector` component for selecting evaluation datasets in the chat interface - Integrated the evaluation dataset selection feature into the annotation answer popup - Optimized the title display logic in the annotation answer popup to distinguish between regular and evaluation annotations - Added relevant internationalization texts, including: - Options for including/excluding evaluation datasets - Text for the "Create Dataset" button - Prompt text for dataset selection - Fixed the data quality status display issue on the evaluation dataset details page - Optimized the style of the back button on the file import page - Added internationalization texts for confirmation and editing title popups related to evaluation datasets --- packages/web/i18n/en/common.json | 3 +- .../web/i18n/en/dashboard_evaluation.json | 7 +- packages/web/i18n/zh-CN/common.json | 3 +- .../web/i18n/zh-CN/dashboard_evaluation.json | 9 +- packages/web/i18n/zh-Hant/common.json | 3 +- .../i18n/zh-Hant/dashboard_evaluation.json | 9 +- .../components/EvaluationDatasetSelector.tsx | 159 ++++++++++++++++++ .../components/SelectMarkCollection.tsx | 31 +++- .../evaluation/dataset/detail/DataList.tsx | 2 +- .../dataset/detail/EditDataModal.tsx | 2 +- .../dataset/detail/InputDataModal.tsx | 54 ++++-- projects/app/src/pages/app/detail/index.tsx | 11 +- .../evaluation/dataset/fileImport.tsx | 15 +- .../dashboard/evaluation/dataset/index.tsx | 4 +- 14 files changed, 271 insertions(+), 41 deletions(-) create mode 100644 projects/app/src/components/core/chat/ChatContainer/ChatBox/components/EvaluationDatasetSelector.tsx diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index 0d43a8d5fe45..26bafee6a874 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -1352,5 +1352,6 @@ "zoomout_tip": "Zoom in (Ctrl +)", "zoomout_tip_mac": "Zoom in (⌘ +)", "no_database_connection": "还没有连接数据库", - "click_config_database": "点击配置数据库" + "click_config_database": "点击配置数据库", + "annotation_answer": "标注答案" } diff --git a/packages/web/i18n/en/dashboard_evaluation.json b/packages/web/i18n/en/dashboard_evaluation.json index d0ec0cdcbf1b..fe619e346dd1 100644 --- a/packages/web/i18n/en/dashboard_evaluation.json +++ b/packages/web/i18n/en/dashboard_evaluation.json @@ -302,5 +302,10 @@ "builtin_context_recall_name": "Context Recall", "builtin_context_recall_desc": "Evaluates whether the retrieval system successfully retrieves all key information necessary for formulating the answer, assessing the completeness of retrieval.", "builtin_context_precision_name": "Context Precision", - "builtin_context_precision_desc": "Evaluates whether high-value information is prioritized in the retrieved content, reflecting the quality of ranking and information density." + "builtin_context_precision_desc": "Evaluates whether high-value information is prioritized in the retrieved content, reflecting the quality of ranking and information density.", + "join_evaluation_dataset": "加入评测数据集", + "not_join_evaluation_dataset": "不加入评测数据集", + "create_new_dataset_btn_text": "新建数据集", + "please_select_evaluation_dataset": "请选择评测数据集", + "join_knowledge_base": "加入知识库" } diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index ddc1d2597b5a..7548c52f81dd 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -1357,5 +1357,6 @@ "core.dataset.search.mode.database desc": "使用向量检索查找数据库中可能相关的表和列", "core.dataset.training.databaseSchema mode": "数据库结构", "core.dataset.import.databaseSchema Tip":"对数据库中的表信息进行自动处理,使其更利于检索,以提高SQL生成的准确率", - "database_search": "数据库搜索" + "database_search": "数据库搜索", + "annotation_answer": "标注答案" } diff --git a/packages/web/i18n/zh-CN/dashboard_evaluation.json b/packages/web/i18n/zh-CN/dashboard_evaluation.json index 0cbca78a1c9a..9e8785cfa192 100644 --- a/packages/web/i18n/zh-CN/dashboard_evaluation.json +++ b/packages/web/i18n/zh-CN/dashboard_evaluation.json @@ -79,7 +79,7 @@ "create_new_dataset": "新建", "smart_generation": "智能生成", "file_import": "文件导入", - "confirm_delete_dataset": "确认删除该数据集吗?", + "confirm_delete_dataset": "确认删除该数据集?", "error_details": "异常详情", "status_queuing": "排队中", "status_parsing": "文件解析中", @@ -305,5 +305,10 @@ "builtin_context_recall_name": "上下文召回", "builtin_context_recall_desc": "衡量检索系统是否能够获取回答所需的所有关键信息,评估其检索的完整性。", "builtin_context_precision_name": "上下文精度", - "builtin_context_precision_desc": "衡量检索内容中是否优先返回高价值信息,反映排序质量与信息密度。" + "builtin_context_precision_desc": "衡量检索内容中是否优先返回高价值信息,反映排序质量与信息密度。", + "join_evaluation_dataset": "加入评测数据集", + "not_join_evaluation_dataset": "不加入评测数据集", + "create_new_dataset_btn_text": "新建数据集", + "please_select_evaluation_dataset": "请选择评测数据集", + "join_knowledge_base": "加入知识库" } diff --git a/packages/web/i18n/zh-Hant/common.json b/packages/web/i18n/zh-Hant/common.json index 8746c4b2f2de..5fb2bd091d84 100644 --- a/packages/web/i18n/zh-Hant/common.json +++ b/packages/web/i18n/zh-Hant/common.json @@ -1348,5 +1348,6 @@ "zoomout_tip": "放大 ctrl +", "zoomout_tip_mac": "放大 ⌘ +", "no_database_connection": "還沒有連接數據庫", - "click_config_database": "點擊配置數據庫" + "click_config_database": "點擊配置數據庫", + "annotation_answer": "標註答案" } diff --git a/packages/web/i18n/zh-Hant/dashboard_evaluation.json b/packages/web/i18n/zh-Hant/dashboard_evaluation.json index e21b4fe56629..6f47bcfaa07a 100644 --- a/packages/web/i18n/zh-Hant/dashboard_evaluation.json +++ b/packages/web/i18n/zh-Hant/dashboard_evaluation.json @@ -72,7 +72,7 @@ "create_new_dataset": "新建", "smart_generation": "智能生成", "file_import": "文件導入", - "confirm_delete_dataset": "確認刪除該數據集嗎?", + "confirm_delete_dataset": "確認刪除該數據集?", "error_details": "異常詳情", "status_queuing": "排隊中", "status_parsing": "文件解析中", @@ -298,5 +298,10 @@ "builtin_context_recall_name": "上下文召回", "builtin_context_recall_desc": "衡量檢索系統是否能夠獲取回答所需的所有關鍵信息,評估其檢索的完整性。", "builtin_context_precision_name": "上下文精度", - "builtin_context_precision_desc": "衡量檢索內容中是否優先返回高價值信息,反映排序質量與信息密度。" + "builtin_context_precision_desc": "衡量檢索內容中是否優先返回高價值信息,反映排序質量與信息密度。", + "join_evaluation_dataset": "加入評測數據集", + "not_join_evaluation_dataset": "不加入評測數據集", + "create_new_dataset_btn_text": "新建數據集", + "please_select_evaluation_dataset": "請選擇評測數據集", + "join_knowledge_base": "加入知識庫" } diff --git a/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/EvaluationDatasetSelector.tsx b/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/EvaluationDatasetSelector.tsx new file mode 100644 index 000000000000..9544085e0a9c --- /dev/null +++ b/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/EvaluationDatasetSelector.tsx @@ -0,0 +1,159 @@ +import React, { useState, useMemo, useCallback } from 'react'; +import { Button, Box, Text, HStack, Flex, useDisclosure } from '@chakra-ui/react'; +import { useTranslation } from 'next-i18next'; +import MySelect from '@fastgpt/web/components/common/MySelect'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import MyMenu from '@fastgpt/web/components/common/MyMenu'; +import dynamic from 'next/dynamic'; +import { useScrollPagination } from '@fastgpt/web/hooks/useScrollPagination'; +import { getEvaluationDatasetList } from '@/web/core/evaluation/dataset'; +import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; + +const IntelligentGeneration = dynamic( + () => import('@/pageComponents/dashboard/evaluation/dataset/IntelligentGeneration') +); + +interface EvaluationDatasetSelectorProps { + value: string; + onChange: (datasetId: string) => void; +} + +const EvaluationDatasetSelector: React.FC = ({ + value, + onChange +}) => { + const { t } = useTranslation(); + + // 智能生成数据集弹窗控制 + const { + isOpen: isIntelligentModalOpen, + onOpen: onOpenIntelligentModal, + onClose: onCloseIntelligentModal + } = useDisclosure(); + + // 获取评测数据集列表 + const scrollParams = useMemo( + () => ({ + searchKey: '', + pageSize: 10 + }), + [] + ); + + const EmptyTipDom = useMemo(() => , [t]); + + const { + data: evaluationDatasetList, + ScrollData, + isLoading: isLoadingDatasets, + refreshList: fetchDatasets + } = useScrollPagination(getEvaluationDatasetList, { + params: scrollParams, + refreshDeps: [], + EmptyTip: EmptyTipDom + }); + + // 转换评测数据集列表为 MySelect 需要的格式 + const evaluationDatasetSelectList = useMemo(() => { + const data = evaluationDatasetList.map((item) => ({ + label: item.name, + value: item._id + })); + + return [{ label: t('dashboard_evaluation:not_join_evaluation_dataset'), value: 'null' }].concat( + data + ); + }, [evaluationDatasetList, t]); + + // 处理创建数据集 + const handleCreateDataset = useCallback( + (type: 'smart' | 'import') => { + if (type === 'smart') { + onOpenIntelligentModal(); + } else { + // 在新标签页打开文件导入页面 + window.open( + '/dashboard/evaluation/dataset/fileImport?scene=evaluationDatasetList', + '_blank' + ); + } + }, + [onOpenIntelligentModal] + ); + + // 智能生成数据集确认回调 + const handleIntelligentGenerationConfirm = useCallback( + (data: any, datasetId?: string) => { + onCloseIntelligentModal(); + fetchDatasets(); + }, + [onCloseIntelligentModal, fetchDatasets] + ); + + return ( + <> + + + + + {t('dashboard_evaluation:join_evaluation_dataset')} + + + + {t('dashboard_evaluation:create_new_dataset_btn_text')} + + } + menuList={[ + { + children: [ + { + label: ( + + + {t('dashboard_evaluation:smart_generation')} + + ), + onClick: () => handleCreateDataset('smart') + }, + { + label: ( + + + {t('dashboard_evaluation:file_import')} + + ), + onClick: () => handleCreateDataset('import') + } + ] + } + ]} + /> + + + + + {/* 智能生成数据集弹窗 */} + {isIntelligentModalOpen && ( + + )} + + ); +}; + +export default React.memo(EvaluationDatasetSelector); diff --git a/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/SelectMarkCollection.tsx b/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/SelectMarkCollection.tsx index 57831eee86b0..2d9a713a5d2c 100644 --- a/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/SelectMarkCollection.tsx +++ b/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/SelectMarkCollection.tsx @@ -1,11 +1,12 @@ import React, { useState, useMemo, useCallback } from 'react'; -import { ModalBody, ModalFooter, Button, Box } from '@chakra-ui/react'; +import { ModalBody, ModalFooter, Button, VStack, FormControl, FormLabel } from '@chakra-ui/react'; import { useTranslation } from 'next-i18next'; import MyModal from '@fastgpt/web/components/common/MyModal'; import dynamic from 'next/dynamic'; import { type AdminFbkType } from '@fastgpt/global/core/chat/type.d'; import FilesCascader from './FilesCascader'; import type { FileSelection } from './FilesCascader'; +import EvaluationDatasetSelector from './EvaluationDatasetSelector'; const InputDataModal = dynamic(() => import('@/pageComponents/dataset/detail/InputDataModal')); @@ -59,6 +60,14 @@ const SelectMarkCollection = ({ [adminMarkData, setAdminMarkData] ); + // 评测数据集选择相关 + const [selectedEvaluationDataset, setSelectedEvaluationDataset] = useState(''); + + // 处理评测数据集选择变化 + const handleEvaluationDatasetChange = useCallback((datasetId: string) => { + setSelectedEvaluationDataset(datasetId); + }, []); + // 处理确认按钮点击 const handleConfirm = useCallback(() => { if (adminMarkData.datasetId && adminMarkData.collectionId) { @@ -80,14 +89,25 @@ const SelectMarkCollection = ({ - - - + + + + + {t('dashboard_evaluation:join_knowledge_base')} + + + + { /> )} - - + + {/* 智能生成数据集弹窗 */} {isIntelligentModalOpen && ( From a74e80cfc0a4e2abc00a52e67fff548920b2a1fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=96=87=E5=90=AF72536?= <72536@sangfor.com> Date: Wed, 3 Sep 2025 15:33:42 +0800 Subject: [PATCH 19/84] [ADD] Pdf parse timeout and concurrency --- .../global/common/system/types/index.d.ts | 2 ++ .../app/src/service/common/system/index.ts | 1 + .../core/dataset/queues/datasetParse.ts | 22 ++++++++++++++++--- projects/app/src/types/index.d.ts | 1 + 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/global/common/system/types/index.d.ts b/packages/global/common/system/types/index.d.ts index 4b78f5f0c455..8a979263221e 100644 --- a/packages/global/common/system/types/index.d.ts +++ b/packages/global/common/system/types/index.d.ts @@ -129,6 +129,7 @@ export type FastGPTFeConfigsType = { export type SystemEnvType = { openapiPrefix?: string; + parseMaxProcess?: number; vectorMaxProcess: number; qaMaxProcess: number; vlmMaxProcess: number; @@ -146,6 +147,7 @@ export type SystemEnvType = { export type customPdfParseType = { url?: string; key?: string; + timeout?: number; doc2xKey?: string; price?: number; }; diff --git a/projects/app/src/service/common/system/index.ts b/projects/app/src/service/common/system/index.ts index 79930c8d8a5b..4468058afffa 100644 --- a/projects/app/src/service/common/system/index.ts +++ b/projects/app/src/service/common/system/index.ts @@ -73,6 +73,7 @@ export function initGlobalVariables() { }; } + global.parseQueueLen = global.parseQueueLen ?? 0; global.qaQueueLen = global.qaQueueLen ?? 0; global.vectorQueueLen = global.vectorQueueLen ?? 0; initHttpAgent(); diff --git a/projects/app/src/service/core/dataset/queues/datasetParse.ts b/projects/app/src/service/core/dataset/queues/datasetParse.ts index f1d479daa4b1..e08a47657228 100644 --- a/projects/app/src/service/core/dataset/queues/datasetParse.ts +++ b/projects/app/src/service/core/dataset/queues/datasetParse.ts @@ -88,11 +88,24 @@ const requestLLMPargraph = async ({ return data; }; +const reduceQueue = () => { + global.parseQueueLen = global.parseQueueLen > 0 ? global.parseQueueLen - 1 : 0; + + return global.parseQueueLen === 0; +}; + export const datasetParseQueue = async (): Promise => { + const max = global.systemEnv?.parseMaxProcess; + addLog.debug(`[Parse Queue] Queue size: ${global.parseQueueLen}`); + + if (max != null && global.parseQueueLen >= max) return; + global.parseQueueLen++; + const startTime = Date.now(); + const timeout = global.systemEnv.customPdfParse?.timeout || 10; while (true) { - // 1. Get task and lock 20 minutes ago + // 1. Get task and lock timeout minutes ago const { data, done = false, @@ -103,7 +116,7 @@ export const datasetParseQueue = async (): Promise => { { mode: TrainingModeEnum.parse, retryCount: { $gt: 0 }, - lockTime: { $lte: addMinutes(new Date(), -10) } + lockTime: { $lte: addMinutes(new Date(), -timeout) } }, { lockTime: new Date(), @@ -364,5 +377,8 @@ export const datasetParseQueue = async (): Promise => { } } - addLog.debug(`[Parse Queue] break loop`); + if (reduceQueue()) { + addLog.info(`[Parse Queue] Done`); + } + addLog.debug(`[Parse Queue] break loop, current queue size: ${global.parseQueueLen}`); }; diff --git a/projects/app/src/types/index.d.ts b/projects/app/src/types/index.d.ts index 6d07318d6f7d..1493cd70e248 100644 --- a/projects/app/src/types/index.d.ts +++ b/projects/app/src/types/index.d.ts @@ -11,6 +11,7 @@ import type { TrackEventName } from '@/web/common/system/constants'; import { SubPlanType } from '@fastgpt/global/support/wallet/sub/type'; declare global { + var parseQueueLen: number; var qaQueueLen: number; var vectorQueueLen: number; From bb3899875d891336a001d67398f6432c996d5459 Mon Sep 17 00:00:00 2001 From: hello_strong <1145607886@qq.com> Date: Wed, 17 Sep 2025 19:12:15 +0800 Subject: [PATCH 20/84] feat: data level score --- packages/global/core/evaluation/type.d.ts | 1 + .../service/core/evaluation/summary/index.ts | 81 ++++++++++- .../service/core/evaluation/task/processor.ts | 132 +++++++++++++++++- .../service/core/evaluation/task/schema.ts | 7 +- .../api/core/evaluation/task/create.test.ts | 22 +-- .../core/evaluation/task/item/list.test.ts | 8 +- 6 files changed, 232 insertions(+), 19 deletions(-) diff --git a/packages/global/core/evaluation/type.d.ts b/packages/global/core/evaluation/type.d.ts index ff7d521701aa..977279db6dc4 100644 --- a/packages/global/core/evaluation/type.d.ts +++ b/packages/global/core/evaluation/type.d.ts @@ -122,6 +122,7 @@ export type EvaluationItemSchemaType = { retry: number; finishTime?: Date; errorMessage?: string; + aggregateScore?: number; // Weighted aggregate score calculated from multiple evaluators }; // Evaluation target input/output types diff --git a/packages/service/core/evaluation/summary/index.ts b/packages/service/core/evaluation/summary/index.ts index 6090b5660717..a4965ef29e19 100644 --- a/packages/service/core/evaluation/summary/index.ts +++ b/packages/service/core/evaluation/summary/index.ts @@ -26,6 +26,7 @@ import { AuditEventEnum } from '@fastgpt/global/support/user/audit/constants'; import { concatUsage, evaluationUsageIndexMap } from '../../../support/wallet/usage/controller'; import { createMergedEvaluationUsage } from '../utils/usage'; import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation'; +import { recalculateAllEvaluationItemAggregateScores } from '../task/processor'; export class EvaluationSummaryService { // Get evaluation summary report @@ -400,8 +401,79 @@ export class EvaluationSummaryService { } } - // Update configuration and recalculate - await this.updateConfigurationAndRecalculate(evalId, evaluation, metricsConfig); + // Check if weights have changed to determine if recalculation is needed + const hasWeightChanges = this.checkWeightChanges(evaluation, metricsConfig); + + // Update configuration and recalculate if weights changed + if (hasWeightChanges) { + await this.updateConfigurationAndRecalculate(evalId, evaluation, metricsConfig); + } else { + // Only update configuration without recalculation + await this.updateConfigurationOnly(evalId, evaluation, metricsConfig); + } + } + + // Check if weights have changed by comparing current and new configurations + private static checkWeightChanges( + evaluation: EvaluationSchemaType, + metricsConfig: Array<{ + metricId: string; + thresholdValue: number; + weight?: number; + calculateType?: CalculateMethodEnum; + }> + ): boolean { + const configMap = new Map(metricsConfig.map((m) => [m.metricId, m])); + + // Check each current summary config for weight changes + for (let index = 0; index < evaluation.summaryConfigs.length; index++) { + const currentSummaryConfig = evaluation.summaryConfigs[index]; + const metricId = evaluation.evaluators[index].metric._id.toString(); + const newConfig = configMap.get(metricId); + + if (newConfig && newConfig.weight !== undefined) { + // Compare current weight with new weight + const currentWeight = currentSummaryConfig.weight || 0; + const newWeight = newConfig.weight || 0; + + if (currentWeight !== newWeight) { + addLog.info('[Evaluation] Weight change detected', { + metricId, + currentWeight, + newWeight + }); + return true; + } + } + } + + addLog.info('[Evaluation] No weight changes detected, skipping recalculation'); + return false; + } + + // Update configuration only without recalculation (for threshold and calculateType changes) + private static async updateConfigurationOnly( + evalId: string, + evaluation: EvaluationSchemaType, + metricsConfig: Array<{ + metricId: string; + thresholdValue: number; + weight?: number; + calculateType?: CalculateMethodEnum; + }> + ): Promise { + const configMap = new Map(metricsConfig.map((m) => [m.metricId, m])); + + // Update database configuration + await this.updateDatabaseConfig(evalId, evaluation, metricsConfig, configMap); + + // Update eval_items thresholds + await this.updateEvalItemThresholds(evalId, metricsConfig); + + addLog.info('[Evaluation] Configuration updated without recalculation', { + evalId, + metricCount: metricsConfig.length + }); } // Simplified method to update configuration and recalculate everything @@ -427,12 +499,17 @@ export class EvaluationSummaryService { const updatedEvaluation = await MongoEvaluation.findById(evalId).lean(); if (updatedEvaluation) { addLog.info('[Evaluation] Configuration updated, recalculating all metrics', { evalId }); + + // Recalculate evaluation summary metrics and aggregate score const calculatedData = await this.calculateMetricScores(updatedEvaluation); await this.updateSummaryConfigsScores( evalId, calculatedData.metricsData, calculatedData.aggregateScore ); + + // Recalculate all evaluation item aggregate scores since weights changed + await recalculateAllEvaluationItemAggregateScores(evalId); } } diff --git a/packages/service/core/evaluation/task/processor.ts b/packages/service/core/evaluation/task/processor.ts index 072f7dae312b..9c5d8a74009d 100644 --- a/packages/service/core/evaluation/task/processor.ts +++ b/packages/service/core/evaluation/task/processor.ts @@ -18,6 +18,60 @@ import { EvaluationErrEnum } from '@fastgpt/global/common/error/code/evaluation' import { getErrText } from '@fastgpt/global/common/error/utils'; import { createMergedEvaluationUsage } from '../utils/usage'; import { EvaluationSummaryService } from '../summary'; + +// Calculate aggregateScore for a single evaluation item +export const calculateEvaluationItemAggregateScore = async ( + evalItemId: string +): Promise => { + try { + const evalItem = await MongoEvalItem.findById(evalItemId).lean(); + if (!evalItem || !evalItem.evaluatorOutputs || evalItem.evaluatorOutputs.length === 0) { + return 0; + } + + // Get evaluation task to access summaryConfigs for weights + const evaluation = await MongoEvaluation.findById(evalItem.evalId).lean(); + if (!evaluation || !evaluation.summaryConfigs || evaluation.summaryConfigs.length === 0) { + return 0; + } + + let totalWeightedScore = 0; + let totalWeight = 0; + + // Calculate weighted score for each evaluator + evalItem.evaluatorOutputs.forEach((evaluatorOutput, index) => { + const score = evaluatorOutput?.data?.score; + if (score !== undefined && score !== null && evaluation.summaryConfigs[index]) { + const weight = evaluation.summaryConfigs[index].weight || 0; + const scoreScaling = evalItem.evaluators[index]?.scoreScaling || 100; + + // Apply score scaling and calculate weighted score + const scaledScore = score * (scoreScaling / 100); + totalWeightedScore += scaledScore * weight; + totalWeight += weight; + } + }); + + // Calculate aggregate score + const aggregateScore = + totalWeight > 0 ? Math.round((totalWeightedScore / totalWeight) * 100) / 100 : 0; + + addLog.debug( + `[Evaluation] Calculated aggregateScore for item: ${evalItemId}, score: ${aggregateScore}`, + { + evalItemId, + totalWeightedScore, + totalWeight, + aggregateScore + } + ); + + return aggregateScore; + } catch (error) { + addLog.error(`[Evaluation] Error calculating aggregateScore for item: ${evalItemId}`, error); + return 0; + } +}; import type { MetricResult } from '@fastgpt/global/core/evaluation/metric/type'; import { MetricResultStatusEnum } from '@fastgpt/global/core/evaluation/metric/constants'; @@ -693,13 +747,23 @@ const evaluationItemProcessor = async (job: Job) => { (sum: number, item: any) => sum + (item.totalPoints || 0), 0 ); + const inputTokens = targetOutput.usage.reduce( + (sum: number, item: any) => sum + (item.inputTokens || 0), + 0 + ); + const outputTokens = targetOutput.usage.reduce( + (sum: number, item: any) => sum + (item.outputTokens || 0), + 0 + ); await createMergedEvaluationUsage({ evalId, teamId: evaluation.teamId, tmbId: evaluation.tmbId, usageId: evaluation.usageId, totalPoints, - type: 'target' + type: 'target', + inputTokens, + outputTokens }); } @@ -746,13 +810,23 @@ const evaluationItemProcessor = async (job: Job) => { retrievalContext: targetOutput.retrievalContext }); + const inputTokens = + evaluatorOutput.usages?.reduce((sum, usage) => sum + (usage.promptTokens || 0), 0) || 0; + const outputTokens = + evaluatorOutput.usages?.reduce( + (sum, usage) => sum + (usage.completionTokens || 0), + 0 + ) || 0; + await createMergedEvaluationUsage({ evalId, teamId: evaluation.teamId, tmbId: evaluation.tmbId, usageId: evaluation.usageId, totalPoints: evaluatorOutput.totalPoints || 0, - type: 'metric' + type: 'metric', + inputTokens, + outputTokens }); // Record error but continue processing @@ -802,13 +876,18 @@ const evaluationItemProcessor = async (job: Job) => { ); } } - // 3. Store results + + // 3. Calculate aggregate score for this evaluation item + const aggregateScore = await calculateEvaluationItemAggregateScore(evalItemId); + + // 4. Store results including aggregateScore await MongoEvalItem.updateOne( { _id: new Types.ObjectId(evalItemId) }, { $set: { targetOutput: targetOutput, evaluatorOutputs: evaluatorOutputs, + aggregateScore: aggregateScore, status: EvaluationStatusEnum.completed, finishTime: new Date() } @@ -822,7 +901,7 @@ const evaluationItemProcessor = async (job: Job) => { .map((output) => output?.data?.score) .filter((score) => score !== undefined); addLog.debug( - `[Evaluation] Evaluation item completed: ${evalItemId}, scores: [${scores.join(', ')}]` + `[Evaluation] Evaluation item completed: ${evalItemId}, scores: [${scores.join(', ')}], aggregateScore: ${aggregateScore}` ); } catch (error) { addLog.error(`[Evaluation] Evaluation item error: ${evalItemId}, error: ${error}`); @@ -849,5 +928,50 @@ export const initEvalTaskItemWorker = () => { return getEvaluationItemWorker(evaluationItemProcessor); }; +// Recalculate aggregate scores for all evaluation items in a given evaluation task +export const recalculateAllEvaluationItemAggregateScores = async ( + evalId: string +): Promise => { + try { + addLog.info('[Evaluation] Starting recalculation of all evaluation item aggregate scores', { + evalId + }); + + // Get all completed evaluation items for this evaluation + const evalItems = await MongoEvalItem.find({ + evalId: new Types.ObjectId(evalId), + status: EvaluationStatusEnum.completed, + evaluatorOutputs: { $exists: true, $nin: [null, []] } + }).lean(); + + if (evalItems.length === 0) { + addLog.info('[Evaluation] No completed evaluation items found for recalculation', { + evalId + }); + return; + } + + // Recalculate aggregate score for each item + const updatePromises = evalItems.map(async (item) => { + const aggregateScore = await calculateEvaluationItemAggregateScore(item._id.toString()); + + return MongoEvalItem.updateOne({ _id: item._id }, { $set: { aggregateScore } }); + }); + + await Promise.all(updatePromises); + + addLog.info('[Evaluation] Successfully recalculated all evaluation item aggregate scores', { + evalId, + updatedItemsCount: evalItems.length + }); + } catch (error) { + addLog.error('[Evaluation] Failed to recalculate evaluation item aggregate scores', { + evalId, + error + }); + throw error; + } +}; + // Export for testing export { evaluationTaskProcessor, evaluationItemProcessor, finishEvaluationTask }; diff --git a/packages/service/core/evaluation/task/schema.ts b/packages/service/core/evaluation/task/schema.ts index 5310f5921358..0276c5640bb0 100644 --- a/packages/service/core/evaluation/task/schema.ts +++ b/packages/service/core/evaluation/task/schema.ts @@ -256,7 +256,12 @@ export const EvaluationItemSchema = new Schema({ default: 3 }, finishTime: Date, - errorMessage: String + errorMessage: String, + // Weighted aggregate score calculated from multiple evaluators + aggregateScore: { + type: Number, + default: 0 + } }); // Optimized indexes for EvaluationItemSchema diff --git a/test/cases/pages/api/core/evaluation/task/create.test.ts b/test/cases/pages/api/core/evaluation/task/create.test.ts index 0f834a084f1a..05fe2191ae66 100644 --- a/test/cases/pages/api/core/evaluation/task/create.test.ts +++ b/test/cases/pages/api/core/evaluation/task/create.test.ts @@ -59,7 +59,7 @@ describe('Create Evaluation Task API Handler', () => { _id: new Types.ObjectId(), name: 'Test Evaluation', description: 'Test Description', - datasetId: new Types.ObjectId(), + evalDatasetCollectionId: new Types.ObjectId(), target: { type: 'workflow', config: { @@ -116,7 +116,7 @@ describe('Create Evaluation Task API Handler', () => { body: { name: 'Test Evaluation', description: 'Test Description', - datasetId: new Types.ObjectId().toString(), + evalDatasetCollectionId: new Types.ObjectId().toString(), target: { type: 'workflow', config: { @@ -152,7 +152,7 @@ describe('Create Evaluation Task API Handler', () => { expect.objectContaining({ name: 'Test Evaluation', description: 'Test Description', - datasetId: mockReq.body.datasetId, + evalDatasetCollectionId: mockReq.body.evalDatasetCollectionId, target: mockReq.body.target, evaluators: mockReq.body.evaluators, autoStart: undefined, // 用户未传递 autoStart 参数时应该是 undefined,服务层会设置默认值 @@ -184,7 +184,7 @@ describe('Create Evaluation Task API Handler', () => { body: { name: 'Test Evaluation', description: 'Test Description', - datasetId: new Types.ObjectId().toString(), + evalDatasetCollectionId: new Types.ObjectId().toString(), target: { type: 'workflow', config: { @@ -221,7 +221,7 @@ describe('Create Evaluation Task API Handler', () => { expect.objectContaining({ name: 'Test Evaluation', description: 'Test Description', - datasetId: mockReq.body.datasetId, + evalDatasetCollectionId: mockReq.body.evalDatasetCollectionId, target: mockReq.body.target, evaluators: mockReq.body.evaluators, autoStart: true, @@ -249,7 +249,7 @@ describe('Create Evaluation Task API Handler', () => { body: { name: 'Test Evaluation', description: 'Test Description', - datasetId: new Types.ObjectId().toString(), + evalDatasetCollectionId: new Types.ObjectId().toString(), target: { type: 'workflow', config: { @@ -286,7 +286,7 @@ describe('Create Evaluation Task API Handler', () => { expect.objectContaining({ name: 'Test Evaluation', description: 'Test Description', - datasetId: mockReq.body.datasetId, + evalDatasetCollectionId: mockReq.body.evalDatasetCollectionId, target: mockReq.body.target, evaluators: mockReq.body.evaluators, autoStart: false, @@ -303,7 +303,7 @@ describe('Create Evaluation Task API Handler', () => { method: 'POST', body: { name: '', - datasetId: new Types.ObjectId().toString(), + evalDatasetCollectionId: new Types.ObjectId().toString(), target: { type: 'workflow', config: { @@ -337,7 +337,7 @@ describe('Create Evaluation Task API Handler', () => { method: 'POST', body: { name: 'Test Evaluation', - datasetId: new Types.ObjectId().toString(), + evalDatasetCollectionId: new Types.ObjectId().toString(), target: { type: 'workflow', config: { @@ -370,7 +370,7 @@ describe('Create Evaluation Task API Handler', () => { // message: 'Target validation failed' // }); - // datasetId 验证会先失败 - await expect(createHandler(mockReq)).rejects.toThrow('evaluationDatasetIdRequired'); + // evalDatasetCollectionId 验证会先失败 + await expect(createHandler(mockReq)).rejects.toThrow('evaluationDatasetCollectionIdRequired'); }); }); diff --git a/test/cases/pages/api/core/evaluation/task/item/list.test.ts b/test/cases/pages/api/core/evaluation/task/item/list.test.ts index b37c51a13704..46e1061638e8 100644 --- a/test/cases/pages/api/core/evaluation/task/item/list.test.ts +++ b/test/cases/pages/api/core/evaluation/task/item/list.test.ts @@ -91,7 +91,13 @@ describe('List Evaluation Items API Handler', () => { 'eval-123', 'team-123', 0, - 20 + 20, + { + status: undefined, + userInput: undefined, + expectedOutput: undefined, + actualOutput: undefined + } ); expect(result).toEqual({ list: mockItems, From a5cbc07cb9ce349c48f646cccd8c59786904261d Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 17 Sep 2025 18:18:01 +0800 Subject: [PATCH 21/84] fix: enhance evaluation dataset quality job management - Add checkEvalDatasetDataQualityJobInactive function to detect completed/failed jobs - Enhance delete API to handle both active and inactive quality jobs with proper logging - Fix quality assessment to properly reset metadata fields using $unset operations - Update test expectations to match actual implementation logging parameters --- .../core/evaluation/dataset/dataQualityMq.ts | 19 ++++++++++++++++++ .../core/evaluation/dataset/data/delete.ts | 16 ++++++++++----- .../dataset/data/qualityAssessment.ts | 11 ++++++++++ .../evaluation/dataset/data/delete.test.ts | 11 +++++++--- .../dataset/data/qualityAssessment.test.ts | 20 +++++++++++++++++++ 5 files changed, 69 insertions(+), 8 deletions(-) diff --git a/packages/service/core/evaluation/dataset/dataQualityMq.ts b/packages/service/core/evaluation/dataset/dataQualityMq.ts index d592b2379935..82dad0d3ab2a 100644 --- a/packages/service/core/evaluation/dataset/dataQualityMq.ts +++ b/packages/service/core/evaluation/dataset/dataQualityMq.ts @@ -62,6 +62,25 @@ export const checkEvalDatasetDataQualityJobActive = async (dataId: string): Prom } }; +export const checkEvalDatasetDataQualityJobInactive = async (dataId: string): Promise => { + try { + const jobId = await evalDatasetDataQualityQueue.getDeduplicationJobId(String(dataId)); + if (!jobId) return false; + + const job = await evalDatasetDataQualityQueue.getJob(jobId); + if (!job) return false; + + const jobState = await job.getState(); + return ['completed', 'failed'].includes(jobState); + } catch (error) { + addLog.error('Failed to check eval dataset data quality job inactive status', { + dataId, + error + }); + return false; + } +}; + export const removeEvalDatasetDataQualityJobsRobust = async ( dataIds: string[], options?: JobCleanupOptions diff --git a/projects/app/src/pages/api/core/evaluation/dataset/data/delete.ts b/projects/app/src/pages/api/core/evaluation/dataset/data/delete.ts index 3bdd7f6e8006..42303c022298 100644 --- a/projects/app/src/pages/api/core/evaluation/dataset/data/delete.ts +++ b/projects/app/src/pages/api/core/evaluation/dataset/data/delete.ts @@ -6,6 +6,7 @@ import { MongoEvalDatasetCollection } from '@fastgpt/service/core/evaluation/dat import type { deleteEvalDatasetDataQuery } from '@fastgpt/global/core/evaluation/dataset/api'; import { checkEvalDatasetDataQualityJobActive, + checkEvalDatasetDataQualityJobInactive, removeEvalDatasetDataQualityJobsRobust } from '@fastgpt/service/core/evaluation/dataset/dataQualityMq'; import { addLog } from '@fastgpt/service/common/system/log'; @@ -54,26 +55,31 @@ async function handler( collectionName = collection.name; const hasActiveQualityJob = await checkEvalDatasetDataQualityJobActive(dataId); + const hasInactiveQualityJob = await checkEvalDatasetDataQualityJobInactive(dataId); - if (hasActiveQualityJob) { - addLog.info('Removing active quality evaluation job before deletion', { + if (hasActiveQualityJob || hasInactiveQualityJob) { + const jobType = hasActiveQualityJob ? 'active' : 'inactive'; + addLog.info(`Removing ${jobType} quality evaluation job before deletion`, { dataId, - teamId + teamId, + jobType }); try { await removeEvalDatasetDataQualityJobsRobust([dataId], { - forceCleanActiveJobs: true, + forceCleanActiveJobs: hasActiveQualityJob, retryDelay: 200 }); addLog.info('Quality evaluation job removed successfully before deletion', { dataId, - teamId + teamId, + jobType }); } catch (error) { addLog.error('Failed to remove quality evaluation job before deletion', { dataId, teamId, + jobType, error }); // Continue with deletion even if queue removal fails diff --git a/projects/app/src/pages/api/core/evaluation/dataset/data/qualityAssessment.ts b/projects/app/src/pages/api/core/evaluation/dataset/data/qualityAssessment.ts index bca9f1ffba31..e24d5e955b05 100644 --- a/projects/app/src/pages/api/core/evaluation/dataset/data/qualityAssessment.ts +++ b/projects/app/src/pages/api/core/evaluation/dataset/data/qualityAssessment.ts @@ -76,11 +76,22 @@ async function handler( evaluationModel: finalEvaluationModel }); + // Reset quality metadata and quality result fields await MongoEvalDatasetData.findByIdAndUpdate(dataId, { $set: { 'qualityMetadata.status': EvalDatasetDataQualityStatusEnum.queuing, 'qualityMetadata.model': finalEvaluationModel, 'qualityMetadata.queueTime': new Date() + }, + $unset: { + 'qualityMetadata.score': '', + 'qualityMetadata.reason': '', + 'qualityMetadata.usages': '', + 'qualityMetadata.runLogs': '', + 'qualityMetadata.startTime': '', + 'qualityMetadata.finishTime': '', + 'qualityMetadata.error': '', + qualityResult: '' } }); diff --git a/test/cases/pages/api/core/evaluation/dataset/data/delete.test.ts b/test/cases/pages/api/core/evaluation/dataset/data/delete.test.ts index af786adb1aa7..7bdcb64df62e 100644 --- a/test/cases/pages/api/core/evaluation/dataset/data/delete.test.ts +++ b/test/cases/pages/api/core/evaluation/dataset/data/delete.test.ts @@ -206,11 +206,11 @@ describe('EvalDatasetData Delete API', () => { ); expect(mockAddLog.info).toHaveBeenCalledWith( 'Removing active quality evaluation job before deletion', - { dataId: CONSTANTS.validDataId, teamId: CONSTANTS.validTeamId } + { dataId: CONSTANTS.validDataId, teamId: CONSTANTS.validTeamId, jobType: 'active' } ); expect(mockAddLog.info).toHaveBeenCalledWith( 'Quality evaluation job removed successfully before deletion', - { dataId: CONSTANTS.validDataId, teamId: CONSTANTS.validTeamId } + { dataId: CONSTANTS.validDataId, teamId: CONSTANTS.validTeamId, jobType: 'active' } ); }); @@ -225,7 +225,12 @@ describe('EvalDatasetData Delete API', () => { expect(mockAddLog.error).toHaveBeenCalledWith( 'Failed to remove quality evaluation job before deletion', - { dataId: CONSTANTS.validDataId, teamId: CONSTANTS.validTeamId, error: jobError } + { + dataId: CONSTANTS.validDataId, + teamId: CONSTANTS.validTeamId, + jobType: 'active', + error: jobError + } ); expect(mockMongoEvalDatasetData.deleteOne).toHaveBeenCalled(); expect(result).toBe('success'); diff --git a/test/cases/pages/api/core/evaluation/dataset/data/qualityAssessment.test.ts b/test/cases/pages/api/core/evaluation/dataset/data/qualityAssessment.test.ts index b1b3dacb5763..58a66acafe69 100644 --- a/test/cases/pages/api/core/evaluation/dataset/data/qualityAssessment.test.ts +++ b/test/cases/pages/api/core/evaluation/dataset/data/qualityAssessment.test.ts @@ -314,6 +314,16 @@ describe('QualityAssessment API', () => { 'qualityMetadata.status': EvalDatasetDataQualityStatusEnum.queuing, 'qualityMetadata.model': validEvaluationModel, 'qualityMetadata.queueTime': expect.any(Date) + }, + $unset: { + 'qualityMetadata.score': '', + 'qualityMetadata.reason': '', + 'qualityMetadata.usages': '', + 'qualityMetadata.runLogs': '', + 'qualityMetadata.startTime': '', + 'qualityMetadata.finishTime': '', + 'qualityMetadata.error': '', + qualityResult: '' } }); }); @@ -537,6 +547,16 @@ describe('QualityAssessment API', () => { 'qualityMetadata.status': EvalDatasetDataQualityStatusEnum.queuing, 'qualityMetadata.model': validEvaluationModel, 'qualityMetadata.queueTime': expect.any(Date) + }, + $unset: { + 'qualityMetadata.score': '', + 'qualityMetadata.reason': '', + 'qualityMetadata.usages': '', + 'qualityMetadata.runLogs': '', + 'qualityMetadata.startTime': '', + 'qualityMetadata.finishTime': '', + 'qualityMetadata.error': '', + qualityResult: '' } }); expect(result).toBe('success'); From d541d0ad8e17c45f8a5115f8a047f883b4f9a4c1 Mon Sep 17 00:00:00 2001 From: sxf-xiongtao Date: Wed, 17 Sep 2025 22:34:02 +0800 Subject: [PATCH 22/84] update admin translations --- packages/web/i18n/en/admin.json | 24 +++++++++++------------- packages/web/i18n/zh-CN/admin.json | 12 +++++------- packages/web/i18n/zh-Hant/admin.json | 14 ++++++-------- 3 files changed, 22 insertions(+), 28 deletions(-) diff --git a/packages/web/i18n/en/admin.json b/packages/web/i18n/en/admin.json index 6e27f44983a5..96acd56b2089 100644 --- a/packages/web/i18n/en/admin.json +++ b/packages/web/i18n/en/admin.json @@ -284,6 +284,7 @@ "input_oneapi_url": "OneAPI address, used for accessing multiple models", "oneapi_key": "OneAPI key (overwrites environment variable configuration)", "input_oneapi_key": "Enter the OneAPI address", + "dataset_parse_max_process": "Max parsing processes for knowledge base", "dataset_index_max_process": "Max indexing processes for knowledge base", "file_understanding_max_process": "Max processes for file understanding model", "image_understanding_max_process": "Max processes for image understanding model", @@ -294,6 +295,7 @@ "token_calc_max_process": "Max token computation processes (set according to expected concurrency))", "custom_pdf_parse_url": "Custom PDF parsing URL", "custom_pdf_parse_key": "Custom PDF parsing key", + "custom_pdf_parse_timeout": "Custom PDF parsing timeout", "doc2x_pdf_parse_key": "Doc2x PDF parsing key (lower priority than custom PDF parsing)", "custom_pdf_parse_price": "Custom PDF parsing price (n points/page)", "max_upload_files_per_time": "Max files per upload", @@ -325,7 +327,7 @@ "baidu_security_id": "Baidu security ID", "baidu_security_secret": "Baidu security secret", "custom_security_check_url": "Custom security check URL", - "baidu_security_register_desc": "注册百度安全校验账号,并创建对应应用。提供应用的 id 和 secret", + "baidu_security_register_desc": "Register a baidu security verification account and create a corresponding application. Provide the application id and secret", "custom_security_check_desc": "If you have your own security verification service, enter the address here and enable Sensitive content check on FastGPT.", "plan_free": "Free edition", "plan_trial": "Trial edition", @@ -343,21 +345,20 @@ "enable_subscription_plan": "Enable subscription plan", "custom_plan_page_description": "The specified address will overwrite the address of the subscription plan page. Users will be redirected to this address when accessing the subscription plan page.", "wechat_payment_materials": "WeChat payment documents", - "wechat_payment_registration_guide": "自行注册微信支付,目前需要wx扫码支付", + "wechat_payment_registration_guide": "Register WeChat payment by yourself, currently you need to scan the QR code to pay", "unused_field_placeholder": "Fill in anything", "certificate_management_guide": "Click Manage to obtain", "wechat_key_extraction_guide": "Follow WeChat instructions to obtain the files, and open the key file with a text editor to get the private key.", "alipay_payment_materials": "Alipay payment documents", - "alipay_application_guide": "自行注册支付宝应用,目前需要开通电脑网站支付", + "alipay_application_guide": "Register the Alipay app yourself. Currently, you need to enable payment via the computer website.", "alipay_certificate_encryption_guide": "Use a certificate for API signing. For details, see", "application_public_key_certificate": "App public key certificate", "private_key_document_reference": "Refer to the above documentation for retrieving the private key", "alipay_root_certificate": "Alipay root certificate", "alipay_public_key_certificate": "Alipay public key certificate", "alipay_dateway": "Alipay gateway", - "alipay_gateway_sandbox_note": "Enter the Alipay gateway URL. Use the sandbox environment for testing.", - "alipay_gateway_production_note": ",而生成环境是", - "alipay_endpoint_sandbox_note": "Enter the Alipay endpoint URL. Use the sandbox environment for testing.", + "alipay_gateway_sandbox_note": "Enter the Alipay gateway URL. Use the sandbox environment for testing\nhttps://openapi-sandbox.dl.alipaydev.com/gateway.do\n,Build environment\nhttps://openapi.alipay.com/gateway.do\n", + "alipay_endpoint_sandbox_note": "Enter the Alipay endpoint URL. Use the sandbox environment for testing\nhttps://openapi-sandbox.dl.alipaydev.com\n,Build environment\nhttps://openapi.alipay.com\n", "message_notification": "Message", "markdown_format_support": "Supported format: Markdown", "third_party_account_config": "Third-party accounts", @@ -396,10 +397,7 @@ "email_password": "Email password", "email_smtp_auth_code": "SMTP authorization code", "enable_email_registration": "Enable email registration", - "aliyun_sms_params": "阿里云短信参数", - "aliyun_sms_apply_guide": "申请对应的签名和短信模板,提供:", - "signature_name": "Signature", - "template_code_sm": "模板CODE,SM开头的", + "aliyun_access_key_guide": "Alibaba Cloud SMS parameters\nhttps://dysms.console.aliyun.com/overview\nApply for the corresponding signature and SMS template, providing:\nACCESSKEYID\nACCESSSECRET\nSignature Name\nTemplate CODE, starting with SM.", "aliyun_secret_key": "Alibaba Cloud account secret key", "sms_signature": "SMS signature", "registration_account": "Sign up", @@ -470,7 +468,7 @@ "token_fee_description": "If enabled, users need to pay token points and API call points to use the plugin.", "call_points": "API call points", "system_key": "System secret key", - "system_key_description": "对于需要密钥的工具,您可为其配置系统密钥,用户可通过支付积分的方式使用系统密钥。", + "system_key_description": "For tools that require keys, you can configure system keys for them, and users can use the system keys by paying points.", "no_plugins": "No plugins available.", "invoice_application": "Invoice requests", "search_user_placeholder": "Username", @@ -497,8 +495,8 @@ "click_download": "Download", "operation_success": "Success", "operation_failed": "Error", - "upload_invoice_pdf": "请上传发票的PDF文件", - "select_invoice_file": "选择发票文件", + "upload_invoice_pdf": "Please upload the PDF file of your invoice", + "select_invoice_file": "Select invoice file", "confirm_submission": "Confirm submission", "balance_recharge": "Balance top-up", "plan_subscription": "Plan subscription", diff --git a/packages/web/i18n/zh-CN/admin.json b/packages/web/i18n/zh-CN/admin.json index 39ccea8c7f9a..e8ccce4f6e7f 100644 --- a/packages/web/i18n/zh-CN/admin.json +++ b/packages/web/i18n/zh-CN/admin.json @@ -284,6 +284,7 @@ "input_oneapi_url": "请输入 oneAPI 地址", "oneapi_key": "OneAPI 密钥(会覆盖环境变量配置的)", "input_oneapi_key": "请输入 OneAPI 密钥", + "dataset_parse_max_process": "知识库解析最大处理进程", "dataset_index_max_process": "知识库索引最大处理进程", "file_understanding_max_process": "文件理解模型最大处理进程", "image_understanding_max_process": "图片理解模型最大处理进程", @@ -294,6 +295,7 @@ "token_calc_max_process": "token计算最大进程(通常多少并发设置多少)", "custom_pdf_parse_url": "自定义 PDF 解析地址", "custom_pdf_parse_key": "自定义 PDF 解析密钥", + "custom_pdf_parse_timeout": "自定义 PDF 解析超时时间", "doc2x_pdf_parse_key": "Doc2x pdf 解析密钥(比自定义 PDF 解析优先级低)", "custom_pdf_parse_price": "自定义 PDF 解析价格(n 积分/页)", "max_upload_files_per_time": "单次最多上传多少个文件", @@ -355,9 +357,8 @@ "alipay_root_certificate": "支付宝根证书", "alipay_public_key_certificate": "支付宝公钥证书", "alipay_dateway": "支付宝网关", - "alipay_gateway_sandbox_note": "支付宝网关,注意测试使用的沙箱环境是", - "alipay_gateway_production_note": ",而生成环境是", - "alipay_endpoint_sandbox_note": "支付宝端点,注意测试使用的沙箱环境是", + "alipay_gateway_sandbox_note": "支付宝网关,注意测试使用的沙箱环境是\nhttps://openapi-sandbox.dl.alipaydev.com/gateway.do\n,而生成环境是\nhttps://openapi.alipay.com/gateway.do\n", + "alipay_endpoint_sandbox_note": "支付宝端点,注意测试使用的沙箱环境是\nhttps://openapi-sandbox.dl.alipaydev.com\n,而生成环境是\nhttps://openapi.alipay.com\n", "message_notification": "消息提示", "markdown_format_support": "支持markdown格式", "third_party_account_config": "第三方账号配置", @@ -396,10 +397,7 @@ "email_password": "邮箱 Password", "email_smtp_auth_code": "SMTP 授权码", "enable_email_registration": "是否开启邮箱注册", - "aliyun_sms_params": "阿里云短信参数", - "aliyun_sms_apply_guide": "申请对应的签名和短信模板,提供:", - "signature_name": "签名名称", - "template_code_sm": "模板CODE,SM开头的", + "aliyun_access_key_guide": "阿里云短信参数\nhttps://dysms.console.aliyun.com/overview\n申请对应的签名和短信模板,提供:\nACCESSKEYID\nACCESSSECRET\n签名名称\n模板CODE,SM开头的", "aliyun_secret_key": "阿里云账号的secret key", "sms_signature": "短信签名", "registration_account": "注册账号", diff --git a/packages/web/i18n/zh-Hant/admin.json b/packages/web/i18n/zh-Hant/admin.json index a92e3971fa04..e0733a05c560 100644 --- a/packages/web/i18n/zh-Hant/admin.json +++ b/packages/web/i18n/zh-Hant/admin.json @@ -284,6 +284,7 @@ "input_oneapi_url": "請輸入 oneAPI 地址", "oneapi_key": "OneAPI 密鑰(會覆蓋環境變量配置的)", "input_oneapi_key": "請輸入 OneAPI 密鑰", + "dataset_parse_max_process": "知識庫解析最大處理進程", "dataset_index_max_process": "知識庫索引最大處理進程", "file_understanding_max_process": "文件理解模型最大處理進程", "image_understanding_max_process": "圖片理解模型最大處理進程", @@ -294,6 +295,7 @@ "token_calc_max_process": "token計算最大進程(通常多少並發設置多少)", "custom_pdf_parse_url": "自定義 PDF 解析地址", "custom_pdf_parse_key": "自定義 PDF 解析密鑰", + "custom_pdf_parse_timeout": "自定義 PDF 解析逾時時間", "doc2x_pdf_parse_key": "Doc2x pdf 解析密鑰(比自定義 PDF 解析優先級低)", "custom_pdf_parse_price": "自定義 PDF 解析價格(n 積分/頁)", "max_upload_files_per_time": "單次最多上傳多少個文件", @@ -355,9 +357,8 @@ "alipay_root_certificate": "支付寶根證書", "alipay_public_key_certificate": "支付寶公鑰證書", "alipay_dateway": "支付寶網關", - "alipay_gateway_sandbox_note": "支付寶網關,注意測試使用的沙箱環境是", - "alipay_gateway_production_note": ",而生成環境是", - "alipay_endpoint_sandbox_note": "支付寶端點,注意測試使用的沙箱環境是", + "alipay_gateway_sandbox_note": "支付寶網關,注意測試使用的沙箱環境是\nhttps://openapi-sandbox.dl.alipaydev.com/gateway.do\n,而生成環境是\nhttps://openapi.alipay.com/gateway.do\n", + "alipay_endpoint_sandbox_note": "支付寶端點,注意測試使用的沙箱環境是\nhttps://openapi-sandbox.dl.alipaydev.com\n,而生成環境是\nhttps://openapi.alipay.com\n", "message_notification": "消息提示", "markdown_format_support": "支持markdown格式", "third_party_account_config": "第三方賬號配置", @@ -396,10 +397,7 @@ "email_password": "郵箱 密碼", "email_smtp_auth_code": "SMTP 授權碼", "enable_email_registration": "是否開啟郵箱註冊", - "aliyun_sms_params": "阿里雲短信參數", - "aliyun_sms_apply_guide": "申請對應的簽名和短信模板,提供:", - "signature_name": "簽名名稱", - "template_code_sm": "模板CODE,SM開頭的", + "aliyun_access_key_guide": "阿里雲短信參數\nhttps://dysms.console.aliyun.com/overview\n申請對應的簽名和短信模板,提供:\nACCESSKEYID\nACCESSSECRET\n簽名名稱\n模板CODE,SM開頭的", "aliyun_secret_key": "阿里雲賬號的secret key", "sms_signature": "短信簽名", "registration_account": "註冊賬號", @@ -609,4 +607,4 @@ "extra_knowledge_base_storage": "額外知識庫存儲", "image_compression_error": "壓縮圖片異常", "image_too_large": "圖片太大了" -} \ No newline at end of file +} From ab95bb4f8bc385415fb82c48280ae42d423b490e Mon Sep 17 00:00:00 2001 From: chanzhi82020 Date: Wed, 17 Sep 2025 16:55:43 +0800 Subject: [PATCH 23/84] optimize the evalt task resource manager --- packages/global/core/evaluation/type.d.ts | 27 +-- .../service/core/evaluation/target/index.ts | 57 ++---- packages/service/core/evaluation/task/mq.ts | 12 -- .../service/core/evaluation/task/processor.ts | 192 +++++++++--------- .../service/core/evaluation/task/schema.ts | 31 ++- 5 files changed, 142 insertions(+), 177 deletions(-) diff --git a/packages/global/core/evaluation/type.d.ts b/packages/global/core/evaluation/type.d.ts index 977279db6dc4..1f3f2a650e72 100644 --- a/packages/global/core/evaluation/type.d.ts +++ b/packages/global/core/evaluation/type.d.ts @@ -99,10 +99,16 @@ export interface TargetCallParams { } /** - * Extended evaluation data item that combines dataset data with target call parameters. + * Evaluation data item that contains only the necessary fields for evaluation execution. * Used in evaluation context where both dataset content and execution parameters are needed. */ -export type EvaluationDataItemType = EvalDatasetDataSchemaType & { +export type EvaluationDataItemType = Pick< + EvalDatasetDataSchemaType, + | '_id' + | EvalDatasetDataKeyEnum.UserInput + | EvalDatasetDataKeyEnum.ExpectedOutput + | EvalDatasetDataKeyEnum.Context +> & { targetCallParams?: TargetCallParams; }; @@ -113,8 +119,6 @@ export type EvaluationItemSchemaType = { // Chat information is stored in targetOutput.chatId and targetOutput.aiChatItemDataId // Dependent component configurations dataItem: EvaluationDataItemType; - target: EvalTarget; - evaluators: EvaluatorSchema[]; // Multiple evaluator configurations // Execution results targetOutput?: TargetOutput; // Actual output from target evaluatorOutputs?: MetricResult[]; // Results from multiple evaluators @@ -147,21 +151,8 @@ export type EvaluationWithPerType = EvaluationSchemaType & { // ===== Display Types ===== -export type EvaluationDisplayType = Pick< - EvaluationWithPerType, - | 'name' - | 'createTime' - | 'finishTime' - | 'status' - | 'errorMessage' - | 'tmbId' - | 'permission' - | 'statistics' - | 'target' -> & { - _id: string; +export type EvaluationDisplayType = EvaluationWithPerType & { evalDatasetCollectionName?: string; - evalDatasetCollectionId?: string; metricNames: string[]; private: boolean; sourceMember: SourceMemberType; diff --git a/packages/service/core/evaluation/target/index.ts b/packages/service/core/evaluation/target/index.ts index c3c9f3a17993..0fdf4d1b1c3e 100644 --- a/packages/service/core/evaluation/target/index.ts +++ b/packages/service/core/evaluation/target/index.ts @@ -113,42 +113,27 @@ export class WorkflowTarget extends EvaluationTarget { // Construct conversation history based on input.context const histories: (UserChatItemType | AIChatItemType)[] = []; - // if (input.histories && input.histories.length > 0) { - // // Convert histories strings to alternating user-ai conversation history - // // Assume histories format: [user1, ai1, user2, ai2, ...] - // for (let i = 0; i < input.histories.length; i++) { - // const isUser = i % 2 === 0; - // const content = input.histories[i]; - - // if (isUser) { - // // User message - // histories.push({ - // obj: ChatRoleEnum.Human, - // value: [ - // { - // type: ChatItemValueTypeEnum.text, - // text: { - // content - // } - // } - // ] - // }); - // } else { - // // AI message - // histories.push({ - // obj: ChatRoleEnum.AI, - // value: [ - // { - // type: ChatItemValueTypeEnum.text, - // text: { - // content - // } - // } - // ] - // }); - // } - // } - // } + // Add context as background knowledge in conversation history + if (input.context && input.context.length > 0) { + const contextText = input.context + .filter((item) => item && item.trim()) + .map((item, index) => `${index + 1}. ${item}`) + .join('\n'); + + if (contextText) { + histories.push({ + obj: ChatRoleEnum.Human, + value: [ + { + type: ChatItemValueTypeEnum.text, + text: { + content: `请参考以下背景知识回答问题:\n${contextText}` + } + } + ] + }); + } + } const chatId = getNanoid(); diff --git a/packages/service/core/evaluation/task/mq.ts b/packages/service/core/evaluation/task/mq.ts index 21a3730fa436..5d1ae4f4adc7 100644 --- a/packages/service/core/evaluation/task/mq.ts +++ b/packages/service/core/evaluation/task/mq.ts @@ -111,15 +111,3 @@ export const removeEvaluationItemJobsByItemId = async ( return result; }; - -export const getEvaluationQueueStats = async () => { - const [taskStats, itemStats] = await Promise.all([ - evaluationTaskQueue.getJobCounts('active', 'prioritized', 'waiting', 'completed', 'failed'), - evaluationItemQueue.getJobCounts('active', 'prioritized', 'waiting', 'completed', 'failed') - ]); - - return { - taskQueue: taskStats, - itemQueue: itemStats - }; -}; diff --git a/packages/service/core/evaluation/task/processor.ts b/packages/service/core/evaluation/task/processor.ts index 9c5d8a74009d..39398c391bf2 100644 --- a/packages/service/core/evaluation/task/processor.ts +++ b/packages/service/core/evaluation/task/processor.ts @@ -3,7 +3,9 @@ import type { Job } from '../../../common/bullmq'; import type { EvaluationTaskJobData, EvaluationItemJobData, - TargetOutput + TargetOutput, + EvaluationItemSchemaType, + EvaluationDataItemType } from '@fastgpt/global/core/evaluation/type'; import { evaluationItemQueue, getEvaluationItemWorker, getEvaluationTaskWorker } from './mq'; import { MongoEvaluation, MongoEvalItem } from './schema'; @@ -43,7 +45,7 @@ export const calculateEvaluationItemAggregateScore = async ( const score = evaluatorOutput?.data?.score; if (score !== undefined && score !== null && evaluation.summaryConfigs[index]) { const weight = evaluation.summaryConfigs[index].weight || 0; - const scoreScaling = evalItem.evaluators[index]?.scoreScaling || 100; + const scoreScaling = evaluation.evaluators[index]?.scoreScaling || 100; // Apply score scaling and calculate weighted score const scaledScore = score * (scoreScaling / 100); @@ -533,14 +535,6 @@ const evaluationTaskProcessor = async (job: Job) => { // Report progress: dataset loaded await job.updateProgress(20); - // TODO: Handle targetCallParams population for evaluation data items - // The dataItems loaded from dataset only contain basic EvalDatasetDataSchemaType fields - // but evaluation items need EvaluationDataItemType (including targetCallParams). - // Need to: - // 1. Determine source of targetCallParams (evaluation config, dataset metadata, or default) - // 2. Transform dataItems to include targetCallParams before creating evaluation items - // 3. Consider caching strategy for targetCallParams if they are dynamic per evaluation - if (dataItems.length === 0) { throw new Error(EvaluationErrEnum.evalDatasetLoadFailed); } @@ -584,14 +578,28 @@ const evaluationTaskProcessor = async (job: Job) => { return; } - // Create evaluation items for each dataItem with all evaluators (batch structure) - const evalItems = []; + // Create evaluation items for each dataItem (batch structure) + const evalItems: Omit[] = []; for (const dataItem of dataItems) { + // Extract only the necessary fields for evaluation execution + const evaluationDataItem: EvaluationDataItemType = { + _id: dataItem._id, + userInput: dataItem.userInput, + expectedOutput: dataItem.expectedOutput, + context: dataItem.context, + // TODO: Handle targetCallParams population for evaluation data items + // The dataItems loaded from dataset only contain basic EvalDatasetDataSchemaType fields + // but evaluation items need EvaluationDataItemType (including targetCallParams). + // Need to: + // 1. Determine source of targetCallParams (evaluation config, dataset metadata, or default) + // 2. Transform dataItems to include targetCallParams before creating evaluation items + // 3. Consider caching strategy for targetCallParams if they are dynamic per evaluation + targetCallParams: undefined + }; + evalItems.push({ evalId, - dataItem, - target: evaluation.target, - evaluators: evaluation.evaluators, // All evaluators for this dataItem + dataItem: evaluationDataItem, status: EvaluationStatusEnum.queuing, retry: maxRetries }); @@ -667,8 +675,11 @@ const evaluationItemProcessor = async (job: Job) => { return; } - // Get evaluation information for AI Points check - const evaluation = await MongoEvaluation.findById(evalId, 'teamId tmbId usageId'); + // Get evaluation information for AI Points check and target/evaluators config + const evaluation = await MongoEvaluation.findById( + evalId, + 'teamId tmbId usageId target evaluators' + ); if (!evaluation) { throw new EvaluationStageError( EvaluationStageEnum.ResourceCheck, @@ -721,7 +732,7 @@ const evaluationItemProcessor = async (job: Job) => { // 1. Call evaluation target (if not already done) if (!targetOutput || !targetOutput.actualOutput) { try { - const targetInstance = await createTargetInstance(evalItem.target, { validate: false }); + const targetInstance = await createTargetInstance(evaluation.target, { validate: false }); targetOutput = await targetInstance.execute({ userInput: evalItem.dataItem.userInput, context: evalItem.dataItem.context, @@ -785,98 +796,93 @@ const evaluationItemProcessor = async (job: Job) => { } // 2. Execute evaluators (batch processing - only execute missing ones) - const completedCount = evaluatorOutputs.filter( - (output) => output?.data?.score !== undefined - ).length; - const needToExecute = evalItem.evaluators.length - completedCount; + // Ensure evaluatorOutputs array matches the length of evaluators + while (evaluatorOutputs.length < evaluation.evaluators.length) { + evaluatorOutputs.push({} as MetricResult); + } - if (needToExecute > 0) { - const errors: Array<{ evaluatorName: string; error: string }> = []; + const errors: Array<{ evaluatorName: string; error: string }> = []; - try { - // Execute only missing evaluators - for (let i = completedCount; i < evalItem.evaluators.length; i++) { - const evaluator = evalItem.evaluators[i]; + // Execute only missing evaluators + for (let i = 0; i < evaluation.evaluators.length; i++) { + const evaluator = evaluation.evaluators[i]; + const existingOutput = evaluatorOutputs[i]; - const evaluatorInstance = await createEvaluatorInstance(evaluator, { - validate: false - }); + // Skip if this evaluator already has a valid result + if (existingOutput?.data?.score !== undefined) { + continue; + } - const evaluatorOutput = await evaluatorInstance.evaluate({ - userInput: evalItem.dataItem.userInput, - expectedOutput: evalItem.dataItem.expectedOutput, - actualOutput: targetOutput.actualOutput, - context: evalItem.dataItem.context, - retrievalContext: targetOutput.retrievalContext - }); + try { + const evaluatorInstance = await createEvaluatorInstance(evaluator, { + validate: false + }); + + const evaluatorOutput = await evaluatorInstance.evaluate({ + userInput: evalItem.dataItem.userInput, + expectedOutput: evalItem.dataItem.expectedOutput, + actualOutput: targetOutput.actualOutput, + context: evalItem.dataItem.context, + retrievalContext: targetOutput.retrievalContext + }); - const inputTokens = - evaluatorOutput.usages?.reduce((sum, usage) => sum + (usage.promptTokens || 0), 0) || 0; - const outputTokens = + await createMergedEvaluationUsage({ + evalId, + teamId: evaluation.teamId, + tmbId: evaluation.tmbId, + usageId: evaluation.usageId, + totalPoints: evaluatorOutput.totalPoints || 0, + inputTokens: + evaluatorOutput.usages?.reduce((sum, usage) => sum + (usage.promptTokens || 0), 0) || 0, + outputTokens: evaluatorOutput.usages?.reduce( (sum, usage) => sum + (usage.completionTokens || 0), 0 - ) || 0; - - await createMergedEvaluationUsage({ - evalId, - teamId: evaluation.teamId, - tmbId: evaluation.tmbId, - usageId: evaluation.usageId, - totalPoints: evaluatorOutput.totalPoints || 0, - type: 'metric', - inputTokens, - outputTokens - }); - - // Record error but continue processing - if (evaluatorOutput.status !== MetricResultStatusEnum.Success || evaluatorOutput.error) { - const errorMessage = evaluatorOutput.error || 'Evaluator execution failed'; - const evaluatorName = evaluator.metric.name || `Evaluator ${i + 1}`; - errors.push({ evaluatorName, error: errorMessage }); - } + ) || 0, + type: 'metric' + }); - evaluatorOutputs.push(evaluatorOutput); + // Record error but continue processing + if (evaluatorOutput.status !== MetricResultStatusEnum.Success || evaluatorOutput.error) { + const errorMessage = evaluatorOutput.error || 'Evaluator execution failed'; + const evaluatorName = evaluator.metric.name || `Evaluator ${i + 1}`; + errors.push({ evaluatorName, error: errorMessage }); + } - // Save progress after each evaluator (checkpoint for resume) - await MongoEvalItem.updateOne( - { _id: new Types.ObjectId(evalItemId) }, - { $set: { evaluatorOutputs: evaluatorOutputs } } - ); + // Update the specific position in the array + evaluatorOutputs[i] = evaluatorOutput; - // Report progress: evaluator completed - const evaluatorProgress = 30 + (60 * (i + 1)) / evalItem.evaluators.length; - await job.updateProgress(Math.round(evaluatorProgress)); - } + // Save progress after each evaluator (checkpoint for resume) + await MongoEvalItem.updateOne( + { _id: new Types.ObjectId(evalItemId) }, + { $set: { evaluatorOutputs: evaluatorOutputs } } + ); - // After all evaluators, check if there were any errors - if (errors.length > 0) { - throw new EvaluatorAggregatedError(errors); - } + // Report progress: evaluator completed + const completedEvaluators = evaluatorOutputs.filter( + (output) => output?.data?.score !== undefined + ).length; + const evaluatorProgress = 30 + (60 * completedEvaluators) / evaluation.evaluators.length; + await job.updateProgress(Math.round(evaluatorProgress)); } catch (error) { - // If it's already an EvaluatorAggregatedError, wrap it in EvaluationStageError - if (error instanceof EvaluatorAggregatedError) { - throw new EvaluationStageError( - EvaluationStageEnum.EvaluatorExecute, - error.message, - error.retriable, - error - ); - } - - // Normalize other evaluator execution errors - const retriable = isEvaluatorExecutionRetriable(error); + // Handle individual evaluator error const errorMessage = getErrText(error) || 'Evaluator execution failed'; - - throw new EvaluationStageError( - EvaluationStageEnum.EvaluatorExecute, - errorMessage, - retriable, - error - ); + const evaluatorName = evaluator.metric.name || `Evaluator ${i + 1}`; + errors.push({ evaluatorName, error: errorMessage }); } } + // After all evaluators, check if there were any errors + if (errors.length > 0) { + const aggregatedError = new EvaluatorAggregatedError(errors); + throw new EvaluationStageError( + EvaluationStageEnum.EvaluatorExecute, + aggregatedError.message, + aggregatedError.retriable, + aggregatedError + ); + } + // 3. Calculate aggregate score for this evaluation item const aggregateScore = await calculateEvaluationItemAggregateScore(evalItemId); diff --git a/packages/service/core/evaluation/task/schema.ts b/packages/service/core/evaluation/task/schema.ts index 0276c5640bb0..78498053079e 100644 --- a/packages/service/core/evaluation/task/schema.ts +++ b/packages/service/core/evaluation/task/schema.ts @@ -216,12 +216,12 @@ export const EvaluationTaskSchema = new Schema({ } }); -// Optimized indexes for EvaluationTaskSchema -EvaluationTaskSchema.index({ teamId: 1, createTime: -1 }); // Main query: team filtering + time sorting -EvaluationTaskSchema.index({ teamId: 1, status: 1, createTime: -1 }); // Status filtering + time sorting -EvaluationTaskSchema.index({ teamId: 1, name: 1 }, { unique: true }); // Name uniqueness check -EvaluationTaskSchema.index({ tmbId: 1, createTime: -1 }); // Query by creator -EvaluationTaskSchema.index({ status: 1 }); // Status-based queue processing +// Optimized indexes for EvaluationTaskSchema based on actual query patterns +EvaluationTaskSchema.index({ _id: 1, teamId: 1 }); // Primary lookup pattern: findOne/updateOne/deleteOne by _id and teamId +EvaluationTaskSchema.index({ teamId: 1, createTime: -1 }); // Main listing query: team filtering + time sorting +EvaluationTaskSchema.index({ teamId: 1, tmbId: 1, createTime: -1 }); // Permission filtering: team + creator + time sorting +EvaluationTaskSchema.index({ teamId: 1, name: 1 }, { unique: true }); // Name uniqueness check within team +EvaluationTaskSchema.index({ status: 1 }); // Queue processing and status-based operations // Atomic evaluation item: one dataItem + one target + one evaluator export const EvaluationItemSchema = new Schema({ @@ -235,8 +235,6 @@ export const EvaluationItemSchema = new Schema({ type: Object, required: true }, - target: EvaluationTargetSchema, - evaluators: [EvaluationEvaluatorSchema], // Multiple evaluator configurations // Execution results targetOutput: { type: Schema.Types.Mixed, @@ -264,21 +262,18 @@ export const EvaluationItemSchema = new Schema({ } }); -// Optimized indexes for EvaluationItemSchema -EvaluationItemSchema.index({ evalId: 1, createTime: -1 }); // Main query: eval filtering + time sorting -EvaluationItemSchema.index({ evalId: 1, status: 1, createTime: -1 }); // Status filtering + time sorting -EvaluationItemSchema.index({ status: 1, retry: 1 }); // Queue processing with retry logic -EvaluationItemSchema.index({ evalId: 1, 'dataItem._id': 1 }); // DataItem aggregation queries -EvaluationItemSchema.index({ evalId: 1, status: 1, retry: 1 }); // Retry operations optimization -// Chat linking index moved to use targetOutput fields if needed -// EvaluationItemSchema.index({ 'targetOutput.chatId': 1, 'targetOutput.aiChatItemDataId': 1 }); +// Optimized indexes for EvaluationItemSchema based on actual query patterns +EvaluationItemSchema.index({ evalId: 1, status: 1 }); // Status-specific queries within evaluation (aggregation, filtering) +EvaluationItemSchema.index({ evalId: 1, createTime: -1 }); // Listing items within evaluation with time sorting +EvaluationItemSchema.index({ evalId: 1, status: 1, createTime: -1 }); // Status filtering + time sorting for pagination +EvaluationItemSchema.index({ status: 1, retry: 1 }); // Queue processing: pending items with retry logic -// Optimized text search for content filtering (removed evalId for flexibility) +// Content search index for filtering by text content (used in listEvaluationItems) EvaluationItemSchema.index({ 'dataItem.userInput': 'text', 'dataItem.expectedOutput': 'text', 'targetOutput.actualOutput': 'text' -}); // Comprehensive text search across all content fields +}); // Text search across user inputs, expected outputs, and actual outputs export const MongoEvaluation = getMongoModel( EvaluationCollectionName, From 58f45f1d6951e7756bfcc929790fd4a7f49efdb7 Mon Sep 17 00:00:00 2001 From: lavine77 <916064092@qq.com> Date: Wed, 17 Sep 2025 18:32:09 +0800 Subject: [PATCH 24/84] feat: add detailed task view components with i18n support - Add BasicInfo component for evaluation task details display - Add GradientBorderBox component for UI enhancement - Implement NavBar component with data classification and action buttons - Create ScoreBar component for dimension scoring display - Develop ScoreDashboard component for overall score visualization - Update i18n files with new interface translations - Optimize evaluation dimension name translation consistency --- .../web/i18n/en/dashboard_evaluation.json | 14 +- .../web/i18n/zh-CN/dashboard_evaluation.json | 26 ++- .../i18n/zh-Hant/dashboard_evaluation.json | 26 ++- .../evaluation/task/detail/BasicInfo.tsx | 141 +++++++++++++++ .../task/detail/GradientBorderBox.tsx | 39 ++++ .../evaluation/task/detail/NavBar.tsx | 167 +++++++++++++++++ .../evaluation/task/detail/ScoreBar.tsx | 79 ++++++++ .../evaluation/task/detail/ScoreDashboard.tsx | 168 ++++++++++++++++++ .../evaluation/context/taskPageContext.tsx | 72 ++++++++ 9 files changed, 717 insertions(+), 15 deletions(-) create mode 100644 projects/app/src/pageComponents/dashboard/evaluation/task/detail/BasicInfo.tsx create mode 100644 projects/app/src/pageComponents/dashboard/evaluation/task/detail/GradientBorderBox.tsx create mode 100644 projects/app/src/pageComponents/dashboard/evaluation/task/detail/NavBar.tsx create mode 100644 projects/app/src/pageComponents/dashboard/evaluation/task/detail/ScoreBar.tsx create mode 100644 projects/app/src/pageComponents/dashboard/evaluation/task/detail/ScoreDashboard.tsx create mode 100644 projects/app/src/web/core/evaluation/context/taskPageContext.tsx diff --git a/packages/web/i18n/en/dashboard_evaluation.json b/packages/web/i18n/en/dashboard_evaluation.json index fe619e346dd1..dbde9ecdad36 100644 --- a/packages/web/i18n/en/dashboard_evaluation.json +++ b/packages/web/i18n/en/dashboard_evaluation.json @@ -307,5 +307,17 @@ "not_join_evaluation_dataset": "不加入评测数据集", "create_new_dataset_btn_text": "新建数据集", "please_select_evaluation_dataset": "请选择评测数据集", - "join_knowledge_base": "加入知识库" + "join_knowledge_base": "加入知识库", + "all_data_with_count": "全部数据({{num}})", + "question_data_with_count": "问题数据({{num}})", + "error_data_with_count": "异常数据({{num}})", + "export_data": "导出", + "retry_action": "重试", + "basic_info": "基本信息", + "application": "应用", + "version": "版本", + "evaluation_dataset_name": "评测数据集", + "start_time": "开始时间", + "end_time": "结束时间", + "executor_name": "执行人" } diff --git a/packages/web/i18n/zh-CN/dashboard_evaluation.json b/packages/web/i18n/zh-CN/dashboard_evaluation.json index 9e8785cfa192..d7dcd06c42bb 100644 --- a/packages/web/i18n/zh-CN/dashboard_evaluation.json +++ b/packages/web/i18n/zh-CN/dashboard_evaluation.json @@ -294,21 +294,33 @@ "create_new_dimension": "新建维度", "retry_success": "重试成功", "data_generation_error_count": "{{count}}条数据生成异常", - "builtin_answer_correctness_name": "答案正确性", + "builtin_answer_correctness_name": "回答准确度", "builtin_answer_correctness_desc": "衡量生成的回答与参考答案在事实上的一致性,评估其是否准确无误。", - "builtin_answer_similarity_name": "答案相似度", + "builtin_answer_similarity_name": "语义相似度", "builtin_answer_similarity_desc": "评估生成回答与参考答案在语义上的匹配程度,判断其是否表达了相同的核心信息。", - "builtin_answer_relevancy_name": "答案相关性", + "builtin_answer_relevancy_name": "回答相关度", "builtin_answer_relevancy_desc": "衡量生成回答与提问之间的契合度,判断回答是否紧扣问题。", - "builtin_faithfulness_name": "忠诚度", + "builtin_faithfulness_name": "回答忠诚度", "builtin_faithfulness_desc": "评估生成回答是否忠实于提供的上下文信息,判断是否存在虚构或不实内容。", - "builtin_context_recall_name": "上下文召回", + "builtin_context_recall_name": "检索匹配度", "builtin_context_recall_desc": "衡量检索系统是否能够获取回答所需的所有关键信息,评估其检索的完整性。", - "builtin_context_precision_name": "上下文精度", + "builtin_context_precision_name": "检索精确度", "builtin_context_precision_desc": "衡量检索内容中是否优先返回高价值信息,反映排序质量与信息密度。", "join_evaluation_dataset": "加入评测数据集", "not_join_evaluation_dataset": "不加入评测数据集", "create_new_dataset_btn_text": "新建数据集", "please_select_evaluation_dataset": "请选择评测数据集", - "join_knowledge_base": "加入知识库" + "join_knowledge_base": "加入知识库", + "all_data_with_count": "全部数据({{num}})", + "question_data_with_count": "问题数据({{num}})", + "error_data_with_count": "异常数据({{num}})", + "export_data": "导出", + "retry_action": "重试", + "basic_info": "基本信息", + "application": "应用", + "version": "版本", + "evaluation_dataset_name": "评测数据集", + "start_time": "开始时间", + "end_time": "结束时间", + "executor_name": "执行人" } diff --git a/packages/web/i18n/zh-Hant/dashboard_evaluation.json b/packages/web/i18n/zh-Hant/dashboard_evaluation.json index 6f47bcfaa07a..285392f760e1 100644 --- a/packages/web/i18n/zh-Hant/dashboard_evaluation.json +++ b/packages/web/i18n/zh-Hant/dashboard_evaluation.json @@ -287,21 +287,33 @@ "create_new_dimension": "新建維度", "retry_success": "重試成功", "data_generation_error_count": "{{count}}條數據生成異常", - "builtin_answer_correctness_name": "答案正確性", + "builtin_answer_correctness_name": "回答準確度", "builtin_answer_correctness_desc": "衡量生成的回答與參考答案在事實上的一致性,評估其是否準確無誤。", - "builtin_answer_similarity_name": "答案相似度", + "builtin_answer_similarity_name": "語義相似度", "builtin_answer_similarity_desc": "評估生成回答與參考答案在語義上的匹配程度,判斷其是否表達了相同的核心信息。", - "builtin_answer_relevancy_name": "答案相關性", + "builtin_answer_relevancy_name": "回答相關度", "builtin_answer_relevancy_desc": "衡量生成回答與提問之間的契合度,判斷回答是否緊扣問題。", - "builtin_faithfulness_name": "忠誠度", + "builtin_faithfulness_name": "回答忠誠度", "builtin_faithfulness_desc": "評估生成回答是否忠實於提供的上下文信息,判斷是否存在虛構或不實內容。", - "builtin_context_recall_name": "上下文召回", + "builtin_context_recall_name": "檢索匹配度", "builtin_context_recall_desc": "衡量檢索系統是否能夠獲取回答所需的所有關鍵信息,評估其檢索的完整性。", - "builtin_context_precision_name": "上下文精度", + "builtin_context_precision_name": "檢索精確度", "builtin_context_precision_desc": "衡量檢索內容中是否優先返回高價值信息,反映排序質量與信息密度。", "join_evaluation_dataset": "加入評測數據集", "not_join_evaluation_dataset": "不加入評測數據集", "create_new_dataset_btn_text": "新建數據集", "please_select_evaluation_dataset": "請選擇評測數據集", - "join_knowledge_base": "加入知識庫" + "join_knowledge_base": "加入知識庫", + "all_data_with_count": "全部數據({{num}})", + "question_data_with_count": "問題數據({{num}})", + "error_data_with_count": "異常數據({{num}})", + "export_data": "導出", + "retry_action": "重試", + "basic_info": "基本資訊", + "application": "應用", + "version": "版本", + "evaluation_dataset_name": "評測數據集", + "start_time": "開始時間", + "end_time": "結束時間", + "executor_name": "執行人" } diff --git a/projects/app/src/pageComponents/dashboard/evaluation/task/detail/BasicInfo.tsx b/projects/app/src/pageComponents/dashboard/evaluation/task/detail/BasicInfo.tsx new file mode 100644 index 000000000000..17d74bf8f5ae --- /dev/null +++ b/projects/app/src/pageComponents/dashboard/evaluation/task/detail/BasicInfo.tsx @@ -0,0 +1,141 @@ +import React, { useState, useMemo } from 'react'; +import { Box, Flex, Collapse, HStack, IconButton } from '@chakra-ui/react'; +import { useTranslation } from 'next-i18next'; +import Avatar from '@fastgpt/web/components/common/Avatar'; +import UserBox from '@fastgpt/web/components/common/UserBox'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import format from 'date-fns/format'; +import type { EvaluationDisplayType } from '@fastgpt/global/core/evaluation/type'; + +interface BasicInfoProps { + evaluationDetail: EvaluationDisplayType | null; +} + +const BasicInfo: React.FC = ({ evaluationDetail }) => { + const { t } = useTranslation(); + const [isExpanded, setIsExpanded] = useState(false); + + const toggleExpanded = () => { + setIsExpanded(!isExpanded); + }; + + // 格式化时间的辅助函数 + const formatTime = useMemo(() => { + return (time: Date | string | undefined) => { + if (!time) return '-'; + try { + if (typeof time === 'string' && time.includes('-') && !time.includes('T')) { + return time; + } + return format(new Date(time), 'yyyy-MM-dd HH:mm:ss'); + } catch (error) { + return '-'; + } + }; + }, []); + + // 安全获取值的辅助函数 + const safeValue = (value: string | undefined | null) => { + return value && value.trim() ? value : '-'; + }; + + return ( + + + + {t('dashboard_evaluation:basic_info')} + + + } + /> + + + {/* 可折叠的内容区域 */} + + + + + {t('dashboard_evaluation:application')} + + + + {safeValue(evaluationDetail?.target?.config?.appName)} + + + + + + {t('dashboard_evaluation:version')} + + + {safeValue(evaluationDetail?.target?.config?.versionName)} + + + + + + {t('dashboard_evaluation:evaluation_dataset_name')} + + {safeValue(evaluationDetail?.evalDatasetCollectionName)} + + + + + {t('dashboard_evaluation:start_time')} + + {formatTime(evaluationDetail?.createTime)} + + + {evaluationDetail?.finishTime && ( + + + {t('dashboard_evaluation:end_time')} + + {formatTime(evaluationDetail.finishTime)} + + )} + + + + {t('dashboard_evaluation:executor_name')} + + {evaluationDetail?.sourceMember ? ( + + ) : ( + - + )} + + + + + ); +}; + +export default BasicInfo; diff --git a/projects/app/src/pageComponents/dashboard/evaluation/task/detail/GradientBorderBox.tsx b/projects/app/src/pageComponents/dashboard/evaluation/task/detail/GradientBorderBox.tsx new file mode 100644 index 000000000000..fc14d2d5715a --- /dev/null +++ b/projects/app/src/pageComponents/dashboard/evaluation/task/detail/GradientBorderBox.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import type { BoxProps } from '@chakra-ui/react'; +import { Box } from '@chakra-ui/react'; + +interface GradientBorderBoxProps extends BoxProps { + children: React.ReactNode; +} + +const GradientBorderBox: React.FC = ({ children, ...boxProps }) => { + return ( + + {children} + + ); +}; + +export default GradientBorderBox; diff --git a/projects/app/src/pageComponents/dashboard/evaluation/task/detail/NavBar.tsx b/projects/app/src/pageComponents/dashboard/evaluation/task/detail/NavBar.tsx new file mode 100644 index 000000000000..5071cff7e612 --- /dev/null +++ b/projects/app/src/pageComponents/dashboard/evaluation/task/detail/NavBar.tsx @@ -0,0 +1,167 @@ +import React, { useCallback, useMemo } from 'react'; +import { useTranslation } from 'next-i18next'; +import { Box, Flex, useTheme, Button, HStack } from '@chakra-ui/react'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import { useRouter } from 'next/router'; +import { useContextSelector } from 'use-context-selector'; +import { TaskPageContext } from '@/web/core/evaluation/context/taskPageContext'; +import LightRowTabs from '@fastgpt/web/components/common/Tabs/LightRowTabs'; +import FolderPath from '@/components/common/folder/Path'; +import type { EvaluationStatsResponse } from '@fastgpt/global/core/evaluation/api'; + +export enum TabEnum { + allData = 'allData', + questionData = 'questionData', + errorData = 'errorData' +} + +const NavBar = ({ + currentTab, + statsData, + onExport, + onRetryFailed +}: { + currentTab: TabEnum; + statsData: EvaluationStatsResponse | null; + onExport?: () => void; + onRetryFailed?: () => void; +}) => { + const theme = useTheme(); + const { t } = useTranslation(); + const router = useRouter(); + const query = router.query; + const { taskDetail } = useContextSelector(TaskPageContext, (v) => v); + + const tabList = useMemo(() => { + const tabConfigs = [ + { + labelKey: 'dashboard_evaluation:all_data_with_count', + value: TabEnum.allData, + count: statsData?.total || 0, + shouldShow: true + }, + { + labelKey: 'dashboard_evaluation:question_data_with_count', + value: TabEnum.questionData, + count: statsData?.queuing || 0, + shouldShow: (statsData?.queuing || 0) > 0 + }, + { + labelKey: 'dashboard_evaluation:error_data_with_count', + value: TabEnum.errorData, + count: statsData?.error || 0, + shouldShow: (statsData?.error || 0) > 0 + } + ]; + + return tabConfigs + .filter((config) => config.shouldShow) + .map((config) => ({ + label: t(config.labelKey, { num: config.count }), + value: config.value + })); + }, [statsData, t]); + + // 获取有效的当前选中标签,如果匹配不到则默认选中全部数据 + const validCurrentTab = useMemo(() => { + const validTabs = tabList.map((tab) => tab.value); + return validTabs.includes(currentTab) ? currentTab : TabEnum.allData; + }, [currentTab, tabList]); + + // 路径导航数据 + const paths = useMemo( + () => [{ parentId: 'current', parentName: taskDetail?.name || '-' }], + [taskDetail?.name] + ); + + const setCurrentTab = useCallback( + (tab: TabEnum) => { + router.replace({ + query: { + taskId: query.taskId, + currentTab: tab + } + }); + }, + [query, router] + ); + + const handleExport = useCallback(() => { + onExport?.(); + }, [onExport]); + + const handleRetry = useCallback(() => { + onRetryFailed?.(); + }, [onRetryFailed]); + + const handleNavigateBack = useCallback(() => { + router.push('/dashboard/evaluation?evaluationTab=tasks'); + }, [router]); + + return ( + + + + + + + + px={4} + py={1} + flex={1} + mx={'auto'} + w={'100%'} + list={tabList} + value={validCurrentTab} + activeColor="primary.700" + onChange={setCurrentTab} + inlineStyles={{ + fontSize: '1rem', + lineHeight: '1.5rem', + fontWeight: 500, + border: 'none', + _hover: { + bg: 'myGray.05' + }, + borderRadius: '6px' + }} + /> + + + + + {validCurrentTab === TabEnum.errorData && ( + + )} + + + ); +}; + +export default NavBar; diff --git a/projects/app/src/pageComponents/dashboard/evaluation/task/detail/ScoreBar.tsx b/projects/app/src/pageComponents/dashboard/evaluation/task/detail/ScoreBar.tsx new file mode 100644 index 000000000000..9c21a15c1af3 --- /dev/null +++ b/projects/app/src/pageComponents/dashboard/evaluation/task/detail/ScoreBar.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { Box, Flex } from '@chakra-ui/react'; + +interface ScoreBarProps { + dimensionName: string; + threshold: number; + actualScore: number; + maxScore?: number; +} + +const ScoreBar: React.FC = ({ + dimensionName, + threshold, + actualScore, + maxScore = 100 +}) => { + // Check if actual score meets threshold + const isAboveThreshold = actualScore >= threshold; + + // Set color based on threshold + const scoreColor = isAboveThreshold ? 'blue.600' : 'yellow.400'; + + return ( + + {/* Dimension name */} + + {dimensionName} + + + {/* Score bar */} + + {/* Threshold line */} + + + {/* Actual score line */} + + + + {/* Score number */} + + {actualScore} + + + ); +}; + +export default ScoreBar; diff --git a/projects/app/src/pageComponents/dashboard/evaluation/task/detail/ScoreDashboard.tsx b/projects/app/src/pageComponents/dashboard/evaluation/task/detail/ScoreDashboard.tsx new file mode 100644 index 000000000000..be5d031063b7 --- /dev/null +++ b/projects/app/src/pageComponents/dashboard/evaluation/task/detail/ScoreDashboard.tsx @@ -0,0 +1,168 @@ +import React from 'react'; +import { Box, Flex } from '@chakra-ui/react'; + +interface ScoreDashboardProps { + threshold: number; + actualScore: number; + maxScore?: number; + size?: number; +} + +const ScoreDashboard: React.FC = ({ + threshold, + actualScore, + maxScore = 100, + size = 140 +}) => { + // 判断实际分数是否达到阈值 + const isAboveThreshold = actualScore >= threshold; + + const scoreColor = isAboveThreshold ? '#3370FF' : '#FDB022'; + + // 计算角度(半圆形,180度),映射到上半圆 + // 0分对应180度(9点钟),100分对应0度(3点钟) + const thresholdAngle = 180 - Math.min(Math.max((threshold / maxScore) * 180, 0), 180); + const actualScoreAngle = 180 - Math.min(Math.max((actualScore / maxScore) * 180, 0), 180); + + // 圆环参数 + const centerX = size / 2; + const centerY = size / 2; + const outerRadius = size / 2 - 8; + const outerInnerRadius = outerRadius - 24; // 外环宽度为24px + const innerOuterRadius = outerInnerRadius - 8; // 内外环间隔8px + const innerInnerRadius = innerOuterRadius - 12; // 内环宽度为12px + + // 创建上半圆弧路径(从9点钟到3点钟) + const createSemiCirclePath = (outerR: number, innerR: number) => { + return `M ${centerX - outerR} ${centerY} + A ${outerR} ${outerR} 0 0 1 ${centerX + outerR} ${centerY} + L ${centerX + innerR} ${centerY} + A ${innerR} ${innerR} 0 0 0 ${centerX - innerR} ${centerY} Z`; + }; + + // 创建弧形路径 + const createArcPath = (startAngle: number, endAngle: number, outerR: number, innerR: number) => { + // 确保角度在有效范围内 + const clampedStartAngle = Math.min(Math.max(startAngle, 0), 180); + const clampedEndAngle = Math.min(Math.max(endAngle, 0), 180); + + if (clampedStartAngle >= clampedEndAngle) return ''; + + // 转换为弧度,0度对应3点钟方向 + const startRad = clampedStartAngle * (Math.PI / 180); + const endRad = clampedEndAngle * (Math.PI / 180); + + const x1 = centerX + outerR * Math.cos(startRad); + const y1 = centerY - outerR * Math.sin(startRad); // 注意这里是减号,因为SVG的Y轴向下 + const x2 = centerX + outerR * Math.cos(endRad); + const y2 = centerY - outerR * Math.sin(endRad); + + const x3 = centerX + innerR * Math.cos(endRad); + const y3 = centerY - innerR * Math.sin(endRad); + const x4 = centerX + innerR * Math.cos(startRad); + const y4 = centerY - innerR * Math.sin(startRad); + + const largeArcFlag = clampedEndAngle - clampedStartAngle > 180 ? 1 : 0; + + return `M ${x1} ${y1} + A ${outerR} ${outerR} 0 ${largeArcFlag} 0 ${x2} ${y2} + L ${x3} ${y3} + A ${innerR} ${innerR} 0 ${largeArcFlag} 1 ${x4} ${y4} Z`; + }; + + // 创建等边三角形指针路径 + const createTrianglePointerPath = (angle: number) => { + const clampedAngle = Math.min(Math.max(angle, 0), 180); + const angleRad = clampedAngle * (Math.PI / 180); + + const sideLength = 10; + + // 三角形顶点距离黄色和蓝色填充2px + // 黄色和蓝色填充的内边缘位置是 outerRadius - 18 + const tipRadius = outerRadius - 18 - 2; + const tipX = centerX + tipRadius * Math.cos(angleRad); + const tipY = centerY - tipRadius * Math.sin(angleRad); + + // 底边中点位置(在内层圆环中间) + const baseRadius = (innerOuterRadius + innerInnerRadius) / 2; + + // 判断是否需要限制底边位置 + const score7Angle = 180 - (7 / maxScore) * 180; // 分数7对应的角度 + const score93Angle = 180 - (93 / maxScore) * 180; // 分数93对应的角度 + + let baseCenterX, baseCenterY; + + if (actualScore < 7) { + // 分数小于7时,底边固定在分数7的位置 + const fixedAngleRad = score7Angle * (Math.PI / 180); + baseCenterX = centerX + baseRadius * Math.cos(fixedAngleRad); + baseCenterY = centerY - baseRadius * Math.sin(fixedAngleRad); + } else if (actualScore > 93) { + // 分数大于93时,底边固定在分数93的位置 + const fixedAngleRad = score93Angle * (Math.PI / 180); + baseCenterX = centerX + baseRadius * Math.cos(fixedAngleRad); + baseCenterY = centerY - baseRadius * Math.sin(fixedAngleRad); + } else { + // 正常情况下,底边跟随实际分数角度 + baseCenterX = centerX + baseRadius * Math.cos(angleRad); + baseCenterY = centerY - baseRadius * Math.sin(angleRad); + } + + const halfSide = sideLength / 2; + const baseAngleRad = Math.atan2(centerY - baseCenterY, baseCenterX - centerX); + const leftX = baseCenterX + halfSide * Math.cos(baseAngleRad + Math.PI / 2); + const leftY = baseCenterY + halfSide * Math.sin(baseAngleRad + Math.PI / 2); + const rightX = baseCenterX + halfSide * Math.cos(baseAngleRad - Math.PI / 2); + const rightY = baseCenterY + halfSide * Math.sin(baseAngleRad - Math.PI / 2); + + return `M ${tipX} ${tipY} L ${leftX} ${leftY} L ${rightX} ${rightY} Z`; + }; + + return ( + + + + {/* 外层背景半圆 */} + + + {/* 外层黄色填充 */} + {thresholdAngle < 179 && ( + + )} + + {/* 外层蓝色填充 */} + {thresholdAngle > 1 && ( + + )} + + {/* 内层圆环背景 */} + + + {/* 指针 */} + + + + {/* 分数显示 */} + + {actualScore} + + + + ); +}; + +export default ScoreDashboard; diff --git a/projects/app/src/web/core/evaluation/context/taskPageContext.tsx b/projects/app/src/web/core/evaluation/context/taskPageContext.tsx new file mode 100644 index 000000000000..0114b39c6872 --- /dev/null +++ b/projects/app/src/web/core/evaluation/context/taskPageContext.tsx @@ -0,0 +1,72 @@ +import { type ReactNode, useState } from 'react'; +import { useTranslation } from 'next-i18next'; +import { createContext } from 'use-context-selector'; +import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; + +// 临时类型定义,后续需要根据实际API调整 +type EvaluationTaskType = { + _id: string; + name: string; + avatar?: string; + createTime: string; + updateTime: string; + // 其他任务相关字段 +}; + +type TaskPageContextType = { + taskId: string; + taskDetail: EvaluationTaskType; + loadTaskDetail: (id: string) => Promise; +}; + +const defaultTaskDetail: EvaluationTaskType = { + _id: '', + name: '', + createTime: '', + updateTime: '' +}; + +export const TaskPageContext = createContext({ + taskId: '', + taskDetail: defaultTaskDetail, + loadTaskDetail: function (id: string): Promise { + throw new Error('Function not implemented.'); + } +}); + +export const TaskPageContextProvider = ({ + children, + taskId +}: { + children: ReactNode; + taskId: string; +}) => { + const { t } = useTranslation(); + + // task detail + const [taskDetail, setTaskDetail] = useState(defaultTaskDetail); + const loadTaskDetail = async (id: string) => { + // TODO: 实现获取任务详情的API调用 + // const data = await getEvaluationTaskById(id); + // setTaskDetail(data); + // return data; + + // 临时返回模拟数据 + const mockData: EvaluationTaskType = { + _id: id, + name: '任务1', + createTime: new Date().toISOString(), + updateTime: new Date().toISOString() + }; + setTaskDetail(mockData); + return mockData; + }; + + const contextValue: TaskPageContextType = { + taskId, + taskDetail, + loadTaskDetail + }; + + return {children}; +}; From 2d265d2987a8f7ad5ff527986418d13a940b85b4 Mon Sep 17 00:00:00 2001 From: lavine77 <916064092@qq.com> Date: Wed, 17 Sep 2025 18:24:50 +0800 Subject: [PATCH 25/84] feat: optimize evaluation task creation and dimension management - Auto-recommend built-in evaluation dimensions based on node types - Add "Answer Accuracy" dimension by default for chat nodes - Add "Faithfulness" and "Context Recall" dimensions by default for retrieval nodes - Auto-fill default evaluation and embedding models - Add function to get app's recently used datasets - Update refresh button icon to "common/confirm/restoreTip" - Optimize refresh button icon in dimension management interface --- .../dashboard/evaluation/task/CreateModal.tsx | 127 ++++++++++++++---- .../evaluation/task/ManageDimension.tsx | 2 +- 2 files changed, 100 insertions(+), 29 deletions(-) diff --git a/projects/app/src/pageComponents/dashboard/evaluation/task/CreateModal.tsx b/projects/app/src/pageComponents/dashboard/evaluation/task/CreateModal.tsx index 1379c3851da3..4e8a0a9e65f1 100644 --- a/projects/app/src/pageComponents/dashboard/evaluation/task/CreateModal.tsx +++ b/projects/app/src/pageComponents/dashboard/evaluation/task/CreateModal.tsx @@ -28,7 +28,7 @@ import AppSelect from '@/components/Select/AppSelect'; import { getAppVersionList } from '@/web/core/app/api/version'; import { formatTime2YMDHMS } from '@fastgpt/global/common/string/time'; import { getEvaluationDatasetList } from '@/web/core/evaluation/dataset'; -import { postCreateEvaluation } from '@/web/core/evaluation/task'; +import { getEvaluationList, postCreateEvaluation } from '@/web/core/evaluation/task'; import type { CreateEvaluationRequest } from '@fastgpt/global/core/evaluation/api'; import type { EvalTarget, EvaluatorSchema } from '@fastgpt/global/core/evaluation/type'; import type { EvalMetricSchemaType } from '@fastgpt/global/core/evaluation/metric/type'; @@ -41,6 +41,11 @@ import IntelligentGeneration from '@/pageComponents/dashboard/evaluation/dataset import { getAppDetailById } from '@/web/core/app/api'; import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; +import { getBuiltinDimensionInfo } from '@/web/core/evaluation/utils'; +import { + getWebDefaultEmbeddingModel, + getWebDefaultEvaluationModel +} from '@/web/common/system/utils'; // 表单数据类型定义 export interface TaskFormData { @@ -112,33 +117,89 @@ const CreateModal = ({ isOpen, onClose, onSubmit }: CreateModalProps) => { // 获取推荐维度的函数 const getRecommendedDimensions = useCallback( (hasDatasetSearch: boolean, hasChatNode: boolean): Dimension[] => { - // TODO: 根据节点类型返回具体的推荐维度列表 - // 这里需要根据实际的维度数据结构来定义推荐的维度 - if (hasDatasetSearch && hasChatNode) { - // TODO: 返回包含知识库搜索和AI对话的3个推荐维度 - return [ - // 示例结构,需要根据实际维度定义来替换 - // { id: 'dimension1', name: '相关性', type: 'builtin', ... }, - // { id: 'dimension2', name: '准确性', type: 'builtin', ... }, - // { id: 'dimension3', name: '完整性', type: 'builtin', ... } - ]; - } else if (hasChatNode) { - // TODO: 返回AI对话的1个推荐维度 - return [ - // 示例结构,需要根据实际维度定义来替换 - // { id: 'dimension1', name: '回答质量', type: 'builtin', ... } - ]; - } else if (hasDatasetSearch) { - // TODO: 返回知识库搜索的2个推荐维度 - return [ - // 示例结构,需要根据实际维度定义来替换 - // { id: 'dimension1', name: '检索相关性', type: 'builtin', ... }, - // { id: 'dimension2', name: '检索准确性', type: 'builtin', ... } - ]; + const defaultEmbeddingModel = getWebDefaultEmbeddingModel(embeddingModelList)?.model || ''; + const defaultEvaluationModel = + getWebDefaultEvaluationModel(llmModelList.filter((item) => item.useInEvaluation))?.model || + ''; + + const recommendedDimensions: Dimension[] = []; + + // 生成节点指标:answer_correctness(默认选中) + if (hasChatNode) { + const answerCorrectnessInfo = getBuiltinDimensionInfo('builtin_answer_correctness'); + recommendedDimensions.push({ + id: 'builtin_answer_correctness', + name: answerCorrectnessInfo + ? t(answerCorrectnessInfo.name) + : t('dashboard_evaluation:builtin_answer_correctness_name'), + type: 'builtin', + description: answerCorrectnessInfo + ? t(answerCorrectnessInfo.description) + : t('dashboard_evaluation:builtin_answer_correctness_desc'), + evaluationModel: defaultEvaluationModel, + indexModel: defaultEmbeddingModel, + llmRequired: true, + embeddingRequired: true, + isSelected: true + }); + } + + // 检索节点指标:faithfulness(默认选中),context_recall(默认选中) + if (hasDatasetSearch) { + const faithfulnessInfo = getBuiltinDimensionInfo('builtin_faithfulness'); + recommendedDimensions.push({ + id: 'builtin_faithfulness', + name: faithfulnessInfo + ? t(faithfulnessInfo.name) + : t('dashboard_evaluation:builtin_faithfulness_name'), + type: 'builtin', + description: faithfulnessInfo + ? t(faithfulnessInfo.description) + : t('dashboard_evaluation:builtin_faithfulness_desc'), + evaluationModel: defaultEvaluationModel, + indexModel: '', + llmRequired: true, + embeddingRequired: false, + isSelected: true + }); + + const contextRecallInfo = getBuiltinDimensionInfo('builtin_context_recall'); + recommendedDimensions.push({ + id: 'builtin_context_recall', + name: contextRecallInfo + ? t(contextRecallInfo.name) + : t('dashboard_evaluation:builtin_context_recall_name'), + type: 'builtin', + description: contextRecallInfo + ? t(contextRecallInfo.description) + : t('dashboard_evaluation:builtin_context_recall_desc'), + evaluationModel: defaultEvaluationModel, + indexModel: '', + llmRequired: true, + embeddingRequired: false, + isSelected: true + }); } - return []; + + return recommendedDimensions; }, - [] + [embeddingModelList, llmModelList, t] + ); + + // 获取应用最近使用的数据集 + const { runAsync: getLastUsedDataset } = useRequest2( + async (appId: string) => { + if (!appId) return null; + const result = await getEvaluationList({ + pageNum: 1, + pageSize: 1, + appId: appId + }); + return result.list.length > 0 ? result.list[0] : null; + }, + { + manual: true + } ); // 获取应用详情并分析节点类型 @@ -149,7 +210,7 @@ const CreateModal = ({ isOpen, onClose, onSubmit }: CreateModalProps) => { }, { manual: true, - onSuccess: (appDetail) => { + onSuccess: async (appDetail) => { if (!appDetail?.modules) { setRecommendedDimensionText(''); // 清空推荐维度 @@ -185,6 +246,16 @@ const CreateModal = ({ isOpen, onClose, onSubmit }: CreateModalProps) => { setRecommendedDimensionText(''); setShouldAutoExpand(true); } + + // 获取并设置该应用最近使用的数据集 + try { + const lastEvaluation = await getLastUsedDataset(appDetail._id); + if (lastEvaluation?.evalDatasetCollectionId) { + setValue('evalDatasetCollectionId', lastEvaluation.evalDatasetCollectionId); + } + } catch (error) { + console.error('Failed to get last used dataset:', error); + } } } ); @@ -490,7 +561,7 @@ const CreateModal = ({ isOpen, onClose, onSubmit }: CreateModalProps) => { ]} /> diff --git a/projects/app/src/pageComponents/dashboard/evaluation/task/ManageDimension.tsx b/projects/app/src/pageComponents/dashboard/evaluation/task/ManageDimension.tsx index dd9ca82d849c..6e957e1ad495 100644 --- a/projects/app/src/pageComponents/dashboard/evaluation/task/ManageDimension.tsx +++ b/projects/app/src/pageComponents/dashboard/evaluation/task/ManageDimension.tsx @@ -399,7 +399,7 @@ const ManageDimension = ({ {t('dashboard_evaluation:create_new_dimension')} From 09f546f004898e81905d7db35687f2b6e2f25e7d Mon Sep 17 00:00:00 2001 From: lyx Date: Thu, 18 Sep 2025 11:16:29 +0800 Subject: [PATCH 26/84] feat(file): add auto file size filtering in file selector - Add `autoFilterOverSize` prop to FileSelector component to enable automatic file size filtering - Implement file size validation in useSelectFile hook with warning toast for oversized files - Apply auto size filtering to evaluation dataset file import interface - Format and clean up styling in FileSelector component - Add file size formatting utility for displaying size limits in warnings --- .../dataset/detail/components/FileSelector.tsx | 9 ++++++++- .../dashboard/evaluation/dataset/fileImport.tsx | 1 + .../src/web/common/file/hooks/useSelectFile.tsx | 17 +++++++++++++++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/projects/app/src/pageComponents/dataset/detail/components/FileSelector.tsx b/projects/app/src/pageComponents/dataset/detail/components/FileSelector.tsx index c5db5c1dc615..d56257fccb94 100644 --- a/projects/app/src/pageComponents/dataset/detail/components/FileSelector.tsx +++ b/projects/app/src/pageComponents/dataset/detail/components/FileSelector.tsx @@ -23,6 +23,7 @@ const FileSelector = ({ maxCount = 1000, maxSize, FileTypeNode, + autoFilterOverSize, ...props }: { fileType: string; @@ -31,6 +32,7 @@ const FileSelector = ({ maxCount?: number; maxSize?: string; FileTypeNode?: React.ReactNode; + autoFilterOverSize?: boolean; } & FlexProps) => { const { t } = useTranslation(); @@ -46,7 +48,12 @@ const FileSelector = ({ const { File, onOpen } = useSelectFile({ fileType, multiple: formatMaxCount > 1, - maxCount: formatMaxCount + maxCount: formatMaxCount, + ...(autoFilterOverSize + ? { + maxSize: systemMaxSize + } + : {}) }); const [isDragging, setIsDragging] = useState(false); const isMaxSelected = useMemo( diff --git a/projects/app/src/pages/dashboard/evaluation/dataset/fileImport.tsx b/projects/app/src/pages/dashboard/evaluation/dataset/fileImport.tsx index 2a7b7aa6655e..ed7f7fad955b 100644 --- a/projects/app/src/pages/dashboard/evaluation/dataset/fileImport.tsx +++ b/projects/app/src/pages/dashboard/evaluation/dataset/fileImport.tsx @@ -235,6 +235,7 @@ const FileImport = () => { /> { const { t } = useTranslation(); - const { fileType = '*', multiple = false, maxCount = 10 } = props || {}; + const { fileType = '*', multiple = false, maxCount = 10, maxSize } = props || {}; const { toast } = useToast(); const SelectFileDom = useRef(null); const openSign = useRef(); @@ -38,7 +40,18 @@ export const useSelectFile = (props?: { }); fileList = fileList.slice(0, maxCount); } - onSelect(fileList, openSign.current); + if (!maxSize) { + onSelect(fileList, openSign.current); + } else { + const filterFiles = fileList.filter((item) => item.size <= maxSize); + if (filterFiles.length < files.length) { + toast({ + status: 'warning', + title: t('file:some_file_size_exceeds_limit', { maxSize: formatFileSize(maxSize) }) + }); + } + onSelect(filterFiles, openSign.current); + } e.target.value = ''; }} From 9c92fa2a14e3b907400f0b5036fecf3cbc423427 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=96=87=E5=90=AF72536?= <72536@sangfor.com> Date: Wed, 3 Sep 2025 15:33:42 +0800 Subject: [PATCH 27/84] [ADD] Pdf parse timeout and concurrency --- packages/service/common/file/read/utils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/service/common/file/read/utils.ts b/packages/service/common/file/read/utils.ts index 461da09dc0c4..437123f8cd5f 100644 --- a/packages/service/common/file/read/utils.ts +++ b/packages/service/common/file/read/utils.ts @@ -73,6 +73,8 @@ export const readRawContentByFileBuffer = async ({ const token = global.systemEnv.customPdfParse?.key; if (!url) return systemParse(); + const timeout = global.systemEnv.customPdfParse?.timeout || 10; + const start = Date.now(); addLog.info('Parsing files from an external service'); @@ -85,7 +87,7 @@ export const readRawContentByFileBuffer = async ({ markdown: string; error?: Object | string; }>(url, data, { - timeout: 600000, + timeout: timeout * 1000 * 60, headers: { ...data.getHeaders(), Authorization: token ? `Bearer ${token}` : undefined From 6461bcf67fb6557ab2e230b176be67528e7dd7a2 Mon Sep 17 00:00:00 2001 From: Jon Date: Thu, 18 Sep 2025 15:08:09 +0800 Subject: [PATCH 28/84] feat: add lightweight evaluation dataset collection list API v2 - Add listv2 API endpoint for efficient collection listing with minimal data - Extract shared filter logic to buildEvalDatasetCollectionFilter utility - Support optional pagination with pageSize/pageNum/offset parameters - Return only essential fields: _id, name, createTime for better performance - Add comprehensive test coverage for new API endpoint - Refactor existing list.ts to use shared filter utility --- .../global/core/evaluation/dataset/api.d.ts | 11 ++ .../service/core/evaluation/dataset/utils.ts | 65 +++++++++++ .../evaluation/dataset/collection/list.ts | 54 +-------- .../evaluation/dataset/collection/listv2.ts | 68 +++++++++++ .../dataset/collection/listv2.test.ts | 109 ++++++++++++++++++ 5 files changed, 258 insertions(+), 49 deletions(-) create mode 100644 projects/app/src/pages/api/core/evaluation/dataset/collection/listv2.ts create mode 100644 test/cases/pages/api/core/evaluation/dataset/collection/listv2.test.ts diff --git a/packages/global/core/evaluation/dataset/api.d.ts b/packages/global/core/evaluation/dataset/api.d.ts index edd7f49401a8..955cdc597beb 100644 --- a/packages/global/core/evaluation/dataset/api.d.ts +++ b/packages/global/core/evaluation/dataset/api.d.ts @@ -42,6 +42,17 @@ export type listEvalDatasetCollectionResponse = PaginationResponse< dataItemsCount: number; } >; + +export type listEvalDatasetCollectionV2Body = { + searchKey?: string; + pageSize?: number; + pageNum?: number; + offset?: number; +}; + +export type listEvalDatasetCollectionV2Response = PaginationResponse< + Pick +>; type QualityEvaluationBase = { enableQualityEvaluation: boolean; evaluationModel?: string; diff --git a/packages/service/core/evaluation/dataset/utils.ts b/packages/service/core/evaluation/dataset/utils.ts index 672d514a47da..cafdcfb9b828 100644 --- a/packages/service/core/evaluation/dataset/utils.ts +++ b/packages/service/core/evaluation/dataset/utils.ts @@ -1,6 +1,10 @@ import type { EvalDatasetCollectionStatus } from '@fastgpt/global/core/evaluation/dataset/type'; import { EvalDatasetCollectionStatusEnum } from '@fastgpt/global/core/evaluation/dataset/constants'; import { evalDatasetDataSynthesizeQueue } from './dataSynthesizeMq'; +import { getEvaluationPermissionAggregation } from '../common'; +import { replaceRegChars } from '@fastgpt/global/common/string/tools'; +import { Types } from 'mongoose'; +import type { ApiRequestProps } from '../../../type/next'; export async function getCollectionStatus( collectionId: string @@ -91,3 +95,64 @@ export function formatCollectionBase(collection: any) { dataItemsCount: collection.dataItemsCount }; } + +export async function buildEvalDatasetCollectionFilter( + req: ApiRequestProps<{ searchKey?: string }>, + searchKey?: string +) { + // API layer permission validation: get permission aggregation info + const { teamId, tmbId, isOwner, roleList, myGroupMap, myOrgSet } = + await getEvaluationPermissionAggregation({ + req, + authApiKey: true, + authToken: true + }); + + // Calculate resource IDs accessible by user + const myRoles = roleList.filter( + (item) => + String(item.tmbId) === String(tmbId) || + myGroupMap.has(String(item.groupId)) || + myOrgSet.has(String(item.orgId)) + ); + const accessibleIds = myRoles.map((item) => String(item.resourceId)); + + // Build unified filter conditions + const baseFilter: Record = { + teamId: new Types.ObjectId(teamId) + }; + + if (searchKey && typeof searchKey === 'string' && searchKey.trim().length > 0) { + baseFilter.name = { $regex: new RegExp(`${replaceRegChars(searchKey.trim())}`, 'i') }; + } + + // Unified permission filtering logic + let finalFilter = baseFilter; + if (!isOwner) { + if (accessibleIds.length > 0) { + finalFilter = { + ...baseFilter, + $or: [ + { _id: { $in: accessibleIds.map((id) => new Types.ObjectId(id)) } }, + { tmbId: new Types.ObjectId(tmbId) } // Own datasets + ] + }; + } else { + // If no permission roles, can only access self-created datasets + finalFilter = { + ...baseFilter, + tmbId: new Types.ObjectId(tmbId) + }; + } + } + + return { + finalFilter, + teamId, + tmbId, + isOwner, + myRoles, + accessibleIds, + roleList + }; +} diff --git a/projects/app/src/pages/api/core/evaluation/dataset/collection/list.ts b/projects/app/src/pages/api/core/evaluation/dataset/collection/list.ts index 25db7dc5a6dd..a5bffc308a3c 100644 --- a/projects/app/src/pages/api/core/evaluation/dataset/collection/list.ts +++ b/projects/app/src/pages/api/core/evaluation/dataset/collection/list.ts @@ -2,20 +2,18 @@ import type { ApiRequestProps } from '@fastgpt/service/type/next'; import { NextAPI } from '@/service/middleware/entry'; import { MongoEvalDatasetCollection } from '@fastgpt/service/core/evaluation/dataset/evalDatasetCollectionSchema'; import { EvaluationPermission } from '@fastgpt/global/support/permission/evaluation/controller'; -import { replaceRegChars } from '@fastgpt/global/common/string/tools'; import { addSourceMember } from '@fastgpt/service/support/user/utils'; import { sumPer } from '@fastgpt/global/support/permission/utils'; -import { Types } from 'mongoose'; import type { listEvalDatasetCollectionBody, listEvalDatasetCollectionResponse } from '@fastgpt/global/core/evaluation/dataset/api'; import { parsePaginationRequest } from '@fastgpt/service/common/api/pagination'; -import { getEvaluationPermissionAggregation } from '@fastgpt/service/core/evaluation/common'; import { getCollectionStatus, buildCollectionAggregationPipeline, - formatCollectionBase + formatCollectionBase, + buildEvalDatasetCollectionFilter } from '@fastgpt/service/core/evaluation/dataset/utils'; /* @@ -33,51 +31,9 @@ async function handler( const { offset, pageSize } = parsePaginationRequest(req); const { searchKey } = req.body; - // API layer permission validation: get permission aggregation info - const { teamId, tmbId, isOwner, roleList, myGroupMap, myOrgSet } = - await getEvaluationPermissionAggregation({ - req, - authApiKey: true, - authToken: true - }); - - // Calculate resource IDs accessible by user - const myRoles = roleList.filter( - (item) => - String(item.tmbId) === String(tmbId) || - myGroupMap.has(String(item.groupId)) || - myOrgSet.has(String(item.orgId)) - ); - const accessibleIds = myRoles.map((item) => String(item.resourceId)); - - // Build unified filter conditions - const baseFilter: Record = { - teamId: new Types.ObjectId(teamId) - }; - - if (searchKey && typeof searchKey === 'string' && searchKey.trim().length > 0) { - baseFilter.name = { $regex: new RegExp(`${replaceRegChars(searchKey.trim())}`, 'i') }; - } - - // Unified permission filtering logic - let finalFilter = baseFilter; - if (!isOwner) { - if (accessibleIds.length > 0) { - finalFilter = { - ...baseFilter, - $or: [ - { _id: { $in: accessibleIds.map((id) => new Types.ObjectId(id)) } }, - { tmbId: new Types.ObjectId(tmbId) } // Own datasets - ] - }; - } else { - // If no permission roles, can only access self-created datasets - finalFilter = { - ...baseFilter, - tmbId: new Types.ObjectId(tmbId) - }; - } - } + // Use shared filter logic + const { finalFilter, teamId, tmbId, isOwner, myRoles, roleList } = + await buildEvalDatasetCollectionFilter(req, searchKey); const [collections, total] = await Promise.all([ MongoEvalDatasetCollection.aggregate([ diff --git a/projects/app/src/pages/api/core/evaluation/dataset/collection/listv2.ts b/projects/app/src/pages/api/core/evaluation/dataset/collection/listv2.ts new file mode 100644 index 000000000000..4c3158ca1cbe --- /dev/null +++ b/projects/app/src/pages/api/core/evaluation/dataset/collection/listv2.ts @@ -0,0 +1,68 @@ +import type { ApiRequestProps } from '@fastgpt/service/type/next'; +import { NextAPI } from '@/service/middleware/entry'; +import { MongoEvalDatasetCollection } from '@fastgpt/service/core/evaluation/dataset/evalDatasetCollectionSchema'; +import type { + listEvalDatasetCollectionV2Body, + listEvalDatasetCollectionV2Response +} from '@fastgpt/global/core/evaluation/dataset/api'; +import { buildEvalDatasetCollectionFilter } from '@fastgpt/service/core/evaluation/dataset/utils'; + +/* + Get evaluation dataset collection list - lightweight version + Returns only essential data: _id, name, createTime + Features: + - Optional pagination (returns all when no pagination params) + - Fast performance (no heavy operations) + - Reuses filter logic from v1 +*/ +async function handler( + req: ApiRequestProps +): Promise { + const { searchKey, pageSize, pageNum, offset } = req.body; + + // Reuse shared filter logic + const { finalFilter } = await buildEvalDatasetCollectionFilter(req, searchKey); + + // Build lightweight aggregation pipeline + const pipeline: any[] = [ + { $match: finalFilter }, + { $sort: { createTime: -1 as const } }, + { + $project: { + _id: 1, + name: 1, + createTime: 1 + } + } + ]; + + // Add pagination only if provided + if (pageSize && (pageNum !== undefined || offset !== undefined)) { + const calculatedOffset = + offset !== undefined ? Number(offset) : (Number(pageNum) - 1) * Number(pageSize); + pipeline.push({ $skip: calculatedOffset }, { $limit: Number(pageSize) }); + } + + // Execute aggregation and count in parallel + const [collections, total] = await Promise.all([ + MongoEvalDatasetCollection.aggregate(pipeline), + MongoEvalDatasetCollection.countDocuments(finalFilter) + ]); + + // Format response + const formattedCollections = collections.map((collection) => ({ + _id: String(collection._id), + name: collection.name, + createTime: collection.createTime + })); + + return { + list: formattedCollections, + total: total + }; +} + +export default NextAPI(handler); + +// Export handler for testing +export const handler_test = process.env.NODE_ENV === 'test' ? handler : undefined; diff --git a/test/cases/pages/api/core/evaluation/dataset/collection/listv2.test.ts b/test/cases/pages/api/core/evaluation/dataset/collection/listv2.test.ts new file mode 100644 index 000000000000..3b62d3afc0b1 --- /dev/null +++ b/test/cases/pages/api/core/evaluation/dataset/collection/listv2.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { handler_test } from '@/pages/api/core/evaluation/dataset/collection/listv2'; +import { buildEvalDatasetCollectionFilter } from '@fastgpt/service/core/evaluation/dataset/utils'; +import { MongoEvalDatasetCollection } from '@fastgpt/service/core/evaluation/dataset/evalDatasetCollectionSchema'; +import { Types } from '@fastgpt/service/common/mongo'; + +vi.mock('@fastgpt/service/core/evaluation/dataset/utils'); +vi.mock('@fastgpt/service/core/evaluation/dataset/evalDatasetCollectionSchema', () => ({ + EvalDatasetCollectionName: 'eval_dataset_collections', + MongoEvalDatasetCollection: { + aggregate: vi.fn(), + countDocuments: vi.fn() + } +})); + +describe('EvalDatasetCollection ListV2 API', () => { + const mockReq = { + body: { + pageSize: 10, + pageNum: 1 + } + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Basic functionality', () => { + it('should return collection list with minimal data', async () => { + if (!handler_test) { + throw new Error('handler_test is not available'); + } + + // Mock the filter function + vi.mocked(buildEvalDatasetCollectionFilter).mockResolvedValue({ + finalFilter: { teamId: new Types.ObjectId() }, + teamId: 'test-team-id', + tmbId: 'test-tmb-id', + isOwner: false, + myRoles: [], + accessibleIds: [] + }); + + // Mock database responses + const mockCollections = [ + { + _id: new Types.ObjectId(), + name: 'Test Collection', + createTime: new Date() + } + ]; + + vi.mocked(MongoEvalDatasetCollection.aggregate).mockResolvedValue(mockCollections); + vi.mocked(MongoEvalDatasetCollection.countDocuments).mockResolvedValue(1); + + const result = await handler_test(mockReq as any); + + expect(result).toHaveProperty('list'); + expect(result).toHaveProperty('total'); + expect(Array.isArray(result.list)).toBe(true); + expect(typeof result.total).toBe('number'); + expect(result.total).toBe(1); + + // Check that each item has only the minimal fields + if (result.list.length > 0) { + const firstItem = result.list[0]; + expect(firstItem).toHaveProperty('_id'); + expect(firstItem).toHaveProperty('name'); + expect(firstItem).toHaveProperty('createTime'); + + // Ensure it only has the 3 expected fields + expect(Object.keys(firstItem)).toEqual(['_id', 'name', 'createTime']); + } + }); + + it('should work without pagination parameters', async () => { + if (!handler_test) { + throw new Error('handler_test is not available'); + } + + const reqWithoutPagination = { + body: { + searchKey: '' + } + }; + + // Mock the filter function + vi.mocked(buildEvalDatasetCollectionFilter).mockResolvedValue({ + finalFilter: { teamId: new Types.ObjectId() }, + teamId: 'test-team-id', + tmbId: 'test-tmb-id', + isOwner: false, + myRoles: [], + accessibleIds: [] + }); + + const mockCollections = []; + vi.mocked(MongoEvalDatasetCollection.aggregate).mockResolvedValue(mockCollections); + vi.mocked(MongoEvalDatasetCollection.countDocuments).mockResolvedValue(0); + + const result = await handler_test(reqWithoutPagination as any); + + expect(result).toHaveProperty('list'); + expect(result).toHaveProperty('total'); + expect(Array.isArray(result.list)).toBe(true); + expect(typeof result.total).toBe('number'); + }); + }); +}); From 290ed58d06aed07a75cf9d8c2fcfdb1ff13c120e Mon Sep 17 00:00:00 2001 From: lyx Date: Thu, 18 Sep 2025 17:04:49 +0800 Subject: [PATCH 29/84] feat(chat): Optimize the logic of the evaluation dataset selector and support lightweight API - Migrate the evaluation dataset selector from `useScrollPagination` to `useRequest2` and utilize a lightweight API - Automatically populate recently used evaluation datasets in the `SelectMarkCollection` component - Allow direct submission of data using evaluation dataset ID without the need for a knowledge base collection - Update the input data modal to support scenarios with only evaluation datasets - Add lightweight evaluation dataset list API V2 interface - Fix permission check logic to be compatible with cases without a collection ID --- .../components/EvaluationDatasetSelector.tsx | 38 +++++-------- .../components/SelectMarkCollection.tsx | 33 ++++++++++-- .../dataset/detail/InputDataModal.tsx | 54 +++++++++++-------- .../app/src/web/core/evaluation/dataset.ts | 6 +++ 4 files changed, 79 insertions(+), 52 deletions(-) diff --git a/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/EvaluationDatasetSelector.tsx b/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/EvaluationDatasetSelector.tsx index 9544085e0a9c..ef5e3cb0e8b7 100644 --- a/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/EvaluationDatasetSelector.tsx +++ b/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/EvaluationDatasetSelector.tsx @@ -1,13 +1,12 @@ -import React, { useState, useMemo, useCallback } from 'react'; +import React, { useState, useMemo, useCallback, useEffect } from 'react'; import { Button, Box, Text, HStack, Flex, useDisclosure } from '@chakra-ui/react'; import { useTranslation } from 'next-i18next'; import MySelect from '@fastgpt/web/components/common/MySelect'; import MyIcon from '@fastgpt/web/components/common/Icon'; import MyMenu from '@fastgpt/web/components/common/MyMenu'; import dynamic from 'next/dynamic'; -import { useScrollPagination } from '@fastgpt/web/hooks/useScrollPagination'; -import { getEvaluationDatasetList } from '@/web/core/evaluation/dataset'; -import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; +import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; +import { getEvaluationDatasetListV2 } from '@/web/core/evaluation/dataset'; const IntelligentGeneration = dynamic( () => import('@/pageComponents/dashboard/evaluation/dataset/IntelligentGeneration') @@ -31,31 +30,19 @@ const EvaluationDatasetSelector: React.FC = ({ onClose: onCloseIntelligentModal } = useDisclosure(); - // 获取评测数据集列表 - const scrollParams = useMemo( - () => ({ - searchKey: '', - pageSize: 10 - }), - [] - ); - - const EmptyTipDom = useMemo(() => , [t]); - const { data: evaluationDatasetList, - ScrollData, - isLoading: isLoadingDatasets, - refreshList: fetchDatasets - } = useScrollPagination(getEvaluationDatasetList, { - params: scrollParams, - refreshDeps: [], - EmptyTip: EmptyTipDom - }); + loading: isLoadingDatasets, + runAsync: fetchDatasets + } = useRequest2(getEvaluationDatasetListV2); + + useEffect(() => { + fetchDatasets({}); + }, []); // 转换评测数据集列表为 MySelect 需要的格式 const evaluationDatasetSelectList = useMemo(() => { - const data = evaluationDatasetList.map((item) => ({ + const data = (evaluationDatasetList?.list || []).map((item: any) => ({ label: item.name, value: item._id })); @@ -85,7 +72,7 @@ const EvaluationDatasetSelector: React.FC = ({ const handleIntelligentGenerationConfirm = useCallback( (data: any, datasetId?: string) => { onCloseIntelligentModal(); - fetchDatasets(); + fetchDatasets({}); }, [onCloseIntelligentModal, fetchDatasets] ); @@ -138,7 +125,6 @@ const EvaluationDatasetSelector: React.FC = ({ list={evaluationDatasetSelectList} isLoading={isLoadingDatasets} onChange={onChange} - ScrollData={ScrollData} bg="myGray.50" /> diff --git a/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/SelectMarkCollection.tsx b/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/SelectMarkCollection.tsx index 2d9a713a5d2c..393f17378f0a 100644 --- a/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/SelectMarkCollection.tsx +++ b/projects/app/src/components/core/chat/ChatContainer/ChatBox/components/SelectMarkCollection.tsx @@ -1,12 +1,14 @@ -import React, { useState, useMemo, useCallback } from 'react'; +import React, { useState, useMemo, useCallback, useEffect } from 'react'; import { ModalBody, ModalFooter, Button, VStack, FormControl, FormLabel } from '@chakra-ui/react'; import { useTranslation } from 'next-i18next'; +import { useRouter } from 'next/router'; import MyModal from '@fastgpt/web/components/common/MyModal'; import dynamic from 'next/dynamic'; import { type AdminFbkType } from '@fastgpt/global/core/chat/type.d'; import FilesCascader from './FilesCascader'; import type { FileSelection } from './FilesCascader'; import EvaluationDatasetSelector from './EvaluationDatasetSelector'; +import { getEvaluationList } from '@/web/core/evaluation/task'; const InputDataModal = dynamic(() => import('@/pageComponents/dataset/detail/InputDataModal')); @@ -35,6 +37,12 @@ const SelectMarkCollection = ({ onSuccess: (adminFeedback: AdminFbkType) => void; }) => { const { t } = useTranslation(); + const router = useRouter(); + + // 从路由查询参数获取appId + const appId = useMemo(() => { + return router.query.appId as string; + }, [router.query.appId]); // 级联选择器的值 const cascaderValue: FileSelection = useMemo(() => { @@ -70,18 +78,35 @@ const SelectMarkCollection = ({ // 处理确认按钮点击 const handleConfirm = useCallback(() => { - if (adminMarkData.datasetId && adminMarkData.collectionId) { + if ( + (adminMarkData.datasetId && adminMarkData.collectionId) || + (selectedEvaluationDataset && selectedEvaluationDataset !== 'null') + ) { // 打开输入数据模态框 setShowInputDataModal(true); } - }, [adminMarkData.datasetId, adminMarkData.collectionId]); + }, [adminMarkData.datasetId, adminMarkData.collectionId, selectedEvaluationDataset]); // 控制是否显示输入数据模态框 const [showInputDataModal, setShowInputDataModal] = useState(false); // 检查是否可以选择(需要同时选择了数据集和集合,且不是"不加入知识库") const canConfirm = - adminMarkData.datasetId && adminMarkData.collectionId && !adminMarkData.noKnowledgeBase; + (adminMarkData.datasetId && adminMarkData.collectionId && !adminMarkData.noKnowledgeBase) || + (selectedEvaluationDataset !== 'null' && selectedEvaluationDataset); + + useEffect(() => { + getEvaluationList({ + pageNum: 1, + pageSize: 1, + appId: appId + }).then((res) => { + const item = res?.list?.[0]; + if (item) { + setSelectedEvaluationDataset(item.evalDatasetCollectionId); + } + }); + }, [appId]); return ( <> diff --git a/projects/app/src/pageComponents/dataset/detail/InputDataModal.tsx b/projects/app/src/pageComponents/dataset/detail/InputDataModal.tsx index beb04dde4e5a..bcf6237846ff 100644 --- a/projects/app/src/pageComponents/dataset/detail/InputDataModal.tsx +++ b/projects/app/src/pageComponents/dataset/detail/InputDataModal.tsx @@ -60,7 +60,7 @@ const InputDataModal = ({ defaultValue?: { q?: string; a?: string; imagePreivewUrl?: string }; evaluationDatasetId?: string; onClose: () => void; - onSuccess: (data: InputDataType & { dataId: string }) => void; + onSuccess: (data: InputDataType & { dataId?: string }) => void; }) => { const { t } = useTranslation(); const { embeddingModelList, defaultModels } = useSystemStore(); @@ -82,7 +82,7 @@ const InputDataModal = ({ const { data: collection = defaultCollectionDetail, loading: initLoading } = useRequest2( async () => { const [collection, dataItem] = await Promise.all([ - getDatasetCollectionById(collectionId), + collectionId ? getDatasetCollectionById(collectionId) : Promise.resolve(), ...(dataId ? [getDatasetDataItemById(dataId)] : []) ]); @@ -109,7 +109,7 @@ const InputDataModal = ({ } // Forcus reset to image tab - if (collection.type === DatasetCollectionTypeEnum.images) { + if (collection?.type === DatasetCollectionTypeEnum.images) { setCurrentTab(TabEnum.image); } return collection; @@ -124,20 +124,34 @@ const InputDataModal = ({ const { runAsync: sureImportData, loading: isImporting } = useRequest2( async (e: InputDataType) => { const data = { ...e }; + let dataId: string | undefined; - const postData: any = { - collectionId: collection._id, - q: e.q, - a: currentTab === TabEnum.qa ? e.a : '', - // Contains no default index - indexes: e.indexes?.filter((item) => !!item.text?.trim()) || [] - }; + // 如果存在evaluationDatasetId,调用评估数据集接口 + if (evaluationDatasetId) { + await postCreateEvaluationDatasetData({ + collectionId: evaluationDatasetId, + userInput: e.q, + expectedOutput: e.a, + enableQualityEvaluation: false + }); + } - const dataId = await postInsertData2Dataset(postData); + // 如果存在collectionId,调用普通数据集接口 + if (collectionId) { + const postData: any = { + collectionId: collection._id, + q: e.q, + a: currentTab === TabEnum.qa ? e.a : '', + // Contains no default index + indexes: e.indexes?.filter((item) => !!item.text?.trim()) || [] + }; + + dataId = await postInsertData2Dataset(postData); + } return { ...data, - dataId + ...(dataId && { dataId }) }; }, { @@ -170,14 +184,6 @@ const InputDataModal = ({ }; await putDatasetDataById(updateData); - if (evaluationDatasetId) { - await postCreateEvaluationDatasetData({ - collectionId: evaluationDatasetId, - userInput: e.q, - expectedOutput: e.a, - enableQualityEvaluation: false - }); - } return { dataId, @@ -465,10 +471,14 @@ const InputDataModal = ({ - {!hasTrainingData && feConfigs?.isPlus && ( - - )} - - )} - {datasetDetail.status === DatasetStatusEnum.syncing && ( - - {t('common:core.dataset.status.syncing')} - - )} - {datasetDetail.status === DatasetStatusEnum.waiting && ( - - {t('common:core.dataset.status.waiting')} - - )} - {datasetDetail.status === DatasetStatusEnum.error && ( - - - {t('dataset:status_error')} - - - - )} - - ) : ( + {hasDatabaseConfig ? ( <> - + ) : ( + <> + + )} )} diff --git a/projects/app/src/pageComponents/dataset/detail/CollectionCard/index.tsx b/projects/app/src/pageComponents/dataset/detail/CollectionCard/index.tsx index 43dc693672a1..75e85b2dea96 100644 --- a/projects/app/src/pageComponents/dataset/detail/CollectionCard/index.tsx +++ b/projects/app/src/pageComponents/dataset/detail/CollectionCard/index.tsx @@ -15,7 +15,7 @@ import { HStack, Button, Alert, - AlertIcon + Text } from '@chakra-ui/react'; import { delDatasetCollectionById, @@ -215,8 +215,10 @@ const CollectionCard = () => { {/* banner */} {isDatabase && ( - - {t('dataset:database_structure_change_tip')} + + + {t('dataset:database_structure_change_tip')} + )} {/* header */} diff --git a/projects/app/src/pageComponents/dataset/detail/Import/Context.tsx b/projects/app/src/pageComponents/dataset/detail/Import/Context.tsx index 333af9334654..e85d4b295068 100644 --- a/projects/app/src/pageComponents/dataset/detail/Import/Context.tsx +++ b/projects/app/src/pageComponents/dataset/detail/Import/Context.tsx @@ -39,6 +39,7 @@ type DatasetImportContextType = { sources: ImportSourceItemType[]; setSources: React.Dispatch>; setTab: React.Dispatch>; + datasetId: string; }; export const defaultFormData: ImportFormType = { @@ -87,7 +88,8 @@ export const DatasetImportContext = createContext({ chunkOverlapRatio: 0, //@ts-ignore processParamsForm: undefined, - autoChunkSize: 0 + autoChunkSize: 0, + datasetId: '' }); const DatasetImportContextProvider = ({ children }: { children: React.ReactNode }) => { @@ -103,6 +105,8 @@ const DatasetImportContextProvider = ({ children }: { children: React.ReactNode mode: 'create' | 'edit'; }; + const datasetId = (router.query.datasetId || '') as string; + const datasetDetail = useContextSelector(DatasetPageContext, (v) => v.datasetDetail); // step @@ -237,7 +241,17 @@ const DatasetImportContextProvider = ({ children }: { children: React.ReactNode processParamsForm, sources, setSources, - setTab + setTab, + datasetId + }; + + const handleReturn = () => { + router.replace({ + query: { + ...router.query, + currentTab: TabEnum.collectionCard + } + }); }; const renderCreateStatusStep = () => { @@ -253,16 +267,11 @@ const DatasetImportContextProvider = ({ children }: { children: React.ReactNode borderRadius={'50%'} variant={'whiteBase'} mr={2} - onClick={() => - router.replace({ - query: { - ...router.query, - currentTab: TabEnum.collectionCard - } - }) - } + onClick={handleReturn} /> - {t('common:Exit')} + + {t('common:Exit')} + ) : ( diff --git a/projects/app/src/pageComponents/dataset/detail/Import/components/FormBottomButtons.tsx b/projects/app/src/pageComponents/dataset/detail/Import/components/FormBottomButtons.tsx index fbd51910a3f5..c632ade1e7fa 100644 --- a/projects/app/src/pageComponents/dataset/detail/Import/components/FormBottomButtons.tsx +++ b/projects/app/src/pageComponents/dataset/detail/Import/components/FormBottomButtons.tsx @@ -1,57 +1,364 @@ -import React, { useMemo } from 'react'; -import { Box, Button, Flex, HStack } from '@chakra-ui/react'; -import { useTranslation } from 'react-i18next'; +import React, { useMemo, useState, useCallback } from 'react'; +import { + Box, + Button, + Flex, + HStack, + ModalBody, + VStack, + Text, + Spinner, + Circle +} from '@chakra-ui/react'; +import { CloseIcon } from '@chakra-ui/icons'; +import { useTranslation } from 'next-i18next'; import MyIcon from '@fastgpt/web/components/common/Icon'; +import MyModal from '@fastgpt/web/components/common/MyModal'; +import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; +import { + updateDatasetConfig, + postCheckDatabaseConnection, + postDetectDatabaseChanges +} from '@/web/core/dataset/api'; +import type { DatabaseConfig } from '@fastgpt/global/core/dataset/type'; +import type { DatabaseFormData } from './ConnectDatabaseForm'; +import type { DetectChangesResponse } from '@/pages/api/core/dataset/database/detectChanges'; + +interface ConnectionTestResult { + success: boolean; + message: string; +} + +interface DatabaseChangesInfo { + hasChanges: boolean; + addedTables: number; + modifiedTables: number; + deletedTables: number; +} interface FormBottomButtonsProps { isEditMode?: boolean; - isLoading?: boolean; - isConnecting?: boolean; - connectionError?: string; - connectionSuccess?: boolean; - onTestConnection: () => void; - onConnectAndNext: () => void; disabled?: boolean; + formData: DatabaseFormData; + datasetId: string; + onSuccess?: () => void; + originalConfig?: DatabaseFormData; } +const iconMap = { + success: { + icon: 'checkCircle', + color: 'green.600' + }, + fail: { + icon: 'common/error', + color: 'red.600' + } +}; + const FormBottomButtons: React.FC = ({ isEditMode = false, - isLoading = false, - isConnecting = false, - connectionError, - connectionSuccess = false, - onTestConnection, - onConnectAndNext, - disabled = false + disabled = false, + formData, + datasetId, + onSuccess, + originalConfig }) => { const { t } = useTranslation(); + const [showConnectionModal, setShowConnectionModal] = useState(false); + const [connectionStatus, setConnectionStatus] = useState<'loading' | 'success' | 'error' | null>( + null + ); + const [connectionMessage, setConnectionMessage] = useState(''); + + // 连接测试状态 + const [connectionTest, setConnectionTest] = useState({ + success: false, + message: '' + }); + + // 数据库变更信息状态 + const [databaseChanges, setDatabaseChanges] = useState({ + hasChanges: false, + addedTables: 0, + modifiedTables: 0, + deletedTables: 0 + }); + + // 检查是否有关键配置变更(除了连接池大小) + const hasKeyConfigChanges = useMemo(() => { + if (!isEditMode || !originalConfig) return false; + + return ( + formData.host !== originalConfig.host || + formData.port !== originalConfig.port || + formData.database !== originalConfig.database || + formData.user !== originalConfig.user || + formData.password !== originalConfig.password + ); + }, [isEditMode, formData, originalConfig]); + + // 检查是否只有连接池大小变更 + const isOnlyPoolSizeChange = useMemo(() => { + if (!isEditMode || !originalConfig) return false; + + const hasPoolSizeChange = formData.poolSize !== originalConfig.poolSize; + return hasPoolSizeChange && !hasKeyConfigChanges; + }, [isEditMode, formData, originalConfig, hasKeyConfigChanges]); + + const isModifyData = useMemo( + () => !isEditMode || isOnlyPoolSizeChange || hasKeyConfigChanges, + [isOnlyPoolSizeChange, hasKeyConfigChanges, isEditMode] + ); + + React.useEffect(() => { + if (connectionTest.message || connectionTest.success) { + setConnectionTest({ + success: false, + message: '' + }); + } + }, [formData]); + + // 连接测试请求 + const { runAsync: testConnection, loading: isConnecting } = useRequest2( + async () => { + const databaseConfig: DatabaseConfig = { + client: 'mysql', + host: formData.host, + port: formData.port, + database: formData.database, + user: formData.user, + password: formData.password, + poolSize: formData.poolSize + }; + + return await postCheckDatabaseConnection({ + datasetId, + databaseConfig + }); + }, + { + onSuccess(res: any) { + setConnectionTest({ + success: res?.success || false, + message: res?.message || t('连接成功') + }); + } + } + ); + + // 提交表单请求 + const { runAsync: onSubmitForm, loading: isSubmitting } = useRequest2( + async (data: any) => { + const databaseConfig: DatabaseConfig = { + client: 'mysql', + host: data.host, + port: data.port, + database: data.database, + user: data.user, + password: data.password, + poolSize: data.poolSize + }; + + // 如果只是连接池大小变更,不需要检测数据库变更 + if (!isEditMode || isOnlyPoolSizeChange) { + return await updateDatasetConfig({ + id: datasetId, + databaseConfig + }); + } + + // 如果有关键配置变更,需要检测数据库变更 + if (hasKeyConfigChanges) { + return await postDetectDatabaseChanges({ datasetId }); + } + }, + { + onSuccess(res: DetectChangesResponse | undefined) { + if (!res) { + if (isOnlyPoolSizeChange) { + // 只是连接池变更,直接成功回调 + onSuccess?.(); + return; + } + setConnectionStatus('success'); + setConnectionMessage(t('dataset:reconnect_success')); + return; + } + + const { hasChanges, summary } = res; + + // 存储数据库变更信息 + const changesInfo: DatabaseChangesInfo = { + hasChanges, + addedTables: summary?.addedTables || 0, + modifiedTables: summary?.modifiedTables || 0, + deletedTables: summary?.deletedTables || 0 + }; + setDatabaseChanges(changesInfo); + + setConnectionStatus('success'); + setConnectionMessage(t('dataset:reconnect_success')); + }, + onError(error) { + setConnectionStatus('error'); + setConnectionMessage(error.message || t('dataset:connection_failed')); + } + } + ); + + const handleConnectAndNext = useCallback(async () => { + if (!isEditMode || isOnlyPoolSizeChange) { + await onSubmitForm(formData); + } else if (hasKeyConfigChanges) { + setShowConnectionModal(true); + setConnectionStatus('loading'); + setConnectionMessage(''); + await onSubmitForm(formData); + } - const editModeBtns = useMemo( - () => ( + !isEditMode && onSuccess?.(); + }, [isEditMode, formData, onSubmitForm, isOnlyPoolSizeChange, hasKeyConfigChanges, onSuccess]); + + const handleCloseModal = () => { + setShowConnectionModal(false); + setConnectionStatus(null); + setConnectionMessage(''); + }; + + const renderModalContent = () => { + switch (connectionStatus) { + case 'loading': + return ( + + + + {t('dataset:reconnecting')} + + + ); + + case 'success': + const getSubTitle = () => { + if (!databaseChanges.hasChanges) { + return t('未出现信息变更'); + } + + const parts = []; + if (databaseChanges.addedTables > 0) { + parts.push( + t('新增 {{addedTables}} 个数据表', { addedTables: databaseChanges.addedTables }) + ); + } + if (databaseChanges.modifiedTables > 0) { + parts.push( + t('{{modifiedTables}} 个数据表存在列的变更', { + modifiedTables: databaseChanges.modifiedTables + }) + ); + } + if (databaseChanges.deletedTables > 0) { + parts.push( + t('{{deletedTables}} 个数据表已不存在', { + deletedTables: databaseChanges.deletedTables + }) + ); + } + + if (parts.length > 0) { + parts.push(t('请核查最新数据。')); + } + + return t('发现') + parts.join(','); + }; + + return ( + + + + + + + + {t('dataset:reconnect_success')} + + + + {getSubTitle()} + {databaseChanges.hasChanges && ( + { + onSuccess?.(); + }} + > + {t('dataset:data_config')} + + )} + + + + ); + + case 'error': + return ( + + + + + + + + {t('dataset:connection_failed')} + + + + {t('dataset:auth_failed')} + + + + ); + + default: + return null; + } + }; + + const editModeBtns = useMemo(() => { + const { icon, color } = iconMap[connectionTest.success ? 'success' : 'fail']; + console.log(connectionTest); + return ( <> - {connectionError && ( + {connectionTest.message && ( <> - - {connectionError} + + {connectionTest.message} )} - + <> + + {!connectionTest.success && connectionTest.message && ( + <> + + {connectionTest.message} + + )} + + + ); - }, [onConnectAndNext, isLoading, isConnecting, disabled, t, connectionError]); + }, [handleConnectAndNext, isSubmitting, isConnecting, disabled, t, connectionTest]); + + return ( + <> + {isEditMode ? editModeBtns : createModeBtns} - return isEditMode ? editModeBtns : createModeBtns; + {/* Connection Status Modal - Only show in edit mode with key config changes */} + {isEditMode && hasKeyConfigChanges && ( + + {renderModalContent()} + + )} + + ); }; export default FormBottomButtons; diff --git a/projects/app/src/pageComponents/dataset/detail/Import/components/hooks/useDataBaseConfig.ts b/projects/app/src/pageComponents/dataset/detail/Import/components/hooks/useDataBaseConfig.ts index a084002c9ecf..8b4db6347066 100644 --- a/projects/app/src/pageComponents/dataset/detail/Import/components/hooks/useDataBaseConfig.ts +++ b/projects/app/src/pageComponents/dataset/detail/Import/components/hooks/useDataBaseConfig.ts @@ -1,271 +1,449 @@ -import { useState } from 'react'; +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { useTranslation } from 'next-i18next'; -import { mockData } from '../const'; - -// 后端数据结构定义 -interface BackendColumn { - columnName: string; - columnType: string; - description: string; - examples: string[]; - enabled: boolean; - valueIndex: boolean; +import { useRouter } from 'next/router'; +import type { + UseFormGetValues, + UseFormSetValue, + UseFormWatch, + UseFormReset +} from 'react-hook-form'; +import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; +import { + postGetDatabaseConfiguration, + postDetectDatabaseChanges, + postCreateDatabaseCollections +} from '@/web/core/dataset/api'; +import type { DetectChangesResponse } from '@/web/core/dataset/temp.d'; +import type { + UIColumn, + UITableData, + TableInfo, + CurrentTableFormData, + TableChangeSummary, + CurrentTableColumnChanges +} from './utils'; +import { + transformBackendToUI, + transformChangesToUI, + transformUIToBackend, + getProblematicTableNames +} from './utils'; +import { TableStatusEnum, ColumnStatusEnum } from '@/web/core/dataset/temp.d'; + +interface FormMethods { + getValues: UseFormGetValues; + setValue: UseFormSetValue; + watch: UseFormWatch; + reset: UseFormReset; } -interface BackendTableData { - tableName: string; - description: string; - enabled: boolean; - columns: Record; -} - -// 前端UI使用的数据结构 -interface UIColumn { - columnName: string; - columnType: string; - description: string; - examples: string[]; - enabled: boolean; - valueIndex: boolean; -} +export const useDataBaseConfig = ( + datasetId: string, + isEditMode: boolean = false, + formMethods: FormMethods +) => { + const { t } = useTranslation(); + const router = useRouter(); + const { getValues, setValue, watch, reset } = formMethods; -interface UITableData { - tableName: string; - description: string; - enabled: boolean; - columns: UIColumn[]; -} + const [uiTables, setUITables] = useState([]); + const [currentTableIndex, setCurrentTableIndex] = useState(0); + const [changesSummary, setChangesSummary] = useState( + null + ); + const [tableChangeSummary, setTableChangeSummary] = useState({ + modifiedTables: { count: 0, tableNames: [] }, + deletedTables: { count: 0, tableNames: [] }, + addedTables: { count: 0, tableNames: [] }, + hasChanges: false, + hasBannerTip: false + }); -interface TableInfo { - tableData: UITableData; - isCurrentTable: boolean; -} + // 监听表单数据变化 + const watchedCurrentTable = watch(); -// 数据预处理函数:将后端数据转换为UI数据 -const transformBackendToUI = (backendTables: BackendTableData[]): UITableData[] => { - return backendTables.map((table) => ({ - tableName: table.tableName, - description: table.description, - enabled: table.enabled, - columns: Object.values(table.columns).map((column) => ({ - columnName: column.columnName, - columnType: column.columnType, - description: column.description, - enabled: column.enabled, - examples: column.examples, - valueIndex: column.valueIndex - })) - })); -}; + // 获取当前表 + const currentTable = useMemo(() => { + return uiTables[currentTableIndex] || null; + }, [uiTables, currentTableIndex]); -// 数据转换函数:将UI数据转换为后端数据 -const transformUIToBackend = (uiTables: UITableData[]): BackendTableData[] => { - return uiTables.map((table) => ({ - tableName: table.tableName, - description: table.description, - enabled: table.enabled, - columns: table.columns.reduce( - (acc, column) => { - acc[column.columnName] = { - columnName: column.columnName, - columnType: column.columnType, - description: column.description, - enabled: column.enabled, - examples: column.examples, - valueIndex: column.valueIndex - }; - return acc; - }, - {} as Record - ) - })); -}; - -export const useDataBaseConfig = () => { - const { t } = useTranslation(); + // 获取数据配置 + const { runAsync: getConfiguration, loading: getConfigLoading } = useRequest2( + postGetDatabaseConfiguration + ); - // 模拟后端数据 - const mockBackendData: BackendTableData[] = mockData as BackendTableData[]; + // 检测变更 + const { runAsync: detectChanges, loading: detectChangesLoading } = + useRequest2(postDetectDatabaseChanges); - // 初始化UI数据 - const [uiTables, setUITables] = useState(() => - transformBackendToUI(mockBackendData) + // 创建数据库知识库数据集 + const { runAsync: createCollections, loading: isCreating } = useRequest2( + postCreateDatabaseCollections, + { + onSuccess: () => { + router.push(`/dataset/detail?datasetId=${datasetId}`); + } + } ); - const [tableInfos, setTableInfos] = useState(() => - uiTables.map((table, index) => ({ + // 动态计算表信息 + const tableInfos = useMemo(() => { + return uiTables.map((table, index) => ({ tableData: table, - isCurrentTable: index === 0 - })) - ); + isCurrentTable: index === currentTableIndex + })); + }, [uiTables, currentTableIndex]); + + // 动态计算loading状态 + const loading = useMemo(() => { + return getConfigLoading || detectChangesLoading; + }, [getConfigLoading, detectChangesLoading]); + + // 动态计算存在问题的表名(表已勾选且表描述为空或列启用但描述为空) + const problematicTableNames = useMemo(() => { + return getProblematicTableNames(uiTables); + }, [uiTables]); + + // 计算当前表格的列变更信息 + const currentTableColumnChanges = useMemo(() => { + if (!currentTable) { + return { + addedColumns: { count: 0, columnNames: [] }, + deletedColumns: { count: 0, columnNames: [] }, + hasColumnChanges: false + }; + } - const [currentTable, setCurrentTable] = useState(uiTables[0]); + const addedColumnNames: string[] = []; + const deletedColumnNames: string[] = []; - // 验证错误状态 - const [validationErrors, setValidationErrors] = useState<{ - tableDescription: string; - columnDescriptions: Record; - }>({ - tableDescription: '', - columnDescriptions: {} - }); + currentTable.columns.forEach((column) => { + if (column.status === ColumnStatusEnum.add) { + addedColumnNames.push(column.columnName); + } else if (column.status === ColumnStatusEnum.delete) { + deletedColumnNames.push(column.columnName); + } + }); + + return { + addedColumns: { + count: addedColumnNames.length, + columnNames: addedColumnNames + }, + deletedColumns: { + count: deletedColumnNames.length, + columnNames: deletedColumnNames + }, + hasColumnChanges: addedColumnNames.length > 0 || deletedColumnNames.length > 0 + }; + }, [currentTable]); - // 验证表描述 - const validateTableDescription = (table: UITableData) => { - if (table.enabled && !table.description.trim()) { - return t('dataset:table_description_required'); + // 同步当前表单数据到uiTables + const syncCurrentTableToUITables = useCallback(() => { + if (!currentTable) return; + + const formData = getValues(); + const updatedTables = [...uiTables]; + const newTableData = { + ...currentTable, + description: formData.description, + columns: formData.columns + }; + + // 只有数据真正发生变化时才更新状态 + if (JSON.stringify(updatedTables[currentTableIndex]) !== JSON.stringify(newTableData)) { + updatedTables[currentTableIndex] = newTableData; + setUITables(updatedTables); } - return ''; + }, [currentTable, currentTableIndex, uiTables, getValues]); + + // 同步uiTables到当前表单 + const syncUITablesToCurrentForm = () => { + if (!currentTable) return; + + reset({ + description: currentTable.description, + columns: currentTable.columns.filter((col) => col.status !== ColumnStatusEnum.delete) + }); }; - // 验证列描述 - const validateColumnDescription = (column: UIColumn) => { - if (column.enabled && !column.description.trim()) { - return t('dataset:column_description_required'); + // 初始化数据 + useEffect(() => { + const initData = async () => { + try { + let uiData: UITableData[] = []; + + // 首先获取数据配置 + const configResult = await getConfiguration({ datasetId }); + uiData = transformBackendToUI(configResult.tables); + + if (isEditMode) { + // 编辑模式:检测变更 + const changesResult = await detectChanges({ datasetId }); + if (changesResult.hasChanges) { + // 合并变更数据 + const changesUIData = transformChangesToUI(changesResult.tables); + + // 处理表格状态 + uiData = uiData.map((originalTable) => { + const changedTable = changesUIData.find( + (t) => t.tableName === originalTable.tableName + ); + if (changedTable) { + return { + ...changedTable, + // 新增表默认不勾选 + enabled: + changedTable.status === TableStatusEnum.add ? false : changedTable.enabled, + columns: changedTable.columns.map((col) => ({ + ...col, + // 新增列默认不启用 + enabled: col.status === ColumnStatusEnum.add ? false : col.enabled + })) + }; + } + return originalTable; + }); + + // 添加新增的表 + const newTables = changesUIData.filter( + (changedTable) => + !uiData.some((originalTable) => originalTable.tableName === changedTable.tableName) + ); + + uiData = [ + ...uiData, + ...newTables.map((table) => ({ + ...table, + enabled: false, // 新增表默认不勾选 + columns: table.columns.map((col) => ({ + ...col, + enabled: col.status === ColumnStatusEnum.add ? false : col.enabled + })) + })) + ]; + + setChangesSummary(changesResult.summary); + } + } + + // 计算表格变更汇总信息并设置hasColumnChanges字段 + const modifiedTableNames: string[] = []; + const deletedTableNames: string[] = []; + const addedTableNames: string[] = []; + + uiData = uiData.map((table) => { + // 检查是否有列变更 + const hasColumnChanges = table.columns.some( + (col) => col.status === ColumnStatusEnum.add || col.status === ColumnStatusEnum.delete + ); + + if (table.status === TableStatusEnum.delete) { + deletedTableNames.push(table.tableName); + } else if (table.status === TableStatusEnum.add) { + addedTableNames.push(table.tableName); + } else if (hasColumnChanges) { + modifiedTableNames.push(table.tableName); + } + + return { + ...table, + hasColumnChanges + }; + }); + + setTableChangeSummary({ + modifiedTables: { + count: modifiedTableNames.length, + tableNames: modifiedTableNames + }, + deletedTables: { + count: deletedTableNames.length, + tableNames: deletedTableNames + }, + addedTables: { + count: addedTableNames.length, + tableNames: addedTableNames + }, + hasChanges: + modifiedTableNames.length > 0 || + deletedTableNames.length > 0 || + addedTableNames.length > 0, + hasBannerTip: modifiedTableNames.length > 0 || deletedTableNames.length > 0 + }); + + setUITables(uiData); + + // 找到第一个未删除的表作为默认选中 + const firstAvailableTableIndex = uiData.findIndex( + (table) => table.status !== TableStatusEnum.delete + ); + setCurrentTableIndex(firstAvailableTableIndex >= 0 ? firstAvailableTableIndex : 0); + + // 初始化表单数据 + const firstAvailableTable = + uiData[firstAvailableTableIndex >= 0 ? firstAvailableTableIndex : 0]; + if (firstAvailableTable) { + reset({ + description: firstAvailableTable.description, + columns: firstAvailableTable.columns.filter( + (col) => col.status !== ColumnStatusEnum.delete + ) + }); + + // 标记初始化完成 + isInitializedRef.current = true; + lastSyncDataRef.current = JSON.stringify({ + description: firstAvailableTable.description, + columns: firstAvailableTable.columns.filter( + (col) => col.status !== ColumnStatusEnum.delete + ) + }); + } + } catch (error) { + console.error(t('dataset:init_data_failed'), error); + } + }; + + if (datasetId) { + initData(); } - return ''; - }; + }, [datasetId, isEditMode, reset, t]); + + // 使用 ref 来避免不必要的重新渲染 + const isInitializedRef = useRef(false); + const lastSyncDataRef = useRef(''); + + // 监听表单数据变化,同步到uiTables + useEffect(() => { + if (!currentTable || !isInitializedRef.current) return; + + const currentSyncData = JSON.stringify(watchedCurrentTable); + if (lastSyncDataRef.current !== currentSyncData) { + lastSyncDataRef.current = currentSyncData; + syncCurrentTableToUITables(); + } + }, [watchedCurrentTable, currentTable, syncCurrentTableToUITables]); // 切换表的启用状态 const handleTableSelect = (index: number) => { + // 先同步当前表单数据 + syncCurrentTableToUITables(); + const updatedTables = [...uiTables]; updatedTables[index].enabled = !updatedTables[index].enabled; setUITables(updatedTables); - const updatedTableInfos = [...tableInfos]; - updatedTableInfos[index].tableData = updatedTables[index]; - setTableInfos(updatedTableInfos); - - // 如果当前编辑的是这个表,更新当前表数据并验证 - if (tableInfos[index].isCurrentTable) { - setCurrentTable(updatedTables[index]); - - // 验证表描述 - const tableDescError = validateTableDescription(updatedTables[index]); - setValidationErrors((prev) => ({ - ...prev, - tableDescription: tableDescError - })); + // 如果是当前表,同步到表单 + if (index === currentTableIndex) { + setValue('description', updatedTables[index].description); } }; // 切换当前编辑的表 - const handleChangeTab = (index: number) => { - const currentTableIndex = tableInfos.findIndex((info) => info.isCurrentTable); - if (index === currentTableIndex) return; + const handleChangeTab = useCallback( + (index: number) => { + if (currentTableIndex === index) return; - // 保存当前表的修改 - const updatedTables = [...uiTables]; - updatedTables[currentTableIndex] = currentTable; - setUITables(updatedTables); + // 先同步当前表单数据 + syncCurrentTableToUITables(); - // 更新表信息 - const updatedTableInfos = tableInfos.map((info, i) => ({ - ...info, - isCurrentTable: i === index, - tableData: updatedTables[i] - })); - setTableInfos(updatedTableInfos); + // 切换到新表 + setCurrentTableIndex(index); - // 设置新的当前表 - setCurrentTable(updatedTables[index]); - }; + // 同步新表数据到表单(过滤掉删除的列) + if (uiTables[index]) { + const newFormData = { + description: uiTables[index].description, + columns: uiTables[index].columns.filter((col) => col.status !== ColumnStatusEnum.delete) + }; + reset(newFormData); + lastSyncDataRef.current = JSON.stringify(newFormData); + } + }, + [currentTableIndex, syncCurrentTableToUITables, uiTables, reset] + ); // 修改表描述 const handleChangeTableDesc = (value: string) => { - const updatedTable = { - ...currentTable, - description: value - }; - setCurrentTable(updatedTable); - - // 验证表描述 - const error = validateTableDescription(updatedTable); - setValidationErrors((prev) => ({ - ...prev, - tableDescription: error - })); + setValue('description', value); }; // 修改列信息 const handleChangeColumnData = ( key: K, - index: number, + columnIndex: number, value: UIColumn[K] ) => { - const updatedColumns = [...currentTable.columns]; - updatedColumns[index][key] = value; - const updatedTable = { - ...currentTable, - columns: updatedColumns + const currentColumns = getValues('columns'); + const updatedColumns = [...currentColumns]; + updatedColumns[columnIndex] = { + ...updatedColumns[columnIndex], + [key]: value }; - setCurrentTable(updatedTable); - - // 如果修改的是描述字段,需要验证 - if (key === 'description') { - const error = validateColumnDescription(updatedColumns[index]); - console.log(error); - setValidationErrors((prev) => ({ - ...prev, - columnDescriptions: { - ...prev.columnDescriptions, - [index]: error - } - })); - } + setValue('columns', updatedColumns); }; // 切换列的启用状态 - const handleColumnToggle = (index: number) => { - const newEnabledState = !currentTable.columns[index].enabled; - handleChangeColumnData('enabled', index, newEnabledState); - - // 验证列描述 - const updatedColumn = { ...currentTable.columns[index], enabled: newEnabledState }; - const error = validateColumnDescription(updatedColumn); - setValidationErrors((prev) => ({ - ...prev, - columnDescriptions: { - ...prev.columnDescriptions, - [index]: error - } - })); + const handleColumnToggle = (columnIndex: number) => { + const currentColumn = getValues(`columns.${columnIndex}`); + setValue(`columns.${columnIndex}.enabled`, !currentColumn.enabled); }; // 切换列的索引状态 - const handleValueIndexToggle = (index: number) => { - handleChangeColumnData('valueIndex', index, !currentTable.columns[index].valueIndex); + const handleValueIndexToggle = (columnIndex: number) => { + const currentColumn = getValues(`columns.${columnIndex}`); + setValue(`columns.${columnIndex}.valueIndex`, !currentColumn.valueIndex); }; - const handleConfirm = () => { - // 保存当前表的修改 - const finalTables = [...uiTables]; - const currentTableIndex = tableInfos.findIndex((info) => info.isCurrentTable); - finalTables[currentTableIndex] = currentTable; + // 表单提交处理 + const onSubmit = async (data: CurrentTableFormData) => { + // 先同步当前表单数据 + syncCurrentTableToUITables(); + + // 校验是否存在问题表 + if (problematicTableNames.length > 0) { + // 找到第一个有问题的表的索引 + const firstProblematicTableIndex = uiTables.findIndex((table) => + problematicTableNames.includes(table.tableName) + ); + + if (firstProblematicTableIndex !== -1) { + // 自动切换到第一个不满足的表 + handleChangeTab(firstProblematicTableIndex); + } + + return; // 阻止提交 + } // 转换为后端格式 - const backendData = transformUIToBackend(finalTables); - console.log('Database config confirmed:', backendData); + const backendData = transformUIToBackend(uiTables); + console.log(backendData); - // 这里可以调用API提交数据 - // await submitDatabaseConfig(backendData); + // 调用创建数据库知识库数据集接口 + await createCollections({ datasetId, ...backendData }); }; return { // 状态 currentTable, + currentTableIndex, + uiTables, tableInfos, - validationErrors, - + loading, + isCreating, + changesSummary, + problematicTableNames, + tableChangeSummary, + currentTableColumnChanges, + + // 方法 handleTableSelect, handleChangeTab, handleChangeTableDesc, handleChangeColumnData, handleColumnToggle, handleValueIndexToggle, - handleConfirm + onSubmit }; }; - -// 导出类型定义 -export type { UIColumn, UITableData, BackendColumn, BackendTableData, TableInfo }; diff --git a/projects/app/src/pageComponents/dataset/detail/Import/components/hooks/utils.ts b/projects/app/src/pageComponents/dataset/detail/Import/components/hooks/utils.ts new file mode 100644 index 000000000000..b5c04cd0f576 --- /dev/null +++ b/projects/app/src/pageComponents/dataset/detail/Import/components/hooks/utils.ts @@ -0,0 +1,198 @@ +import type { CreateDatabaseCollectionsBody, DBTableChange } from '@/web/core/dataset/temp.d'; +import { ColumnStatusEnum, TableStatusEnum } from '@/web/core/dataset/temp.d'; +import { GetConfigurationResponse, DetectChangesResponse } from '@/web/core/dataset/temp.d'; +import { i18nT } from '@fastgpt/web/i18n/utils'; + +// 后端数据结构定义 +export interface BackendColumn { + columnName: string; + columnType: string; + description: string; + examples: string[]; + forbid: boolean; + valueIndex: boolean; +} + +export interface BackendTableData { + tableName: string; + description: string; + forbid: boolean; + columns: Record; +} + +// 前端UI使用的数据结构 +export interface UIColumn { + columnName: string; + columnType: string; + description: string; + examples: string[]; + enabled: boolean; + valueIndex: boolean; + status?: ColumnStatusEnum; +} + +export interface UITableData { + tableName: string; + description: string; + enabled: boolean; + columns: UIColumn[]; + status?: TableStatusEnum; + hasColumnChanges: boolean; +} + +export interface TableInfo { + tableData: UITableData; + isCurrentTable: boolean; +} + +// 表单数据结构 - 只包含当前表的数据 +export interface CurrentTableFormData { + description: string; + columns: UIColumn[]; +} + +// 表格变更汇总信息 +export interface TableChangeSummary { + // 存在列变更的表 + modifiedTables: { + count: number; + tableNames: string[]; + }; + // 已删除的表 + deletedTables: { + count: number; + tableNames: string[]; + }; + // 新增的表 + addedTables: { + count: number; + tableNames: string[]; + }; + // 是否有任何变更 + hasChanges: boolean; + // 是否需要横幅提示(新增不需要提示) + hasBannerTip: boolean; +} + +// 当前表格的列变更信息 +export interface CurrentTableColumnChanges { + // 新增列 + addedColumns: { + count: number; + columnNames: string[]; + }; + // 删除列 + deletedColumns: { + count: number; + columnNames: string[]; + }; + // 是否有列变更 + hasColumnChanges: boolean; +} + +// 原有的完整表单数据结构(保留用于兼容) +export interface DatabaseFormData { + tables: UITableData[]; + currentTableIndex: number; +} + +// 数据预处理函数:将后端数据转换为UI数据 +export const transformBackendToUI = (backendTables: BackendTableData[]): UITableData[] => { + return backendTables.map((table) => ({ + tableName: table.tableName, + description: table.description, + enabled: !table.forbid, + status: TableStatusEnum.available, + hasColumnChanges: false, + columns: Object.values(table.columns).map((column) => ({ + columnName: column.columnName, + columnType: column.columnType, + description: column.description, + enabled: !column.forbid, + examples: column.examples, + valueIndex: column.valueIndex, + status: ColumnStatusEnum.available + })) + })); +}; + +// 数据转换函数:将变更检测数据转换为UI数据 +export const transformChangesToUI = (backendTables: DBTableChange[]): UITableData[] => { + return backendTables.map((table) => { + const columns = Object.values(table.columns).map((column) => ({ + columnName: column.columnName, + columnType: column.columnType, + description: column.description, + examples: column.examples, + enabled: column.enabled, + valueIndex: column.valueIndex, + status: column.status + })); + + // 检查是否有列变更 + const hasColumnChanges = columns.some( + (col) => col.status === ColumnStatusEnum.add || col.status === ColumnStatusEnum.delete + ); + + return { + tableName: table.tableName, + description: table.description, + enabled: table.enabled, + columns, + status: table.status, + hasColumnChanges + }; + }); +}; + +// 数据转换函数:将UI数据转换为后端数据 +export const transformUIToBackend = (uiTables: UITableData[]): CreateDatabaseCollectionsBody => { + return { + tables: uiTables + .filter((table) => table.enabled) + .map((table) => ({ + tableName: table.tableName, + description: table.description, + forbid: !table.enabled, + columns: table.columns.reduce( + (acc, column) => { + acc[column.columnName] = { + columnName: column.columnName, + columnType: column.columnType, + description: column.description, + examples: column.examples, + forbid: !column.enabled, + valueIndex: column.valueIndex + }; + return acc; + }, + {} as Record + ) + })) + }; +}; + +// 数据转换函数:将表单数据转换为后端数据(保留用于兼容) +export const transformFormDataToBackend = ( + formData: DatabaseFormData +): CreateDatabaseCollectionsBody => { + return transformUIToBackend(formData.tables); +}; + +// 计算存在问题的表名(表已勾选且表描述为空或列启用但描述为空) +export const getProblematicTableNames = (uiTables: UITableData[]): string[] => { + return uiTables + .filter((table) => { + // 条件1:表已勾选 + if (!table.enabled) return false; + + // 条件2:表描述为空 或者 数据列配置中启用状态为true但是描述为空 + const hasEmptyTableDesc = !table.description.trim(); + const hasEnabledColumnsWithEmptyDesc = table.columns.some( + (column) => column.enabled && !column.description.trim() + ); + + return hasEmptyTableDesc || hasEnabledColumnsWithEmptyDesc; + }) + .map((table) => table.tableName); +}; diff --git a/projects/app/src/web/core/dataset/api.ts b/projects/app/src/web/core/dataset/api.ts index d0c273089250..edae32269f39 100644 --- a/projects/app/src/web/core/dataset/api.ts +++ b/projects/app/src/web/core/dataset/api.ts @@ -80,6 +80,10 @@ import type { GetApiDatasetPathResponse } from '@/pages/api/core/dataset/apiDataset/getPathNames'; import type { DelCollectionBody } from '@/pages/api/core/dataset/collection/delete'; +import type { DatabaseConfig } from '@fastgpt/global/core/dataset/type'; +import type { GetConfigurationResponse } from '@/pages/api/core/dataset/database/getConfiguration'; +import type { DetectChangesResponse } from '@/pages/api/core/dataset/database/detectChanges'; +import type { CreateDatabaseCollectionsBody } from '@/pages/api/core/dataset/database/createCollections'; /* ======================== dataset ======================= */ export const getDatasets = (data: GetDatasetListBody) => @@ -319,3 +323,60 @@ export const getApiDatasetCatalog = (data: GetApiDatasetCataLogProps) => export const getApiDatasetPaths = (data: GetApiDatasetPathBody) => POST('/core/dataset/apiDataset/getPathNames', data); + +/* ================== database ======================== */ +/** + * 搜索测试 (需要结合text2SQL微服务,确认数据口径) + */ +export const postDatasetCollectionSearchTest = (collectionId: string) => + POST(`/core/dataset/collection/searchTest`, { collectionId }); + +/** + * 更新数据配置 + */ +export const postUpdateDatasetCollectionConfig = (data: { + collectionId: string; + [key: string]: any; +}) => POST(`/core/dataset/collection/update`, data); + +/** + * 检测变更 | 刷新数据源 + */ +export const postDetectDatabaseChanges = (data: { datasetId: string }) => + POST(`/core/dataset/database/detectChanges?datasetId=${data.datasetId}`); + +/** + * 创建数据库知识库数据集 + */ +export const postCreateDatabaseCollections = ( + data: { datasetId: string } & CreateDatabaseCollectionsBody +) => { + const { datasetId, ...body } = data; + + return POST(`/core/dataset/database/createCollections?datasetId=${datasetId}`, body); +}; + +/** + * 获取数据配置 + */ +export const postGetDatabaseConfiguration = (data: { datasetId: string }) => + GET( + `/core/dataset/database/getConfiguration?datasetId=${data.datasetId}` + ); + +/** + * 连通性测试 + * TODO-lyx 临时适配后端接口,参数要变更 + */ +export const postCheckDatabaseConnection = (data: { + datasetId: string; + databaseConfig: DatabaseConfig; +}) => { + const { datasetId, databaseConfig } = data; + return POST(`/core/dataset/database/checkConnection?datasetId=${datasetId}`, { databaseConfig }); +}; + +/** + * 更新知识库配置 + */ +export const updateDatasetConfig = (data: DatasetUpdateBody) => POST(`/core/dataset/update`, data); diff --git a/projects/app/src/web/core/dataset/temp.d.ts b/projects/app/src/web/core/dataset/temp.d.ts new file mode 100644 index 000000000000..b3dff9873d88 --- /dev/null +++ b/projects/app/src/web/core/dataset/temp.d.ts @@ -0,0 +1,120 @@ +/** + * TODO-lyx-临时文件后面删除用后端定义的 + */ +type Query = { + datasetId: string; +}; +export type CreateDatabaseCollectionsBody = { + tables: Array<{ + tableName: string; + description: string; + forbid: boolean; + columns: Record< + string, + { + columnName: string; + columnType: string; + description: string; + examples: string[]; + forbid: boolean; + valueIndex: boolean; + } + >; + foreignKeys?: Array<{ + constrainedColumns: string[]; + referredSchema: string | null; + referredTable: string; + referredColumns: string[]; + }>; + primaryKeys?: string[]; + }>; +}; + +export type CreateDatabaseCollectionsResponse = { + collectionIds: string[]; +}; + +export enum ColumnStatusEnum { + add = 'add', + delete = 'delete', + available = 'available' +} + +export enum TableStatusEnum { + add = 'add', + delete = 'delete', + available = 'available' +} + +export type TableColumn = { + columnName: string; + columnType: string; + description: string; + examples: string[]; + status: ColumnStatusEnum; + enabled: boolean; + valueIndex: boolean; +}; + +export type DBTableChange = { + tableName: string; + description: string; + enabled: boolean; + columns: Record; + status: TableStatusEnum; +}; + +export type DetectChangesQuery = { + datasetId: string; +}; + +export type DetectChangesResponse = { + tables: DBTableChange[]; + hasChanges: boolean; + summary: { + addedTables: number; + deletedTables: number; + modifiedTables: number; + addedColumns: number; + deletedColumns: number; + }; +}; + +export type GetConfigurationResponse = { + tables: Array<{ + tableName: string; + description: string; + forbid: boolean; + columns: Record< + string, + { + columnName: string; + columnType: string; + description: string; + examples: string[]; + forbid: boolean; + valueIndex: boolean; + } + >; + foreignKeys: Array<{ + constrainedColumns: string[]; + referredSchema: string | null; + referredTable: string; + referredColumns: string[]; + }>; + primaryKeys: string[]; + }>; +}; + +export type UpdateDatasetCollectionParams = { + id?: string; + parentId?: string; + name?: string; + tags?: string[]; // Not tag id, is tag label + forbid?: boolean; + createTime?: Date; + + // External file id + datasetId?: string; + externalFileId?: string; +}; From ca35e404e0316f6290713ab278f073646414bd33 Mon Sep 17 00:00:00 2001 From: chanzany Date: Fri, 19 Sep 2025 14:20:38 +0800 Subject: [PATCH 35/84] fix: eval task item should support review after task created --- .../service/core/evaluation/task/index.ts | 95 ++++++++++----- .../service/core/evaluation/task/processor.ts | 110 +++++++++++------- 2 files changed, 136 insertions(+), 69 deletions(-) diff --git a/packages/service/core/evaluation/task/index.ts b/packages/service/core/evaluation/task/index.ts index 71ca6c9b96ce..cc82fe6a9937 100644 --- a/packages/service/core/evaluation/task/index.ts +++ b/packages/service/core/evaluation/task/index.ts @@ -1,4 +1,5 @@ import { MongoEvaluation, MongoEvalItem } from './schema'; +import { MongoEvalDatasetData } from '../dataset/evalDatasetDataSchema'; import type { EvaluationSchemaType, EvaluationItemSchemaType, @@ -63,22 +64,59 @@ export class EvaluationTaskService { const evaluationObject = evaluation[0].toObject(); + // Load dataset and create evaluation items immediately + const dataItems = await MongoEvalDatasetData.find({ + evalDatasetCollectionId: evaluationParams.evalDatasetCollectionId, + teamId + }) + .session(session) + .lean(); + + if (dataItems.length === 0) { + throw new Error(EvaluationErrEnum.evalDatasetLoadFailed); + } + + // Create evaluation items for each dataItem + const evalItems: Omit[] = []; + for (const dataItem of dataItems) { + const evaluationDataItem = { + _id: dataItem._id, + userInput: dataItem.userInput, + expectedOutput: dataItem.expectedOutput, + context: dataItem.context, + targetCallParams: undefined + }; + + evalItems.push({ + evalId: evaluationObject._id, + dataItem: evaluationDataItem, + status: EvaluationStatusEnum.queuing, + retry: 3 + }); + } + + // Batch insert evaluation items within transaction + const insertedItems = await MongoEvalItem.insertMany(evalItems, { session }); + addLog.debug(`[Evaluation] Created ${insertedItems.length} evaluation items`); + + // Update evaluation statistics + await MongoEvaluation.updateOne( + { _id: evaluationObject._id }, + { + $set: { + 'statistics.totalItems': insertedItems.length + } + }, + { session } + ); + // Auto-start the evaluation if autoStart is true if (autoStart) { - // Update status to evaluating within transaction - await MongoEvaluation.updateOne( - { _id: evaluationObject._id }, - { $set: { status: EvaluationStatusEnum.evaluating } }, - { session } - ); - - // Queue operation within transaction - if it fails, transaction will rollback + // Queue operation within transaction - processor will handle status updates await evaluationTaskQueue.add(`eval_task_${evaluationObject._id}`, { evalId: evaluationObject._id.toString() }); - // Update status in returned object - evaluationObject.status = EvaluationStatusEnum.evaluating; addLog.debug(`[Evaluation] Task created and auto-started: ${evaluationObject._id}`); } else { addLog.debug(`[Evaluation] Task created: ${evaluationObject._id}`); @@ -506,31 +544,28 @@ export class EvaluationTaskService { throw new Error(EvaluationErrEnum.evalInvalidStateTransition); } - // Update status to processing and clear error message if restarting - const updateData: any = { status: EvaluationStatusEnum.evaluating }; - const unsetData: any = {}; - + // Clear error message if restarting + const updateQuery: any = {}; if (evaluation.status === EvaluationStatusEnum.error) { - unsetData.errorMessage = 1; - unsetData.finishTime = 1; - } - - const updateQuery: any = { $set: updateData }; - if (Object.keys(unsetData).length > 0) { - updateQuery.$unset = unsetData; + updateQuery.$unset = { + errorMessage: 1, + finishTime: 1 + }; } - // Use transaction to ensure atomicity between status update and queue submission + // Use transaction to ensure atomicity between cleanup and queue submission const startEval = async (session: ClientSession) => { - // Update status within transaction - const result = await MongoEvaluation.updateOne( - { _id: new Types.ObjectId(evalId), teamId: new Types.ObjectId(teamId) }, - updateQuery, - { session } - ); + // Clear error state if needed, but leave status as queuing for processor to handle + if (Object.keys(updateQuery).length > 0) { + const result = await MongoEvaluation.updateOne( + { _id: new Types.ObjectId(evalId), teamId: new Types.ObjectId(teamId) }, + updateQuery, + { session } + ); - if (result.matchedCount === 0) { - throw new Error(EvaluationErrEnum.evalTaskNotFound); + if (result.matchedCount === 0) { + throw new Error(EvaluationErrEnum.evalTaskNotFound); + } } // Queue operation within transaction - if it fails, transaction will rollback diff --git a/packages/service/core/evaluation/task/processor.ts b/packages/service/core/evaluation/task/processor.ts index da1fb5540817..86c364c0e051 100644 --- a/packages/service/core/evaluation/task/processor.ts +++ b/packages/service/core/evaluation/task/processor.ts @@ -519,27 +519,42 @@ const evaluationTaskProcessor = async (job: Job) => { // Report initial progress await job.updateProgress(0); - // Get evaluation task information - const evaluation = await MongoEvaluation.findById(evalId).lean(); + // Update status to evaluating and get evaluation data in one atomic operation + const evaluation = await MongoEvaluation.findOneAndUpdate( + { + _id: new Types.ObjectId(evalId), + status: { + $in: [ + EvaluationStatusEnum.queuing, + EvaluationStatusEnum.error // Allow restarting manually stopped tasks + ] + } + }, + { $set: { status: EvaluationStatusEnum.evaluating } }, + { returnDocument: 'after', lean: true } + ); + + // If the task cannot be updated, it's either already processing or in an invalid state if (!evaluation) { - addLog.warn(`[Evaluation] Evaluation task does not exist: ${evalId}`); + // Get current status for better error reporting + const currentEval = await MongoEvaluation.findById(evalId).select('status').lean(); + if (!currentEval) { + addLog.warn(`[Evaluation] Task ${evalId} no longer exists, skipping`); + } else if (currentEval.status === EvaluationStatusEnum.evaluating) { + addLog.warn( + `[Evaluation] Task ${evalId} is already being processed by another worker, skipping` + ); + } else { + addLog.warn( + `[Evaluation] Task ${evalId} is in state '${currentEval.status}', cannot process` + ); + } return; } - // Load dataset - const dataItems = await MongoEvalDatasetData.find({ - evalDatasetCollectionId: evaluation.evalDatasetCollectionId, - teamId: evaluation.teamId - }).lean(); - - // Report progress: dataset loaded - await job.updateProgress(20); - - if (dataItems.length === 0) { - throw new Error(EvaluationErrEnum.evalDatasetLoadFailed); - } + addLog.debug(`[Evaluation] Task status updated to evaluating: ${evalId}`); - // Validate target and evaluators configuration + // Validate target and evaluators configuration early if (!evaluation.target || !evaluation.target.type || !evaluation.target.config) { throw new Error(EvaluationErrEnum.evalTargetConfigInvalid); } @@ -548,52 +563,69 @@ const evaluationTaskProcessor = async (job: Job) => { throw new Error(EvaluationErrEnum.evalEvaluatorsConfigInvalid); } - // Check if evaluation items already exist (reentrant handling) + // Report progress: validation completed + await job.updateProgress(20); + + // Check if evaluation items already exist (created during task creation) const existingItems = await MongoEvalItem.find({ evalId }).lean(); if (existingItems.length > 0) { - addLog.debug(`[Evaluation] Task already has ${existingItems.length} items, resuming...`); + // Normal path: items were created during task creation + addLog.debug( + `[Evaluation] Task already has ${existingItems.length} items, submitting to queue...` + ); - // Re-submit unfinished items to queue - const pendingItems = existingItems.filter( + // Submit pending and retryable items to queue + const itemsToProcess = existingItems.filter( (item) => item.status === EvaluationStatusEnum.queuing || (item.status === EvaluationStatusEnum.error && item.retry > 0) ); - if (pendingItems.length > 0) { - const jobs = pendingItems.map((item, index) => ({ - name: `eval_item_${evalId}_resume_${index}`, + if (itemsToProcess.length > 0) { + const jobs = itemsToProcess.map((item, index) => ({ + name: `eval_item_${evalId}_${index}`, data: { evalId, evalItemId: item._id.toString() }, opts: { - delay: index * 100 + delay: index * 100 // Add small delay to avoid starting too many tasks simultaneously } })); await evaluationItemQueue.addBulk(jobs); - addLog.debug(`[Evaluation] Resumed ${jobs.length} pending items`); + addLog.debug(`[Evaluation] Submitted ${jobs.length} items to queue`); + } else { + addLog.debug(`[Evaluation] No items to process, all items are completed or failed`); } + + // Report final progress + await job.updateProgress(100); return; } - // Create evaluation items for each dataItem (batch structure) + // Fallback: Create evaluation items if they don't exist (backward compatibility) + // This should rarely happen with the new flow + addLog.warn(`[Evaluation] No existing items found for evaluation ${evalId}, creating items...`); + + // Load dataset only when we need to create items (rare case) + const dataItems = await MongoEvalDatasetData.find({ + evalDatasetCollectionId: evaluation.evalDatasetCollectionId, + teamId: evaluation.teamId + }).lean(); + + if (dataItems.length === 0) { + throw new Error(EvaluationErrEnum.evalDatasetLoadFailed); + } + + // Create evaluation items for each dataItem const evalItems: Omit[] = []; for (const dataItem of dataItems) { - // Extract only the necessary fields for evaluation execution const evaluationDataItem: EvaluationDataItemType = { _id: dataItem._id, userInput: dataItem.userInput, expectedOutput: dataItem.expectedOutput, context: dataItem.context, - // TODO: Handle targetCallParams population for evaluation data items - // The dataItems loaded from dataset only contain basic EvalDatasetDataSchemaType fields - // but evaluation items need EvaluationDataItemType (including targetCallParams). - // Need to: - // 1. Determine source of targetCallParams (evaluation config, dataset metadata, or default) - // 2. Transform dataItems to include targetCallParams before creating evaluation items - // 3. Consider caching strategy for targetCallParams if they are dynamic per evaluation targetCallParams: undefined }; @@ -613,10 +645,7 @@ const evaluationTaskProcessor = async (job: Job) => { // Batch insert evaluation items const insertedItems = await MongoEvalItem.insertMany(evalItems); - addLog.debug(`[Evaluation] Created ${insertedItems.length} batch evaluation items`); - - // Report progress: items created - await job.updateProgress(80); + addLog.debug(`[Evaluation] Created ${insertedItems.length} evaluation items`); // Submit to evaluation item queue for concurrent processing const jobs = insertedItems.map((item, index) => ({ @@ -626,7 +655,7 @@ const evaluationTaskProcessor = async (job: Job) => { evalItemId: item._id.toString() }, opts: { - delay: index * 100 // Add small delay to avoid starting too many tasks simultaneously + delay: index * 100 } })); @@ -905,6 +934,9 @@ const evaluationItemProcessor = async (job: Job) => { aggregateScore: aggregateScore, status: EvaluationStatusEnum.completed, finishTime: new Date() + }, + $unset: { + errorMessage: 1 // Clear any previous error message } } ); From 7e073bc3a4883adffa47357fa6dcfc2998fbd45b Mon Sep 17 00:00:00 2001 From: hello_strong <1145607886@qq.com> Date: Fri, 19 Sep 2025 14:56:56 +0800 Subject: [PATCH 36/84] feat: task item list api return evaluators --- packages/global/core/evaluation/type.d.ts | 7 +++++- .../service/core/evaluation/task/index.ts | 22 +++++++++++++++++-- .../service/core/evaluation/task/processor.ts | 6 ----- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/packages/global/core/evaluation/type.d.ts b/packages/global/core/evaluation/type.d.ts index 38f9d6f7d1bb..0d633cef4df7 100644 --- a/packages/global/core/evaluation/type.d.ts +++ b/packages/global/core/evaluation/type.d.ts @@ -158,7 +158,12 @@ export type EvaluationDisplayType = EvaluationWithPerType & { sourceMember: SourceMemberType; }; -export type EvaluationItemDisplayType = EvaluationItemSchemaType; +export type EvaluationItemDisplayType = EvaluationItemSchemaType & { + evaluators: Array<{ + metric: EvalMetricSchemaType; // Contains complete metric configuration + thresholdValue?: number; // Threshold value for this evaluator + }>; // Array of evaluator configurations +}; export interface CreateEvaluationParams { name: string; diff --git a/packages/service/core/evaluation/task/index.ts b/packages/service/core/evaluation/task/index.ts index cc82fe6a9937..dc021cb2697e 100644 --- a/packages/service/core/evaluation/task/index.ts +++ b/packages/service/core/evaluation/task/index.ts @@ -777,7 +777,16 @@ export class EvaluationTaskService { const items = await MongoEvalItem.aggregate(aggregationPipeline); - return { items, total }; + // Add evaluators data from parent evaluation + const itemsWithEvaluators = items.map((item) => ({ + ...item, + evaluators: evaluation.evaluators.map((evaluator) => ({ + metric: evaluator.metric, + thresholdValue: evaluator.thresholdValue + })) + })); + + return { items: itemsWithEvaluators, total }; } else { // Handle normal status filtering if (status !== undefined) { @@ -804,7 +813,16 @@ export class EvaluationTaskService { MongoEvalItem.countDocuments(filter) ]); - return { items, total }; + // Add evaluators data from parent evaluation + const itemsWithEvaluators = items.map((item) => ({ + ...item, + evaluators: evaluation.evaluators.map((evaluator) => ({ + metric: evaluator.metric, + thresholdValue: evaluator.thresholdValue + })) + })); + + return { items: itemsWithEvaluators, total }; } } diff --git a/packages/service/core/evaluation/task/processor.ts b/packages/service/core/evaluation/task/processor.ts index 86c364c0e051..903ef70ca2bc 100644 --- a/packages/service/core/evaluation/task/processor.ts +++ b/packages/service/core/evaluation/task/processor.ts @@ -629,15 +629,9 @@ const evaluationTaskProcessor = async (job: Job) => { targetCallParams: undefined }; - // Initialize evaluatorOutputs array based on the evaluators schema definition - const evaluatorOutputs: MetricResult[] = evaluation.evaluators.map((evaluator) => ({ - metricName: evaluator.metric.name - })); - evalItems.push({ evalId, dataItem: evaluationDataItem, - evaluatorOutputs, status: EvaluationStatusEnum.queuing, retry: maxRetries }); From 16d5674fc9738788d2a9a6f9bba986bd1419d6cc Mon Sep 17 00:00:00 2001 From: hello_strong <1145607886@qq.com> Date: Fri, 19 Sep 2025 16:25:40 +0800 Subject: [PATCH 37/84] feat: add weight in evaluator --- packages/global/core/evaluation/type.d.ts | 1 + packages/service/core/evaluation/task/index.ts | 11 +++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/global/core/evaluation/type.d.ts b/packages/global/core/evaluation/type.d.ts index 0d633cef4df7..5d80dc1ac0e4 100644 --- a/packages/global/core/evaluation/type.d.ts +++ b/packages/global/core/evaluation/type.d.ts @@ -162,6 +162,7 @@ export type EvaluationItemDisplayType = EvaluationItemSchemaType & { evaluators: Array<{ metric: EvalMetricSchemaType; // Contains complete metric configuration thresholdValue?: number; // Threshold value for this evaluator + weight?: number; }>; // Array of evaluator configurations }; diff --git a/packages/service/core/evaluation/task/index.ts b/packages/service/core/evaluation/task/index.ts index dc021cb2697e..17b0620b67d5 100644 --- a/packages/service/core/evaluation/task/index.ts +++ b/packages/service/core/evaluation/task/index.ts @@ -383,6 +383,7 @@ export class EvaluationTaskService { metricNames: 1, statistics: 1, summaryConfigs: 1, + aggregateScore: 1, tmbId: 1 } }, @@ -780,9 +781,10 @@ export class EvaluationTaskService { // Add evaluators data from parent evaluation const itemsWithEvaluators = items.map((item) => ({ ...item, - evaluators: evaluation.evaluators.map((evaluator) => ({ + evaluators: evaluation.evaluators.map((evaluator, index) => ({ metric: evaluator.metric, - thresholdValue: evaluator.thresholdValue + thresholdValue: evaluator.thresholdValue, + weight: evaluation.summaryConfigs[index]?.weight })) })); @@ -816,9 +818,10 @@ export class EvaluationTaskService { // Add evaluators data from parent evaluation const itemsWithEvaluators = items.map((item) => ({ ...item, - evaluators: evaluation.evaluators.map((evaluator) => ({ + evaluators: evaluation.evaluators.map((evaluator, index) => ({ metric: evaluator.metric, - thresholdValue: evaluator.thresholdValue + thresholdValue: evaluator.thresholdValue, + weight: evaluation.summaryConfigs[index]?.weight })) })); From 544b2e58160167c35ece07452add4d197cebfb19 Mon Sep 17 00:00:00 2001 From: chanzany Date: Fri, 19 Sep 2025 16:43:16 +0800 Subject: [PATCH 38/84] feat: add failed(no-pass) count in stats API return --- packages/global/core/evaluation/api.d.ts | 1 + .../service/core/evaluation/task/index.ts | 133 ++++++++++++++---- projects/app/src/web/core/evaluation/task.ts | 2 +- 3 files changed, 111 insertions(+), 25 deletions(-) diff --git a/packages/global/core/evaluation/api.d.ts b/packages/global/core/evaluation/api.d.ts index 142a11c4f217..90a28a0c3965 100644 --- a/packages/global/core/evaluation/api.d.ts +++ b/packages/global/core/evaluation/api.d.ts @@ -56,6 +56,7 @@ export type EvaluationStatsResponse = { evaluating: number; queuing: number; error: number; + failed: number; }; // Export Evaluation Items diff --git a/packages/service/core/evaluation/task/index.ts b/packages/service/core/evaluation/task/index.ts index 17b0620b67d5..e7f60df60999 100644 --- a/packages/service/core/evaluation/task/index.ts +++ b/packages/service/core/evaluation/task/index.ts @@ -25,6 +25,69 @@ import { mongoSessionRun } from '../../../common/mongo/sessionRun'; import { type ClientSession } from '../../../common/mongo'; export class EvaluationTaskService { + /** + * Build evaluator fail checks for MongoDB aggregation pipeline + * Used by both getEvaluationStats and listEvaluationItems for consistency + */ + private static buildEvaluatorFailChecks(evaluators: any[]) { + return evaluators.map((evaluator, index) => { + const threshold = evaluator.thresholdValue || 0.8; + return { + $or: [ + { + $eq: [ + { + $getField: { + field: 'score', + input: { + $getField: { + field: 'data', + input: { $arrayElemAt: ['$evaluatorOutputs', index] } + } + } + } + }, + null + ] + }, + { + $eq: [ + { + $type: { + $getField: { + field: 'score', + input: { + $getField: { + field: 'data', + input: { $arrayElemAt: ['$evaluatorOutputs', index] } + } + } + } + } + }, + 'missing' + ] + }, + { + $lt: [ + { + $getField: { + field: 'score', + input: { + $getField: { + field: 'data', + input: { $arrayElemAt: ['$evaluatorOutputs', index] } + } + } + } + }, + threshold + ] + } + ] + }; + }); + } static async createEvaluation( params: CreateEvaluationParams & { teamId: string; @@ -655,11 +718,22 @@ export class EvaluationTaskService { evaluating: number; queuing: number; error: number; + failed: number; }> { const evaluation = await this.getEvaluation(evalId, teamId); - const [statsResult] = await MongoEvalItem.aggregate([ + // Build dynamic expressions for checking if each evaluator output fails threshold + const evaluators = evaluation.evaluators || []; + const evaluatorFailChecks = this.buildEvaluatorFailChecks(evaluators); + + const pipeline = [ { $match: { evalId: evaluation._id } }, + { + $addFields: { + // Add a field to check if this item has any failed evaluators + hasFailedEvaluator: evaluatorFailChecks.length > 0 ? { $or: evaluatorFailChecks } : false + } + }, { $group: { _id: null, @@ -675,21 +749,37 @@ export class EvaluationTaskService { }, error: { $sum: { $cond: [{ $eq: ['$status', EvaluationStatusEnum.error] }, 1, 0] } + }, + // Count failed items (completed items that have at least one failed evaluator) + failed: { + $sum: { + $cond: [ + { + $and: [ + { $eq: ['$status', EvaluationStatusEnum.completed] }, + '$hasFailedEvaluator' + ] + }, + 1, + 0 + ] + } } } } - ]); + ]; + + const [statsResult] = await MongoEvalItem.aggregate(pipeline); // Return stats with defaults for empty results - const result = { + return { total: statsResult?.total || 0, completed: statsResult?.completed || 0, evaluating: statsResult?.evaluating || 0, queuing: statsResult?.queuing || 0, - error: statsResult?.error || 0 + error: statsResult?.error || 0, + failed: statsResult?.failed || 0 }; - - return result; } // ========================= Evaluation Item Related APIs ========================= @@ -716,31 +806,26 @@ export class EvaluationTaskService { // Handle special belowThreshold filter if (belowThreshold) { - // Filter for completed items where aggregateScore is below weighted threshold + // Filter for completed items that have at least one failed evaluator (same logic as getEvaluationStats) filter.status = EvaluationStatusEnum.completed; - // Calculate weighted threshold from evaluators and summaryConfigs - let totalWeightedThreshold = 0; - let totalWeight = 0; - - evaluation.evaluators.forEach((evaluator, index) => { - const weight = evaluation.summaryConfigs[index]?.weight || 0; - const threshold = evaluator.thresholdValue || 0; - totalWeightedThreshold += weight * threshold; - totalWeight += weight; - }); + // Build dynamic expressions for checking if each evaluator output fails threshold (same as getEvaluationStats) + const evaluators = evaluation.evaluators || []; + const evaluatorFailChecks = this.buildEvaluatorFailChecks(evaluators); - const weightedThreshold = totalWeight > 0 ? totalWeightedThreshold / totalWeight : 0; - - // Build aggregation pipeline to filter items with aggregateScore below weighted threshold + // Build aggregation pipeline to filter items that have any failed evaluators const aggregationPipeline: any[] = [ { $match: filter }, + { + $addFields: { + // Add a field to check if this item has any failed evaluators (same logic as getEvaluationStats) + hasFailedEvaluator: + evaluatorFailChecks.length > 0 ? { $or: evaluatorFailChecks } : false + } + }, { $match: { - $and: [ - { aggregateScore: { $exists: true } }, - { aggregateScore: { $lt: weightedThreshold } } - ] + hasFailedEvaluator: true } } ]; diff --git a/projects/app/src/web/core/evaluation/task.ts b/projects/app/src/web/core/evaluation/task.ts index abd9aca5613d..bea3783cd62f 100644 --- a/projects/app/src/web/core/evaluation/task.ts +++ b/projects/app/src/web/core/evaluation/task.ts @@ -101,7 +101,7 @@ export const getEvaluationStats = (evalId: string) => { evaluating: 2, queuing: 1, error: 2, - avgScore: 85.5 + failed: 3 } as EvaluationStatsResponse); // 真实接口调用(联调时启用) From c6b0a38c81698941788fd5251478c8cbb7d04f9a Mon Sep 17 00:00:00 2001 From: lyx Date: Fri, 19 Sep 2025 16:51:31 +0800 Subject: [PATCH 39/84] feat(dataset): Enhance database configuration and refresh functionality - Add multi-step navigation support for the database configuration page - Implement change detection and refresh functionality for the database, including a change notification popup - Optimize the operation menu for the database list table to differentiate between actions based on different statuses - Introduce internationalization fields for database change notifications - Fix the initial selection logic on the database configuration page to support URL parameters for specifying table names - Improve the initialization logic for the database configuration form - Add a loading indicator for the database refresh status --- packages/web/i18n/en/dataset.json | 8 +- packages/web/i18n/zh-CN/dataset.json | 8 +- packages/web/i18n/zh-Hant/dataset.json | 8 +- .../dataset/detail/CollectionCard/Context.tsx | 16 ++- .../CollectionCard/DatabaseListTable.tsx | 38 ++++-- .../dataset/detail/CollectionCard/Header.tsx | 124 ++++++++++++++---- .../dataset/detail/CollectionCard/index.tsx | 28 ++-- .../dataset/detail/Import/Context.tsx | 6 +- .../Import/components/DataBaseConfig.tsx | 2 +- .../components/hooks/useDataBaseConfig.ts | 41 +++--- 10 files changed, 213 insertions(+), 66 deletions(-) diff --git a/packages/web/i18n/en/dataset.json b/packages/web/i18n/en/dataset.json index 06af756d7b7d..a97a3211a6d0 100644 --- a/packages/web/i18n/en/dataset.json +++ b/packages/web/i18n/en/dataset.json @@ -314,5 +314,11 @@ "please_verify_data": "请核查最新数据。", "changes_detected": "发现", "new_columns_added_disabled": "个新增列(默认未启用),", - "columns_no_longer_exist": "个列已不存在," + "columns_no_longer_exist": "个列已不存在,", + "comma": ",", + "check_latest_data": ",请核查最新数据。", + "config": "配置", + "refresh_failed": "刷新失败", + "unknown_error": "未知错误", + "no_data_available": "暂无数据" } diff --git a/packages/web/i18n/zh-CN/dataset.json b/packages/web/i18n/zh-CN/dataset.json index 36f9bebea770..a7dd93c4930f 100644 --- a/packages/web/i18n/zh-CN/dataset.json +++ b/packages/web/i18n/zh-CN/dataset.json @@ -335,5 +335,11 @@ "please_verify_data": "请核查最新数据。", "changes_detected": "发现", "new_columns_added_disabled": "个新增列(默认未启用),", - "columns_no_longer_exist": "个列已不存在," + "columns_no_longer_exist": "个列已不存在,", + "comma": ",", + "check_latest_data": ",请核查最新数据。", + "config": "配置", + "refresh_failed": "刷新失败", + "unknown_error": "未知错误", + "no_data_available": "暂无数据" } diff --git a/packages/web/i18n/zh-Hant/dataset.json b/packages/web/i18n/zh-Hant/dataset.json index 16537d71c1bc..6649ca6907e8 100644 --- a/packages/web/i18n/zh-Hant/dataset.json +++ b/packages/web/i18n/zh-Hant/dataset.json @@ -314,5 +314,11 @@ "please_verify_data": "請核查最新數據。", "changes_detected": "發現", "new_columns_added_disabled": "個新增列(默認未啟用),", - "columns_no_longer_exist": "個列已不存在," + "columns_no_longer_exist": "個列已不存在,", + "comma": ",", + "check_latest_data": ",請核查最新數據。", + "config": "配置", + "refresh_failed": "刷新失敗", + "unknown_error": "未知錯誤", + "no_data_available": "暫無數據" } diff --git a/projects/app/src/pageComponents/dataset/detail/CollectionCard/Context.tsx b/projects/app/src/pageComponents/dataset/detail/CollectionCard/Context.tsx index a94e402bd5a5..97644f2898a6 100644 --- a/projects/app/src/pageComponents/dataset/detail/CollectionCard/Context.tsx +++ b/projects/app/src/pageComponents/dataset/detail/CollectionCard/Context.tsx @@ -18,6 +18,7 @@ import { isEmpty } from 'lodash'; import { useMemo } from 'react'; import { TabEnum } from '../../../../pages/dataset/detail/index'; import { ImportDataSourceEnum } from '@fastgpt/global/core/dataset/constants'; +import { omit } from 'lodash'; const WebSiteConfigModal = dynamic(() => import('./WebsiteConfig')); @@ -36,7 +37,11 @@ type CollectionPageContextType = { filterTags: string[]; setFilterTags: Dispatch>; hasDatabaseConfig: boolean; - handleOpenConfigPage: (mode?: 'edit' | 'create', databaseName?: string) => void; + handleOpenConfigPage: ( + mode?: 'edit' | 'create', + databaseName?: string, + activeStep?: number + ) => void; }; export const CollectionPageContext = createContext({ @@ -149,13 +154,18 @@ const CollectionPageContextProvider = ({ children }: { children: ReactNode }) => // database const hasDatabaseConfig = useMemo(() => !isEmpty(datasetDetail.databaseConfig), [datasetDetail]); - const handleOpenConfigPage = (mode: 'edit' | 'create' = 'create', databaseName?: string) => { + const handleOpenConfigPage = ( + mode: 'edit' | 'create' = 'create', + databaseName?: string, + activeStep = 0 + ) => { router.replace({ query: { - ...router.query, + ...omit(router.query, ['databaseName']), currentTab: TabEnum.import, source: ImportDataSourceEnum.database, mode, + activeStep, ...(databaseName ? { databaseName diff --git a/projects/app/src/pageComponents/dataset/detail/CollectionCard/DatabaseListTable.tsx b/projects/app/src/pageComponents/dataset/detail/CollectionCard/DatabaseListTable.tsx index f82de3045bfd..29d3a6da30fb 100644 --- a/projects/app/src/pageComponents/dataset/detail/CollectionCard/DatabaseListTable.tsx +++ b/projects/app/src/pageComponents/dataset/detail/CollectionCard/DatabaseListTable.tsx @@ -27,7 +27,7 @@ interface DatabaseListTableProps { total: number; onUpdateCollection: (params: { id: string; forbid: boolean }) => void; onTrainingStatesClick: (collectionId: string) => void; - onDataConfigClick: (collectionId: string) => void; + onDataConfigClick: (databaseName: string, activeStep: number) => void; onRemoveClick: (collectionId: string) => void; } @@ -63,7 +63,7 @@ const DatabaseListTable: React.FC = ({ {collection.name} - {collection.name} + {collection.name} Mock-差描述字段 {formatTime2YMDHM(collection.createTime)} {formatTime2YMDHM(collection.updateTime)} @@ -128,16 +128,36 @@ const DatabaseListTable: React.FC = ({ } menuList={[ + ...(collection.statusKey === 'ready' + ? [ + { + children: [ + { + label: t('dataset:data_config'), + icon: 'common/setting', + onClick: () => { + onDataConfigClick(collection.name, 1); + } + } + ] + } + ] + : []), { children: [ { - label: t('dataset:data_config'), - onClick: () => { - onDataConfigClick(collection._id); - } - }, - { - label: t('dataset:remove'), + label: ( + + + {t('dataset:remove')} + + ), + type: 'danger', onClick: () => { onRemoveClick(collection._id); } diff --git a/projects/app/src/pageComponents/dataset/detail/CollectionCard/Header.tsx b/projects/app/src/pageComponents/dataset/detail/CollectionCard/Header.tsx index e27b4cff0a8f..db48c26a71ca 100644 --- a/projects/app/src/pageComponents/dataset/detail/CollectionCard/Header.tsx +++ b/projects/app/src/pageComponents/dataset/detail/CollectionCard/Header.tsx @@ -17,7 +17,8 @@ import { import { getDatasetCollectionPathById, postDatasetCollection, - putDatasetCollectionById + putDatasetCollectionById, + postDetectDatabaseChanges } from '@/web/core/dataset/api'; import { useTranslation } from 'next-i18next'; import MyIcon from '@fastgpt/web/components/common/Icon'; @@ -127,6 +128,103 @@ const Header = ({ hasTrainingData }: { hasTrainingData: boolean }) => { } ); + const { runAsync: onDetectDatabaseChanges, loading: isDetecting } = useRequest2( + async () => { + const result = await postDetectDatabaseChanges({ datasetId: datasetDetail._id }); + return result; + }, + { + manual: true, + errorToast: '' + } + ); + + const handleRefreshDataSource = async () => { + try { + const result = await onDetectDatabaseChanges(); + console.log(result.summary); + + const toastId = toast({ + position: 'bottom-right', + duration: null, + render: () => ( + + + + {t('dataset:refresh_success')} + + {!(result.summary.modifiedTables > 0 || result.summary.deletedTables > 0) ? ( + t('dataset:no_data_changes') + ) : ( + + {t('dataset:found')} + {result.summary.modifiedTables > 0 && ( + <> + {result.summary.modifiedTables} {t('dataset:tables_with_column_changes')} + {result.summary.deletedTables > 0 && t('dataset:comma')} + + )} + {result.summary.deletedTables > 0 && ( + <> + {result.summary.deletedTables} {t('dataset:tables_not_exist')} + + )} + {t('dataset:check_latest_data')} + + + )} + + + toast.close(toastId)} + /> + + ) + }); + } catch (error: any) { + const toastId = toast({ + position: 'bottom-right', + duration: null, + render: () => ( + + + + {t('dataset:refresh_failed')} + + {error?.message || t('dataset:unknown_error')} + + + toast.close(toastId)} + /> + + ) + }); + } + }; + const isWebSite = datasetDetail?.type === DatasetTypeEnum.websiteDataset; const isDatabase = datasetDetail?.type === DatasetTypeEnum.database; @@ -494,28 +592,8 @@ const Header = ({ hasTrainingData }: { hasTrainingData: boolean }) => { diff --git a/projects/app/src/pageComponents/dashboard/evaluation/task/detail/DetailedResponseModal.tsx b/projects/app/src/pageComponents/dashboard/evaluation/task/detail/DetailedResponseModal.tsx new file mode 100644 index 000000000000..bf0a6c2f4801 --- /dev/null +++ b/projects/app/src/pageComponents/dashboard/evaluation/task/detail/DetailedResponseModal.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { useTranslation } from 'next-i18next'; +import MyModal from '@fastgpt/web/components/common/MyModal'; +import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; +import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; +import { getChatResData } from '@/web/core/chat/api'; +import { ResponseBox } from '@/components/core/chat/components/WholeResponseModal'; + +interface DetailedResponseModalProps { + isOpen: boolean; + onClose: () => void; + chatId?: string; + dataId: string; + appId: string; + chatTime?: Date; +} + +const DetailedResponseModal = ({ + isOpen, + onClose, + chatId, + dataId, + appId, + chatTime = new Date() +}: DetailedResponseModalProps) => { + const { t } = useTranslation(); + + const { loading: isLoading, data: response } = useRequest2( + () => getChatResData({ chatId, dataId, appId }), + { + manual: false, + ready: isOpen && !!dataId && !!appId + } + ); + + return ( + + {!!response?.length ? ( + + ) : ( + + )} + + ); +}; + +export default DetailedResponseModal; diff --git a/projects/app/src/pageComponents/dashboard/evaluation/task/detail/EvaluationSummaryCard.tsx b/projects/app/src/pageComponents/dashboard/evaluation/task/detail/EvaluationSummaryCard.tsx new file mode 100644 index 000000000000..b1bbbf02c9b5 --- /dev/null +++ b/projects/app/src/pageComponents/dashboard/evaluation/task/detail/EvaluationSummaryCard.tsx @@ -0,0 +1,160 @@ +import React, { useState } from 'react'; +import { Box, Flex, Text, IconButton } from '@chakra-ui/react'; +import { useTranslation } from 'next-i18next'; +import { ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons'; +import MyImage from '@fastgpt/web/components/common/Image/MyImage'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import GradientBorderBox from './GradientBorderBox'; +import { SummaryStatusEnum } from '@fastgpt/global/core/evaluation/constants'; +import { getBuiltinDimensionInfo } from '@/web/core/evaluation/utils'; + +interface EvaluationSummaryData { + metricName: string; + metricScore: number; + threshold: number; + summaryStatus: string; + customSummary?: string; + errorReason?: string; +} + +interface EvaluationSummaryCardProps { + data: EvaluationSummaryData[]; +} + +const EvaluationSummaryCard: React.FC = ({ data }) => { + const { t } = useTranslation(); + const [currentIndex, setCurrentIndex] = useState(0); + + const handlePrevious = () => { + if (currentIndex > 0) { + setCurrentIndex(currentIndex - 1); + } + }; + + const handleNext = () => { + if (currentIndex < data.length - 1) { + setCurrentIndex(currentIndex + 1); + } + }; + + const renderSummaryContent = (item: EvaluationSummaryData) => { + switch (item.summaryStatus) { + case SummaryStatusEnum.completed: + return ( + + {item.customSummary} + + ); + case SummaryStatusEnum.failed: + return ( + + + {t('总结内容生成异常,点击重试')} + + + {t('报错信息:')} + {item.errorReason} + + + ); + case SummaryStatusEnum.pending: + return ( + + + + {t('总结内容待生成')} + + + ); + case SummaryStatusEnum.generating: + return ( + + + + {t('总结内容生成中')} + + + ); + default: + return null; + } + }; + + const renderSingleDimension = (item: EvaluationSummaryData, index: number) => { + const dimensionInfo = getBuiltinDimensionInfo(item.metricName); + const dimensionName = t(dimensionInfo?.name) || item.metricName; + const isExpected = item.metricScore >= item.threshold; + const title = `${dimensionName}${isExpected ? t('符合预期!') : t('低于预期分数!')}`; + + return ( + + + + + {title} + + + {renderSummaryContent(item)} + + ); + }; + + if (data.length === 0) { + return null; + } + + // 单个维度场景 + if (data.length === 1) { + return {renderSingleDimension(data[0], 0)}; + } + + // 两个维度场景 + if (data.length === 2) { + return ( + + {renderSingleDimension(data[0], 0)} + + {renderSingleDimension(data[1], 1)} + + ); + } + + // 三个及以上维度场景 - 轮播模式 + return ( + + {renderSingleDimension(data[currentIndex], currentIndex)} + + + } + size="sm" + variant="ghost" + isDisabled={currentIndex === 0} + onClick={handlePrevious} + color={currentIndex === 0 ? 'myGray.300' : 'myGray.600'} + /> + + + {currentIndex + 1}/{data.length} + + + } + size="sm" + variant="ghost" + isDisabled={currentIndex === data.length - 1} + onClick={handleNext} + color={currentIndex === data.length - 1 ? 'myGray.300' : 'myGray.600'} + /> + + + ); +}; + +export default EvaluationSummaryCard; diff --git a/projects/app/src/pageComponents/dashboard/evaluation/task/detail/GradientBorderBox.tsx b/projects/app/src/pageComponents/dashboard/evaluation/task/detail/GradientBorderBox.tsx index fc14d2d5715a..2b598c5310bc 100644 --- a/projects/app/src/pageComponents/dashboard/evaluation/task/detail/GradientBorderBox.tsx +++ b/projects/app/src/pageComponents/dashboard/evaluation/task/detail/GradientBorderBox.tsx @@ -9,8 +9,9 @@ interface GradientBorderBoxProps extends BoxProps { const GradientBorderBox: React.FC = ({ children, ...boxProps }) => { return ( = ({ children, ...boxP mask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)', maskComposite: 'xor', WebkitMask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)', - WebkitMaskComposite: 'xor' + WebkitMaskComposite: 'xor', + pointerEvents: 'none' }} {...boxProps} > diff --git a/projects/app/src/pageComponents/dashboard/evaluation/task/detail/NavBar.tsx b/projects/app/src/pageComponents/dashboard/evaluation/task/detail/NavBar.tsx index 5071cff7e612..71352a69e0a0 100644 --- a/projects/app/src/pageComponents/dashboard/evaluation/task/detail/NavBar.tsx +++ b/projects/app/src/pageComponents/dashboard/evaluation/task/detail/NavBar.tsx @@ -8,6 +8,7 @@ import { TaskPageContext } from '@/web/core/evaluation/context/taskPageContext'; import LightRowTabs from '@fastgpt/web/components/common/Tabs/LightRowTabs'; import FolderPath from '@/components/common/folder/Path'; import type { EvaluationStatsResponse } from '@fastgpt/global/core/evaluation/api'; +import { EvaluationStatusEnum } from '@fastgpt/global/core/evaluation/constants'; export enum TabEnum { allData = 'allData', @@ -15,6 +16,26 @@ export enum TabEnum { errorData = 'errorData' } +// 定义过滤参数类型 +export type TabFilterParams = { + status?: EvaluationStatusEnum; + belowThreshold?: boolean; +}; + +// 获取不同 tab 对应的过滤参数 +export const getTabFilterParams = (tab: TabEnum): TabFilterParams => { + switch (tab) { + case TabEnum.allData: + return {}; // 不过滤 + case TabEnum.questionData: + return { belowThreshold: true }; // 低于阈值的数据 + case TabEnum.errorData: + return { status: EvaluationStatusEnum.error }; // 错误状态的数据 + default: + return {}; + } +}; + const NavBar = ({ currentTab, statsData, @@ -43,8 +64,8 @@ const NavBar = ({ { labelKey: 'dashboard_evaluation:question_data_with_count', value: TabEnum.questionData, - count: statsData?.queuing || 0, - shouldShow: (statsData?.queuing || 0) > 0 + count: statsData?.failed || 0, + shouldShow: (statsData?.failed || 0) > 0 }, { labelKey: 'dashboard_evaluation:error_data_with_count', diff --git a/projects/app/src/pages/dashboard/evaluation/dimension/index.tsx b/projects/app/src/pages/dashboard/evaluation/dimension/index.tsx index d79549aafca8..d52c5e0a8dfc 100644 --- a/projects/app/src/pages/dashboard/evaluation/dimension/index.tsx +++ b/projects/app/src/pages/dashboard/evaluation/dimension/index.tsx @@ -30,7 +30,10 @@ import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; import { getMetricList, deleteMetric } from '@/web/core/evaluation/dimension'; import { EvalMetricTypeEnum } from '@fastgpt/global/core/evaluation/metric/constants'; import type { EvalMetricDisplayType } from '@fastgpt/global/core/evaluation/metric/type'; -import { getBuiltinDimensionInfo } from '@/web/core/evaluation/utils'; +import { + getBuiltinDimensionInfo, + getBuiltinDimensionNameFromId +} from '@/web/core/evaluation/utils'; const EvaluationDimensions = ({ Tab }: { Tab: React.ReactNode }) => { const [searchValue, setSearchValue] = useState(''); @@ -56,7 +59,8 @@ const EvaluationDimensions = ({ Tab }: { Tab: React.ReactNode }) => { return allDimensions.map((dimension) => { // 如果是内置维度,使用国际化信息 if (dimension.type === EvalMetricTypeEnum.Builtin) { - const builtinInfo = getBuiltinDimensionInfo(dimension._id); + const dimensionName = getBuiltinDimensionNameFromId(dimension._id); + const builtinInfo = getBuiltinDimensionInfo(dimensionName); if (builtinInfo) { return { ...dimension, diff --git a/projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx b/projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx index 7c686c702358..11d37acf50e4 100644 --- a/projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx +++ b/projects/app/src/pages/dashboard/evaluation/task/detail/index.tsx @@ -1,40 +1,1270 @@ -import React from 'react'; +'use client'; +import React, { useState, useMemo, useCallback } from 'react'; import { useRouter } from 'next/router'; -import { Box, Flex } from '@chakra-ui/react'; +import { + Box, + Flex, + Input, + InputGroup, + InputLeftElement, + HStack, + Button, + IconButton, + Textarea, + Switch, + Popover, + PopoverTrigger, + PopoverContent, + PopoverArrow, + useDisclosure, + Link +} from '@chakra-ui/react'; +import { useToast } from '@fastgpt/web/hooks/useToast'; +import { getErrText } from '@fastgpt/global/common/error/utils'; +import { serviceSideProps } from '@/web/common/i18n/utils'; import { useTranslation } from 'next-i18next'; -import FolderPath from '@/components/common/folder/Path'; +import { + TaskPageContext, + TaskPageContextProvider +} from '@/web/core/evaluation/context/taskPageContext'; +import { useContextSelector } from 'use-context-selector'; +import NextHead from '@/components/common/NextHead'; +import { useRequest2 } from '@fastgpt/web/hooks/useRequest'; +import { useScrollPagination } from '@fastgpt/web/hooks/useScrollPagination'; +import NavBar, { + TabEnum, + getTabFilterParams +} from '@/pageComponents/dashboard/evaluation/task/detail/NavBar'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import { useForm } from 'react-hook-form'; +import PopoverConfirm from '@fastgpt/web/components/common/MyPopover/PopoverConfirm'; +import ScoreBar from '@/pageComponents/dashboard/evaluation/task/detail/ScoreBar'; +import ScoreDashboard from '@/pageComponents/dashboard/evaluation/task/detail/ScoreDashboard'; +import GradientBorderBox from '@/pageComponents/dashboard/evaluation/task/detail/GradientBorderBox'; +import EvaluationSummaryCard from '@/pageComponents/dashboard/evaluation/task/detail/EvaluationSummaryCard'; +import BasicInfo from '@/pageComponents/dashboard/evaluation/task/detail/BasicInfo'; +import ConfigParams from '@/pageComponents/dashboard/evaluation/task/detail/ConfigParams'; +import DetailedResponseModal from '@/pageComponents/dashboard/evaluation/task/detail/DetailedResponseModal'; +import MyBox from '@fastgpt/web/components/common/MyBox'; +import QuestionTip from '@fastgpt/web/components/common/MyTooltip/QuestionTip'; +import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; +import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; +import { getEvaluationItemList } from '@/web/core/evaluation/task'; +import { + BUILTIN_DIMENSION_MAP, + getBuiltinDimensionInfo, + formatScoreToPercentage +} from '@/web/core/evaluation/utils'; +import { + EvaluationStatusEnum, + EvaluationStatusMap +} from '@fastgpt/global/core/evaluation/constants'; +import { MetricResultStatusEnum } from '@fastgpt/global/core/evaluation/metric/constants'; +import { EvalDatasetDataKeyEnum } from '@fastgpt/global/core/evaluation/dataset/constants'; -const EvaluationTaskDetail = () => { +type Props = { taskId: string; currentTab: TabEnum }; + +const Detail = ({ taskId, currentTab }: Props) => { const { t } = useTranslation(); + const { toast } = useToast(); const router = useRouter(); - // 路径导航 - const paths = [{ parentId: 'current', parentName: 'taskName' }]; + // 从 Context 获取数据和方法 + const taskDetail = useContextSelector(TaskPageContext, (v) => v.taskDetail); + const loadTaskDetail = useContextSelector(TaskPageContext, (v) => v.loadTaskDetail); + const statsData = useContextSelector(TaskPageContext, (v) => v.statsData); + const summaryData = useContextSelector(TaskPageContext, (v) => v.summaryData); + const evaluationDetail = useContextSelector(TaskPageContext, (v) => v.evaluationDetail); + const loading = useContextSelector(TaskPageContext, (v) => v.loading); + const searchValue = useContextSelector(TaskPageContext, (v) => v.searchValue); + const setSearchValue = useContextSelector(TaskPageContext, (v) => v.setSearchValue); + const loadAllData = useContextSelector(TaskPageContext, (v) => v.loadAllData); + const deleteItem = useContextSelector(TaskPageContext, (v) => v.deleteItem); + const retryItem = useContextSelector(TaskPageContext, (v) => v.retryItem); + const updateItem = useContextSelector(TaskPageContext, (v) => v.updateItem); + const retryFailedItems = useContextSelector(TaskPageContext, (v) => v.retryFailedItems); + const exportItems = useContextSelector(TaskPageContext, (v) => v.exportItems); + const generateSummary = useContextSelector(TaskPageContext, (v) => v.generateSummary); + + // 本地状态(UI 相关) + const [selectedIndex, setSelectedIndex] = useState(0); + const [editing, setEditing] = useState(false); + const [modifyDataset, setModifyDataset] = useState(true); + const [selectedDimension, setSelectedDimension] = useState(0); + const { + isOpen: isSavePopoverOpen, + onOpen: onSavePopoverOpen, + onClose: onSavePopoverClose + } = useDisclosure(); + const { + isOpen: isConfigParamsOpen, + onOpen: onConfigParamsOpen, + onClose: onConfigParamsClose + } = useDisclosure(); + const { + isOpen: isDetailedResponseOpen, + onOpen: onDetailedResponseOpen, + onClose: onDetailedResponseClose + } = useDisclosure(); + + // 判断是否处于排队中或评测中状态 + const isQueuingOrEvaluating = useMemo(() => { + if (!evaluationDetail?.status) return false; + + return ( + evaluationDetail.status === EvaluationStatusEnum.queuing || + evaluationDetail.status === EvaluationStatusEnum.evaluating + ); + }, [evaluationDetail]); + + // 判断是否全部数据执行异常 + const isAllDataFailed = useMemo(() => { + if (!evaluationDetail?.status || !statsData) return false; + + return ( + evaluationDetail.status === EvaluationStatusEnum.error && statsData.error === statsData.total + ); + }, [evaluationDetail, statsData]); + + // 判断是否为正常完成状态 + const isNormalCompleted = useMemo(() => { + if (!evaluationDetail?.status) return false; + + return ( + evaluationDetail.status === EvaluationStatusEnum.completed || + (evaluationDetail.status === EvaluationStatusEnum.error && !isAllDataFailed) + ); + }, [evaluationDetail, isAllDataFailed]); + + // 初始化数据加载 + useRequest2( + async () => { + const taskDetailData = await loadTaskDetail(taskId); + await loadAllData(taskDetailData); + }, + { + onError(err: any) { + router.replace(`/dashboard/evaluation/task/list`); + toast({ + title: t(getErrText(err, t('common:load_failed')) as any), + status: 'error' + }); + }, + manual: false + } + ); + + // 滚动分页参数 + const scrollParams = useMemo( + () => ({ + evalId: taskId, + ...(searchValue && { [EvalDatasetDataKeyEnum.UserInput]: searchValue }), + ...getTabFilterParams(currentTab) + }), + [taskId, searchValue, currentTab] + ); + + // 空状态提示组件 + const EmptyTipDom = useMemo(() => , [t]); + + // 使用滚动分页获取评估项列表 + const { + data: evaluationItems, + ScrollData, + total: totalItems, + refreshList: refreshEvaluationItems, + setData: setEvaluationItems + } = useScrollPagination(getEvaluationItemList, { + pageSize: 20, + params: scrollParams, + refreshDeps: [searchValue, taskId, currentTab], + EmptyTip: EmptyTipDom + }); + + const handleSearch = useCallback( + (value: string) => { + setSearchValue(value); + // 重置选中项索引 + setSelectedIndex(0); + // 重置编辑状态 + setEditing(false); + }, + [setSearchValue] + ); + + // 计算序号格式 + const getItemNumber = useCallback( + (index: number) => { + const itemNumber = index + 1; + if (totalItems < 100) { + return itemNumber < 10 ? `0${itemNumber}` : `${itemNumber}`; + } else { + return itemNumber < 10 + ? `00${itemNumber}` + : itemNumber < 100 + ? `0${itemNumber}` + : `${itemNumber}`; + } + }, + [totalItems] + ); + + // 动态计算表头 + const tableHeaders = useMemo(() => { + if (evaluationItems.length === 0) return []; + + const firstItem = evaluationItems[0]; + const evaluators = firstItem.evaluators || []; + + const headers = [{ key: 'question', label: t('问题'), flex: 3 }]; + + if (evaluators.length < 3) { + // 小于3个维度,显示每个维度名称 + evaluators.forEach((evaluator, index) => { + // 查找是否有匹配的内置维度信息 + const matchedDimension = + BUILTIN_DIMENSION_MAP[evaluator.metric.name as keyof typeof BUILTIN_DIMENSION_MAP]; + const displayName = matchedDimension ? t(matchedDimension.name) : evaluator.metric.name; + + headers.push({ + key: `metric_${index}`, + label: displayName, + flex: 1 + }); + }); + } else { + // 大于等于3个维度,只显示综合评分 + headers.push({ + key: 'totalScore', + label: t('综合评分'), + flex: 1 + }); + } + + return headers; + }, [evaluationItems, t]); + + // 获取当前选中项的详细信息 + const selectedItem = useMemo(() => { + return evaluationItems[selectedIndex] || null; + }, [evaluationItems, selectedIndex]); + + // 根据选中项的 evaluatorOutputs 动态生成评测维度数据 + const evaluationDimensions = useMemo(() => { + if ( + !selectedItem || + selectedItem.status !== EvaluationStatusEnum.completed || + !selectedItem.evaluatorOutputs + ) { + return []; + } + + return selectedItem.evaluatorOutputs.map((output, index) => { + // 查找是否有匹配的内置维度信息 + const builtinInfo = getBuiltinDimensionInfo(output.metricName); + const displayName = builtinInfo ? t(builtinInfo.name) : output.metricName; + + // 从对应序号的 evaluators 中获取阈值 + const evaluator = selectedItem.evaluators?.[index]; + const threshold = evaluator?.thresholdValue || 0.8; // 默认阈值为 0.8 + + return { + name: displayName, + score: formatScoreToPercentage(output.data?.score || 0), + threshold: formatScoreToPercentage(threshold), + description: output.data?.reason || '-' + }; + }); + }, [selectedItem, t]); + + // 获取错误信息列表 + const errorMessages = useMemo(() => { + if (!selectedItem || selectedItem.status !== EvaluationStatusEnum.error) { + return []; + } + + const messages: string[] = []; + + // 优先取 evaluatorOutputs 下的 data.reason,但需要 status 为 Failed + if (selectedItem.evaluatorOutputs && selectedItem.evaluatorOutputs.length > 0) { + selectedItem.evaluatorOutputs.forEach((output) => { + if (output.status === MetricResultStatusEnum.Failed && output.data?.reason) { + messages.push(output.data.reason); + } + }); + } + + // 如果没有找到任何 reason,则取外层的 errorMessage 作为兜底 + if (messages.length === 0 && selectedItem.errorMessage) { + messages.push(selectedItem.errorMessage); + } + + return messages; + }, [selectedItem]); + + const { register, handleSubmit, reset, setValue } = useForm(); + + // 添加一个重置表单的函数 + const resetForm = useCallback(() => { + reset(); + }, [reset]); + + const handleEdit = useCallback(() => { + // 重置表单并设置当前选中项的值 + if (selectedItem) { + reset({ + question: selectedItem.dataItem.userInput, + expectedResponse: selectedItem.dataItem.expectedOutput + }); + } + setEditing(true); + }, [selectedItem, reset]); + + const handleSave = useCallback( + async (data: any) => { + if (!selectedItem) { + toast({ + title: t('请先选择要编辑的数据项'), + status: 'warning' + }); + return; + } + + try { + const updateData = { + evalItemId: selectedItem._id, + [EvalDatasetDataKeyEnum.UserInput]: data.question, + [EvalDatasetDataKeyEnum.ExpectedOutput]: data.expectedResponse, + modifyDataset: modifyDataset + }; + + await updateItem(selectedItem._id, updateData); + // 刷新列表数据 + await refreshEvaluationItems(); + setEditing(false); + onSavePopoverClose(); + } catch (error: any) { + // 错误处理已在 Context 中完成 + console.error('保存失败:', error); + } + }, + [selectedItem, modifyDataset, toast, t, onSavePopoverClose, updateItem, refreshEvaluationItems] + ); + + const handleRefresh = useCallback(async () => { + if (!selectedItem) { + toast({ + title: t('请先选择要重试的数据项'), + status: 'warning' + }); + return; + } + + try { + await retryItem(selectedItem._id); + // 刷新列表数据 + await refreshEvaluationItems(); + } catch (error: any) { + // 错误处理已在 Context 中完成 + console.error('重试失败:', error); + } + }, [selectedItem, toast, t, retryItem, refreshEvaluationItems]); + + const handleDelete = useCallback(async () => { + if (!selectedItem) { + toast({ + title: t('请先选择要删除的数据项'), + status: 'warning' + }); + return; + } + + try { + await deleteItem(selectedItem._id); + + // 刷新列表数据 + await refreshEvaluationItems(); + + // 调整选中索引:选择下一个项目,如果是最后一项则选择新列表的最后一项 + const currentItemIndex = selectedIndex; + const newSelectedIndex = + currentItemIndex < evaluationItems.length - 1 + ? currentItemIndex // 选择下一个项目(当前索引保持不变,因为删除后后面的项目会前移) + : Math.max(0, evaluationItems.length - 2); // 如果是最后一项,选择删除后列表的最后一项 + + setSelectedIndex(newSelectedIndex); + + // 重置编辑状态 + setEditing(false); + resetForm(); + } catch (error: any) { + // 错误处理已在 Context 中完成 + console.error('删除失败:', error); + } + }, [ + selectedItem, + evaluationItems, + selectedIndex, + toast, + t, + resetForm, + deleteItem, + refreshEvaluationItems + ]); + + const handleCancel = useCallback(() => { + setEditing(false); + // 取消时重置表单 + resetForm(); + }, [resetForm]); + + // 导出数据处理函数 + const handleExport = useCallback(async () => { + try { + await exportItems('csv'); + } catch (error: any) { + // 错误处理已在 Context 中完成 + console.error('导出失败:', error); + } + }, [exportItems]); + + // 重试失败项处理函数 + const handleRetryFailed = useCallback(async () => { + try { + await retryFailedItems(); + } catch (error: any) { + // 错误处理已在 Context 中完成 + console.error('重试失败:', error); + } + }, [retryFailedItems]); + + // 刷新评分处理函数 + const handleRefreshScore = useCallback(async () => { + try { + await generateSummary(); + } catch (error: any) { + // 错误处理已在 Context 中完成 + console.error('刷新评分失败:', error); + } + }, [generateSummary]); + + // 设置评分处理函数 + const handleScoreSettings = useCallback(() => { + onConfigParamsOpen(); + }, [onConfigParamsOpen]); + + // 查看完整响应处理函数 + const handleViewFullResponse = useCallback(() => { + if (!selectedItem) { + toast({ + title: t('请先选择要查看的数据项'), + status: 'warning' + }); + return; + } + + // 检查是否有必要的数据 + if (!selectedItem.targetOutput?.aiChatItemDataId) { + toast({ + title: t('该数据项暂无完整响应数据'), + status: 'warning' + }); + return; + } + + onDetailedResponseOpen(); + }, [selectedItem, toast, t, onDetailedResponseOpen]); + + // 处理配置参数确认 + const handleConfigParamsConfirm = useCallback(() => { + // 配置保存成功后刷新相关数据 + loadAllData(taskDetail); + }, [loadAllData, taskDetail]); return ( - - {/* 顶部导航栏 */} - - {/* 路径导航 */} - - { - router.push(`/dashboard/evaluation?evaluationTab=tasks`); - }} + <> + + + + {/* 左侧主内容区域 */} + + {/* 顶部 NavBar */} + + + {/* 数据和详情的水平布局 */} + + {/* 数据区域 - 占 4/10 */} + + {/* 数据区域头部 */} + + + + + {t('数据({{data}})', { + data: statsData + ? statsData.completed !== statsData.total + ? `${statsData.completed}/${statsData.total}` + : statsData.total + : 0 + })} + + + + + + + + handleSearch(e.target.value)} + bg={'white'} + /> + + + + {/* 数据列表内容区域 */} + + {/* 表头 - 只在有数据且不在加载中时显示 */} + {tableHeaders.length > 0 && !loading.items && ( + + {tableHeaders.map((header, index) => ( + + {header.label} + + ))} + + )} + + {/* 数据列表 - 使用滚动分页组件 */} + + {evaluationItems.map((item, index) => { + const evaluatorOutputs = item.evaluatorOutputs || []; + + return ( + { + setSelectedIndex(index); + setEditing(false); + // 切换选中项时重置表单 + resetForm(); + }} + > + {/* 问题列 */} + + + {getItemNumber(index)} + + {item.dataItem.userInput} + + + + + {/* 动态列 */} + {evaluatorOutputs.length === 0 ? ( + // 当 evaluatorOutputs 为空时,显示外层状态 + { + if (item.status === EvaluationStatusEnum.evaluating) { + return 'blue.500'; + } else if (item.status === EvaluationStatusEnum.error) { + return 'red.500'; + } + return 'myGray.600'; + })()} + > + {(() => { + const statusInfo = EvaluationStatusMap[item.status]; + return statusInfo ? t(statusInfo.name) : '-'; + })()} + + ) : evaluatorOutputs.length < 3 ? ( + // 显示每个维度的分数或状态 + evaluatorOutputs.map((output, outputIndex) => { + // 获取显示内容和颜色 + const getDisplayInfo = () => { + if ( + output.status === MetricResultStatusEnum.Success && + output.data?.score !== undefined + ) { + // 获取对应序号的评估器阈值 + const evaluator = item.evaluators?.[outputIndex]; + const threshold = evaluator?.thresholdValue || 0.8; + const score = output.data.score; + + // 比较得分与阈值,决定颜色 + const color = score >= threshold ? 'myGray.600' : 'yellow.600'; + + return { + content: formatScoreToPercentage(score), + color: color + }; + } + + if (output.status === MetricResultStatusEnum.Failed) { + return { + content: t('异常'), + color: 'red.500' + }; + } + + // 使用外层状态 + const statusInfo = EvaluationStatusMap[item.status]; + const statusName = statusInfo ? t(statusInfo.name) : '-'; + + let color = 'myGray.600'; // 默认颜色(排队中) + if (item.status === EvaluationStatusEnum.evaluating) { + color = 'blue.500'; // 评测中为蓝色 + } else if (item.status === EvaluationStatusEnum.error) { + color = 'red.500'; // 异常为红色 + } + + return { + content: statusName, + color: color + }; + }; + + const displayInfo = getDisplayInfo(); + + return ( + + {displayInfo.content} + + ); + }) + ) : ( + // 显示综合评分或状态(计算加权综合得分) + { + const hasFailedOutputs = evaluatorOutputs.some( + (output) => output.status === MetricResultStatusEnum.Failed + ); + const successOutputs = evaluatorOutputs.filter( + (output) => + output.status === MetricResultStatusEnum.Success && + output.data?.score !== undefined + ); + + if (hasFailedOutputs) { + return 'red.500'; + } + + if (successOutputs.length > 0) { + // 计算综合得分和综合阈值 + let totalWeightedScore = 0; + let totalWeightedThreshold = 0; + let totalWeight = 0; + + evaluatorOutputs.forEach((output, outputIndex) => { + if ( + output.status === MetricResultStatusEnum.Success && + output.data?.score !== undefined + ) { + const evaluator = item.evaluators?.[outputIndex]; + const weight = evaluator?.weight || 0; + const score = output.data.score; + const threshold = evaluator?.thresholdValue || 0.8; + + totalWeightedScore += (score * weight) / 100; + totalWeightedThreshold += (threshold * weight) / 100; + totalWeight += weight; + } + }); + + // 比较综合得分与综合阈值 + if (totalWeight > 0) { + return totalWeightedScore >= totalWeightedThreshold + ? 'myGray.600' + : 'yellow.600'; + } + + return 'myGray.600'; + } + + // 使用外层状态确定颜色 + if (item.status === EvaluationStatusEnum.evaluating) { + return 'blue.500'; + } else if (item.status === EvaluationStatusEnum.error) { + return 'red.500'; + } + + return 'myGray.600'; + })()} + > + {(() => { + const successOutputs = evaluatorOutputs.filter( + (output) => + output.status === MetricResultStatusEnum.Success && + output.data?.score !== undefined + ); + const failedOutputs = evaluatorOutputs.filter( + (output) => output.status === MetricResultStatusEnum.Failed + ); + + if (failedOutputs.length > 0) { + return t('异常'); + } + + if (successOutputs.length > 0) { + // 计算加权综合得分 + let totalWeightedScore = 0; + let totalWeight = 0; + + evaluatorOutputs.forEach((output, outputIndex) => { + if ( + output.status === MetricResultStatusEnum.Success && + output.data?.score !== undefined + ) { + const evaluator = item.evaluators?.[outputIndex]; + const weight = evaluator?.weight || 0; + const score = output.data.score; + + totalWeightedScore += (score * weight) / 100; + totalWeight += weight; + } + }); + + if (totalWeight > 0) { + return formatScoreToPercentage(totalWeightedScore); + } + + // 如果没有权重信息,使用平均分作为兜底 + const totalScore = successOutputs.reduce( + (sum, output) => sum + (output.data?.score || 0), + 0 + ); + const avgScore = totalScore / successOutputs.length; + return formatScoreToPercentage(avgScore); + } + + // 使用外层状态 + const statusInfo = EvaluationStatusMap[item.status]; + return statusInfo ? t(statusInfo.name) : '-'; + })()} + + )} + + ); + })} + + + + + {/* 详情区域 - 占 6/10 */} + + {/* 详情区域头部 */} + + + + + {t('详情')} + + + + + {editing ? ( + <> + + + + + + + + + + {t('同时修改评测数据集')} + + setModifyDataset(e.target.checked)} + colorScheme="primary" + /> + + + + + + + + + ) : ( + <> + {/* 根据选中项状态动态显示按钮 */} + {selectedItem?.status === EvaluationStatusEnum.error && ( + <> + } + onClick={handleRefresh} + /> + } + onClick={handleEdit} + /> + } + /> + } + /> + + )} + + {selectedItem?.status === EvaluationStatusEnum.completed && ( + <> + + } + /> + } + /> + + )} + + {/* 排队中或评测中状态不显示任何按钮 */} + + )} + + + + {/* 详情内容区域 */} + + {/* 评测维度组件 - 仅在选中项状态为 completed 且有 evaluatorOutputs 时显示 */} + {selectedItem && + selectedItem.status === EvaluationStatusEnum.completed && + evaluationDimensions.length > 0 && ( + + + {evaluationDimensions.map((dimension, index) => { + const isActive = selectedDimension === index; + const isAboveThreshold = dimension.score >= dimension.threshold; + const bgColor = isAboveThreshold ? 'blue.50' : 'yellow.50'; + const borderColor = isAboveThreshold ? 'blue.200' : 'yellow.200'; + const textColor = isAboveThreshold ? 'blue.600' : 'yellow.600'; + + return ( + setSelectedDimension(index)} + > + {dimension.name} + {dimension.score} + + ); + })} + + + {evaluationDimensions[selectedDimension] && ( + = + evaluationDimensions[selectedDimension].threshold + ? 'blue.600' + : 'yellow.600' + } + fontSize={'sm'} + lineHeight={'1.6'} + mt={2} + > + {evaluationDimensions[selectedDimension].description} + + )} + + )} + + {/* 根据选中的索引显示对应的详情内容 */} + {selectedItem && ( + <> + {/* 错误信息显示 */} + {errorMessages.length > 0 && ( + + + + + {errorMessages.map((message, index) => ( + + {index + 1}、{message} + + ))} + + + + )} + + + {t('问题')} + {editing ? ( +