实现了完整的网页搜索与抓取内置工具集: 1. 新增搜索工具链:支持SearXNG与Tavily多Provider降级、滑动窗口限流、统一结果格式 2. 新增网页抓取工具:单页抓取、站点递归抓取、内容入库知识库功能 3. 完善安全防护:SSRF校验、robots.txt合规、请求限流 4. 自动注册工具到系统注册表,无需额外配置即可使用
81 lines
3.0 KiB
Python
81 lines
3.0 KiB
Python
"""Provider 降级编排链,前者出错时自动降级到后者。"""
|
||
import httpx
|
||
|
||
from yuxi.config import config
|
||
from yuxi.utils.logging_config import logger
|
||
|
||
from .models import SearchResult
|
||
from .providers import SearchProvider, get_searxng_provider, get_tavily_provider
|
||
|
||
|
||
class ProviderChain:
|
||
"""Provider 优先级链,前者出错时自动降级到后者。
|
||
|
||
降级规则:
|
||
1. Provider 抛异常时降级(网络错误 / 429 / 5xx)
|
||
2. 空结果不降级(空结果是合法的"无匹配")
|
||
3. 4xx(非 429)不降级(视为配置错误,如 403 format 未启用、401 API Key 无效)
|
||
"""
|
||
|
||
def __init__(self, providers: list[SearchProvider]):
|
||
self._providers = providers
|
||
|
||
@property
|
||
def is_empty(self) -> bool:
|
||
"""是否没有可用 Provider。"""
|
||
return len(self._providers) == 0
|
||
|
||
async def search(self, query: str, *, max_results: int = 5) -> tuple[list[SearchResult], str]:
|
||
"""返回 (结果列表, 实际命中的 Provider 名)。
|
||
|
||
所有 Provider 均失败时返回 ([], ""),由调用方返回友好提示。
|
||
"""
|
||
for provider in self._providers:
|
||
if not provider.is_available():
|
||
logger.debug(f"Provider {provider.name} 不可用,跳过")
|
||
continue
|
||
try:
|
||
results = await provider.search(query, max_results=max_results)
|
||
# 空结果不降级,直接返回
|
||
return results, provider.name
|
||
except Exception as exc:
|
||
if self._is_fallbackable(exc):
|
||
logger.warning(
|
||
f"provider_fallback from={provider.name} reason={type(exc).__name__}: {exc}"
|
||
)
|
||
continue
|
||
# 不可降级错误(4xx 非 429),记录错误后返回失败
|
||
logger.error(f"Provider {provider.name} 不可降级错误: {exc}")
|
||
return [], provider.name
|
||
return [], ""
|
||
|
||
@staticmethod
|
||
def _is_fallbackable(exc: Exception) -> bool:
|
||
"""判断异常是否可降级。"""
|
||
if isinstance(exc, httpx.HTTPStatusError):
|
||
status = exc.response.status_code
|
||
return status == 429 or status >= 500
|
||
# TransportError 是 ConnectError/TimeoutException/ReadError 等的基类
|
||
return isinstance(exc, httpx.TransportError)
|
||
|
||
|
||
def build_provider_chain() -> ProviderChain:
|
||
"""根据 config.web_search_provider_chain 构建 ProviderChain。
|
||
|
||
每次调用时从 config 读取,支持配置热生效。
|
||
Provider 实例通过单例工厂获取,复用 HTTP 连接池。
|
||
"""
|
||
provider_factories = {
|
||
"searxng": get_searxng_provider,
|
||
"tavily": get_tavily_provider,
|
||
}
|
||
chain_names = config.web_search_provider_chain
|
||
providers = []
|
||
for name in chain_names:
|
||
factory = provider_factories.get(name)
|
||
if factory is None:
|
||
logger.warning(f"未知 Provider 名: {name},跳过")
|
||
continue
|
||
providers.append(factory())
|
||
return ProviderChain(providers)
|