From 360316963e63991cd34d42b5fcd200718dd53043 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=82=96=E6=B3=BD=E6=B6=9B?= Date: Fri, 20 Feb 2026 13:23:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(evaluation):=20=E6=94=AF=E6=8C=81=E8=AF=84?= =?UTF-8?q?=E4=BC=B0=E5=9F=BA=E5=87=86=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/evaluation_router.py | 24 ++++++++ src/services/evaluation_service.py | 23 ++++++++ test/api/test_evaluation_router.py | 62 ++++++++++++++++++++ web/src/apis/knowledge_api.js | 9 +++ web/src/components/EvaluationBenchmarks.vue | 64 +++++++++++++++++++++ 5 files changed, 182 insertions(+) create mode 100644 test/api/test_evaluation_router.py diff --git a/server/routers/evaluation_router.py b/server/routers/evaluation_router.py index 5fee1ff2..ee635ce2 100644 --- a/server/routers/evaluation_router.py +++ b/server/routers/evaluation_router.py @@ -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, diff --git a/src/services/evaluation_service.py b/src/services/evaluation_service.py index e1b6968b..1c4b42b9 100644 --- a/src/services/evaluation_service.py +++ b/src/services/evaluation_service.py @@ -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: diff --git a/test/api/test_evaluation_router.py b/test/api/test_evaluation_router.py new file mode 100644 index 00000000..c90e6492 --- /dev/null +++ b/test/api/test_evaluation_router.py @@ -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 + diff --git a/web/src/apis/knowledge_api.js b/web/src/apis/knowledge_api.js index 8c23230b..7e78794f 100644 --- a/web/src/apis/knowledge_api.js +++ b/web/src/apis/knowledge_api.js @@ -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 diff --git a/web/src/components/EvaluationBenchmarks.vue b/web/src/components/EvaluationBenchmarks.vue index 667d9c80..5edf108b 100644 --- a/web/src/components/EvaluationBenchmarks.vue +++ b/web/src/components/EvaluationBenchmarks.vue @@ -48,6 +48,14 @@ + + + @@ -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({