feat(eval): 添加评估基准分页和错误结果过滤功能
实现评估基准和结果的分页查询,支持按错误过滤结果 优化评估结果表格展示,增加文档链接和说明 提升文件上传验证逻辑和错误处理
This commit is contained in:
parent
c196aaca03
commit
0875e9769c
@ -15,7 +15,7 @@
|
||||
- 同名文件处理逻辑:遇到同名文件则在上传区域提示,是否删除旧文件
|
||||
- conversation 待修改为异步的版本
|
||||
- DBManager 需要将数据库修改为异步的aiosqlite或者异步mysql,缓存使用Redis存储
|
||||
- 【eval】缺少自动生成评估的功能
|
||||
- 【eval】支持 easy dataset 的数据格式
|
||||
- chat_model 的 call 需要异步
|
||||
|
||||
### Bugs
|
||||
|
||||
@ -16,6 +16,8 @@ Yuxi-Know 提供了完整的 RAG 系统评估解决方案,帮助您科学地
|
||||
- 当基准包含标准答案时,系统会使用 LLM 作为评判者评估生成答案的准确性
|
||||
- 同时包含两者时,系统会进行全面的检索和生成质量评估
|
||||
|
||||
也可以从 easy dataset 自动生成评估基准。
|
||||
|
||||
#### JSONL 文件格式示例
|
||||
|
||||
```json
|
||||
|
||||
@ -14,15 +14,27 @@ evaluation = APIRouter(prefix="/evaluation", tags=["evaluation"])
|
||||
|
||||
@evaluation.get("/databases/{db_id}/benchmarks/{benchmark_id}")
|
||||
async def get_evaluation_benchmark_by_db(
|
||||
db_id: str, benchmark_id: str, current_user: User = Depends(get_admin_user)
|
||||
db_id: str,
|
||||
benchmark_id: str,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
"""根据 db_id 获取评估基准详情"""
|
||||
"""根据 db_id 获取评估基准详情(支持分页)"""
|
||||
from src.services.evaluation_service import EvaluationService
|
||||
|
||||
try:
|
||||
# 验证分页参数
|
||||
if page < 1:
|
||||
raise HTTPException(status_code=400, detail="页码必须大于0")
|
||||
if page_size < 1 or page_size > 100:
|
||||
raise HTTPException(status_code=400, detail="每页大小必须在1-100之间")
|
||||
|
||||
service = EvaluationService()
|
||||
benchmark = await service.get_benchmark_detail_by_db(db_id, benchmark_id)
|
||||
benchmark = await service.get_benchmark_detail_by_db(db_id, benchmark_id, page, page_size)
|
||||
return {"message": "success", "data": benchmark}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"获取评估基准详情失败: {e}, {traceback.format_exc()}")
|
||||
raise HTTPException(status_code=500, detail=f"获取评估基准详情失败: {str(e)}")
|
||||
@ -44,13 +56,28 @@ async def delete_evaluation_benchmark(benchmark_id: str, current_user: User = De
|
||||
|
||||
|
||||
@evaluation.get("/databases/{db_id}/results/{task_id}")
|
||||
async def get_evaluation_results_by_db(db_id: str, task_id: str, current_user: User = Depends(get_admin_user)):
|
||||
"""获取评估结果(带 db_id)"""
|
||||
async def get_evaluation_results_by_db(
|
||||
db_id: str,
|
||||
task_id: str,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
error_only: bool = False,
|
||||
current_user: User = Depends(get_admin_user)
|
||||
):
|
||||
"""获取评估结果(带 db_id,支持分页)"""
|
||||
from src.services.evaluation_service import EvaluationService
|
||||
|
||||
try:
|
||||
# 验证分页参数
|
||||
if page < 1:
|
||||
raise HTTPException(status_code=400, detail="页码必须大于0")
|
||||
if page_size < 1 or page_size > 100:
|
||||
raise HTTPException(status_code=400, detail="每页大小必须在1-100之间")
|
||||
|
||||
service = EvaluationService()
|
||||
results = await service.get_evaluation_results_by_db(db_id, task_id)
|
||||
results = await service.get_evaluation_results_by_db(
|
||||
db_id, task_id, page=page, page_size=page_size, error_only=error_only
|
||||
)
|
||||
return {"message": "success", "data": results}
|
||||
except Exception as e:
|
||||
logger.error(f"获取评估结果失败: {e}, {traceback.format_exc()}")
|
||||
|
||||
@ -136,8 +136,8 @@ class EvaluationService:
|
||||
logger.error(f"获取评估基准详情失败: {e}")
|
||||
raise
|
||||
|
||||
async def get_benchmark_detail_by_db(self, db_id: str, benchmark_id: str) -> dict[str, Any]:
|
||||
"""根据 db_id 直接获取评估基准详情"""
|
||||
async def get_benchmark_detail_by_db(self, db_id: str, benchmark_id: str, page: int = 1, page_size: int = 10) -> dict[str, Any]:
|
||||
"""根据 db_id 获取评估基准详情(支持分页)"""
|
||||
try:
|
||||
kb_instance = knowledge_base.get_kb(db_id)
|
||||
benchmarks_map = kb_instance.benchmarks_meta.get(db_id, {})
|
||||
@ -145,14 +145,46 @@ class EvaluationService:
|
||||
raise ValueError("Benchmark not found")
|
||||
meta = benchmarks_map[benchmark_id]
|
||||
data_file_path = meta.get("benchmark_file")
|
||||
|
||||
# 获取总问题数和分页数据
|
||||
total_questions = meta.get("question_count", 0)
|
||||
questions = []
|
||||
|
||||
if data_file_path and os.path.exists(data_file_path):
|
||||
# 计算分页范围
|
||||
start_index = (page - 1) * page_size
|
||||
end_index = start_index + page_size
|
||||
|
||||
# 读取指定范围的问题
|
||||
with open(data_file_path, encoding="utf-8") as f:
|
||||
current_index = 0
|
||||
for line in f:
|
||||
if line.strip():
|
||||
if not line.strip():
|
||||
continue
|
||||
|
||||
# 只处理指定范围内的问题
|
||||
if current_index >= start_index and current_index < end_index:
|
||||
questions.append(json.loads(line))
|
||||
elif current_index >= end_index:
|
||||
break # 已经读取到足够的问题,停止读取
|
||||
|
||||
current_index += 1
|
||||
|
||||
# 计算分页信息
|
||||
total_pages = (total_questions + page_size - 1) // page_size
|
||||
|
||||
meta_with_q = meta.copy()
|
||||
meta_with_q["questions"] = questions
|
||||
meta_with_q.update({
|
||||
"questions": questions,
|
||||
"pagination": {
|
||||
"current_page": page,
|
||||
"page_size": page_size,
|
||||
"total_questions": total_questions,
|
||||
"total_pages": total_pages,
|
||||
"has_next": page < total_pages,
|
||||
"has_prev": page > 1
|
||||
}
|
||||
})
|
||||
return meta_with_q
|
||||
except Exception as e:
|
||||
logger.error(f"获取评估基准详情失败: {e}")
|
||||
@ -556,7 +588,8 @@ class EvaluationService:
|
||||
f"基于以下上下文信息,请回答用户的问题。\n\n"
|
||||
f"上下文信息:{context_text}\n\n"
|
||||
f"用户问题:{question_data["query"]}\n\n"
|
||||
"请根据上下文信息准确回答问题。如果上下文中没有相关信息,请说明。\n\n"
|
||||
"请根据上下文信息准确回答问题。\n\n"
|
||||
"如果上下文中缺少相关信息,请回答“信息不足,无法回答”。\n\n"
|
||||
)
|
||||
|
||||
# 生成答案 - 使用 asyncio.to_thread 避免阻塞事件循环
|
||||
@ -728,7 +761,14 @@ class EvaluationService:
|
||||
raise
|
||||
# 索引与回退逻辑已移除,统一通过 db_id 定位
|
||||
|
||||
async def get_evaluation_results_by_db(self, db_id: str, task_id: str) -> dict[str, Any]:
|
||||
async def get_evaluation_results_by_db(
|
||||
self,
|
||||
db_id: str,
|
||||
task_id: str,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
error_only: bool = False
|
||||
) -> dict[str, Any]:
|
||||
result_file_path = os.path.join(self._get_result_dir(db_id), f"{task_id}.json")
|
||||
if not os.path.exists(result_file_path):
|
||||
task = await tasker.get_task(task_id)
|
||||
@ -740,8 +780,63 @@ class EvaluationService:
|
||||
"message": task.message,
|
||||
}
|
||||
raise ValueError(f"Result not found for task {task_id}")
|
||||
|
||||
# 加载JSON文件
|
||||
with open(result_file_path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
data = json.load(f)
|
||||
|
||||
# 如果是分页请求,处理详细结果
|
||||
if page and page_size:
|
||||
all_results = data.get("interim_results", data.get("results", []))
|
||||
|
||||
# 如果只要错误结果,先过滤
|
||||
if error_only:
|
||||
filtered_results = []
|
||||
for item in all_results:
|
||||
# 检查答案评分是否为错误(score <= 0.5)
|
||||
if item.get("metrics", {}).get("score", 1.0) <= 0.5:
|
||||
filtered_results.append(item)
|
||||
continue
|
||||
|
||||
# 检查检索指标是否明显偏低
|
||||
metrics = item.get("metrics", {})
|
||||
has_low_recall = any(
|
||||
metrics.get(k, 1.0) < 0.3
|
||||
for k in metrics
|
||||
if k.startswith("recall@")
|
||||
)
|
||||
if has_low_recall:
|
||||
filtered_results.append(item)
|
||||
all_results = filtered_results
|
||||
|
||||
# 计算分页
|
||||
total = len(all_results)
|
||||
start_idx = (page - 1) * page_size
|
||||
end_idx = start_idx + page_size
|
||||
paged_results = all_results[start_idx:end_idx]
|
||||
|
||||
# 返回分页数据
|
||||
return {
|
||||
"task_id": data.get("task_id", task_id),
|
||||
"status": data.get("status"),
|
||||
"started_at": data.get("started_at"),
|
||||
"completed_at": data.get("completed_at"),
|
||||
"total_questions": data.get("total_questions", 0),
|
||||
"completed_questions": data.get("completed_questions", 0),
|
||||
"overall_score": data.get("overall_score"),
|
||||
"retrieval_config": data.get("retrieval_config"),
|
||||
"interim_results": paged_results,
|
||||
"pagination": {
|
||||
"current_page": page,
|
||||
"page_size": page_size,
|
||||
"total": total,
|
||||
"total_pages": (total + page_size - 1) // page_size,
|
||||
"error_only": error_only
|
||||
}
|
||||
}
|
||||
|
||||
# 非分页请求,返回完整数据(保持向后兼容)
|
||||
return data
|
||||
|
||||
async def delete_evaluation_result_by_db(self, db_id: str, task_id: str) -> None:
|
||||
result_file_path = os.path.join(self._get_result_dir(db_id), f"{task_id}.json")
|
||||
|
||||
@ -360,8 +360,12 @@ export const evaluationApi = {
|
||||
* @param {string} dbId - 知识库ID
|
||||
* @param {string} benchmarkId - 基准ID
|
||||
*/
|
||||
getBenchmarkByDb: async (dbId, benchmarkId) => {
|
||||
return apiAdminGet(`/api/evaluation/databases/${dbId}/benchmarks/${benchmarkId}`)
|
||||
getBenchmarkByDb: async (dbId, benchmarkId, page = 1, pageSize = 50) => {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
page_size: pageSize.toString()
|
||||
})
|
||||
return apiAdminGet(`/api/evaluation/databases/${dbId}/benchmarks/${benchmarkId}?${params}`)
|
||||
},
|
||||
|
||||
/**
|
||||
@ -420,8 +424,15 @@ export const evaluationApi = {
|
||||
},
|
||||
|
||||
// 新接口:带 db_id 的评估结果查询与删除
|
||||
getEvaluationResultsByDb: async (dbId, taskId) => {
|
||||
return apiAdminGet(`/api/evaluation/databases/${dbId}/results/${taskId}`)
|
||||
getEvaluationResultsByDb: async (dbId, taskId, params = {}) => {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params.page) queryParams.append('page', params.page);
|
||||
if (params.pageSize) queryParams.append('page_size', params.pageSize);
|
||||
if (params.errorOnly !== undefined) queryParams.append('error_only', params.errorOnly);
|
||||
|
||||
const url = `/api/evaluation/databases/${dbId}/results/${taskId}${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
|
||||
return apiAdminGet(url);
|
||||
},
|
||||
deleteEvaluationResultByDb: async (dbId, taskId) => {
|
||||
return apiAdminDelete(`/api/evaluation/databases/${dbId}/results/${taskId}`)
|
||||
|
||||
@ -120,7 +120,7 @@
|
||||
<a-modal
|
||||
v-model:open="previewModalVisible"
|
||||
title="评估基准详情"
|
||||
width="800px"
|
||||
width="1200px"
|
||||
:footer="null"
|
||||
>
|
||||
<div v-if="previewData" class="preview-content">
|
||||
@ -146,30 +146,44 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-questions" v-if="previewQuestions.length > 0">
|
||||
<h4>问题示例 (前5条)</h4>
|
||||
<div class="question-list">
|
||||
<div
|
||||
v-for="(item, index) in previewQuestions.slice(0, 5)"
|
||||
:key="index"
|
||||
class="question-item"
|
||||
>
|
||||
<div class="question-header">
|
||||
<span class="question-num">Q{{ index + 1 }}</span>
|
||||
</div>
|
||||
<div class="question-body">
|
||||
<p class="question-text">{{ item.query }}</p>
|
||||
<div v-if="item.gold_chunk_ids" class="question-chunk">
|
||||
黄金Chunk: {{ item.gold_chunk_ids.slice(0, 3).join(', ') }}
|
||||
<span v-if="item.gold_chunk_ids.length > 3">...等{{ item.gold_chunk_ids.length }}个</span>
|
||||
</div>
|
||||
<div v-if="item.gold_answer" class="question-answer">
|
||||
黄金答案: {{ item.gold_answer.slice(0, 150) }}
|
||||
<span v-if="item.gold_answer.length > 150">...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-questions" v-if="previewQuestions && previewQuestions.length > 0">
|
||||
<h4>问题列表 (共{{ previewPagination.total }}条)</h4>
|
||||
<a-table
|
||||
:dataSource="previewQuestions"
|
||||
:columns="displayedQuestionColumns"
|
||||
:pagination="paginationConfig"
|
||||
size="small"
|
||||
:rowKey="(_, index) => index"
|
||||
:loading="previewPagination.loading"
|
||||
>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'index'">
|
||||
<span class="question-num">Q{{ (previewPagination.current - 1) * previewPagination.pageSize + index + 1 }}</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'query'">
|
||||
<a-tooltip :title="record?.query || ''" placement="topLeft">
|
||||
<div class="question-text">{{ record?.query || '' }}</div>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<template v-if="column.key === 'gold_chunk_ids'">
|
||||
<a-tooltip v-if="record?.gold_chunk_ids && record.gold_chunk_ids.length > 0" :title="record.gold_chunk_ids.join(', ')" placement="topLeft">
|
||||
<div class="question-chunk">
|
||||
{{ record.gold_chunk_ids.slice(0, 3).join(', ') }}
|
||||
<span v-if="record.gold_chunk_ids.length > 3">...等{{ record.gold_chunk_ids.length }}个</span>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
<span v-else class="no-data">-</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'gold_answer'">
|
||||
<a-tooltip v-if="record?.gold_answer" :title="record.gold_answer" placement="topLeft">
|
||||
<div class="question-answer">
|
||||
{{ record.gold_answer }}
|
||||
</div>
|
||||
</a-tooltip>
|
||||
<span v-else class="no-data">-</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
@ -177,7 +191,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { ref, reactive, onMounted, computed } from 'vue';
|
||||
import { message, Modal } from 'ant-design-vue';
|
||||
import {
|
||||
UploadOutlined,
|
||||
@ -212,6 +226,64 @@ const generateModalVisible = ref(false);
|
||||
const previewModalVisible = ref(false);
|
||||
const previewData = ref(null);
|
||||
const previewQuestions = ref([]);
|
||||
const previewPagination = ref({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
loading: false
|
||||
});
|
||||
|
||||
// 表格列定义
|
||||
const questionColumns = [
|
||||
{
|
||||
title: '#',
|
||||
key: 'index',
|
||||
width: 60,
|
||||
align: 'center'
|
||||
},
|
||||
{
|
||||
title: '问题',
|
||||
dataIndex: 'query',
|
||||
key: 'query',
|
||||
width: 280,
|
||||
ellipsis: false
|
||||
},
|
||||
{
|
||||
title: '黄金Chunk',
|
||||
dataIndex: 'gold_chunk_ids',
|
||||
key: 'gold_chunk_ids',
|
||||
width: 200,
|
||||
ellipsis: false
|
||||
},
|
||||
{
|
||||
title: '黄金答案',
|
||||
dataIndex: 'gold_answer',
|
||||
key: 'gold_answer',
|
||||
width: 420,
|
||||
ellipsis: false
|
||||
}
|
||||
];
|
||||
|
||||
const displayedQuestionColumns = computed(() => {
|
||||
if (previewData.value && previewData.value.has_gold_chunks === false) {
|
||||
return questionColumns.filter(c => c.key !== 'gold_chunk_ids');
|
||||
}
|
||||
return questionColumns;
|
||||
});
|
||||
|
||||
// 分页配置
|
||||
const paginationConfig = computed(() => ({
|
||||
current: previewPagination.value.current,
|
||||
pageSize: previewPagination.value.pageSize,
|
||||
total: previewPagination.value.total,
|
||||
showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条,共 ${total} 条`,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['5', '10', '20', '50'],
|
||||
showQuickJumper: true,
|
||||
size: 'small',
|
||||
onChange: handlePageChange,
|
||||
onShowSizeChange: handlePageSizeChange
|
||||
}));
|
||||
|
||||
// 加载基准列表
|
||||
const loadBenchmarks = async () => {
|
||||
@ -264,13 +336,71 @@ const onGenerateSuccess = () => {
|
||||
emit('refresh');
|
||||
};
|
||||
|
||||
// 分页处理函数
|
||||
const handlePageChange = (page, pageSize) => {
|
||||
previewPagination.value.current = page;
|
||||
previewPagination.value.pageSize = pageSize;
|
||||
loadPreviewQuestions();
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (current, size) => {
|
||||
previewPagination.value.current = 1;
|
||||
previewPagination.value.pageSize = size;
|
||||
loadPreviewQuestions();
|
||||
};
|
||||
|
||||
// 加载预览问题(分页)
|
||||
const loadPreviewQuestions = async () => {
|
||||
if (!previewData.value?.benchmark_id) return;
|
||||
|
||||
try {
|
||||
previewPagination.value.loading = true;
|
||||
const response = await evaluationApi.getBenchmarkByDb(
|
||||
props.databaseId,
|
||||
previewData.value.benchmark_id,
|
||||
previewPagination.value.current,
|
||||
previewPagination.value.pageSize
|
||||
);
|
||||
|
||||
if (response.message === 'success') {
|
||||
previewQuestions.value = response.data.questions || [];
|
||||
previewPagination.value.total = response.data.pagination?.total_questions || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载预览问题失败:', error);
|
||||
message.error('加载预览问题失败');
|
||||
} finally {
|
||||
previewPagination.value.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 预览基准
|
||||
const previewBenchmark = async (benchmark) => {
|
||||
try {
|
||||
const response = await evaluationApi.getBenchmarkByDb(props.databaseId, benchmark.benchmark_id);
|
||||
// 重置分页状态
|
||||
previewPagination.value = {
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
loading: false
|
||||
};
|
||||
|
||||
const response = await evaluationApi.getBenchmarkByDb(
|
||||
props.databaseId,
|
||||
benchmark.benchmark_id,
|
||||
previewPagination.value.current,
|
||||
previewPagination.value.pageSize
|
||||
);
|
||||
|
||||
if (response.message === 'success') {
|
||||
previewData.value = response.data;
|
||||
// 保存基准ID用于后续分页请求
|
||||
previewData.value = {
|
||||
...response.data,
|
||||
benchmark_id: benchmark.benchmark_id // 手动添加benchmark_id
|
||||
};
|
||||
previewQuestions.value = response.data.questions || [];
|
||||
previewPagination.value.total = response.data.pagination?.total_questions || 0;
|
||||
console.log('预览问题数据:', response.data.questions); // 调试信息
|
||||
previewModalVisible.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
@ -563,42 +693,70 @@ onMounted(() => {
|
||||
color: var(--gray-900);
|
||||
}
|
||||
|
||||
.question-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
.question-num {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
.question-item {
|
||||
padding: 16px;
|
||||
background: var(--gray-50);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--gray-200);
|
||||
.question-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--gray-800);
|
||||
word-break: break-all;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
max-height: 6em; // 4行 * 1.5em line-height
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.question-header {
|
||||
margin-bottom: 8px;
|
||||
.question-chunk,
|
||||
.question-answer {
|
||||
font-size: 13px;
|
||||
color: var(--gray-600);
|
||||
word-break: break-all;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
max-height: 6em; // 4行 * 1.5em line-height for 13px font
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.question-num {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
.no-data {
|
||||
color: var(--gray-400);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
:deep(.ant-table) {
|
||||
.ant-table-thead > tr > th {
|
||||
background-color: var(--gray-50);
|
||||
border-bottom: 1px solid var(--gray-200);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.question-body {
|
||||
.question-text {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--gray-800);
|
||||
}
|
||||
.ant-table-tbody > tr > td {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--gray-150);
|
||||
font-size: 13px;
|
||||
vertical-align: top;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.question-chunk,
|
||||
.question-answer {
|
||||
margin: 8px 0;
|
||||
font-size: 13px;
|
||||
color: var(--gray-600);
|
||||
}
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
background-color: var(--gray-50);
|
||||
}
|
||||
|
||||
// 确保表格单元格内容可以换行
|
||||
.ant-table-cell {
|
||||
white-space: normal !important;
|
||||
word-wrap: break-word !important;
|
||||
word-break: break-all !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,6 +37,9 @@
|
||||
>
|
||||
<SettingOutlined /> 分块参数 ({{ chunkParams.chunk_size }}/{{ chunkParams.chunk_overlap }})
|
||||
</a-button>
|
||||
<a-button type="link" class="doc-link-btn" @click="openDocLink">
|
||||
<InfoCircleOutlined /> 文档处理与 OCR 说明
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -691,6 +694,10 @@ const getAuthHeaders = () => {
|
||||
return userStore.getAuthHeaders();
|
||||
};
|
||||
|
||||
const openDocLink = () => {
|
||||
window.open('https://xerrors.github.io/Yuxi-Know/latest/advanced/document-processing.html', '_blank', 'noopener');
|
||||
};
|
||||
|
||||
const chunkData = async () => {
|
||||
if (!databaseId.value) {
|
||||
message.error('请先选择知识库');
|
||||
@ -866,6 +873,11 @@ const chunkData = async () => {
|
||||
color: var(--color-warning-500);
|
||||
}
|
||||
|
||||
.doc-link-btn {
|
||||
color: var(--main-600);
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
// 同名文件提示样式
|
||||
.same-name-files-section {
|
||||
margin-top: 16px;
|
||||
|
||||
@ -260,23 +260,48 @@
|
||||
</a-row>
|
||||
|
||||
<!-- 详细结果表格 -->
|
||||
<h4 style="margin-bottom: 16px">详细评估结果</h4>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<div>
|
||||
<h4 style="margin: 0;">详细评估结果</h4>
|
||||
<span style="font-size: 12px; color: var(--gray-600); margin-left: 8px;">
|
||||
{{ showErrorsOnly ? `仅显示错误结果(共 ${paginationTotal} 条)` : `显示全部结果(共 ${paginationTotal} 条)` }}
|
||||
</span>
|
||||
</div>
|
||||
<a-button
|
||||
type="default"
|
||||
size="small"
|
||||
@click="toggleErrorOnly"
|
||||
:class="{ 'error-only-active': showErrorsOnly }"
|
||||
>
|
||||
{{ showErrorsOnly ? '显示全部' : '仅查看错误' }}
|
||||
</a-button>
|
||||
</div>
|
||||
<a-table
|
||||
:columns="resultColumns"
|
||||
:data-source="detailedResults"
|
||||
:pagination="{ pageSize: 10, showSizeChanger: true, showQuickJumper: true }"
|
||||
:pagination="{
|
||||
current: currentPage,
|
||||
pageSize: pageSize,
|
||||
total: paginationTotal,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条,共 ${total} 条`,
|
||||
onChange: handlePageChange,
|
||||
onShowSizeChange: handlePageSizeChange
|
||||
}"
|
||||
:scroll="{ x: 1000 }"
|
||||
size="small"
|
||||
:loading="resultsLoading"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'query'">
|
||||
<a-tooltip :title="record.query">
|
||||
<span class="query-text">{{ truncateText(record.query, 50) }}</span>
|
||||
<div class="query-text">{{ record.query }}</div>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'generated_answer'">
|
||||
<a-tooltip :title="record.generated_answer">
|
||||
<span class="answer-text">{{ truncateText(record.generated_answer || '-', 80) }}</span>
|
||||
<div class="answer-text">{{ record.generated_answer || '-' }}</div>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'retrieval_score'">
|
||||
@ -299,7 +324,7 @@
|
||||
</a-tag>
|
||||
<div v-if="record.metrics.reasoning" class="answer-reasoning">
|
||||
<a-tooltip :title="record.metrics.reasoning">
|
||||
{{ truncateText(record.metrics.reasoning, 80) }}
|
||||
{{ record.metrics.reasoning }}
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
@ -359,6 +384,11 @@ const evaluationStats = ref({});
|
||||
const resultsLoading = ref(false);
|
||||
const searchConfigModalVisible = ref(false);
|
||||
const refreshingHistory = ref(false);
|
||||
const showErrorsOnly = ref(false);
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const paginationTotal = ref(0);
|
||||
const paginationTotalPages = ref(0);
|
||||
|
||||
|
||||
// 评估配置表单(使用知识库默认配置)
|
||||
@ -368,29 +398,48 @@ const configForm = reactive({
|
||||
});
|
||||
|
||||
// 表格列定义
|
||||
const resultColumns = [
|
||||
{
|
||||
title: '问题',
|
||||
dataIndex: 'query',
|
||||
key: 'query',
|
||||
width: 200
|
||||
},
|
||||
{
|
||||
title: '生成答案',
|
||||
key: 'generated_answer',
|
||||
width: 250
|
||||
},
|
||||
{
|
||||
title: '检索指标',
|
||||
key: 'retrieval_score',
|
||||
width: 300
|
||||
},
|
||||
{
|
||||
title: '答案评判',
|
||||
key: 'answer_score',
|
||||
width: 200
|
||||
const resultColumns = computed(() => {
|
||||
const columns = [
|
||||
{
|
||||
title: '问题',
|
||||
dataIndex: 'query',
|
||||
key: 'query',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '生成答案',
|
||||
key: 'generated_answer',
|
||||
width: 180
|
||||
},
|
||||
{
|
||||
title: '答案评判',
|
||||
key: 'answer_score',
|
||||
width: 260
|
||||
}
|
||||
];
|
||||
|
||||
// 检查是否有检索指标数据
|
||||
const hasRetrievalMetrics = detailedResults.value.some(item => {
|
||||
if (!item.metrics) return false;
|
||||
return Object.keys(item.metrics).some(key =>
|
||||
key.startsWith('recall') ||
|
||||
key.startsWith('precision') ||
|
||||
key === 'map' ||
|
||||
key === 'ndcg'
|
||||
);
|
||||
});
|
||||
|
||||
// 如果有检索指标数据,添加检索指标列
|
||||
if (hasRetrievalMetrics) {
|
||||
columns.splice(2, 0, {
|
||||
title: '检索指标',
|
||||
key: 'retrieval_score',
|
||||
width: 100
|
||||
});
|
||||
}
|
||||
];
|
||||
|
||||
return columns;
|
||||
});
|
||||
|
||||
const historyColumns = [
|
||||
{
|
||||
@ -463,6 +512,93 @@ const currentBenchmark = computed(() => {
|
||||
return availableBenchmarks.value.find(b => b.benchmark_id === selectedBenchmarkId.value);
|
||||
});
|
||||
|
||||
// 切换错误显示模式
|
||||
const toggleErrorOnly = async () => {
|
||||
resultsLoading.value = true;
|
||||
showErrorsOnly.value = !showErrorsOnly.value;
|
||||
currentPage.value = 1; // 切换模式时重置到第一页
|
||||
|
||||
// 立即加载新的分页数据
|
||||
await loadResultsWithPagination();
|
||||
};
|
||||
|
||||
// 处理分页变化
|
||||
const handlePageChange = (page, size) => {
|
||||
currentPage.value = page;
|
||||
if (size !== pageSize.value) {
|
||||
pageSize.value = size;
|
||||
}
|
||||
loadResultsWithPagination();
|
||||
};
|
||||
|
||||
// 处理页面大小变化
|
||||
const handlePageSizeChange = (current, size) => {
|
||||
currentPage.value = 1;
|
||||
pageSize.value = size;
|
||||
loadResultsWithPagination();
|
||||
};
|
||||
|
||||
// 加载分页结果
|
||||
const loadResultsWithPagination = async () => {
|
||||
if (!selectedResult.value) return;
|
||||
|
||||
try {
|
||||
resultsLoading.value = true;
|
||||
const response = await evaluationApi.getEvaluationResultsByDb(
|
||||
props.databaseId,
|
||||
selectedResult.value.task_id,
|
||||
{
|
||||
page: currentPage.value,
|
||||
pageSize: pageSize.value,
|
||||
errorOnly: showErrorsOnly.value
|
||||
}
|
||||
);
|
||||
|
||||
if (response.message === 'success' && response.data) {
|
||||
const resultData = response.data;
|
||||
|
||||
// 更新详细结果
|
||||
detailedResults.value = resultData.interim_results || [];
|
||||
|
||||
// 更新分页信息
|
||||
if (resultData.pagination) {
|
||||
paginationTotal.value = resultData.pagination.total;
|
||||
paginationTotalPages.value = resultData.pagination.total_pages;
|
||||
} else {
|
||||
// 兼容旧格式数据
|
||||
paginationTotal.value = detailedResults.value.length;
|
||||
paginationTotalPages.value = 1;
|
||||
}
|
||||
|
||||
// 更新统计信息
|
||||
// 如果是过滤模式,需要基于过滤后的总数计算统计
|
||||
if (showErrorsOnly.value) {
|
||||
// 在过滤模式下,只计算当前页的统计(避免重复计算)
|
||||
evaluationStats.value = {
|
||||
...evaluationStats.value,
|
||||
totalQuestions: paginationTotal.value,
|
||||
// 可以在这里添加其他基于过滤后数据的统计
|
||||
};
|
||||
} else if (currentPage.value === 1) {
|
||||
// 非过滤模式且是第一页时,才计算完整统计
|
||||
evaluationStats.value = calculateEvaluationStats(detailedResults.value);
|
||||
}
|
||||
|
||||
// 更新其他基本信息(保持原有的信息不变)
|
||||
if (resultData.started_at && resultData.completed_at) {
|
||||
const startTime = new Date(resultData.started_at);
|
||||
const endTime = new Date(resultData.completed_at);
|
||||
evaluationStats.value.totalDuration = (endTime - startTime) / 1000;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载评估结果失败:', error);
|
||||
message.error('加载评估结果失败');
|
||||
} finally {
|
||||
resultsLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 打开检索配置弹窗
|
||||
const openSearchConfigModal = () => {
|
||||
searchConfigModalVisible.value = true;
|
||||
@ -646,17 +782,17 @@ const calculateEvaluationStats = (results) => {
|
||||
const viewResults = async (taskId) => {
|
||||
try {
|
||||
resultsLoading.value = true;
|
||||
|
||||
// 重置分页状态
|
||||
currentPage.value = 1;
|
||||
showErrorsOnly.value = false;
|
||||
|
||||
// 先获取基本信息(不分页)
|
||||
const response = await evaluationApi.getEvaluationResultsByDb(props.databaseId, taskId);
|
||||
|
||||
if (response.message === 'success' && response.data) {
|
||||
// API 返回的是直接的评估结果对象
|
||||
const resultData = response.data;
|
||||
|
||||
// 设置详细结果 - 如果有 interim_results 就用它,否则尝试其他字段
|
||||
detailedResults.value = resultData.interim_results || resultData.results || [];
|
||||
|
||||
// 计算统计信息
|
||||
evaluationStats.value = calculateEvaluationStats(detailedResults.value);
|
||||
|
||||
// 从历史记录中找到对应的任务信息,如果没有则使用API返回的数据
|
||||
selectedResult.value = evaluationHistory.value.find(r => r.task_id === taskId) || {
|
||||
task_id: resultData.task_id,
|
||||
@ -674,14 +810,11 @@ const viewResults = async (taskId) => {
|
||||
selectedResult.value.retrieval_config = resultData.retrieval_config;
|
||||
}
|
||||
|
||||
// 计算总耗时
|
||||
if (resultData.started_at && resultData.completed_at) {
|
||||
const startTime = new Date(resultData.started_at);
|
||||
const endTime = new Date(resultData.completed_at);
|
||||
evaluationStats.value.totalDuration = (endTime - startTime) / 1000; // 秒
|
||||
}
|
||||
|
||||
// 打开模态框
|
||||
resultModalVisible.value = true;
|
||||
|
||||
// 加载分页数据
|
||||
await loadResultsWithPagination();
|
||||
} else {
|
||||
message.error('获取评估结果失败:数据格式错误');
|
||||
}
|
||||
@ -965,21 +1098,25 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.query-text {
|
||||
display: inline-block;
|
||||
max-width: 200px;
|
||||
font-size: 12px;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.answer-text {
|
||||
display: inline-block;
|
||||
max-width: 250px;
|
||||
font-size: 12px;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.4;
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
@ -993,7 +1130,8 @@ onMounted(() => {
|
||||
// 优化表格样式
|
||||
:deep(.ant-table) {
|
||||
.ant-table-tbody > tr > td {
|
||||
padding: 8px 12px;
|
||||
padding: 12px 12px;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.ant-table-thead > tr > th {
|
||||
@ -1167,9 +1305,15 @@ onMounted(() => {
|
||||
.answer-reasoning {
|
||||
font-size: 12px;
|
||||
color: var(--gray-600);
|
||||
margin-top: 4px;
|
||||
line-height: 1.3;
|
||||
margin-top: 8px;
|
||||
line-height: 1.4;
|
||||
cursor: pointer;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
word-wrap: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
&:hover {
|
||||
color: var(--gray-800);
|
||||
@ -1320,4 +1464,21 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// 仅查看错误按钮样式
|
||||
.error-only-active {
|
||||
background-color: var(--color-error-500) !important;
|
||||
border-color: var(--color-error-500) !important;
|
||||
color: white !important;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-error-600) !important;
|
||||
border-color: var(--color-error-600) !important;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background-color: var(--color-error-500) !important;
|
||||
border-color: var(--color-error-500) !important;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
</a-textarea>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="生成参数" name="params">
|
||||
<a-form-item label="生成参数" name="params" :extra="extraText">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="问题数量" name="count" :labelCol="{ span: 24 }" :wrapperCol="{ span: 24 }">
|
||||
@ -112,7 +112,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch } from 'vue';
|
||||
import { ref, reactive, computed, watch, h } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { evaluationApi } from '@/apis/knowledge_api';
|
||||
import ModelSelectorComponent from '@/components/ModelSelectorComponent.vue';
|
||||
@ -166,6 +166,16 @@ const visible = computed({
|
||||
set: (val) => emit('update:visible', val)
|
||||
});
|
||||
|
||||
// 说明文本
|
||||
const extraText = computed(() => h('span', {}, [
|
||||
'需要了解评估基准生成原理?查看',
|
||||
h('a', {
|
||||
href: 'https://xerrors.github.io/Yuxi-Know/latest/intro/evaluation.html',
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer'
|
||||
}, '使用说明')
|
||||
]));
|
||||
|
||||
// 生成基准
|
||||
const handleGenerate = async () => {
|
||||
try {
|
||||
|
||||
@ -28,7 +28,11 @@
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="基准文件" name="file">
|
||||
<a-form-item
|
||||
label="基准文件"
|
||||
name="file"
|
||||
:extra="extraText"
|
||||
>
|
||||
<a-upload-dragger
|
||||
v-model:fileList="fileList"
|
||||
name="file"
|
||||
@ -52,7 +56,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch } from 'vue';
|
||||
import { ref, reactive, computed, watch, h } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { FileTextOutlined } from '@ant-design/icons-vue';
|
||||
import { evaluationApi } from '@/apis/knowledge_api';
|
||||
@ -98,52 +102,67 @@ const visible = computed({
|
||||
set: (val) => emit('update:visible', val)
|
||||
});
|
||||
|
||||
// 说明文本
|
||||
const extraText = computed(() => h('span', {}, [
|
||||
'需要了解评估基准格式?查看',
|
||||
h('a', {
|
||||
href: 'https://xerrors.github.io/Yuxi-Know/latest/intro/evaluation.html',
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer'
|
||||
}, '使用说明')
|
||||
]));
|
||||
|
||||
// 文件上传前验证
|
||||
const beforeUpload = (file) => {
|
||||
const beforeUpload = async (file) => {
|
||||
// 检查文件类型
|
||||
if (!file.name.endsWith('.jsonl')) {
|
||||
message.error('仅支持 JSONL 格式文件');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查文件大小(限制为10MB)
|
||||
const isLt10M = file.size / 1024 / 1024 < 10;
|
||||
if (!isLt10M) {
|
||||
message.error('文件大小不能超过 10MB');
|
||||
// 检查文件大小(限制为100MB)
|
||||
const isLt100M = file.size / 1024 / 1024 < 100;
|
||||
if (!isLt100M) {
|
||||
message.error('文件大小不能超过 100MB');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 读取文件内容验证格式
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const content = e.target.result;
|
||||
const lines = content.trim().split('\n');
|
||||
try {
|
||||
// 读取文件内容验证格式
|
||||
const content = await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => resolve(e.target.result);
|
||||
reader.onerror = () => reject(new Error('文件读取失败'));
|
||||
reader.readAsText(file);
|
||||
});
|
||||
|
||||
// 验证至少有一行
|
||||
if (lines.length === 0) {
|
||||
message.error('文件不能为空');
|
||||
return false;
|
||||
}
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
// 验证JSON格式
|
||||
for (let i = 0; i < Math.min(5, lines.length); i++) {
|
||||
const line = lines[i].trim();
|
||||
if (line) {
|
||||
JSON.parse(line);
|
||||
}
|
||||
}
|
||||
|
||||
formState.file = file;
|
||||
} catch (error) {
|
||||
message.error('文件格式错误,请检查JSONL格式');
|
||||
// 验证至少有一行
|
||||
if (lines.length === 0) {
|
||||
message.error('文件不能为空');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
|
||||
// 阻止自动上传
|
||||
return false;
|
||||
// 验证JSON格式
|
||||
for (let i = 0; i < Math.min(5, lines.length); i++) {
|
||||
const line = lines[i].trim();
|
||||
if (line) {
|
||||
JSON.parse(line);
|
||||
}
|
||||
}
|
||||
|
||||
// 验证通过,设置文件
|
||||
formState.file = file;
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
message.error('文件格式错误,请检查JSONL格式');
|
||||
} else {
|
||||
message.error('文件验证失败: ' + error.message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 移除文件
|
||||
|
||||
Loading…
Reference in New Issue
Block a user