proceedings_recall.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. """Proceedings recall from official/discovered pages."""
  2. from __future__ import annotations
  3. import asyncio
  4. import html
  5. import logging
  6. import re
  7. from typing import Any
  8. import anyio
  9. from ...core.paper import Paper as LitPaper
  10. from ...core.search.normalize import extract_pinned_topic_terms
  11. from .paper_filters import should_exclude_main_conference_paper
  12. from .plan_helpers import is_venue_browse_plan
  13. from .recall_context import RecallContext
  14. from .search_plan import ResolvedSearchPlan
  15. logger = logging.getLogger(__name__)
  16. def _clean_html_text(raw: str) -> str:
  17. return re.sub(r"\s+", " ", html.unescape(re.sub(r"<[^>]+>", " ", raw or ""))).strip()
  18. def _paper_from_title(
  19. searcher: Any,
  20. *,
  21. title: str,
  22. venue: str,
  23. year: int | None,
  24. source_url: str = "",
  25. authors: list[Any] | None = None,
  26. source: str = "tavily",
  27. ) -> LitPaper:
  28. journal = venue.strip().upper() or "Official Proceedings"
  29. return searcher._make_paper(
  30. title=title, authors=authors or [], abstract="",
  31. journal=journal, year=int(year) if year else None,
  32. source_url=source_url or None, source=source,
  33. )
  34. async def _llm_extract_papers_from_page(
  35. page_text: str,
  36. *,
  37. venue: str,
  38. year: int | None,
  39. topic: str,
  40. limit: int,
  41. ) -> list[dict[str, Any]]:
  42. """Extract main-track paper titles and authors from a page."""
  43. try:
  44. from ..llm.llm_service import get_llm, is_llm_configured
  45. from ..llm.agent_runtime import run_json_task
  46. if not is_llm_configured():
  47. return []
  48. except Exception:
  49. return []
  50. clipped = _clean_html_text(page_text)[:12000]
  51. if len(clipped) < 200:
  52. return []
  53. prompt = (
  54. "从下面的会议页面文本中抽取主会论文的标题和作者。\n"
  55. "重要:只抽取真实的学术论文,标题应该是具体的研究成果名称。\n"
  56. "绝对不要抽取以下内容:\n"
  57. "- 论文集名称(如 Advances in Neural Information Processing Systems)\n"
  58. "- 导航链接(如 Proceedings、List of Proceedings、Accepted Papers)\n"
  59. "- Workshop、Challenge、Tutorial、Demonstration 论文\n"
  60. "- 关于会议本身的元分析/综述论文\n"
  61. "- 数据集/代码包/Benchmark 描述文档\n"
  62. "- 日程表、征文通知、委员会名单\n"
  63. "- 网页导航、页眉页脚、版权声明\n"
  64. f"会议:{venue},年份:{year or '未知'},最多 {limit} 篇。\n"
  65. "输出 JSON:{\"papers\":[{\"title\":\"...\",\"authors\":[\"...\"]}]}\n\n"
  66. f"页面文本:\n{clipped}"
  67. )
  68. try:
  69. data = await anyio.to_thread.run_sync(
  70. lambda: run_json_task(
  71. task_name="proceedings_page_extract",
  72. agent_name="papergraph_proceedings_extractor",
  73. llm=get_llm(),
  74. system_prompt="你是严格的信息抽取器,只输出合法 JSON,不得编造页面文本中没有的论文。",
  75. user_prompt=prompt, timeout_sec=15, retries=0,
  76. default={"papers": []},
  77. )
  78. )
  79. except Exception:
  80. return []
  81. arr = data.get("papers") if isinstance(data, dict) else []
  82. if not isinstance(arr, list):
  83. return []
  84. out: list[dict[str, Any]] = []
  85. seen: set[str] = set()
  86. for it in arr:
  87. if not isinstance(it, dict):
  88. continue
  89. title = _clean_paper_title(_clean_html_text(str(it.get("title") or "")))
  90. if not title or len(title) < 8 or len(title) > 300:
  91. continue
  92. tl = title.lower()
  93. if any(w in tl for w in ("workshop", "challenge", "tutorial", "demo track", "competition")):
  94. continue
  95. key = title.lower()
  96. if key in seen:
  97. continue
  98. seen.add(key)
  99. authors = it.get("authors") if isinstance(it.get("authors"), list) else []
  100. out.append({"title": title, "authors": [str(a)[:120] for a in authors[:12] if str(a).strip()]})
  101. if len(out) >= limit:
  102. break
  103. return out
  104. async def _fetch_discovered_page(searcher: Any, url: str) -> str:
  105. await searcher._ensure_async_client()
  106. resp = await searcher._async_http_get_with_retry(
  107. url, params={},
  108. headers={"User-Agent": searcher._user_agent()},
  109. timeout=30.0, max_attempts=2,
  110. )
  111. return resp.text or ""
  112. def _clean_paper_title(raw_title: str) -> str:
  113. """Strip common title prefixes."""
  114. t = (raw_title or "").strip()
  115. t = re.sub(r"^\s*\[PDF\]\s*", "", t, flags=re.I)
  116. t = re.sub(r"^\s*\[pdf\]\s*", "", t, flags=re.I)
  117. t = re.sub(r"^\s*#+\s*", "", t)
  118. t = re.sub(r"^\s*\d+[\.\)]\s*", "", t)
  119. t = re.sub(r"\s+", " ", t).strip()
  120. return t
  121. def _dedupe_by_title(papers: list[LitPaper]) -> list[LitPaper]:
  122. seen: set[str] = set()
  123. out: list[LitPaper] = []
  124. for p in papers:
  125. key = (getattr(p, "title", "") or "").strip().lower()
  126. if key and key not in seen:
  127. seen.add(key)
  128. out.append(p)
  129. return out
  130. async def _recall_from_discovered_links(
  131. searcher: Any,
  132. *,
  133. links: list[dict[str, Any]],
  134. venue: str,
  135. year: int | None,
  136. topic: str,
  137. max_results: int,
  138. ) -> list[LitPaper]:
  139. """Extract papers from discovered proceedings links."""
  140. papers: list[LitPaper] = []
  141. seen_titles: set[str] = set()
  142. link_limit = min(len(links), max(8, max_results // 3))
  143. for item in links[:link_limit]:
  144. link = str(item.get("link") or "").strip()
  145. if not link:
  146. continue
  147. page = str(item.get("raw_content") or "").strip()
  148. if len(page) < 200:
  149. try:
  150. page = await _fetch_discovered_page(searcher, link)
  151. except Exception:
  152. page = ""
  153. llm_items = await _llm_extract_papers_from_page(
  154. page, venue=venue, year=year, topic=topic, limit=max_results,
  155. )
  156. # Empty extraction usually means this is not a paper listing.
  157. for lp in llm_items:
  158. title = _clean_paper_title(lp["title"].strip())
  159. if not title or len(title) < 8 or len(title) > 300:
  160. continue
  161. tl = title.lower()
  162. if any(w in tl for w in ("workshop", "challenge", "tutorial", "demo track", "competition")):
  163. continue
  164. key = title.lower()
  165. if key in seen_titles:
  166. continue
  167. seen_titles.add(key)
  168. papers.append(
  169. _paper_from_title(
  170. searcher, title=title, venue=venue, year=year,
  171. source_url=link, authors=lp.get("authors", []),
  172. source="tavily",
  173. )
  174. )
  175. if len(papers) >= max(8, int(max_results)):
  176. return papers
  177. return papers
  178. async def recall_from_proceedings_site(
  179. searcher: Any,
  180. *,
  181. plan: ResolvedSearchPlan,
  182. ctx: RecallContext | None = None,
  183. max_results: int = 24,
  184. ) -> list[LitPaper]:
  185. """Recall papers through discovery and configured proceedings domains."""
  186. if not plan.venues:
  187. return []
  188. venue = str(plan.venues[0]).strip()
  189. if not venue:
  190. return []
  191. year = plan.year_from if plan.year_from is not None else plan.year_to
  192. q = (ctx.effective_query if ctx else None) or (plan.query or "").strip()
  193. if not q and plan.keywords:
  194. q = " ".join(str(k) for k in plan.keywords[:4])
  195. if not q:
  196. q = venue
  197. topic = extract_pinned_topic_terms(
  198. query=q, merged_kw=list(plan.keywords or []),
  199. venue=venue, year=year if isinstance(year, int) else None,
  200. )
  201. if not topic and is_venue_browse_plan(plan):
  202. topic = ""
  203. from ...settings import get_settings
  204. tavily_key = str(getattr(get_settings(), "tavily_api_key", "") or "").strip()
  205. if not tavily_key:
  206. logger.info("[proceedings_recall] skip tavily: no tavily_api_key")
  207. return []
  208. venue_browse = is_venue_browse_plan(plan)
  209. y = year if isinstance(year, int) else None
  210. from .proceedings_discovery import discover_proceedings_links
  211. from ...core.search.sources.tavily import search_tavily_proceedings
  212. async def _discover() -> list[LitPaper]:
  213. try:
  214. links = await discover_proceedings_links(
  215. api_key=tavily_key, venue=venue, year=y,
  216. httpx_client=getattr(searcher, "_async_client", None),
  217. max_links=max(5, max_results // 2),
  218. )
  219. if not links:
  220. return []
  221. logger.info("[proceedings_recall] discovered %d links, LLM extracting…", len(links))
  222. return await _recall_from_discovered_links(
  223. searcher, links=links, venue=venue, year=y,
  224. topic=topic, max_results=max_results,
  225. )
  226. except Exception:
  227. logger.debug("[proceedings_recall] discovery failed", exc_info=True)
  228. return []
  229. async def _domain_search() -> list[LitPaper]:
  230. try:
  231. proc_cap = max(24, min(40, int(max_results))) if venue_browse else max(8, min(30, int(max_results)))
  232. papers = list(
  233. await search_tavily_proceedings(
  234. searcher, q, venue, year, proc_cap, venue_browse=venue_browse,
  235. ) or []
  236. )
  237. logger.info("[proceedings_recall] domain search → %d papers", len(papers))
  238. return papers
  239. except Exception as e:
  240. logger.warning("[proceedings_recall] domain search failed: %s", e)
  241. return []
  242. discovered, domain_papers = await asyncio.gather(_discover(), _domain_search())
  243. all_papers = _dedupe_by_title(discovered + domain_papers)
  244. if plan.main_conference_proceedings_only:
  245. pin_y = plan.year_from if plan.year_from == plan.year_to else None
  246. all_papers = [
  247. p for p in all_papers
  248. if not should_exclude_main_conference_paper(p, venue, pinned_year=pin_y)
  249. ]
  250. logger.info(
  251. "[proceedings_recall] venue=%s year=%s → %d total (discovered=%d, domain=%d)",
  252. venue, year, len(all_papers), len(discovered), len(domain_papers),
  253. )
  254. return all_papers