| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179 |
- # -*- 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"<title[^>]*>(.*?)</title>", html, re.IGNORECASE | re.DOTALL)
- if title_match:
- title = title_match.group(1).strip()
- # ── 去除不需要的标签及其内容 ──
- for tag in ("script", "style", "nav", "footer", "header", "noscript", "iframe"):
- html = re.sub(
- rf"<{tag}[^>]*>.*?</{tag}>",
- "",
- html,
- flags=re.IGNORECASE | re.DOTALL,
- )
- # ── 去除所有 HTML 标签,提取纯文本 ──
- text = re.sub(r"<[^>]+>", " ", html)
- # ── 清理 HTML 实体 ──
- text = re.sub(r" ", " ", text)
- text = re.sub(r"&", "&", text)
- text = re.sub(r"<", "<", text)
- text = re.sub(r">", ">", text)
- text = re.sub(r""", '"', text)
- text = re.sub(r"&#?\w+;", " ", text)
- text = re.sub(r"\s+", " ", text).strip()
- # ── 截断到 8000 字符 ──
- max_chars = 8000
- if len(text) > max_chars:
- text = text[:max_chars] + "…(内容已截断)"
- return json.dumps(
- {
- "url": url,
- "title": title,
- "content": text,
- "content_length": len(text),
- },
- ensure_ascii=False,
- )
|