From f81640653d8d6f8138487b4102aa19cea319d6a6 Mon Sep 17 00:00:00 2001 From: zhangwei139623 <1552775457@qq.com> Date: Sun, 23 Aug 2026 11:22:18 +0800 Subject: [PATCH 01/14] feat(knowledge): add read-only RAGFlow retrieval --- .../deerflow/community/ragflow/client.py | 162 +++++++++++++++ .../deerflow/community/ragflow/formatting.py | 93 +++++++++ .../deerflow/community/ragflow/tools.py | 188 ++++++++++++++++++ .../harness/deerflow/config/__init__.py | 2 + .../harness/deerflow/config/app_config.py | 2 + .../deerflow/config/knowledge_base_config.py | 19 ++ .../packages/harness/deerflow/tools/tools.py | 8 + config.example.yaml | 33 +++ deploy/helm/deer-flow/values.yaml | 21 ++ 9 files changed, 528 insertions(+) create mode 100644 backend/packages/harness/deerflow/community/ragflow/client.py create mode 100644 backend/packages/harness/deerflow/community/ragflow/formatting.py create mode 100644 backend/packages/harness/deerflow/community/ragflow/tools.py create mode 100644 backend/packages/harness/deerflow/config/knowledge_base_config.py diff --git a/backend/packages/harness/deerflow/community/ragflow/client.py b/backend/packages/harness/deerflow/community/ragflow/client.py new file mode 100644 index 00000000000..00070bfbcd9 --- /dev/null +++ b/backend/packages/harness/deerflow/community/ragflow/client.py @@ -0,0 +1,162 @@ +"""Minimal asynchronous client for the RAGFlow APIs DeerFlow consumes.""" + +from __future__ import annotations + +from typing import Any + +import httpx + + +class RAGFlowError(Exception): + """Base class for normalized RAGFlow failures.""" + + +class RAGFlowAPIError(RAGFlowError): + """RAGFlow returned a valid response envelope with a non-zero code.""" + + def __init__(self, message: str, *, code: object = None) -> None: + self.code = code + super().__init__(message) + + +class RAGFlowConnectionError(RAGFlowError): + """RAGFlow could not be reached or timed out.""" + + +class RAGFlowProtocolError(RAGFlowError): + """RAGFlow returned an invalid or unexpected HTTP response.""" + + +class RAGFlowClient: + """Direct HTTP client for DeerFlow's read-only retrieval tools. + + The client deliberately owns no cache or persistent state. A fresh HTTP + session is opened for each method call so callers do not need to manage a + client lifecycle. + """ + + def __init__( + self, + *, + base_url: str, + api_key: str, + timeout: float = 30, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self._api_key = api_key + self._transport = transport + + def _redact(self, value: object) -> str: + text = str(value) + if self._api_key: + text = text.replace(self._api_key, "[REDACTED]") + return text + + async def _request( + self, + method: str, + path: str, + *, + params: dict[str, object] | list[tuple[str, str]] | None = None, + json: dict[str, Any] | None = None, + ) -> dict[str, Any]: + request_headers = { + "Authorization": f"Bearer {self._api_key}", + "Accept": "application/json", + } + client_kwargs: dict[str, Any] = { + "base_url": f"{self.base_url}/api/v1", + "headers": request_headers, + "timeout": self.timeout, + } + if self._transport is not None: + client_kwargs["transport"] = self._transport + + try: + async with httpx.AsyncClient(**client_kwargs) as client: + response = await client.request(method, path, params=params, json=json) + except httpx.TimeoutException: + raise RAGFlowConnectionError(f"请求超时({self.timeout:g} 秒)") from None + except httpx.RequestError as exc: + detail = self._redact(exc) + raise RAGFlowConnectionError(f"{type(exc).__name__}: {detail}") from None + + if response.is_error: + try: + error_payload = response.json() + except ValueError: + error_payload = None + if isinstance(error_payload, dict) and error_payload.get("code") not in (None, 0): + message = self._redact(error_payload.get("message") or f"RAGFlow API error (HTTP {response.status_code})") + raise RAGFlowAPIError(message, code=error_payload.get("code")) + raise RAGFlowProtocolError(f"RAGFlow 请求失败(HTTP {response.status_code})") + + try: + payload = response.json() + except ValueError: + raise RAGFlowProtocolError("RAGFlow 返回了无效 JSON") from None + if not isinstance(payload, dict): + raise RAGFlowProtocolError("RAGFlow 返回了非对象 JSON") + + code = payload.get("code") + if code != 0: + message = self._redact(payload.get("message") or "RAGFlow 请求失败") + raise RAGFlowAPIError(message, code=code) + return payload + + async def list_datasets(self) -> list[dict[str, Any]]: + """List every RAGFlow dataset accessible to the configured tenant key.""" + page = 1 + page_size = 100 + datasets: list[dict[str, Any]] = [] + + while True: + params: dict[str, object] = {"page": page, "page_size": page_size} + payload = await self._request( + "GET", + "/datasets", + params=params, + ) + data = payload.get("data") + if not isinstance(data, list): + raise RAGFlowProtocolError("RAGFlow 返回了无效的知识库列表") + batch = [item for item in data if isinstance(item, dict)] + datasets.extend(batch) + + total = payload.get("total_datasets", payload.get("total")) + if isinstance(total, int) and len(datasets) >= total: + break + if len(data) < page_size: + break + page += 1 + + return datasets + + async def retrieve( + self, + query: str, + *, + dataset_ids: list[str] | None = None, + page_size: int = 8, + similarity_threshold: float = 0.2, + vector_similarity_weight: float = 0.3, + top_k: int = 256, + ) -> dict[str, Any]: + """Retrieve chunks, optionally scoped to specific dataset UUIDs.""" + request_body: dict[str, object] = { + "question": query, + "page_size": page_size, + "similarity_threshold": similarity_threshold, + "vector_similarity_weight": vector_similarity_weight, + "top_k": top_k, + } + if dataset_ids is not None: + request_body["dataset_ids"] = dataset_ids + + payload = await self._request("POST", "/retrieval", json=request_body) + data = payload.get("data") + if not isinstance(data, dict): + raise RAGFlowProtocolError("RAGFlow 返回了无效的检索结果") + return data diff --git a/backend/packages/harness/deerflow/community/ragflow/formatting.py b/backend/packages/harness/deerflow/community/ragflow/formatting.py new file mode 100644 index 00000000000..a5d3035d336 --- /dev/null +++ b/backend/packages/harness/deerflow/community/ragflow/formatting.py @@ -0,0 +1,93 @@ +"""Compact, citation-friendly formatting for RAGFlow retrieval results.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + + +def _truncate(value: str, max_chars: int, *, marker: str = "…") -> str: + if len(value) <= max_chars: + return value + if max_chars <= len(marker): + return marker[:max_chars] + return f"{value[: max_chars - len(marker)].rstrip()}{marker}" + + +def _document_aggregates(value: object) -> list[Mapping[str, Any]]: + if isinstance(value, list): + return [item for item in value if isinstance(item, Mapping)] + if isinstance(value, Mapping): + return [item for item in value.values() if isinstance(item, Mapping)] + return [] + + +def _score(value: object) -> float | None: + if isinstance(value, bool): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def format_retrieval_result( + result: Mapping[str, Any], + *, + dataset_names_by_id: Mapping[str, str], + max_chars_per_chunk: int = 800, + max_total_chars: int = 8000, +) -> str: + """Format one RAGFlow retrieval response into compact cited text. + + RAGFlow v0.26 documentation calls the dataset field ``kb_id`` while the + deployed API may return ``dataset_id``. Both are accepted, but neither UUID + is ever emitted to the model. + """ + raw_chunks = result.get("chunks") + if not isinstance(raw_chunks, list): + raw_chunks = [] + chunks = [chunk for chunk in raw_chunks if isinstance(chunk, Mapping)] + if not chunks: + return "未检索到相关内容。" + + aggregates = _document_aggregates(result.get("doc_aggs")) + document_names_by_id = {str(item["doc_id"]): str(item["doc_name"]) for item in aggregates if item.get("doc_id") and item.get("doc_name")} + + entries: list[str] = [] + for index, chunk in enumerate(chunks, start=1): + dataset_id = chunk.get("dataset_id") or chunk.get("kb_id") + dataset_name = dataset_names_by_id.get(str(dataset_id), "未知知识库") + + document_id = chunk.get("document_id") or chunk.get("doc_id") + document_name = chunk.get("document_keyword") or chunk.get("document_name") + if not document_name and document_id: + document_name = document_names_by_id.get(str(document_id)) + document_name = str(document_name or "未知文档") + + similarity = _score(chunk.get("similarity")) + score_suffix = f" (相关度 {similarity:.2f})" if similarity is not None else "" + content = str(chunk.get("content") or "").strip() + content = _truncate(content, max_chars_per_chunk) + entries.append(f"[{index}] {dataset_name} / {document_name}{score_suffix}\n{content}") + + if aggregates: + summaries: list[str] = [] + for item in aggregates: + name = item.get("doc_name") + if not name: + continue + count = item.get("count") + count_text = str(count) if isinstance(count, int) and not isinstance(count, bool) else "?" + summaries.append(f"{name} ({count_text} 段)") + if summaries: + entries.append(f"命中文档:{', '.join(summaries)}") + + formatted = "\n\n".join(entries) + truncation_marker = "…(响应已截断)" + if len(formatted) <= max_total_chars: + return formatted + if max_total_chars <= len(truncation_marker): + return truncation_marker[:max_total_chars] + prefix_length = max_total_chars - len(truncation_marker) + return f"{formatted[:prefix_length].rstrip()}{truncation_marker}" diff --git a/backend/packages/harness/deerflow/community/ragflow/tools.py b/backend/packages/harness/deerflow/community/ragflow/tools.py new file mode 100644 index 00000000000..532be2b9b84 --- /dev/null +++ b/backend/packages/harness/deerflow/community/ragflow/tools.py @@ -0,0 +1,188 @@ +"""Read-only Agent tools for RAGFlow knowledge retrieval.""" + +from __future__ import annotations + +import logging +import re +from collections.abc import Mapping + +from langchain.tools import tool +from pydantic import SecretStr + +from deerflow.config import get_app_config +from deerflow.config.knowledge_base_config import KnowledgeBaseConfig + +from .client import RAGFlowAPIError, RAGFlowClient, RAGFlowConnectionError, RAGFlowProtocolError +from .formatting import format_retrieval_result + +logger = logging.getLogger(__name__) + +_warned: set[str] = set() +_RAGFLOW_UUID_PATTERN = re.compile(r"(? str | None: + value = settings.api_key + if isinstance(value, SecretStr): + value = value.get_secret_value() + if isinstance(value, str) and value.strip(): + return value.strip() + return None + + +def _redact(value: object, api_key: str | None) -> str: + text = str(value) + if api_key: + text = text.replace(api_key, "[REDACTED]") + return _RAGFLOW_UUID_PATTERN.sub("[DATASET_ID]", text) + + +def _settings_or_error() -> tuple[KnowledgeBaseConfig | None, str | None]: + settings = get_app_config().knowledge_base + if not settings.enabled: + return None, "Error: 知识库功能未启用,请在 config.yaml 中设置 knowledge_base.enabled: true。" + if not _api_key(settings): + if "api_key" not in _warned: + _warned.add("api_key") + logger.warning("RAGFlow API Key 未配置;请设置 knowledge_base.api_key,建议使用 $RAGFLOW_API_KEY 环境变量引用。") + return None, "Error: 未配置 RAGFlow API Key,请设置 knowledge_base.api_key(建议使用 $RAGFLOW_API_KEY)。" + return settings, None + + +def _build_client(settings: KnowledgeBaseConfig) -> RAGFlowClient: + api_key = _api_key(settings) + if api_key is None: # Guarded by _settings_or_error; keeps this helper total. + raise ValueError("RAGFlow API Key is missing") + return RAGFlowClient( + base_url=str(settings.base_url).rstrip("/"), + api_key=api_key, + timeout=settings.timeout, + ) + + +def _tool_error(exc: Exception, settings: KnowledgeBaseConfig) -> str: + key = _api_key(settings) + safe_detail = _redact(exc, key) + base_url = _redact(str(settings.base_url).rstrip("/"), key) + + if isinstance(exc, RAGFlowAPIError): + logger.warning("RAGFlow API rejected a read-only tool request (code=%s)", exc.code) + return f"Error: {safe_detail}" + if isinstance(exc, RAGFlowConnectionError): + logger.warning("RAGFlow connection failed for %s (%s)", base_url, type(exc).__name__) + return f"Error: 无法连接 RAGFlow ({base_url}): {safe_detail}" + if isinstance(exc, RAGFlowProtocolError): + logger.warning("RAGFlow returned an invalid response for a read-only tool request (%s)", type(exc).__name__) + return f"Error: RAGFlow 请求失败: {safe_detail}" + + logger.warning("Unexpected RAGFlow read-only tool failure (%s)", type(exc).__name__) + return "Error: RAGFlow 检索发生未知错误,请稍后重试。" + + +def _valid_datasets(datasets: list[dict]) -> list[tuple[str, str, Mapping[str, object]]]: + valid: list[tuple[str, str, Mapping[str, object]]] = [] + for dataset in datasets: + dataset_id = dataset.get("id") + name = dataset.get("name") + if not isinstance(dataset_id, str) or not dataset_id or not isinstance(name, str) or not name.strip(): + continue + valid.append((dataset_id, name.strip(), dataset)) + return valid + + +async def list_knowledge_bases() -> str: + """List accessible RAGFlow knowledge bases without exposing their UUIDs.""" + settings, error = _settings_or_error() + if settings is None: + return error or "Error: 知识库配置无效。" + + try: + datasets = _valid_datasets(await _build_client(settings).list_datasets()) + except Exception as exc: + return _tool_error(exc, settings) + + if not datasets: + return "当前没有可用的知识库。" + + lines = ["可用知识库:"] + for _, name, dataset in datasets: + description = dataset.get("description") + description_text = f" — {description.strip()}" if isinstance(description, str) and description.strip() else "" + document_count = dataset.get("document_count") + count_text = f"({document_count} 个文档)" if isinstance(document_count, int) and not isinstance(document_count, bool) else "" + lines.append(f"- {name}{description_text}{count_text}") + return _redact("\n".join(lines), _api_key(settings)) + + +async def knowledge_search(query: str, knowledge_bases: list[str] | None = None) -> str: + """Search RAGFlow and return compact chunks with stable citation markers.""" + query = query.strip() + if not query: + return "Error: 查询内容不能为空。" + if knowledge_bases is not None and not any(isinstance(name, str) and name.strip() for name in knowledge_bases): + return "Error: knowledge_bases 为空;请至少指定一个知识库,或省略该参数进行全库兜底检索。" + + settings, error = _settings_or_error() + if settings is None: + return error or "Error: 知识库配置无效。" + + client = _build_client(settings) + try: + datasets = _valid_datasets(await client.list_datasets()) + names_by_id = {dataset_id: name for dataset_id, name, _ in datasets} + + retrieve_options: dict[str, object] = { + "page_size": settings.page_size, + "similarity_threshold": settings.similarity_threshold, + "vector_similarity_weight": settings.vector_similarity_weight, + "top_k": settings.top_k, + } + if knowledge_bases is not None: + ids_by_casefolded_name = {name.casefold(): dataset_id for dataset_id, name, _ in datasets} + requested_names = [name.strip() for name in knowledge_bases if isinstance(name, str) and name.strip()] + unknown_names = [name for name in requested_names if name.casefold() not in ids_by_casefolded_name] + if unknown_names: + available = ", ".join(name for _, name, _ in datasets) or "(无)" + return _redact( + f"Error: 未知知识库:{', '.join(unknown_names)}。当前可用知识库:{available}。", + _api_key(settings), + ) + + dataset_ids: list[str] = [] + for name in requested_names: + dataset_id = ids_by_casefolded_name[name.casefold()] + if dataset_id not in dataset_ids: + dataset_ids.append(dataset_id) + retrieve_options["dataset_ids"] = dataset_ids + + result = await client.retrieve(query, **retrieve_options) + formatted = format_retrieval_result( + result, + dataset_names_by_id=names_by_id, + max_chars_per_chunk=settings.max_chars_per_chunk, + max_total_chars=settings.max_total_chars, + ) + return _redact(formatted, _api_key(settings)) + except Exception as exc: + return _tool_error(exc, settings) + + +@tool("list_knowledge_bases", parse_docstring=True) +async def list_knowledge_bases_tool() -> str: + """List all private knowledge bases available to this DeerFlow deployment.""" + return await list_knowledge_bases() + + +@tool("knowledge_search", parse_docstring=True) +async def knowledge_search_tool(query: str, knowledge_bases: list[str] | None = None) -> str: + """Search private RAGFlow knowledge bases for relevant source chunks. + + If you are unsure which knowledge bases exist, call + ``list_knowledge_bases`` first. Prefer explicit knowledge-base names because + searching all bases can fail when they use different embedding models. + + Args: + query: Specific question or search terms to retrieve from private documents. + knowledge_bases: Knowledge-base names to search. Omit only as a fallback search across all bases. + """ + return await knowledge_search(query, knowledge_bases) diff --git a/backend/packages/harness/deerflow/config/__init__.py b/backend/packages/harness/deerflow/config/__init__.py index 76751936bed..0d11690560b 100644 --- a/backend/packages/harness/deerflow/config/__init__.py +++ b/backend/packages/harness/deerflow/config/__init__.py @@ -1,5 +1,6 @@ from .app_config import get_app_config from .extensions_config import ExtensionsConfig, get_extensions_config +from .knowledge_base_config import KnowledgeBaseConfig from .loop_detection_config import LoopDetectionConfig from .memory_config import MemoryConfig, get_memory_config from .paths import Paths, get_paths @@ -21,6 +22,7 @@ "get_paths", "SkillsConfig", "ExtensionsConfig", + "KnowledgeBaseConfig", "get_extensions_config", "LoopDetectionConfig", "MemoryConfig", diff --git a/backend/packages/harness/deerflow/config/app_config.py b/backend/packages/harness/deerflow/config/app_config.py index 5af8109b650..617dcec6a30 100644 --- a/backend/packages/harness/deerflow/config/app_config.py +++ b/backend/packages/harness/deerflow/config/app_config.py @@ -23,6 +23,7 @@ from deerflow.config.file_signature import get_config_signature as _get_config_signature from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict from deerflow.config.input_polish_config import InputPolishConfig +from deerflow.config.knowledge_base_config import KnowledgeBaseConfig from deerflow.config.loop_detection_config import LoopDetectionConfig from deerflow.config.mcp_tasks_config import McpTasksConfig from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict @@ -244,6 +245,7 @@ class AppConfig(BaseModel): title: TitleConfig = Field(default_factory=TitleConfig, description="Automatic title generation configuration") summarization: SummarizationConfig = Field(default_factory=SummarizationConfig, description="Conversation summarization configuration") memory: MemoryConfig = Field(default_factory=MemoryConfig, description="Memory subsystem configuration") + knowledge_base: KnowledgeBaseConfig = Field(default_factory=KnowledgeBaseConfig, description="RAGFlow knowledge-base retrieval configuration") agents_api: AgentsApiConfig = Field(default_factory=AgentsApiConfig, description="Custom-agent management API configuration") acp_agents: dict[str, ACPAgentConfig] = Field(default_factory=dict, description="ACP-compatible agent configuration") subagents: SubagentsAppConfig = Field(default_factory=SubagentsAppConfig, description="Subagent runtime configuration") diff --git a/backend/packages/harness/deerflow/config/knowledge_base_config.py b/backend/packages/harness/deerflow/config/knowledge_base_config.py new file mode 100644 index 00000000000..9dd806608fa --- /dev/null +++ b/backend/packages/harness/deerflow/config/knowledge_base_config.py @@ -0,0 +1,19 @@ +from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, SecretStr + + +class KnowledgeBaseConfig(BaseModel): + """Hot-reloadable RAGFlow retrieval settings.""" + + model_config = ConfigDict(validate_default=True) + + enabled: bool = Field(default=False) + base_url: AnyHttpUrl = Field(default="http://localhost:9380") + api_key: SecretStr | None = Field(default=None) + timeout: float = Field(default=30, gt=0, le=600) + + page_size: int = Field(default=8, ge=1, le=100) + similarity_threshold: float = Field(default=0.2, ge=0, le=1) + vector_similarity_weight: float = Field(default=0.3, ge=0, le=1) + top_k: int = Field(default=256, ge=1, le=1024) + max_chars_per_chunk: int = Field(default=800, ge=1, le=100_000) + max_total_chars: int = Field(default=8000, ge=1, le=1_000_000) diff --git a/backend/packages/harness/deerflow/tools/tools.py b/backend/packages/harness/deerflow/tools/tools.py index 77e9bed5779..6def272a387 100644 --- a/backend/packages/harness/deerflow/tools/tools.py +++ b/backend/packages/harness/deerflow/tools/tools.py @@ -85,6 +85,14 @@ def get_available_tools( config = app_config or get_app_config() tool_configs = [tool for tool in config.tools if groups is None or tool.group in groups] + # RAGFlow knowledge tools are opt-in as a group. Keeping the entries in the + # example config while filtering them here makes ``knowledge_base.enabled`` + # the single hot-reloadable feature flag and preserves zero behavior change + # for existing deployments. + knowledge_base_config = getattr(config, "knowledge_base", None) + if not getattr(knowledge_base_config, "enabled", False): + tool_configs = [tool for tool in tool_configs if tool.group != "knowledge"] + # Do not expose host bash by default when LocalSandboxProvider is active. if not is_host_bash_allowed(config): tool_configs = [tool for tool in tool_configs if not _is_host_bash_tool(tool)] diff --git a/config.example.yaml b/config.example.yaml index eeb611395f2..2de8eacb007 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -658,6 +658,29 @@ models: # write_timeout: 60.0 # pool_timeout: 30.0 +# ============================================================================ +# Knowledge Base (RAGFlow) +# ============================================================================ +# Optional private-corpus retrieval backed by RAGFlow. DeerFlow stores no +# dataset metadata or retrieval state; RAGFlow remains the sole source of truth. +# The API key is tenant-scoped, so every DeerFlow user shares the same datasets. +# Set RAGFLOW_API_KEY in the environment, change api_key to $RAGFLOW_API_KEY, +# and enable this block to expose the two read-only Agent retrieval tools. +knowledge_base: + enabled: false + base_url: http://localhost:9380 # Docker: use a host/container-reachable URL + api_key: null # Recommended when enabled: $RAGFLOW_API_KEY + timeout: 30 + + # Retrieval defaults. Prefer explicit knowledge-base names: a cross-dataset + # search can fail when datasets use different embedding models. + page_size: 8 + similarity_threshold: 0.2 + vector_similarity_weight: 0.3 + top_k: 256 + max_chars_per_chunk: 800 + max_total_chars: 8000 + # ============================================================================ # Tool Groups Configuration # ============================================================================ @@ -669,6 +692,7 @@ tool_groups: - name: file:write - name: bash - name: browser + - name: knowledge # ============================================================================ # Tools Configuration @@ -676,6 +700,15 @@ tool_groups: # Configure available tools for the agent to use tools: + # RAGFlow knowledge retrieval (read-only). These tools are hidden unless + # knowledge_base.enabled is true. Dataset UUIDs are never shown to the model. + - name: knowledge_search + group: knowledge + use: deerflow.community.ragflow.tools:knowledge_search_tool + - name: list_knowledge_bases + group: knowledge + use: deerflow.community.ragflow.tools:list_knowledge_bases_tool + # Web search tool (uses DuckDuckGo, no API key required) - name: web_search group: web diff --git a/deploy/helm/deer-flow/values.yaml b/deploy/helm/deer-flow/values.yaml index 940f76ebc96..0f41b1e8d27 100644 --- a/deploy/helm/deer-flow/values.yaml +++ b/deploy/helm/deer-flow/values.yaml @@ -246,6 +246,20 @@ config: | config_version: 36 log_level: info + # Optional tenant-shared, read-only RAGFlow retrieval. Add RAGFLOW_API_KEY to + # `secrets`, set api_key to $RAGFLOW_API_KEY, and enable when the Gateway can + # reach it. + knowledge_base: + enabled: false + base_url: http://ragflow:9380 + api_key: null + timeout: 30 + page_size: 8 + similarity_threshold: 0.2 + vector_similarity_weight: 0.3 + top_k: 256 + max_chars_per_chunk: 800 + max_total_chars: 8000 models: [] # Example (uncomment & set the matching secret in `secrets`): # - name: gpt-4 @@ -307,8 +321,15 @@ config: | - name: file:read - name: file:write - name: bash + - name: knowledge tools: + - name: knowledge_search + group: knowledge + use: deerflow.community.ragflow.tools:knowledge_search_tool + - name: list_knowledge_bases + group: knowledge + use: deerflow.community.ragflow.tools:list_knowledge_bases_tool - name: web_search group: web use: deerflow.community.ddg_search.tools:web_search_tool From 9726c00e290e7db4b5a05e03f043fb4f15d0d036 Mon Sep 17 00:00:00 2001 From: zhangwei139623 <1552775457@qq.com> Date: Sun, 23 Aug 2026 11:24:00 +0800 Subject: [PATCH 02/14] test(knowledge): cover RAGFlow retrieval contracts --- backend/tests/test_ragflow_client.py | 194 +++++++++++++++ backend/tests/test_ragflow_tools.py | 357 +++++++++++++++++++++++++++ 2 files changed, 551 insertions(+) create mode 100644 backend/tests/test_ragflow_client.py create mode 100644 backend/tests/test_ragflow_tools.py diff --git a/backend/tests/test_ragflow_client.py b/backend/tests/test_ragflow_client.py new file mode 100644 index 00000000000..a404707dad9 --- /dev/null +++ b/backend/tests/test_ragflow_client.py @@ -0,0 +1,194 @@ +import json + +import httpx +import pytest + +from deerflow.community.ragflow.client import ( + RAGFlowAPIError, + RAGFlowClient, + RAGFlowConnectionError, + RAGFlowProtocolError, +) + + +@pytest.mark.anyio +async def test_list_datasets_builds_authenticated_request() -> None: + async def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "GET" + assert request.url == httpx.URL("http://ragflow.test/api/v1/datasets?page=1&page_size=100") + assert request.headers["Authorization"] == "Bearer ragflow-secret" + return httpx.Response(200, json={"code": 0, "data": [{"id": "dataset-1", "name": "Policies"}]}) + + client = RAGFlowClient( + base_url="http://ragflow.test/", + api_key="ragflow-secret", + timeout=12, + transport=httpx.MockTransport(handler), + ) + + assert await client.list_datasets() == [{"id": "dataset-1", "name": "Policies"}] + + +@pytest.mark.anyio +async def test_list_datasets_follows_pagination_until_total_is_reached() -> None: + requested_pages: list[int] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + page = int(request.url.params["page"]) + requested_pages.append(page) + if page == 1: + data = [{"id": f"dataset-{index}", "name": f"Dataset {index}"} for index in range(100)] + else: + data = [{"id": "dataset-100", "name": "Dataset 100"}] + return httpx.Response(200, json={"code": 0, "data": data, "total_datasets": 101}) + + client = RAGFlowClient( + base_url="http://ragflow.test", + api_key="ragflow-secret", + transport=httpx.MockTransport(handler), + ) + + datasets = await client.list_datasets() + + assert requested_pages == [1, 2] + assert len(datasets) == 101 + + +@pytest.mark.anyio +async def test_retrieve_builds_expected_payload() -> None: + async def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url == httpx.URL("http://ragflow.test/api/v1/retrieval") + assert json.loads(request.content) == { + "question": "annual leave", + "dataset_ids": ["dataset-1"], + "page_size": 8, + "similarity_threshold": 0.2, + "vector_similarity_weight": 0.3, + "top_k": 256, + } + return httpx.Response(200, json={"code": 0, "data": {"chunks": [], "doc_aggs": [], "total": 0}}) + + client = RAGFlowClient( + base_url="http://ragflow.test", + api_key="ragflow-secret", + transport=httpx.MockTransport(handler), + ) + + result = await client.retrieve( + "annual leave", + dataset_ids=["dataset-1"], + page_size=8, + similarity_threshold=0.2, + vector_similarity_weight=0.3, + top_k=256, + ) + + assert result["total"] == 0 + + +@pytest.mark.anyio +async def test_retrieve_omits_dataset_ids_when_unspecified() -> None: + async def handler(request: httpx.Request) -> httpx.Response: + payload = json.loads(request.content) + assert "dataset_ids" not in payload + return httpx.Response(200, json={"code": 0, "data": {"chunks": []}}) + + client = RAGFlowClient( + base_url="http://ragflow.test", + api_key="ragflow-secret", + transport=httpx.MockTransport(handler), + ) + + await client.retrieve("fallback search", dataset_ids=None) + + +@pytest.mark.anyio +async def test_nonzero_api_code_is_normalized_and_redacts_api_key() -> None: + async def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + json={"code": 102, "message": "invalid credential ragflow-secret"}, + ) + + client = RAGFlowClient( + base_url="http://ragflow.test", + api_key="ragflow-secret", + transport=httpx.MockTransport(handler), + ) + + with pytest.raises(RAGFlowAPIError) as exc_info: + await client.list_datasets() + + assert exc_info.value.code == 102 + assert "invalid credential" in str(exc_info.value) + assert "ragflow-secret" not in str(exc_info.value) + assert "[REDACTED]" in str(exc_info.value) + + +@pytest.mark.anyio +async def test_timeout_is_normalized_without_leaking_api_key(caplog: pytest.LogCaptureFixture) -> None: + async def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ReadTimeout("timed out with ragflow-secret", request=request) + + client = RAGFlowClient( + base_url="http://ragflow.test", + api_key="ragflow-secret", + timeout=2, + transport=httpx.MockTransport(handler), + ) + + with pytest.raises(RAGFlowConnectionError) as exc_info: + await client.list_datasets() + + assert "ragflow-secret" not in str(exc_info.value) + assert "ragflow-secret" not in caplog.text + assert "超时" in str(exc_info.value) + + +@pytest.mark.anyio +async def test_http_error_body_cannot_echo_api_key() -> None: + async def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(401, text="unauthorized: ragflow-secret") + + client = RAGFlowClient( + base_url="http://ragflow.test", + api_key="ragflow-secret", + transport=httpx.MockTransport(handler), + ) + + with pytest.raises(RAGFlowProtocolError) as exc_info: + await client.list_datasets() + + assert "HTTP 401" in str(exc_info.value) + assert "ragflow-secret" not in str(exc_info.value) + + +@pytest.mark.anyio +async def test_invalid_json_response_is_normalized() -> None: + async def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, text="not-json") + + client = RAGFlowClient( + base_url="http://ragflow.test", + api_key="ragflow-secret", + transport=httpx.MockTransport(handler), + ) + + with pytest.raises(RAGFlowProtocolError, match="JSON"): + await client.list_datasets() + + +@pytest.mark.anyio +async def test_list_datasets_rejects_unexpected_data_shape() -> None: + async def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"code": 0, "data": {"id": "not-a-list"}}) + + client = RAGFlowClient( + base_url="http://ragflow.test", + api_key="ragflow-secret", + transport=httpx.MockTransport(handler), + ) + + with pytest.raises(RAGFlowProtocolError, match="知识库列表"): + await client.list_datasets() diff --git a/backend/tests/test_ragflow_tools.py b/backend/tests/test_ragflow_tools.py new file mode 100644 index 00000000000..830d415def4 --- /dev/null +++ b/backend/tests/test_ragflow_tools.py @@ -0,0 +1,357 @@ +import logging +from types import SimpleNamespace + +import pytest + +import deerflow.community.ragflow.tools as ragflow_tools +from deerflow.community.ragflow.client import RAGFlowAPIError, RAGFlowConnectionError +from deerflow.community.ragflow.formatting import format_retrieval_result +from deerflow.config.knowledge_base_config import KnowledgeBaseConfig +from deerflow.config.tool_config import ToolConfig +from deerflow.tools.tools import get_available_tools + + +class FakeRAGFlowClient: + def __init__(self, *, datasets: list[dict] | None = None, retrieval: dict | None = None, error: Exception | None = None) -> None: + self.datasets = datasets or [] + self.retrieval = retrieval or {"chunks": [], "doc_aggs": [], "total": 0} + self.error = error + self.retrieve_calls: list[tuple[str, dict]] = [] + + async def list_datasets(self) -> list[dict]: + if self.error is not None: + raise self.error + return self.datasets + + async def retrieve(self, query: str, **kwargs: object) -> dict: + if self.error is not None: + raise self.error + self.retrieve_calls.append((query, kwargs)) + return self.retrieval + + +@pytest.fixture(autouse=True) +def reset_warning_deduplication() -> None: + ragflow_tools._warned.clear() + + +def _config( + *, + enabled: bool = True, + api_key: str | None = "ragflow-secret", + base_url: str = "http://ragflow.test", +) -> SimpleNamespace: + return SimpleNamespace( + knowledge_base=SimpleNamespace( + enabled=enabled, + base_url=base_url, + api_key=api_key, + timeout=30, + page_size=8, + similarity_threshold=0.2, + vector_similarity_weight=0.3, + top_k=256, + max_chars_per_chunk=800, + max_total_chars=8000, + ) + ) + + +def _install(monkeypatch: pytest.MonkeyPatch, fake: FakeRAGFlowClient, *, config: SimpleNamespace | None = None) -> None: + monkeypatch.setattr(ragflow_tools, "get_app_config", lambda: config or _config()) + monkeypatch.setattr(ragflow_tools, "_build_client", lambda settings: fake) + + +@pytest.mark.anyio +async def test_list_knowledge_bases_returns_names_without_uuids(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeRAGFlowClient( + datasets=[ + {"id": "dataset-secret-1", "name": "HR Policies", "description": "Employee handbook", "document_count": 3}, + {"id": "dataset-secret-2", "name": "Engineering", "description": "", "document_count": 7}, + ] + ) + _install(monkeypatch, fake) + + result = await ragflow_tools.list_knowledge_bases() + + assert "HR Policies" in result + assert "Employee handbook" in result + assert "3 个文档" in result + assert "Engineering" in result + assert "dataset-secret" not in result + + +@pytest.mark.anyio +async def test_knowledge_search_resolves_names_to_ids_and_formats_citations(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeRAGFlowClient( + datasets=[{"id": "dataset-1", "name": "HR Policies", "description": "", "document_count": 1}], + retrieval={ + "chunks": [ + { + "dataset_id": "dataset-1", + "document_id": "doc-1", + "document_keyword": "handbook.pdf", + "content": "Annual leave is based on years of service.", + "similarity": 0.874, + } + ], + "doc_aggs": [{"doc_id": "doc-1", "doc_name": "handbook.pdf", "count": 1}], + "total": 1, + }, + ) + _install(monkeypatch, fake) + + result = await ragflow_tools.knowledge_search("annual leave", ["HR Policies"]) + + assert fake.retrieve_calls == [ + ( + "annual leave", + { + "dataset_ids": ["dataset-1"], + "page_size": 8, + "similarity_threshold": 0.2, + "vector_similarity_weight": 0.3, + "top_k": 256, + }, + ) + ] + assert "[1] HR Policies / handbook.pdf (相关度 0.87)" in result + assert "Annual leave" in result + assert "命中文档:handbook.pdf (1 段)" in result + assert "dataset-1" not in result + + +@pytest.mark.anyio +async def test_knowledge_search_accepts_case_insensitive_dataset_names(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeRAGFlowClient(datasets=[{"id": "dataset-1", "name": "HR Policies"}]) + _install(monkeypatch, fake) + + await ragflow_tools.knowledge_search("leave", ["hr policies"]) + + assert fake.retrieve_calls[0][1]["dataset_ids"] == ["dataset-1"] + + +@pytest.mark.anyio +async def test_knowledge_search_unknown_name_returns_available_names(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeRAGFlowClient(datasets=[{"id": "dataset-1", "name": "HR Policies"}]) + _install(monkeypatch, fake) + + result = await ragflow_tools.knowledge_search("leave", ["Finance"]) + + assert "Finance" in result + assert "HR Policies" in result + assert fake.retrieve_calls == [] + + +@pytest.mark.anyio +async def test_unknown_dataset_error_redacts_api_key(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeRAGFlowClient(datasets=[{"id": "dataset-1", "name": "HR Policies"}]) + _install(monkeypatch, fake) + + result = await ragflow_tools.knowledge_search("leave", ["ragflow-secret"]) + + assert "ragflow-secret" not in result + assert "[REDACTED]" in result + + +@pytest.mark.anyio +async def test_knowledge_search_without_names_does_not_pass_dataset_ids(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeRAGFlowClient(datasets=[{"id": "dataset-1", "name": "HR Policies"}]) + _install(monkeypatch, fake) + + await ragflow_tools.knowledge_search("fallback", None) + + assert "dataset_ids" not in fake.retrieve_calls[0][1] + + +@pytest.mark.anyio +async def test_knowledge_search_rejects_explicit_empty_dataset_list(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeRAGFlowClient(datasets=[{"id": "dataset-1", "name": "HR Policies"}]) + _install(monkeypatch, fake) + + result = await ragflow_tools.knowledge_search("leave", []) + + assert "至少指定一个知识库" in result + assert fake.retrieve_calls == [] + + +@pytest.mark.anyio +async def test_missing_api_key_returns_guidance_and_warns_only_once(monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture) -> None: + fake = FakeRAGFlowClient() + _install(monkeypatch, fake, config=_config(api_key=None)) + + with caplog.at_level(logging.WARNING, logger="deerflow.community.ragflow.tools"): + first = await ragflow_tools.list_knowledge_bases() + second = await ragflow_tools.knowledge_search("leave") + + assert "未配置 RAGFlow API Key" in first + assert "未配置 RAGFlow API Key" in second + assert caplog.text.count("RAGFlow API Key") == 1 + assert fake.retrieve_calls == [] + + +@pytest.mark.anyio +async def test_disabled_feature_returns_guidance_without_calling_ragflow(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeRAGFlowClient() + _install(monkeypatch, fake, config=_config(enabled=False)) + + result = await ragflow_tools.list_knowledge_bases() + + assert "knowledge_base.enabled: true" in result + + +@pytest.mark.anyio +async def test_api_error_is_returned_as_readable_text(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeRAGFlowClient(error=RAGFlowAPIError("embedding models do not match", code=102)) + _install(monkeypatch, fake) + + result = await ragflow_tools.knowledge_search("leave", ["HR Policies"]) + + assert result == "Error: embedding models do not match" + + +@pytest.mark.anyio +async def test_api_error_cannot_expose_dataset_uuid(monkeypatch: pytest.MonkeyPatch) -> None: + dataset_id = "0123456789abcdef0123456789abcdef" + fake = FakeRAGFlowClient(error=RAGFlowAPIError(f"dataset {dataset_id} failed", code=102)) + _install(monkeypatch, fake) + + result = await ragflow_tools.list_knowledge_bases() + + assert dataset_id not in result + assert "[DATASET_ID]" in result + + +@pytest.mark.anyio +async def test_connection_error_is_recoverable_and_does_not_leak_key(monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture) -> None: + fake = FakeRAGFlowClient(error=RAGFlowConnectionError("ConnectError: refused ragflow-secret")) + _install(monkeypatch, fake) + + with caplog.at_level(logging.WARNING, logger="deerflow.community.ragflow.tools"): + result = await ragflow_tools.list_knowledge_bases() + + assert result.startswith("Error: 无法连接 RAGFlow (http://ragflow.test):") + assert "ragflow-secret" not in result + assert "ragflow-secret" not in caplog.text + + +@pytest.mark.anyio +async def test_connection_error_redacts_key_embedded_in_base_url( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + fake = FakeRAGFlowClient(error=RAGFlowConnectionError("connection refused")) + _install( + monkeypatch, + fake, + config=_config(base_url="http://ragflow-secret@ragflow.test"), + ) + + with caplog.at_level(logging.WARNING, logger="deerflow.community.ragflow.tools"): + result = await ragflow_tools.list_knowledge_bases() + + assert "ragflow-secret" not in result + assert "ragflow-secret" not in caplog.text + + +@pytest.mark.anyio +async def test_empty_retrieval_has_explicit_message(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeRAGFlowClient(datasets=[{"id": "dataset-1", "name": "HR Policies"}]) + _install(monkeypatch, fake) + + result = await ragflow_tools.knowledge_search("nothing", ["HR Policies"]) + + assert result == "未检索到相关内容。" + + +def test_formatting_applies_per_chunk_truncation_and_supports_kb_id() -> None: + result = format_retrieval_result( + { + "chunks": [ + { + "kb_id": "dataset-1", + "document_keyword": "handbook.pdf", + "content": "abcdefghij", + "similarity": 0.5, + } + ] + }, + dataset_names_by_id={"dataset-1": "HR Policies"}, + max_chars_per_chunk=5, + max_total_chars=1000, + ) + + assert "abcd…" in result + assert "abcdefghij" not in result + assert "dataset-1" not in result + + +def test_formatting_applies_total_response_truncation() -> None: + result = format_retrieval_result( + { + "chunks": [ + { + "dataset_id": "dataset-1", + "document_keyword": f"document-{index}.txt", + "content": "content " * 20, + "similarity": 0.5, + } + for index in range(4) + ] + }, + dataset_names_by_id={"dataset-1": "Policies"}, + max_chars_per_chunk=100, + max_total_chars=120, + ) + + assert len(result) <= 120 + assert result.endswith("…(响应已截断)") + + +def test_knowledge_base_config_has_safe_defaults_and_secret_repr() -> None: + config = KnowledgeBaseConfig(api_key="ragflow-secret") + + assert config.enabled is False + assert str(config.base_url).rstrip("/") == "http://localhost:9380" + assert config.page_size == 8 + assert config.max_chars_per_chunk == 800 + assert config.max_total_chars == 8000 + assert "ragflow-secret" not in repr(config) + + +def test_agent_tool_contracts_are_async_and_model_facing() -> None: + assert ragflow_tools.list_knowledge_bases_tool.name == "list_knowledge_bases" + assert ragflow_tools.list_knowledge_bases_tool.coroutine is not None + assert ragflow_tools.knowledge_search_tool.name == "knowledge_search" + assert ragflow_tools.knowledge_search_tool.coroutine is not None + assert set(ragflow_tools.knowledge_search_tool.tool_call_schema.model_fields) == {"query", "knowledge_bases"} + assert "list_knowledge_bases" in ragflow_tools.knowledge_search_tool.description + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_tool_assembly_gates_knowledge_group_with_feature_flag(enabled: bool) -> None: + config = SimpleNamespace( + tools=[ + ToolConfig( + name="knowledge_search", + group="knowledge", + use="deerflow.community.ragflow.tools:knowledge_search_tool", + ), + ToolConfig( + name="list_knowledge_bases", + group="knowledge", + use="deerflow.community.ragflow.tools:list_knowledge_bases_tool", + ), + ], + knowledge_base=SimpleNamespace(enabled=enabled), + sandbox=SimpleNamespace(use="example.remote:Sandbox"), + skill_evolution=SimpleNamespace(enabled=False), + models=[], + acp_agents={}, + get_model_config=lambda name: None, + ) + + names = {tool.name for tool in get_available_tools(include_mcp=False, app_config=config)} + + assert ("knowledge_search" in names) is enabled + assert ("list_knowledge_bases" in names) is enabled From 59bacd0a19fb61c8997c77887241be15bccb6727 Mon Sep 17 00:00:00 2001 From: zhangwei139623 <1552775457@qq.com> Date: Sun, 23 Aug 2026 11:25:38 +0800 Subject: [PATCH 03/14] docs(knowledge): document retrieval-only RAGFlow setup --- README.md | 1 + backend/AGENTS.md | 13 ++++++++ backend/docs/CONFIGURATION.md | 32 +++++++++++++++++++ backend/packages/harness/deerflow/AGENTS.md | 13 ++++++++ .../harness/deerflow/config/AGENTS.md | 1 + 5 files changed, 60 insertions(+) diff --git a/README.md b/README.md index 105fcbb34cb..9cdaf5ddbc8 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ DeerFlow has newly integrated the intelligent search and crawling toolset indepe - [Core Features](#core-features) - [Skills \& Tools](#skills--tools) - [Claude Code Integration](#claude-code-integration) + - [Private Knowledge Retrieval (RAGFlow)](#private-knowledge-retrieval-ragflow) - [Session Goals](#session-goals) - [Manual Context Compaction](#manual-context-compaction) - [Sub-Agents](#sub-agents) diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 7b823a7044c..302847be313 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -291,6 +291,19 @@ For models with `supports_vision: true`: - `view_image_tool` added to agent's toolset - Images are converted to base64 and injected into a hidden message carrying both a reserved ID prefix and a server-owned metadata marker for the model call; Gateway strips that marker from untrusted input, and the middleware requires both identifiers before removing the message. The `before_model` and `model` node checkpoints for that call still contain the payload; after `after_model` cleanup, subsequent checkpoints retain only lightweight `viewed_images` metadata, while client-chosen IDs survive +### RAGFlow Knowledge Retrieval + +The harness provides an opt-in, read-only RAGFlow integration under +`deerflow.community.ragflow`. `knowledge_base.enabled` gates the entire +`knowledge` tool group; the two Agent tools list tenant-shared knowledge bases +and retrieve compact cited chunks. RAGFlow remains the sole source of truth: +there are no DeerFlow ORM models, migrations, or mirrored knowledge metadata. +The configured tenant API key must never appear in logs or model-visible tool +errors, and dataset UUIDs must not enter model context. This slice deliberately +contains no Gateway management API, watcher, SSE endpoint, or frontend UI; +knowledge-base writes remain in RAGFlow. Tests live in +`tests/test_ragflow_client.py` and `tests/test_ragflow_tools.py`. + ## Code Style - Uses `ruff` for linting and formatting diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index bee2139cee5..4005a7223bc 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -218,6 +218,38 @@ models: `PatchedChatMiMo` preserves MiMo's `choices[].message.reasoning_content`, streaming `delta.reasoning_content`, and request-history assistant `reasoning_content` fields. It does not reuse the DeepSeek provider. +### RAGFlow Knowledge Retrieval + +RAGFlow integration is disabled by default. It adds two read-only Agent tools: +`list_knowledge_bases` and `knowledge_search`. DeerFlow does not persist a copy +of dataset or document metadata; RAGFlow is the sole source of truth. The +configured API key is tenant-scoped, so all DeerFlow users share the same +knowledge bases. + +```yaml +knowledge_base: + enabled: true + base_url: http://localhost:9380 + api_key: $RAGFLOW_API_KEY + timeout: 30 + page_size: 8 + similarity_threshold: 0.2 + vector_similarity_weight: 0.3 + top_k: 256 + max_chars_per_chunk: 800 + max_total_chars: 8000 +``` + +The configured `knowledge` tool group is hidden while `enabled` is false and is +picked up on the next config access after enabling. Prefer passing explicit +knowledge-base names to `knowledge_search`: RAGFlow can reject a cross-dataset +query when the selected datasets use different embedding models. For Docker or +Kubernetes, `base_url` must be reachable from the Gateway container or Pod; +`localhost` refers to that container or Pod, not the host machine. + +This integration is retrieval-only. Dataset creation, uploads, parsing, and +deletion remain in RAGFlow and are not exposed as Agent tools or DeerFlow APIs. + ### Tool Groups Organize tools into logical groups: diff --git a/backend/packages/harness/deerflow/AGENTS.md b/backend/packages/harness/deerflow/AGENTS.md index b098aec020d..b3c69711f35 100644 --- a/backend/packages/harness/deerflow/AGENTS.md +++ b/backend/packages/harness/deerflow/AGENTS.md @@ -34,6 +34,19 @@ artifact. New automatic capture entry points must reuse the shared progress encoding definition in `tools.py` so the byte encoding and `.jpg` suffix cannot drift. +### RAGFlow Knowledge Retrieval (`community/ragflow/`) + +The optional `knowledge` tool group exposes only `list_knowledge_bases` and +`knowledge_search`. Both call RAGFlow directly through the async `httpx` client; +they never persist dataset metadata, expose dataset UUIDs to the model, or +provide write operations. `knowledge_base.enabled=false` removes the whole +group during tool assembly. Retrieval output is bounded at both the individual +chunk and full-response levels, and every error path must redact the configured +tenant API key before logging or returning model-visible text. +The client intentionally exposes only dataset listing and retrieval. Dataset +creation, uploads, parsing, deletion, Gateway management routes, SSE, and +frontend UI are outside this retrieval-only slice. + ### Embedded Client (`packages/harness/deerflow/client.py`) `DeerFlowClient` provides direct in-process access to all DeerFlow capabilities without HTTP services. All return types align with the Gateway API response schemas, so consumer code works identically in HTTP and embedded modes. diff --git a/backend/packages/harness/deerflow/config/AGENTS.md b/backend/packages/harness/deerflow/config/AGENTS.md index dbe6715a121..52b00ed1716 100644 --- a/backend/packages/harness/deerflow/config/AGENTS.md +++ b/backend/packages/harness/deerflow/config/AGENTS.md @@ -61,6 +61,7 @@ Extensions are optional only in the fallback *search* mode (priority 3-4 above): - `subagent_runtime` - Startup-only shared process admission (`max_running`, bounded async wait queue, queue/reject policy, and queue timeout) for ordinary and durable-batch native subagents - `subagent_batches` - Startup-only explicit durable batch scheduler limits (disabled by default), including separate total, live, and running dimensions plus leases/retries/result bounds - `memory` - Memory system (enabled, storage_path, debounce_seconds, shutdown_flush_timeout_seconds, model_name, max_facts, fact_confidence_threshold, injection_enabled, max_injection_tokens, staleness_review_enabled, staleness_age_days, staleness_min_candidates, staleness_max_removals_per_cycle, staleness_protected_categories, staleness_max_lifetime_multiplier, staleness_max_extension_days) +- `knowledge_base` - Hot-reloadable, tenant-shared RAGFlow connection and read-only retrieval defaults. It gates the `knowledge` Agent tool group. Its API key is a `SecretStr`; do not log or serialize the cleartext value. **`extensions_config.json`**: - `mcpServers` - Map of server name → config (enabled, type, command, args, env, url, headers, oauth, description, `routing`, `tools`, `tool_call_timeout`, `session_init_timeout`). `routing.mode="prefer"` emits `` prompt guidance; if `tool_search` defers the hinted tool, `McpRoutingMiddleware` can also auto-promote matching deferred schemas before the model call. It does not hard-disable other tools. `session_init_timeout` (default `DEFAULT_MCP_SESSION_INIT_TIMEOUT` = 60s, `null` to disable) bounds server bring-up: tool discovery and persistent stdio session initialization, so a hung server cannot block agent construction indefinitely; durable HTTP/SSE task calls use it for their ephemeral session initialization too. `tool_call_timeout` bounds individual stdio calls and durable-task calls on every transport; other HTTP/SSE tools use transport-level timeouts. From 6682e168ad81051a85e3de3e210bb10a3d6d34a2 Mon Sep 17 00:00:00 2001 From: zhangwei139623 <1552775457@qq.com> Date: Sun, 23 Aug 2026 11:56:35 +0800 Subject: [PATCH 04/14] refactor(knowledge): move RAGFlow settings to tool config --- backend/AGENTS.md | 10 +-- backend/docs/CONFIGURATION.md | 47 ++++++++----- backend/packages/harness/deerflow/AGENTS.md | 10 +-- .../deerflow/community/ragflow/tools.py | 42 ++++++++--- .../harness/deerflow/config/AGENTS.md | 1 - .../harness/deerflow/config/__init__.py | 2 - .../harness/deerflow/config/app_config.py | 2 - .../deerflow/config/knowledge_base_config.py | 19 ----- .../packages/harness/deerflow/tools/tools.py | 8 --- backend/tests/test_ragflow_tools.py | 70 +++++++++++-------- config.example.yaml | 51 ++++++-------- deploy/helm/deer-flow/values.yaml | 40 +++++------ 12 files changed, 151 insertions(+), 151 deletions(-) delete mode 100644 backend/packages/harness/deerflow/config/knowledge_base_config.py diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 302847be313..1eebae2f0da 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -294,10 +294,12 @@ For models with `supports_vision: true`: ### RAGFlow Knowledge Retrieval The harness provides an opt-in, read-only RAGFlow integration under -`deerflow.community.ragflow`. `knowledge_base.enabled` gates the entire -`knowledge` tool group; the two Agent tools list tenant-shared knowledge bases -and retrieve compact cited chunks. RAGFlow remains the sole source of truth: -there are no DeerFlow ORM models, migrations, or mirrored knowledge metadata. +`deerflow.community.ragflow`. The normal `tools:` list enables the two Agent +tools; RAGFlow connection and retrieval parameters live as extra fields on the +`knowledge_search` entry and are reused by `list_knowledge_bases`. The tools +list tenant-shared knowledge bases and retrieve compact cited chunks. RAGFlow +remains the sole source of truth: there are no DeerFlow ORM models, migrations, +or mirrored knowledge metadata. The configured tenant API key must never appear in logs or model-visible tool errors, and dataset UUIDs must not enter model context. This slice deliberately contains no Gateway management API, watcher, SSE endpoint, or frontend UI; diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index 4005a7223bc..2d8581b9ef8 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -227,25 +227,34 @@ configured API key is tenant-scoped, so all DeerFlow users share the same knowledge bases. ```yaml -knowledge_base: - enabled: true - base_url: http://localhost:9380 - api_key: $RAGFLOW_API_KEY - timeout: 30 - page_size: 8 - similarity_threshold: 0.2 - vector_similarity_weight: 0.3 - top_k: 256 - max_chars_per_chunk: 800 - max_total_chars: 8000 -``` - -The configured `knowledge` tool group is hidden while `enabled` is false and is -picked up on the next config access after enabling. Prefer passing explicit -knowledge-base names to `knowledge_search`: RAGFlow can reject a cross-dataset -query when the selected datasets use different embedding models. For Docker or -Kubernetes, `base_url` must be reachable from the Gateway container or Pod; -`localhost` refers to that container or Pod, not the host machine. +tool_groups: + - name: knowledge + +tools: + - name: knowledge_search + group: knowledge + use: deerflow.community.ragflow.tools:knowledge_search_tool + base_url: http://localhost:9380 + api_key: $RAGFLOW_API_KEY + timeout: 30 + page_size: 8 + similarity_threshold: 0.2 + vector_similarity_weight: 0.3 + top_k: 256 + max_chars_per_chunk: 800 + max_total_chars: 8000 + - name: list_knowledge_bases + group: knowledge + use: deerflow.community.ragflow.tools:list_knowledge_bases_tool +``` + +Both tools are opt-in through the normal `tools:` list. Connection and retrieval +settings belong to `knowledge_search`; `list_knowledge_bases` reads that same +configuration. Prefer passing explicit knowledge-base names to +`knowledge_search`: RAGFlow can reject a cross-dataset query when the selected +datasets use different embedding models. For Docker or Kubernetes, `base_url` +must be reachable from the Gateway container or Pod; `localhost` refers to that +container or Pod, not the host machine. This integration is retrieval-only. Dataset creation, uploads, parsing, and deletion remain in RAGFlow and are not exposed as Agent tools or DeerFlow APIs. diff --git a/backend/packages/harness/deerflow/AGENTS.md b/backend/packages/harness/deerflow/AGENTS.md index b3c69711f35..9a45f4c869e 100644 --- a/backend/packages/harness/deerflow/AGENTS.md +++ b/backend/packages/harness/deerflow/AGENTS.md @@ -39,10 +39,12 @@ drift. The optional `knowledge` tool group exposes only `list_knowledge_bases` and `knowledge_search`. Both call RAGFlow directly through the async `httpx` client; they never persist dataset metadata, expose dataset UUIDs to the model, or -provide write operations. `knowledge_base.enabled=false` removes the whole -group during tool assembly. Retrieval output is bounded at both the individual -chunk and full-response levels, and every error path must redact the configured -tenant API key before logging or returning model-visible text. +provide write operations. Both are enabled through the normal `tools:` list. +Provider connection and retrieval extras belong to `knowledge_search` and are +reused by `list_knowledge_bases`; there is no top-level provider configuration. +Retrieval output is bounded at both the individual chunk and full-response +levels, and every error path must redact the configured tenant API key before +logging or returning model-visible text. The client intentionally exposes only dataset listing and retrieval. Dataset creation, uploads, parsing, deletion, Gateway management routes, SSE, and frontend UI are outside this retrieval-only slice. diff --git a/backend/packages/harness/deerflow/community/ragflow/tools.py b/backend/packages/harness/deerflow/community/ragflow/tools.py index 532be2b9b84..023cd65d846 100644 --- a/backend/packages/harness/deerflow/community/ragflow/tools.py +++ b/backend/packages/harness/deerflow/community/ragflow/tools.py @@ -7,10 +7,9 @@ from collections.abc import Mapping from langchain.tools import tool -from pydantic import SecretStr +from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, SecretStr, ValidationError from deerflow.config import get_app_config -from deerflow.config.knowledge_base_config import KnowledgeBaseConfig from .client import RAGFlowAPIError, RAGFlowClient, RAGFlowConnectionError, RAGFlowProtocolError from .formatting import format_retrieval_result @@ -21,7 +20,23 @@ _RAGFLOW_UUID_PATTERN = re.compile(r"(? str | None: +class _RAGFlowRetrievalSettings(BaseModel): + """Validated provider settings stored on the knowledge_search tool entry.""" + + model_config = ConfigDict(validate_default=True) + + base_url: AnyHttpUrl = Field(default="http://localhost:9380") + api_key: SecretStr | None = Field(default=None) + timeout: float = Field(default=30, gt=0, le=600) + page_size: int = Field(default=8, ge=1, le=100) + similarity_threshold: float = Field(default=0.2, ge=0, le=1) + vector_similarity_weight: float = Field(default=0.3, ge=0, le=1) + top_k: int = Field(default=256, ge=1, le=1024) + max_chars_per_chunk: int = Field(default=800, ge=1, le=100_000) + max_total_chars: int = Field(default=8000, ge=1, le=1_000_000) + + +def _api_key(settings: _RAGFlowRetrievalSettings) -> str | None: value = settings.api_key if isinstance(value, SecretStr): value = value.get_secret_value() @@ -37,19 +52,24 @@ def _redact(value: object, api_key: str | None) -> str: return _RAGFLOW_UUID_PATTERN.sub("[DATASET_ID]", text) -def _settings_or_error() -> tuple[KnowledgeBaseConfig | None, str | None]: - settings = get_app_config().knowledge_base - if not settings.enabled: - return None, "Error: 知识库功能未启用,请在 config.yaml 中设置 knowledge_base.enabled: true。" +def _settings_or_error() -> tuple[_RAGFlowRetrievalSettings | None, str | None]: + tool_config = get_app_config().get_tool_config("knowledge_search") + if tool_config is None: + return None, "Error: 未配置 knowledge_search;请在 config.yaml 的 tools 列表中添加该工具及其 RAGFlow 连接参数。" + try: + settings = _RAGFlowRetrievalSettings.model_validate(tool_config.model_extra or {}) + except ValidationError: + logger.warning("RAGFlow knowledge_search tool configuration is invalid") + return None, "Error: knowledge_search 的 RAGFlow 配置无效,请检查 config.yaml。" if not _api_key(settings): if "api_key" not in _warned: _warned.add("api_key") - logger.warning("RAGFlow API Key 未配置;请设置 knowledge_base.api_key,建议使用 $RAGFLOW_API_KEY 环境变量引用。") - return None, "Error: 未配置 RAGFlow API Key,请设置 knowledge_base.api_key(建议使用 $RAGFLOW_API_KEY)。" + logger.warning("RAGFlow API Key 未配置;请设置 tools 中 knowledge_search.api_key,建议使用 $RAGFLOW_API_KEY 环境变量引用。") + return None, "Error: 未配置 RAGFlow API Key,请设置 tools 中的 knowledge_search.api_key(建议使用 $RAGFLOW_API_KEY)。" return settings, None -def _build_client(settings: KnowledgeBaseConfig) -> RAGFlowClient: +def _build_client(settings: _RAGFlowRetrievalSettings) -> RAGFlowClient: api_key = _api_key(settings) if api_key is None: # Guarded by _settings_or_error; keeps this helper total. raise ValueError("RAGFlow API Key is missing") @@ -60,7 +80,7 @@ def _build_client(settings: KnowledgeBaseConfig) -> RAGFlowClient: ) -def _tool_error(exc: Exception, settings: KnowledgeBaseConfig) -> str: +def _tool_error(exc: Exception, settings: _RAGFlowRetrievalSettings) -> str: key = _api_key(settings) safe_detail = _redact(exc, key) base_url = _redact(str(settings.base_url).rstrip("/"), key) diff --git a/backend/packages/harness/deerflow/config/AGENTS.md b/backend/packages/harness/deerflow/config/AGENTS.md index 52b00ed1716..dbe6715a121 100644 --- a/backend/packages/harness/deerflow/config/AGENTS.md +++ b/backend/packages/harness/deerflow/config/AGENTS.md @@ -61,7 +61,6 @@ Extensions are optional only in the fallback *search* mode (priority 3-4 above): - `subagent_runtime` - Startup-only shared process admission (`max_running`, bounded async wait queue, queue/reject policy, and queue timeout) for ordinary and durable-batch native subagents - `subagent_batches` - Startup-only explicit durable batch scheduler limits (disabled by default), including separate total, live, and running dimensions plus leases/retries/result bounds - `memory` - Memory system (enabled, storage_path, debounce_seconds, shutdown_flush_timeout_seconds, model_name, max_facts, fact_confidence_threshold, injection_enabled, max_injection_tokens, staleness_review_enabled, staleness_age_days, staleness_min_candidates, staleness_max_removals_per_cycle, staleness_protected_categories, staleness_max_lifetime_multiplier, staleness_max_extension_days) -- `knowledge_base` - Hot-reloadable, tenant-shared RAGFlow connection and read-only retrieval defaults. It gates the `knowledge` Agent tool group. Its API key is a `SecretStr`; do not log or serialize the cleartext value. **`extensions_config.json`**: - `mcpServers` - Map of server name → config (enabled, type, command, args, env, url, headers, oauth, description, `routing`, `tools`, `tool_call_timeout`, `session_init_timeout`). `routing.mode="prefer"` emits `` prompt guidance; if `tool_search` defers the hinted tool, `McpRoutingMiddleware` can also auto-promote matching deferred schemas before the model call. It does not hard-disable other tools. `session_init_timeout` (default `DEFAULT_MCP_SESSION_INIT_TIMEOUT` = 60s, `null` to disable) bounds server bring-up: tool discovery and persistent stdio session initialization, so a hung server cannot block agent construction indefinitely; durable HTTP/SSE task calls use it for their ephemeral session initialization too. `tool_call_timeout` bounds individual stdio calls and durable-task calls on every transport; other HTTP/SSE tools use transport-level timeouts. diff --git a/backend/packages/harness/deerflow/config/__init__.py b/backend/packages/harness/deerflow/config/__init__.py index 0d11690560b..76751936bed 100644 --- a/backend/packages/harness/deerflow/config/__init__.py +++ b/backend/packages/harness/deerflow/config/__init__.py @@ -1,6 +1,5 @@ from .app_config import get_app_config from .extensions_config import ExtensionsConfig, get_extensions_config -from .knowledge_base_config import KnowledgeBaseConfig from .loop_detection_config import LoopDetectionConfig from .memory_config import MemoryConfig, get_memory_config from .paths import Paths, get_paths @@ -22,7 +21,6 @@ "get_paths", "SkillsConfig", "ExtensionsConfig", - "KnowledgeBaseConfig", "get_extensions_config", "LoopDetectionConfig", "MemoryConfig", diff --git a/backend/packages/harness/deerflow/config/app_config.py b/backend/packages/harness/deerflow/config/app_config.py index 617dcec6a30..5af8109b650 100644 --- a/backend/packages/harness/deerflow/config/app_config.py +++ b/backend/packages/harness/deerflow/config/app_config.py @@ -23,7 +23,6 @@ from deerflow.config.file_signature import get_config_signature as _get_config_signature from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict from deerflow.config.input_polish_config import InputPolishConfig -from deerflow.config.knowledge_base_config import KnowledgeBaseConfig from deerflow.config.loop_detection_config import LoopDetectionConfig from deerflow.config.mcp_tasks_config import McpTasksConfig from deerflow.config.memory_config import MemoryConfig, load_memory_config_from_dict @@ -245,7 +244,6 @@ class AppConfig(BaseModel): title: TitleConfig = Field(default_factory=TitleConfig, description="Automatic title generation configuration") summarization: SummarizationConfig = Field(default_factory=SummarizationConfig, description="Conversation summarization configuration") memory: MemoryConfig = Field(default_factory=MemoryConfig, description="Memory subsystem configuration") - knowledge_base: KnowledgeBaseConfig = Field(default_factory=KnowledgeBaseConfig, description="RAGFlow knowledge-base retrieval configuration") agents_api: AgentsApiConfig = Field(default_factory=AgentsApiConfig, description="Custom-agent management API configuration") acp_agents: dict[str, ACPAgentConfig] = Field(default_factory=dict, description="ACP-compatible agent configuration") subagents: SubagentsAppConfig = Field(default_factory=SubagentsAppConfig, description="Subagent runtime configuration") diff --git a/backend/packages/harness/deerflow/config/knowledge_base_config.py b/backend/packages/harness/deerflow/config/knowledge_base_config.py deleted file mode 100644 index 9dd806608fa..00000000000 --- a/backend/packages/harness/deerflow/config/knowledge_base_config.py +++ /dev/null @@ -1,19 +0,0 @@ -from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, SecretStr - - -class KnowledgeBaseConfig(BaseModel): - """Hot-reloadable RAGFlow retrieval settings.""" - - model_config = ConfigDict(validate_default=True) - - enabled: bool = Field(default=False) - base_url: AnyHttpUrl = Field(default="http://localhost:9380") - api_key: SecretStr | None = Field(default=None) - timeout: float = Field(default=30, gt=0, le=600) - - page_size: int = Field(default=8, ge=1, le=100) - similarity_threshold: float = Field(default=0.2, ge=0, le=1) - vector_similarity_weight: float = Field(default=0.3, ge=0, le=1) - top_k: int = Field(default=256, ge=1, le=1024) - max_chars_per_chunk: int = Field(default=800, ge=1, le=100_000) - max_total_chars: int = Field(default=8000, ge=1, le=1_000_000) diff --git a/backend/packages/harness/deerflow/tools/tools.py b/backend/packages/harness/deerflow/tools/tools.py index 6def272a387..77e9bed5779 100644 --- a/backend/packages/harness/deerflow/tools/tools.py +++ b/backend/packages/harness/deerflow/tools/tools.py @@ -85,14 +85,6 @@ def get_available_tools( config = app_config or get_app_config() tool_configs = [tool for tool in config.tools if groups is None or tool.group in groups] - # RAGFlow knowledge tools are opt-in as a group. Keeping the entries in the - # example config while filtering them here makes ``knowledge_base.enabled`` - # the single hot-reloadable feature flag and preserves zero behavior change - # for existing deployments. - knowledge_base_config = getattr(config, "knowledge_base", None) - if not getattr(knowledge_base_config, "enabled", False): - tool_configs = [tool for tool in tool_configs if tool.group != "knowledge"] - # Do not expose host bash by default when LocalSandboxProvider is active. if not is_host_bash_allowed(config): tool_configs = [tool for tool in tool_configs if not _is_host_bash_tool(tool)] diff --git a/backend/tests/test_ragflow_tools.py b/backend/tests/test_ragflow_tools.py index 830d415def4..2ab3c8f5a6f 100644 --- a/backend/tests/test_ragflow_tools.py +++ b/backend/tests/test_ragflow_tools.py @@ -6,7 +6,6 @@ import deerflow.community.ragflow.tools as ragflow_tools from deerflow.community.ragflow.client import RAGFlowAPIError, RAGFlowConnectionError from deerflow.community.ragflow.formatting import format_retrieval_result -from deerflow.config.knowledge_base_config import KnowledgeBaseConfig from deerflow.config.tool_config import ToolConfig from deerflow.tools.tools import get_available_tools @@ -37,23 +36,26 @@ def reset_warning_deduplication() -> None: def _config( *, - enabled: bool = True, + configured: bool = True, api_key: str | None = "ragflow-secret", base_url: str = "http://ragflow.test", ) -> SimpleNamespace: + search_config = ToolConfig( + name="knowledge_search", + group="knowledge", + use="deerflow.community.ragflow.tools:knowledge_search_tool", + base_url=base_url, + api_key=api_key, + timeout=30, + page_size=8, + similarity_threshold=0.2, + vector_similarity_weight=0.3, + top_k=256, + max_chars_per_chunk=800, + max_total_chars=8000, + ) return SimpleNamespace( - knowledge_base=SimpleNamespace( - enabled=enabled, - base_url=base_url, - api_key=api_key, - timeout=30, - page_size=8, - similarity_threshold=0.2, - vector_similarity_weight=0.3, - top_k=256, - max_chars_per_chunk=800, - max_total_chars=8000, - ) + get_tool_config=lambda name: search_config if configured and name == "knowledge_search" else None, ) @@ -191,13 +193,14 @@ async def test_missing_api_key_returns_guidance_and_warns_only_once(monkeypatch: @pytest.mark.anyio -async def test_disabled_feature_returns_guidance_without_calling_ragflow(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_missing_knowledge_search_config_returns_guidance_without_calling_ragflow(monkeypatch: pytest.MonkeyPatch) -> None: fake = FakeRAGFlowClient() - _install(monkeypatch, fake, config=_config(enabled=False)) + _install(monkeypatch, fake, config=_config(configured=False)) result = await ragflow_tools.list_knowledge_bases() - assert "knowledge_base.enabled: true" in result + assert "tools" in result + assert "knowledge_search" in result @pytest.mark.anyio @@ -308,11 +311,14 @@ def test_formatting_applies_total_response_truncation() -> None: assert result.endswith("…(响应已截断)") -def test_knowledge_base_config_has_safe_defaults_and_secret_repr() -> None: - config = KnowledgeBaseConfig(api_key="ragflow-secret") +def test_retrieval_settings_load_from_knowledge_search_tool_and_hide_secret(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(ragflow_tools, "get_app_config", lambda: _config()) - assert config.enabled is False - assert str(config.base_url).rstrip("/") == "http://localhost:9380" + config, error = ragflow_tools._settings_or_error() + + assert error is None + assert config is not None + assert str(config.base_url).rstrip("/") == "http://ragflow.test" assert config.page_size == 8 assert config.max_chars_per_chunk == 800 assert config.max_total_chars == 8000 @@ -328,22 +334,28 @@ def test_agent_tool_contracts_are_async_and_model_facing() -> None: assert "list_knowledge_bases" in ragflow_tools.knowledge_search_tool.description -@pytest.mark.parametrize("enabled", [False, True]) -def test_tool_assembly_gates_knowledge_group_with_feature_flag(enabled: bool) -> None: - config = SimpleNamespace( - tools=[ +@pytest.mark.parametrize("configured", [False, True]) +def test_tool_assembly_uses_normal_tool_presence_without_feature_flag(configured: bool) -> None: + tools = ( + [ ToolConfig( name="knowledge_search", group="knowledge", use="deerflow.community.ragflow.tools:knowledge_search_tool", + base_url="http://ragflow.test", + api_key="ragflow-secret", ), ToolConfig( name="list_knowledge_bases", group="knowledge", use="deerflow.community.ragflow.tools:list_knowledge_bases_tool", ), - ], - knowledge_base=SimpleNamespace(enabled=enabled), + ] + if configured + else [] + ) + config = SimpleNamespace( + tools=tools, sandbox=SimpleNamespace(use="example.remote:Sandbox"), skill_evolution=SimpleNamespace(enabled=False), models=[], @@ -353,5 +365,5 @@ def test_tool_assembly_gates_knowledge_group_with_feature_flag(enabled: bool) -> names = {tool.name for tool in get_available_tools(include_mcp=False, app_config=config)} - assert ("knowledge_search" in names) is enabled - assert ("list_knowledge_bases" in names) is enabled + assert ("knowledge_search" in names) is configured + assert ("list_knowledge_bases" in names) is configured diff --git a/config.example.yaml b/config.example.yaml index 2de8eacb007..97985660e45 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -658,29 +658,6 @@ models: # write_timeout: 60.0 # pool_timeout: 30.0 -# ============================================================================ -# Knowledge Base (RAGFlow) -# ============================================================================ -# Optional private-corpus retrieval backed by RAGFlow. DeerFlow stores no -# dataset metadata or retrieval state; RAGFlow remains the sole source of truth. -# The API key is tenant-scoped, so every DeerFlow user shares the same datasets. -# Set RAGFLOW_API_KEY in the environment, change api_key to $RAGFLOW_API_KEY, -# and enable this block to expose the two read-only Agent retrieval tools. -knowledge_base: - enabled: false - base_url: http://localhost:9380 # Docker: use a host/container-reachable URL - api_key: null # Recommended when enabled: $RAGFLOW_API_KEY - timeout: 30 - - # Retrieval defaults. Prefer explicit knowledge-base names: a cross-dataset - # search can fail when datasets use different embedding models. - page_size: 8 - similarity_threshold: 0.2 - vector_similarity_weight: 0.3 - top_k: 256 - max_chars_per_chunk: 800 - max_total_chars: 8000 - # ============================================================================ # Tool Groups Configuration # ============================================================================ @@ -700,14 +677,26 @@ tool_groups: # Configure available tools for the agent to use tools: - # RAGFlow knowledge retrieval (read-only). These tools are hidden unless - # knowledge_base.enabled is true. Dataset UUIDs are never shown to the model. - - name: knowledge_search - group: knowledge - use: deerflow.community.ragflow.tools:knowledge_search_tool - - name: list_knowledge_bases - group: knowledge - use: deerflow.community.ragflow.tools:list_knowledge_bases_tool + # RAGFlow knowledge retrieval (read-only). Uncomment both entries together. + # Connection and retrieval settings live on knowledge_search; the listing + # tool reuses them. Dataset UUIDs are never shown to the model. + # Prefer explicit knowledge-base names because cross-dataset retrieval can + # fail when datasets use different embedding models. + # - name: knowledge_search + # group: knowledge + # use: deerflow.community.ragflow.tools:knowledge_search_tool + # base_url: http://localhost:9380 # Docker: use a backend-reachable URL + # api_key: $RAGFLOW_API_KEY + # timeout: 30 + # page_size: 8 + # similarity_threshold: 0.2 + # vector_similarity_weight: 0.3 + # top_k: 256 + # max_chars_per_chunk: 800 + # max_total_chars: 8000 + # - name: list_knowledge_bases + # group: knowledge + # use: deerflow.community.ragflow.tools:list_knowledge_bases_tool # Web search tool (uses DuckDuckGo, no API key required) - name: web_search diff --git a/deploy/helm/deer-flow/values.yaml b/deploy/helm/deer-flow/values.yaml index 0f41b1e8d27..e63c96d3f55 100644 --- a/deploy/helm/deer-flow/values.yaml +++ b/deploy/helm/deer-flow/values.yaml @@ -246,20 +246,6 @@ config: | config_version: 36 log_level: info - # Optional tenant-shared, read-only RAGFlow retrieval. Add RAGFLOW_API_KEY to - # `secrets`, set api_key to $RAGFLOW_API_KEY, and enable when the Gateway can - # reach it. - knowledge_base: - enabled: false - base_url: http://ragflow:9380 - api_key: null - timeout: 30 - page_size: 8 - similarity_threshold: 0.2 - vector_similarity_weight: 0.3 - top_k: 256 - max_chars_per_chunk: 800 - max_total_chars: 8000 models: [] # Example (uncomment & set the matching secret in `secrets`): # - name: gpt-4 @@ -321,15 +307,27 @@ config: | - name: file:read - name: file:write - name: bash - - name: knowledge + # - name: knowledge # Enable with both RAGFlow tools below. tools: - - name: knowledge_search - group: knowledge - use: deerflow.community.ragflow.tools:knowledge_search_tool - - name: list_knowledge_bases - group: knowledge - use: deerflow.community.ragflow.tools:list_knowledge_bases_tool + # Optional tenant-shared, read-only RAGFlow retrieval. Put RAGFLOW_API_KEY + # in `secrets`; connection settings belong to knowledge_search and are + # reused by list_knowledge_bases. + # - name: knowledge_search + # group: knowledge + # use: deerflow.community.ragflow.tools:knowledge_search_tool + # base_url: http://ragflow:9380 + # api_key: $RAGFLOW_API_KEY + # timeout: 30 + # page_size: 8 + # similarity_threshold: 0.2 + # vector_similarity_weight: 0.3 + # top_k: 256 + # max_chars_per_chunk: 800 + # max_total_chars: 8000 + # - name: list_knowledge_bases + # group: knowledge + # use: deerflow.community.ragflow.tools:list_knowledge_bases_tool - name: web_search group: web use: deerflow.community.ddg_search.tools:web_search_tool From 919ab85ad76318d4dcfae731d728f794998fd54e Mon Sep 17 00:00:00 2001 From: zhangwei139623 <1552775457@qq.com> Date: Mon, 24 Aug 2026 17:52:10 +0800 Subject: [PATCH 05/14] fix(ragflow): bind retrieval to configured datasets --- backend/AGENTS.md | 17 +- backend/docs/CONFIGURATION.md | 34 +- backend/packages/harness/deerflow/AGENTS.md | 20 +- .../deerflow/community/ragflow/__init__.py | 0 .../deerflow/community/ragflow/client.py | 59 ++- .../deerflow/community/ragflow/formatting.py | 28 +- .../deerflow/community/ragflow/tools.py | 238 +++++++----- .../packages/harness/deerflow/tools/tools.py | 21 +- backend/tests/test_ragflow_client.py | 85 ++--- backend/tests/test_ragflow_tools.py | 355 +++++++++++------- config.example.yaml | 15 +- deploy/helm/deer-flow/values.yaml | 11 +- 12 files changed, 523 insertions(+), 360 deletions(-) create mode 100644 backend/packages/harness/deerflow/community/ragflow/__init__.py diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 1eebae2f0da..791027f6724 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -294,14 +294,17 @@ For models with `supports_vision: true`: ### RAGFlow Knowledge Retrieval The harness provides an opt-in, read-only RAGFlow integration under -`deerflow.community.ragflow`. The normal `tools:` list enables the two Agent -tools; RAGFlow connection and retrieval parameters live as extra fields on the -`knowledge_search` entry and are reused by `list_knowledge_bases`. The tools -list tenant-shared knowledge bases and retrieve compact cited chunks. RAGFlow -remains the sole source of truth: there are no DeerFlow ORM models, migrations, -or mirrored knowledge metadata. +`deerflow.community.ragflow`. The normal `tools:` list enables one +`knowledge_search(query)` Agent tool. Its entry owns the RAGFlow connection, +retrieval parameters, and an operator-controlled allowlist of exact dataset +names. The provider injects those names into the assembled model-visible tool +description, resolves them lazily through name-filtered RAGFlow requests, and +always retrieves with a non-empty `dataset_ids` list. Tenant-wide dataset +listing is not Agent-visible. RAGFlow remains the sole source of truth: there +are no DeerFlow ORM models, migrations, or mirrored knowledge metadata. The configured tenant API key must never appear in logs or model-visible tool -errors, and dataset UUIDs must not enter model context. This slice deliberately +errors, and RAGFlow dataset UUIDs must not enter model context. Provider-authored +model-visible text is English. This slice deliberately contains no Gateway management API, watcher, SSE endpoint, or frontend UI; knowledge-base writes remain in RAGFlow. Tests live in `tests/test_ragflow_client.py` and `tests/test_ragflow_tools.py`. diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index 2d8581b9ef8..15d24473573 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -220,11 +220,11 @@ models: ### RAGFlow Knowledge Retrieval -RAGFlow integration is disabled by default. It adds two read-only Agent tools: -`list_knowledge_bases` and `knowledge_search`. DeerFlow does not persist a copy -of dataset or document metadata; RAGFlow is the sole source of truth. The -configured API key is tenant-scoped, so all DeerFlow users share the same -knowledge bases. +RAGFlow integration is disabled by default. It adds one read-only Agent tool, +`knowledge_search`. DeerFlow does not persist a copy of dataset or document +metadata; RAGFlow is the sole source of truth. The configured API key is +tenant-scoped, while the operator-controlled `datasets` list restricts every +Agent on this deployment to the same named subset. ```yaml tool_groups: @@ -236,6 +236,9 @@ tools: use: deerflow.community.ragflow.tools:knowledge_search_tool base_url: http://localhost:9380 api_key: $RAGFLOW_API_KEY + datasets: + - HR Policies + - Engineering Handbook timeout: 30 page_size: 8 similarity_threshold: 0.2 @@ -243,17 +246,20 @@ tools: top_k: 256 max_chars_per_chunk: 800 max_total_chars: 8000 - - name: list_knowledge_bases - group: knowledge - use: deerflow.community.ragflow.tools:list_knowledge_bases_tool ``` -Both tools are opt-in through the normal `tools:` list. Connection and retrieval -settings belong to `knowledge_search`; `list_knowledge_bases` reads that same -configuration. Prefer passing explicit knowledge-base names to -`knowledge_search`: RAGFlow can reject a cross-dataset query when the selected -datasets use different embedding models. For Docker or Kubernetes, `base_url` -must be reachable from the Gateway container or Pod; `localhost` refers to that +The tool is opt-in through the normal `tools:` list. `datasets` must contain one +or more exact RAGFlow dataset names selected by the deployment operator. DeerFlow +does not validate their existence while loading configuration; on each search it +resolves the names with filtered RAGFlow requests and always sends the resulting +non-empty `dataset_ids` allowlist to retrieval. A deleted or renamed dataset +produces guidance to check `config.yaml`. Bound names are copied into the tool +description visible to the Agent, but tenant-wide listing is not exposed. + +Configure only datasets with compatible embedding models because RAGFlow can +reject cross-dataset retrieval when models differ. `base_url` must not contain +embedded username or password information. For Docker or Kubernetes, it must be +reachable from the Gateway container or Pod; `localhost` refers to that container or Pod, not the host machine. This integration is retrieval-only. Dataset creation, uploads, parsing, and diff --git a/backend/packages/harness/deerflow/AGENTS.md b/backend/packages/harness/deerflow/AGENTS.md index 9a45f4c869e..3e4d3e143fe 100644 --- a/backend/packages/harness/deerflow/AGENTS.md +++ b/backend/packages/harness/deerflow/AGENTS.md @@ -36,15 +36,19 @@ drift. ### RAGFlow Knowledge Retrieval (`community/ragflow/`) -The optional `knowledge` tool group exposes only `list_knowledge_bases` and -`knowledge_search`. Both call RAGFlow directly through the async `httpx` client; -they never persist dataset metadata, expose dataset UUIDs to the model, or -provide write operations. Both are enabled through the normal `tools:` list. -Provider connection and retrieval extras belong to `knowledge_search` and are -reused by `list_knowledge_bases`; there is no top-level provider configuration. +The optional `knowledge` tool group exposes one `knowledge_search(query)` tool. +Its normal `tools:` entry owns the provider connection, retrieval extras, and a +required operator allowlist of exact dataset names; there is no top-level +provider configuration or tenant-wide listing tool. The provider uses the +synchronous `configure_for_tool_entry` assembly hook to copy bound names into +the model-visible tool description without network IO. Each invocation resolves +those names through filtered RAGFlow requests and sends a non-empty +`dataset_ids` list to retrieval. It never persists dataset metadata, exposes +RAGFlow dataset UUIDs to the model, or provides write operations. Retrieval output is bounded at both the individual chunk and full-response -levels, and every error path must redact the configured tenant API key before -logging or returning model-visible text. +levels. Provider-authored model-visible text is English. Every path must redact +the configured tenant API key; dataset-UUID redaction is error-only so normal +content does not corrupt legitimate checksums, trace IDs, or UUID fields. The client intentionally exposes only dataset listing and retrieval. Dataset creation, uploads, parsing, deletion, Gateway management routes, SSE, and frontend UI are outside this retrieval-only slice. diff --git a/backend/packages/harness/deerflow/community/ragflow/__init__.py b/backend/packages/harness/deerflow/community/ragflow/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/backend/packages/harness/deerflow/community/ragflow/client.py b/backend/packages/harness/deerflow/community/ragflow/client.py index 00070bfbcd9..0e53f62ba0d 100644 --- a/backend/packages/harness/deerflow/community/ragflow/client.py +++ b/backend/packages/harness/deerflow/community/ragflow/client.py @@ -78,7 +78,7 @@ async def _request( async with httpx.AsyncClient(**client_kwargs) as client: response = await client.request(method, path, params=params, json=json) except httpx.TimeoutException: - raise RAGFlowConnectionError(f"请求超时({self.timeout:g} 秒)") from None + raise RAGFlowConnectionError(f"RAGFlow request timed out after {self.timeout:g} seconds.") from None except httpx.RequestError as exc: detail = self._redact(exc) raise RAGFlowConnectionError(f"{type(exc).__name__}: {detail}") from None @@ -91,72 +91,57 @@ async def _request( if isinstance(error_payload, dict) and error_payload.get("code") not in (None, 0): message = self._redact(error_payload.get("message") or f"RAGFlow API error (HTTP {response.status_code})") raise RAGFlowAPIError(message, code=error_payload.get("code")) - raise RAGFlowProtocolError(f"RAGFlow 请求失败(HTTP {response.status_code})") + raise RAGFlowProtocolError(f"RAGFlow request failed (HTTP {response.status_code}).") try: payload = response.json() except ValueError: - raise RAGFlowProtocolError("RAGFlow 返回了无效 JSON") from None + raise RAGFlowProtocolError("RAGFlow returned invalid JSON.") from None if not isinstance(payload, dict): - raise RAGFlowProtocolError("RAGFlow 返回了非对象 JSON") + raise RAGFlowProtocolError("RAGFlow returned a non-object JSON payload.") code = payload.get("code") if code != 0: - message = self._redact(payload.get("message") or "RAGFlow 请求失败") + message = self._redact(payload.get("message") or "RAGFlow request failed.") raise RAGFlowAPIError(message, code=code) return payload - async def list_datasets(self) -> list[dict[str, Any]]: - """List every RAGFlow dataset accessible to the configured tenant key.""" - page = 1 - page_size = 100 - datasets: list[dict[str, Any]] = [] - - while True: - params: dict[str, object] = {"page": page, "page_size": page_size} - payload = await self._request( - "GET", - "/datasets", - params=params, - ) - data = payload.get("data") - if not isinstance(data, list): - raise RAGFlowProtocolError("RAGFlow 返回了无效的知识库列表") - batch = [item for item in data if isinstance(item, dict)] - datasets.extend(batch) - - total = payload.get("total_datasets", payload.get("total")) - if isinstance(total, int) and len(datasets) >= total: - break - if len(data) < page_size: - break - page += 1 - - return datasets + async def list_datasets(self, *, name: str) -> list[dict[str, Any]]: + """Resolve a configured dataset name without enumerating the tenant catalog.""" + if not name.strip(): + raise ValueError("name must not be empty") + + payload = await self._request("GET", "/datasets", params={"name": name}) + data = payload.get("data") + if not isinstance(data, list): + raise RAGFlowProtocolError("RAGFlow returned an invalid dataset list.") + return [item for item in data if isinstance(item, dict)] async def retrieve( self, query: str, *, - dataset_ids: list[str] | None = None, + dataset_ids: list[str], page_size: int = 8, similarity_threshold: float = 0.2, vector_similarity_weight: float = 0.3, top_k: int = 256, ) -> dict[str, Any]: - """Retrieve chunks, optionally scoped to specific dataset UUIDs.""" + """Retrieve chunks from an explicit, non-empty dataset allowlist.""" + if not dataset_ids or not all(isinstance(dataset_id, str) and dataset_id.strip() for dataset_id in dataset_ids): + raise ValueError("dataset_ids must contain at least one dataset ID") + request_body: dict[str, object] = { "question": query, + "dataset_ids": dataset_ids, "page_size": page_size, "similarity_threshold": similarity_threshold, "vector_similarity_weight": vector_similarity_weight, "top_k": top_k, } - if dataset_ids is not None: - request_body["dataset_ids"] = dataset_ids payload = await self._request("POST", "/retrieval", json=request_body) data = payload.get("data") if not isinstance(data, dict): - raise RAGFlowProtocolError("RAGFlow 返回了无效的检索结果") + raise RAGFlowProtocolError("RAGFlow returned an invalid retrieval result.") return data diff --git a/backend/packages/harness/deerflow/community/ragflow/formatting.py b/backend/packages/harness/deerflow/community/ragflow/formatting.py index a5d3035d336..0905fdc84fa 100644 --- a/backend/packages/harness/deerflow/community/ragflow/formatting.py +++ b/backend/packages/harness/deerflow/community/ragflow/formatting.py @@ -40,33 +40,34 @@ def format_retrieval_result( ) -> str: """Format one RAGFlow retrieval response into compact cited text. - RAGFlow v0.26 documentation calls the dataset field ``kb_id`` while the - deployed API may return ``dataset_id``. Both are accepted, but neither UUID - is ever emitted to the model. + RAGFlow normalizes response chunk fields before returning them from the + REST endpoint (for example, ``kb_id`` becomes ``dataset_id``). Only those + public response field names are consumed, and dataset IDs are mapped back + to the operator-configured names before anything reaches the model. """ raw_chunks = result.get("chunks") if not isinstance(raw_chunks, list): raw_chunks = [] chunks = [chunk for chunk in raw_chunks if isinstance(chunk, Mapping)] if not chunks: - return "未检索到相关内容。" + return "No relevant content found." aggregates = _document_aggregates(result.get("doc_aggs")) document_names_by_id = {str(item["doc_id"]): str(item["doc_name"]) for item in aggregates if item.get("doc_id") and item.get("doc_name")} entries: list[str] = [] for index, chunk in enumerate(chunks, start=1): - dataset_id = chunk.get("dataset_id") or chunk.get("kb_id") - dataset_name = dataset_names_by_id.get(str(dataset_id), "未知知识库") + dataset_id = chunk.get("dataset_id") + dataset_name = dataset_names_by_id.get(str(dataset_id), "Unknown dataset") - document_id = chunk.get("document_id") or chunk.get("doc_id") - document_name = chunk.get("document_keyword") or chunk.get("document_name") + document_id = chunk.get("document_id") + document_name = chunk.get("document_keyword") if not document_name and document_id: document_name = document_names_by_id.get(str(document_id)) - document_name = str(document_name or "未知文档") + document_name = str(document_name or "Unknown document") similarity = _score(chunk.get("similarity")) - score_suffix = f" (相关度 {similarity:.2f})" if similarity is not None else "" + score_suffix = f" (score {similarity:.2f})" if similarity is not None else "" content = str(chunk.get("content") or "").strip() content = _truncate(content, max_chars_per_chunk) entries.append(f"[{index}] {dataset_name} / {document_name}{score_suffix}\n{content}") @@ -79,12 +80,13 @@ def format_retrieval_result( continue count = item.get("count") count_text = str(count) if isinstance(count, int) and not isinstance(count, bool) else "?" - summaries.append(f"{name} ({count_text} 段)") + unit = "chunk" if count == 1 else "chunks" + summaries.append(f"{name} ({count_text} {unit})") if summaries: - entries.append(f"命中文档:{', '.join(summaries)}") + entries.append(f"Matched documents: {', '.join(summaries)}") formatted = "\n\n".join(entries) - truncation_marker = "…(响应已截断)" + truncation_marker = "… (response truncated)" if len(formatted) <= max_total_chars: return formatted if max_total_chars <= len(truncation_marker): diff --git a/backend/packages/harness/deerflow/community/ragflow/tools.py b/backend/packages/harness/deerflow/community/ragflow/tools.py index 023cd65d846..3de6c3f637b 100644 --- a/backend/packages/harness/deerflow/community/ragflow/tools.py +++ b/backend/packages/harness/deerflow/community/ragflow/tools.py @@ -1,13 +1,14 @@ -"""Read-only Agent tools for RAGFlow knowledge retrieval.""" +"""Read-only Agent tool for operator-scoped RAGFlow knowledge retrieval.""" from __future__ import annotations +import asyncio import logging import re from collections.abc import Mapping -from langchain.tools import tool -from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, SecretStr, ValidationError +from langchain_core.tools import StructuredTool +from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, SecretStr, ValidationError, field_validator from deerflow.config import get_app_config @@ -25,6 +26,7 @@ class _RAGFlowRetrievalSettings(BaseModel): model_config = ConfigDict(validate_default=True) + datasets: list[str] = Field(min_length=1, max_length=100) base_url: AnyHttpUrl = Field(default="http://localhost:9380") api_key: SecretStr | None = Field(default=None) timeout: float = Field(default=30, gt=0, le=600) @@ -35,6 +37,29 @@ class _RAGFlowRetrievalSettings(BaseModel): max_chars_per_chunk: int = Field(default=800, ge=1, le=100_000) max_total_chars: int = Field(default=8000, ge=1, le=1_000_000) + @field_validator("datasets") + @classmethod + def _normalize_dataset_names(cls, value: list[str]) -> list[str]: + normalized: list[str] = [] + seen: set[str] = set() + for name in value: + clean_name = name.strip() + if not clean_name or len(clean_name) > 256: + raise ValueError("dataset names must contain between 1 and 256 characters") + if clean_name not in seen: + normalized.append(clean_name) + seen.add(clean_name) + if not normalized: + raise ValueError("at least one dataset name is required") + return normalized + + @field_validator("base_url") + @classmethod + def _reject_url_userinfo(cls, value: AnyHttpUrl) -> AnyHttpUrl: + if value.username is not None or value.password is not None: + raise ValueError("base_url must not contain username or password information") + return value + def _api_key(settings: _RAGFlowRetrievalSettings) -> str | None: value = settings.api_key @@ -45,34 +70,43 @@ def _api_key(settings: _RAGFlowRetrievalSettings) -> str | None: return None -def _redact(value: object, api_key: str | None) -> str: +def _redact_api_key(value: object, api_key: str | None) -> str: text = str(value) if api_key: text = text.replace(api_key, "[REDACTED]") - return _RAGFLOW_UUID_PATTERN.sub("[DATASET_ID]", text) + return text + + +def _redact_error(value: object, api_key: str | None) -> str: + """Redact provider credentials and opaque dataset IDs on error paths.""" + return _RAGFLOW_UUID_PATTERN.sub("[DATASET_ID]", _redact_api_key(value, api_key)) + + +def _settings_from_extra(extra: Mapping[str, object]) -> _RAGFlowRetrievalSettings: + return _RAGFlowRetrievalSettings.model_validate(dict(extra)) def _settings_or_error() -> tuple[_RAGFlowRetrievalSettings | None, str | None]: tool_config = get_app_config().get_tool_config("knowledge_search") if tool_config is None: - return None, "Error: 未配置 knowledge_search;请在 config.yaml 的 tools 列表中添加该工具及其 RAGFlow 连接参数。" + return None, "Error: knowledge_search is not configured; add its RAGFlow settings to the tools list in config.yaml." try: - settings = _RAGFlowRetrievalSettings.model_validate(tool_config.model_extra or {}) + settings = _settings_from_extra(tool_config.model_extra or {}) except ValidationError: logger.warning("RAGFlow knowledge_search tool configuration is invalid") - return None, "Error: knowledge_search 的 RAGFlow 配置无效,请检查 config.yaml。" + return None, "Error: Invalid RAGFlow settings for knowledge_search; check config.yaml." if not _api_key(settings): if "api_key" not in _warned: _warned.add("api_key") - logger.warning("RAGFlow API Key 未配置;请设置 tools 中 knowledge_search.api_key,建议使用 $RAGFLOW_API_KEY 环境变量引用。") - return None, "Error: 未配置 RAGFlow API Key,请设置 tools 中的 knowledge_search.api_key(建议使用 $RAGFLOW_API_KEY)。" + logger.warning("RAGFlow API key is not configured; set knowledge_search.api_key in config.yaml, preferably via $RAGFLOW_API_KEY.") + return None, "Error: RAGFlow API key is not configured; set knowledge_search.api_key in config.yaml (prefer $RAGFLOW_API_KEY)." return settings, None def _build_client(settings: _RAGFlowRetrievalSettings) -> RAGFlowClient: api_key = _api_key(settings) if api_key is None: # Guarded by _settings_or_error; keeps this helper total. - raise ValueError("RAGFlow API Key is missing") + raise ValueError("RAGFlow API key is missing") return RAGFlowClient( base_url=str(settings.base_url).rstrip("/"), api_key=api_key, @@ -82,127 +116,155 @@ def _build_client(settings: _RAGFlowRetrievalSettings) -> RAGFlowClient: def _tool_error(exc: Exception, settings: _RAGFlowRetrievalSettings) -> str: key = _api_key(settings) - safe_detail = _redact(exc, key) - base_url = _redact(str(settings.base_url).rstrip("/"), key) + safe_detail = _redact_error(exc, key) + base_url = _redact_error(str(settings.base_url).rstrip("/"), key) if isinstance(exc, RAGFlowAPIError): logger.warning("RAGFlow API rejected a read-only tool request (code=%s)", exc.code) return f"Error: {safe_detail}" if isinstance(exc, RAGFlowConnectionError): logger.warning("RAGFlow connection failed for %s (%s)", base_url, type(exc).__name__) - return f"Error: 无法连接 RAGFlow ({base_url}): {safe_detail}" + return f"Error: Unable to connect to RAGFlow ({base_url}): {safe_detail}" if isinstance(exc, RAGFlowProtocolError): logger.warning("RAGFlow returned an invalid response for a read-only tool request (%s)", type(exc).__name__) - return f"Error: RAGFlow 请求失败: {safe_detail}" + return f"Error: RAGFlow request failed: {safe_detail}" logger.warning("Unexpected RAGFlow read-only tool failure (%s)", type(exc).__name__) - return "Error: RAGFlow 检索发生未知错误,请稍后重试。" + return "Error: An unexpected RAGFlow retrieval error occurred; try again later." -def _valid_datasets(datasets: list[dict]) -> list[tuple[str, str, Mapping[str, object]]]: - valid: list[tuple[str, str, Mapping[str, object]]] = [] +def _exact_dataset_matches(datasets: list[dict], bound_name: str) -> list[tuple[str, str]]: + matches: list[tuple[str, str]] = [] + seen_ids: set[str] = set() for dataset in datasets: dataset_id = dataset.get("id") name = dataset.get("name") - if not isinstance(dataset_id, str) or not dataset_id or not isinstance(name, str) or not name.strip(): + if not isinstance(dataset_id, str) or not dataset_id or name != bound_name or dataset_id in seen_ids: continue - valid.append((dataset_id, name.strip(), dataset)) - return valid + matches.append((dataset_id, bound_name)) + seen_ids.add(dataset_id) + return matches -async def list_knowledge_bases() -> str: - """List accessible RAGFlow knowledge bases without exposing their UUIDs.""" - settings, error = _settings_or_error() - if settings is None: - return error or "Error: 知识库配置无效。" +def _missing_dataset_error(names: list[str], api_key: str | None) -> str: + if len(names) == 1: + message = f"Error: Configured RAGFlow dataset was not found: {names[0]}. It may have been deleted or renamed; check knowledge_search.datasets in config.yaml." + else: + message = f"Error: Configured RAGFlow datasets were not found: {', '.join(names)}. They may have been deleted or renamed; check knowledge_search.datasets in config.yaml." + return _redact_error(message, api_key) - try: - datasets = _valid_datasets(await _build_client(settings).list_datasets()) - except Exception as exc: - return _tool_error(exc, settings) - if not datasets: - return "当前没有可用的知识库。" +def _ambiguous_dataset_error(names: list[str], api_key: str | None) -> str: + label = "name is" if len(names) == 1 else "names are" + return _redact_error( + f"Error: Configured RAGFlow dataset {label} ambiguous: {', '.join(names)}. Check knowledge_search.datasets in config.yaml.", + api_key, + ) - lines = ["可用知识库:"] - for _, name, dataset in datasets: - description = dataset.get("description") - description_text = f" — {description.strip()}" if isinstance(description, str) and description.strip() else "" - document_count = dataset.get("document_count") - count_text = f"({document_count} 个文档)" if isinstance(document_count, int) and not isinstance(document_count, bool) else "" - lines.append(f"- {name}{description_text}{count_text}") - return _redact("\n".join(lines), _api_key(settings)) + +async def _resolve_configured_datasets( + client: RAGFlowClient, + settings: _RAGFlowRetrievalSettings, +) -> tuple[list[str] | None, dict[str, str] | None, str | None]: + batches = await asyncio.gather(*(client.list_datasets(name=name) for name in settings.datasets)) + + dataset_ids: list[str] = [] + names_by_id: dict[str, str] = {} + missing: list[str] = [] + ambiguous: list[str] = [] + for bound_name, datasets in zip(settings.datasets, batches, strict=True): + matches = _exact_dataset_matches(datasets, bound_name) + if not matches: + missing.append(bound_name) + continue + if len(matches) > 1: + ambiguous.append(bound_name) + continue + dataset_id, dataset_name = matches[0] + dataset_ids.append(dataset_id) + names_by_id[dataset_id] = dataset_name + + key = _api_key(settings) + if missing: + return None, None, _missing_dataset_error(missing, key) + if ambiguous: + return None, None, _ambiguous_dataset_error(ambiguous, key) + return dataset_ids, names_by_id, None -async def knowledge_search(query: str, knowledge_bases: list[str] | None = None) -> str: - """Search RAGFlow and return compact chunks with stable citation markers.""" +async def knowledge_search(query: str) -> str: + """Search the operator-configured RAGFlow dataset allowlist.""" query = query.strip() if not query: - return "Error: 查询内容不能为空。" - if knowledge_bases is not None and not any(isinstance(name, str) and name.strip() for name in knowledge_bases): - return "Error: knowledge_bases 为空;请至少指定一个知识库,或省略该参数进行全库兜底检索。" + return "Error: query must not be empty." settings, error = _settings_or_error() if settings is None: - return error or "Error: 知识库配置无效。" + return error or "Error: Invalid RAGFlow settings for knowledge_search; check config.yaml." client = _build_client(settings) try: - datasets = _valid_datasets(await client.list_datasets()) - names_by_id = {dataset_id: name for dataset_id, name, _ in datasets} - - retrieve_options: dict[str, object] = { - "page_size": settings.page_size, - "similarity_threshold": settings.similarity_threshold, - "vector_similarity_weight": settings.vector_similarity_weight, - "top_k": settings.top_k, - } - if knowledge_bases is not None: - ids_by_casefolded_name = {name.casefold(): dataset_id for dataset_id, name, _ in datasets} - requested_names = [name.strip() for name in knowledge_bases if isinstance(name, str) and name.strip()] - unknown_names = [name for name in requested_names if name.casefold() not in ids_by_casefolded_name] - if unknown_names: - available = ", ".join(name for _, name, _ in datasets) or "(无)" - return _redact( - f"Error: 未知知识库:{', '.join(unknown_names)}。当前可用知识库:{available}。", - _api_key(settings), - ) - - dataset_ids: list[str] = [] - for name in requested_names: - dataset_id = ids_by_casefolded_name[name.casefold()] - if dataset_id not in dataset_ids: - dataset_ids.append(dataset_id) - retrieve_options["dataset_ids"] = dataset_ids - - result = await client.retrieve(query, **retrieve_options) + dataset_ids, names_by_id, resolution_error = await _resolve_configured_datasets(client, settings) + if resolution_error is not None: + return resolution_error + if not dataset_ids or names_by_id is None: # Defensive; settings require at least one binding. + return "Error: No configured RAGFlow datasets could be resolved; check knowledge_search.datasets in config.yaml." + + result = await client.retrieve( + query, + dataset_ids=dataset_ids, + page_size=settings.page_size, + similarity_threshold=settings.similarity_threshold, + vector_similarity_weight=settings.vector_similarity_weight, + top_k=settings.top_k, + ) formatted = format_retrieval_result( result, dataset_names_by_id=names_by_id, max_chars_per_chunk=settings.max_chars_per_chunk, max_total_chars=settings.max_total_chars, ) - return _redact(formatted, _api_key(settings)) + # API-key redaction remains mandatory on success. UUID redaction is + # deliberately error-only so valid checksums and trace IDs survive. + return _redact_api_key(formatted, _api_key(settings)) except Exception as exc: return _tool_error(exc, settings) -@tool("list_knowledge_bases", parse_docstring=True) -async def list_knowledge_bases_tool() -> str: - """List all private knowledge bases available to this DeerFlow deployment.""" - return await list_knowledge_bases() +def _tool_description(dataset_names: list[str], api_key: str | None) -> str: + base = "Search the operator-approved RAGFlow datasets and return compact, citation-numbered source chunks." + if not dataset_names: + return f"{base} Dataset access is controlled by knowledge_search.datasets in config.yaml." + visible_names = [_redact_api_key(name, api_key) for name in dataset_names] + return f"{base} This tool is restricted to these configured datasets: {', '.join(visible_names)}." + + +class _ConfiguredKnowledgeSearchTool(StructuredTool): + """Structured tool whose model description is derived from its config entry.""" + def configure_for_tool_entry(self, extra: Mapping[str, object]) -> StructuredTool: + configured = self.model_copy(deep=False) + try: + settings = _settings_from_extra(extra) + except ValidationError: + configured.description = _tool_description([], None) + else: + configured.description = _tool_description(settings.datasets, _api_key(settings)) + return configured -@tool("knowledge_search", parse_docstring=True) -async def knowledge_search_tool(query: str, knowledge_bases: list[str] | None = None) -> str: - """Search private RAGFlow knowledge bases for relevant source chunks. - If you are unsure which knowledge bases exist, call - ``list_knowledge_bases`` first. Prefer explicit knowledge-base names because - searching all bases can fail when they use different embedding models. +async def _knowledge_search_entrypoint(query: str) -> str: + """Search the RAGFlow datasets selected by the deployment operator. Args: - query: Specific question or search terms to retrieve from private documents. - knowledge_bases: Knowledge-base names to search. Omit only as a fallback search across all bases. + query: Specific question or search terms to retrieve from the configured private documents. """ - return await knowledge_search(query, knowledge_bases) + return await knowledge_search(query) + + +knowledge_search_tool = _ConfiguredKnowledgeSearchTool.from_function( + coroutine=_knowledge_search_entrypoint, + name="knowledge_search", + description=_tool_description([], None), + parse_docstring=True, +) diff --git a/backend/packages/harness/deerflow/tools/tools.py b/backend/packages/harness/deerflow/tools/tools.py index 77e9bed5779..abdbe763b66 100644 --- a/backend/packages/harness/deerflow/tools/tools.py +++ b/backend/packages/harness/deerflow/tools/tools.py @@ -56,6 +56,22 @@ def _ensure_sync_invocable_tool(tool: BaseTool) -> BaseTool: return tool +def _configure_tool_for_entry(tool: BaseTool, tool_config: object) -> BaseTool: + """Let a provider derive a per-entry tool copy from local config only. + + Providers use this optional hook for model-visible metadata that depends on + their own tool-entry fields. The hook is synchronous by design, preventing + configuration loading from turning into external discovery or validation. + """ + configure = getattr(tool, "configure_for_tool_entry", None) + if not callable(configure): + return tool + configured = configure(getattr(tool_config, "model_extra", None) or {}) + if not isinstance(configured, BaseTool): + raise TypeError(f"Tool provider {getattr(tool_config, 'use', tool.name)} returned a non-BaseTool configured value") + return configured + + def get_available_tools( groups: list[str] | None = None, include_mcp: bool = True, @@ -89,7 +105,10 @@ def get_available_tools( if not is_host_bash_allowed(config): tool_configs = [tool for tool in tool_configs if not _is_host_bash_tool(tool)] - loaded_tools_raw = [(cfg, resolve_variable(cfg.use, BaseTool)) for cfg in tool_configs] + loaded_tools_raw = [] + for cfg in tool_configs: + loaded = resolve_variable(cfg.use, BaseTool) + loaded_tools_raw.append((cfg, _configure_tool_for_entry(loaded, cfg))) # Warn when the config ``name`` field and the tool object's ``.name`` # attribute diverge — this mismatch is the root cause of issue #1803 where diff --git a/backend/tests/test_ragflow_client.py b/backend/tests/test_ragflow_client.py index a404707dad9..2233266db68 100644 --- a/backend/tests/test_ragflow_client.py +++ b/backend/tests/test_ragflow_client.py @@ -12,12 +12,22 @@ @pytest.mark.anyio -async def test_list_datasets_builds_authenticated_request() -> None: +async def test_list_datasets_filters_by_bound_name_in_one_request() -> None: + requests: list[httpx.Request] = [] + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) assert request.method == "GET" - assert request.url == httpx.URL("http://ragflow.test/api/v1/datasets?page=1&page_size=100") + assert request.url == httpx.URL("http://ragflow.test/api/v1/datasets?name=HR+Policies") assert request.headers["Authorization"] == "Bearer ragflow-secret" - return httpx.Response(200, json={"code": 0, "data": [{"id": "dataset-1", "name": "Policies"}]}) + return httpx.Response( + 200, + json={ + "code": 0, + "data": [{"id": "dataset-1", "name": "HR Policies"}], + "total_datasets": 101, + }, + ) client = RAGFlowClient( base_url="http://ragflow.test/", @@ -26,36 +36,12 @@ async def handler(request: httpx.Request) -> httpx.Response: transport=httpx.MockTransport(handler), ) - assert await client.list_datasets() == [{"id": "dataset-1", "name": "Policies"}] + assert await client.list_datasets(name="HR Policies") == [{"id": "dataset-1", "name": "HR Policies"}] + assert len(requests) == 1 @pytest.mark.anyio -async def test_list_datasets_follows_pagination_until_total_is_reached() -> None: - requested_pages: list[int] = [] - - async def handler(request: httpx.Request) -> httpx.Response: - page = int(request.url.params["page"]) - requested_pages.append(page) - if page == 1: - data = [{"id": f"dataset-{index}", "name": f"Dataset {index}"} for index in range(100)] - else: - data = [{"id": "dataset-100", "name": "Dataset 100"}] - return httpx.Response(200, json={"code": 0, "data": data, "total_datasets": 101}) - - client = RAGFlowClient( - base_url="http://ragflow.test", - api_key="ragflow-secret", - transport=httpx.MockTransport(handler), - ) - - datasets = await client.list_datasets() - - assert requested_pages == [1, 2] - assert len(datasets) == 101 - - -@pytest.mark.anyio -async def test_retrieve_builds_expected_payload() -> None: +async def test_retrieve_always_sends_nonempty_dataset_ids() -> None: async def handler(request: httpx.Request) -> httpx.Response: assert request.method == "POST" assert request.url == httpx.URL("http://ragflow.test/api/v1/retrieval") @@ -88,11 +74,13 @@ async def handler(request: httpx.Request) -> httpx.Response: @pytest.mark.anyio -async def test_retrieve_omits_dataset_ids_when_unspecified() -> None: +async def test_retrieve_rejects_empty_dataset_ids_before_request() -> None: + called = False + async def handler(request: httpx.Request) -> httpx.Response: - payload = json.loads(request.content) - assert "dataset_ids" not in payload - return httpx.Response(200, json={"code": 0, "data": {"chunks": []}}) + nonlocal called + called = True + return httpx.Response(500) client = RAGFlowClient( base_url="http://ragflow.test", @@ -100,7 +88,10 @@ async def handler(request: httpx.Request) -> httpx.Response: transport=httpx.MockTransport(handler), ) - await client.retrieve("fallback search", dataset_ids=None) + with pytest.raises(ValueError, match="dataset_ids must contain at least one dataset ID"): + await client.retrieve("fallback search", dataset_ids=[]) + + assert called is False @pytest.mark.anyio @@ -118,7 +109,7 @@ async def handler(request: httpx.Request) -> httpx.Response: ) with pytest.raises(RAGFlowAPIError) as exc_info: - await client.list_datasets() + await client.list_datasets(name="HR Policies") assert exc_info.value.code == 102 assert "invalid credential" in str(exc_info.value) @@ -127,7 +118,7 @@ async def handler(request: httpx.Request) -> httpx.Response: @pytest.mark.anyio -async def test_timeout_is_normalized_without_leaking_api_key(caplog: pytest.LogCaptureFixture) -> None: +async def test_timeout_is_english_and_does_not_leak_api_key(caplog: pytest.LogCaptureFixture) -> None: async def handler(request: httpx.Request) -> httpx.Response: raise httpx.ReadTimeout("timed out with ragflow-secret", request=request) @@ -139,11 +130,11 @@ async def handler(request: httpx.Request) -> httpx.Response: ) with pytest.raises(RAGFlowConnectionError) as exc_info: - await client.list_datasets() + await client.list_datasets(name="HR Policies") + assert str(exc_info.value) == "RAGFlow request timed out after 2 seconds." assert "ragflow-secret" not in str(exc_info.value) assert "ragflow-secret" not in caplog.text - assert "超时" in str(exc_info.value) @pytest.mark.anyio @@ -158,14 +149,14 @@ async def handler(request: httpx.Request) -> httpx.Response: ) with pytest.raises(RAGFlowProtocolError) as exc_info: - await client.list_datasets() + await client.list_datasets(name="HR Policies") - assert "HTTP 401" in str(exc_info.value) + assert str(exc_info.value) == "RAGFlow request failed (HTTP 401)." assert "ragflow-secret" not in str(exc_info.value) @pytest.mark.anyio -async def test_invalid_json_response_is_normalized() -> None: +async def test_invalid_json_response_is_normalized_in_english() -> None: async def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, text="not-json") @@ -175,12 +166,12 @@ async def handler(request: httpx.Request) -> httpx.Response: transport=httpx.MockTransport(handler), ) - with pytest.raises(RAGFlowProtocolError, match="JSON"): - await client.list_datasets() + with pytest.raises(RAGFlowProtocolError, match="RAGFlow returned invalid JSON"): + await client.list_datasets(name="HR Policies") @pytest.mark.anyio -async def test_list_datasets_rejects_unexpected_data_shape() -> None: +async def test_list_datasets_rejects_unexpected_data_shape_in_english() -> None: async def handler(request: httpx.Request) -> httpx.Response: return httpx.Response(200, json={"code": 0, "data": {"id": "not-a-list"}}) @@ -190,5 +181,5 @@ async def handler(request: httpx.Request) -> httpx.Response: transport=httpx.MockTransport(handler), ) - with pytest.raises(RAGFlowProtocolError, match="知识库列表"): - await client.list_datasets() + with pytest.raises(RAGFlowProtocolError, match="invalid dataset list"): + await client.list_datasets(name="HR Policies") diff --git a/backend/tests/test_ragflow_tools.py b/backend/tests/test_ragflow_tools.py index 2ab3c8f5a6f..76190d26950 100644 --- a/backend/tests/test_ragflow_tools.py +++ b/backend/tests/test_ragflow_tools.py @@ -1,4 +1,6 @@ import logging +from collections.abc import Mapping +from pathlib import Path from types import SimpleNamespace import pytest @@ -11,16 +13,24 @@ class FakeRAGFlowClient: - def __init__(self, *, datasets: list[dict] | None = None, retrieval: dict | None = None, error: Exception | None = None) -> None: - self.datasets = datasets or [] + def __init__( + self, + *, + datasets_by_name: Mapping[str, list[dict]] | None = None, + retrieval: dict | None = None, + error: Exception | None = None, + ) -> None: + self.datasets_by_name = dict(datasets_by_name or {}) self.retrieval = retrieval or {"chunks": [], "doc_aggs": [], "total": 0} self.error = error + self.list_calls: list[str] = [] self.retrieve_calls: list[tuple[str, dict]] = [] - async def list_datasets(self) -> list[dict]: + async def list_datasets(self, *, name: str) -> list[dict]: if self.error is not None: raise self.error - return self.datasets + self.list_calls.append(name) + return self.datasets_by_name.get(name, []) async def retrieve(self, query: str, **kwargs: object) -> dict: if self.error is not None: @@ -39,20 +49,26 @@ def _config( configured: bool = True, api_key: str | None = "ragflow-secret", base_url: str = "http://ragflow.test", + datasets: list[str] | None = None, ) -> SimpleNamespace: + extra: dict[str, object] = { + "base_url": base_url, + "api_key": api_key, + "timeout": 30, + "page_size": 8, + "similarity_threshold": 0.2, + "vector_similarity_weight": 0.3, + "top_k": 256, + "max_chars_per_chunk": 800, + "max_total_chars": 8000, + } + if datasets is not None: + extra["datasets"] = datasets search_config = ToolConfig( name="knowledge_search", group="knowledge", use="deerflow.community.ragflow.tools:knowledge_search_tool", - base_url=base_url, - api_key=api_key, - timeout=30, - page_size=8, - similarity_threshold=0.2, - vector_similarity_weight=0.3, - top_k=256, - max_chars_per_chunk=800, - max_total_chars=8000, + **extra, ) return SimpleNamespace( get_tool_config=lambda name: search_config if configured and name == "knowledge_search" else None, @@ -60,33 +76,17 @@ def _config( def _install(monkeypatch: pytest.MonkeyPatch, fake: FakeRAGFlowClient, *, config: SimpleNamespace | None = None) -> None: - monkeypatch.setattr(ragflow_tools, "get_app_config", lambda: config or _config()) + monkeypatch.setattr(ragflow_tools, "get_app_config", lambda: config or _config(datasets=["HR Policies"])) monkeypatch.setattr(ragflow_tools, "_build_client", lambda settings: fake) @pytest.mark.anyio -async def test_list_knowledge_bases_returns_names_without_uuids(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_knowledge_search_resolves_configured_names_and_always_passes_ids(monkeypatch: pytest.MonkeyPatch) -> None: fake = FakeRAGFlowClient( - datasets=[ - {"id": "dataset-secret-1", "name": "HR Policies", "description": "Employee handbook", "document_count": 3}, - {"id": "dataset-secret-2", "name": "Engineering", "description": "", "document_count": 7}, - ] - ) - _install(monkeypatch, fake) - - result = await ragflow_tools.list_knowledge_bases() - - assert "HR Policies" in result - assert "Employee handbook" in result - assert "3 个文档" in result - assert "Engineering" in result - assert "dataset-secret" not in result - - -@pytest.mark.anyio -async def test_knowledge_search_resolves_names_to_ids_and_formats_citations(monkeypatch: pytest.MonkeyPatch) -> None: - fake = FakeRAGFlowClient( - datasets=[{"id": "dataset-1", "name": "HR Policies", "description": "", "document_count": 1}], + datasets_by_name={ + "HR Policies": [{"id": "dataset-1", "name": "HR Policies"}], + "Engineering": [{"id": "dataset-2", "name": "Engineering"}], + }, retrieval={ "chunks": [ { @@ -101,15 +101,16 @@ async def test_knowledge_search_resolves_names_to_ids_and_formats_citations(monk "total": 1, }, ) - _install(monkeypatch, fake) + _install(monkeypatch, fake, config=_config(datasets=["HR Policies", "Engineering"])) - result = await ragflow_tools.knowledge_search("annual leave", ["HR Policies"]) + result = await ragflow_tools.knowledge_search("annual leave") + assert fake.list_calls == ["HR Policies", "Engineering"] assert fake.retrieve_calls == [ ( "annual leave", { - "dataset_ids": ["dataset-1"], + "dataset_ids": ["dataset-1", "dataset-2"], "page_size": 8, "similarity_threshold": 0.2, "vector_similarity_weight": 0.3, @@ -117,90 +118,109 @@ async def test_knowledge_search_resolves_names_to_ids_and_formats_citations(monk }, ) ] - assert "[1] HR Policies / handbook.pdf (相关度 0.87)" in result + assert "[1] HR Policies / handbook.pdf (score 0.87)" in result assert "Annual leave" in result - assert "命中文档:handbook.pdf (1 段)" in result + assert "Matched documents: handbook.pdf (1 chunk)" in result assert "dataset-1" not in result @pytest.mark.anyio -async def test_knowledge_search_accepts_case_insensitive_dataset_names(monkeypatch: pytest.MonkeyPatch) -> None: - fake = FakeRAGFlowClient(datasets=[{"id": "dataset-1", "name": "HR Policies"}]) +async def test_knowledge_search_uses_exact_name_filtered_lookups(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeRAGFlowClient( + datasets_by_name={ + "HR Policies": [ + {"id": "dataset-wrong", "name": "HR Policies Archive"}, + {"id": "dataset-1", "name": "HR Policies"}, + ] + } + ) _install(monkeypatch, fake) - await ragflow_tools.knowledge_search("leave", ["hr policies"]) + await ragflow_tools.knowledge_search("leave") + assert fake.list_calls == ["HR Policies"] assert fake.retrieve_calls[0][1]["dataset_ids"] == ["dataset-1"] @pytest.mark.anyio -async def test_knowledge_search_unknown_name_returns_available_names(monkeypatch: pytest.MonkeyPatch) -> None: - fake = FakeRAGFlowClient(datasets=[{"id": "dataset-1", "name": "HR Policies"}]) - _install(monkeypatch, fake) +async def test_missing_bound_dataset_returns_operator_guidance(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeRAGFlowClient() + _install(monkeypatch, fake, config=_config(datasets=["Finance"])) - result = await ragflow_tools.knowledge_search("leave", ["Finance"]) + result = await ragflow_tools.knowledge_search("leave") - assert "Finance" in result - assert "HR Policies" in result + assert result == ("Error: Configured RAGFlow dataset was not found: Finance. It may have been deleted or renamed; check knowledge_search.datasets in config.yaml.") + assert fake.list_calls == ["Finance"] assert fake.retrieve_calls == [] @pytest.mark.anyio -async def test_unknown_dataset_error_redacts_api_key(monkeypatch: pytest.MonkeyPatch) -> None: - fake = FakeRAGFlowClient(datasets=[{"id": "dataset-1", "name": "HR Policies"}]) - _install(monkeypatch, fake) +async def test_missing_bound_dataset_error_redacts_api_key(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeRAGFlowClient() + _install(monkeypatch, fake, config=_config(datasets=["ragflow-secret archive"])) - result = await ragflow_tools.knowledge_search("leave", ["ragflow-secret"]) + result = await ragflow_tools.knowledge_search("leave") assert "ragflow-secret" not in result assert "[REDACTED]" in result @pytest.mark.anyio -async def test_knowledge_search_without_names_does_not_pass_dataset_ids(monkeypatch: pytest.MonkeyPatch) -> None: - fake = FakeRAGFlowClient(datasets=[{"id": "dataset-1", "name": "HR Policies"}]) - _install(monkeypatch, fake) +async def test_ambiguous_bound_dataset_returns_operator_guidance(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeRAGFlowClient( + datasets_by_name={ + "Policies": [ + {"id": "dataset-1", "name": "Policies"}, + {"id": "dataset-2", "name": "Policies"}, + ] + } + ) + _install(monkeypatch, fake, config=_config(datasets=["Policies"])) - await ragflow_tools.knowledge_search("fallback", None) + result = await ragflow_tools.knowledge_search("leave") - assert "dataset_ids" not in fake.retrieve_calls[0][1] + assert result == "Error: Configured RAGFlow dataset name is ambiguous: Policies. Check knowledge_search.datasets in config.yaml." + assert fake.retrieve_calls == [] @pytest.mark.anyio -async def test_knowledge_search_rejects_explicit_empty_dataset_list(monkeypatch: pytest.MonkeyPatch) -> None: - fake = FakeRAGFlowClient(datasets=[{"id": "dataset-1", "name": "HR Policies"}]) - _install(monkeypatch, fake) +async def test_missing_dataset_binding_is_rejected_without_network_io(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeRAGFlowClient() + _install(monkeypatch, fake, config=_config(datasets=None)) - result = await ragflow_tools.knowledge_search("leave", []) + result = await ragflow_tools.knowledge_search("leave") - assert "至少指定一个知识库" in result - assert fake.retrieve_calls == [] + assert result == "Error: Invalid RAGFlow settings for knowledge_search; check config.yaml." + assert fake.list_calls == [] @pytest.mark.anyio -async def test_missing_api_key_returns_guidance_and_warns_only_once(monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture) -> None: +async def test_missing_api_key_returns_english_guidance_and_warns_only_once( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: fake = FakeRAGFlowClient() - _install(monkeypatch, fake, config=_config(api_key=None)) + _install(monkeypatch, fake, config=_config(api_key=None, datasets=["HR Policies"])) with caplog.at_level(logging.WARNING, logger="deerflow.community.ragflow.tools"): - first = await ragflow_tools.list_knowledge_bases() - second = await ragflow_tools.knowledge_search("leave") + first = await ragflow_tools.knowledge_search("leave") + second = await ragflow_tools.knowledge_search("benefits") - assert "未配置 RAGFlow API Key" in first - assert "未配置 RAGFlow API Key" in second - assert caplog.text.count("RAGFlow API Key") == 1 - assert fake.retrieve_calls == [] + assert first == "Error: RAGFlow API key is not configured; set knowledge_search.api_key in config.yaml (prefer $RAGFLOW_API_KEY)." + assert second == first + assert caplog.text.count("RAGFlow API key is not configured") == 1 + assert fake.list_calls == [] @pytest.mark.anyio -async def test_missing_knowledge_search_config_returns_guidance_without_calling_ragflow(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_missing_knowledge_search_config_returns_english_guidance(monkeypatch: pytest.MonkeyPatch) -> None: fake = FakeRAGFlowClient() - _install(monkeypatch, fake, config=_config(configured=False)) + _install(monkeypatch, fake, config=_config(configured=False, datasets=["HR Policies"])) - result = await ragflow_tools.list_knowledge_bases() + result = await ragflow_tools.knowledge_search("leave") - assert "tools" in result - assert "knowledge_search" in result + assert result == "Error: knowledge_search is not configured; add its RAGFlow settings to the tools list in config.yaml." + assert fake.list_calls == [] @pytest.mark.anyio @@ -208,72 +228,142 @@ async def test_api_error_is_returned_as_readable_text(monkeypatch: pytest.Monkey fake = FakeRAGFlowClient(error=RAGFlowAPIError("embedding models do not match", code=102)) _install(monkeypatch, fake) - result = await ragflow_tools.knowledge_search("leave", ["HR Policies"]) + result = await ragflow_tools.knowledge_search("leave") assert result == "Error: embedding models do not match" @pytest.mark.anyio -async def test_api_error_cannot_expose_dataset_uuid(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_error_path_redacts_dataset_uuid(monkeypatch: pytest.MonkeyPatch) -> None: dataset_id = "0123456789abcdef0123456789abcdef" fake = FakeRAGFlowClient(error=RAGFlowAPIError(f"dataset {dataset_id} failed", code=102)) _install(monkeypatch, fake) - result = await ragflow_tools.list_knowledge_bases() + result = await ragflow_tools.knowledge_search("leave") assert dataset_id not in result assert "[DATASET_ID]" in result @pytest.mark.anyio -async def test_connection_error_is_recoverable_and_does_not_leak_key(monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture) -> None: +async def test_success_path_preserves_legitimate_uuid_and_md5_text(monkeypatch: pytest.MonkeyPatch) -> None: + uuid = "123e4567-e89b-12d3-a456-426614174000" + md5 = "d41d8cd98f00b204e9800998ecf8427e" + fake = FakeRAGFlowClient( + datasets_by_name={"HR Policies": [{"id": "dataset-1", "name": "HR Policies"}]}, + retrieval={ + "chunks": [ + { + "dataset_id": "dataset-1", + "document_keyword": "checksums.txt", + "content": f"Trace {uuid}; checksum {md5}.", + } + ] + }, + ) + _install(monkeypatch, fake) + + result = await ragflow_tools.knowledge_search("trace") + + assert uuid in result + assert md5 in result + assert "[DATASET_ID]" not in result + + +@pytest.mark.anyio +async def test_success_path_still_redacts_api_key(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeRAGFlowClient( + datasets_by_name={"HR Policies": [{"id": "dataset-1", "name": "HR Policies"}]}, + retrieval={ + "chunks": [ + { + "dataset_id": "dataset-1", + "document_keyword": "secret.txt", + "content": "Accidental echo: ragflow-secret", + } + ] + }, + ) + _install(monkeypatch, fake) + + result = await ragflow_tools.knowledge_search("secret") + + assert "ragflow-secret" not in result + assert "[REDACTED]" in result + + +@pytest.mark.anyio +async def test_connection_error_is_english_and_does_not_leak_key( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: fake = FakeRAGFlowClient(error=RAGFlowConnectionError("ConnectError: refused ragflow-secret")) _install(monkeypatch, fake) with caplog.at_level(logging.WARNING, logger="deerflow.community.ragflow.tools"): - result = await ragflow_tools.list_knowledge_bases() + result = await ragflow_tools.knowledge_search("leave") - assert result.startswith("Error: 无法连接 RAGFlow (http://ragflow.test):") + assert result == "Error: Unable to connect to RAGFlow (http://ragflow.test): ConnectError: refused [REDACTED]" assert "ragflow-secret" not in result assert "ragflow-secret" not in caplog.text @pytest.mark.anyio -async def test_connection_error_redacts_key_embedded_in_base_url( +@pytest.mark.parametrize( + "base_url", + [ + "http://ragflow-secret@ragflow.test", + "http://ragflow%2Dsecret@ragflow.test", + "http://user:ragflow-secret@ragflow.test", + ], +) +async def test_base_url_with_plain_or_encoded_userinfo_is_rejected_without_leaking_credentials( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, + base_url: str, ) -> None: - fake = FakeRAGFlowClient(error=RAGFlowConnectionError("connection refused")) - _install( - monkeypatch, - fake, - config=_config(base_url="http://ragflow-secret@ragflow.test"), - ) + fake = FakeRAGFlowClient() + _install(monkeypatch, fake, config=_config(base_url=base_url, datasets=["HR Policies"])) with caplog.at_level(logging.WARNING, logger="deerflow.community.ragflow.tools"): - result = await ragflow_tools.list_knowledge_bases() + result = await ragflow_tools.knowledge_search("leave") + assert result == "Error: Invalid RAGFlow settings for knowledge_search; check config.yaml." assert "ragflow-secret" not in result assert "ragflow-secret" not in caplog.text + assert "ragflow%2Dsecret" not in caplog.text + assert fake.list_calls == [] + + +@pytest.mark.anyio +async def test_empty_query_has_english_error(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeRAGFlowClient() + _install(monkeypatch, fake) + + result = await ragflow_tools.knowledge_search(" ") + + assert result == "Error: query must not be empty." + assert fake.list_calls == [] @pytest.mark.anyio -async def test_empty_retrieval_has_explicit_message(monkeypatch: pytest.MonkeyPatch) -> None: - fake = FakeRAGFlowClient(datasets=[{"id": "dataset-1", "name": "HR Policies"}]) +async def test_empty_retrieval_has_explicit_english_message(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeRAGFlowClient(datasets_by_name={"HR Policies": [{"id": "dataset-1", "name": "HR Policies"}]}) _install(monkeypatch, fake) - result = await ragflow_tools.knowledge_search("nothing", ["HR Policies"]) + result = await ragflow_tools.knowledge_search("nothing") - assert result == "未检索到相关内容。" + assert result == "No relevant content found." -def test_formatting_applies_per_chunk_truncation_and_supports_kb_id() -> None: +def test_formatting_uses_only_normalized_chunk_fields() -> None: result = format_retrieval_result( { "chunks": [ { "kb_id": "dataset-1", - "document_keyword": "handbook.pdf", + "doc_id": "doc-legacy", + "docnm_kwd": "legacy.pdf", "content": "abcdefghij", "similarity": 0.5, } @@ -284,12 +374,15 @@ def test_formatting_applies_per_chunk_truncation_and_supports_kb_id() -> None: max_total_chars=1000, ) + assert "Unknown dataset / Unknown document" in result + assert "HR Policies" not in result + assert "legacy.pdf" not in result assert "abcd…" in result assert "abcdefghij" not in result assert "dataset-1" not in result -def test_formatting_applies_total_response_truncation() -> None: +def test_formatting_applies_total_response_truncation_in_english() -> None: result = format_retrieval_result( { "chunks": [ @@ -308,16 +401,17 @@ def test_formatting_applies_total_response_truncation() -> None: ) assert len(result) <= 120 - assert result.endswith("…(响应已截断)") + assert result.endswith("… (response truncated)") -def test_retrieval_settings_load_from_knowledge_search_tool_and_hide_secret(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(ragflow_tools, "get_app_config", lambda: _config()) +def test_retrieval_settings_load_bound_datasets_and_hide_secret(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(ragflow_tools, "get_app_config", lambda: _config(datasets=["HR Policies", "Engineering"])) config, error = ragflow_tools._settings_or_error() assert error is None assert config is not None + assert config.datasets == ["HR Policies", "Engineering"] assert str(config.base_url).rstrip("/") == "http://ragflow.test" assert config.page_size == 8 assert config.max_chars_per_chunk == 800 @@ -325,37 +419,26 @@ def test_retrieval_settings_load_from_knowledge_search_tool_and_hide_secret(monk assert "ragflow-secret" not in repr(config) -def test_agent_tool_contracts_are_async_and_model_facing() -> None: - assert ragflow_tools.list_knowledge_bases_tool.name == "list_knowledge_bases" - assert ragflow_tools.list_knowledge_bases_tool.coroutine is not None +def test_agent_exposes_only_query_on_single_search_tool() -> None: + assert not hasattr(ragflow_tools, "list_knowledge_bases_tool") + assert not hasattr(ragflow_tools, "list_knowledge_bases") assert ragflow_tools.knowledge_search_tool.name == "knowledge_search" assert ragflow_tools.knowledge_search_tool.coroutine is not None - assert set(ragflow_tools.knowledge_search_tool.tool_call_schema.model_fields) == {"query", "knowledge_bases"} - assert "list_knowledge_bases" in ragflow_tools.knowledge_search_tool.description - - -@pytest.mark.parametrize("configured", [False, True]) -def test_tool_assembly_uses_normal_tool_presence_without_feature_flag(configured: bool) -> None: - tools = ( - [ - ToolConfig( - name="knowledge_search", - group="knowledge", - use="deerflow.community.ragflow.tools:knowledge_search_tool", - base_url="http://ragflow.test", - api_key="ragflow-secret", - ), - ToolConfig( - name="list_knowledge_bases", - group="knowledge", - use="deerflow.community.ragflow.tools:list_knowledge_bases_tool", - ), - ] - if configured - else [] + assert set(ragflow_tools.knowledge_search_tool.tool_call_schema.model_fields) == {"query"} + + +def test_tool_assembly_injects_bound_dataset_names_without_network_io(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(ragflow_tools, "_build_client", lambda settings: pytest.fail("tool assembly must not perform network IO")) + tool_config = ToolConfig( + name="knowledge_search", + group="knowledge", + use="deerflow.community.ragflow.tools:knowledge_search_tool", + base_url="http://ragflow.test", + api_key="ragflow-secret", + datasets=["HR Policies", "Engineering", "ragflow-secret archive"], ) config = SimpleNamespace( - tools=tools, + tools=[tool_config], sandbox=SimpleNamespace(use="example.remote:Sandbox"), skill_evolution=SimpleNamespace(enabled=False), models=[], @@ -363,7 +446,17 @@ def test_tool_assembly_uses_normal_tool_presence_without_feature_flag(configured get_model_config=lambda name: None, ) - names = {tool.name for tool in get_available_tools(include_mcp=False, app_config=config)} + tools = get_available_tools(include_mcp=False, app_config=config) + assembled = next(tool for tool in tools if tool.name == "knowledge_search") + + assert "HR Policies" in assembled.description + assert "Engineering" in assembled.description + assert "[REDACTED] archive" in assembled.description + assert "ragflow-secret" not in assembled.description + assert {tool.name for tool in tools}.isdisjoint({"list_knowledge_bases"}) + + +def test_ragflow_package_has_explicit_init_file() -> None: + package_dir = Path(ragflow_tools.__file__).resolve().parent - assert ("knowledge_search" in names) is configured - assert ("list_knowledge_bases" in names) is configured + assert (package_dir / "__init__.py").is_file() diff --git a/config.example.yaml b/config.example.yaml index 97985660e45..ed8be7b7c8c 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -677,16 +677,18 @@ tool_groups: # Configure available tools for the agent to use tools: - # RAGFlow knowledge retrieval (read-only). Uncomment both entries together. - # Connection and retrieval settings live on knowledge_search; the listing - # tool reuses them. Dataset UUIDs are never shown to the model. - # Prefer explicit knowledge-base names because cross-dataset retrieval can - # fail when datasets use different embedding models. + # RAGFlow knowledge retrieval (read-only). Uncomment this single entry. + # `datasets` is an operator-controlled allowlist of exact RAGFlow dataset + # names. The Agent cannot enumerate the tenant catalog or change this scope. + # Bind only datasets with compatible embedding models. Names are resolved at + # search time, and dataset UUIDs are never shown to the model. # - name: knowledge_search # group: knowledge # use: deerflow.community.ragflow.tools:knowledge_search_tool # base_url: http://localhost:9380 # Docker: use a backend-reachable URL # api_key: $RAGFLOW_API_KEY + # datasets: + # - HR Policies # Replace with an exact dataset name from RAGFlow # timeout: 30 # page_size: 8 # similarity_threshold: 0.2 @@ -694,9 +696,6 @@ tools: # top_k: 256 # max_chars_per_chunk: 800 # max_total_chars: 8000 - # - name: list_knowledge_bases - # group: knowledge - # use: deerflow.community.ragflow.tools:list_knowledge_bases_tool # Web search tool (uses DuckDuckGo, no API key required) - name: web_search diff --git a/deploy/helm/deer-flow/values.yaml b/deploy/helm/deer-flow/values.yaml index e63c96d3f55..915c3c3b551 100644 --- a/deploy/helm/deer-flow/values.yaml +++ b/deploy/helm/deer-flow/values.yaml @@ -307,17 +307,19 @@ config: | - name: file:read - name: file:write - name: bash - # - name: knowledge # Enable with both RAGFlow tools below. + # - name: knowledge # Enable with the RAGFlow tool below. tools: # Optional tenant-shared, read-only RAGFlow retrieval. Put RAGFLOW_API_KEY - # in `secrets`; connection settings belong to knowledge_search and are - # reused by list_knowledge_bases. + # in `secrets`, then bind an operator-controlled allowlist of exact dataset + # names. The Agent cannot enumerate the tenant catalog. # - name: knowledge_search # group: knowledge # use: deerflow.community.ragflow.tools:knowledge_search_tool # base_url: http://ragflow:9380 # api_key: $RAGFLOW_API_KEY + # datasets: + # - HR Policies # timeout: 30 # page_size: 8 # similarity_threshold: 0.2 @@ -325,9 +327,6 @@ config: | # top_k: 256 # max_chars_per_chunk: 800 # max_total_chars: 8000 - # - name: list_knowledge_bases - # group: knowledge - # use: deerflow.community.ragflow.tools:list_knowledge_bases_tool - name: web_search group: web use: deerflow.community.ddg_search.tools:web_search_tool From 17e24998d2ed0f1f9180e41a50c2caf26b264fbe Mon Sep 17 00:00:00 2001 From: zhangwei139623 <1552775457@qq.com> Date: Mon, 24 Aug 2026 22:08:12 +0800 Subject: [PATCH 06/14] docs(ragflow): record validated response versions --- .../harness/deerflow/community/ragflow/formatting.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/packages/harness/deerflow/community/ragflow/formatting.py b/backend/packages/harness/deerflow/community/ragflow/formatting.py index 0905fdc84fa..7a179273a40 100644 --- a/backend/packages/harness/deerflow/community/ragflow/formatting.py +++ b/backend/packages/harness/deerflow/community/ragflow/formatting.py @@ -40,10 +40,11 @@ def format_retrieval_result( ) -> str: """Format one RAGFlow retrieval response into compact cited text. - RAGFlow normalizes response chunk fields before returning them from the - REST endpoint (for example, ``kb_id`` becomes ``dataset_id``). Only those - public response field names are consumed, and dataset IDs are mapped back - to the operator-configured names before anything reaches the model. + Verified against RAGFlow v0.26.4 and v0.27.0: the REST retrieval endpoint + normalizes response chunk fields before returning them (for example, + ``kb_id`` becomes ``dataset_id``). Only those public response field names + are consumed, and dataset IDs are mapped back to the operator-configured + names before anything reaches the model. """ raw_chunks = result.get("chunks") if not isinstance(raw_chunks, list): From 08840ed82f88d84aa8d0a6bc69044f3879cafffa Mon Sep 17 00:00:00 2001 From: zhangwei139623 <1552775457@qq.com> Date: Mon, 24 Aug 2026 22:34:40 +0800 Subject: [PATCH 07/14] fix(ragflow): bind retrieval by dataset id --- backend/AGENTS.md | 9 +- backend/docs/CONFIGURATION.md | 18 +-- backend/packages/harness/deerflow/AGENTS.md | 14 +-- .../deerflow/community/ragflow/client.py | 10 +- .../deerflow/community/ragflow/tools.py | 103 +++++----------- .../packages/harness/deerflow/tools/tools.py | 21 +--- backend/tests/test_ragflow_client.py | 16 +-- backend/tests/test_ragflow_tools.py | 115 +++++++++--------- config.example.yaml | 10 +- deploy/helm/deer-flow/values.yaml | 6 +- 10 files changed, 127 insertions(+), 195 deletions(-) diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 791027f6724..caf8b72ee18 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -297,10 +297,11 @@ The harness provides an opt-in, read-only RAGFlow integration under `deerflow.community.ragflow`. The normal `tools:` list enables one `knowledge_search(query)` Agent tool. Its entry owns the RAGFlow connection, retrieval parameters, and an operator-controlled allowlist of exact dataset -names. The provider injects those names into the assembled model-visible tool -description, resolves them lazily through name-filtered RAGFlow requests, and -always retrieves with a non-empty `dataset_ids` list. Tenant-wide dataset -listing is not Agent-visible. RAGFlow remains the sole source of truth: there +IDs. Configuration loading performs no network I/O. At invocation time the +provider verifies those IDs through ID-filtered RAGFlow requests, resolves their +current names for citation formatting, and always retrieves with the same +non-empty `dataset_ids` list. Neither the IDs nor tenant-wide dataset listing is +Agent-visible. RAGFlow remains the sole source of truth: there are no DeerFlow ORM models, migrations, or mirrored knowledge metadata. The configured tenant API key must never appear in logs or model-visible tool errors, and RAGFlow dataset UUIDs must not enter model context. Provider-authored diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index 15d24473573..407dd175de7 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -224,7 +224,7 @@ RAGFlow integration is disabled by default. It adds one read-only Agent tool, `knowledge_search`. DeerFlow does not persist a copy of dataset or document metadata; RAGFlow is the sole source of truth. The configured API key is tenant-scoped, while the operator-controlled `datasets` list restricts every -Agent on this deployment to the same named subset. +Agent on this deployment to the same dataset-ID allowlist. ```yaml tool_groups: @@ -237,8 +237,8 @@ tools: base_url: http://localhost:9380 api_key: $RAGFLOW_API_KEY datasets: - - HR Policies - - Engineering Handbook + - 0123456789abcdef0123456789abcdef + - fedcba9876543210fedcba9876543210 timeout: 30 page_size: 8 similarity_threshold: 0.2 @@ -249,12 +249,12 @@ tools: ``` The tool is opt-in through the normal `tools:` list. `datasets` must contain one -or more exact RAGFlow dataset names selected by the deployment operator. DeerFlow -does not validate their existence while loading configuration; on each search it -resolves the names with filtered RAGFlow requests and always sends the resulting -non-empty `dataset_ids` allowlist to retrieval. A deleted or renamed dataset -produces guidance to check `config.yaml`. Bound names are copied into the tool -description visible to the Agent, but tenant-wide listing is not exposed. +or more RAGFlow dataset IDs selected by the deployment operator. DeerFlow does +not validate their existence while loading configuration. On each search it +verifies them with ID-filtered RAGFlow requests, resolves their current names for +citation formatting, and always sends the same non-empty `dataset_ids` allowlist +to retrieval. A deleted or inaccessible dataset produces guidance to check +`config.yaml`. Dataset IDs and tenant-wide listing are not exposed to the Agent. Configure only datasets with compatible embedding models because RAGFlow can reject cross-dataset retrieval when models differ. `base_url` must not contain diff --git a/backend/packages/harness/deerflow/AGENTS.md b/backend/packages/harness/deerflow/AGENTS.md index 3e4d3e143fe..3aae42c67ba 100644 --- a/backend/packages/harness/deerflow/AGENTS.md +++ b/backend/packages/harness/deerflow/AGENTS.md @@ -38,13 +38,13 @@ drift. The optional `knowledge` tool group exposes one `knowledge_search(query)` tool. Its normal `tools:` entry owns the provider connection, retrieval extras, and a -required operator allowlist of exact dataset names; there is no top-level -provider configuration or tenant-wide listing tool. The provider uses the -synchronous `configure_for_tool_entry` assembly hook to copy bound names into -the model-visible tool description without network IO. Each invocation resolves -those names through filtered RAGFlow requests and sends a non-empty -`dataset_ids` list to retrieval. It never persists dataset metadata, exposes -RAGFlow dataset UUIDs to the model, or provides write operations. +required operator allowlist of stable dataset IDs; there is no top-level +provider configuration or tenant-wide listing tool. Configuration loading does +not contact RAGFlow. Each invocation verifies the bound IDs through ID-filtered +RAGFlow requests, resolves their current names for citation formatting, and +sends the same non-empty `dataset_ids` list to retrieval. It never persists +dataset metadata, exposes RAGFlow dataset IDs to the model, or provides write +operations. Retrieval output is bounded at both the individual chunk and full-response levels. Provider-authored model-visible text is English. Every path must redact the configured tenant API key; dataset-UUID redaction is error-only so normal diff --git a/backend/packages/harness/deerflow/community/ragflow/client.py b/backend/packages/harness/deerflow/community/ragflow/client.py index 0e53f62ba0d..9d79b6ef40a 100644 --- a/backend/packages/harness/deerflow/community/ragflow/client.py +++ b/backend/packages/harness/deerflow/community/ragflow/client.py @@ -106,12 +106,12 @@ async def _request( raise RAGFlowAPIError(message, code=code) return payload - async def list_datasets(self, *, name: str) -> list[dict[str, Any]]: - """Resolve a configured dataset name without enumerating the tenant catalog.""" - if not name.strip(): - raise ValueError("name must not be empty") + async def list_datasets(self, *, dataset_id: str) -> list[dict[str, Any]]: + """Resolve one configured dataset ID without enumerating the tenant catalog.""" + if not dataset_id.strip(): + raise ValueError("dataset_id must not be empty") - payload = await self._request("GET", "/datasets", params={"name": name}) + payload = await self._request("GET", "/datasets", params={"id": dataset_id}) data = payload.get("data") if not isinstance(data, list): raise RAGFlowProtocolError("RAGFlow returned an invalid dataset list.") diff --git a/backend/packages/harness/deerflow/community/ragflow/tools.py b/backend/packages/harness/deerflow/community/ragflow/tools.py index 3de6c3f637b..3551219b1e9 100644 --- a/backend/packages/harness/deerflow/community/ragflow/tools.py +++ b/backend/packages/harness/deerflow/community/ragflow/tools.py @@ -39,18 +39,18 @@ class _RAGFlowRetrievalSettings(BaseModel): @field_validator("datasets") @classmethod - def _normalize_dataset_names(cls, value: list[str]) -> list[str]: + def _normalize_dataset_ids(cls, value: list[str]) -> list[str]: normalized: list[str] = [] seen: set[str] = set() - for name in value: - clean_name = name.strip() - if not clean_name or len(clean_name) > 256: - raise ValueError("dataset names must contain between 1 and 256 characters") - if clean_name not in seen: - normalized.append(clean_name) - seen.add(clean_name) + for dataset_id in value: + clean_id = dataset_id.strip() + if not clean_id or len(clean_id) > 256: + raise ValueError("dataset IDs must contain between 1 and 256 characters") + if clean_id not in seen: + normalized.append(clean_id) + seen.add(clean_id) if not normalized: - raise ValueError("at least one dataset name is required") + raise ValueError("at least one dataset ID is required") return normalized @field_validator("base_url") @@ -133,63 +133,33 @@ def _tool_error(exc: Exception, settings: _RAGFlowRetrievalSettings) -> str: return "Error: An unexpected RAGFlow retrieval error occurred; try again later." -def _exact_dataset_matches(datasets: list[dict], bound_name: str) -> list[tuple[str, str]]: - matches: list[tuple[str, str]] = [] - seen_ids: set[str] = set() +def _current_dataset_name(datasets: list[dict], bound_id: str) -> str | None: for dataset in datasets: dataset_id = dataset.get("id") name = dataset.get("name") - if not isinstance(dataset_id, str) or not dataset_id or name != bound_name or dataset_id in seen_ids: - continue - matches.append((dataset_id, bound_name)) - seen_ids.add(dataset_id) - return matches - - -def _missing_dataset_error(names: list[str], api_key: str | None) -> str: - if len(names) == 1: - message = f"Error: Configured RAGFlow dataset was not found: {names[0]}. It may have been deleted or renamed; check knowledge_search.datasets in config.yaml." - else: - message = f"Error: Configured RAGFlow datasets were not found: {', '.join(names)}. They may have been deleted or renamed; check knowledge_search.datasets in config.yaml." - return _redact_error(message, api_key) - - -def _ambiguous_dataset_error(names: list[str], api_key: str | None) -> str: - label = "name is" if len(names) == 1 else "names are" - return _redact_error( - f"Error: Configured RAGFlow dataset {label} ambiguous: {', '.join(names)}. Check knowledge_search.datasets in config.yaml.", - api_key, - ) + if dataset_id == bound_id: + return str(name).strip() if name else "Unknown dataset" + return None + + +def _missing_dataset_error() -> str: + return "Error: A configured RAGFlow dataset was not found or is inaccessible; check knowledge_search.datasets in config.yaml." async def _resolve_configured_datasets( client: RAGFlowClient, settings: _RAGFlowRetrievalSettings, ) -> tuple[list[str] | None, dict[str, str] | None, str | None]: - batches = await asyncio.gather(*(client.list_datasets(name=name) for name in settings.datasets)) + batches = await asyncio.gather(*(client.list_datasets(dataset_id=dataset_id) for dataset_id in settings.datasets)) - dataset_ids: list[str] = [] names_by_id: dict[str, str] = {} - missing: list[str] = [] - ambiguous: list[str] = [] - for bound_name, datasets in zip(settings.datasets, batches, strict=True): - matches = _exact_dataset_matches(datasets, bound_name) - if not matches: - missing.append(bound_name) - continue - if len(matches) > 1: - ambiguous.append(bound_name) - continue - dataset_id, dataset_name = matches[0] - dataset_ids.append(dataset_id) - names_by_id[dataset_id] = dataset_name + for bound_id, datasets in zip(settings.datasets, batches, strict=True): + current_name = _current_dataset_name(datasets, bound_id) + if current_name is None: + return None, None, _missing_dataset_error() + names_by_id[bound_id] = current_name - key = _api_key(settings) - if missing: - return None, None, _missing_dataset_error(missing, key) - if ambiguous: - return None, None, _ambiguous_dataset_error(ambiguous, key) - return dataset_ids, names_by_id, None + return list(settings.datasets), names_by_id, None async def knowledge_search(query: str) -> str: @@ -231,26 +201,9 @@ async def knowledge_search(query: str) -> str: return _tool_error(exc, settings) -def _tool_description(dataset_names: list[str], api_key: str | None) -> str: +def _tool_description() -> str: base = "Search the operator-approved RAGFlow datasets and return compact, citation-numbered source chunks." - if not dataset_names: - return f"{base} Dataset access is controlled by knowledge_search.datasets in config.yaml." - visible_names = [_redact_api_key(name, api_key) for name in dataset_names] - return f"{base} This tool is restricted to these configured datasets: {', '.join(visible_names)}." - - -class _ConfiguredKnowledgeSearchTool(StructuredTool): - """Structured tool whose model description is derived from its config entry.""" - - def configure_for_tool_entry(self, extra: Mapping[str, object]) -> StructuredTool: - configured = self.model_copy(deep=False) - try: - settings = _settings_from_extra(extra) - except ValidationError: - configured.description = _tool_description([], None) - else: - configured.description = _tool_description(settings.datasets, _api_key(settings)) - return configured + return f"{base} Access is restricted to operator-configured dataset IDs, which are never shown to the model." async def _knowledge_search_entrypoint(query: str) -> str: @@ -262,9 +215,9 @@ async def _knowledge_search_entrypoint(query: str) -> str: return await knowledge_search(query) -knowledge_search_tool = _ConfiguredKnowledgeSearchTool.from_function( +knowledge_search_tool = StructuredTool.from_function( coroutine=_knowledge_search_entrypoint, name="knowledge_search", - description=_tool_description([], None), + description=_tool_description(), parse_docstring=True, ) diff --git a/backend/packages/harness/deerflow/tools/tools.py b/backend/packages/harness/deerflow/tools/tools.py index abdbe763b66..77e9bed5779 100644 --- a/backend/packages/harness/deerflow/tools/tools.py +++ b/backend/packages/harness/deerflow/tools/tools.py @@ -56,22 +56,6 @@ def _ensure_sync_invocable_tool(tool: BaseTool) -> BaseTool: return tool -def _configure_tool_for_entry(tool: BaseTool, tool_config: object) -> BaseTool: - """Let a provider derive a per-entry tool copy from local config only. - - Providers use this optional hook for model-visible metadata that depends on - their own tool-entry fields. The hook is synchronous by design, preventing - configuration loading from turning into external discovery or validation. - """ - configure = getattr(tool, "configure_for_tool_entry", None) - if not callable(configure): - return tool - configured = configure(getattr(tool_config, "model_extra", None) or {}) - if not isinstance(configured, BaseTool): - raise TypeError(f"Tool provider {getattr(tool_config, 'use', tool.name)} returned a non-BaseTool configured value") - return configured - - def get_available_tools( groups: list[str] | None = None, include_mcp: bool = True, @@ -105,10 +89,7 @@ def get_available_tools( if not is_host_bash_allowed(config): tool_configs = [tool for tool in tool_configs if not _is_host_bash_tool(tool)] - loaded_tools_raw = [] - for cfg in tool_configs: - loaded = resolve_variable(cfg.use, BaseTool) - loaded_tools_raw.append((cfg, _configure_tool_for_entry(loaded, cfg))) + loaded_tools_raw = [(cfg, resolve_variable(cfg.use, BaseTool)) for cfg in tool_configs] # Warn when the config ``name`` field and the tool object's ``.name`` # attribute diverge — this mismatch is the root cause of issue #1803 where diff --git a/backend/tests/test_ragflow_client.py b/backend/tests/test_ragflow_client.py index 2233266db68..b26d60d25a0 100644 --- a/backend/tests/test_ragflow_client.py +++ b/backend/tests/test_ragflow_client.py @@ -12,13 +12,13 @@ @pytest.mark.anyio -async def test_list_datasets_filters_by_bound_name_in_one_request() -> None: +async def test_list_datasets_filters_by_bound_id_in_one_request() -> None: requests: list[httpx.Request] = [] async def handler(request: httpx.Request) -> httpx.Response: requests.append(request) assert request.method == "GET" - assert request.url == httpx.URL("http://ragflow.test/api/v1/datasets?name=HR+Policies") + assert request.url == httpx.URL("http://ragflow.test/api/v1/datasets?id=dataset-1") assert request.headers["Authorization"] == "Bearer ragflow-secret" return httpx.Response( 200, @@ -36,7 +36,7 @@ async def handler(request: httpx.Request) -> httpx.Response: transport=httpx.MockTransport(handler), ) - assert await client.list_datasets(name="HR Policies") == [{"id": "dataset-1", "name": "HR Policies"}] + assert await client.list_datasets(dataset_id="dataset-1") == [{"id": "dataset-1", "name": "HR Policies"}] assert len(requests) == 1 @@ -109,7 +109,7 @@ async def handler(request: httpx.Request) -> httpx.Response: ) with pytest.raises(RAGFlowAPIError) as exc_info: - await client.list_datasets(name="HR Policies") + await client.list_datasets(dataset_id="dataset-1") assert exc_info.value.code == 102 assert "invalid credential" in str(exc_info.value) @@ -130,7 +130,7 @@ async def handler(request: httpx.Request) -> httpx.Response: ) with pytest.raises(RAGFlowConnectionError) as exc_info: - await client.list_datasets(name="HR Policies") + await client.list_datasets(dataset_id="dataset-1") assert str(exc_info.value) == "RAGFlow request timed out after 2 seconds." assert "ragflow-secret" not in str(exc_info.value) @@ -149,7 +149,7 @@ async def handler(request: httpx.Request) -> httpx.Response: ) with pytest.raises(RAGFlowProtocolError) as exc_info: - await client.list_datasets(name="HR Policies") + await client.list_datasets(dataset_id="dataset-1") assert str(exc_info.value) == "RAGFlow request failed (HTTP 401)." assert "ragflow-secret" not in str(exc_info.value) @@ -167,7 +167,7 @@ async def handler(request: httpx.Request) -> httpx.Response: ) with pytest.raises(RAGFlowProtocolError, match="RAGFlow returned invalid JSON"): - await client.list_datasets(name="HR Policies") + await client.list_datasets(dataset_id="dataset-1") @pytest.mark.anyio @@ -182,4 +182,4 @@ async def handler(request: httpx.Request) -> httpx.Response: ) with pytest.raises(RAGFlowProtocolError, match="invalid dataset list"): - await client.list_datasets(name="HR Policies") + await client.list_datasets(dataset_id="dataset-1") diff --git a/backend/tests/test_ragflow_tools.py b/backend/tests/test_ragflow_tools.py index 76190d26950..b4186c49d1e 100644 --- a/backend/tests/test_ragflow_tools.py +++ b/backend/tests/test_ragflow_tools.py @@ -11,26 +11,30 @@ from deerflow.config.tool_config import ToolConfig from deerflow.tools.tools import get_available_tools +DATASET_ID_1 = "0123456789abcdef0123456789abcdef" +DATASET_ID_2 = "fedcba9876543210fedcba9876543210" +MISSING_DATASET_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + class FakeRAGFlowClient: def __init__( self, *, - datasets_by_name: Mapping[str, list[dict]] | None = None, + datasets_by_id: Mapping[str, list[dict]] | None = None, retrieval: dict | None = None, error: Exception | None = None, ) -> None: - self.datasets_by_name = dict(datasets_by_name or {}) + self.datasets_by_id = dict(datasets_by_id or {}) self.retrieval = retrieval or {"chunks": [], "doc_aggs": [], "total": 0} self.error = error self.list_calls: list[str] = [] self.retrieve_calls: list[tuple[str, dict]] = [] - async def list_datasets(self, *, name: str) -> list[dict]: + async def list_datasets(self, *, dataset_id: str) -> list[dict]: if self.error is not None: raise self.error - self.list_calls.append(name) - return self.datasets_by_name.get(name, []) + self.list_calls.append(dataset_id) + return self.datasets_by_id.get(dataset_id, []) async def retrieve(self, query: str, **kwargs: object) -> dict: if self.error is not None: @@ -76,21 +80,21 @@ def _config( def _install(monkeypatch: pytest.MonkeyPatch, fake: FakeRAGFlowClient, *, config: SimpleNamespace | None = None) -> None: - monkeypatch.setattr(ragflow_tools, "get_app_config", lambda: config or _config(datasets=["HR Policies"])) + monkeypatch.setattr(ragflow_tools, "get_app_config", lambda: config or _config(datasets=[DATASET_ID_1])) monkeypatch.setattr(ragflow_tools, "_build_client", lambda settings: fake) @pytest.mark.anyio -async def test_knowledge_search_resolves_configured_names_and_always_passes_ids(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_knowledge_search_resolves_configured_ids_to_current_names(monkeypatch: pytest.MonkeyPatch) -> None: fake = FakeRAGFlowClient( - datasets_by_name={ - "HR Policies": [{"id": "dataset-1", "name": "HR Policies"}], - "Engineering": [{"id": "dataset-2", "name": "Engineering"}], + datasets_by_id={ + DATASET_ID_1: [{"id": DATASET_ID_1, "name": "HR Policies"}], + DATASET_ID_2: [{"id": DATASET_ID_2, "name": "Engineering"}], }, retrieval={ "chunks": [ { - "dataset_id": "dataset-1", + "dataset_id": DATASET_ID_1, "document_id": "doc-1", "document_keyword": "handbook.pdf", "content": "Annual leave is based on years of service.", @@ -101,16 +105,16 @@ async def test_knowledge_search_resolves_configured_names_and_always_passes_ids( "total": 1, }, ) - _install(monkeypatch, fake, config=_config(datasets=["HR Policies", "Engineering"])) + _install(monkeypatch, fake, config=_config(datasets=[DATASET_ID_1, DATASET_ID_2])) result = await ragflow_tools.knowledge_search("annual leave") - assert fake.list_calls == ["HR Policies", "Engineering"] + assert fake.list_calls == [DATASET_ID_1, DATASET_ID_2] assert fake.retrieve_calls == [ ( "annual leave", { - "dataset_ids": ["dataset-1", "dataset-2"], + "dataset_ids": [DATASET_ID_1, DATASET_ID_2], "page_size": 8, "similarity_threshold": 0.2, "vector_similarity_weight": 0.3, @@ -121,65 +125,58 @@ async def test_knowledge_search_resolves_configured_names_and_always_passes_ids( assert "[1] HR Policies / handbook.pdf (score 0.87)" in result assert "Annual leave" in result assert "Matched documents: handbook.pdf (1 chunk)" in result - assert "dataset-1" not in result + assert DATASET_ID_1 not in result + assert DATASET_ID_2 not in result @pytest.mark.anyio -async def test_knowledge_search_uses_exact_name_filtered_lookups(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_knowledge_search_uses_id_filter_and_survives_dataset_rename(monkeypatch: pytest.MonkeyPatch) -> None: fake = FakeRAGFlowClient( - datasets_by_name={ - "HR Policies": [ - {"id": "dataset-wrong", "name": "HR Policies Archive"}, - {"id": "dataset-1", "name": "HR Policies"}, - ] - } + datasets_by_id={DATASET_ID_1: [{"id": DATASET_ID_1, "name": "Renamed Policies"}]}, + retrieval={"chunks": [{"dataset_id": DATASET_ID_1, "document_keyword": "policy.pdf", "content": "Current policy."}]}, ) _install(monkeypatch, fake) - await ragflow_tools.knowledge_search("leave") + result = await ragflow_tools.knowledge_search("leave") - assert fake.list_calls == ["HR Policies"] - assert fake.retrieve_calls[0][1]["dataset_ids"] == ["dataset-1"] + assert fake.list_calls == [DATASET_ID_1] + assert fake.retrieve_calls[0][1]["dataset_ids"] == [DATASET_ID_1] + assert "Renamed Policies / policy.pdf" in result + assert DATASET_ID_1 not in result @pytest.mark.anyio async def test_missing_bound_dataset_returns_operator_guidance(monkeypatch: pytest.MonkeyPatch) -> None: fake = FakeRAGFlowClient() - _install(monkeypatch, fake, config=_config(datasets=["Finance"])) + _install(monkeypatch, fake, config=_config(datasets=[MISSING_DATASET_ID])) result = await ragflow_tools.knowledge_search("leave") - assert result == ("Error: Configured RAGFlow dataset was not found: Finance. It may have been deleted or renamed; check knowledge_search.datasets in config.yaml.") - assert fake.list_calls == ["Finance"] + assert result == "Error: A configured RAGFlow dataset was not found or is inaccessible; check knowledge_search.datasets in config.yaml." + assert MISSING_DATASET_ID not in result + assert fake.list_calls == [MISSING_DATASET_ID] assert fake.retrieve_calls == [] @pytest.mark.anyio -async def test_missing_bound_dataset_error_redacts_api_key(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_missing_bound_dataset_error_does_not_expose_configured_id(monkeypatch: pytest.MonkeyPatch) -> None: fake = FakeRAGFlowClient() - _install(monkeypatch, fake, config=_config(datasets=["ragflow-secret archive"])) + _install(monkeypatch, fake, config=_config(datasets=[MISSING_DATASET_ID])) result = await ragflow_tools.knowledge_search("leave") - assert "ragflow-secret" not in result - assert "[REDACTED]" in result + assert MISSING_DATASET_ID not in result + assert "[DATASET_ID]" not in result @pytest.mark.anyio -async def test_ambiguous_bound_dataset_returns_operator_guidance(monkeypatch: pytest.MonkeyPatch) -> None: - fake = FakeRAGFlowClient( - datasets_by_name={ - "Policies": [ - {"id": "dataset-1", "name": "Policies"}, - {"id": "dataset-2", "name": "Policies"}, - ] - } - ) - _install(monkeypatch, fake, config=_config(datasets=["Policies"])) +async def test_mismatched_id_filtered_response_returns_operator_guidance(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeRAGFlowClient(datasets_by_id={DATASET_ID_1: [{"id": DATASET_ID_2, "name": "Wrong dataset"}]}) + _install(monkeypatch, fake, config=_config(datasets=[DATASET_ID_1])) result = await ragflow_tools.knowledge_search("leave") - assert result == "Error: Configured RAGFlow dataset name is ambiguous: Policies. Check knowledge_search.datasets in config.yaml." + assert result == "Error: A configured RAGFlow dataset was not found or is inaccessible; check knowledge_search.datasets in config.yaml." assert fake.retrieve_calls == [] @@ -200,7 +197,7 @@ async def test_missing_api_key_returns_english_guidance_and_warns_only_once( caplog: pytest.LogCaptureFixture, ) -> None: fake = FakeRAGFlowClient() - _install(monkeypatch, fake, config=_config(api_key=None, datasets=["HR Policies"])) + _install(monkeypatch, fake, config=_config(api_key=None, datasets=[DATASET_ID_1])) with caplog.at_level(logging.WARNING, logger="deerflow.community.ragflow.tools"): first = await ragflow_tools.knowledge_search("leave") @@ -215,7 +212,7 @@ async def test_missing_api_key_returns_english_guidance_and_warns_only_once( @pytest.mark.anyio async def test_missing_knowledge_search_config_returns_english_guidance(monkeypatch: pytest.MonkeyPatch) -> None: fake = FakeRAGFlowClient() - _install(monkeypatch, fake, config=_config(configured=False, datasets=["HR Policies"])) + _install(monkeypatch, fake, config=_config(configured=False, datasets=[DATASET_ID_1])) result = await ragflow_tools.knowledge_search("leave") @@ -250,11 +247,11 @@ async def test_success_path_preserves_legitimate_uuid_and_md5_text(monkeypatch: uuid = "123e4567-e89b-12d3-a456-426614174000" md5 = "d41d8cd98f00b204e9800998ecf8427e" fake = FakeRAGFlowClient( - datasets_by_name={"HR Policies": [{"id": "dataset-1", "name": "HR Policies"}]}, + datasets_by_id={DATASET_ID_1: [{"id": DATASET_ID_1, "name": "HR Policies"}]}, retrieval={ "chunks": [ { - "dataset_id": "dataset-1", + "dataset_id": DATASET_ID_1, "document_keyword": "checksums.txt", "content": f"Trace {uuid}; checksum {md5}.", } @@ -273,11 +270,11 @@ async def test_success_path_preserves_legitimate_uuid_and_md5_text(monkeypatch: @pytest.mark.anyio async def test_success_path_still_redacts_api_key(monkeypatch: pytest.MonkeyPatch) -> None: fake = FakeRAGFlowClient( - datasets_by_name={"HR Policies": [{"id": "dataset-1", "name": "HR Policies"}]}, + datasets_by_id={DATASET_ID_1: [{"id": DATASET_ID_1, "name": "HR Policies"}]}, retrieval={ "chunks": [ { - "dataset_id": "dataset-1", + "dataset_id": DATASET_ID_1, "document_keyword": "secret.txt", "content": "Accidental echo: ragflow-secret", } @@ -323,7 +320,7 @@ async def test_base_url_with_plain_or_encoded_userinfo_is_rejected_without_leaki base_url: str, ) -> None: fake = FakeRAGFlowClient() - _install(monkeypatch, fake, config=_config(base_url=base_url, datasets=["HR Policies"])) + _install(monkeypatch, fake, config=_config(base_url=base_url, datasets=[DATASET_ID_1])) with caplog.at_level(logging.WARNING, logger="deerflow.community.ragflow.tools"): result = await ragflow_tools.knowledge_search("leave") @@ -348,7 +345,7 @@ async def test_empty_query_has_english_error(monkeypatch: pytest.MonkeyPatch) -> @pytest.mark.anyio async def test_empty_retrieval_has_explicit_english_message(monkeypatch: pytest.MonkeyPatch) -> None: - fake = FakeRAGFlowClient(datasets_by_name={"HR Policies": [{"id": "dataset-1", "name": "HR Policies"}]}) + fake = FakeRAGFlowClient(datasets_by_id={DATASET_ID_1: [{"id": DATASET_ID_1, "name": "HR Policies"}]}) _install(monkeypatch, fake) result = await ragflow_tools.knowledge_search("nothing") @@ -404,14 +401,14 @@ def test_formatting_applies_total_response_truncation_in_english() -> None: assert result.endswith("… (response truncated)") -def test_retrieval_settings_load_bound_datasets_and_hide_secret(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(ragflow_tools, "get_app_config", lambda: _config(datasets=["HR Policies", "Engineering"])) +def test_retrieval_settings_load_bound_dataset_ids_and_hide_secret(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(ragflow_tools, "get_app_config", lambda: _config(datasets=[DATASET_ID_1, DATASET_ID_2])) config, error = ragflow_tools._settings_or_error() assert error is None assert config is not None - assert config.datasets == ["HR Policies", "Engineering"] + assert config.datasets == [DATASET_ID_1, DATASET_ID_2] assert str(config.base_url).rstrip("/") == "http://ragflow.test" assert config.page_size == 8 assert config.max_chars_per_chunk == 800 @@ -427,7 +424,7 @@ def test_agent_exposes_only_query_on_single_search_tool() -> None: assert set(ragflow_tools.knowledge_search_tool.tool_call_schema.model_fields) == {"query"} -def test_tool_assembly_injects_bound_dataset_names_without_network_io(monkeypatch: pytest.MonkeyPatch) -> None: +def test_tool_assembly_hides_bound_dataset_ids_without_network_io(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(ragflow_tools, "_build_client", lambda settings: pytest.fail("tool assembly must not perform network IO")) tool_config = ToolConfig( name="knowledge_search", @@ -435,7 +432,7 @@ def test_tool_assembly_injects_bound_dataset_names_without_network_io(monkeypatc use="deerflow.community.ragflow.tools:knowledge_search_tool", base_url="http://ragflow.test", api_key="ragflow-secret", - datasets=["HR Policies", "Engineering", "ragflow-secret archive"], + datasets=[DATASET_ID_1, DATASET_ID_2], ) config = SimpleNamespace( tools=[tool_config], @@ -449,9 +446,9 @@ def test_tool_assembly_injects_bound_dataset_names_without_network_io(monkeypatc tools = get_available_tools(include_mcp=False, app_config=config) assembled = next(tool for tool in tools if tool.name == "knowledge_search") - assert "HR Policies" in assembled.description - assert "Engineering" in assembled.description - assert "[REDACTED] archive" in assembled.description + assert "operator-configured dataset IDs" in assembled.description + assert DATASET_ID_1 not in assembled.description + assert DATASET_ID_2 not in assembled.description assert "ragflow-secret" not in assembled.description assert {tool.name for tool in tools}.isdisjoint({"list_knowledge_bases"}) diff --git a/config.example.yaml b/config.example.yaml index ed8be7b7c8c..2879a598f29 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -678,17 +678,17 @@ tool_groups: tools: # RAGFlow knowledge retrieval (read-only). Uncomment this single entry. - # `datasets` is an operator-controlled allowlist of exact RAGFlow dataset - # names. The Agent cannot enumerate the tenant catalog or change this scope. - # Bind only datasets with compatible embedding models. Names are resolved at - # search time, and dataset UUIDs are never shown to the model. + # `datasets` is an operator-controlled allowlist of stable RAGFlow dataset + # IDs. The Agent cannot enumerate the tenant catalog or change this scope. + # Bind only datasets with compatible embedding models. Current names are + # resolved at search time, and dataset IDs are never shown to the model. # - name: knowledge_search # group: knowledge # use: deerflow.community.ragflow.tools:knowledge_search_tool # base_url: http://localhost:9380 # Docker: use a backend-reachable URL # api_key: $RAGFLOW_API_KEY # datasets: - # - HR Policies # Replace with an exact dataset name from RAGFlow + # - 0123456789abcdef0123456789abcdef # Replace with a RAGFlow dataset ID # timeout: 30 # page_size: 8 # similarity_threshold: 0.2 diff --git a/deploy/helm/deer-flow/values.yaml b/deploy/helm/deer-flow/values.yaml index 915c3c3b551..f10ad7f6a03 100644 --- a/deploy/helm/deer-flow/values.yaml +++ b/deploy/helm/deer-flow/values.yaml @@ -311,15 +311,15 @@ config: | tools: # Optional tenant-shared, read-only RAGFlow retrieval. Put RAGFLOW_API_KEY - # in `secrets`, then bind an operator-controlled allowlist of exact dataset - # names. The Agent cannot enumerate the tenant catalog. + # in `secrets`, then bind an operator-controlled allowlist of stable dataset + # IDs. The Agent cannot enumerate the tenant catalog or see those IDs. # - name: knowledge_search # group: knowledge # use: deerflow.community.ragflow.tools:knowledge_search_tool # base_url: http://ragflow:9380 # api_key: $RAGFLOW_API_KEY # datasets: - # - HR Policies + # - 0123456789abcdef0123456789abcdef # timeout: 30 # page_size: 8 # similarity_threshold: 0.2 From e5c554e0905f14676538ee6c6e8a1a9a926fbbaa Mon Sep 17 00:00:00 2001 From: zhangwei139623 <1552775457@qq.com> Date: Mon, 24 Aug 2026 22:54:56 +0800 Subject: [PATCH 08/14] fix(ragflow): search all datasets by default --- backend/AGENTS.md | 13 ++--- backend/docs/CONFIGURATION.md | 26 ++++++---- backend/packages/harness/deerflow/AGENTS.md | 11 ++-- .../deerflow/community/ragflow/client.py | 52 +++++++++++++++---- .../deerflow/community/ragflow/tools.py | 43 ++++++++++----- backend/tests/test_ragflow_client.py | 49 +++++++++++++++++ backend/tests/test_ragflow_tools.py | 48 ++++++++++++++--- config.example.yaml | 10 ++-- deploy/helm/deer-flow/values.yaml | 6 +-- 9 files changed, 200 insertions(+), 58 deletions(-) diff --git a/backend/AGENTS.md b/backend/AGENTS.md index caf8b72ee18..fd5699dfb0b 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -296,12 +296,13 @@ For models with `supports_vision: true`: The harness provides an opt-in, read-only RAGFlow integration under `deerflow.community.ragflow`. The normal `tools:` list enables one `knowledge_search(query)` Agent tool. Its entry owns the RAGFlow connection, -retrieval parameters, and an operator-controlled allowlist of exact dataset -IDs. Configuration loading performs no network I/O. At invocation time the -provider verifies those IDs through ID-filtered RAGFlow requests, resolves their -current names for citation formatting, and always retrieves with the same -non-empty `dataset_ids` list. Neither the IDs nor tenant-wide dataset listing is -Agent-visible. RAGFlow remains the sole source of truth: there +retrieval parameters, and an optional operator-controlled allowlist of exact +dataset IDs. Configuration loading performs no network I/O. At invocation time, +the provider either verifies configured IDs through ID-filtered requests or, +when no IDs are configured, paginates through all tenant-visible datasets. It +resolves current names for citation formatting and always sends an explicit, +non-empty `dataset_ids` list to retrieval. Neither IDs nor tenant-wide listing +is Agent-visible. RAGFlow remains the sole source of truth: there are no DeerFlow ORM models, migrations, or mirrored knowledge metadata. The configured tenant API key must never appear in logs or model-visible tool errors, and RAGFlow dataset UUIDs must not enter model context. Provider-authored diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index 407dd175de7..ad9f64de983 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -223,8 +223,9 @@ models: RAGFlow integration is disabled by default. It adds one read-only Agent tool, `knowledge_search`. DeerFlow does not persist a copy of dataset or document metadata; RAGFlow is the sole source of truth. The configured API key is -tenant-scoped, while the operator-controlled `datasets` list restricts every -Agent on this deployment to the same dataset-ID allowlist. +tenant-scoped. An optional operator-controlled `datasets` list restricts every +Agent on this deployment to the same dataset-ID allowlist; omitting it searches +all datasets visible to that tenant API key. ```yaml tool_groups: @@ -248,16 +249,19 @@ tools: max_total_chars: 8000 ``` -The tool is opt-in through the normal `tools:` list. `datasets` must contain one -or more RAGFlow dataset IDs selected by the deployment operator. DeerFlow does -not validate their existence while loading configuration. On each search it -verifies them with ID-filtered RAGFlow requests, resolves their current names for -citation formatting, and always sends the same non-empty `dataset_ids` allowlist -to retrieval. A deleted or inaccessible dataset produces guidance to check -`config.yaml`. Dataset IDs and tenant-wide listing are not exposed to the Agent. +The tool is opt-in through the normal `tools:` list. `datasets` is optional. If +it contains RAGFlow dataset IDs selected by the deployment operator, DeerFlow +does not validate their existence while loading configuration; on each search +it verifies them with ID-filtered requests. If `datasets` is omitted, each +search paginates through the tenant-visible dataset catalog. Both paths resolve +current names for citation formatting and explicitly send a non-empty +`dataset_ids` list to retrieval. A deleted or inaccessible configured dataset +produces guidance to check `config.yaml`. Dataset IDs and catalog listing are +not exposed to the Agent. -Configure only datasets with compatible embedding models because RAGFlow can -reject cross-dataset retrieval when models differ. `base_url` must not contain +Configure an allowlist when tenant datasets use incompatible embedding models, +because RAGFlow can reject cross-dataset retrieval when models differ. +`base_url` must not contain embedded username or password information. For Docker or Kubernetes, it must be reachable from the Gateway container or Pod; `localhost` refers to that container or Pod, not the host machine. diff --git a/backend/packages/harness/deerflow/AGENTS.md b/backend/packages/harness/deerflow/AGENTS.md index 3aae42c67ba..a3e69500b76 100644 --- a/backend/packages/harness/deerflow/AGENTS.md +++ b/backend/packages/harness/deerflow/AGENTS.md @@ -38,11 +38,12 @@ drift. The optional `knowledge` tool group exposes one `knowledge_search(query)` tool. Its normal `tools:` entry owns the provider connection, retrieval extras, and a -required operator allowlist of stable dataset IDs; there is no top-level -provider configuration or tenant-wide listing tool. Configuration loading does -not contact RAGFlow. Each invocation verifies the bound IDs through ID-filtered -RAGFlow requests, resolves their current names for citation formatting, and -sends the same non-empty `dataset_ids` list to retrieval. It never persists +optional operator allowlist of stable dataset IDs; there is no top-level +provider configuration or Agent-visible listing tool. Configuration loading +does not contact RAGFlow. Each invocation either verifies the bound IDs through +ID-filtered requests or, when no IDs are bound, internally paginates through all +tenant-visible datasets. It resolves their current names for citation formatting +and sends an explicit non-empty `dataset_ids` list to retrieval. It never persists dataset metadata, exposes RAGFlow dataset IDs to the model, or provides write operations. Retrieval output is bounded at both the individual chunk and full-response diff --git a/backend/packages/harness/deerflow/community/ragflow/client.py b/backend/packages/harness/deerflow/community/ragflow/client.py index 9d79b6ef40a..bb42d737c9d 100644 --- a/backend/packages/harness/deerflow/community/ragflow/client.py +++ b/backend/packages/harness/deerflow/community/ragflow/client.py @@ -6,6 +6,9 @@ import httpx +_DATASET_PAGE_SIZE = 100 +_MAX_DATASET_PAGES = 100 + class RAGFlowError(Exception): """Base class for normalized RAGFlow failures.""" @@ -106,16 +109,45 @@ async def _request( raise RAGFlowAPIError(message, code=code) return payload - async def list_datasets(self, *, dataset_id: str) -> list[dict[str, Any]]: - """Resolve one configured dataset ID without enumerating the tenant catalog.""" - if not dataset_id.strip(): - raise ValueError("dataset_id must not be empty") - - payload = await self._request("GET", "/datasets", params={"id": dataset_id}) - data = payload.get("data") - if not isinstance(data, list): - raise RAGFlowProtocolError("RAGFlow returned an invalid dataset list.") - return [item for item in data if isinstance(item, dict)] + async def list_datasets(self, *, dataset_id: str | None = None) -> list[dict[str, Any]]: + """Resolve one dataset ID, or enumerate every page when no ID is given.""" + if dataset_id is not None: + dataset_id = dataset_id.strip() + if not dataset_id: + raise ValueError("dataset_id must not be empty") + + payload = await self._request("GET", "/datasets", params={"id": dataset_id}) + data = payload.get("data") + if not isinstance(data, list): + raise RAGFlowProtocolError("RAGFlow returned an invalid dataset list.") + return [item for item in data if isinstance(item, dict)] + + datasets: list[dict[str, Any]] = [] + received_count = 0 + for page in range(1, _MAX_DATASET_PAGES + 1): + payload = await self._request( + "GET", + "/datasets", + params={"page": page, "page_size": _DATASET_PAGE_SIZE}, + ) + data = payload.get("data") + if not isinstance(data, list): + raise RAGFlowProtocolError("RAGFlow returned an invalid dataset list.") + + datasets.extend(item for item in data if isinstance(item, dict)) + received_count += len(data) + + total = payload.get("total_datasets") + has_valid_total = isinstance(total, int) and not isinstance(total, bool) and total >= 0 + if has_valid_total: + if received_count >= total: + return datasets + if not data: + raise RAGFlowProtocolError("RAGFlow dataset listing ended before the reported total.") + elif len(data) < _DATASET_PAGE_SIZE: + return datasets + + raise RAGFlowProtocolError(f"RAGFlow dataset listing exceeded {_MAX_DATASET_PAGES} pages.") async def retrieve( self, diff --git a/backend/packages/harness/deerflow/community/ragflow/tools.py b/backend/packages/harness/deerflow/community/ragflow/tools.py index 3551219b1e9..ce1ec0c1815 100644 --- a/backend/packages/harness/deerflow/community/ragflow/tools.py +++ b/backend/packages/harness/deerflow/community/ragflow/tools.py @@ -26,7 +26,7 @@ class _RAGFlowRetrievalSettings(BaseModel): model_config = ConfigDict(validate_default=True) - datasets: list[str] = Field(min_length=1, max_length=100) + datasets: list[str] | None = Field(default=None, max_length=100) base_url: AnyHttpUrl = Field(default="http://localhost:9380") api_key: SecretStr | None = Field(default=None) timeout: float = Field(default=30, gt=0, le=600) @@ -39,7 +39,9 @@ class _RAGFlowRetrievalSettings(BaseModel): @field_validator("datasets") @classmethod - def _normalize_dataset_ids(cls, value: list[str]) -> list[str]: + def _normalize_dataset_ids(cls, value: list[str] | None) -> list[str] | None: + if value is None: + return None normalized: list[str] = [] seen: set[str] = set() for dataset_id in value: @@ -49,9 +51,7 @@ def _normalize_dataset_ids(cls, value: list[str]) -> list[str]: if clean_id not in seen: normalized.append(clean_id) seen.add(clean_id) - if not normalized: - raise ValueError("at least one dataset ID is required") - return normalized + return normalized or None @field_validator("base_url") @classmethod @@ -146,10 +146,29 @@ def _missing_dataset_error() -> str: return "Error: A configured RAGFlow dataset was not found or is inaccessible; check knowledge_search.datasets in config.yaml." -async def _resolve_configured_datasets( +async def _resolve_datasets( client: RAGFlowClient, settings: _RAGFlowRetrievalSettings, ) -> tuple[list[str] | None, dict[str, str] | None, str | None]: + if settings.datasets is None: + datasets = await client.list_datasets() + names_by_id: dict[str, str] = {} + for dataset in datasets: + dataset_id = dataset.get("id") + if not isinstance(dataset_id, str) or not dataset_id.strip(): + continue + clean_id = dataset_id.strip() + name = dataset.get("name") + names_by_id.setdefault(clean_id, str(name).strip() if name else "Unknown dataset") + + if not names_by_id: + return ( + None, + None, + "Error: No accessible RAGFlow datasets were found; configure knowledge_search.datasets or add a dataset in RAGFlow.", + ) + return list(names_by_id), names_by_id, None + batches = await asyncio.gather(*(client.list_datasets(dataset_id=dataset_id) for dataset_id in settings.datasets)) names_by_id: dict[str, str] = {} @@ -163,7 +182,7 @@ async def _resolve_configured_datasets( async def knowledge_search(query: str) -> str: - """Search the operator-configured RAGFlow dataset allowlist.""" + """Search the configured RAGFlow scope, defaulting to every accessible dataset.""" query = query.strip() if not query: return "Error: query must not be empty." @@ -174,11 +193,11 @@ async def knowledge_search(query: str) -> str: client = _build_client(settings) try: - dataset_ids, names_by_id, resolution_error = await _resolve_configured_datasets(client, settings) + dataset_ids, names_by_id, resolution_error = await _resolve_datasets(client, settings) if resolution_error is not None: return resolution_error - if not dataset_ids or names_by_id is None: # Defensive; settings require at least one binding. - return "Error: No configured RAGFlow datasets could be resolved; check knowledge_search.datasets in config.yaml." + if not dataset_ids or names_by_id is None: # Defensive; both resolution paths return a non-empty scope. + return "Error: No RAGFlow datasets could be resolved; check knowledge_search in config.yaml." result = await client.retrieve( query, @@ -203,11 +222,11 @@ async def knowledge_search(query: str) -> str: def _tool_description() -> str: base = "Search the operator-approved RAGFlow datasets and return compact, citation-numbered source chunks." - return f"{base} Access is restricted to operator-configured dataset IDs, which are never shown to the model." + return f"{base} If knowledge_search.datasets is omitted, all datasets accessible to the configured RAGFlow API key are searched. Dataset IDs are never shown to the model." async def _knowledge_search_entrypoint(query: str) -> str: - """Search the RAGFlow datasets selected by the deployment operator. + """Search the configured RAGFlow datasets, or every accessible dataset by default. Args: query: Specific question or search terms to retrieve from the configured private documents. diff --git a/backend/tests/test_ragflow_client.py b/backend/tests/test_ragflow_client.py index b26d60d25a0..5f7f2441106 100644 --- a/backend/tests/test_ragflow_client.py +++ b/backend/tests/test_ragflow_client.py @@ -40,6 +40,55 @@ async def handler(request: httpx.Request) -> httpx.Response: assert len(requests) == 1 +@pytest.mark.anyio +async def test_list_datasets_without_id_fetches_every_page() -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + page = int(request.url.params["page"]) + assert request.url.params["page_size"] == "100" + if page == 1: + data = [{"id": f"dataset-{index}", "name": f"Dataset {index}"} for index in range(100)] + elif page == 2: + data = [{"id": "dataset-100", "name": "Dataset 100"}] + else: + pytest.fail(f"unexpected page {page}") + return httpx.Response(200, json={"code": 0, "data": data, "total_datasets": 101}) + + client = RAGFlowClient( + base_url="http://ragflow.test", + api_key="ragflow-secret", + transport=httpx.MockTransport(handler), + ) + + datasets = await client.list_datasets() + + assert len(datasets) == 101 + assert [request.url.params["page"] for request in requests] == ["1", "2"] + + +@pytest.mark.anyio +async def test_list_datasets_without_id_has_a_hard_page_cap() -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + data = [{"id": f"dataset-{index}", "name": f"Dataset {index}"} for index in range(100)] + return httpx.Response(200, json={"code": 0, "data": data}) + + client = RAGFlowClient( + base_url="http://ragflow.test", + api_key="ragflow-secret", + transport=httpx.MockTransport(handler), + ) + + with pytest.raises(RAGFlowProtocolError, match="exceeded 100 pages"): + await client.list_datasets() + + assert len(requests) == 100 + + @pytest.mark.anyio async def test_retrieve_always_sends_nonempty_dataset_ids() -> None: async def handler(request: httpx.Request) -> httpx.Response: diff --git a/backend/tests/test_ragflow_tools.py b/backend/tests/test_ragflow_tools.py index b4186c49d1e..deedd608933 100644 --- a/backend/tests/test_ragflow_tools.py +++ b/backend/tests/test_ragflow_tools.py @@ -21,19 +21,23 @@ def __init__( self, *, datasets_by_id: Mapping[str, list[dict]] | None = None, + all_datasets: list[dict] | None = None, retrieval: dict | None = None, error: Exception | None = None, ) -> None: self.datasets_by_id = dict(datasets_by_id or {}) + self.all_datasets = list(all_datasets or []) self.retrieval = retrieval or {"chunks": [], "doc_aggs": [], "total": 0} self.error = error - self.list_calls: list[str] = [] + self.list_calls: list[str | None] = [] self.retrieve_calls: list[tuple[str, dict]] = [] - async def list_datasets(self, *, dataset_id: str) -> list[dict]: + async def list_datasets(self, *, dataset_id: str | None = None) -> list[dict]: if self.error is not None: raise self.error self.list_calls.append(dataset_id) + if dataset_id is None: + return self.all_datasets return self.datasets_by_id.get(dataset_id, []) async def retrieve(self, query: str, **kwargs: object) -> dict: @@ -181,14 +185,35 @@ async def test_mismatched_id_filtered_response_returns_operator_guidance(monkeyp @pytest.mark.anyio -async def test_missing_dataset_binding_is_rejected_without_network_io(monkeypatch: pytest.MonkeyPatch) -> None: +async def test_missing_dataset_binding_lists_all_and_passes_every_id(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeRAGFlowClient( + all_datasets=[ + {"id": DATASET_ID_1, "name": "HR Policies"}, + {"id": DATASET_ID_2, "name": "Engineering"}, + ], + retrieval={"chunks": [{"dataset_id": DATASET_ID_2, "document_keyword": "guide.pdf", "content": "Build guide."}]}, + ) + _install(monkeypatch, fake, config=_config(datasets=None)) + + result = await ragflow_tools.knowledge_search("leave") + + assert fake.list_calls == [None] + assert fake.retrieve_calls[0][1]["dataset_ids"] == [DATASET_ID_1, DATASET_ID_2] + assert "Engineering / guide.pdf" in result + assert DATASET_ID_1 not in result + assert DATASET_ID_2 not in result + + +@pytest.mark.anyio +async def test_missing_dataset_binding_with_empty_catalog_returns_guidance(monkeypatch: pytest.MonkeyPatch) -> None: fake = FakeRAGFlowClient() _install(monkeypatch, fake, config=_config(datasets=None)) result = await ragflow_tools.knowledge_search("leave") - assert result == "Error: Invalid RAGFlow settings for knowledge_search; check config.yaml." - assert fake.list_calls == [] + assert result == "Error: No accessible RAGFlow datasets were found; configure knowledge_search.datasets or add a dataset in RAGFlow." + assert fake.list_calls == [None] + assert fake.retrieve_calls == [] @pytest.mark.anyio @@ -416,6 +441,16 @@ def test_retrieval_settings_load_bound_dataset_ids_and_hide_secret(monkeypatch: assert "ragflow-secret" not in repr(config) +def test_retrieval_settings_allow_omitting_dataset_ids(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(ragflow_tools, "get_app_config", lambda: _config(datasets=None)) + + config, error = ragflow_tools._settings_or_error() + + assert error is None + assert config is not None + assert config.datasets is None + + def test_agent_exposes_only_query_on_single_search_tool() -> None: assert not hasattr(ragflow_tools, "list_knowledge_bases_tool") assert not hasattr(ragflow_tools, "list_knowledge_bases") @@ -446,7 +481,8 @@ def test_tool_assembly_hides_bound_dataset_ids_without_network_io(monkeypatch: p tools = get_available_tools(include_mcp=False, app_config=config) assembled = next(tool for tool in tools if tool.name == "knowledge_search") - assert "operator-configured dataset IDs" in assembled.description + assert "If knowledge_search.datasets is omitted" in assembled.description + assert "all datasets accessible to the configured RAGFlow API key" in assembled.description assert DATASET_ID_1 not in assembled.description assert DATASET_ID_2 not in assembled.description assert "ragflow-secret" not in assembled.description diff --git a/config.example.yaml b/config.example.yaml index 2879a598f29..3f7b9b2bf35 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -678,16 +678,16 @@ tool_groups: tools: # RAGFlow knowledge retrieval (read-only). Uncomment this single entry. - # `datasets` is an operator-controlled allowlist of stable RAGFlow dataset - # IDs. The Agent cannot enumerate the tenant catalog or change this scope. - # Bind only datasets with compatible embedding models. Current names are - # resolved at search time, and dataset IDs are never shown to the model. + # `datasets` is optional. Omit it to list every tenant-visible dataset at + # search time and explicitly pass all discovered IDs to retrieval. Configure + # stable IDs to restrict scope or avoid mixing incompatible embedding models. + # Dataset IDs and catalog listing are never shown to the model. # - name: knowledge_search # group: knowledge # use: deerflow.community.ragflow.tools:knowledge_search_tool # base_url: http://localhost:9380 # Docker: use a backend-reachable URL # api_key: $RAGFLOW_API_KEY - # datasets: + # datasets: # Optional operator-controlled allowlist # - 0123456789abcdef0123456789abcdef # Replace with a RAGFlow dataset ID # timeout: 30 # page_size: 8 diff --git a/deploy/helm/deer-flow/values.yaml b/deploy/helm/deer-flow/values.yaml index f10ad7f6a03..29a1328062c 100644 --- a/deploy/helm/deer-flow/values.yaml +++ b/deploy/helm/deer-flow/values.yaml @@ -311,14 +311,14 @@ config: | tools: # Optional tenant-shared, read-only RAGFlow retrieval. Put RAGFLOW_API_KEY - # in `secrets`, then bind an operator-controlled allowlist of stable dataset - # IDs. The Agent cannot enumerate the tenant catalog or see those IDs. + # in `secrets`. `datasets` is an optional stable-ID allowlist; omit it to + # search all tenant-visible datasets. The Agent cannot list or see the IDs. # - name: knowledge_search # group: knowledge # use: deerflow.community.ragflow.tools:knowledge_search_tool # base_url: http://ragflow:9380 # api_key: $RAGFLOW_API_KEY - # datasets: + # datasets: # Optional operator-controlled allowlist # - 0123456789abcdef0123456789abcdef # timeout: 30 # page_size: 8 From 20050bed3418717dfbf37e9f1104d64511abfb8d Mon Sep 17 00:00:00 2001 From: zhangwei139623 <1552775457@qq.com> Date: Tue, 25 Aug 2026 10:02:28 +0800 Subject: [PATCH 09/14] fix(ragflow): retrieve mixed embeddings by group --- backend/AGENTS.md | 11 +- backend/docs/CONFIGURATION.md | 21 +- backend/packages/harness/deerflow/AGENTS.md | 15 +- .../deerflow/community/ragflow/tools.py | 188 +++++++++++++++--- backend/tests/test_ragflow_tools.py | 183 ++++++++++++++++- config.example.yaml | 7 +- deploy/helm/deer-flow/values.yaml | 4 +- 7 files changed, 368 insertions(+), 61 deletions(-) diff --git a/backend/AGENTS.md b/backend/AGENTS.md index fd5699dfb0b..f26f078565f 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -300,9 +300,14 @@ retrieval parameters, and an optional operator-controlled allowlist of exact dataset IDs. Configuration loading performs no network I/O. At invocation time, the provider either verifies configured IDs through ID-filtered requests or, when no IDs are configured, paginates through all tenant-visible datasets. It -resolves current names for citation formatting and always sends an explicit, -non-empty `dataset_ids` list to retrieval. Neither IDs nor tenant-wide listing -is Agent-visible. RAGFlow remains the sole source of truth: there +resolves current names, embedding models, and chunk counts, skips empty +datasets, and groups the searchable scope by exact embedding-model identifier. +Groups are retrieved with a four-request concurrency cap. Their provider-ranked +chunks are interleaved by rank (raw scores from different embedding spaces are +not compared) and globally truncated to `page_size`; any group failure fails +the whole tool call. Every provider request still carries an explicit, +non-empty `dataset_ids` list. Neither IDs nor tenant-wide listing is +Agent-visible. RAGFlow remains the sole source of truth: there are no DeerFlow ORM models, migrations, or mirrored knowledge metadata. The configured tenant API key must never appear in logs or model-visible tool errors, and RAGFlow dataset UUIDs must not enter model context. Provider-authored diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index ad9f64de983..1ded321f29d 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -254,14 +254,19 @@ it contains RAGFlow dataset IDs selected by the deployment operator, DeerFlow does not validate their existence while loading configuration; on each search it verifies them with ID-filtered requests. If `datasets` is omitted, each search paginates through the tenant-visible dataset catalog. Both paths resolve -current names for citation formatting and explicitly send a non-empty -`dataset_ids` list to retrieval. A deleted or inaccessible configured dataset -produces guidance to check `config.yaml`. Dataset IDs and catalog listing are -not exposed to the Agent. - -Configure an allowlist when tenant datasets use incompatible embedding models, -because RAGFlow can reject cross-dataset retrieval when models differ. -`base_url` must not contain +current names, embedding models, and chunk counts. Empty datasets are ignored. +The remaining datasets are grouped by the exact embedding-model identifier and +each group is sent to RAGFlow with a non-empty `dataset_ids` list. At most four +groups are retrieved concurrently. Because raw similarity scores from different +embedding spaces are not globally comparable, DeerFlow preserves each group's +RAGFlow ranking, interleaves equal rank positions, and applies `page_size` as a +single global chunk limit. If any searchable group fails, the whole tool call +fails rather than silently omitting part of the configured scope. A deleted or +inaccessible configured dataset produces guidance to check `config.yaml`. +Dataset IDs and catalog listing are not exposed to the Agent. + +Use an allowlist to narrow the tenant-wide scope; compatible embedding models +are no longer required across selected datasets. `base_url` must not contain embedded username or password information. For Docker or Kubernetes, it must be reachable from the Gateway container or Pod; `localhost` refers to that container or Pod, not the host machine. diff --git a/backend/packages/harness/deerflow/AGENTS.md b/backend/packages/harness/deerflow/AGENTS.md index a3e69500b76..481624f1453 100644 --- a/backend/packages/harness/deerflow/AGENTS.md +++ b/backend/packages/harness/deerflow/AGENTS.md @@ -37,15 +37,20 @@ drift. ### RAGFlow Knowledge Retrieval (`community/ragflow/`) The optional `knowledge` tool group exposes one `knowledge_search(query)` tool. -Its normal `tools:` entry owns the provider connection, retrieval extras, and a +Its normal `tools:` entry owns the provider connection, retrieval extras, and an optional operator allowlist of stable dataset IDs; there is no top-level provider configuration or Agent-visible listing tool. Configuration loading does not contact RAGFlow. Each invocation either verifies the bound IDs through ID-filtered requests or, when no IDs are bound, internally paginates through all -tenant-visible datasets. It resolves their current names for citation formatting -and sends an explicit non-empty `dataset_ids` list to retrieval. It never persists -dataset metadata, exposes RAGFlow dataset IDs to the model, or provides write -operations. +tenant-visible datasets. It resolves current names, embedding models, and chunk +counts, ignores empty datasets, and groups the searchable scope by exact +embedding-model identifier. Up to four groups are retrieved concurrently with +an explicit non-empty `dataset_ids` list per request. Results preserve each +group's provider order, interleave equal rank positions, and use `page_size` as +one global limit because cross-model raw similarity scores are not comparable. +Any group failure fails the tool call rather than silently dropping scope. It +never persists dataset metadata, exposes RAGFlow dataset IDs to the model, or +provides write operations. Retrieval output is bounded at both the individual chunk and full-response levels. Provider-authored model-visible text is English. Every path must redact the configured tenant API key; dataset-UUID redaction is error-only so normal diff --git a/backend/packages/harness/deerflow/community/ragflow/tools.py b/backend/packages/harness/deerflow/community/ragflow/tools.py index ce1ec0c1815..20c1737a706 100644 --- a/backend/packages/harness/deerflow/community/ragflow/tools.py +++ b/backend/packages/harness/deerflow/community/ragflow/tools.py @@ -6,6 +6,8 @@ import logging import re from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any from langchain_core.tools import StructuredTool from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, SecretStr, ValidationError, field_validator @@ -19,6 +21,16 @@ _warned: set[str] = set() _RAGFLOW_UUID_PATTERN = re.compile(r"(? str: return "Error: An unexpected RAGFlow retrieval error occurred; try again later." -def _current_dataset_name(datasets: list[dict], bound_id: str) -> str | None: +def _resolved_dataset(dataset: Mapping[str, object], *, expected_id: str | None = None) -> _ResolvedDataset | None: + dataset_id = dataset.get("id") + if not isinstance(dataset_id, str) or not dataset_id.strip(): + return None + clean_id = dataset_id.strip() + if expected_id is not None and clean_id != expected_id: + return None + + embedding_model = dataset.get("embedding_model") + if not isinstance(embedding_model, str) or not embedding_model.strip(): + raise RAGFlowProtocolError("RAGFlow returned a dataset without embedding model metadata.") + + name = dataset.get("name") + raw_chunk_count = dataset.get("chunk_count") + chunk_count = raw_chunk_count if isinstance(raw_chunk_count, int) and not isinstance(raw_chunk_count, bool) and raw_chunk_count >= 0 else None + return _ResolvedDataset( + dataset_id=clean_id, + name=str(name).strip() if name else "Unknown dataset", + embedding_model=embedding_model.strip(), + chunk_count=chunk_count, + ) + + +def _current_dataset(datasets: list[dict], bound_id: str) -> _ResolvedDataset | None: for dataset in datasets: - dataset_id = dataset.get("id") - name = dataset.get("name") - if dataset_id == bound_id: - return str(name).strip() if name else "Unknown dataset" + resolved = _resolved_dataset(dataset, expected_id=bound_id) + if resolved is not None: + return resolved return None @@ -149,36 +183,130 @@ def _missing_dataset_error() -> str: async def _resolve_datasets( client: RAGFlowClient, settings: _RAGFlowRetrievalSettings, -) -> tuple[list[str] | None, dict[str, str] | None, str | None]: +) -> tuple[list[_ResolvedDataset] | None, str | None]: if settings.datasets is None: datasets = await client.list_datasets() - names_by_id: dict[str, str] = {} + resolved_by_id: dict[str, _ResolvedDataset] = {} for dataset in datasets: - dataset_id = dataset.get("id") - if not isinstance(dataset_id, str) or not dataset_id.strip(): + resolved = _resolved_dataset(dataset) + if resolved is None: continue - clean_id = dataset_id.strip() - name = dataset.get("name") - names_by_id.setdefault(clean_id, str(name).strip() if name else "Unknown dataset") + resolved_by_id.setdefault(resolved.dataset_id, resolved) - if not names_by_id: + if not resolved_by_id: return ( - None, None, "Error: No accessible RAGFlow datasets were found; configure knowledge_search.datasets or add a dataset in RAGFlow.", ) - return list(names_by_id), names_by_id, None + return list(resolved_by_id.values()), None batches = await asyncio.gather(*(client.list_datasets(dataset_id=dataset_id) for dataset_id in settings.datasets)) - names_by_id: dict[str, str] = {} + resolved_datasets: list[_ResolvedDataset] = [] for bound_id, datasets in zip(settings.datasets, batches, strict=True): - current_name = _current_dataset_name(datasets, bound_id) - if current_name is None: - return None, None, _missing_dataset_error() - names_by_id[bound_id] = current_name + resolved = _current_dataset(datasets, bound_id) + if resolved is None: + return None, _missing_dataset_error() + resolved_datasets.append(resolved) - return list(settings.datasets), names_by_id, None + return resolved_datasets, None + + +def _group_searchable_datasets(datasets: list[_ResolvedDataset]) -> list[tuple[str, list[str]]]: + groups: dict[str, list[str]] = {} + for dataset in datasets: + if dataset.chunk_count == 0: + continue + groups.setdefault(dataset.embedding_model, []).append(dataset.dataset_id) + return sorted(groups.items()) + + +def _result_chunks(result: Mapping[str, Any]) -> list[Mapping[str, Any]]: + chunks = result.get("chunks") + if not isinstance(chunks, list): + return [] + return [chunk for chunk in chunks if isinstance(chunk, Mapping)] + + +def _result_document_aggregates(result: Mapping[str, Any]) -> list[Mapping[str, Any]]: + aggregates = result.get("doc_aggs") + if isinstance(aggregates, list): + return [aggregate for aggregate in aggregates if isinstance(aggregate, Mapping)] + if isinstance(aggregates, Mapping): + return [aggregate for aggregate in aggregates.values() if isinstance(aggregate, Mapping)] + return [] + + +def _merge_group_results(results: list[dict[str, Any]], *, page_size: int) -> dict[str, Any]: + chunk_groups = [_result_chunks(result) for result in results] + merged_chunks: list[Mapping[str, Any]] = [] + max_group_size = max((len(chunks) for chunks in chunk_groups), default=0) + # Similarity scores from different embedding spaces are not globally + # calibrated. Preserve each provider-ranked list and interleave equal rank + # positions instead of comparing raw scores across models. + for rank in range(max_group_size): + for chunks in chunk_groups: + if rank < len(chunks): + merged_chunks.append(chunks[rank]) + if len(merged_chunks) >= page_size: + break + if len(merged_chunks) >= page_size: + break + + selected_document_ids: list[str] = [] + for chunk in merged_chunks: + document_id = chunk.get("document_id") + if document_id is not None: + clean_id = str(document_id) + if clean_id not in selected_document_ids: + selected_document_ids.append(clean_id) + + aggregates_by_document_id: dict[str, Mapping[str, Any]] = {} + for result in results: + for aggregate in _result_document_aggregates(result): + document_id = aggregate.get("doc_id") + if document_id is not None: + aggregates_by_document_id.setdefault(str(document_id), aggregate) + + total = 0 + for result in results: + value = result.get("total") + if isinstance(value, int) and not isinstance(value, bool) and value >= 0: + total += value + return { + "chunks": merged_chunks, + "doc_aggs": [aggregates_by_document_id[document_id] for document_id in selected_document_ids if document_id in aggregates_by_document_id], + "total": total, + } + + +async def _retrieve_dataset_groups( + client: RAGFlowClient, + settings: _RAGFlowRetrievalSettings, + query: str, + groups: list[tuple[str, list[str]]], +) -> dict[str, Any]: + semaphore = asyncio.Semaphore(_MAX_PARALLEL_RETRIEVAL_GROUPS) + + async def retrieve_group(dataset_ids: list[str]) -> dict[str, Any]: + async with semaphore: + return await client.retrieve( + query, + dataset_ids=dataset_ids, + page_size=settings.page_size, + similarity_threshold=settings.similarity_threshold, + vector_similarity_weight=settings.vector_similarity_weight, + top_k=settings.top_k, + ) + + results = await asyncio.gather(*(retrieve_group(dataset_ids) for _, dataset_ids in groups), return_exceptions=True) + successful_results: list[dict[str, Any]] = [] + for result in results: + if isinstance(result, BaseException): + raise result + successful_results.append(result) + + return _merge_group_results(successful_results, page_size=settings.page_size) async def knowledge_search(query: str) -> str: @@ -193,20 +321,18 @@ async def knowledge_search(query: str) -> str: client = _build_client(settings) try: - dataset_ids, names_by_id, resolution_error = await _resolve_datasets(client, settings) + datasets, resolution_error = await _resolve_datasets(client, settings) if resolution_error is not None: return resolution_error - if not dataset_ids or names_by_id is None: # Defensive; both resolution paths return a non-empty scope. + if not datasets: # Defensive; both resolution paths return a non-empty scope. return "Error: No RAGFlow datasets could be resolved; check knowledge_search in config.yaml." - result = await client.retrieve( - query, - dataset_ids=dataset_ids, - page_size=settings.page_size, - similarity_threshold=settings.similarity_threshold, - vector_similarity_weight=settings.vector_similarity_weight, - top_k=settings.top_k, - ) + groups = _group_searchable_datasets(datasets) + if not groups: + return _NO_RELEVANT_CONTENT + + result = await _retrieve_dataset_groups(client, settings, query, groups) + names_by_id = {dataset.dataset_id: dataset.name for dataset in datasets} formatted = format_retrieval_result( result, dataset_names_by_id=names_by_id, diff --git a/backend/tests/test_ragflow_tools.py b/backend/tests/test_ragflow_tools.py index deedd608933..465020c432d 100644 --- a/backend/tests/test_ragflow_tools.py +++ b/backend/tests/test_ragflow_tools.py @@ -1,3 +1,4 @@ +import asyncio import logging from collections.abc import Mapping from pathlib import Path @@ -14,6 +15,23 @@ DATASET_ID_1 = "0123456789abcdef0123456789abcdef" DATASET_ID_2 = "fedcba9876543210fedcba9876543210" MISSING_DATASET_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +EMBEDDING_V2 = "text-embedding-v2@primary@Tongyi-Qianwen" +EMBEDDING_V3 = "text-embedding-v3@primary@Tongyi-Qianwen" + + +def _dataset( + dataset_id: str, + name: str, + *, + embedding_model: str = EMBEDDING_V3, + chunk_count: int = 1, +) -> dict: + return { + "id": dataset_id, + "name": name, + "embedding_model": embedding_model, + "chunk_count": chunk_count, + } class FakeRAGFlowClient: @@ -23,11 +41,15 @@ def __init__( datasets_by_id: Mapping[str, list[dict]] | None = None, all_datasets: list[dict] | None = None, retrieval: dict | None = None, + retrieval_by_dataset_ids: Mapping[tuple[str, ...], dict] | None = None, + retrieval_errors_by_dataset_ids: Mapping[tuple[str, ...], Exception] | None = None, error: Exception | None = None, ) -> None: self.datasets_by_id = dict(datasets_by_id or {}) self.all_datasets = list(all_datasets or []) self.retrieval = retrieval or {"chunks": [], "doc_aggs": [], "total": 0} + self.retrieval_by_dataset_ids = dict(retrieval_by_dataset_ids or {}) + self.retrieval_errors_by_dataset_ids = dict(retrieval_errors_by_dataset_ids or {}) self.error = error self.list_calls: list[str | None] = [] self.retrieve_calls: list[tuple[str, dict]] = [] @@ -44,6 +66,12 @@ async def retrieve(self, query: str, **kwargs: object) -> dict: if self.error is not None: raise self.error self.retrieve_calls.append((query, kwargs)) + dataset_ids = kwargs.get("dataset_ids") + key = tuple(dataset_ids) if isinstance(dataset_ids, list) else () + if error := self.retrieval_errors_by_dataset_ids.get(key): + raise error + if key in self.retrieval_by_dataset_ids: + return self.retrieval_by_dataset_ids[key] return self.retrieval @@ -58,12 +86,13 @@ def _config( api_key: str | None = "ragflow-secret", base_url: str = "http://ragflow.test", datasets: list[str] | None = None, + page_size: int = 8, ) -> SimpleNamespace: extra: dict[str, object] = { "base_url": base_url, "api_key": api_key, "timeout": 30, - "page_size": 8, + "page_size": page_size, "similarity_threshold": 0.2, "vector_similarity_weight": 0.3, "top_k": 256, @@ -92,8 +121,8 @@ def _install(monkeypatch: pytest.MonkeyPatch, fake: FakeRAGFlowClient, *, config async def test_knowledge_search_resolves_configured_ids_to_current_names(monkeypatch: pytest.MonkeyPatch) -> None: fake = FakeRAGFlowClient( datasets_by_id={ - DATASET_ID_1: [{"id": DATASET_ID_1, "name": "HR Policies"}], - DATASET_ID_2: [{"id": DATASET_ID_2, "name": "Engineering"}], + DATASET_ID_1: [_dataset(DATASET_ID_1, "HR Policies")], + DATASET_ID_2: [_dataset(DATASET_ID_2, "Engineering")], }, retrieval={ "chunks": [ @@ -136,7 +165,7 @@ async def test_knowledge_search_resolves_configured_ids_to_current_names(monkeyp @pytest.mark.anyio async def test_knowledge_search_uses_id_filter_and_survives_dataset_rename(monkeypatch: pytest.MonkeyPatch) -> None: fake = FakeRAGFlowClient( - datasets_by_id={DATASET_ID_1: [{"id": DATASET_ID_1, "name": "Renamed Policies"}]}, + datasets_by_id={DATASET_ID_1: [_dataset(DATASET_ID_1, "Renamed Policies")]}, retrieval={"chunks": [{"dataset_id": DATASET_ID_1, "document_keyword": "policy.pdf", "content": "Current policy."}]}, ) _install(monkeypatch, fake) @@ -175,7 +204,7 @@ async def test_missing_bound_dataset_error_does_not_expose_configured_id(monkeyp @pytest.mark.anyio async def test_mismatched_id_filtered_response_returns_operator_guidance(monkeypatch: pytest.MonkeyPatch) -> None: - fake = FakeRAGFlowClient(datasets_by_id={DATASET_ID_1: [{"id": DATASET_ID_2, "name": "Wrong dataset"}]}) + fake = FakeRAGFlowClient(datasets_by_id={DATASET_ID_1: [_dataset(DATASET_ID_2, "Wrong dataset")]}) _install(monkeypatch, fake, config=_config(datasets=[DATASET_ID_1])) result = await ragflow_tools.knowledge_search("leave") @@ -188,8 +217,8 @@ async def test_mismatched_id_filtered_response_returns_operator_guidance(monkeyp async def test_missing_dataset_binding_lists_all_and_passes_every_id(monkeypatch: pytest.MonkeyPatch) -> None: fake = FakeRAGFlowClient( all_datasets=[ - {"id": DATASET_ID_1, "name": "HR Policies"}, - {"id": DATASET_ID_2, "name": "Engineering"}, + _dataset(DATASET_ID_1, "HR Policies"), + _dataset(DATASET_ID_2, "Engineering"), ], retrieval={"chunks": [{"dataset_id": DATASET_ID_2, "document_keyword": "guide.pdf", "content": "Build guide."}]}, ) @@ -204,6 +233,140 @@ async def test_missing_dataset_binding_lists_all_and_passes_every_id(monkeypatch assert DATASET_ID_2 not in result +@pytest.mark.anyio +async def test_mixed_embedding_models_are_retrieved_in_parallel_groups_and_rank_interleaved(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeRAGFlowClient( + datasets_by_id={ + DATASET_ID_1: [_dataset(DATASET_ID_1, "Legacy", embedding_model=EMBEDDING_V2)], + DATASET_ID_2: [_dataset(DATASET_ID_2, "Current", embedding_model=EMBEDDING_V3)], + }, + retrieval_by_dataset_ids={ + (DATASET_ID_1,): { + "chunks": [ + {"dataset_id": DATASET_ID_1, "document_id": "legacy-1", "document_keyword": "legacy-1.txt", "content": "Legacy rank one.", "similarity": 0.41}, + {"dataset_id": DATASET_ID_1, "document_id": "legacy-2", "document_keyword": "legacy-2.txt", "content": "Legacy rank two.", "similarity": 0.99}, + ], + "doc_aggs": [ + {"doc_id": "legacy-1", "doc_name": "legacy-1.txt", "count": 1}, + {"doc_id": "legacy-2", "doc_name": "legacy-2.txt", "count": 1}, + ], + "total": 2, + }, + (DATASET_ID_2,): { + "chunks": [ + {"dataset_id": DATASET_ID_2, "document_id": "current-1", "document_keyword": "current-1.txt", "content": "Current rank one.", "similarity": 0.87}, + {"dataset_id": DATASET_ID_2, "document_id": "current-2", "document_keyword": "current-2.txt", "content": "Current rank two.", "similarity": 0.86}, + ], + "doc_aggs": [ + {"doc_id": "current-1", "doc_name": "current-1.txt", "count": 1}, + {"doc_id": "current-2", "doc_name": "current-2.txt", "count": 1}, + ], + "total": 2, + }, + }, + ) + _install(monkeypatch, fake, config=_config(datasets=[DATASET_ID_1, DATASET_ID_2], page_size=3)) + + result = await ragflow_tools.knowledge_search("policy") + + assert [call[1]["dataset_ids"] for call in fake.retrieve_calls] == [[DATASET_ID_1], [DATASET_ID_2]] + assert result.index("Legacy rank one.") < result.index("Current rank one.") < result.index("Legacy rank two.") + assert "Current rank two." not in result + assert "current-2.txt (1 chunk)" not in result + assert DATASET_ID_1 not in result + assert DATASET_ID_2 not in result + + +@pytest.mark.anyio +async def test_all_dataset_scope_skips_empty_datasets_before_grouped_retrieval(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeRAGFlowClient( + all_datasets=[ + _dataset(DATASET_ID_1, "Empty legacy", embedding_model=EMBEDDING_V2, chunk_count=0), + _dataset(DATASET_ID_2, "Current", embedding_model=EMBEDDING_V3, chunk_count=4), + ], + retrieval_by_dataset_ids={(DATASET_ID_2,): {"chunks": [{"dataset_id": DATASET_ID_2, "document_keyword": "guide.txt", "content": "Searchable."}], "doc_aggs": [], "total": 1}}, + ) + _install(monkeypatch, fake, config=_config(datasets=None)) + + result = await ragflow_tools.knowledge_search("searchable") + + assert [call[1]["dataset_ids"] for call in fake.retrieve_calls] == [[DATASET_ID_2]] + assert "Searchable." in result + + +@pytest.mark.anyio +async def test_all_empty_dataset_scope_returns_no_content_without_retrieval(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeRAGFlowClient( + all_datasets=[ + _dataset(DATASET_ID_1, "Empty legacy", embedding_model=EMBEDDING_V2, chunk_count=0), + _dataset(DATASET_ID_2, "Empty current", embedding_model=EMBEDDING_V3, chunk_count=0), + ] + ) + _install(monkeypatch, fake, config=_config(datasets=None)) + + result = await ragflow_tools.knowledge_search("anything") + + assert result == "No relevant content found." + assert fake.retrieve_calls == [] + + +@pytest.mark.anyio +async def test_grouped_retrieval_limits_concurrency_to_four(monkeypatch: pytest.MonkeyPatch) -> None: + class ConcurrencyTrackingClient(FakeRAGFlowClient): + def __init__(self) -> None: + super().__init__(all_datasets=[_dataset(f"dataset-{index}", f"Dataset {index}", embedding_model=f"embedding-{index}@provider") for index in range(5)]) + self.active_retrievals = 0 + self.max_active_retrievals = 0 + + async def retrieve(self, query: str, **kwargs: object) -> dict: + self.retrieve_calls.append((query, kwargs)) + self.active_retrievals += 1 + self.max_active_retrievals = max(self.max_active_retrievals, self.active_retrievals) + try: + await asyncio.sleep(0.05) + return {"chunks": [], "doc_aggs": [], "total": 0} + finally: + self.active_retrievals -= 1 + + fake = ConcurrencyTrackingClient() + _install(monkeypatch, fake, config=_config(datasets=None)) + + result = await ragflow_tools.knowledge_search("anything") + + assert result == "No relevant content found." + assert len(fake.retrieve_calls) == 5 + assert fake.max_active_retrievals == 4 + + +@pytest.mark.anyio +async def test_dataset_without_embedding_metadata_returns_protocol_error(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeRAGFlowClient(all_datasets=[{"id": DATASET_ID_1, "name": "Broken", "chunk_count": 1}]) + _install(monkeypatch, fake, config=_config(datasets=None)) + + result = await ragflow_tools.knowledge_search("anything") + + assert result == "Error: RAGFlow request failed: RAGFlow returned a dataset without embedding model metadata." + assert fake.retrieve_calls == [] + + +@pytest.mark.anyio +async def test_group_failure_remains_strict_and_redacts_secret_and_dataset_id(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeRAGFlowClient( + all_datasets=[ + _dataset(DATASET_ID_1, "Legacy", embedding_model=EMBEDDING_V2), + _dataset(DATASET_ID_2, "Current", embedding_model=EMBEDDING_V3), + ], + retrieval_by_dataset_ids={(DATASET_ID_2,): {"chunks": [], "doc_aggs": [], "total": 0}}, + retrieval_errors_by_dataset_ids={(DATASET_ID_1,): RAGFlowAPIError(f"dataset {DATASET_ID_1} rejected ragflow-secret", code=102)}, + ) + _install(monkeypatch, fake, config=_config(datasets=None)) + + result = await ragflow_tools.knowledge_search("anything") + + assert result == "Error: dataset [DATASET_ID] rejected [REDACTED]" + assert len(fake.retrieve_calls) == 2 + + @pytest.mark.anyio async def test_missing_dataset_binding_with_empty_catalog_returns_guidance(monkeypatch: pytest.MonkeyPatch) -> None: fake = FakeRAGFlowClient() @@ -272,7 +435,7 @@ async def test_success_path_preserves_legitimate_uuid_and_md5_text(monkeypatch: uuid = "123e4567-e89b-12d3-a456-426614174000" md5 = "d41d8cd98f00b204e9800998ecf8427e" fake = FakeRAGFlowClient( - datasets_by_id={DATASET_ID_1: [{"id": DATASET_ID_1, "name": "HR Policies"}]}, + datasets_by_id={DATASET_ID_1: [_dataset(DATASET_ID_1, "HR Policies")]}, retrieval={ "chunks": [ { @@ -295,7 +458,7 @@ async def test_success_path_preserves_legitimate_uuid_and_md5_text(monkeypatch: @pytest.mark.anyio async def test_success_path_still_redacts_api_key(monkeypatch: pytest.MonkeyPatch) -> None: fake = FakeRAGFlowClient( - datasets_by_id={DATASET_ID_1: [{"id": DATASET_ID_1, "name": "HR Policies"}]}, + datasets_by_id={DATASET_ID_1: [_dataset(DATASET_ID_1, "HR Policies")]}, retrieval={ "chunks": [ { @@ -370,7 +533,7 @@ async def test_empty_query_has_english_error(monkeypatch: pytest.MonkeyPatch) -> @pytest.mark.anyio async def test_empty_retrieval_has_explicit_english_message(monkeypatch: pytest.MonkeyPatch) -> None: - fake = FakeRAGFlowClient(datasets_by_id={DATASET_ID_1: [{"id": DATASET_ID_1, "name": "HR Policies"}]}) + fake = FakeRAGFlowClient(datasets_by_id={DATASET_ID_1: [_dataset(DATASET_ID_1, "HR Policies")]}) _install(monkeypatch, fake) result = await ragflow_tools.knowledge_search("nothing") diff --git a/config.example.yaml b/config.example.yaml index 3f7b9b2bf35..ab2a89f3760 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -679,9 +679,10 @@ tool_groups: tools: # RAGFlow knowledge retrieval (read-only). Uncomment this single entry. # `datasets` is optional. Omit it to list every tenant-visible dataset at - # search time and explicitly pass all discovered IDs to retrieval. Configure - # stable IDs to restrict scope or avoid mixing incompatible embedding models. - # Dataset IDs and catalog listing are never shown to the model. + # search time. Empty datasets are skipped; searchable datasets are grouped by + # embedding model and retrieved with at most four groups in parallel. Group + # ranks are interleaved under the global `page_size` limit. Configure stable + # IDs only to restrict scope. IDs and catalog listing never reach the model. # - name: knowledge_search # group: knowledge # use: deerflow.community.ragflow.tools:knowledge_search_tool diff --git a/deploy/helm/deer-flow/values.yaml b/deploy/helm/deer-flow/values.yaml index 29a1328062c..da6d5125b9c 100644 --- a/deploy/helm/deer-flow/values.yaml +++ b/deploy/helm/deer-flow/values.yaml @@ -312,7 +312,9 @@ config: | tools: # Optional tenant-shared, read-only RAGFlow retrieval. Put RAGFLOW_API_KEY # in `secrets`. `datasets` is an optional stable-ID allowlist; omit it to - # search all tenant-visible datasets. The Agent cannot list or see the IDs. + # search all tenant-visible datasets. Empty datasets are skipped; remaining + # datasets are grouped by embedding model with up to four parallel retrieval + # requests and one global `page_size` limit. The Agent cannot see the IDs. # - name: knowledge_search # group: knowledge # use: deerflow.community.ragflow.tools:knowledge_search_tool From c992f4e7f8423d8025a3e25cd70d11d5af893b80 Mon Sep 17 00:00:00 2001 From: zhangwei139623 <1552775457@qq.com> Date: Tue, 25 Aug 2026 10:30:52 +0800 Subject: [PATCH 10/14] docs(ragflow): keep feature details out of agent guides --- backend/AGENTS.md | 11 +++-------- backend/packages/harness/deerflow/AGENTS.md | 15 +++++---------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/backend/AGENTS.md b/backend/AGENTS.md index f26f078565f..fd5699dfb0b 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -300,14 +300,9 @@ retrieval parameters, and an optional operator-controlled allowlist of exact dataset IDs. Configuration loading performs no network I/O. At invocation time, the provider either verifies configured IDs through ID-filtered requests or, when no IDs are configured, paginates through all tenant-visible datasets. It -resolves current names, embedding models, and chunk counts, skips empty -datasets, and groups the searchable scope by exact embedding-model identifier. -Groups are retrieved with a four-request concurrency cap. Their provider-ranked -chunks are interleaved by rank (raw scores from different embedding spaces are -not compared) and globally truncated to `page_size`; any group failure fails -the whole tool call. Every provider request still carries an explicit, -non-empty `dataset_ids` list. Neither IDs nor tenant-wide listing is -Agent-visible. RAGFlow remains the sole source of truth: there +resolves current names for citation formatting and always sends an explicit, +non-empty `dataset_ids` list to retrieval. Neither IDs nor tenant-wide listing +is Agent-visible. RAGFlow remains the sole source of truth: there are no DeerFlow ORM models, migrations, or mirrored knowledge metadata. The configured tenant API key must never appear in logs or model-visible tool errors, and RAGFlow dataset UUIDs must not enter model context. Provider-authored diff --git a/backend/packages/harness/deerflow/AGENTS.md b/backend/packages/harness/deerflow/AGENTS.md index 481624f1453..a3e69500b76 100644 --- a/backend/packages/harness/deerflow/AGENTS.md +++ b/backend/packages/harness/deerflow/AGENTS.md @@ -37,20 +37,15 @@ drift. ### RAGFlow Knowledge Retrieval (`community/ragflow/`) The optional `knowledge` tool group exposes one `knowledge_search(query)` tool. -Its normal `tools:` entry owns the provider connection, retrieval extras, and an +Its normal `tools:` entry owns the provider connection, retrieval extras, and a optional operator allowlist of stable dataset IDs; there is no top-level provider configuration or Agent-visible listing tool. Configuration loading does not contact RAGFlow. Each invocation either verifies the bound IDs through ID-filtered requests or, when no IDs are bound, internally paginates through all -tenant-visible datasets. It resolves current names, embedding models, and chunk -counts, ignores empty datasets, and groups the searchable scope by exact -embedding-model identifier. Up to four groups are retrieved concurrently with -an explicit non-empty `dataset_ids` list per request. Results preserve each -group's provider order, interleave equal rank positions, and use `page_size` as -one global limit because cross-model raw similarity scores are not comparable. -Any group failure fails the tool call rather than silently dropping scope. It -never persists dataset metadata, exposes RAGFlow dataset IDs to the model, or -provides write operations. +tenant-visible datasets. It resolves their current names for citation formatting +and sends an explicit non-empty `dataset_ids` list to retrieval. It never persists +dataset metadata, exposes RAGFlow dataset IDs to the model, or provides write +operations. Retrieval output is bounded at both the individual chunk and full-response levels. Provider-authored model-visible text is English. Every path must redact the configured tenant API key; dataset-UUID redaction is error-only so normal From a9695e225e678e8b3e885b2bd094fe349db7e968 Mon Sep 17 00:00:00 2001 From: zhangwei139623 <1552775457@qq.com> Date: Tue, 25 Aug 2026 10:32:42 +0800 Subject: [PATCH 11/14] docs(ragflow): remove agent guide changes --- backend/AGENTS.md | 20 -------------------- backend/packages/harness/deerflow/AGENTS.md | 20 -------------------- 2 files changed, 40 deletions(-) diff --git a/backend/AGENTS.md b/backend/AGENTS.md index fd5699dfb0b..7b823a7044c 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -291,26 +291,6 @@ For models with `supports_vision: true`: - `view_image_tool` added to agent's toolset - Images are converted to base64 and injected into a hidden message carrying both a reserved ID prefix and a server-owned metadata marker for the model call; Gateway strips that marker from untrusted input, and the middleware requires both identifiers before removing the message. The `before_model` and `model` node checkpoints for that call still contain the payload; after `after_model` cleanup, subsequent checkpoints retain only lightweight `viewed_images` metadata, while client-chosen IDs survive -### RAGFlow Knowledge Retrieval - -The harness provides an opt-in, read-only RAGFlow integration under -`deerflow.community.ragflow`. The normal `tools:` list enables one -`knowledge_search(query)` Agent tool. Its entry owns the RAGFlow connection, -retrieval parameters, and an optional operator-controlled allowlist of exact -dataset IDs. Configuration loading performs no network I/O. At invocation time, -the provider either verifies configured IDs through ID-filtered requests or, -when no IDs are configured, paginates through all tenant-visible datasets. It -resolves current names for citation formatting and always sends an explicit, -non-empty `dataset_ids` list to retrieval. Neither IDs nor tenant-wide listing -is Agent-visible. RAGFlow remains the sole source of truth: there -are no DeerFlow ORM models, migrations, or mirrored knowledge metadata. -The configured tenant API key must never appear in logs or model-visible tool -errors, and RAGFlow dataset UUIDs must not enter model context. Provider-authored -model-visible text is English. This slice deliberately -contains no Gateway management API, watcher, SSE endpoint, or frontend UI; -knowledge-base writes remain in RAGFlow. Tests live in -`tests/test_ragflow_client.py` and `tests/test_ragflow_tools.py`. - ## Code Style - Uses `ruff` for linting and formatting diff --git a/backend/packages/harness/deerflow/AGENTS.md b/backend/packages/harness/deerflow/AGENTS.md index a3e69500b76..b098aec020d 100644 --- a/backend/packages/harness/deerflow/AGENTS.md +++ b/backend/packages/harness/deerflow/AGENTS.md @@ -34,26 +34,6 @@ artifact. New automatic capture entry points must reuse the shared progress encoding definition in `tools.py` so the byte encoding and `.jpg` suffix cannot drift. -### RAGFlow Knowledge Retrieval (`community/ragflow/`) - -The optional `knowledge` tool group exposes one `knowledge_search(query)` tool. -Its normal `tools:` entry owns the provider connection, retrieval extras, and a -optional operator allowlist of stable dataset IDs; there is no top-level -provider configuration or Agent-visible listing tool. Configuration loading -does not contact RAGFlow. Each invocation either verifies the bound IDs through -ID-filtered requests or, when no IDs are bound, internally paginates through all -tenant-visible datasets. It resolves their current names for citation formatting -and sends an explicit non-empty `dataset_ids` list to retrieval. It never persists -dataset metadata, exposes RAGFlow dataset IDs to the model, or provides write -operations. -Retrieval output is bounded at both the individual chunk and full-response -levels. Provider-authored model-visible text is English. Every path must redact -the configured tenant API key; dataset-UUID redaction is error-only so normal -content does not corrupt legitimate checksums, trace IDs, or UUID fields. -The client intentionally exposes only dataset listing and retrieval. Dataset -creation, uploads, parsing, deletion, Gateway management routes, SSE, and -frontend UI are outside this retrieval-only slice. - ### Embedded Client (`packages/harness/deerflow/client.py`) `DeerFlowClient` provides direct in-process access to all DeerFlow capabilities without HTTP services. All return types align with the Gateway API response schemas, so consumer code works identically in HTTP and embedded modes. From 5aa51bdce7e663db5501837c6bb624f718389328 Mon Sep 17 00:00:00 2001 From: zhangwei139623 <1552775457@qq.com> Date: Tue, 25 Aug 2026 10:38:29 +0800 Subject: [PATCH 12/14] docs(ragflow): remove root readme changes --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 9cdaf5ddbc8..105fcbb34cb 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,6 @@ DeerFlow has newly integrated the intelligent search and crawling toolset indepe - [Core Features](#core-features) - [Skills \& Tools](#skills--tools) - [Claude Code Integration](#claude-code-integration) - - [Private Knowledge Retrieval (RAGFlow)](#private-knowledge-retrieval-ragflow) - [Session Goals](#session-goals) - [Manual Context Compaction](#manual-context-compaction) - [Sub-Agents](#sub-agents) From 1f177a50681b4cb1c76d037614e43d8295f18c4e Mon Sep 17 00:00:00 2001 From: zhangwei139623 <1552775457@qq.com> Date: Tue, 25 Aug 2026 15:02:26 +0800 Subject: [PATCH 13/14] fix(ragflow): handle unresolved and empty datasets --- backend/docs/CONFIGURATION.md | 17 ++-- .../deerflow/community/ragflow/tools.py | 53 ++++++++--- backend/tests/test_ragflow_tools.py | 95 ++++++++++++++++--- config.example.yaml | 6 +- deploy/helm/deer-flow/values.yaml | 3 +- 5 files changed, 142 insertions(+), 32 deletions(-) diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index 1ded321f29d..6d56656ca66 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -254,15 +254,18 @@ it contains RAGFlow dataset IDs selected by the deployment operator, DeerFlow does not validate their existence while loading configuration; on each search it verifies them with ID-filtered requests. If `datasets` is omitted, each search paginates through the tenant-visible dataset catalog. Both paths resolve -current names, embedding models, and chunk counts. Empty datasets are ignored. -The remaining datasets are grouped by the exact embedding-model identifier and -each group is sent to RAGFlow with a non-empty `dataset_ids` list. At most four -groups are retrieved concurrently. Because raw similarity scores from different -embedding spaces are not globally comparable, DeerFlow preserves each group's -RAGFlow ranking, interleaves equal rank positions, and applies `page_size` as a +current names, embedding models, and chunk counts. Empty datasets are ignored; +an empty dataset that has no embedding-model metadata is also skipped with a +server warning. The remaining datasets are grouped by the exact embedding-model +identifier and each group is sent to RAGFlow with a non-empty `dataset_ids` +list. At most four groups are retrieved concurrently. Because raw similarity +scores from different embedding spaces are not globally comparable, DeerFlow +preserves each group's RAGFlow ranking, interleaves equal rank positions, omits +score labels when more than one group is searched, and applies `page_size` as a single global chunk limit. If any searchable group fails, the whole tool call fails rather than silently omitting part of the configured scope. A deleted or -inaccessible configured dataset produces guidance to check `config.yaml`. +inaccessible configured dataset identifies its ordinal entry in +`knowledge_search.datasets` and produces guidance to check `config.yaml`. Dataset IDs and catalog listing are not exposed to the Agent. Use an allowlist to narrow the tenant-wide scope; compatible embedding models diff --git a/backend/packages/harness/deerflow/community/ragflow/tools.py b/backend/packages/harness/deerflow/community/ragflow/tools.py index 20c1737a706..a52876e11d7 100644 --- a/backend/packages/harness/deerflow/community/ragflow/tools.py +++ b/backend/packages/harness/deerflow/community/ragflow/tools.py @@ -153,13 +153,19 @@ def _resolved_dataset(dataset: Mapping[str, object], *, expected_id: str | None if expected_id is not None and clean_id != expected_id: return None - embedding_model = dataset.get("embedding_model") - if not isinstance(embedding_model, str) or not embedding_model.strip(): - raise RAGFlowProtocolError("RAGFlow returned a dataset without embedding model metadata.") - name = dataset.get("name") raw_chunk_count = dataset.get("chunk_count") chunk_count = raw_chunk_count if isinstance(raw_chunk_count, int) and not isinstance(raw_chunk_count, bool) and raw_chunk_count >= 0 else None + embedding_model = dataset.get("embedding_model") + if not isinstance(embedding_model, str) or not embedding_model.strip(): + if chunk_count == 0: + warning_key = f"empty_embedding:{clean_id}" + if warning_key not in _warned: + _warned.add(warning_key) + logger.warning("Skipping empty RAGFlow dataset without embedding model metadata (dataset_id=%s)", clean_id) + embedding_model = "" + else: + raise RAGFlowProtocolError("RAGFlow returned a searchable dataset without embedding model metadata.") return _ResolvedDataset( dataset_id=clean_id, name=str(name).strip() if name else "Unknown dataset", @@ -176,8 +182,25 @@ def _current_dataset(datasets: list[dict], bound_id: str) -> _ResolvedDataset | return None -def _missing_dataset_error() -> str: - return "Error: A configured RAGFlow dataset was not found or is inaccessible; check knowledge_search.datasets in config.yaml." +def _ordinal(value: int) -> str: + if 10 <= value % 100 <= 20: + suffix = "th" + else: + suffix = {1: "st", 2: "nd", 3: "rd"}.get(value % 10, "th") + return f"{value}{suffix}" + + +def _missing_dataset_error(position: int) -> str: + return f"Error: The {_ordinal(position)} entry of knowledge_search.datasets was not found or is inaccessible; check config.yaml." + + +def _log_missing_dataset(*, position: int, dataset_id: str, code: object = None) -> None: + logger.warning( + "Configured RAGFlow dataset binding could not be resolved (position=%d, dataset_id=%s, code=%s)", + position, + dataset_id, + code, + ) async def _resolve_datasets( @@ -200,13 +223,17 @@ async def _resolve_datasets( ) return list(resolved_by_id.values()), None - batches = await asyncio.gather(*(client.list_datasets(dataset_id=dataset_id) for dataset_id in settings.datasets)) - resolved_datasets: list[_ResolvedDataset] = [] - for bound_id, datasets in zip(settings.datasets, batches, strict=True): + for position, bound_id in enumerate(settings.datasets, start=1): + try: + datasets = await client.list_datasets(dataset_id=bound_id) + except RAGFlowAPIError as exc: + _log_missing_dataset(position=position, dataset_id=bound_id, code=exc.code) + return None, _missing_dataset_error(position) resolved = _current_dataset(datasets, bound_id) if resolved is None: - return None, _missing_dataset_error() + _log_missing_dataset(position=position, dataset_id=bound_id) + return None, _missing_dataset_error(position) resolved_datasets.append(resolved) return resolved_datasets, None @@ -241,13 +268,17 @@ def _merge_group_results(results: list[dict[str, Any]], *, page_size: int) -> di chunk_groups = [_result_chunks(result) for result in results] merged_chunks: list[Mapping[str, Any]] = [] max_group_size = max((len(chunks) for chunks in chunk_groups), default=0) + hide_cross_group_scores = len(results) > 1 # Similarity scores from different embedding spaces are not globally # calibrated. Preserve each provider-ranked list and interleave equal rank # positions instead of comparing raw scores across models. for rank in range(max_group_size): for chunks in chunk_groups: if rank < len(chunks): - merged_chunks.append(chunks[rank]) + chunk = chunks[rank] + if hide_cross_group_scores and "similarity" in chunk: + chunk = {key: value for key, value in chunk.items() if key != "similarity"} + merged_chunks.append(chunk) if len(merged_chunks) >= page_size: break if len(merged_chunks) >= page_size: diff --git a/backend/tests/test_ragflow_tools.py b/backend/tests/test_ragflow_tools.py index 465020c432d..cf155fea789 100644 --- a/backend/tests/test_ragflow_tools.py +++ b/backend/tests/test_ragflow_tools.py @@ -39,6 +39,7 @@ def __init__( self, *, datasets_by_id: Mapping[str, list[dict]] | None = None, + dataset_errors_by_id: Mapping[str, Exception] | None = None, all_datasets: list[dict] | None = None, retrieval: dict | None = None, retrieval_by_dataset_ids: Mapping[tuple[str, ...], dict] | None = None, @@ -46,6 +47,7 @@ def __init__( error: Exception | None = None, ) -> None: self.datasets_by_id = dict(datasets_by_id or {}) + self.dataset_errors_by_id = dict(dataset_errors_by_id or {}) self.all_datasets = list(all_datasets or []) self.retrieval = retrieval or {"chunks": [], "doc_aggs": [], "total": 0} self.retrieval_by_dataset_ids = dict(retrieval_by_dataset_ids or {}) @@ -60,6 +62,8 @@ async def list_datasets(self, *, dataset_id: str | None = None) -> list[dict]: self.list_calls.append(dataset_id) if dataset_id is None: return self.all_datasets + if error := self.dataset_errors_by_id.get(dataset_id): + raise error return self.datasets_by_id.get(dataset_id, []) async def retrieve(self, query: str, **kwargs: object) -> dict: @@ -179,21 +183,45 @@ async def test_knowledge_search_uses_id_filter_and_survives_dataset_rename(monke @pytest.mark.anyio -async def test_missing_bound_dataset_returns_operator_guidance(monkeypatch: pytest.MonkeyPatch) -> None: - fake = FakeRAGFlowClient() - _install(monkeypatch, fake, config=_config(datasets=[MISSING_DATASET_ID])) +async def test_missing_bound_dataset_api_error_returns_indexed_operator_guidance( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + tenant_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + fake = FakeRAGFlowClient( + datasets_by_id={DATASET_ID_1: [_dataset(DATASET_ID_1, "Existing")]}, + dataset_errors_by_id={ + MISSING_DATASET_ID: RAGFlowAPIError( + f"User '{tenant_id}' lacks permission for dataset '{MISSING_DATASET_ID}'", + code=102, + ) + }, + ) + _install(monkeypatch, fake, config=_config(datasets=[DATASET_ID_1, MISSING_DATASET_ID])) - result = await ragflow_tools.knowledge_search("leave") + with caplog.at_level(logging.WARNING, logger="deerflow.community.ragflow.tools"): + result = await ragflow_tools.knowledge_search("leave") - assert result == "Error: A configured RAGFlow dataset was not found or is inaccessible; check knowledge_search.datasets in config.yaml." + assert result == "Error: The 2nd entry of knowledge_search.datasets was not found or is inaccessible; check config.yaml." assert MISSING_DATASET_ID not in result - assert fake.list_calls == [MISSING_DATASET_ID] + assert tenant_id not in result + assert "lacks permission" not in result + assert fake.list_calls == [DATASET_ID_1, MISSING_DATASET_ID] assert fake.retrieve_calls == [] + assert MISSING_DATASET_ID in caplog.text + assert "code=102" in caplog.text @pytest.mark.anyio async def test_missing_bound_dataset_error_does_not_expose_configured_id(monkeypatch: pytest.MonkeyPatch) -> None: - fake = FakeRAGFlowClient() + fake = FakeRAGFlowClient( + dataset_errors_by_id={ + MISSING_DATASET_ID: RAGFlowAPIError( + f"User '{DATASET_ID_1}' lacks permission for dataset '{MISSING_DATASET_ID}'", + code=102, + ) + } + ) _install(monkeypatch, fake, config=_config(datasets=[MISSING_DATASET_ID])) result = await ragflow_tools.knowledge_search("leave") @@ -209,7 +237,7 @@ async def test_mismatched_id_filtered_response_returns_operator_guidance(monkeyp result = await ragflow_tools.knowledge_search("leave") - assert result == "Error: A configured RAGFlow dataset was not found or is inaccessible; check knowledge_search.datasets in config.yaml." + assert result == "Error: The 1st entry of knowledge_search.datasets was not found or is inaccessible; check config.yaml." assert fake.retrieve_calls == [] @@ -273,6 +301,7 @@ async def test_mixed_embedding_models_are_retrieved_in_parallel_groups_and_rank_ assert result.index("Legacy rank one.") < result.index("Current rank one.") < result.index("Legacy rank two.") assert "Current rank two." not in result assert "current-2.txt (1 chunk)" not in result + assert "(score " not in result assert DATASET_ID_1 not in result assert DATASET_ID_2 not in result @@ -294,6 +323,44 @@ async def test_all_dataset_scope_skips_empty_datasets_before_grouped_retrieval(m assert "Searchable." in result +@pytest.mark.anyio +async def test_all_dataset_scope_skips_empty_dataset_without_embedding_model_and_warns( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + fake = FakeRAGFlowClient( + all_datasets=[ + _dataset(DATASET_ID_1, "Unconfigured empty", embedding_model="", chunk_count=0), + _dataset(DATASET_ID_2, "Current", embedding_model=EMBEDDING_V3, chunk_count=4), + ], + retrieval_by_dataset_ids={ + (DATASET_ID_2,): { + "chunks": [ + { + "dataset_id": DATASET_ID_2, + "document_keyword": "guide.txt", + "content": "Remaining dataset result.", + "similarity": 0.75, + } + ], + "doc_aggs": [], + "total": 1, + } + }, + ) + _install(monkeypatch, fake, config=_config(datasets=None)) + + with caplog.at_level(logging.WARNING, logger="deerflow.community.ragflow.tools"): + result = await ragflow_tools.knowledge_search("searchable") + + assert [call[1]["dataset_ids"] for call in fake.retrieve_calls] == [[DATASET_ID_2]] + assert "Remaining dataset result." in result + assert "(score 0.75)" in result + assert DATASET_ID_1 not in result + assert "Skipping empty RAGFlow dataset without embedding model metadata" in caplog.text + assert DATASET_ID_1 in caplog.text + + @pytest.mark.anyio async def test_all_empty_dataset_scope_returns_no_content_without_retrieval(monkeypatch: pytest.MonkeyPatch) -> None: fake = FakeRAGFlowClient( @@ -345,7 +412,7 @@ async def test_dataset_without_embedding_metadata_returns_protocol_error(monkeyp result = await ragflow_tools.knowledge_search("anything") - assert result == "Error: RAGFlow request failed: RAGFlow returned a dataset without embedding model metadata." + assert result == "Error: RAGFlow request failed: RAGFlow returned a searchable dataset without embedding model metadata." assert fake.retrieve_calls == [] @@ -410,7 +477,10 @@ async def test_missing_knowledge_search_config_returns_english_guidance(monkeypa @pytest.mark.anyio async def test_api_error_is_returned_as_readable_text(monkeypatch: pytest.MonkeyPatch) -> None: - fake = FakeRAGFlowClient(error=RAGFlowAPIError("embedding models do not match", code=102)) + fake = FakeRAGFlowClient( + datasets_by_id={DATASET_ID_1: [_dataset(DATASET_ID_1, "Policies")]}, + retrieval_errors_by_dataset_ids={(DATASET_ID_1,): RAGFlowAPIError("embedding models do not match", code=102)}, + ) _install(monkeypatch, fake) result = await ragflow_tools.knowledge_search("leave") @@ -421,7 +491,10 @@ async def test_api_error_is_returned_as_readable_text(monkeypatch: pytest.Monkey @pytest.mark.anyio async def test_error_path_redacts_dataset_uuid(monkeypatch: pytest.MonkeyPatch) -> None: dataset_id = "0123456789abcdef0123456789abcdef" - fake = FakeRAGFlowClient(error=RAGFlowAPIError(f"dataset {dataset_id} failed", code=102)) + fake = FakeRAGFlowClient( + datasets_by_id={DATASET_ID_1: [_dataset(DATASET_ID_1, "Policies")]}, + retrieval_errors_by_dataset_ids={(DATASET_ID_1,): RAGFlowAPIError(f"dataset {dataset_id} failed", code=102)}, + ) _install(monkeypatch, fake) result = await ragflow_tools.knowledge_search("leave") diff --git a/config.example.yaml b/config.example.yaml index ab2a89f3760..b778986536b 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -681,8 +681,10 @@ tools: # `datasets` is optional. Omit it to list every tenant-visible dataset at # search time. Empty datasets are skipped; searchable datasets are grouped by # embedding model and retrieved with at most four groups in parallel. Group - # ranks are interleaved under the global `page_size` limit. Configure stable - # IDs only to restrict scope. IDs and catalog listing never reach the model. + # ranks are interleaved under the global `page_size` limit; scores are omitted + # when multiple groups are searched because they are not comparable. + # Configure stable IDs only to restrict scope. IDs and catalog listing never + # reach the model. # - name: knowledge_search # group: knowledge # use: deerflow.community.ragflow.tools:knowledge_search_tool diff --git a/deploy/helm/deer-flow/values.yaml b/deploy/helm/deer-flow/values.yaml index da6d5125b9c..84966adbfe4 100644 --- a/deploy/helm/deer-flow/values.yaml +++ b/deploy/helm/deer-flow/values.yaml @@ -314,7 +314,8 @@ config: | # in `secrets`. `datasets` is an optional stable-ID allowlist; omit it to # search all tenant-visible datasets. Empty datasets are skipped; remaining # datasets are grouped by embedding model with up to four parallel retrieval - # requests and one global `page_size` limit. The Agent cannot see the IDs. + # requests and one global `page_size` limit. Multi-group scores are omitted + # because they are not comparable. The Agent cannot see the IDs. # - name: knowledge_search # group: knowledge # use: deerflow.community.ragflow.tools:knowledge_search_tool From 1a6c426c15139eb741fb7b2975f518e18c27ef41 Mon Sep 17 00:00:00 2001 From: zhangwei139623 <1552775457@qq.com> Date: Tue, 25 Aug 2026 16:25:49 +0800 Subject: [PATCH 14/14] fix(ragflow): harden dataset scope and errors --- backend/docs/CONFIGURATION.md | 6 ++- .../deerflow/community/ragflow/client.py | 12 ++++- .../deerflow/community/ragflow/tools.py | 10 ++-- backend/tests/test_ragflow_client.py | 29 ++++++++-- backend/tests/test_ragflow_tools.py | 54 ++++++++++++------- config.example.yaml | 3 +- deploy/helm/deer-flow/values.yaml | 3 +- 7 files changed, 83 insertions(+), 34 deletions(-) diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index 6d56656ca66..862027c30bb 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -225,7 +225,8 @@ RAGFlow integration is disabled by default. It adds one read-only Agent tool, metadata; RAGFlow is the sole source of truth. The configured API key is tenant-scoped. An optional operator-controlled `datasets` list restricts every Agent on this deployment to the same dataset-ID allowlist; omitting it searches -all datasets visible to that tenant API key. +all datasets visible to that tenant API key. An explicitly empty `datasets: []` +is rejected rather than being treated as tenant-wide access. ```yaml tool_groups: @@ -249,7 +250,8 @@ tools: max_total_chars: 8000 ``` -The tool is opt-in through the normal `tools:` list. `datasets` is optional. If +The tool is opt-in through the normal `tools:` list. `datasets` is optional but, +when present, must contain at least one ID. If it contains RAGFlow dataset IDs selected by the deployment operator, DeerFlow does not validate their existence while loading configuration; on each search it verifies them with ID-filtered requests. If `datasets` is omitted, each diff --git a/backend/packages/harness/deerflow/community/ragflow/client.py b/backend/packages/harness/deerflow/community/ragflow/client.py index bb42d737c9d..5d9d69eecc5 100644 --- a/backend/packages/harness/deerflow/community/ragflow/client.py +++ b/backend/packages/harness/deerflow/community/ragflow/client.py @@ -116,7 +116,13 @@ async def list_datasets(self, *, dataset_id: str | None = None) -> list[dict[str if not dataset_id: raise ValueError("dataset_id must not be empty") - payload = await self._request("GET", "/datasets", params={"id": dataset_id}) + # RAGFlow's singular `id` filter returns the generic DATA_ERROR code + # for an inaccessible or missing dataset, which is indistinguishable + # from several provider failures. Its `ids` filter instead returns a + # successful empty list for an inaccessible ID, allowing callers to + # classify only that result as a missing binding while preserving all + # real API errors. + payload = await self._request("GET", "/datasets", params={"ids": dataset_id}) data = payload.get("data") if not isinstance(data, list): raise RAGFlowProtocolError("RAGFlow returned an invalid dataset list.") @@ -137,7 +143,9 @@ async def list_datasets(self, *, dataset_id: str | None = None) -> list[dict[str datasets.extend(item for item in data if isinstance(item, dict)) received_count += len(data) - total = payload.get("total_datasets") + total = payload.get("total") + if not (isinstance(total, int) and not isinstance(total, bool) and total >= 0): + total = payload.get("total_datasets") has_valid_total = isinstance(total, int) and not isinstance(total, bool) and total >= 0 if has_valid_total: if received_count >= total: diff --git a/backend/packages/harness/deerflow/community/ragflow/tools.py b/backend/packages/harness/deerflow/community/ragflow/tools.py index a52876e11d7..df41a15ca89 100644 --- a/backend/packages/harness/deerflow/community/ragflow/tools.py +++ b/backend/packages/harness/deerflow/community/ragflow/tools.py @@ -54,6 +54,8 @@ class _RAGFlowRetrievalSettings(BaseModel): def _normalize_dataset_ids(cls, value: list[str] | None) -> list[str] | None: if value is None: return None + if not value: + raise ValueError("datasets must not be empty when configured; omit it to search all accessible datasets") normalized: list[str] = [] seen: set[str] = set() for dataset_id in value: @@ -63,7 +65,7 @@ def _normalize_dataset_ids(cls, value: list[str] | None) -> list[str] | None: if clean_id not in seen: normalized.append(clean_id) seen.add(clean_id) - return normalized or None + return normalized @field_validator("base_url") @classmethod @@ -225,11 +227,7 @@ async def _resolve_datasets( resolved_datasets: list[_ResolvedDataset] = [] for position, bound_id in enumerate(settings.datasets, start=1): - try: - datasets = await client.list_datasets(dataset_id=bound_id) - except RAGFlowAPIError as exc: - _log_missing_dataset(position=position, dataset_id=bound_id, code=exc.code) - return None, _missing_dataset_error(position) + datasets = await client.list_datasets(dataset_id=bound_id) resolved = _current_dataset(datasets, bound_id) if resolved is None: _log_missing_dataset(position=position, dataset_id=bound_id) diff --git a/backend/tests/test_ragflow_client.py b/backend/tests/test_ragflow_client.py index 5f7f2441106..62b96360ad3 100644 --- a/backend/tests/test_ragflow_client.py +++ b/backend/tests/test_ragflow_client.py @@ -18,14 +18,14 @@ async def test_list_datasets_filters_by_bound_id_in_one_request() -> None: async def handler(request: httpx.Request) -> httpx.Response: requests.append(request) assert request.method == "GET" - assert request.url == httpx.URL("http://ragflow.test/api/v1/datasets?id=dataset-1") + assert request.url == httpx.URL("http://ragflow.test/api/v1/datasets?ids=dataset-1") assert request.headers["Authorization"] == "Bearer ragflow-secret" return httpx.Response( 200, json={ "code": 0, "data": [{"id": "dataset-1", "name": "HR Policies"}], - "total_datasets": 101, + "total": 1, }, ) @@ -54,7 +54,7 @@ async def handler(request: httpx.Request) -> httpx.Response: data = [{"id": "dataset-100", "name": "Dataset 100"}] else: pytest.fail(f"unexpected page {page}") - return httpx.Response(200, json={"code": 0, "data": data, "total_datasets": 101}) + return httpx.Response(200, json={"code": 0, "data": data, "total": 101}) client = RAGFlowClient( base_url="http://ragflow.test", @@ -89,6 +89,29 @@ async def handler(request: httpx.Request) -> httpx.Response: assert len(requests) == 100 +@pytest.mark.anyio +async def test_list_datasets_accepts_reported_total_at_the_page_cap() -> None: + requests: list[httpx.Request] = [] + + async def handler(request: httpx.Request) -> httpx.Response: + requests.append(request) + page = int(request.url.params["page"]) + start = (page - 1) * 100 + data = [{"id": f"dataset-{index}", "name": f"Dataset {index}"} for index in range(start, start + 100)] + return httpx.Response(200, json={"code": 0, "data": data, "total": 10_000}) + + client = RAGFlowClient( + base_url="http://ragflow.test", + api_key="ragflow-secret", + transport=httpx.MockTransport(handler), + ) + + datasets = await client.list_datasets() + + assert len(datasets) == 10_000 + assert len(requests) == 100 + + @pytest.mark.anyio async def test_retrieve_always_sends_nonempty_dataset_ids() -> None: async def handler(request: httpx.Request) -> httpx.Response: diff --git a/backend/tests/test_ragflow_tools.py b/backend/tests/test_ragflow_tools.py index cf155fea789..03e015e0e9d 100644 --- a/backend/tests/test_ragflow_tools.py +++ b/backend/tests/test_ragflow_tools.py @@ -183,19 +183,12 @@ async def test_knowledge_search_uses_id_filter_and_survives_dataset_rename(monke @pytest.mark.anyio -async def test_missing_bound_dataset_api_error_returns_indexed_operator_guidance( +async def test_missing_bound_dataset_returns_indexed_operator_guidance( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: - tenant_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" fake = FakeRAGFlowClient( datasets_by_id={DATASET_ID_1: [_dataset(DATASET_ID_1, "Existing")]}, - dataset_errors_by_id={ - MISSING_DATASET_ID: RAGFlowAPIError( - f"User '{tenant_id}' lacks permission for dataset '{MISSING_DATASET_ID}'", - code=102, - ) - }, ) _install(monkeypatch, fake, config=_config(datasets=[DATASET_ID_1, MISSING_DATASET_ID])) @@ -204,24 +197,15 @@ async def test_missing_bound_dataset_api_error_returns_indexed_operator_guidance assert result == "Error: The 2nd entry of knowledge_search.datasets was not found or is inaccessible; check config.yaml." assert MISSING_DATASET_ID not in result - assert tenant_id not in result - assert "lacks permission" not in result assert fake.list_calls == [DATASET_ID_1, MISSING_DATASET_ID] assert fake.retrieve_calls == [] assert MISSING_DATASET_ID in caplog.text - assert "code=102" in caplog.text + assert "code=None" in caplog.text @pytest.mark.anyio async def test_missing_bound_dataset_error_does_not_expose_configured_id(monkeypatch: pytest.MonkeyPatch) -> None: - fake = FakeRAGFlowClient( - dataset_errors_by_id={ - MISSING_DATASET_ID: RAGFlowAPIError( - f"User '{DATASET_ID_1}' lacks permission for dataset '{MISSING_DATASET_ID}'", - code=102, - ) - } - ) + fake = FakeRAGFlowClient() _install(monkeypatch, fake, config=_config(datasets=[MISSING_DATASET_ID])) result = await ragflow_tools.knowledge_search("leave") @@ -230,6 +214,26 @@ async def test_missing_bound_dataset_error_does_not_expose_configured_id(monkeyp assert "[DATASET_ID]" not in result +@pytest.mark.anyio +async def test_bound_dataset_api_error_uses_normal_redacted_error_handler( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + fake = FakeRAGFlowClient( + dataset_errors_by_id={ + DATASET_ID_1: RAGFlowAPIError("invalid credential ragflow-secret", code=102), + } + ) + _install(monkeypatch, fake, config=_config(datasets=[DATASET_ID_1])) + + with caplog.at_level(logging.WARNING, logger="deerflow.community.ragflow.tools"): + result = await ragflow_tools.knowledge_search("leave") + + assert result == "Error: invalid credential [REDACTED]" + assert "code=102" in caplog.text + assert fake.retrieve_calls == [] + + @pytest.mark.anyio async def test_mismatched_id_filtered_response_returns_operator_guidance(monkeypatch: pytest.MonkeyPatch) -> None: fake = FakeRAGFlowClient(datasets_by_id={DATASET_ID_1: [_dataset(DATASET_ID_2, "Wrong dataset")]}) @@ -687,6 +691,18 @@ def test_retrieval_settings_allow_omitting_dataset_ids(monkeypatch: pytest.Monke assert config.datasets is None +@pytest.mark.anyio +async def test_explicitly_empty_dataset_allowlist_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None: + fake = FakeRAGFlowClient(all_datasets=[_dataset(DATASET_ID_1, "Must remain inaccessible")]) + _install(monkeypatch, fake, config=_config(datasets=[])) + + result = await ragflow_tools.knowledge_search("leave") + + assert result == "Error: Invalid RAGFlow settings for knowledge_search; check config.yaml." + assert fake.list_calls == [] + assert fake.retrieve_calls == [] + + def test_agent_exposes_only_query_on_single_search_tool() -> None: assert not hasattr(ragflow_tools, "list_knowledge_bases_tool") assert not hasattr(ragflow_tools, "list_knowledge_bases") diff --git a/config.example.yaml b/config.example.yaml index b778986536b..6997ebb2f55 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -679,7 +679,8 @@ tool_groups: tools: # RAGFlow knowledge retrieval (read-only). Uncomment this single entry. # `datasets` is optional. Omit it to list every tenant-visible dataset at - # search time. Empty datasets are skipped; searchable datasets are grouped by + # search time; an explicit `datasets: []` is invalid. Empty datasets are + # skipped; searchable datasets are grouped by # embedding model and retrieved with at most four groups in parallel. Group # ranks are interleaved under the global `page_size` limit; scores are omitted # when multiple groups are searched because they are not comparable. diff --git a/deploy/helm/deer-flow/values.yaml b/deploy/helm/deer-flow/values.yaml index 84966adbfe4..3e6d752d683 100644 --- a/deploy/helm/deer-flow/values.yaml +++ b/deploy/helm/deer-flow/values.yaml @@ -312,7 +312,8 @@ config: | tools: # Optional tenant-shared, read-only RAGFlow retrieval. Put RAGFLOW_API_KEY # in `secrets`. `datasets` is an optional stable-ID allowlist; omit it to - # search all tenant-visible datasets. Empty datasets are skipped; remaining + # search all tenant-visible datasets. An explicit empty list is invalid. + # Empty datasets are skipped; remaining # datasets are grouped by embedding model with up to four parallel retrieval # requests and one global `page_size` limit. Multi-group scores are omitted # because they are not comparable. The Agent cannot see the IDs.