diff --git a/frontend/package.json b/frontend/package.json index 47da039..950825d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,8 @@ "test:data-process-wizard": "node scripts/regression-data-process-wizard.mjs", "test:dataset-task-tab": "node scripts/regression-dataset-task-tab.mjs", "test:dataset-preview": "node scripts/regression-dataset-preview.mjs", + "test:eval-create": "node scripts/regression-eval-create-wizard.mjs", + "test:eval-detail": "node scripts/regression-eval-detail.mjs", "test:model-manage": "node scripts/regression-model-manage.mjs", "test:training-log-layout": "node scripts/regression-training-log-layout.mjs", "test:page-surface": "node scripts/regression-page-surface.mjs" diff --git a/frontend/scripts/regression-eval-create-wizard.mjs b/frontend/scripts/regression-eval-create-wizard.mjs new file mode 100644 index 0000000..51608ce --- /dev/null +++ b/frontend/scripts/regression-eval-create-wizard.mjs @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import path from 'node:path' + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const sourceRoot = path.resolve(scriptDir, '../src') +const evalViewDir = path.join(sourceRoot, 'views/eval') + +const [ + createSource, + taskStepSource, + ruleStepSource, + listSource, + routerSource, + apiSource, +] = await Promise.all([ + readFile(path.join(evalViewDir, 'EvalCreateView.vue'), 'utf8'), + readFile(path.join(evalViewDir, 'create/EvalTaskSetupStep.vue'), 'utf8'), + readFile(path.join(evalViewDir, 'create/EvalRuleSetupStep.vue'), 'utf8'), + readFile(path.join(evalViewDir, 'EvalView.vue'), 'utf8'), + readFile(path.join(sourceRoot, 'router/index.ts'), 'utf8'), + readFile(path.join(sourceRoot, 'api/modules/eval.ts'), 'utf8'), +]) + +function extractRouteBlock(source, routePath) { + const pathPattern = new RegExp(`path:\\s*['"]${routePath.replaceAll('/', '\\/')}['"]`) + const pathIndex = source.search(pathPattern) + assert.notEqual(pathIndex, -1, `未找到路由 ${routePath}`) + + const openBrace = source.lastIndexOf('{', pathIndex) + assert.notEqual(openBrace, -1, `路由 ${routePath} 缺少左花括号`) + + let depth = 0 + for (let index = openBrace; index < source.length; index += 1) { + if (source[index] === '{') depth += 1 + if (source[index] === '}') depth -= 1 + if (depth === 0) return source.slice(openBrace + 1, index) + } + + assert.fail(`路由 ${routePath} 缺少右花括号`) +} + +const createDimensionRoute = extractRouteBlock(routerSource, 'model-eval/dimension/create') +assert.match( + createDimensionRoute, + /redirect:\s*['"]\/model-eval\/create['"]/, + '独立创建维度入口必须收敛到新建评测向导', +) +assert.doesNotMatch( + createDimensionRoute, + /DimensionCreateView/, + '独立创建维度路由不应继续加载旧创建页', +) + +const editDimensionRoute = extractRouteBlock(routerSource, 'model-eval/dimension/:id/edit') +assert.match( + editDimensionRoute, + /DimensionCreateView\.vue/, + '编辑维度路由必须继续复用 DimensionCreateView.vue', +) + +const dimensionTabStart = listSource.indexOf('') +assert.notEqual(dimensionTabStart, -1, '评测列表缺少维度标签页') +const dimensionTabSource = listSource.slice(dimensionTabStart) +assert.doesNotMatch(dimensionTabSource, /create-text=["']添加维度["']/, '维度标签页不应保留“添加维度”按钮') +assert.doesNotMatch(dimensionTabSource, /@create=["']handleCreateClick["']/, '维度标签页不应保留独立创建事件') + +assert.match( + apiSource, + /createDimension\s*=\s*\([^)]*\)\s*=>\s*[\s\S]*?post<\{\s*id:\s*string\s*\|\s*number\s*\}>\(['"]\/dimension['"]\s*,\s*data\)/, + 'createDimension 必须声明返回包含 string | number 类型 id', +) + +assert.match(createSource, /EvalTaskSetupStep/, '向导缺少任务配置子组件') +assert.match(createSource, /EvalRuleSetupStep/, '向导缺少评测规则子组件') +assert.match( + createSource, + /任务配置[\s\S]*?评测规则/, + '评测创建向导必须按“任务配置、评测规则”定义两个步骤', +) +assert.match(createSource, /currentStep\s*=\s*ref\(0\)/, '评测创建向导缺少当前步骤状态') +assert.match(createSource, /class=["']custom-wizard-steps["']/, '评测向导未复用数据处理的自定义步骤条结构') +assert.match(createSource, /class=["']step-connector["']/, '评测向导步骤条缺少连接线') +assert.match(createSource, /:aria-current=/, '步骤条缺少当前步骤的可访问性绑定') +assert.match(createSource, /currentStep === index \? 'step' : undefined/, '步骤条当前步骤语义不正确') +assert.doesNotMatch(createSource, /\s*import\(['"]@\/views\/eval\/EvalDetailView\.vue['"]\)/, + '评测详情路由未加载 EvalDetailView.vue', +) + +assert.match( + listSource, + /router\.push\(\s*\{\s*name:\s*['"]model-eval-detail['"]\s*,\s*params:\s*\{\s*id:\s*row\.id\s*\}\s*\}\s*\)/, + '评测任务详情按钮必须使用命名路由并携带任务 id', +) +assert.doesNotMatch(listSource, /详情功能开发中/, '评测任务详情入口仍是占位提示') + +assert.match(apiSource, /export\s+const\s+getEvalDetail\b/, '评测 API 缺少 getEvalDetail') +assert.match( + apiSource, + /get(?:<[^>]+>)?\(`\/model-eval\/\$\{id\}`\)/, + 'getEvalDetail 未请求 GET /model-eval/:id', +) +assert.ok( + mockSource.includes('/^\\/model-eval\\/([^/]+)$/'), + 'Mock 未按任务 id 匹配 /model-eval/:id', +) +assert.match(mockSource, /method\s*===\s*['"]get['"]/, '评测详情 Mock 未处理 GET 请求') + +for (const className of [ + 'overall-review', + 'score-hero', + 'improvement-list', + 'sample-results-table', + 'dimension-summary', +]) { + assert.match( + detailSource, + new RegExp(`class=["'][^"']*\\b${className}\\b`), + `评测详情页缺少 .${className} 结构`, + ) +} + +for (const label of ['输入', '标准答案', '模型回答', '得分', '判定']) { + assert.match( + detailSource, + new RegExp(`(?:label=["'][^"']*${label}[^"']*["']|>${label}<)`), + `样本结果列表缺少“${label}”语义`, + ) +} + +assert.match(detailSource, /\bloading\b/, '评测详情页缺少加载状态') +assert.match(detailSource, /\berror\b/, '评测详情页缺少错误状态') +assert.match( + detailSource, + /(?:el-empty|empty-text|样本结果为空|暂无样本|暂无数据)/, + '评测详情页缺少空状态', +) +assert.match(detailSource, / get('/model-eval') +/** 评测任务详情 */ +export const getEvalDetail = (id: string | number) => get(`/model-eval/${id}`) + /** 删除评测任务 */ export const deleteEval = (id: string | number) => del(`/model-eval/${id}`) @@ -17,7 +20,8 @@ export const getDimensionList = () => get('/dimension') export const getDimension = (id: string | number) => get(`/dimension/${id}`) /** 创建维度 */ -export const createDimension = (data: Partial) => post('/dimension', data) +export const createDimension = (data: Partial) => + post<{ id: string | number }>('/dimension', data) /** 编辑维度 */ export const updateDimension = (id: string | number, data: Partial) => diff --git a/frontend/src/mock/adapter.ts b/frontend/src/mock/adapter.ts index 7460a72..2fad813 100644 --- a/frontend/src/mock/adapter.ts +++ b/frontend/src/mock/adapter.ts @@ -16,6 +16,7 @@ import { mockFineTuneList, mockCompareList, mockEvalList, + mockEvalDetails, mockDimensions, mockLogFiles, mockTrainingLogFiles, @@ -229,6 +230,12 @@ async function handleMock(config: AxiosRequestConfig) { if (url === '/model-eval' && method === 'get') return ok(mockEvalList) if (url === '/model-eval' && method === 'delete') return ok({ deleted: true }) if (url === '/model-eval/start' && method === 'post') return ok({ task_id: Math.floor(Math.random() * 1000) + 1 }) + m = url.match(/^\/model-eval\/([^/]+)$/) + const evalTaskId = m?.[1] + if (evalTaskId && method === 'get') { + const found = mockEvalDetails.find((item) => String(item.id) === evalTaskId) + return found ? ok(found) : fail('评测任务不存在', 404) + } if (url === '/dimension' && method === 'get') return ok(mockDimensions) if (url === '/dimension' && method === 'post') { return ok({ id: Math.floor(Math.random() * 1000) + 100 }) diff --git a/frontend/src/mock/data.ts b/frontend/src/mock/data.ts index cac81e0..63ab3c0 100644 --- a/frontend/src/mock/data.ts +++ b/frontend/src/mock/data.ts @@ -10,6 +10,7 @@ import type { DatasetItem, CompareTask, EvalTask, + EvalTaskDetail, Dimension, SystemInfo, HealthMetrics, @@ -254,6 +255,139 @@ export const mockEvalList: EvalTask[] = [ { id: 3, eval_task_name: '基线对比评测', model_name: 'Qwen2.5-7B-Instruct', dataset: '金融评测集', metric: 'accuracy', score: 72.3, status: 'running', create_time: '2026-02-10T09:00:00Z' }, ] +export const mockEvalDetails: EvalTaskDetail[] = [ + { + ...mockEvalList[0], + evaluator_model: 'GPT-4o', + sample_count: 3, + completed_count: 3, + passed_count: 3, + completed_time: '2026-02-01T10:18:00Z', + overall_score: 87.5, + overall_score_max: 100, + overall_evaluation: '模型在金融基础知识与常见计算题上表现稳定,答案整体准确、直接。', + improvement_suggestions: ['补充期限匹配、再平衡等资产配置知识', '在建议类回答中明确假设与风险边界', '增加复杂金融场景和反事实样本训练'], + dimension_summary: [ + { name: '核心事实正确性', score: 91, max_score: 100, pass_rate: 100 }, + { name: '信息完整性', score: 84, max_score: 100, pass_rate: 100 }, + { name: '无幻觉', score: 90, max_score: 100, pass_rate: 100 }, + { name: '格式合规性', score: 85, max_score: 100, pass_rate: 100 }, + ], + samples: [ + { + id: 'finance-1', index: 1, status: 'completed', score: 1, max_score: 1, passed: true, judgement: '正确', + input: '某公司流动资产为 800 万元,流动负债为 500 万元,流动比率是多少?', + reference_answer: '流动比率为 1.6。', + model_output: '流动比率 = 流动资产 ÷ 流动负债 = 800 ÷ 500 = 1.6。', + evaluation_reason: '计算过程与结论均正确,并给出了必要公式。', + dimension_scores: [{ name: '准确性', score: 1, max_score: 1 }, { name: '清晰度', score: 1, max_score: 1 }], + }, + { + id: 'finance-2', index: 2, status: 'completed', score: 1, max_score: 1, passed: true, judgement: '正确', + input: '简述央行提高存款准备金率对市场流动性的影响。', + reference_answer: '商业银行可贷资金减少,市场流动性通常收紧。', + model_output: '提高存款准备金率会减少银行可用于放贷的资金,通常使市场流动性收紧。', + evaluation_reason: '准确覆盖了传导路径,表述简洁。', + dimension_scores: [{ name: '准确性', score: 1, max_score: 1 }, { name: '完整性', score: 0.9, max_score: 1 }], + }, + { + id: 'finance-3', index: 3, status: 'completed', score: 0.8, max_score: 1, passed: true, judgement: '部分正确', + input: '投资者风险承受能力较低时,资产配置应注意什么?', + reference_answer: '降低高波动资产比例,重视分散配置、流动性与本金安全。', + model_output: '应以低波动资产为主,分散投资并预留流动资金,同时控制权益类资产比例。', + evaluation_reason: '建议方向正确,但可进一步说明期限匹配与再平衡机制。', + dimension_scores: [{ name: '准确性', score: 0.9, max_score: 1 }, { name: '完整性', score: 0.7, max_score: 1 }], + }, + ], + }, + { + ...mockEvalList[1], + evaluator_model: 'Claude-3.5-Sonnet', + sample_count: 3, + completed_count: 3, + passed_count: 2, + completed_time: '2026-02-05T11:21:00Z', + overall_score: 0.82, + overall_score_max: 1, + overall_evaluation: '模型具备较好的客服语气和基础问题处理能力,但对订单状态约束的识别仍不稳定。', + improvement_suggestions: ['增加已发货、已出库等边界状态样本', '要求模型在承诺操作前先核验业务规则', '为每类客服场景补充标准处理步骤'], + dimension_summary: [ + { name: '核心事实正确性', score: 0.86, max_score: 1, pass_rate: 67 }, + { name: '信息完整性', score: 0.78, max_score: 1, pass_rate: 67 }, + { name: '无幻觉', score: 0.7, max_score: 1, pass_rate: 67 }, + { name: '格式合规性', score: 0.94, max_score: 1, pass_rate: 100 }, + ], + samples: [ + { + id: 'service-1', index: 1, status: 'completed', score: 0.91, max_score: 1, passed: true, judgement: '正确', + input: '用户反馈收到的商品外包装破损,应如何回复?', + reference_answer: '表达歉意,核实订单及破损情况,并提供补发或退款方案。', + model_output: '很抱歉给您带来不便。请提供订单号和破损照片,我们核实后可为您安排补发或退款。', + evaluation_reason: '关键信息覆盖完整,语气符合客服场景。', + dimension_scores: [{ name: '相关性', score: 0.95, max_score: 1 }, { name: '服务语气', score: 0.96, max_score: 1 }], + }, + { + id: 'service-2', index: 2, status: 'completed', score: 0.84, max_score: 1, passed: true, judgement: '部分正确', + input: '用户询问订单为何仍未发货。', + reference_answer: '致歉并核查订单状态,告知预计发货时间和后续查询方式。', + model_output: '抱歉让您久等了,我可以帮您核查订单状态,并尽快反馈预计发货时间。', + evaluation_reason: '态度友好,但未主动说明后续查询方式。', + dimension_scores: [{ name: '服务语气', score: 0.95, max_score: 1 }, { name: '完整性', score: 0.72, max_score: 1 }], + }, + { + id: 'service-3', index: 3, status: 'completed', score: 0.69, max_score: 1, passed: false, judgement: '错误', error_type: '其他', + input: '用户要求立即取消已出库的订单。', + reference_answer: '说明已出库订单可能无法直接取消,协助拦截或引导拒收、退货。', + model_output: '好的,我现在为您取消订单,请稍候。', + evaluation_reason: '未说明已出库限制,承诺了可能无法完成的操作。', + dimension_scores: [{ name: '相关性', score: 0.7, max_score: 1 }, { name: '规则遵循', score: 0.35, max_score: 1 }], + }, + ], + }, + { + ...mockEvalList[2], + evaluator_model: 'GPT-4o', + sample_count: 3, + completed_count: 2, + passed_count: 1, + overall_score: 72.3, + overall_score_max: 100, + overall_evaluation: '当前为阶段性评价:模型基础计算能力正常,但逻辑推理表现波动,任务仍在运行。', + improvement_suggestions: ['补充形式逻辑与多步推理样本', '任务完成后再依据完整结果复核综合结论'], + dimension_summary: [ + { name: '核心事实正确性', score: 66.7, max_score: 100, pass_rate: 50 }, + { name: '信息完整性', score: 58, max_score: 100, pass_rate: 50 }, + { name: '无幻觉', score: 88, max_score: 100, pass_rate: 100 }, + { name: '格式合规性', score: 76, max_score: 100, pass_rate: 50 }, + ], + samples: [ + { + id: 'baseline-1', index: 1, status: 'completed', score: 1, max_score: 1, passed: true, judgement: '正确', + input: '计算 15% 的 240。', + reference_answer: '36。', + model_output: '240 × 15% = 36。', + evaluation_reason: '计算与结果均正确。', + dimension_scores: [{ name: '准确性', score: 1, max_score: 1 }, { name: '清晰度', score: 1, max_score: 1 }], + }, + { + id: 'baseline-2', index: 2, status: 'completed', score: 0, max_score: 1, passed: false, judgement: '错误', error_type: '其他', + input: '若所有 A 都是 B,且某个 C 是 A,可以推出什么?', + reference_answer: '该 C 是 B。', + model_output: '无法确定 C 是否属于 B。', + evaluation_reason: '未正确应用包含关系的传递性。', + dimension_scores: [{ name: '准确性', score: 0, max_score: 1 }, { name: '逻辑性', score: 0, max_score: 1 }], + }, + { + id: 'baseline-3', index: 3, status: 'pending', score: null, max_score: 1, + input: '用一句话解释什么是机会成本。', + reference_answer: '机会成本是选择某方案时放弃的最佳替代方案的价值。', + model_output: '', + evaluation_reason: '等待模型生成与评分。', + }, + ], + }, +] + export const mockDimensions: Dimension[] = [ { id: 1, name: '回答准确性', type: 'classification', description: '评估模型回答是否准确', eval_model: 'GPT-4o', eval_method: 'standard', eval_prompt: '# 角色\n你是专业的评估专家...', is_active: true, is_default: true, create_time: '2025-12-01T08:00:00Z' }, { id: 2, name: '综合评分', type: 'metric', description: '0-5 分综合评分', eval_model: 'Claude-3.5-Sonnet', eval_method: 'metric_standard', eval_prompt: '# 角色\n你是专业评分专家...', is_active: true, is_default: false, score_min: 0, score_max: 5, pass_threshold: 3.5, create_time: '2025-12-05T09:00:00Z' }, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index e91559a..074d94a 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -46,11 +46,16 @@ const routes: RouteRecordRaw[] = [ component: () => import('@/views/eval/EvalCreateView.vue'), meta: { title: '新建评测' }, }, + { + path: 'model-eval/:id', + name: 'model-eval-detail', + component: () => import('@/views/eval/EvalDetailView.vue'), + meta: { title: '评测详情' }, + }, { path: 'model-eval/dimension/create', name: 'dimension-create', - component: () => import('@/views/eval/DimensionCreateView.vue'), - meta: { title: '添加评测维度' }, + redirect: '/model-eval/create', }, { path: 'model-eval/dimension/:id/edit', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index d231c37..884bedc 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -155,6 +155,7 @@ export interface CompareModelRef { // ============ 模型评测 ============ export type EvalType = 'custom' | 'baseline' export type DimensionType = 'classification' | 'metric' | 'text_similarity' +export type EvalErrorType = '混淆' | '不完整' | '格式偏差' | '幻觉' | '其他' export interface EvalTask { id: number | string @@ -168,6 +169,47 @@ export interface EvalTask { create_time?: string } +/** 单个测试样本的评测结果 */ +export interface EvalSampleResult { + id: number | string + index: number + input: string + reference_answer?: string + model_output: string + score: number | null + max_score?: number + passed?: boolean + status: 'completed' | 'pending' | 'failed' + judgement?: '正确' | '部分正确' | '错误' + evaluation_reason?: string + error_type?: EvalErrorType + dimension_scores?: Array<{ + name: string + score: number + max_score: number + }> +} + +/** 评测任务详情,包含逐样本结果和综合评价 */ +export interface EvalTaskDetail extends EvalTask { + evaluator_model?: string + sample_count: number + completed_count: number + passed_count?: number + completed_time?: string + overall_score: number + overall_score_max: number + overall_evaluation: string + improvement_suggestions: string[] + dimension_summary: Array<{ + name: string + score: number + max_score: number + pass_rate: number + }> + samples: EvalSampleResult[] +} + export interface Dimension { id: number | string name: string diff --git a/frontend/src/views/eval/DimensionCreateView.vue b/frontend/src/views/eval/DimensionCreateView.vue index dbfd5a1..1890b4c 100644 --- a/frontend/src/views/eval/DimensionCreateView.vue +++ b/frontend/src/views/eval/DimensionCreateView.vue @@ -1,19 +1,17 @@ + + diff --git a/frontend/src/views/eval/EvalDetailView.vue b/frontend/src/views/eval/EvalDetailView.vue new file mode 100644 index 0000000..57d69f9 --- /dev/null +++ b/frontend/src/views/eval/EvalDetailView.vue @@ -0,0 +1,749 @@ + + + + + diff --git a/frontend/src/views/eval/EvalView.vue b/frontend/src/views/eval/EvalView.vue index 1906e13..28ed381 100644 --- a/frontend/src/views/eval/EvalView.vue +++ b/frontend/src/views/eval/EvalView.vue @@ -68,15 +68,15 @@ function editDimension(row: any) { } function handleCreateClick() { - if (activeTab.value === 'tasks') { - router.push('/model-eval/create') - } else if (activeTab.value === 'dimensions') { - router.push('/model-eval/dimension/create') - } + router.push('/model-eval/create') +} + +function isActiveTab(tab: string) { + return activeTab.value === tab } function handleViewDetail(row: any) { - ElMessage.info('详情功能开发中') + router.push({ name: 'model-eval-detail', params: { id: row.id } }) } onMounted(() => { @@ -101,9 +101,9 @@ onMounted(() => { >