feat(evaluation): 支持评估基准下载

This commit is contained in:
肖泽涛 2026-02-20 13:23:20 +08:00
parent 5348106513
commit 360316963e
5 changed files with 182 additions and 0 deletions

View File

@ -1,6 +1,7 @@
import traceback
from fastapi import APIRouter, HTTPException, Depends, File, Form, Body, UploadFile
from fastapi.responses import FileResponse
from src.storage.postgres.models_business import User
from server.utils.auth_middleware import get_admin_user
from src.utils import logger
@ -53,6 +54,29 @@ async def delete_evaluation_benchmark(benchmark_id: str, current_user: User = De
raise HTTPException(status_code=500, detail=f"删除评估基准失败: {str(e)}")
@evaluation.get("/benchmarks/{benchmark_id}/download")
async def download_evaluation_benchmark(benchmark_id: str, current_user: User = Depends(get_admin_user)):
"""下载评估基准文件"""
from src.services.evaluation_service import EvaluationService
try:
service = EvaluationService()
download_info = await service.get_benchmark_download_info(benchmark_id)
return FileResponse(
path=download_info["file_path"],
filename=download_info["filename"],
media_type="application/x-ndjson",
)
except ValueError as e:
if "not found" in str(e).lower():
raise HTTPException(status_code=404, detail=str(e))
logger.error(f"下载评估基准失败: {e}, {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"下载评估基准失败: {str(e)}")
except Exception as e:
logger.error(f"下载评估基准失败: {e}, {traceback.format_exc()}")
raise HTTPException(status_code=500, detail=f"下载评估基准失败: {str(e)}")
@evaluation.get("/databases/{db_id}/results/{task_id}")
async def get_evaluation_results_by_db(
db_id: str,

View File

@ -231,6 +231,29 @@ class EvaluationService:
logger.error(f"获取评估基准详情失败: {e}")
raise
async def get_benchmark_download_info(self, benchmark_id: str) -> dict[str, str]:
"""获取评估基准下载信息"""
row = await self.eval_repo.get_benchmark(benchmark_id)
if row is None:
raise ValueError("Benchmark not found")
data_file_path = row.data_file_path or ""
if not data_file_path or not os.path.exists(data_file_path):
raise ValueError("Benchmark file not found")
filename_base = (row.name or "").strip()
if not filename_base:
filename_base = row.benchmark_id
filename_base = re.sub(r"[\\/:*?\"<>|]+", "_", filename_base).strip()
if not filename_base or filename_base in {".", ".."}:
filename_base = row.benchmark_id
if not filename_base.endswith(".jsonl"):
filename_base = f"{filename_base}.jsonl"
return {"file_path": data_file_path, "filename": filename_base}
async def delete_benchmark(self, benchmark_id: str) -> None:
"""删除评估基准"""
try:

View File

@ -0,0 +1,62 @@
"""
Integration tests for evaluation router endpoints.
"""
from __future__ import annotations
import uuid
import pytest
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
async def _upload_test_benchmark(test_client, admin_headers: dict[str, str], db_id: str) -> tuple[str, str]:
benchmark_name = f"pytest_benchmark_{uuid.uuid4().hex[:8]}"
line = '{"query":"什么是单元测试?","gold_answer":"用于验证代码行为的自动化测试"}\n'
response = await test_client.post(
f"/api/evaluation/databases/{db_id}/benchmarks/upload",
data={"name": benchmark_name, "description": "pytest benchmark for download"},
files={"file": ("pytest_benchmark.jsonl", line.encode("utf-8"), "application/x-ndjson")},
headers=admin_headers,
)
assert response.status_code == 200, response.text
payload = response.json()
assert payload.get("message") == "success"
benchmark_id = payload.get("data", {}).get("benchmark_id")
assert benchmark_id
return benchmark_id, line
async def test_download_benchmark_requires_admin(test_client, standard_user):
response = await test_client.get(
"/api/evaluation/benchmarks/benchmark_fake/download",
headers=standard_user["headers"],
)
assert response.status_code == 403
async def test_admin_can_download_benchmark(test_client, admin_headers, knowledge_database):
benchmark_id, expected_line = await _upload_test_benchmark(test_client, admin_headers, knowledge_database["db_id"])
response = await test_client.get(
f"/api/evaluation/benchmarks/{benchmark_id}/download",
headers=admin_headers,
)
assert response.status_code == 200, response.text
assert "application/x-ndjson" in response.headers.get("content-type", "")
assert "attachment" in response.headers.get("content-disposition", "").lower()
content = response.content.decode("utf-8")
assert expected_line.strip() in content
async def test_download_benchmark_not_found(test_client, admin_headers):
response = await test_client.get(
f"/api/evaluation/benchmarks/benchmark_not_found_{uuid.uuid4().hex[:8]}/download",
headers=admin_headers,
)
assert response.status_code == 404, response.text

View File

@ -453,6 +453,15 @@ export const evaluationApi = {
return apiAdminDelete(`/api/evaluation/benchmarks/${benchmarkId}`)
},
/**
* 下载评估基准
* @param {string} benchmarkId - 基准ID
* @returns {Promise} - Response对象
*/
downloadBenchmark: async (benchmarkId) => {
return apiAdminGet(`/api/evaluation/benchmarks/${benchmarkId}/download`, {}, 'blob')
},
/**
* 自动生成评估基准
* @param {string} dbId - 知识库ID

View File

@ -48,6 +48,14 @@
<a-button type="text" size="small" @click.stop="previewBenchmark(benchmark)">
<EyeOutlined />
</a-button>
<a-button
type="text"
size="small"
:loading="!!downloadingBenchmarkMap[benchmark.benchmark_id]"
@click.stop="downloadBenchmark(benchmark)"
>
<DownloadOutlined />
</a-button>
<a-button type="text" size="small" danger @click.stop="deleteBenchmark(benchmark)">
<DeleteOutlined />
</a-button>
@ -196,6 +204,7 @@ import {
UploadOutlined,
RobotOutlined,
EyeOutlined,
DownloadOutlined,
DeleteOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
@ -225,6 +234,7 @@ const generateModalVisible = ref(false)
const previewModalVisible = ref(false)
const previewData = ref(null)
const previewQuestions = ref([])
const downloadingBenchmarkMap = reactive({})
const previewPagination = ref({
current: 1,
pageSize: 10,
@ -407,6 +417,60 @@ const previewBenchmark = async (benchmark) => {
}
}
const parseDownloadFilename = (contentDisposition) => {
if (!contentDisposition) return ''
const utf8Match = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)
if (utf8Match && utf8Match[1]) {
try {
return decodeURIComponent(utf8Match[1])
} catch (error) {
console.warn('解析 UTF-8 文件名失败:', error)
}
}
const asciiMatch = contentDisposition.match(/filename=\"?([^\";]+)\"?/i)
if (asciiMatch && asciiMatch[1]) {
return asciiMatch[1]
}
return ''
}
//
const downloadBenchmark = async (benchmark) => {
const benchmarkId = benchmark?.benchmark_id
if (!benchmarkId) return
if (downloadingBenchmarkMap[benchmarkId]) return
downloadingBenchmarkMap[benchmarkId] = true
try {
const response = await evaluationApi.downloadBenchmark(benchmarkId)
const blob = await response.blob()
const contentDisposition =
response.headers.get('Content-Disposition') || response.headers.get('content-disposition')
const headerFilename = parseDownloadFilename(contentDisposition)
const fallbackFilename = `${benchmark.name || benchmarkId}.jsonl`
const filename = headerFilename || fallbackFilename
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
message.success('下载成功')
} catch (error) {
console.error('下载基准失败:', error)
message.error(`下载失败: ${error.message || '未知错误'}`)
} finally {
delete downloadingBenchmarkMap[benchmarkId]
}
}
//
const deleteBenchmark = (benchmark) => {
Modal.confirm({