T4: AnySearch 联网检索接入 (anysearch_client trust_env直连 NO_PROXY, kb_router use_web RRF融合, chat_context 自动注入联网上下文, admin 支持 anysearch 服务类型, 新增 POST /api/knowledge/web-search 端点)

This commit is contained in:
QihuAI Design committed 2026-07-21 18:29:51 +08:00
1 parent be7d792dc2
commit 3c9d9518b1
5 files changed
+6161 -2993

No files matched your search

+118
View File
@@ -0,0 +1,118 @@
"""
AnySearch 联网检索客户端
======================
调用 AnySearch 网页/领域搜索 API (https://api.anysearch.com/v1/search),
为 AI 聊天注入实时联网检索结果,亦可被后端端点 / Agent 工具复用。
约束:平台容器存在智能路由代理,对外部 API 偶发重置;本客户端使用
trust_env=False 直连 (等价于 NO_PROXY=api.anysearch.com),规避代理重置。
失败一律返回空/降级结果,保证聊天链路不中断。
"""
import logging
from typing import List, Dict, Optional
logger = logging.getLogger("aiflow.anysearch")
DEFAULT_API_URL = "https://api.anysearch.com/v1/search"
DEFAULT_ZONE = "cn"
REQUEST_TIMEOUT = 8.0
async def anysearch_search(
api_key: str,
api_url: str = DEFAULT_API_URL,
query: str = "",
top_k: int = 5,
zone: str = DEFAULT_ZONE,
freshness: Optional[str] = None,
timeout: float = REQUEST_TIMEOUT,
) -> List[Dict]:
"""调用 AnySearch 搜索,返回结构化结果列表。
每个结果:
{
"content": snippet 摘要文本,
"score": 0.9..0.5 递减,
"source": "web_anysearch",
"metadata": {"url", "title", "source_domain", "zone"}
}
失败时返回 [] (不抛异常)。
"""
if not api_key:
return []
try:
import httpx
except ImportError:
logger.warning("httpx not available for AnySearch")
return []
payload = {
"query": query,
"zone": zone,
"top_k": max(1, min(int(top_k), 20)),
}
if freshness:
payload["freshness"] = freshness
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
}
try:
# trust_env=False -> 忽略容器内 HTTP(S)_PROXY,直连 api.anysearch.com
async with httpx.AsyncClient(trust_env=False, timeout=timeout) as client:
resp = await client.post(api_url, json=payload, headers=headers)
if resp.status_code != 200:
logger.warning(f"AnySearch HTTP {resp.status_code}: {resp.text[:200]}")
return []
data = resp.json()
if data.get("code") != 0:
logger.warning(f"AnySearch biz error: {data.get('message')}")
return []
results = (data.get("data") or {}).get("results") or []
hits = []
for idx, r in enumerate(results[:top_k]):
title = r.get("title", "") or ""
url = r.get("url", "") or ""
snippet = r.get("snippet") or r.get("content") or ""
if not snippet and not title:
continue
try:
from urllib.parse import urlparse
domain = urlparse(url).netloc
except Exception:
domain = ""
hits.append({
"content": snippet,
"score": max(0.4, 0.9 - idx * 0.08),
"source": "web_anysearch",
"metadata": {
"url": url,
"title": title,
"source_domain": domain,
"zone": zone,
},
})
return hits
except Exception as e:
logger.warning(f"AnySearch search failed: {e}")
return []
async def anysearch_health(api_key: str, api_url: str = DEFAULT_API_URL, timeout: float = REQUEST_TIMEOUT) -> Dict:
"""连通性探测:返回 {"status": "connected"/"disconnected", "detail": ...}。"""
if not api_key:
return {"status": "disconnected", "detail": "未配置 API Key", "connected": False, "usable": False}
try:
import httpx
async with httpx.AsyncClient(trust_env=False, timeout=timeout) as client:
resp = await client.post(
api_url,
json={"query": "ping", "zone": DEFAULT_ZONE, "top_k": 1},
headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"},
)
if resp.status_code == 200:
return {"status": "connected", "detail": "AnySearch 连接正常", "connected": True, "usable": True}
return {"status": "error", "detail": f"HTTP {resp.status_code}", "connected": False, "usable": False}
except Exception as e:
return {"status": "error", "detail": str(e)[:200], "connected": False, "usable": False}