376 lines
11 KiB
Python
376 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import date
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from deerflow.community.configurable_search.tools import (
|
|
ConfigurableSearchSettings,
|
|
execute_search,
|
|
)
|
|
from deerflow.config.app_config import AppConfig
|
|
from deerflow.config.tool_config import ToolConfig
|
|
from deerflow.tools.tools import get_available_tools
|
|
|
|
|
|
def _settings(**overrides: object) -> ConfigurableSearchSettings:
|
|
values = {
|
|
"enabled": True,
|
|
"endpoint": "https://search.example.test/searchName",
|
|
"payload": {"query": "configured value", "xxx": "xxxx"},
|
|
"verify_ssl": False,
|
|
"timeout": 30.0,
|
|
"max_results": 5,
|
|
"result_url_template": "https://search.example.test/mindId/xxx?recUuid={recUuid}",
|
|
}
|
|
values.update(overrides)
|
|
return ConfigurableSearchSettings(**values) # type: ignore[arg-type]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_configurable_search_overrides_query_and_normalizes_nullable_results() -> None:
|
|
requests: list[httpx.Request] = []
|
|
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
assert request.method == "POST"
|
|
payload = json.loads(request.content)
|
|
assert payload["query"].strip()
|
|
assert payload["xxx"] == "xxxx"
|
|
assert payload["start_date"] == ""
|
|
assert payload["end_date"] == ""
|
|
assert payload["top_k"] == ""
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"results": [
|
|
{
|
|
"recUuid": "record/123",
|
|
"content": "test content",
|
|
"title": "test title",
|
|
"url": "https://original.example.test/one",
|
|
},
|
|
{
|
|
"recUuid": None,
|
|
"content": None,
|
|
"title": None,
|
|
"url": "https://original.example.test/two",
|
|
},
|
|
"invalid row",
|
|
]
|
|
},
|
|
)
|
|
|
|
settings = _settings()
|
|
result = json.loads(
|
|
await execute_search(
|
|
" test query ",
|
|
settings,
|
|
transport=httpx.MockTransport(handler),
|
|
)
|
|
)
|
|
|
|
assert len(requests) == 1
|
|
assert settings.payload == {"query": "configured value", "xxx": "xxxx"}
|
|
assert result == {
|
|
"query": "test query",
|
|
"total_results": 2,
|
|
"results": [
|
|
{
|
|
"recUuid": "record/123",
|
|
"content": "test content",
|
|
"title": "test title",
|
|
"url": "https://search.example.test/mindId/xxx?recUuid=record%2F123",
|
|
},
|
|
{
|
|
"recUuid": "",
|
|
"content": "",
|
|
"title": "",
|
|
"url": "https://original.example.test/two",
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_configurable_search_handles_empty_results() -> None:
|
|
async def handler(_: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(200, json={"results": []})
|
|
|
|
result = json.loads(
|
|
await execute_search(
|
|
"nothing",
|
|
_settings(),
|
|
transport=httpx.MockTransport(handler),
|
|
)
|
|
)
|
|
|
|
assert result == {"query": "nothing", "total_results": 0, "results": []}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("query", "expected_start", "expected_end"),
|
|
[
|
|
("近期政策", "2026-05-13", "2026-08-13"),
|
|
("近几天政策", "2026-08-06", "2026-08-13"),
|
|
("查询 2026年8月10日 到 2026-08-13 的政策", "2026-08-10", "2026-08-13"),
|
|
],
|
|
)
|
|
async def test_configurable_search_adds_date_payload(
|
|
query: str,
|
|
expected_start: str,
|
|
expected_end: str,
|
|
) -> None:
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
payload = json.loads(request.content)
|
|
assert payload["start_date"] == expected_start
|
|
assert payload["end_date"] == expected_end
|
|
assert payload["top_k"] == ""
|
|
return httpx.Response(200, json={"results": []})
|
|
|
|
await execute_search(
|
|
query,
|
|
_settings(),
|
|
today=date(2026, 8, 13),
|
|
transport=httpx.MockTransport(handler),
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_configurable_search_explicit_date_args_and_top_k_win() -> None:
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
payload = json.loads(request.content)
|
|
assert payload["start_date"] == "2026-08-10"
|
|
assert payload["end_date"] == "2026-08-13"
|
|
assert payload["top_k"] == "15"
|
|
return httpx.Response(200, json={"results": []})
|
|
|
|
await execute_search(
|
|
"近期政策",
|
|
_settings(),
|
|
start_date="2026/8/10",
|
|
end_date="2026年8月13日",
|
|
top_k=15,
|
|
today=date(2026, 8, 13),
|
|
transport=httpx.MockTransport(handler),
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_configurable_search_infers_top_k_from_query() -> None:
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
payload = json.loads(request.content)
|
|
assert payload["top_k"] == "15"
|
|
return httpx.Response(200, json={"results": []})
|
|
|
|
await execute_search(
|
|
"近期政策 返回15条",
|
|
_settings(),
|
|
today=date(2026, 8, 13),
|
|
transport=httpx.MockTransport(handler),
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_configurable_search_accepts_root_array_results() -> None:
|
|
async def handler(_: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(
|
|
200,
|
|
json=[
|
|
{
|
|
"recUuid": "root-array-record",
|
|
"content": "direct array content",
|
|
"title": "direct array title",
|
|
"url": "https://original.example.test/root-array",
|
|
}
|
|
],
|
|
)
|
|
|
|
result = json.loads(
|
|
await execute_search(
|
|
"array response",
|
|
_settings(result_url_template=""),
|
|
transport=httpx.MockTransport(handler),
|
|
)
|
|
)
|
|
|
|
assert result == {
|
|
"query": "array response",
|
|
"total_results": 1,
|
|
"results": [
|
|
{
|
|
"recUuid": "root-array-record",
|
|
"content": "direct array content",
|
|
"title": "direct array title",
|
|
"url": "https://original.example.test/root-array",
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_configurable_search_normalizes_metadata_wrapped_results() -> None:
|
|
async def handler(_: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(
|
|
200,
|
|
json=[
|
|
{
|
|
"page_content": "outer content",
|
|
"metadata": {
|
|
"m_title": "metadata title",
|
|
"content": "metadata content",
|
|
"source1": "https://source.example.test/article/1",
|
|
"m_publish": "2026-08-13",
|
|
},
|
|
}
|
|
],
|
|
)
|
|
|
|
result = json.loads(
|
|
await execute_search(
|
|
"metadata response",
|
|
_settings(result_url_template=""),
|
|
transport=httpx.MockTransport(handler),
|
|
)
|
|
)
|
|
|
|
assert result["results"] == [
|
|
{
|
|
"recUuid": "",
|
|
"content": "outer content",
|
|
"title": "metadata title",
|
|
"source": "https://source.example.test/article/1",
|
|
"time": "2026-08-13",
|
|
"publish_time": "2026-08-13",
|
|
"url": "https://source.example.test/article/1",
|
|
}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
("response_factory", "expected_error"),
|
|
[
|
|
(lambda request: httpx.Response(503, request=request), "Search API returned HTTP 503"),
|
|
(
|
|
lambda request: httpx.Response(200, content=b"not-json", request=request),
|
|
"Search API request failed: JSONDecodeError",
|
|
),
|
|
(
|
|
lambda request: httpx.Response(200, json={"results": None}, request=request),
|
|
"search API response field 'results' must be an array",
|
|
),
|
|
],
|
|
)
|
|
async def test_configurable_search_returns_safe_errors(
|
|
response_factory,
|
|
expected_error: str,
|
|
) -> None:
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
return response_factory(request)
|
|
|
|
result = json.loads(
|
|
await execute_search(
|
|
"query",
|
|
_settings(),
|
|
transport=httpx.MockTransport(handler),
|
|
)
|
|
)
|
|
|
|
assert result["results"] == []
|
|
assert result["total_results"] == 0
|
|
assert result["error"] == expected_error
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_configurable_search_handles_timeout() -> None:
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
raise httpx.ReadTimeout("timed out", request=request)
|
|
|
|
result = json.loads(
|
|
await execute_search(
|
|
"query",
|
|
_settings(),
|
|
transport=httpx.MockTransport(handler),
|
|
)
|
|
)
|
|
|
|
assert result["error"] == "Search API request timed out"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_configurable_search_passes_verify_ssl_to_httpx(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
captured: dict[str, object] = {}
|
|
|
|
class FakeAsyncClient:
|
|
def __init__(self, **kwargs: object) -> None:
|
|
captured.update(kwargs)
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, *_: object) -> None:
|
|
return None
|
|
|
|
async def post(self, url: str, *, json: dict[str, object]) -> httpx.Response:
|
|
request = httpx.Request("POST", url, json=json)
|
|
return httpx.Response(200, json={"results": []}, request=request)
|
|
|
|
monkeypatch.setattr(
|
|
"deerflow.community.configurable_search.tools.httpx.AsyncClient",
|
|
FakeAsyncClient,
|
|
)
|
|
|
|
await execute_search("query", _settings(verify_ssl=False))
|
|
|
|
assert captured["verify"] is False
|
|
assert captured["timeout"] == 30.0
|
|
|
|
|
|
def test_disabled_configured_tool_is_not_registered() -> None:
|
|
config = AppConfig(
|
|
sandbox={"use": "deerflow.sandbox.local:LocalSandboxProvider"},
|
|
tools=[
|
|
ToolConfig(
|
|
name="web_search",
|
|
group="web",
|
|
enabled=False,
|
|
use="deerflow.community.configurable_search.tools:web_search_tool",
|
|
endpoint="https://search.example.test/searchName",
|
|
)
|
|
],
|
|
)
|
|
|
|
available = get_available_tools(
|
|
groups=["web"],
|
|
include_mcp=False,
|
|
app_config=config,
|
|
)
|
|
|
|
assert "web_search" not in {item.name for item in available}
|
|
|
|
config.tools[0].enabled = True
|
|
enabled_tools = get_available_tools(
|
|
groups=["web"],
|
|
include_mcp=False,
|
|
app_config=config,
|
|
)
|
|
|
|
assert "web_search" in {item.name for item in enabled_tools}
|
|
|
|
|
|
def test_config_validation_requires_rec_uuid_url_placeholder() -> None:
|
|
config = ToolConfig(
|
|
name="web_search",
|
|
group="web",
|
|
use="deerflow.community.configurable_search.tools:web_search_tool",
|
|
endpoint="https://search.example.test/searchName",
|
|
result_url_template="https://search.example.test/mindId/xxx",
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="must contain"):
|
|
ConfigurableSearchSettings.from_tool_config(config)
|