""" 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