Files

203 lines
6.8 KiB
Python

"""Configurable JSON-over-HTTP implementation of the ``web_search`` tool."""
from __future__ import annotations
import json
import logging
from copy import deepcopy
from dataclasses import dataclass
from typing import Any
from urllib.parse import quote
import httpx
from langchain.tools import tool
from deerflow.config import get_app_config
from deerflow.config.tool_config import ToolConfig
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ConfigurableSearchSettings:
"""Validated runtime settings read from the ``web_search`` tool entry."""
enabled: bool
endpoint: str
payload: dict[str, Any]
verify_ssl: bool
timeout: float
max_results: int
result_url_template: str
@classmethod
def from_tool_config(cls, config: ToolConfig | None) -> ConfigurableSearchSettings:
if config is None:
raise ValueError("web_search is not configured")
extra = config.model_extra or {}
endpoint = str(extra.get("endpoint") or "").strip()
if not endpoint.startswith(("http://", "https://")):
raise ValueError("web_search.endpoint must use http:// or https://")
raw_payload = extra.get("payload", {})
if not isinstance(raw_payload, dict):
raise ValueError("web_search.payload must be an object")
try:
timeout = float(extra.get("timeout", 30))
except (TypeError, ValueError) as exc:
raise ValueError("web_search.timeout must be a number") from exc
if timeout <= 0:
raise ValueError("web_search.timeout must be greater than zero")
try:
max_results = int(extra.get("max_results", 5))
except (TypeError, ValueError) as exc:
raise ValueError("web_search.max_results must be an integer") from exc
if max_results <= 0:
raise ValueError("web_search.max_results must be greater than zero")
result_url_template = str(extra.get("result_url_template") or "").strip()
if result_url_template and "{recUuid}" not in result_url_template:
raise ValueError("web_search.result_url_template must contain {recUuid}")
verify_ssl = extra.get("verify_ssl", True)
if not isinstance(verify_ssl, bool):
raise ValueError("web_search.verify_ssl must be true or false")
return cls(
enabled=config.enabled,
endpoint=endpoint,
payload=deepcopy(raw_payload),
verify_ssl=verify_ssl,
timeout=timeout,
max_results=max_results,
result_url_template=result_url_template,
)
def _text(value: Any) -> str:
"""Normalize optional scalar result fields without leaking ``None``."""
if value is None:
return ""
if isinstance(value, str):
return value
return str(value)
def _error_result(query: str, message: str) -> str:
return json.dumps(
{
"query": query,
"total_results": 0,
"results": [],
"error": message,
},
ensure_ascii=False,
)
def _normalize_results(data: Any, settings: ConfigurableSearchSettings) -> list[dict[str, str]]:
if not isinstance(data, dict):
raise ValueError("search API response must be a JSON object")
raw_results = data.get("results")
if not isinstance(raw_results, list):
raise ValueError("search API response field 'results' must be an array")
normalized: list[dict[str, str]] = []
for item in raw_results[: settings.max_results]:
if not isinstance(item, dict):
logger.warning("Ignoring non-object search result: %r", item)
continue
rec_uuid = _text(item.get("recUuid")).strip()
original_url = _text(item.get("url")).strip()
result_url = original_url
if rec_uuid and settings.result_url_template:
result_url = settings.result_url_template.replace(
"{recUuid}",
quote(rec_uuid, safe=""),
)
normalized.append(
{
"recUuid": rec_uuid,
"content": _text(item.get("content")),
"title": _text(item.get("title")),
"url": result_url,
}
)
return normalized
async def execute_search(
query: str,
settings: ConfigurableSearchSettings,
*,
transport: httpx.AsyncBaseTransport | None = None,
) -> str:
"""Execute a search request; ``transport`` exists for network-free tests."""
normalized_query = query.strip()
if not normalized_query:
return _error_result(query, "Search query must not be empty")
if not settings.enabled:
return _error_result(normalized_query, "web_search is disabled")
payload = deepcopy(settings.payload)
payload["query"] = normalized_query
try:
async with httpx.AsyncClient(
timeout=settings.timeout,
verify=settings.verify_ssl,
transport=transport,
) as client:
response = await client.post(settings.endpoint, json=payload)
response.raise_for_status()
data = response.json()
results = _normalize_results(data, settings)
except httpx.TimeoutException:
logger.warning("Configured web search timed out for endpoint %s", settings.endpoint)
return _error_result(normalized_query, "Search API request timed out")
except httpx.HTTPStatusError as exc:
logger.warning(
"Configured web search returned HTTP %s for endpoint %s",
exc.response.status_code,
settings.endpoint,
)
return _error_result(normalized_query, f"Search API returned HTTP {exc.response.status_code}")
except (httpx.HTTPError, json.JSONDecodeError) as exc:
logger.warning("Configured web search request failed: %s", exc)
return _error_result(normalized_query, f"Search API request failed: {type(exc).__name__}")
except ValueError as exc:
logger.warning("Configured web search returned an invalid response: %s", exc)
return _error_result(normalized_query, str(exc))
return json.dumps(
{
"query": normalized_query,
"total_results": len(results),
"results": results,
},
ensure_ascii=False,
)
@tool("web_search", parse_docstring=True)
async def web_search_tool(query: str) -> str:
"""Search the configured information service for current facts and source passages.
Args:
query: Focused search keywords or a natural-language retrieval question.
"""
try:
settings = ConfigurableSearchSettings.from_tool_config(get_app_config().get_tool_config("web_search"))
except ValueError as exc:
return _error_result(query, str(exc))
return await execute_search(query, settings)