180 lines
6.4 KiB
Python
180 lines
6.4 KiB
Python
"""
|
||
AnySearch 联网检索客户端
|
||
======================
|
||
调用 AnySearch 网页/领域搜索 API (https://api.anysearch.com/v1/search),
|
||
为 AI 聊天注入实时联网检索结果,亦可被后端端点 / Agent 工具复用。
|
||
|
||
约束:平台容器存在智能路由代理,对外部 API 偶发重置;本客户端使用
|
||
trust_env=False 直连 (等价于 NO_PROXY=api.anysearch.com),规避代理重置。
|
||
失败一律返回空/降级结果,保证聊天链路不中断。
|
||
"""
|
||
import json
|
||
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}
|
||
|
||
|
||
def load_anysearch_config(db_session) -> Dict:
|
||
"""从 site_settings 读取全局 AnySearch 配置(失败返回空字典)。"""
|
||
if db_session is None:
|
||
return {}
|
||
try:
|
||
from app.models import SiteSetting
|
||
row = db_session.query(SiteSetting).filter(SiteSetting.key == "anysearch_config").first()
|
||
if not row or not row.value:
|
||
return {}
|
||
cfg = json.loads(row.value)
|
||
if not isinstance(cfg, dict):
|
||
return {}
|
||
return cfg
|
||
except Exception as e:
|
||
logger.warning(f"load_anysearch_config failed: {e}")
|
||
return {}
|
||
|
||
|
||
def save_anysearch_config(
|
||
db_session,
|
||
api_key: str,
|
||
is_enabled: bool = True,
|
||
api_url: str = DEFAULT_API_URL,
|
||
zone: str = DEFAULT_ZONE,
|
||
freshness: Optional[str] = None,
|
||
top_k: int = 5,
|
||
) -> bool:
|
||
"""保存/更新全局 AnySearch 配置到 site_settings。"""
|
||
if db_session is None:
|
||
return False
|
||
try:
|
||
from app.models import SiteSetting
|
||
from datetime import datetime
|
||
cfg = {
|
||
"api_key": api_key.strip(),
|
||
"is_enabled": bool(is_enabled),
|
||
"api_url": api_url.strip() or DEFAULT_API_URL,
|
||
"zone": zone or DEFAULT_ZONE,
|
||
"freshness": freshness,
|
||
"top_k": max(1, min(int(top_k), 20)),
|
||
}
|
||
row = db_session.query(SiteSetting).filter(SiteSetting.key == "anysearch_config").first()
|
||
if row:
|
||
row.value = json.dumps(cfg, ensure_ascii=False)
|
||
row.updated_at = datetime.now()
|
||
else:
|
||
from app.models import gen_uuid
|
||
row = SiteSetting(id=gen_uuid(), key="anysearch_config", value=json.dumps(cfg, ensure_ascii=False))
|
||
db_session.add(row)
|
||
db_session.commit()
|
||
return True
|
||
except Exception as e:
|
||
logger.warning(f"save_anysearch_config failed: {e}")
|
||
try:
|
||
db_session.rollback()
|
||
except Exception:
|
||
pass
|
||
return False
|