literature_tool.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. """
  2. 文献检索工具 — Semantic Scholar API
  3. 覆盖 2 亿+ 学术论文,涵盖计算机科学、医学、生物学、物理学、化学、
  4. 社会科学、经济学、人文艺术等全学科领域。
  5. API 文档: https://api.semanticscholar.org/api-docs/
  6. """
  7. import urllib.request
  8. import urllib.parse
  9. import urllib.error
  10. import json
  11. import os
  12. import time
  13. from typing import Dict, Any, List
  14. from hello_agents.tools import Tool, ToolParameter, ToolResponse, ToolStatus
  15. class LiteratureSearchTool(Tool):
  16. """全学科文献检索工具
  17. 通过 Semantic Scholar API 在多学科数据库中检索学术论文。
  18. 覆盖 2 亿+ 论文,支持关键词、作者、年份、学科领域等筛选条件。
  19. 返回论文标题、作者、摘要、发表信息、引用次数、PDF 链接等。
  20. """
  21. BASE_URL = "https://api.semanticscholar.org/graph/v1/paper/search"
  22. # 请求的论文字段
  23. FIELDS = [
  24. "title", "abstract", "authors", "year", "venue",
  25. "externalIds", "citationCount", "influentialCitationCount",
  26. "openAccessPdf", "journal", "publicationTypes", "fieldsOfStudy"
  27. ]
  28. # 中文学科关键词映射
  29. FIELD_ALIASES = {
  30. "计算机科学": "Computer Science",
  31. "人工智能": "Artificial Intelligence",
  32. "机器学习": "Machine Learning",
  33. "医学": "Medicine",
  34. "生物学": "Biology",
  35. "物理学": "Physics",
  36. "化学": "Chemistry",
  37. "数学": "Mathematics",
  38. "经济学": "Economics",
  39. "心理学": "Psychology",
  40. "社会学": "Sociology",
  41. "语言学": "Linguistics",
  42. "哲学": "Philosophy",
  43. "历史": "History",
  44. "工程": "Engineering",
  45. "环境科学": "Environmental Science",
  46. "材料科学": "Materials Science",
  47. "教育学": "Education",
  48. "法学": "Law",
  49. "政治学": "Political Science",
  50. "商学": "Business",
  51. "艺术": "Art",
  52. "地理": "Geography",
  53. "地质": "Geology",
  54. }
  55. def __init__(self):
  56. super().__init__(
  57. name="literature_search",
  58. description="通过 Semantic Scholar 在全学科数据库中检索学术论文。"
  59. "覆盖 2 亿+ 论文,涵盖计算机科学、医学、生物、物理、化学、"
  60. "社会科学、经济学、人文等所有学术领域。"
  61. "支持按关键词、作者、年份范围、学科领域筛选。"
  62. "返回论文标题、作者、摘要、期刊、引用次数、PDF 链接等信息。"
  63. "当需要跨学科检索学术文献时使用此工具,比 arXiv 覆盖面更广。"
  64. )
  65. def _map_field(self, field_input: str) -> str:
  66. """将中文/模糊学科名映射到 Semantic Scholar 领域"""
  67. if not field_input:
  68. return ""
  69. field_input = field_input.strip()
  70. # 直接匹配
  71. for cn, en in self.FIELD_ALIASES.items():
  72. if cn in field_input or field_input.lower() in cn.lower():
  73. return en
  74. # 已经是英文则直接返回
  75. return field_input
  76. def _build_url(self, parameters: Dict[str, Any]) -> str:
  77. """构建 Semantic Scholar 搜索 URL"""
  78. keyword = parameters.get("keyword", "")
  79. author = parameters.get("author", "")
  80. field = parameters.get("field", "")
  81. year_from = parameters.get("year_from", "")
  82. year_to = parameters.get("year_to", "")
  83. limit = min(parameters.get("max_results", 5), 20)
  84. # 构建查询字符串
  85. query_parts = []
  86. if keyword:
  87. query_parts.append(keyword.strip())
  88. if author:
  89. query_parts.append(f'author:"{author.strip()}"')
  90. query = " ".join(query_parts) if query_parts else "machine learning"
  91. params = {
  92. "query": query,
  93. "limit": str(limit),
  94. "fields": ",".join(self.FIELDS)
  95. }
  96. # 学科筛选
  97. mapped_field = self._map_field(field) if field else ""
  98. if mapped_field:
  99. params["fieldsOfStudy"] = mapped_field
  100. # 年份筛选
  101. if year_from or year_to:
  102. year_filter = f"{year_from or '1900'}-{year_to or '2026'}"
  103. params["year"] = year_filter
  104. return f"{self.BASE_URL}?{urllib.parse.urlencode(params)}"
  105. def _format_paper(self, paper: Dict, index: int, keyword: str = "") -> str:
  106. """格式化单篇论文为 Markdown"""
  107. title = paper.get("title", "N/A")
  108. year = paper.get("year", "N/A")
  109. venue = paper.get("venue", "")
  110. journal = paper.get("journal", {})
  111. journal_name = journal.get("name", "") if journal else ""
  112. publication_venue = venue or journal_name or "N/A"
  113. # 作者列表
  114. authors_list = paper.get("authors", [])
  115. author_names = [a.get("name", "") for a in authors_list[:5]]
  116. authors_str = ", ".join(author_names)
  117. if len(authors_list) > 5:
  118. authors_str += " et al."
  119. # 摘要:优先取 TLDR,其次取 abstract
  120. abstract = paper.get("abstract") or "暂无摘要"
  121. if len(abstract) > 400:
  122. abstract = abstract[:400] + "..."
  123. # 引用次数
  124. citations = paper.get("citationCount", 0)
  125. # DOI
  126. external_ids = paper.get("externalIds", {}) or {}
  127. doi = external_ids.get("DOI", "")
  128. # PDF 链接
  129. open_access = paper.get("openAccessPdf", {}) or {}
  130. pdf_url = open_access.get("url", "")
  131. arxiv_id = external_ids.get("ArXiv", "")
  132. # 领域标签
  133. fields = paper.get("fieldsOfStudy", []) or []
  134. fields_str = ", ".join(fields[:3]) if fields else ""
  135. lines = [f"### {index}. {title}"]
  136. if authors_str:
  137. lines.append(f"> 作者: {authors_str}")
  138. lines.append(f"> 发表: {year} | {publication_venue}")
  139. if fields_str:
  140. lines.append(f"> 领域: {fields_str}")
  141. lines.append(f"> 引用: {citations} 次")
  142. # 链接
  143. links = []
  144. if doi:
  145. links.append(f"[DOI](https://doi.org/{doi})")
  146. if pdf_url:
  147. links.append(f"[PDF]({pdf_url})")
  148. if arxiv_id:
  149. links.append(f"[arXiv](https://arxiv.org/abs/{arxiv_id})")
  150. if links:
  151. lines.append(f"> {' | '.join(links)}")
  152. lines.append(f">> {abstract}")
  153. lines.append("")
  154. return "\n".join(lines)
  155. def _make_request(self, url: str, api_key: str, max_retries: int = 3) -> Dict:
  156. """发送 API 请求,带指数退避重试"""
  157. last_error = None
  158. for attempt in range(max_retries):
  159. try:
  160. req = urllib.request.Request(
  161. url,
  162. headers={
  163. "User-Agent": "PaperAssistant/1.0",
  164. "Accept": "application/json"
  165. }
  166. )
  167. if api_key:
  168. req.add_header("x-api-key", api_key)
  169. with urllib.request.urlopen(req, timeout=20) as resp:
  170. return json.loads(resp.read().decode("utf-8"))
  171. except urllib.error.HTTPError as e:
  172. if e.code == 429:
  173. # 速率限制:等待后重试
  174. wait = 2 ** (attempt + 1) # 2s, 4s, 8s
  175. if attempt < max_retries - 1:
  176. time.sleep(wait)
  177. continue
  178. raise RuntimeError(
  179. "API 请求频率已达上限(429 Too Many Requests)。\n"
  180. "Semantic Scholar 免费额度为 100 次/5 分钟。\n"
  181. "请稍等 1-5 分钟后重试,或申请免费 API Key:\n"
  182. "https://www.semanticscholar.org/product/api\n"
  183. "获取后在 .env 中设置 SEMANTIC_SCHOLAR_API_KEY"
  184. ) from e
  185. raise RuntimeError(
  186. f"Semantic Scholar API 返回 HTTP {e.code}: {e.reason}"
  187. ) from e
  188. except urllib.error.URLError as e:
  189. last_error = e
  190. if attempt < max_retries - 1:
  191. time.sleep(2 ** (attempt + 1))
  192. continue
  193. raise RuntimeError(f"网络连接失败: {str(e.reason)}") from e
  194. raise RuntimeError(f"请求失败(已重试 {max_retries} 次): {last_error}")
  195. def run(self, parameters: Dict[str, Any]) -> ToolResponse:
  196. keyword = parameters.get("keyword", "")
  197. author = parameters.get("author", "")
  198. field = parameters.get("field", "")
  199. if not keyword and not author:
  200. return ToolResponse.error(
  201. code="INVALID_PARAM",
  202. message="请至少提供关键词(keyword)或作者(author)"
  203. )
  204. url = self._build_url(parameters)
  205. api_key = os.getenv("SEMANTIC_SCHOLAR_API_KEY", "")
  206. try:
  207. data = self._make_request(url, api_key)
  208. papers = data.get("data", [])
  209. total = data.get("total", 0)
  210. offset = data.get("offset", 0)
  211. if not papers:
  212. # 尝试推荐相似的搜索词
  213. suggestion = ""
  214. if keyword:
  215. suggestion = f"\n\n建议:尝试更简短的关键词,或更换同义词。如将 '{keyword}' 改为更通用的表述。"
  216. return ToolResponse.success(
  217. text=f"未找到匹配的论文(共 {total} 条结果)。{suggestion}",
  218. data={"count": 0, "total": total, "papers": []}
  219. )
  220. # 格式化输出
  221. lines = [f"找到 {total} 篇论文(显示前 {len(papers)} 篇,偏移 {offset}):\n"]
  222. for i, paper in enumerate(papers, 1):
  223. lines.append(self._format_paper(paper, i, keyword))
  224. lines.append(f"---")
  225. lines.append(f"*本次检索共 {total} 篇结果。如需更多,请调整关键词或筛选条件。*")
  226. if total > len(papers):
  227. lines.append(f"*提示:可通过增加 max_results 获取更多结果(最大 20)。*")
  228. return ToolResponse.success(
  229. text="\n".join(lines),
  230. data={
  231. "count": len(papers),
  232. "total": total,
  233. "offset": offset,
  234. "papers": [
  235. {
  236. "title": p.get("title"),
  237. "authors": [a.get("name") for a in p.get("authors", [])],
  238. "year": p.get("year"),
  239. "venue": p.get("venue", ""),
  240. "citationCount": p.get("citationCount", 0),
  241. "abstract": (p.get("abstract") or "")[:300],
  242. "doi": (p.get("externalIds") or {}).get("DOI", ""),
  243. "fieldsOfStudy": p.get("fieldsOfStudy", [])
  244. }
  245. for p in papers
  246. ]
  247. }
  248. )
  249. except RuntimeError as e:
  250. # _make_request 中已含重试逻辑,此处为最终失败
  251. return ToolResponse.error(
  252. code="API_ERROR",
  253. message=f"[检索失败] {str(e)}\n\n"
  254. "请等待 1-2 分钟后重试。在此期间可使用其他数据源(OpenAlex、CrossRef、PubMed)。"
  255. )
  256. except json.JSONDecodeError:
  257. return ToolResponse.error(
  258. code="INVALID_FORMAT",
  259. message="解析 API 返回数据失败,请稍后重试。"
  260. )
  261. except Exception as e:
  262. return ToolResponse.error(
  263. code="INTERNAL_ERROR",
  264. message=f"检索过程出错: {str(e)}"
  265. )
  266. def get_parameters(self) -> List[ToolParameter]:
  267. return [
  268. ToolParameter(
  269. name="keyword", type="string",
  270. description="搜索关键词,支持中英文。如 'transformer attention mechanism' 或 '深度学习 图像分割'",
  271. required=False
  272. ),
  273. ToolParameter(
  274. name="author", type="string",
  275. description="作者姓名,如 'Geoffrey Hinton' 或 '何恺明'",
  276. required=False
  277. ),
  278. ToolParameter(
  279. name="field", type="string",
  280. description="学科领域,支持中英文。如 '计算机科学'/'Computer Science'、'医学'/'Medicine'、'物理学'/'Physics'",
  281. required=False
  282. ),
  283. ToolParameter(
  284. name="year_from", type="string",
  285. description="起始年份,如 '2020'",
  286. required=False
  287. ),
  288. ToolParameter(
  289. name="year_to", type="string",
  290. description="截止年份,如 '2026'",
  291. required=False
  292. ),
  293. ToolParameter(
  294. name="max_results", type="integer",
  295. description="最大返回结果数(默认5,最大20)",
  296. required=False
  297. ),
  298. ]