From fde46e3b5e31e6ce6643720f4a1d4d77d71f0c47 Mon Sep 17 00:00:00 2001 From: suhan <72542107+suhan42@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:10:43 +0800 Subject: [PATCH] =?UTF-8?q?fix(evaluation):=20=E6=94=B6=E6=95=9B=E8=AF=84?= =?UTF-8?q?=E4=BC=B0=E5=8F=96=E6=B6=88=E4=B8=8E=E6=9F=A5=E8=AF=A2=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 评估链路强制检索 fail-fast:run_evaluation 写入 raise_on_error=true, evaluate_question 拆分开关后透传,避免作为普通检索参数泄漏。 - 查询失败语义归位:KnowledgeBaseManager.aquery 只透传 raise_on_error, 不再捕获配置/实例/查询异常;Dify 与 Notion 在执行器层保留默认静默降级和严格上抛两种模式, Milvus 保持原生 fail-fast。 - 新增评估运行取消接口 POST /databases/{kb_id}/runs/{run_id}/cancel, 使用知识库 manage 权限定位活跃 Durable Task 并复用通用取消; cancel_requested 投影为 Run 的 cancelled 状态,其余失败保持 failed。 - running Run 删除前拒绝,防止任务继续写入已删除的评估记录。 - Dataset 删除前同步生成任务终态,并拒绝 pending/running 状态,防止活跃生成任务继续写入。 - 前端补齐 cancelled 状态展示与取消按钮,取消后刷新任务中心和评估概览。 - 新增查询层回归测试与评估服务生成/取消/删除保护测试。 --- .../package/yuxi/knowledge/eval/evaluator.py | 10 +- .../package/yuxi/knowledge/eval/service.py | 44 ++- .../yuxi/knowledge/implementations/dify.py | 5 + .../yuxi/knowledge/implementations/milvus.py | 2 + .../yuxi/knowledge/implementations/notion.py | 3 + backend/package/yuxi/knowledge/manager.py | 12 +- .../server/routers/knowledge_eval_router.py | 22 ++ .../unit/knowledge/eval/test_evaluator.py | 19 +- .../knowledge/eval/test_service_generation.py | 261 ++++++++++++++++++ .../test_knowledge_manager_aquery.py | 145 ++++++++++ ...evaluation-run-cancel-and-query-failure.md | 40 +++ web/src/apis/knowledge_api.js | 4 + .../KnowledgeEvaluationWorkspace.vue | 46 ++- .../views/EvaluationBenchmarkDetailView.vue | 10 +- 14 files changed, 614 insertions(+), 9 deletions(-) create mode 100644 backend/test/unit/knowledge/test_knowledge_manager_aquery.py create mode 100644 docs/develop-guides/decisions/implemented/2026-10-12-evaluation-run-cancel-and-query-failure.md diff --git a/backend/package/yuxi/knowledge/eval/evaluator.py b/backend/package/yuxi/knowledge/eval/evaluator.py index c850458d0c..59f295de0f 100644 --- a/backend/package/yuxi/knowledge/eval/evaluator.py +++ b/backend/package/yuxi/knowledge/eval/evaluator.py @@ -14,6 +14,13 @@ def normalize_query_result(query_result: Any) -> tuple[str, list[dict[str, Any]] return "", [] +def split_raise_on_error_config(retrieval_config: dict[str, Any]) -> tuple[dict[str, Any], bool]: + """拆出查询层错误开关,避免它作为普通检索参数泄漏给底层实现。""" + config = dict(retrieval_config or {}) + raise_on_error = bool(config.pop("raise_on_error", False)) + return config, raise_on_error + + def build_answer_prompt(query: str, retrieved_chunks: list[dict[str, Any]], max_docs: int = 5) -> str: context_docs = [] for idx, chunk in enumerate(retrieved_chunks[:max_docs]): @@ -67,7 +74,8 @@ async def evaluate_question( select_model_fn: Callable[..., Any], ) -> dict[str, Any]: query = question_data["query"] - query_result = await kb_manager.aquery(query, kb_id, **retrieval_config) + retrieval_options, raise_on_error = split_raise_on_error_config(retrieval_config) + query_result = await kb_manager.aquery(query, kb_id, raise_on_error=raise_on_error, **retrieval_options) generated_answer, retrieved_chunks = normalize_query_result(query_result) generated_answer = await generate_answer_if_needed( query=query, diff --git a/backend/package/yuxi/knowledge/eval/service.py b/backend/package/yuxi/knowledge/eval/service.py index 00f59f605d..f7e65ace13 100644 --- a/backend/package/yuxi/knowledge/eval/service.py +++ b/backend/package/yuxi/knowledge/eval/service.py @@ -160,6 +160,7 @@ async def _sync_evaluation_run(self, row, task: Any = _TASK_NOT_LOADED): completed_at = None if task is not None and task.status in {"failed", "cancelled"}: error = task.error or task.message or "评估任务失败" + status = "cancelled" if task.status == "cancelled" else "failed" completed_at = task.completed_at or utc_now() if completed_at.tzinfo is None: completed_at = completed_at.replace(tzinfo=UTC) @@ -168,15 +169,16 @@ async def _sync_evaluation_run(self, row, task: Any = _TASK_NOT_LOADED): and row.started_at and coerce_any_to_utc_datetime(row.started_at).replace(tzinfo=None) < utc_now_naive() - timedelta(minutes=1) ): + status = "failed" error = "评估任务提交中断" completed_at = utc_now() if error is not None: await self.eval_repo.update_run( row.run_id, - {"status": "failed", "metrics": {"error": error}, "completed_at": completed_at}, + {"status": status, "metrics": {"error": error}, "completed_at": completed_at}, ) - row.status = "failed" + row.status = status row.metrics = {"error": error} row.completed_at = completed_at return task @@ -333,6 +335,10 @@ async def delete_dataset(self, dataset_id: str) -> None: row = await self.eval_repo.get_dataset(dataset_id) if row is None: raise ValueError("Dataset not found") + # 先按 Durable Task 终态收敛元数据,避免任务已取消或失败后仍被陈旧 running 状态误拦。 + await self._sync_dataset_build_metadata(row) + if (row.build_metadata or {}).get("status") in {"pending", "running"}: + raise ValueError("评估数据集正在生成,请先取消生成任务") await self.eval_repo.delete_dataset(dataset_id) logger.info(f"成功删除评估数据集: {dataset_id}") except Exception as e: @@ -663,6 +669,8 @@ async def run_evaluation( if model_config: retrieval_config.update(model_config) + # 评估必须区分真实零命中和检索故障;底层接口失败时让整条 Run 进入 failed。 + retrieval_config["raise_on_error"] = True task_payload = { "run_id": run_id, @@ -672,6 +680,7 @@ async def run_evaluation( "retrieval_config": retrieval_config, "created_by": created_by, } + async with pg_manager.get_async_session_context() as session: task = await tasker.create_in_session( session, @@ -704,6 +713,32 @@ async def run_evaluation( logger.error(f"启动评估失败: {e}") raise + async def cancel_run(self, kb_id: str, run_id: str) -> dict[str, Any]: + """取消一条运行中的评估,并把终态语义交给 Durable Task 失败钩子。""" + if not re.match(r"^run_[a-f0-9]{8}$", run_id): + raise ValueError("Invalid run_id format") + run = await self.eval_repo.get_run(run_id) + if run is None or run.kb_id != kb_id: + raise ValueError(f"Run not found for {run_id}") + if run.status != "running": + raise ValueError("评估任务已结束") + task = await self.task_repo.find_latest_by_payload( + task_type="rag_evaluation", + payload_match={"run_id": run_id}, + statuses={"pending", "running"}, + ) + if task is None: + raise ValueError("评估任务不存在") + cancelled = await tasker.cancel_task(task.id) + if cancelled is None: + raise ValueError("评估任务已结束") + return { + "run_id": run_id, + "task_id": task.id, + "status": cancelled.status, + "cancel_requested": cancelled.cancel_requested, + } + async def _run_evaluation_task(self, context: TaskContext): try: payload = context.payload @@ -927,6 +962,8 @@ async def delete_run(self, kb_id: str, run_id: str) -> None: row = await self.eval_repo.get_run(run_id) if row is None or row.kb_id != kb_id: raise ValueError("Run not found") + if row.status == "running": + raise ValueError("评估任务仍在运行,请先取消") await self.eval_repo.delete_run(run_id) logger.info(f"成功删除评估运行: {run_id}") @@ -995,6 +1032,7 @@ async def fail_rag_evaluation_task(session, task_record, error: str) -> None: run = await session.scalar(select(EvaluationRun).where(EvaluationRun.run_id == run_id).with_for_update()) if run is None or run.status == "completed": return - run.status = "failed" + run.status = "cancelled" if task_record.cancel_requested else "failed" + run.completed_items = int(run.completed_items or 0) run.metrics = {"error": error} run.completed_at = utc_now() diff --git a/backend/package/yuxi/knowledge/implementations/dify.py b/backend/package/yuxi/knowledge/implementations/dify.py index 4837a6dcf1..2d2fe1502d 100644 --- a/backend/package/yuxi/knowledge/implementations/dify.py +++ b/backend/package/yuxi/knowledge/implementations/dify.py @@ -70,6 +70,7 @@ async def aquery( kb_id: str, *, config: KnowledgeBaseConfig, + raise_on_error: bool = False, agent_call: bool = False, **kwargs, ) -> list[dict]: @@ -81,6 +82,8 @@ async def aquery( if not api_url or not token or not dataset_id: logger.error(f"Dify config incomplete for kb_id={kb_id}") + if raise_on_error: + raise ValueError(f"Dify 配置缺失,无法查询 kb_id={kb_id}") return [] merged = {**config.query_options, **kwargs} @@ -130,6 +133,8 @@ async def aquery( logger.error( f"Dify query fallback failed for kb_id={kb_id}: {fallback_error}, {traceback.format_exc()}" ) + if raise_on_error: + raise e return [] records = response_json.get("records", []) if isinstance(response_json, dict) else [] diff --git a/backend/package/yuxi/knowledge/implementations/milvus.py b/backend/package/yuxi/knowledge/implementations/milvus.py index 86611a00a9..8f60770ac0 100644 --- a/backend/package/yuxi/knowledge/implementations/milvus.py +++ b/backend/package/yuxi/knowledge/implementations/milvus.py @@ -874,10 +874,12 @@ async def aquery( kb_id: str, *, config: KnowledgeBaseConfig, + raise_on_error: bool = False, agent_call: bool = False, **kwargs, ) -> list[dict]: """异步查询知识库""" + del raise_on_error embedding_model_spec = config.embedding_model_spec collection = await self._get_or_create_milvus_collection(kb_id, embedding_model_spec) if not collection: diff --git a/backend/package/yuxi/knowledge/implementations/notion.py b/backend/package/yuxi/knowledge/implementations/notion.py index 868ed37be9..44425bb4b0 100644 --- a/backend/package/yuxi/knowledge/implementations/notion.py +++ b/backend/package/yuxi/knowledge/implementations/notion.py @@ -234,6 +234,7 @@ async def aquery( kb_id: str, *, config: KnowledgeBaseConfig, + raise_on_error: bool = False, agent_call: bool = False, **kwargs, ) -> list[dict]: @@ -279,6 +280,8 @@ async def aquery( return sorted(results, key=lambda item: item.get("score", 0.0), reverse=True)[:final_top_k] except (NotionAPIError, httpx.HTTPError, ValueError) as exc: logger.error(f"Notion query failed for kb_id={kb_id}: {exc}, {traceback.format_exc()}") + if raise_on_error: + raise return [] async def _search_candidate_pages( diff --git a/backend/package/yuxi/knowledge/manager.py b/backend/package/yuxi/knowledge/manager.py index 7950420908..fa3a1b9cac 100644 --- a/backend/package/yuxi/knowledge/manager.py +++ b/backend/package/yuxi/knowledge/manager.py @@ -641,14 +641,22 @@ async def update_file_params(self, kb_id: str, file_id: str, params: dict, opera additional_params=config.additional_params, ) - async def aquery(self, query_text: str, kb_id: str, **kwargs) -> str: - """异步查询知识库""" + async def aquery( + self, + query_text: str, + kb_id: str, + *, + raise_on_error: bool = False, + **kwargs, + ) -> str: + """异步查询知识库;`raise_on_error` 供评估等调用方关闭执行器层静默降级。""" config = await self.get_kb_config(kb_id) executor = await self._get_or_create_kb_instance(config.kb_type) return await executor.aquery( query_text, kb_id, config=config, + raise_on_error=raise_on_error, **kwargs, ) diff --git a/backend/server/routers/knowledge_eval_router.py b/backend/server/routers/knowledge_eval_router.py index 1bbc09d30b..3113f6a10d 100644 --- a/backend/server/routers/knowledge_eval_router.py +++ b/backend/server/routers/knowledge_eval_router.py @@ -336,6 +336,28 @@ async def get_evaluation_run_results( raise HTTPException(status_code=500, detail=f"获取评估运行结果失败: {str(e)}") +@evaluation.post("/databases/{kb_id}/runs/{run_id}/cancel") +async def cancel_evaluation_run( + kb_id: str, + run_id: str, + current_user: User = Depends(require_knowledge_base_manage), +): + """取消运行中的 RAG 评估""" + try: + service = EvaluationService() + result = await service.cancel_run(kb_id, run_id) + return {"message": "success", "data": result} + except ValueError as e: + if "not found" in str(e).lower(): + raise HTTPException(status_code=404, detail=str(e)) + raise HTTPException(status_code=400, detail=str(e)) + except HTTPException: + raise + except Exception as e: + logger.exception(f"取消评估运行失败: {e}") + raise HTTPException(status_code=500, detail=f"取消评估运行失败: {str(e)}") + + @evaluation.delete("/databases/{kb_id}/runs/{run_id}") async def delete_evaluation_run( kb_id: str, diff --git a/backend/test/unit/knowledge/eval/test_evaluator.py b/backend/test/unit/knowledge/eval/test_evaluator.py index 9e3fa85d23..0f9d2d08b8 100644 --- a/backend/test/unit/knowledge/eval/test_evaluator.py +++ b/backend/test/unit/knowledge/eval/test_evaluator.py @@ -48,10 +48,27 @@ async def test_evaluate_question_uses_runtime_kb_manager(monkeypatch): select_model_fn=lambda **_: None, ) - aquery.assert_awaited_once_with("问题", "kb-1", final_top_k=3) + aquery.assert_awaited_once_with("问题", "kb-1", raise_on_error=False, final_top_k=3) assert result["detail"]["retrieved_chunks"] == [{"content": "检索结果"}] +async def test_evaluate_question_splits_and_forwards_raise_on_error(monkeypatch): + aquery = AsyncMock(return_value=[]) + monkeypatch.setattr(evaluator, "kb_manager", SimpleNamespace(aquery=aquery)) + + await evaluate_question( + kb_id="kb-1", + question_data={"query": "问题"}, + retrieval_config={"final_top_k": 3, "raise_on_error": True}, + has_gold_chunks=False, + has_gold_answers=False, + judge_llm=None, + select_model_fn=lambda **_: None, + ) + + aquery.assert_awaited_once_with("问题", "kb-1", final_top_k=3, raise_on_error=True) + + def test_aggregate_metrics_matches_service_output_shape(): metrics, _ = aggregate_metrics( [{"recall@1": 1.0, "f1@1": 0.0}, {"recall@1": 0.0, "f1@1": 1.0}], diff --git a/backend/test/unit/knowledge/eval/test_service_generation.py b/backend/test/unit/knowledge/eval/test_service_generation.py index 5f8bd19659..5ce0b991e2 100644 --- a/backend/test/unit/knowledge/eval/test_service_generation.py +++ b/backend/test/unit/knowledge/eval/test_service_generation.py @@ -1,5 +1,6 @@ from contextlib import asynccontextmanager from types import SimpleNamespace +from datetime import timedelta import pytest @@ -13,6 +14,7 @@ def __init__(self): self.updated_dataset = None self.dataset = None self.created_run = None + self.deleted_dataset = False async def create_dataset_in_session(self, session, payload): self.created_dataset = payload @@ -23,6 +25,10 @@ async def update_dataset(self, dataset_id, payload): async def get_dataset(self, dataset_id): return self.dataset + async def delete_dataset(self, dataset_id): + """记录数据集删除副作用。""" + self.deleted_dataset = True + async def create_run_in_session(self, session, payload): self.created_run = payload @@ -217,6 +223,238 @@ async def list_by_payload_values(self, **kwargs): assert service.eval_repo.updated_run[0] == "run_12345678" +@pytest.mark.asyncio +async def test_list_runs_projects_cancelled_durable_task_to_cancelled_run(): + started_at = eval_service_module.utc_now_naive() + run = SimpleNamespace( + run_id="run_12345678", + name="评估", + dataset_id="dataset_1", + status="running", + started_at=started_at, + completed_at=None, + total_items=1, + completed_items=7, + overall_score=None, + retrieval_config={}, + metrics={}, + ) + + class RunRepository(FakeEvaluationRepository): + async def list_runs(self, kb_id): + return [run] + + async def update_run(self, run_id, data): + self.updated_run = (run_id, data) + + class CancelledTaskRepository: + async def list_by_payload_values(self, **kwargs): + return [ + SimpleNamespace( + payload={"run_id": "run_12345678"}, + status="cancelled", + error="任务已取消", + message="任务已取消", + completed_at=started_at, + ) + ] + + service = EvaluationService() + service.eval_repo = RunRepository() + service.task_repo = CancelledTaskRepository() + + result = await service.list_runs("kb_1") + + assert result[0]["status"] == "cancelled" + assert result[0]["completed_items"] == 7 + assert service.eval_repo.updated_run[1]["status"] == "cancelled" + + +@pytest.mark.asyncio +async def test_list_runs_projects_orphan_running_run_to_failed(): + started_at = eval_service_module.utc_now_naive() - timedelta(minutes=2) + run = SimpleNamespace( + run_id="run_12345678", + name="评估", + dataset_id="dataset_1", + status="running", + started_at=started_at, + completed_at=None, + total_items=1, + completed_items=7, + overall_score=None, + retrieval_config={}, + metrics={}, + ) + + class RunRepository(FakeEvaluationRepository): + async def list_runs(self, kb_id): + return [run] + + async def update_run(self, run_id, data): + self.updated_run = (run_id, data) + + class MissingTaskRepository: + async def list_by_payload_values(self, **kwargs): + return [] + + service = EvaluationService() + service.eval_repo = RunRepository() + service.task_repo = MissingTaskRepository() + + result = await service.list_runs("kb_1") + + assert result[0]["status"] == "failed" + assert result[0]["completed_items"] == 7 + assert service.eval_repo.updated_run[1]["status"] == "failed" + assert service.eval_repo.updated_run[1]["metrics"] == {"error": "评估任务提交中断"} + + +@pytest.mark.asyncio +async def test_cancel_run_requests_active_durable_task(monkeypatch): + run = SimpleNamespace(run_id="run_12345678", kb_id="kb_1", status="running") + + class RunRepository: + async def get_run(self, run_id): + return run + + class TaskRepository: + async def find_latest_by_payload(self, **kwargs): + assert kwargs["task_type"] == "rag_evaluation" + assert kwargs["payload_match"] == {"run_id": "run_12345678"} + assert kwargs["statuses"] == {"pending", "running"} + return SimpleNamespace(id="task_1") + + class Tasker: + async def cancel_task(self, task_id): + assert task_id == "task_1" + return SimpleNamespace(status="cancelled", cancel_requested=True) + + service = EvaluationService() + service.eval_repo = RunRepository() + service.task_repo = TaskRepository() + monkeypatch.setattr(eval_service_module, "tasker", Tasker()) + + result = await service.cancel_run("kb_1", "run_12345678") + + assert result == { + "run_id": "run_12345678", + "task_id": "task_1", + "status": "cancelled", + "cancel_requested": True, + } + + +@pytest.mark.asyncio +async def test_cancel_run_rejects_terminal_run(): + class RunRepository: + async def get_run(self, run_id): + return SimpleNamespace(run_id="run_12345678", kb_id="kb_1", status="completed") + + service = EvaluationService() + service.eval_repo = RunRepository() + + with pytest.raises(ValueError, match="已结束"): + await service.cancel_run("kb_1", "run_12345678") + + +@pytest.mark.asyncio +async def test_delete_run_rejects_running_run(): + class RunRepository: + def __init__(self): + self.deleted = False + + async def get_run(self, run_id): + return SimpleNamespace(run_id="run_12345678", kb_id="kb_1", status="running") + + async def delete_run(self, run_id): + self.deleted = True + + repo = RunRepository() + service = EvaluationService() + service.eval_repo = repo + + with pytest.raises(ValueError, match="先取消"): + await service.delete_run("kb_1", "run_12345678") + + assert repo.deleted is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", ["pending", "running"]) +async def test_delete_dataset_rejects_active_generation(status): + """等待中和运行中的生成任务都阻止数据集删除。""" + + class ActiveTaskRepository: + """提供尚未结束的生成任务。""" + + async def get_by_id(self, task_id): + """返回数据集关联的活跃任务。""" + assert task_id == "task_1" + return SimpleNamespace(status=status, progress=30, message="生成中") + + repo = FakeEvaluationRepository() + repo.dataset = SimpleNamespace( + dataset_id="dataset_1", + build_metadata={"source": "generated", "status": status, "task_id": "task_1"}, + ) + service = EvaluationService() + service.eval_repo = repo + service.task_repo = ActiveTaskRepository() + + with pytest.raises(ValueError, match="先取消生成任务"): + await service.delete_dataset("dataset_1") + + assert repo.deleted_dataset is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status", ["completed", "failed", None]) +async def test_delete_dataset_allows_inactive_dataset(status): + """终态及没有生成元数据的数据集保持可删除。""" + repo = FakeEvaluationRepository() + repo.dataset = SimpleNamespace( + dataset_id="dataset_1", + build_metadata={"source": "generated", "status": status} if status else None, + ) + service = EvaluationService() + service.eval_repo = repo + + await service.delete_dataset("dataset_1") + + assert repo.deleted_dataset is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize("task_status", ["success", "failed", "cancelled"]) +async def test_delete_dataset_projects_terminal_task_before_delete(task_status): + """先同步任务终态,避免陈旧 running 元数据误拦删除。""" + + class TerminalTaskRepository: + """提供已经结束的生成任务。""" + + async def get_by_id(self, task_id): + """返回数据集关联的终态任务。""" + assert task_id == "task_1" + return SimpleNamespace(status=task_status, error=None, message="任务结束") + + repo = FakeEvaluationRepository() + repo.dataset = SimpleNamespace( + dataset_id="dataset_1", + build_metadata={"source": "generated", "status": "running", "task_id": "task_1"}, + ) + service = EvaluationService() + service.eval_repo = repo + service.task_repo = TerminalTaskRepository() + + await service.delete_dataset("dataset_1") + + expected_status = "completed" if task_status == "success" else "failed" + assert repo.dataset.build_metadata["status"] == expected_status + assert repo.updated_dataset == ("dataset_1", {"build_metadata": repo.dataset.build_metadata}) + assert repo.deleted_dataset is True + + def test_build_evaluation_run_name_uses_eval_date_hash_format(): name = build_evaluation_run_name(hash_value="abcdef12") @@ -252,3 +490,26 @@ async def test_run_evaluation_saves_custom_name(task_submission): assert repo.created_run["name"] == "回归评估" assert repo.created_run["retrieval_config"]["top_k"] == 3 assert repo.created_run["retrieval_config"]["answer_llm"] == "test:model" + + +@pytest.mark.asyncio +async def test_run_evaluation_forces_query_error_fail_fast(task_submission): + repo = FakeEvaluationRepository() + repo.dataset = SimpleNamespace( + dataset_id="dataset_1", + kb_id="db_1", + name="dataset", + item_count=1, + build_metadata={"status": "completed"}, + ) + service = EvaluationService() + service.eval_repo = repo + service.kb_repo = FakeKnowledgeBaseRepository() + + await service.run_evaluation( + kb_id="db_1", + dataset_id="dataset_1", + model_config={"raise_on_error": False}, + ) + + assert repo.created_run["retrieval_config"]["raise_on_error"] is True diff --git a/backend/test/unit/knowledge/test_knowledge_manager_aquery.py b/backend/test/unit/knowledge/test_knowledge_manager_aquery.py new file mode 100644 index 0000000000..acc9d6724d --- /dev/null +++ b/backend/test/unit/knowledge/test_knowledge_manager_aquery.py @@ -0,0 +1,145 @@ +"""验证知识库查询层与真实执行器的静默降级边界。""" + +from types import SimpleNamespace + +import pytest + +from yuxi.knowledge.implementations.dify import DifyKB +from yuxi.knowledge.implementations.notion import NotionKB +from yuxi.knowledge.manager import KnowledgeBaseManager + + +async def test_dify_executor_raises_when_opted_in(monkeypatch, tmp_path): + """开启严格模式时,Dify 请求失败必须上抛。""" + executor = DifyKB(work_dir=str(tmp_path)) + + async def failing_request(**kwargs): + """模拟真实请求失败。""" + raise RuntimeError("dify unavailable") + + monkeypatch.setattr(executor, "_request_dify", failing_request) + + with pytest.raises(RuntimeError, match="dify unavailable"): + await executor.aquery("query", "kb_1", config=_kb_config(), raise_on_error=True) + + +async def test_dify_executor_preserves_default_silent_degradation(monkeypatch, tmp_path): + """Dify 默认保留执行器内部的空结果降级。""" + executor = DifyKB(work_dir=str(tmp_path)) + + async def failing_request(**kwargs): + """模拟真实请求失败。""" + raise RuntimeError("dify unavailable") + + monkeypatch.setattr(executor, "_request_dify", failing_request) + + assert await executor.aquery("query", "kb_1", config=_kb_config()) == [] + + +async def test_notion_executor_raises_when_opted_in(monkeypatch, tmp_path): + """有效配置下的 Notion 检索故障在严格模式中上抛。""" + executor = NotionKB(work_dir=str(tmp_path)) + + async def failing_search(*args, **kwargs): + """在通过连接配置校验后模拟检索失败。""" + raise ValueError("notion unavailable") + + monkeypatch.setattr(executor, "_search_candidate_pages", failing_search) + + with pytest.raises(ValueError, match="notion unavailable"): + await executor.aquery("query", "kb_1", config=_notion_config(), raise_on_error=True) + + +async def test_notion_executor_preserves_default_silent_degradation(monkeypatch, tmp_path): + """Notion 默认保留执行器内部的空结果降级。""" + executor = NotionKB(work_dir=str(tmp_path)) + + async def failing_search(*args, **kwargs): + """在通过连接配置校验后模拟检索失败。""" + raise ValueError("notion unavailable") + + monkeypatch.setattr(executor, "_search_candidate_pages", failing_search) + + assert await executor.aquery("query", "kb_1", config=_notion_config()) == [] + + +@pytest.mark.parametrize("query_options", [{}, {"raise_on_error": True}]) +async def test_aquery_propagates_executor_error(monkeypatch, query_options): + """共享入口在默认和严格模式中都保留执行器的异常。""" + manager = KnowledgeBaseManager.__new__(KnowledgeBaseManager) + + async def failing_executor(*args, **kwargs): + """模拟主检索失败。""" + raise RuntimeError("query failed") + + async def get_config(kb_id): + """返回有效的知识库类型。""" + return SimpleNamespace(kb_type="milvus") + + async def get_instance(kb_type): + """返回会抛出主检索异常的执行器。""" + return SimpleNamespace(aquery=failing_executor) + + monkeypatch.setattr(manager, "get_kb_config", get_config) + monkeypatch.setattr(manager, "_get_or_create_kb_instance", get_instance) + + with pytest.raises(RuntimeError, match="query failed"): + await manager.aquery("query", "kb_1", **query_options) + + +async def test_aquery_propagates_config_error_by_default(monkeypatch): + """共享入口不把配置读取故障降级成成功空结果。""" + manager = KnowledgeBaseManager.__new__(KnowledgeBaseManager) + + async def get_config(kb_id): + """模拟配置读取失败。""" + raise RuntimeError("config unavailable") + + monkeypatch.setattr(manager, "get_kb_config", get_config) + + with pytest.raises(RuntimeError, match="config unavailable"): + await manager.aquery("query", "kb_1") + + +@pytest.mark.parametrize("raise_on_error", [False, True]) +async def test_aquery_forwards_error_contract_to_executor(monkeypatch, raise_on_error): + """共享入口透传错误策略和普通查询选项。""" + manager = KnowledgeBaseManager.__new__(KnowledgeBaseManager) + received_options = {} + + async def executor_query(*args, **kwargs): + """记录查询选项并返回空结果。""" + received_options.update(kwargs) + return [] + + async def get_config(kb_id): + """返回有效的知识库类型。""" + return SimpleNamespace(kb_type="dify") + + async def get_instance(kb_type): + """返回记录查询参数的执行器。""" + return SimpleNamespace(aquery=executor_query) + + monkeypatch.setattr(manager, "get_kb_config", get_config) + monkeypatch.setattr(manager, "_get_or_create_kb_instance", get_instance) + + assert await manager.aquery("query", "kb_1", raise_on_error=raise_on_error, final_top_k=3) == [] + assert received_options["raise_on_error"] is raise_on_error + assert received_options["final_top_k"] == 3 + + +def _kb_config(): + """构造 Dify 执行器测试所需的最小配置。""" + return SimpleNamespace( + kb_type="dify", + query_options={}, + additional_params={"dify_api_url": "https://dify.example/v1", "dify_token": "token", "dify_dataset_id": "ds"}, + ) + + +def _notion_config(): + """构造 Notion 执行器测试所需的最小连接配置。""" + return SimpleNamespace( + query_options={}, + additional_params={"notion_token": "token", "notion_data_source_id": "data_source"}, + ) diff --git a/docs/develop-guides/decisions/implemented/2026-10-12-evaluation-run-cancel-and-query-failure.md b/docs/develop-guides/decisions/implemented/2026-10-12-evaluation-run-cancel-and-query-failure.md new file mode 100644 index 0000000000..0e95925481 --- /dev/null +++ b/docs/develop-guides/decisions/implemented/2026-10-12-evaluation-run-cancel-and-query-failure.md @@ -0,0 +1,40 @@ +# 评估运行取消与查询失败语义 + +状态:implemented +类型:bug-fix +Owner:backend/package/yuxi/knowledge/eval/service.py + +## 问题 + +RAG 评估的 Task 取消、超时和异常都由同一个失败钩子收敛成 `failed`,评估页无法区分用户停止和真实错误;取消入口只存在于管理员任务中心,普通知识库管理者没有对应业务操作。检索链路可能把底层查询错误降级成空列表,评估会把故障算成零命中。运行中的 Run 还能被直接删除,任务可能继续消耗资源或写入已删除的关联数据。 + +## 决策 + +### 实现方案 + +评估提交在 `retrieval_config` 中强制写入 `raise_on_error=true`;`KnowledgeBaseManager.aquery` 只把开关作为显式关键字参数传给执行器,不捕获配置读取、执行器创建或底层查询错误。评估链路开启时由真实异常 Owner 上抛:Dify 配置缺失或两次请求均失败、Notion 连接/查询失败会中断评估并进入既有失败钩子,Milvus 主检索原本就上抛。Dify 的兼容重试成功仍返回结果,不把成功重试当故障。`fail_rag_evaluation_task` 和 `list_runs` 的读时投影按 Task 的 `cancel_requested` / `cancelled` 状态把 Run 收敛为 `cancelled`,其余失败仍是 `failed`,取消保留已完成题目数量。评估路由新增 `POST /api/evaluation/databases/{kb_id}/runs/{run_id}/cancel`,使用知识库管理权限定位活跃 Durable Task 后调用通用取消;running Run 的删除被拒绝,前端补取消按钮、取消中和 `cancelled` 展示。 + +Dataset 删除由 `EvaluationService.delete_dataset` 在 service 层保护:先复用生成元数据同步逻辑,把关联 Durable Task 的终态投影到 Dataset,再拒绝 `build_metadata.status` 为 `pending` 或 `running` 的删除并提示先取消生成任务。排队中的生成任务也受保护,因为它仍可启动并写入数据集;终态同步避免已结束任务的陈旧 running 元数据误拦删除。删除操作不联动取消任务,路由沿用 `ValueError` 到 HTTP 400 的转换。 + +## 替代方案 + +把所有任务失败都保持成 `failed` 最小,但会让用户主动停止和系统错误共用一个含义。把检索失败记录成逐项 `retrieval_status` 可以保留更多结果,但会扩大评估表、指标汇总和 UI 契约,当前不需要。删除 running Run 时静默取消任务看起来更顺手,但会把破坏性操作和长耗时任务终止隐式耦合。 + +## 后果 + +用户取消保留部分评估进度并显示“已取消”;检索故障会让整条评估失败,不再伪装成低分结果。知识库管理权限可以取消自己的评估,不再必须进入全局任务中心。running Run 必须先取消评估任务并等待终态再删除;pending / running Dataset 必须先取消生成任务并等待终态再删除。共享查询层保留既有的失败传播语义;默认静默降级只属于 Dify 和 Notion 执行器,Milvus 与共享层故障继续由普通查询路由返回 `failed`。 + +Dataset 删除保护是 service 层的状态检查,检查与删除不在同一加锁事务中,并发恢复生成与删除的互斥未验证。缺少 task_id 且找不到活跃任务的生成元数据沿用既有同步逻辑,保持 pending / running 并拒绝删除。 + +## 验证 + +| 验证 | 结果 | +|---|---| +| `backend/.venv/Scripts/python.exe -m pytest test/unit/knowledge/test_evaluation_task_cancellation.py test/unit/knowledge/test_knowledge_manager_aquery.py test/unit/knowledge/eval/test_service_generation.py test/unit/knowledge/eval/test_evaluator.py test/unit/services/test_tasker_behavior.py` | 既有验证记录:38 passed,仅覆盖 Run 取消与查询失败变更,不包含新增 Dataset 删除保护用例 | +| `python -B -m pytest backend/test/unit/knowledge/eval/test_service_generation.py`(本机补充验证 Dataset 删除保护用例) | Not run:系统 Python 3.11 缺少 pytest,无法在本机执行 | +| `python -B -m pytest backend/test/unit/knowledge/test_knowledge_manager_aquery.py`(执行器 fail-fast 回归) | Not run:本机系统 Python 3.11 缺少 pytest,且没有 backend 虚拟环境、uv 和 Docker;新增用例覆盖 manager 失败传播与透传、Dify 请求失败的开启/关闭语义及 Notion 上抛与默认降级路径 | +| `python -B scripts/verify_engineering_contracts.py` | √ 工程契约检查通过 | +| `python -B -m unittest scripts.test_verify_engineering_contracts` | 63 项中 62 项成功、1 项错误;符号链接隔离用例因 Windows WinError 1314 无创建符号链接权限失败,沙盒外重跑结果相同 | +| Dataset 删除保护真实 HTTP / PostgreSQL / worker 验证、文档构建 | Not run:本机缺少对应运行环境与工具,尚未验证真实删除结果和取消后删除链路 | +| `git diff --check` | 通过;Windows 仅提示既有 CRLF 转换警告 | +| `python -B -m py_compile`(manager、Dify、Notion、Milvus 及查询回归测试) | √ 使用临时 Python 缓存目录通过 | diff --git a/web/src/apis/knowledge_api.js b/web/src/apis/knowledge_api.js index 08ea9c0584..79c41b8717 100644 --- a/web/src/apis/knowledge_api.js +++ b/web/src/apis/knowledge_api.js @@ -646,6 +646,10 @@ export const evaluationApi = { return apiAdminGet(`/api/evaluation/databases/${kbId}/runs`) }, + cancelRun: async (kbId, runId) => { + return apiAdminPost(`/api/evaluation/databases/${kbId}/runs/${runId}/cancel`, {}) + }, + getRunResults: async (kbId, runId, params = {}) => { const queryParams = new URLSearchParams() diff --git a/web/src/components/evaluation/KnowledgeEvaluationWorkspace.vue b/web/src/components/evaluation/KnowledgeEvaluationWorkspace.vue index 0120107075..0016a59e41 100644 --- a/web/src/components/evaluation/KnowledgeEvaluationWorkspace.vue +++ b/web/src/components/evaluation/KnowledgeEvaluationWorkspace.vue @@ -234,6 +234,7 @@ /> + @@ -282,6 +283,16 @@ /> + const getRunName = (run) => run?.name || run?.run_name || run?.run_id?.slice(0, 8) || '-' const getRunStatusText = (status) => - ({ running: '运行中', completed: '已完成', failed: '失败', paused: '已暂停' })[status] || status + ({ + running: '运行中', + completed: '已完成', + failed: '失败', + cancelled: '已取消', + paused: '已暂停' + })[status] || status const getRunProgress = (run) => { if (Number.isFinite(run?.progress)) return Math.max(0, Math.min(Math.round(run.progress), 100)) @@ -539,6 +558,21 @@ const handleDatasetAction = async (key, dataset) => { } } +const cancelRun = async (run) => { + cancellingRunId.value = run.run_id + try { + await evaluationApi.cancelRun(props.kbId, run.run_id) + message.success('已请求取消评估') + taskerStore.loadTasks() + await loadOverview() + } catch (error) { + console.error('取消评估记录失败:', error) + message.error(error.message || '取消评估记录失败') + } finally { + cancellingRunId.value = '' + } +} + const deleteRun = async (run) => { try { await evaluationApi.deleteRun(props.kbId, run.run_id) @@ -715,6 +749,11 @@ defineExpose({ loadOverview }) background: var(--color-error-50); color: var(--color-error-700); } + + &.run-cancelled { + background: var(--gray-100); + color: var(--gray-600); + } } .evaluation-row-main { @@ -805,6 +844,11 @@ defineExpose({ loadOverview }) color: var(--color-error-700); } + &.status-cancelled { + background: var(--gray-100); + color: var(--gray-600); + } + &.status-pending, &.status-paused { background: var(--color-warning-50); diff --git a/web/src/views/EvaluationBenchmarkDetailView.vue b/web/src/views/EvaluationBenchmarkDetailView.vue index 9e063fd19e..282befcc2b 100644 --- a/web/src/views/EvaluationBenchmarkDetailView.vue +++ b/web/src/views/EvaluationBenchmarkDetailView.vue @@ -399,7 +399,13 @@ const runOptions = computed(() => const getRunName = (run) => run?.name || run?.run_name || '未命名测试' const getRunStatusText = (status) => - ({ running: '运行中', completed: '已完成', failed: '失败', paused: '已暂停' })[status] || '未知状态' + ({ + running: '运行中', + completed: '已完成', + failed: '失败', + cancelled: '已取消', + paused: '已暂停' + })[status] || '未知状态' const formatMetric = (value) => (Number.isFinite(value) ? Number(value).toFixed(2) : '-') const formatScore = (value) => (Number.isFinite(value) ? `${(value * 100).toFixed(1)}%` : '-') const formatTime = (value) => (value ? new Date(value).toLocaleString('zh-CN') : '-') @@ -408,6 +414,7 @@ const formatRunItems = (run) => `${run?.completed_items || 0}/${run?.total_items const formatRunDuration = (run) => { if (!run) return '-' if (run.status === 'running') return '进行中' + if (run.status === 'cancelled') return '已停止' if (!run.started_at || !run.completed_at) return '-' const seconds = Math.max(0, (new Date(run.completed_at) - new Date(run.started_at)) / 1000) if (seconds < 60) return `${Math.round(seconds)} 秒` @@ -956,6 +963,7 @@ onUnmounted(() => { } &.status-failed, + &.status-cancelled, &.score-error { background: var(--color-error-50); color: var(--color-error-700);