feat(web_search): 新增Outcrawl搜索源 (#3538)

This commit is contained in:
Nirupam Bhowmick authored and GitHub committed 2026-11-04 10:22:41 +08:00
1 parent 5b0de039c9
commit 7d9f47b0d1
6 files changed
+193 -7

No files matched your search

+12 -4
View File
@@ -2,14 +2,15 @@
## 功能简介
联网搜索插件 `web_search` 支持在对话过程中实时联网搜索信息并返回结果。插件支持三个搜索源:秘塔(Metaso)、Tavily和Serply,用户可根据需要选择其中一个。
联网搜索插件 `web_search` 支持在对话过程中实时联网搜索信息并返回结果。插件支持四个搜索源:秘塔(Metaso)、Tavily、Serply和Outcrawl,用户可根据需要选择其中一个。
## API Key申请指南
目前我们适配了`秘塔搜索`、`Tavily搜索`和`Serply搜索`。
目前我们适配了`秘塔搜索`、`Tavily搜索`、`Serply搜索`和`Outcrawl搜索`。
- Tavily搜索:每个月1000次免费额度。
- 秘塔搜索:拥有较为优质的国内数据源。
- Serply搜索:返回谷歌搜索结果,注册赠送2500次免费额度,额度用完后需要付费。
- Outcrawl搜索:面向AI智能体的网页搜索接口,每月1000 credits免费额度。
## API Key申请指南
@@ -32,6 +33,13 @@
- 复制生成的API Key,这是配置所需的关键信息
- 接口说明见 [Serply文档](https://serply.io/docs)
### 方式四:使用Outcrawl搜索
- 访问 [Outcrawl](https://outcrawl.ai),注册并登录账号
- 在控制台中创建API Key
- 复制生成的API Key,这是配置所需的关键信息
- 接口说明见 [Outcrawl文档](https://outcrawl.ai/docs/search/overview)
## 配置方式
### 方式1. 使用智控台部署(推荐)
@@ -40,14 +48,14 @@
- 进入"配置角色"页面,选择要配置的智能体
- 点击"编辑功能"按钮,在右侧参数配置区域找到"联网搜索"插件
- 勾选"联网搜索"
- 填入搜索源(`metaso`、`tavily`或`serply`),并将对应的`API Key`填入配置项
- 填入搜索源(`metaso`、`tavily`、`serply`或`outcrawl`),并将对应的`API Key`填入配置项
- 保存配置,再保存智能体配置
### 方式2. 单模块xiaozhi-server部署
在 `data/.config.yaml` 中配置:
- 将搜索源填入 `provider`,可选值为 `metaso`、`tavily` 或 `serply`
- 将搜索源填入 `provider`,可选值为 `metaso`、`tavily`、`serply` 或 `outcrawl`
- 将申请到的API Key填入 `api_key`
```yaml
@@ -0,0 +1,9 @@
-- 联网搜索插件新增Outcrawl搜索源
UPDATE `ai_model_provider`
SET `fields` = JSON_SET(
`fields`,
'$[0].label',
'搜索源:metaso / tavily / serply / outcrawl'
)
WHERE `id` = 'SYSTEM_PLUGIN_WEB_SEARCH';
@@ -760,3 +760,10 @@ databaseChangeLog:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202610302051.sql
- changeSet:
id: 202611021200
author: heydryft
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202611021200.sql
+4 -3
View File
@@ -172,18 +172,19 @@ plugins:
# ragflow知识库id
dataset_ids: ["123456789"]
# 联网搜索插件配置
# 支持秘塔(metaso)、Tavily和Serply三个搜索源
# 支持秘塔(metaso)、Tavily、Serply和Outcrawl四个搜索源
# 秘塔API Key申请:https://metaso.cn/search-api/api-keys
# Tavily API Key申请:https://app.tavily.com/home
# Serply API Key申请:https://serply.io
# Outcrawl API Key申请:https://outcrawl.ai
web_search:
# 搜索源:metaso / tavily / serply
# 搜索源:metaso / tavily / serply / outcrawl
provider: "metaso"
# 工具描述,用于告知LLM何时调用此工具,可根据需要自定义
description: "联网搜索工具。当用户明确需要联网搜索问题时使用此工具。"
# 返回结果数量(默认5)
max_results: 5
# 秘塔API Key(mk-前缀) / Tavily API Key(tvly-前缀) / Serply API Key
# 秘塔API Key(mk-前缀) / Tavily API Key(tvly-前缀) / Serply API Key / Outcrawl API Key
api_key: "mk-xxx"
# 声纹识别配置
voiceprint:
@@ -149,6 +149,46 @@ async def _search_serply(api_key: str, query: str, max_results: int) -> str:
return "\n".join(lines)
async def _search_outcrawl(api_key: str, query: str, max_results: int) -> str:
"""调用Outcrawl搜索API"""
url = "https://api.outcrawl.ai/v1/search"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
"User-Agent": "xiaozhi-esp32-server",
}
# Outcrawl单次最多返回20条
payload = {"query": query, "limit": max(1, min(max_results, 20))}
logger.bind(tag=TAG).debug(f"Outcrawl搜索请求 | URL: {url} | payload: {payload}")
async with httpx.AsyncClient(timeout=httpx.Timeout(15.0, connect=3.0)) as client:
response = await client.post(url, json=payload, headers=headers)
data = response.json()
logger.bind(tag=TAG).debug(f"Outcrawl搜索响应 | status: {response.status_code}")
if response.status_code != 200:
detail = data.get("message", "") if isinstance(data, dict) else ""
logger.bind(tag=TAG).error(
f"Outcrawl搜索失败 | status: {response.status_code} | detail: {detail}"
)
if response.status_code == 402:
return "联网搜索额度已用完,请检查Outcrawl账户余额。"
return "联网搜索请求失败,请检查API Key是否正确。"
hits = data.get("hits", [])
if not hits:
return "未找到相关搜索结果。"
lines = ["【联网搜索结果】"]
for i, item in enumerate(hits[:max_results], 1):
title = item.get("title") or "无标题"
snippet = item.get("snippet", "")
lines.append(f"{i}. 标题:{title}")
if snippet:
lines.append(f" 摘要:{snippet}")
return "\n".join(lines)
@register_function("web_search", WEB_SEARCH_FUNCTION_DESC, ToolType.SYSTEM_CTL)
async def web_search(conn: "ConnectionHandler", query: str = None):
logger.bind(tag=TAG).info(f"web_search 被调用 | query={query}")
@@ -175,6 +215,8 @@ async def web_search(conn: "ConnectionHandler", query: str = None):
result_text = await _search_tavily(api_key, query, max_results)
elif provider == "serply":
result_text = await _search_serply(api_key, query, max_results)
elif provider == "outcrawl":
result_text = await _search_outcrawl(api_key, query, max_results)
else:
return ActionResponse(
Action.REQLLM,
@@ -0,0 +1,119 @@
"""Tests for the Outcrawl source of the web_search plugin.
The HTTP layer is replaced with a fake client and every request is recorded.
Nothing here touches the network.
"""
import httpx
import pytest
from plugins_func.functions import web_search as web_search_module
from plugins_func.register import Action
class FakeResponse:
def __init__(self, status_code=200, payload=None):
self.status_code = status_code
self.payload = payload
def json(self):
return self.payload
class FakeAsyncClient:
"""Stands in for httpx.AsyncClient and records what was requested."""
requests = []
response = None
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *exc_info):
return False
async def post(self, url, json=None, headers=None):
FakeAsyncClient.requests.append({"url": url, "json": json, "headers": headers})
return FakeAsyncClient.response
@pytest.fixture
def fake_http(monkeypatch):
"""Replace httpx.AsyncClient with the recording fake."""
FakeAsyncClient.requests = []
FakeAsyncClient.response = FakeResponse(payload={"hits": []})
monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient)
return FakeAsyncClient
class FakeConn:
def __init__(self, web_search_config):
self.config = {"plugins": {"web_search": web_search_config}}
async def test_outcrawl_sends_bearer_key_and_limit(fake_http):
await web_search_module._search_outcrawl("oc-key", "上海天气", 5)
[request] = fake_http.requests
assert request["url"] == "https://api.outcrawl.ai/v1/search"
assert request["json"] == {"query": "上海天气", "limit": 5}
assert request["headers"]["Authorization"] == "Bearer oc-key"
assert request["headers"]["User-Agent"] == "xiaozhi-esp32-server"
@pytest.mark.parametrize(("max_results", "limit"), [(0, 1), (50, 20)])
async def test_outcrawl_clamps_limit(fake_http, max_results, limit):
await web_search_module._search_outcrawl("oc-key", "q", max_results)
assert fake_http.requests[0]["json"]["limit"] == limit
async def test_outcrawl_formats_titles_and_snippets_without_urls(fake_http):
fake_http.response = FakeResponse(
payload={
"hits": [
{"url": "https://a.example/1", "title": "标题一", "snippet": "摘要一", "rank": 1},
{"url": "https://a.example/2", "title": None, "snippet": "", "rank": 2},
{"url": "https://a.example/3", "title": "标题三", "snippet": "摘要三", "rank": 3},
],
"usage": {"credits": "1.5"},
}
)
result = await web_search_module._search_outcrawl("oc-key", "q", 2)
assert result == "【联网搜索结果】\n1. 标题:标题一\n 摘要:摘要一\n2. 标题:无标题"
assert "https://" not in result
async def test_outcrawl_empty_hits(fake_http):
fake_http.response = FakeResponse(payload={"hits": []})
assert await web_search_module._search_outcrawl("oc-key", "q", 3) == "未找到相关搜索结果。"
@pytest.mark.parametrize(
("status_code", "message"),
[
(401, "联网搜索请求失败,请检查API Key是否正确。"),
(402, "联网搜索额度已用完,请检查Outcrawl账户余额。"),
(429, "联网搜索请求失败,请检查API Key是否正确。"),
],
)
async def test_outcrawl_error_status(fake_http, status_code, message):
fake_http.response = FakeResponse(status_code=status_code, payload={"message": "denied"})
assert await web_search_module._search_outcrawl("oc-key", "q", 3) == message
async def test_web_search_dispatches_to_outcrawl(fake_http):
fake_http.response = FakeResponse(payload={"hits": [{"url": "https://a.example", "title": "T", "snippet": "S"}]})
conn = FakeConn({"provider": "Outcrawl", "api_key": "oc-key", "max_results": 3})
response = await web_search_module.web_search(conn, "q")
assert response.action == Action.REQLLM
assert response.result == "【联网搜索结果】\n1. 标题:T\n 摘要:S"
assert fake_http.requests[0]["json"] == {"query": "q", "limit": 3}