# -*- coding: utf-8 -*- """ 联网搜索工具模块 提供两大能力: - web_search: 通过 DuckDuckGo 搜索互联网公开信息 - web_fetch: 抓取指定网页并提取正文文本 所有工具函数供 DeepAgent 调用,返回结构化 JSON 文本,由 LLM 解析并生成自然语言回复。 """ import asyncio import json import re import time import httpx from ddgs import DDGS # ── 搜索重试配置 ── _MAX_RETRIES = 3 # 最大重试次数 _RETRY_BASE_DELAY = 2.0 # 重试基础延迟(秒),按指数增长:2 → 4 → 8 _SEARCH_TIMEOUT = 20.0 # 单次搜索超时(秒) async def web_search(query: str, max_results: int = 5) -> str: """搜索互联网获取公开信息。当用户询问的问题超出煤矿通风专业知识库范围、 或需要了解最新政策/新闻/行业动态时调用此工具。 优先策略: - 煤矿安全规程、技术标准 → 优先使用 query_knowledge_base(内部知识库) - 最新政策动态、行业新闻、通用知识 → 使用 web_search Args: query: 搜索关键词,建议使用简洁明确的中文词组 max_results: 最大返回结果数,默认 5 条 Returns: JSON 格式的搜索结果,包含 query 和 results 列表。 每条结果含 title(标题)、href(链接)、body(摘要)。 """ if not query or not query.strip(): return json.dumps({"error": "搜索关键词不能为空"}, ensure_ascii=False) q = query.strip() last_error = "" for attempt in range(1, _MAX_RETRIES + 1): try: # 在线程池中运行同步 DDGS 调用(避免阻塞事件循环),并设置超时 results = await asyncio.wait_for( asyncio.to_thread(_do_search, q, max_results), timeout=_SEARCH_TIMEOUT, ) formatted = [] for r in results: formatted.append({ "title": r.get("title", ""), "href": r.get("href", ""), "body": r.get("body", ""), }) return json.dumps( {"query": q, "results": formatted}, ensure_ascii=False, ) except asyncio.TimeoutError: last_error = f"搜索超时(>{_SEARCH_TIMEOUT}s)" except Exception as e: last_error = str(e) if attempt < _MAX_RETRIES: delay = _RETRY_BASE_DELAY ** attempt await asyncio.sleep(delay) return json.dumps( {"error": f"搜索失败(已重试{_MAX_RETRIES}次): {last_error}", "query": q}, ensure_ascii=False, ) def _do_search(query: str, max_results: int) -> list[dict]: """同步搜索辅助函数(在线程池中执行)。""" with DDGS() as ddgs: return list(ddgs.text(query, max_results=max_results)) async def web_fetch(url: str) -> str: """抓取指定网页并提取正文文本。通常在 web_search 获取到相关链接后, 需要查看页面详细内容时调用。 Args: url: 要抓取的网页完整 URL(含 https://) Returns: JSON 格式,包含 url、title(页面标题)、content(提取的正文文本)、 content_length(正文字符数)。 正文最多保留 8000 字符,超出部分截断。 """ if not url or not url.strip(): return json.dumps({"error": "URL 不能为空"}, ensure_ascii=False) url = url.strip() try: async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: resp = await client.get( url, headers={ "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/125.0.0.0 Safari/537.36" ), "Accept": "text/html,application/xhtml+xml", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", }, ) resp.raise_for_status() html = resp.text except httpx.HTTPStatusError as e: return json.dumps( {"error": f"请求失败 HTTP {e.response.status_code}", "url": url}, ensure_ascii=False, ) except httpx.TimeoutException: return json.dumps( {"error": "请求超时(15s)", "url": url}, ensure_ascii=False, ) except Exception as e: return json.dumps( {"error": f"抓取失败: {str(e)}", "url": url}, ensure_ascii=False, ) # ── 提取标题 ── title = "" title_match = re.search(r"