fix(community): reject booleans in the knowledge_search provider settings (#7042)

This commit is contained in:
jay 2026-10-20 23:17:51 +08:00 committed by GitHub
parent f4eaa51a73
commit cb1d782fb3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 121 additions and 2 deletions

View File

@ -7,10 +7,11 @@ from collections.abc import Mapping
from typing import Literal
from langchain_core.tools import StructuredTool
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, SecretStr, ValidationError, field_validator
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, SecretStr, ValidationError, ValidationInfo, field_validator
from deerflow.community.provider_credentials import redact_secret, reject_url_userinfo, resolve_api_key
from deerflow.config import get_app_config
from deerflow.config._boolean_guards import reject_boolean
from deerflow.knowledge import format_result
from deerflow.knowledge.providers import validate_scope_capabilities
from deerflow.knowledge.sources import redact_strings, source_artifact
@ -49,6 +50,24 @@ class _LightRAGRetrievalSettings(BaseModel):
def _reject_url_userinfo(cls, value: AnyHttpUrl) -> AnyHttpUrl:
return reject_url_userinfo(value)
@field_validator("top_k", "max_chars_per_chunk", "max_total_chars", mode="before")
@classmethod
def _reject_boolean_integers(cls, value: object, info: ValidationInfo) -> object:
"""Reject booleans before Pydantic coerces them to integers."""
return reject_boolean(value, info, kind="an integer")
@field_validator("chunk_top_k", mode="before")
@classmethod
def _reject_boolean_optional_integer(cls, value: object, info: ValidationInfo) -> object:
"""Reject booleans before Pydantic coerces them to an optional integer."""
return reject_boolean(value, info, kind="an integer or null")
@field_validator("timeout", mode="before")
@classmethod
def _reject_boolean_timeout(cls, value: object, info: ValidationInfo) -> object:
"""Reject booleans before Pydantic coerces them to a number."""
return reject_boolean(value, info, kind="a number")
def _api_key(settings: _LightRAGRetrievalSettings) -> str | None:
# LightRAG may run without authentication, so a missing key stays valid;

View File

@ -10,10 +10,11 @@ from dataclasses import dataclass
from typing import Any, cast
from langchain_core.tools import StructuredTool
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, SecretStr, ValidationError, field_validator
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, SecretStr, ValidationError, ValidationInfo, field_validator
from deerflow.community.provider_credentials import redact_secret, reject_url_userinfo, resolve_api_key
from deerflow.config import get_app_config
from deerflow.config._boolean_guards import reject_boolean
from deerflow.knowledge import format_result
from deerflow.knowledge.sources import redact_strings
from deerflow.knowledge_scope import (
@ -89,6 +90,18 @@ class _RAGFlowRetrievalSettings(BaseModel):
def _reject_url_userinfo(cls, value: AnyHttpUrl) -> AnyHttpUrl:
return reject_url_userinfo(value)
@field_validator("page_size", "top_k", "max_chars_per_chunk", "max_total_chars", mode="before")
@classmethod
def _reject_boolean_integers(cls, value: object, info: ValidationInfo) -> object:
"""Reject booleans before Pydantic coerces them to integers."""
return reject_boolean(value, info, kind="an integer")
@field_validator("timeout", "similarity_threshold", "vector_similarity_weight", mode="before")
@classmethod
def _reject_boolean_numbers(cls, value: object, info: ValidationInfo) -> object:
"""Reject booleans before Pydantic coerces them to numbers."""
return reject_boolean(value, info, kind="a number")
def _api_key(settings: _RAGFlowRetrievalSettings) -> str | None:
return resolve_api_key(settings.api_key)

View File

@ -5,6 +5,7 @@ from typing import Any
from unittest.mock import AsyncMock
import pytest
from pydantic import ValidationError
import deerflow.community.lightrag.tools as lightrag_tools
from deerflow.community.lightrag.client import LightRAGAPIError, LightRAGConnectionError
@ -446,3 +447,35 @@ async def test_runtime_unfiltered_scope_preserves_lightrag_search(monkeypatch, s
assert content == "evidence"
assert artifact is not None
search.assert_awaited_once_with("q", with_sources=True)
LIGHTRAG_BOOLEAN_FIELDS = (
("timeout", "a number"),
("top_k", "an integer"),
("chunk_top_k", "an integer or null"),
("max_chars_per_chunk", "an integer"),
("max_total_chars", "an integer"),
)
@pytest.mark.parametrize("value", [True, False])
@pytest.mark.parametrize(("field", "kind"), LIGHTRAG_BOOLEAN_FIELDS)
def test_lightrag_settings_reject_booleans(field: str, kind: str, value: bool) -> None:
with pytest.raises(ValidationError, match=f"{field} must be {kind}, not a boolean"):
lightrag_tools._LightRAGRetrievalSettings.model_validate({field: value})
def test_lightrag_settings_keep_numeric_values() -> None:
settings = lightrag_tools._LightRAGRetrievalSettings.model_validate({"timeout": 45, "top_k": 10, "chunk_top_k": 5, "max_chars_per_chunk": 400, "max_total_chars": 2000})
assert (settings.timeout, settings.top_k, settings.chunk_top_k) == (45.0, 10, 5)
assert (settings.max_chars_per_chunk, settings.max_total_chars) == (400, 2000)
def test_lightrag_boolean_setting_reports_invalid_configuration(monkeypatch: pytest.MonkeyPatch) -> None:
"""A boolean in config.yaml must not silently become one second of timeout."""
monkeypatch.setattr(lightrag_tools, "get_app_config", lambda: _config(extra={"timeout": True}))
settings, error = lightrag_tools._settings_or_error()
assert settings is None
assert error == "Error: Invalid LightRAG settings for knowledge_search; check config.yaml."

View File

@ -5,6 +5,7 @@ from pathlib import Path
from types import SimpleNamespace
import pytest
from pydantic import ValidationError
import deerflow.community.ragflow.tools as ragflow_tools
from deerflow.community.ragflow.client import RAGFlowAPIError, RAGFlowConnectionError
@ -1265,3 +1266,56 @@ async def test_large_document_scope_batches_share_global_concurrency_limit() ->
assert error is None
assert result == {item["dataset_id"]: item["document_ids"] for item in filters}
assert peak == 4
RAGFLOW_BOOLEAN_FIELDS = (
("timeout", "a number"),
("page_size", "an integer"),
("similarity_threshold", "a number"),
("vector_similarity_weight", "a number"),
("top_k", "an integer"),
("max_chars_per_chunk", "an integer"),
("max_total_chars", "an integer"),
)
@pytest.mark.parametrize("value", [True, False])
@pytest.mark.parametrize(("field", "kind"), RAGFLOW_BOOLEAN_FIELDS)
def test_ragflow_settings_reject_booleans(field: str, kind: str, value: bool) -> None:
with pytest.raises(ValidationError, match=f"{field} must be {kind}, not a boolean"):
ragflow_tools._RAGFlowRetrievalSettings.model_validate({field: value})
def test_ragflow_settings_keep_numeric_values() -> None:
settings = ragflow_tools._RAGFlowRetrievalSettings.model_validate(
{
"timeout": 45,
"page_size": 20,
"similarity_threshold": 0.5,
"vector_similarity_weight": 0.6,
"top_k": 64,
"max_chars_per_chunk": 400,
"max_total_chars": 2000,
}
)
assert (settings.timeout, settings.page_size) == (45.0, 20)
assert (settings.similarity_threshold, settings.vector_similarity_weight) == (0.5, 0.6)
assert (settings.top_k, settings.max_chars_per_chunk, settings.max_total_chars) == (64, 400, 2000)
def test_ragflow_boolean_setting_reports_invalid_configuration() -> None:
"""A boolean in config.yaml must not silently become a single result at a one-second timeout."""
entry = ToolConfig(
name="knowledge_search",
group="knowledge",
use="deerflow.community.ragflow.tools:knowledge_search_tool",
base_url="http://ragflow.test",
api_key="ragflow-secret",
top_k=True,
)
app_config = SimpleNamespace(get_tool_config=lambda name: entry if name == "knowledge_search" else None)
settings, error = ragflow_tools._settings_or_error(app_config)
assert settings is None
assert error == "Error: Invalid RAGFlow settings for knowledge_search; check config.yaml."