| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441 |
- """论文精排模块 —— LLM 驱动的候选论文排序与理由生成."""
- from __future__ import annotations
- import json
- import logging
- import os
- import re
- from dataclasses import dataclass, field
- from typing import Any
- from app.core.paper import Paper as LitPaper
- from ..llm.agent_runtime import _exception_chain_predicate, run_agent_task
- from ..llm.llm_service import get_llm
- from ..search_intent import extract_json_object
- from ...settings import get_settings
- from .paper_filters import should_exclude_main_conference_paper
- from .ranking_prompt import (
- RANKER_SYSTEM_PROMPT,
- RANKER_SYSTEM_PROMPT_RETRY,
- build_ranking_prompt,
- )
- logger = logging.getLogger(__name__)
- __all__ = [
- "LlmPaperRanker",
- "RankedPaper",
- "_papers_to_ranked_pool",
- ]
- def _recall_max_candidates() -> int:
- try:
- return max(8, min(60, int(get_settings().papergraph_recall_max_candidates)))
- except Exception:
- return 24
- def _pool_fallback_sort_key(rp: RankedPaper) -> tuple:
- return (
- float(getattr(rp, "fine_score", 0) or 0),
- int(getattr(rp.paper, "year", 0) or 0),
- int(getattr(rp.paper, "citations", 0) or 0),
- )
- def _papers_to_ranked_pool(
- papers: list[LitPaper],
- *,
- cap: int,
- prefer_recency: bool,
- ) -> list[RankedPaper]:
- pool = [RankedPaper(paper=p) for p in papers[: max(1, cap)]]
- if prefer_recency:
- pool.sort(key=lambda x: int(getattr(x.paper, "year", 0) or 0), reverse=True)
- return pool
- def _looks_like_llm_timeout(exc: BaseException) -> bool:
- def pred(x: BaseException) -> bool:
- if isinstance(x, TimeoutError):
- return True
- s = str(x).lower()
- return any(
- k in s
- for k in ("timeout", "timed out", "readtimeout", "apitimeout", "agent task timeout")
- )
- return _exception_chain_predicate(exc, pred)
- def _is_connectionish_error(exc: BaseException) -> bool:
- etxt = str(exc).lower()
- return any(k in etxt for k in ("connection", "remoteprotocolerror", "server disconnected", "eof"))
- @dataclass
- class RankedPaper:
- paper: LitPaper
- fine_score: float = 0.0
- final_score: float = 0.0
- ranking_reason: str = ""
- metadata: dict[str, Any] = field(default_factory=dict)
- def _is_venue_match(paper: LitPaper, venue: str) -> bool:
- """Check if paper's journal/source matches the target venue."""
- from .paper_filters import has_strong_main_conference_venue_signal
- return has_strong_main_conference_venue_signal(paper, venue)
- class LlmPaperRanker:
- """召回去重后由 LLM 直接排序。"""
- def __init__(self, recall_max: int = 24, fine_top_k: int = 10, llm=None):
- self.recall_max = max(8, int(recall_max or 24))
- self.fine_top_k = fine_top_k
- self._llm = llm or get_llm()
- @staticmethod
- def _ranked_paper_dedupe_key(rp: RankedPaper) -> str:
- p = rp.paper
- aid = str(getattr(p, "arxiv_id", "") or "").strip()
- if aid:
- return f"arxiv:{aid}"
- doi = str(getattr(p, "doi", "") or "").strip().lower()
- if doi:
- return f"doi:{doi}"
- t = str(getattr(p, "title", "") or "").strip().lower()[:240]
- return f"t:{t}" if t else f"id:{id(p)}"
- def _parse_ranking_result(self, result: str, papers: list[RankedPaper]) -> list[RankedPaper]:
- if not papers:
- return []
- raw = (result or "").strip()
- if not raw:
- return []
- data: dict[str, Any | None] = None
- try:
- data = extract_json_object(raw)
- if data is None:
- m = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", raw, re.IGNORECASE | re.DOTALL)
- if m:
- data = extract_json_object(m.group(1))
- if data is None:
- start, end = raw.find("{"), raw.rfind("}")
- if start >= 0 and end > start:
- chunk = re.sub(r",\s*([\]}])", r"\1", raw[start : end + 1])
- data = json.loads(chunk)
- if not isinstance(data, dict):
- data = None
- except Exception as e:
- logger.warning("[LlmPaperRanker] 精排 JSON 解析失败: %s", e)
- return []
- if not isinstance(data, dict):
- return []
- rankings = data.get("rankings")
- if not isinstance(rankings, list):
- return []
- result_list: list[RankedPaper] = []
- seen_idx: set[int] = set()
- for r in rankings:
- if not isinstance(r, dict):
- continue
- try:
- idx = int(r.get("paper_index", r.get("index", 0))) - 1
- except (TypeError, ValueError):
- continue
- if idx in seen_idx or not (0 <= idx < len(papers)):
- continue
- seen_idx.add(idx)
- rp = papers[idx]
- try:
- rp.fine_score = float(r.get("fine_score", 0))
- except (TypeError, ValueError):
- rp.fine_score = 0.0
- rp.ranking_reason = str(r.get("reason", "") or "").strip()
- result_list.append(rp)
- return result_list
- def _supplement_ranked(
- self,
- ranked: list[RankedPaper],
- pool: list[RankedPaper],
- top_k: int,
- *,
- allow_supplement: bool = True,
- ) -> list[RankedPaper]:
- cap = min(int(top_k or 10), len(pool))
- if not allow_supplement or len(ranked) >= cap:
- return ranked[:cap]
- keys = {self._ranked_paper_dedupe_key(rp) for rp in ranked}
- out = list(ranked)
- for rp in sorted(pool, key=_pool_fallback_sort_key, reverse=True):
- if len(out) >= cap:
- break
- k = self._ranked_paper_dedupe_key(rp)
- if k in keys:
- continue
- keys.add(k)
- rp.fine_score = float(getattr(rp, "fine_score", 0.0) or 0.0)
- if not getattr(rp, "ranking_reason", ""):
- rp.ranking_reason = "(精排序列未覆盖该项,按召回顺序递补)"
- out.append(rp)
- return out[:cap]
- def _finalize_scores(self, ranked: list[RankedPaper]) -> None:
- for rp in ranked:
- rp.final_score = round(float(rp.fine_score or 0), 2)
- def _invoke_llm_rank(
- self,
- candidates: list[RankedPaper],
- prompt: str,
- *,
- task_name: str,
- agent_name: str,
- system_prompt: str,
- timeout_sec: float,
- ) -> str:
- return run_agent_task(
- task_name=task_name,
- agent_name=agent_name,
- llm=self._llm,
- system_prompt=system_prompt,
- user_prompt=prompt,
- timeout_sec=timeout_sec,
- retries=0,
- task_logger=logger,
- )
- def _rank_from_llm_output(
- self,
- result_text: str,
- candidates: list[RankedPaper],
- top_k: int,
- ) -> tuple[list[RankedPaper], str]:
- ranked = self._parse_ranking_result(result_text, candidates)
- if not ranked:
- ranked = sorted(candidates, key=_pool_fallback_sort_key, reverse=True)[:top_k]
- for rp in ranked:
- rp.fine_score = 0.0
- rp.ranking_reason = rp.ranking_reason or "(精排未返回有效条目,按召回顺序保留)"
- return self._supplement_ranked(ranked, candidates, top_k), "recall_fallback"
- return self._supplement_ranked(ranked, candidates, top_k), "llm_rank"
- def _fine_rank(
- self,
- papers: list[RankedPaper],
- query: str,
- top_k: int = 10,
- *,
- ranking_profile: str = "accuracy",
- target_venue: str | None = None,
- main_conference_proceedings_only: bool = False,
- intent_source_message: str | None = None,
- target_titles: list[str] | None = None,
- authors: list[str] | None = None,
- venues: list[str] | None = None,
- year_from: int | None = None,
- year_to: int | None = None,
- method_acronym: str | None = None,
- ) -> tuple[list[RankedPaper], str]:
- if not papers:
- return [], "llm_rank"
- profile = str(ranking_profile or "accuracy").strip().lower()
- if profile not in ("accuracy", "novelty", "classic"):
- profile = "accuracy"
- try:
- cand_limit = int(str(get_settings().papergraph_fine_rank_candidates))
- except Exception:
- cand_limit = 12
- cand_limit = max(int(top_k or 10), min(40, max(10, cand_limit)))
- try:
- fine_timeout_sec = float(os.getenv("PAPERGRAPH_FINE_RANK_TIMEOUT_SEC", "30").strip() or 30)
- except Exception:
- fine_timeout_sec = 30.0
- fine_timeout_sec = max(10.0, min(120.0, fine_timeout_sec))
- candidates = list(papers or [])[:cand_limit]
- try:
- abs_max = int(os.getenv("PAPERGRAPH_FINE_RANK_ABSTRACT_CHARS", "").strip() or 200)
- except Exception:
- abs_max = 200
- rank_kwargs = dict(
- ranking_profile=profile,
- abstract_max_chars=abs_max,
- target_venue=target_venue,
- main_conference_proceedings_only=main_conference_proceedings_only,
- intent_source_message=intent_source_message,
- target_titles=target_titles,
- authors=authors,
- venues=venues,
- year_from=year_from,
- year_to=year_to,
- method_acronym=method_acronym,
- )
- try:
- prompt = build_ranking_prompt(candidates, query, top_k, **rank_kwargs)
- result_text = self._invoke_llm_rank(
- candidates,
- prompt,
- task_name="paper_ranker_fine_rank",
- agent_name="paper_ranker",
- system_prompt=RANKER_SYSTEM_PROMPT,
- timeout_sec=fine_timeout_sec,
- )
- ranked, fine_method = self._rank_from_llm_output(result_text, candidates, top_k)
- self._finalize_scores(ranked)
- return ranked[:top_k], fine_method
- except Exception as e:
- if _looks_like_llm_timeout(e) or _is_connectionish_error(e):
- try:
- retry_limit = min(max(int(top_k or 10) * 2, 12), max(12, cand_limit))
- retry_candidates = list(papers or [])[:retry_limit]
- prompt2 = build_ranking_prompt(
- retry_candidates,
- query,
- top_k,
- ranking_profile=profile,
- abstract_max_chars=min(280, max(160, abs_max // 2)),
- target_venue=target_venue,
- main_conference_proceedings_only=main_conference_proceedings_only,
- intent_source_message=intent_source_message,
- )
- ranked2, method2 = self._rank_from_llm_output(
- self._invoke_llm_rank(
- retry_candidates,
- prompt2,
- task_name="paper_ranker_fine_rank_retry",
- agent_name="paper_ranker_retry",
- system_prompt=RANKER_SYSTEM_PROMPT_RETRY,
- timeout_sec=min(120.0, fine_timeout_sec + 25.0),
- ),
- retry_candidates,
- top_k,
- )
- if method2 == "llm_rank":
- self._finalize_scores(ranked2)
- return ranked2[:top_k], method2
- except Exception:
- pass
- if _looks_like_llm_timeout(e):
- logger.warning(
- "[LlmPaperRanker] 精排 LLM 超时(当前上限 %.0fs),已按召回顺序降级;可提高 "
- "PAPERGRAPH_FINE_RANK_TIMEOUT_SEC,或减小 PAPERGRAPH_FINE_RANK_CANDIDATES / "
- "PAPERGRAPH_FINE_RANK_ABSTRACT_CHARS。详情: %s",
- fine_timeout_sec,
- str(e)[:200],
- )
- else:
- logger.exception(
- "[LlmPaperRanker] 精排失败: %s (llm_set=%s, LLM_API_KEY=%s, LLM_BASE_URL=%s, LLM_MODEL_ID=%s)",
- e,
- bool(self._llm),
- ("已配置" if os.getenv("LLM_API_KEY") else "未配置"),
- os.getenv("LLM_BASE_URL", "未配置"),
- os.getenv("LLM_MODEL_ID", "未配置"),
- )
- etxt = str(e).lower()
- if any(k in etxt for k in ("proxy", "ssl", "eof", "connection")):
- logger.warning(
- "[LlmPaperRanker] 提示:若为代理/SSL 握手失败,可在 backend/.env 设置 LLM_DISABLE_PROXY=1 后重启;"
- "或临时取消 HTTPS_PROXY/ALL_PROXY;或确认 NO_PROXY 包含 LLM 域名(见 llm_service._maybe_disable_proxy_for_llm)。"
- )
- fallback = sorted(list(papers or []), key=_pool_fallback_sort_key, reverse=True)
- if main_conference_proceedings_only and target_venue:
- yf, yt = kwargs.get("year_from"), kwargs.get("year_to")
- pin_y = int(yf) if yf is not None and yf == yt else None
- fallback = [
- rp
- for rp in fallback
- if not should_exclude_main_conference_paper(
- rp.paper, target_venue, pinned_year=pin_y
- )
- ]
- self._finalize_scores(fallback)
- return fallback[:top_k], "recall_fallback"
- def rank(
- self,
- papers: list[LitPaper],
- query: str,
- top_k: int | None = None,
- **kwargs: Any,
- ) -> tuple[list[RankedPaper], dict[str, Any]]:
- final_k = top_k or self.fine_top_k
- profile = str(kwargs.get("ranking_profile") or "accuracy").strip().lower()
- if profile not in ("accuracy", "novelty", "classic"):
- profile = "accuracy"
- target_venue = (kwargs.get("target_venue") or "").strip() or None
- main_conf = bool(kwargs.get("main_conference_proceedings_only"))
- if main_conf and target_venue:
- yf, yt = kwargs.get("year_from"), kwargs.get("year_to")
- pin_y = int(yf) if yf is not None and yf == yt else None
- papers = [
- p
- for p in papers
- if not should_exclude_main_conference_paper(p, target_venue, pinned_year=pin_y)
- ]
- sort_mode = str(kwargs.get("sort") or "").strip().lower()
- prefer_recency = bool(kwargs.get("prefer_recency") or sort_mode == "date" or target_venue)
- recall_cap = min(_recall_max_candidates(), max(self.recall_max, final_k + 4))
- # Pre-rank: when venue is specified, boost venue-matched papers ahead of others
- if target_venue:
- papers = sorted(
- papers,
- key=lambda p: (
- 0 if _is_venue_match(p, target_venue) else 1,
- -(int(getattr(p, "year", 0) or 0)),
- -(int(getattr(p, "citations", 0) or 0)),
- ),
- )
- candidate_pool = _papers_to_ranked_pool(papers, cap=recall_cap, prefer_recency=prefer_recency)
- if not candidate_pool:
- return [], {"error": "无候选论文"}
- try:
- fine_result, fine_method = self._fine_rank(
- papers=candidate_pool,
- query=query,
- top_k=final_k,
- ranking_profile=profile,
- target_venue=target_venue,
- main_conference_proceedings_only=main_conf,
- intent_source_message=kwargs.get("intent_source_message"),
- target_titles=list(kwargs.get("target_titles") or []),
- authors=list(kwargs.get("authors") or []),
- venues=list(kwargs.get("venues") or []),
- year_from=kwargs.get("year_from"),
- year_to=kwargs.get("year_to"),
- method_acronym=(kwargs.get("method_acronym") or "").strip() or None,
- )
- method = fine_method
- except Exception:
- fine_result = sorted(candidate_pool, key=_pool_fallback_sort_key, reverse=True)[:final_k]
- method = "recall_fallback"
- return fine_result, {
- "ranking_method": method,
- "ranking_profile": profile,
- "total_candidates": len(papers),
- "recall_pool": len(candidate_pool),
- "fine_output": len(fine_result),
- }
|