paper_ranker.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. """论文精排模块 —— LLM 驱动的候选论文排序与理由生成."""
  2. from __future__ import annotations
  3. import json
  4. import logging
  5. import os
  6. import re
  7. from dataclasses import dataclass, field
  8. from typing import Any
  9. from app.core.paper import Paper as LitPaper
  10. from ..llm.agent_runtime import _exception_chain_predicate, run_agent_task
  11. from ..llm.llm_service import get_llm
  12. from ..search_intent import extract_json_object
  13. from ...settings import get_settings
  14. from .paper_filters import should_exclude_main_conference_paper
  15. from .ranking_prompt import (
  16. RANKER_SYSTEM_PROMPT,
  17. RANKER_SYSTEM_PROMPT_RETRY,
  18. build_ranking_prompt,
  19. )
  20. logger = logging.getLogger(__name__)
  21. __all__ = [
  22. "LlmPaperRanker",
  23. "RankedPaper",
  24. "_papers_to_ranked_pool",
  25. ]
  26. def _recall_max_candidates() -> int:
  27. try:
  28. return max(8, min(60, int(get_settings().papergraph_recall_max_candidates)))
  29. except Exception:
  30. return 24
  31. def _pool_fallback_sort_key(rp: RankedPaper) -> tuple:
  32. return (
  33. float(getattr(rp, "fine_score", 0) or 0),
  34. int(getattr(rp.paper, "year", 0) or 0),
  35. int(getattr(rp.paper, "citations", 0) or 0),
  36. )
  37. def _papers_to_ranked_pool(
  38. papers: list[LitPaper],
  39. *,
  40. cap: int,
  41. prefer_recency: bool,
  42. ) -> list[RankedPaper]:
  43. pool = [RankedPaper(paper=p) for p in papers[: max(1, cap)]]
  44. if prefer_recency:
  45. pool.sort(key=lambda x: int(getattr(x.paper, "year", 0) or 0), reverse=True)
  46. return pool
  47. def _looks_like_llm_timeout(exc: BaseException) -> bool:
  48. def pred(x: BaseException) -> bool:
  49. if isinstance(x, TimeoutError):
  50. return True
  51. s = str(x).lower()
  52. return any(
  53. k in s
  54. for k in ("timeout", "timed out", "readtimeout", "apitimeout", "agent task timeout")
  55. )
  56. return _exception_chain_predicate(exc, pred)
  57. def _is_connectionish_error(exc: BaseException) -> bool:
  58. etxt = str(exc).lower()
  59. return any(k in etxt for k in ("connection", "remoteprotocolerror", "server disconnected", "eof"))
  60. @dataclass
  61. class RankedPaper:
  62. paper: LitPaper
  63. fine_score: float = 0.0
  64. final_score: float = 0.0
  65. ranking_reason: str = ""
  66. metadata: dict[str, Any] = field(default_factory=dict)
  67. def _is_venue_match(paper: LitPaper, venue: str) -> bool:
  68. """Check if paper's journal/source matches the target venue."""
  69. from .paper_filters import has_strong_main_conference_venue_signal
  70. return has_strong_main_conference_venue_signal(paper, venue)
  71. class LlmPaperRanker:
  72. """召回去重后由 LLM 直接排序。"""
  73. def __init__(self, recall_max: int = 24, fine_top_k: int = 10, llm=None):
  74. self.recall_max = max(8, int(recall_max or 24))
  75. self.fine_top_k = fine_top_k
  76. self._llm = llm or get_llm()
  77. @staticmethod
  78. def _ranked_paper_dedupe_key(rp: RankedPaper) -> str:
  79. p = rp.paper
  80. aid = str(getattr(p, "arxiv_id", "") or "").strip()
  81. if aid:
  82. return f"arxiv:{aid}"
  83. doi = str(getattr(p, "doi", "") or "").strip().lower()
  84. if doi:
  85. return f"doi:{doi}"
  86. t = str(getattr(p, "title", "") or "").strip().lower()[:240]
  87. return f"t:{t}" if t else f"id:{id(p)}"
  88. def _parse_ranking_result(self, result: str, papers: list[RankedPaper]) -> list[RankedPaper]:
  89. if not papers:
  90. return []
  91. raw = (result or "").strip()
  92. if not raw:
  93. return []
  94. data: dict[str, Any | None] = None
  95. try:
  96. data = extract_json_object(raw)
  97. if data is None:
  98. m = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", raw, re.IGNORECASE | re.DOTALL)
  99. if m:
  100. data = extract_json_object(m.group(1))
  101. if data is None:
  102. start, end = raw.find("{"), raw.rfind("}")
  103. if start >= 0 and end > start:
  104. chunk = re.sub(r",\s*([\]}])", r"\1", raw[start : end + 1])
  105. data = json.loads(chunk)
  106. if not isinstance(data, dict):
  107. data = None
  108. except Exception as e:
  109. logger.warning("[LlmPaperRanker] 精排 JSON 解析失败: %s", e)
  110. return []
  111. if not isinstance(data, dict):
  112. return []
  113. rankings = data.get("rankings")
  114. if not isinstance(rankings, list):
  115. return []
  116. result_list: list[RankedPaper] = []
  117. seen_idx: set[int] = set()
  118. for r in rankings:
  119. if not isinstance(r, dict):
  120. continue
  121. try:
  122. idx = int(r.get("paper_index", r.get("index", 0))) - 1
  123. except (TypeError, ValueError):
  124. continue
  125. if idx in seen_idx or not (0 <= idx < len(papers)):
  126. continue
  127. seen_idx.add(idx)
  128. rp = papers[idx]
  129. try:
  130. rp.fine_score = float(r.get("fine_score", 0))
  131. except (TypeError, ValueError):
  132. rp.fine_score = 0.0
  133. rp.ranking_reason = str(r.get("reason", "") or "").strip()
  134. result_list.append(rp)
  135. return result_list
  136. def _supplement_ranked(
  137. self,
  138. ranked: list[RankedPaper],
  139. pool: list[RankedPaper],
  140. top_k: int,
  141. *,
  142. allow_supplement: bool = True,
  143. ) -> list[RankedPaper]:
  144. cap = min(int(top_k or 10), len(pool))
  145. if not allow_supplement or len(ranked) >= cap:
  146. return ranked[:cap]
  147. keys = {self._ranked_paper_dedupe_key(rp) for rp in ranked}
  148. out = list(ranked)
  149. for rp in sorted(pool, key=_pool_fallback_sort_key, reverse=True):
  150. if len(out) >= cap:
  151. break
  152. k = self._ranked_paper_dedupe_key(rp)
  153. if k in keys:
  154. continue
  155. keys.add(k)
  156. rp.fine_score = float(getattr(rp, "fine_score", 0.0) or 0.0)
  157. if not getattr(rp, "ranking_reason", ""):
  158. rp.ranking_reason = "(精排序列未覆盖该项,按召回顺序递补)"
  159. out.append(rp)
  160. return out[:cap]
  161. def _finalize_scores(self, ranked: list[RankedPaper]) -> None:
  162. for rp in ranked:
  163. rp.final_score = round(float(rp.fine_score or 0), 2)
  164. def _invoke_llm_rank(
  165. self,
  166. candidates: list[RankedPaper],
  167. prompt: str,
  168. *,
  169. task_name: str,
  170. agent_name: str,
  171. system_prompt: str,
  172. timeout_sec: float,
  173. ) -> str:
  174. return run_agent_task(
  175. task_name=task_name,
  176. agent_name=agent_name,
  177. llm=self._llm,
  178. system_prompt=system_prompt,
  179. user_prompt=prompt,
  180. timeout_sec=timeout_sec,
  181. retries=0,
  182. task_logger=logger,
  183. )
  184. def _rank_from_llm_output(
  185. self,
  186. result_text: str,
  187. candidates: list[RankedPaper],
  188. top_k: int,
  189. ) -> tuple[list[RankedPaper], str]:
  190. ranked = self._parse_ranking_result(result_text, candidates)
  191. if not ranked:
  192. ranked = sorted(candidates, key=_pool_fallback_sort_key, reverse=True)[:top_k]
  193. for rp in ranked:
  194. rp.fine_score = 0.0
  195. rp.ranking_reason = rp.ranking_reason or "(精排未返回有效条目,按召回顺序保留)"
  196. return self._supplement_ranked(ranked, candidates, top_k), "recall_fallback"
  197. return self._supplement_ranked(ranked, candidates, top_k), "llm_rank"
  198. def _fine_rank(
  199. self,
  200. papers: list[RankedPaper],
  201. query: str,
  202. top_k: int = 10,
  203. *,
  204. ranking_profile: str = "accuracy",
  205. target_venue: str | None = None,
  206. main_conference_proceedings_only: bool = False,
  207. intent_source_message: str | None = None,
  208. target_titles: list[str] | None = None,
  209. authors: list[str] | None = None,
  210. venues: list[str] | None = None,
  211. year_from: int | None = None,
  212. year_to: int | None = None,
  213. method_acronym: str | None = None,
  214. ) -> tuple[list[RankedPaper], str]:
  215. if not papers:
  216. return [], "llm_rank"
  217. profile = str(ranking_profile or "accuracy").strip().lower()
  218. if profile not in ("accuracy", "novelty", "classic"):
  219. profile = "accuracy"
  220. try:
  221. cand_limit = int(str(get_settings().papergraph_fine_rank_candidates))
  222. except Exception:
  223. cand_limit = 12
  224. cand_limit = max(int(top_k or 10), min(40, max(10, cand_limit)))
  225. try:
  226. fine_timeout_sec = float(os.getenv("PAPERGRAPH_FINE_RANK_TIMEOUT_SEC", "30").strip() or 30)
  227. except Exception:
  228. fine_timeout_sec = 30.0
  229. fine_timeout_sec = max(10.0, min(120.0, fine_timeout_sec))
  230. candidates = list(papers or [])[:cand_limit]
  231. try:
  232. abs_max = int(os.getenv("PAPERGRAPH_FINE_RANK_ABSTRACT_CHARS", "").strip() or 200)
  233. except Exception:
  234. abs_max = 200
  235. rank_kwargs = dict(
  236. ranking_profile=profile,
  237. abstract_max_chars=abs_max,
  238. target_venue=target_venue,
  239. main_conference_proceedings_only=main_conference_proceedings_only,
  240. intent_source_message=intent_source_message,
  241. target_titles=target_titles,
  242. authors=authors,
  243. venues=venues,
  244. year_from=year_from,
  245. year_to=year_to,
  246. method_acronym=method_acronym,
  247. )
  248. try:
  249. prompt = build_ranking_prompt(candidates, query, top_k, **rank_kwargs)
  250. result_text = self._invoke_llm_rank(
  251. candidates,
  252. prompt,
  253. task_name="paper_ranker_fine_rank",
  254. agent_name="paper_ranker",
  255. system_prompt=RANKER_SYSTEM_PROMPT,
  256. timeout_sec=fine_timeout_sec,
  257. )
  258. ranked, fine_method = self._rank_from_llm_output(result_text, candidates, top_k)
  259. self._finalize_scores(ranked)
  260. return ranked[:top_k], fine_method
  261. except Exception as e:
  262. if _looks_like_llm_timeout(e) or _is_connectionish_error(e):
  263. try:
  264. retry_limit = min(max(int(top_k or 10) * 2, 12), max(12, cand_limit))
  265. retry_candidates = list(papers or [])[:retry_limit]
  266. prompt2 = build_ranking_prompt(
  267. retry_candidates,
  268. query,
  269. top_k,
  270. ranking_profile=profile,
  271. abstract_max_chars=min(280, max(160, abs_max // 2)),
  272. target_venue=target_venue,
  273. main_conference_proceedings_only=main_conference_proceedings_only,
  274. intent_source_message=intent_source_message,
  275. )
  276. ranked2, method2 = self._rank_from_llm_output(
  277. self._invoke_llm_rank(
  278. retry_candidates,
  279. prompt2,
  280. task_name="paper_ranker_fine_rank_retry",
  281. agent_name="paper_ranker_retry",
  282. system_prompt=RANKER_SYSTEM_PROMPT_RETRY,
  283. timeout_sec=min(120.0, fine_timeout_sec + 25.0),
  284. ),
  285. retry_candidates,
  286. top_k,
  287. )
  288. if method2 == "llm_rank":
  289. self._finalize_scores(ranked2)
  290. return ranked2[:top_k], method2
  291. except Exception:
  292. pass
  293. if _looks_like_llm_timeout(e):
  294. logger.warning(
  295. "[LlmPaperRanker] 精排 LLM 超时(当前上限 %.0fs),已按召回顺序降级;可提高 "
  296. "PAPERGRAPH_FINE_RANK_TIMEOUT_SEC,或减小 PAPERGRAPH_FINE_RANK_CANDIDATES / "
  297. "PAPERGRAPH_FINE_RANK_ABSTRACT_CHARS。详情: %s",
  298. fine_timeout_sec,
  299. str(e)[:200],
  300. )
  301. else:
  302. logger.exception(
  303. "[LlmPaperRanker] 精排失败: %s (llm_set=%s, LLM_API_KEY=%s, LLM_BASE_URL=%s, LLM_MODEL_ID=%s)",
  304. e,
  305. bool(self._llm),
  306. ("已配置" if os.getenv("LLM_API_KEY") else "未配置"),
  307. os.getenv("LLM_BASE_URL", "未配置"),
  308. os.getenv("LLM_MODEL_ID", "未配置"),
  309. )
  310. etxt = str(e).lower()
  311. if any(k in etxt for k in ("proxy", "ssl", "eof", "connection")):
  312. logger.warning(
  313. "[LlmPaperRanker] 提示:若为代理/SSL 握手失败,可在 backend/.env 设置 LLM_DISABLE_PROXY=1 后重启;"
  314. "或临时取消 HTTPS_PROXY/ALL_PROXY;或确认 NO_PROXY 包含 LLM 域名(见 llm_service._maybe_disable_proxy_for_llm)。"
  315. )
  316. fallback = sorted(list(papers or []), key=_pool_fallback_sort_key, reverse=True)
  317. if main_conference_proceedings_only and target_venue:
  318. yf, yt = kwargs.get("year_from"), kwargs.get("year_to")
  319. pin_y = int(yf) if yf is not None and yf == yt else None
  320. fallback = [
  321. rp
  322. for rp in fallback
  323. if not should_exclude_main_conference_paper(
  324. rp.paper, target_venue, pinned_year=pin_y
  325. )
  326. ]
  327. self._finalize_scores(fallback)
  328. return fallback[:top_k], "recall_fallback"
  329. def rank(
  330. self,
  331. papers: list[LitPaper],
  332. query: str,
  333. top_k: int | None = None,
  334. **kwargs: Any,
  335. ) -> tuple[list[RankedPaper], dict[str, Any]]:
  336. final_k = top_k or self.fine_top_k
  337. profile = str(kwargs.get("ranking_profile") or "accuracy").strip().lower()
  338. if profile not in ("accuracy", "novelty", "classic"):
  339. profile = "accuracy"
  340. target_venue = (kwargs.get("target_venue") or "").strip() or None
  341. main_conf = bool(kwargs.get("main_conference_proceedings_only"))
  342. if main_conf and target_venue:
  343. yf, yt = kwargs.get("year_from"), kwargs.get("year_to")
  344. pin_y = int(yf) if yf is not None and yf == yt else None
  345. papers = [
  346. p
  347. for p in papers
  348. if not should_exclude_main_conference_paper(p, target_venue, pinned_year=pin_y)
  349. ]
  350. sort_mode = str(kwargs.get("sort") or "").strip().lower()
  351. prefer_recency = bool(kwargs.get("prefer_recency") or sort_mode == "date" or target_venue)
  352. recall_cap = min(_recall_max_candidates(), max(self.recall_max, final_k + 4))
  353. # Pre-rank: when venue is specified, boost venue-matched papers ahead of others
  354. if target_venue:
  355. papers = sorted(
  356. papers,
  357. key=lambda p: (
  358. 0 if _is_venue_match(p, target_venue) else 1,
  359. -(int(getattr(p, "year", 0) or 0)),
  360. -(int(getattr(p, "citations", 0) or 0)),
  361. ),
  362. )
  363. candidate_pool = _papers_to_ranked_pool(papers, cap=recall_cap, prefer_recency=prefer_recency)
  364. if not candidate_pool:
  365. return [], {"error": "无候选论文"}
  366. try:
  367. fine_result, fine_method = self._fine_rank(
  368. papers=candidate_pool,
  369. query=query,
  370. top_k=final_k,
  371. ranking_profile=profile,
  372. target_venue=target_venue,
  373. main_conference_proceedings_only=main_conf,
  374. intent_source_message=kwargs.get("intent_source_message"),
  375. target_titles=list(kwargs.get("target_titles") or []),
  376. authors=list(kwargs.get("authors") or []),
  377. venues=list(kwargs.get("venues") or []),
  378. year_from=kwargs.get("year_from"),
  379. year_to=kwargs.get("year_to"),
  380. method_acronym=(kwargs.get("method_acronym") or "").strip() or None,
  381. )
  382. method = fine_method
  383. except Exception:
  384. fine_result = sorted(candidate_pool, key=_pool_fallback_sort_key, reverse=True)[:final_k]
  385. method = "recall_fallback"
  386. return fine_result, {
  387. "ranking_method": method,
  388. "ranking_profile": profile,
  389. "total_candidates": len(papers),
  390. "recall_pool": len(candidate_pool),
  391. "fine_output": len(fine_result),
  392. }