daily_support.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  1. """每日推荐支撑 —— arXiv RSS 解析、候选论文格式化与 API 适配."""
  2. from __future__ import annotations
  3. import json
  4. import logging
  5. import re
  6. import time
  7. from collections import Counter
  8. from typing import Any
  9. from fastapi.concurrency import run_in_threadpool
  10. from ...agents import get_search_agent
  11. from ...core.search import _arxiv_canonical_from_paper
  12. from ...settings import get_settings
  13. from ...utils.common import suppress_exceptions, suppress_exceptions_async
  14. from .daily_recommend_feedback import get_high_value_keywords_from_feedback, get_skipped_papers
  15. from .user_behavior_analytics import get_user_interest_profile_for_daily_recommend
  16. from ..llm.llm_service import coerce_hello_agents_llm_output_to_str
  17. logger = logging.getLogger(__name__)
  18. _DAILY_HTTP_TIMEOUT_SEC = 45
  19. _DAILY_HTTP_MAX_ATTEMPTS = 3
  20. _ARXIV_QUERY_NOISE = frozenset({
  21. "academicsearch", "tavilysearch", "refinequery", "parseintent", "filterresults",
  22. "explainresults", "diversifyresults", "proceedingsitesearch", "finish",
  23. })
  24. _OPENALEX_FALLBACK_QUERY = "machine learning neural network transformer deep learning"
  25. _user_profile_cache: tuple[Any, ...] | None = None
  26. _user_profile_cache_ts: float = 0.0
  27. _USER_PROFILE_CACHE_TTL = 7200
  28. @suppress_exceptions(default_return={"http_timeout_sec": float(_DAILY_HTTP_TIMEOUT_SEC), "http_max_attempts": int(_DAILY_HTTP_MAX_ATTEMPTS)})
  29. def daily_arxiv_http_kw() -> dict[str, float | int]:
  30. s = get_settings()
  31. to = float(getattr(s, "papergraph_daily_arxiv_http_timeout_sec", _DAILY_HTTP_TIMEOUT_SEC))
  32. at = int(getattr(s, "papergraph_daily_arxiv_http_max_attempts", _DAILY_HTTP_MAX_ATTEMPTS))
  33. return {"http_timeout_sec": max(15.0, min(300.0, to)), "http_max_attempts": max(1, min(10, at))}
  34. def prepare_memory_keywords(mem_kw: set[str], *, limit: int = 12, short_first: bool = False) -> tuple[list[str], int]:
  35. raw_items = {str(x).strip().lower() for x in (mem_kw or set()) if str(x).strip()}
  36. ranked = sorted(raw_items, key=lambda s: (len(s), s)) if short_first else sorted(raw_items)
  37. out, seen = [], set()
  38. for t in ranked:
  39. if t and t not in seen and len(t) > 2 and not (t.isdigit() and len(t) <= 4):
  40. out.append(t)
  41. seen.add(t)
  42. if len(out) >= limit:
  43. break
  44. return out, len(raw_items)
  45. def collect_memory_store_texts(
  46. store: Any,
  47. lib_ids: list[int],
  48. *,
  49. global_limit: int = 28,
  50. snippets_per_paper: int = 5,
  51. max_papers: int = 60,
  52. ) -> list[str]:
  53. raw_texts: list[str] = []
  54. for line in store.list_recent_contents(
  55. scope="global", paper_id=None, kinds=["preference", "working", "short"], limit=global_limit
  56. ):
  57. s = str(line or "").strip()
  58. if s:
  59. raw_texts.append(s)
  60. seen: set[int] = set()
  61. n = 0
  62. for pid in lib_ids:
  63. try:
  64. i = int(pid)
  65. except Exception:
  66. continue
  67. if i <= 0 or i in seen:
  68. continue
  69. seen.add(i)
  70. n += 1
  71. if n > max_papers:
  72. break
  73. for line in store.list_recent_contents(
  74. scope="paper",
  75. paper_id=i,
  76. kinds=["short", "working", "paper_summary"],
  77. limit=snippets_per_paper,
  78. ):
  79. s = str(line or "").strip()
  80. if s:
  81. raw_texts.append(s)
  82. return raw_texts
  83. def extract_library_characteristics(library_papers: list[Any]) -> tuple[int, set[str]]:
  84. """从用户文献库标题/摘要提取高频词,供每日推荐 arXiv 查询拼接。"""
  85. blobs: list[str] = []
  86. for p in library_papers or []:
  87. title = str(getattr(p, "title", "") or "").strip()
  88. abstract = str(getattr(p, "abstract", "") or "").strip()
  89. if title:
  90. blobs.append(title)
  91. if abstract:
  92. blobs.append(abstract[:800])
  93. return len(library_papers or []), memory_keywords_from_texts(blobs, tokens_cap=80)
  94. def memory_keywords_from_texts(blobs: list[str], *, tokens_cap: int = 320) -> set[str]:
  95. if not blobs:
  96. return set()
  97. def _tok(text: str) -> list[str]:
  98. t = re.sub(r"[^a-z0-9\u4e00-\u9fff]+", " ", (text or "").lower())
  99. return [x for x in (x.strip() for x in t.split() if x.strip()) if len(x) >= 3][:2000]
  100. freq: Counter[str] = Counter()
  101. for b in blobs:
  102. for w in _tok(b):
  103. freq[w] += 1
  104. return {w for w, _ in freq.most_common(tokens_cap) if 3 <= len(w) <= 28}
  105. def build_daily_arxiv_query(mem_kw: set[str], lib_kw: set[str] | list[str] | None, *, log: Any = None) -> str:
  106. try:
  107. merged = list(mem_kw or set()) + list(lib_kw or [])
  108. clean = [
  109. str(x).strip().lower()
  110. for x in merged
  111. if str(x).strip() and len(str(x).strip()) >= 3
  112. and str(x).strip().lower() not in _ARXIV_QUERY_NOISE
  113. and not str(x).strip().startswith("http")
  114. ]
  115. keywords = clean[:12]
  116. if not keywords:
  117. return ""
  118. if len(keywords) >= 3:
  119. llm_query = _llm_build_arxiv_query(keywords, log=log)
  120. if llm_query and len(llm_query) >= 10:
  121. return llm_query[:200]
  122. return " OR ".join(f'"{kw}"' for kw in keywords[:4])[:200]
  123. except Exception:
  124. return ""
  125. def _llm_build_arxiv_query(keywords: list[str], *, log: Any = None) -> str:
  126. try:
  127. from ..llm.llm_service import get_llm, is_llm_configured, coerce_hello_agents_llm_output_to_str
  128. if not is_llm_configured():
  129. return ""
  130. kw_str = ", ".join(keywords[:12])
  131. prompt = (
  132. f"将用户研究关键词转为 arXiv API 搜索查询(ti_abs 模式,AND/OR 组合,不加 site: 或类别前缀)。"
  133. f"只输出纯文本查询,不要 JSON 包裹,不要解释。\n"
  134. f"关键词:{kw_str}\n"
  135. f"查询:"
  136. )
  137. llm = get_llm()
  138. raw = coerce_hello_agents_llm_output_to_str(
  139. llm.invoke([{"role": "user", "content": prompt}], temperature=0.1, max_tokens=128)
  140. )
  141. q = raw.strip().strip('"').strip("'")[:200]
  142. return q if len(q) >= 4 else ""
  143. except Exception as e:
  144. if log:
  145. log.debug("LLM arXiv query construction failed: %s", e)
  146. return ""
  147. def append_unique_by_title(into: list[Any], extra: list[Any]) -> None:
  148. seen = {str(getattr(x, "title", "") or "").strip().lower() for x in into}
  149. for p in extra:
  150. tt = str(getattr(p, "title", "") or "").strip().lower()
  151. if tt and tt not in seen:
  152. seen.add(tt)
  153. into.append(p)
  154. async def _safe_load_keywords(coro_or_func, *args, **kwargs) -> set[str]:
  155. try:
  156. result = await (coro_or_func(*args, **kwargs) if callable(coro_or_func) else coro_or_func)
  157. return result if isinstance(result, set) else set()
  158. except Exception:
  159. return set()
  160. async def extract_memory_keywords_via_llm(raw_texts: list[str], log: Any) -> set[str]:
  161. if not raw_texts:
  162. return set()
  163. try:
  164. agent = get_search_agent()
  165. llm = getattr(agent, "llm", None)
  166. if not llm:
  167. return set()
  168. seen: set[str] = set()
  169. deduped: list[str] = []
  170. total_chars = 0
  171. for t in raw_texts:
  172. s = str(t).strip()
  173. if not s or s in seen:
  174. continue
  175. seen.add(s)
  176. deduped.append(s)
  177. total_chars += len(s)
  178. if total_chars > 3000:
  179. break
  180. memory_block = "\n---\n".join(deduped[:60])
  181. prompt = (
  182. "Extract research keywords (methods, models, tasks, domain terms) from user memory fragments. "
  183. "Output JSON array only, no explanation. Skip stopwords, greetings, dates, URLs.\n\n"
  184. f"{memory_block}\n\n"
  185. 'Format: ["keyword1", ...]'
  186. )
  187. raw = await run_in_threadpool(
  188. llm.invoke,
  189. [{"role": "user", "content": prompt}],
  190. temperature=0.0,
  191. max_tokens=400,
  192. )
  193. txt = coerce_hello_agents_llm_output_to_str(raw).strip()
  194. try:
  195. parsed = json.loads(txt)
  196. except Exception:
  197. m = re.search(r"\[.*?\]", txt, re.DOTALL)
  198. if not m:
  199. return set()
  200. try:
  201. parsed = json.loads(m.group())
  202. except Exception:
  203. return set()
  204. if isinstance(parsed, list):
  205. return {str(x).strip().lower() for x in parsed if str(x).strip() and len(str(x).strip()) >= 2}
  206. return set()
  207. except Exception as e:
  208. log.warning("LLM 提取记忆关键词失败: %s", e)
  209. return set()
  210. async def load_memory_keywords(*, db_path: str, lib_ids: list[int], log: Any) -> set[str]:
  211. mem_kw: set[str] = set()
  212. @suppress_exceptions_async(default_return=(None, None))
  213. async def _load_store_kw() -> tuple:
  214. from ..memory.memory_store import MemoryStore
  215. store = MemoryStore(str(db_path))
  216. raw_texts = collect_memory_store_texts(store, lib_ids)
  217. llm_kw = await extract_memory_keywords_via_llm(raw_texts, log)
  218. if llm_kw:
  219. return (llm_kw, None)
  220. return (None, memory_keywords_from_texts(raw_texts))
  221. llm_kw, store_kw = await _load_store_kw()
  222. if llm_kw:
  223. mem_kw.update(llm_kw)
  224. return mem_kw
  225. if store_kw:
  226. mem_kw.update(store_kw)
  227. @suppress_exceptions_async(default_return=None)
  228. async def _load_shared_kw() -> set[str] | None:
  229. from ..memory.agent_memory import get_agent_memory
  230. am = get_agent_memory()
  231. shared_lines = am.recent(agent_name="shared", memory_types=["working", "episodic"], limit=40, shared=True)
  232. if not shared_lines:
  233. return None
  234. shared_texts = [str(ln).strip() for ln in shared_lines if str(ln).strip()]
  235. shared_kw = await extract_memory_keywords_via_llm(shared_texts, log)
  236. return shared_kw or am.keywords_from_shared(limit_lines=50, tokens_cap=120)
  237. shared_kw = await _load_shared_kw()
  238. if shared_kw:
  239. mem_kw.update(shared_kw)
  240. return mem_kw
  241. @suppress_exceptions_async(default_return=set())
  242. async def load_feedback_keywords(*, db_path: str, mem_kw: set[str]) -> set[str]:
  243. feedback_keywords = await run_in_threadpool(
  244. get_high_value_keywords_from_feedback, db_path, days=21, top_n=15
  245. )
  246. mem_kw.update(feedback_keywords)
  247. return mem_kw
  248. async def load_profile_keywords(*, db_path: str, mem_kw: set[str], log: Any) -> set[str]:
  249. try:
  250. user_profile = await run_in_threadpool(get_user_interest_profile_for_daily_recommend, db_path)
  251. mem_kw.update(kw.lower() for kw, weight in user_profile.top_keywords[:25] if weight >= 1.0)
  252. except Exception as e:
  253. log.debug("数据库行为画像提取失败: %s", e)
  254. return mem_kw
  255. async def load_user_context(
  256. *,
  257. db_path: str,
  258. lib_ids: list[int],
  259. log: Any,
  260. include_shown_exclusions: bool = True,
  261. ) -> tuple[set[str], int, list[str], set[str]]:
  262. mem_kw = await load_memory_keywords(db_path=db_path, lib_ids=lib_ids, log=log)
  263. await load_feedback_keywords(db_path=db_path, mem_kw=mem_kw)
  264. await load_profile_keywords(db_path=db_path, mem_kw=mem_kw, log=log)
  265. skipped_papers = await _safe_load_keywords(
  266. run_in_threadpool(get_skipped_papers, db_path, days=14, include_shown=include_shown_exclusions)
  267. )
  268. mem_kw_list, mem_kw_n = prepare_memory_keywords(mem_kw)
  269. return mem_kw, mem_kw_n, mem_kw_list, skipped_papers
  270. def invalidate_user_profile_cache() -> None:
  271. global _user_profile_cache, _user_profile_cache_ts
  272. _user_profile_cache = None
  273. _user_profile_cache_ts = 0.0
  274. async def get_or_load_user_context(
  275. *,
  276. db_path: str,
  277. lib_ids: list[int],
  278. log: Any,
  279. force_reload: bool = False,
  280. include_shown_exclusions: bool = True,
  281. ) -> tuple[set[str], int, list[str], set[str]]:
  282. global _user_profile_cache, _user_profile_cache_ts
  283. now = time.time()
  284. if (
  285. not force_reload
  286. and _user_profile_cache is not None
  287. and (now - _user_profile_cache_ts) < _USER_PROFILE_CACHE_TTL
  288. ):
  289. return _user_profile_cache
  290. result = await load_user_context(
  291. db_path=db_path,
  292. lib_ids=lib_ids,
  293. log=log,
  294. include_shown_exclusions=include_shown_exclusions,
  295. )
  296. _user_profile_cache = result
  297. _user_profile_cache_ts = now
  298. return result
  299. def daily_arxiv_category_list(daily_arxiv_cs_categories: list[str] | None) -> list[str]:
  300. cats = [str(c).strip() for c in (daily_arxiv_cs_categories or []) if str(c).strip()]
  301. return cats if cats else ["cs.CV", "cs.LG", "cs.AI", "cs.CL"]
  302. def llm_arxiv_categories(agent: Any, user_keywords: list[str], all_categories: list[str]) -> list[str]:
  303. if not user_keywords or len(user_keywords) < 3:
  304. return all_categories[:4]
  305. kw_str = ", ".join(user_keywords[:10])
  306. cats_str = ", ".join(all_categories)
  307. prompt = f"用户研究兴趣: {kw_str}\narXiv分类: {cats_str}\n选出最相关的4-6个分类,只返回逗号分隔列表:"
  308. try:
  309. raw = agent.llm.invoke([{"role": "user", "content": prompt}], temperature=0.0, max_tokens=60)
  310. result = coerce_hello_agents_llm_output_to_str(raw).strip()
  311. selected = [c.strip() for c in result.split(",") if c.strip() in all_categories]
  312. return selected[:6] if selected else all_categories[:4]
  313. except Exception:
  314. return all_categories[:4]
  315. def append_arxiv_batch_filtered(
  316. batch: list[Any],
  317. *,
  318. arxiv_results: list[Any],
  319. seen_titles: set[str],
  320. exclude_sigs: set[str],
  321. ) -> None:
  322. for p in batch:
  323. t = str(getattr(p, "title", "") or "").strip().lower()
  324. if not t or t in seen_titles:
  325. continue
  326. pid = _arxiv_canonical_from_paper(p)
  327. doi = (getattr(p, "doi", "") or "").strip().lower()
  328. if (pid and f"arxiv:{pid}" in exclude_sigs) or (doi and f"doi:{doi}" in exclude_sigs):
  329. continue
  330. if f"ty:{t}|{getattr(p, 'year', '')}" in exclude_sigs:
  331. continue
  332. seen_titles.add(t)
  333. arxiv_results.append(p)
  334. async def fetch_arxiv_candidates(
  335. *,
  336. searcher: Any,
  337. arxiv_query: str,
  338. days_back: int,
  339. daily_arxiv_cs_categories: list[str],
  340. log: Any,
  341. exclude_sigs: set[str] | None = None,
  342. ) -> tuple[list[Any], int]:
  343. cats = daily_arxiv_category_list(daily_arxiv_cs_categories)
  344. exclude_sigs = exclude_sigs or set()
  345. q = (arxiv_query or "").strip()
  346. http_kw = daily_arxiv_http_kw()
  347. arxiv_results: list[Any] = []
  348. seen_titles: set[str] = set()
  349. n_fail = 0
  350. # Widen the date window only when recent arXiv results are too sparse.
  351. days_tiers = [1, 3, 7] if days_back <= 7 else [days_back]
  352. if days_back > 7:
  353. days_tiers = [days_back, 14, 30]
  354. else:
  355. days_tiers = [d for d in [1, 3, 7] if d >= min(days_back, 7)] or [1, 3, 7]
  356. for dbk in days_tiers:
  357. if len(arxiv_results) >= 60:
  358. break
  359. for cat in cats:
  360. if len(arxiv_results) >= 60 or n_fail >= 3:
  361. break
  362. try:
  363. batch = await searcher.search_arxiv_async(
  364. q, max_results=30, days_back=dbk, arxiv_categories=[cat],
  365. arxiv_query_style="ti_abs", **http_kw,
  366. ) or []
  367. except Exception:
  368. n_fail += 1
  369. log.debug("每日论文:arXiv 请求失败 dbk=%s cat=%s", dbk, cat)
  370. continue
  371. n_fail = 0
  372. append_arxiv_batch_filtered(
  373. batch, arxiv_results=arxiv_results, seen_titles=seen_titles, exclude_sigs=exclude_sigs
  374. )
  375. if not arxiv_results:
  376. log.warning("每日论文:arXiv 未拉取到可用论文,将触发 OpenAlex 兜底")
  377. return arxiv_results, len(arxiv_results)
  378. async def fetch_openalex_daily_fallback(
  379. *,
  380. searcher: Any,
  381. mem_kw: set[str],
  382. lib_kw: set[str] | list[str] | None,
  383. log: Any,
  384. max_results: int = 80,
  385. ) -> list[Any]:
  386. import datetime as _dt
  387. try:
  388. q = build_daily_arxiv_query(mem_kw, lib_kw, log=log)
  389. if len(q) < 4:
  390. bits = [
  391. t for t in prepare_memory_keywords(mem_kw, limit=12, short_first=True)[0]
  392. if len(t) >= 3 and not t.isdigit()
  393. ][:6]
  394. q = " ".join(bits).strip()
  395. if len(q) < 4:
  396. q = _OPENALEX_FALLBACK_QUERY
  397. yr = int(_dt.datetime.now(_dt.timezone.utc).year) - 2
  398. hits = list(
  399. await searcher.search_openalex_async(
  400. q[:220],
  401. max_results=max(40, min(120, max_results)),
  402. year_from=yr,
  403. )
  404. or []
  405. )
  406. if hits:
  407. log.info("每日论文:OpenAlex 兜底命中 %s 篇", len(hits))
  408. return hits
  409. except Exception as e:
  410. log.warning("每日论文:OpenAlex 兜底失败:%s", e)
  411. return []
  412. async def fetch_external_candidates(
  413. *,
  414. searcher: Any,
  415. mem_kw: set[str],
  416. lib_kw: set[str] | list[str] | None,
  417. days_back: int,
  418. daily_arxiv_cs_categories: list[str],
  419. log: Any,
  420. exclude_sigs: set[str] | None = None,
  421. ) -> tuple[list[Any], dict[str, int], str]:
  422. arxiv_query = build_daily_arxiv_query(mem_kw, lib_kw, log=log)
  423. arxiv_results, arx_n = await fetch_arxiv_candidates(
  424. searcher=searcher,
  425. arxiv_query=arxiv_query,
  426. days_back=days_back,
  427. daily_arxiv_cs_categories=daily_arxiv_cs_categories,
  428. log=log,
  429. exclude_sigs=exclude_sigs,
  430. )
  431. if len(arxiv_results) < 16 and exclude_sigs:
  432. log.info(
  433. "每日论文:剔除已展示/跳过后过少(%s),本轮忽略排除集再抓一批以便形成推荐池",
  434. len(arxiv_results),
  435. )
  436. rescue, _ = await fetch_arxiv_candidates(
  437. searcher=searcher,
  438. arxiv_query="",
  439. days_back=max(7, days_back),
  440. daily_arxiv_cs_categories=daily_arxiv_cs_categories,
  441. log=log,
  442. exclude_sigs=set(),
  443. )
  444. append_unique_by_title(arxiv_results, rescue)
  445. arxiv_results.sort(
  446. key=lambda p: (int(getattr(p, "year", 0) or 0), int(getattr(p, "citations", 0) or 0)),
  447. reverse=True,
  448. )
  449. arxiv_results = arxiv_results[:96]
  450. return arxiv_results, {"arxiv": len(arxiv_results)}, arxiv_query