reader_recommend_llm.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. """阅读推荐 —— 基于当前论文内容推荐相关文献."""
  2. from __future__ import annotations
  3. import logging
  4. import re
  5. from typing import Any
  6. from ...agents.support.reader_reference_lookup_tool import (
  7. READER_RECOMMEND_MAX_RESULTS,
  8. prioritize_reader_related_pairs_refs_first,
  9. )
  10. from ...utils import parse_llm_json, truncate_text
  11. from ..llm.llm_service import coerce_hello_agents_llm_output_to_str, get_llm, is_llm_configured
  12. from .paper_reader_context import preprocess_pdf_text_for_reference_blob
  13. logger = logging.getLogger(__name__)
  14. READER_BIB_SOURCES = frozenset({"bibliography", "ref_block"})
  15. def extract_title_queries_from_ref_blob_llm(
  16. section_raw: str,
  17. snap: dict[str, Any],
  18. *,
  19. max_queries: int = 12,
  20. ) -> list[str]:
  21. if not is_llm_configured() or not (section_raw or "").strip():
  22. return []
  23. blob = truncate_text((section_raw or "").strip(), 11000, suffix="...")
  24. title = str(snap.get("title") or "").strip()
  25. ab = truncate_text(str(snap.get("abstract") or "").strip(), 1200, suffix="...")
  26. kw = snap.get("keywords") or []
  27. kw_s = ", ".join(str(x) for x in kw[:20] if str(x).strip()) if isinstance(kw, (list, tuple)) else ""
  28. system = (
  29. "Extract English paper titles/phrases from the reference blob below for OpenAlex search. "
  30. "Output JSON: {\"queries\":[...]}, max "
  31. f"{max_queries} items, each 16-160 chars. "
  32. "Each must be a contiguous substring of the reference blob (join lines with spaces). "
  33. "Prefer long titles (>=22 chars); arXiv/DOI are OK as single items. "
  34. "Skip journal names, venue-only lines, vol/pages, generic topics."
  35. )
  36. user = (
  37. f"[Title] {title}\n[Abstract snippet] {ab}\n[Keywords] {kw_s}\n\n"
  38. f"[Reference blob]\n{blob}\n"
  39. )
  40. try:
  41. llm = get_llm()
  42. raw = llm.invoke(
  43. [
  44. {"role": "system", "content": system},
  45. {"role": "user", "content": user},
  46. ]
  47. )
  48. text = coerce_hello_agents_llm_output_to_str(raw).strip()
  49. except Exception as exc:
  50. logger.debug("extract_title_queries_llm_invoke_failed", exc_info=exc)
  51. return []
  52. data = parse_llm_json(text)
  53. if not isinstance(data, dict):
  54. return []
  55. arr = data.get("queries") or data.get("title_queries") or []
  56. if not isinstance(arr, list):
  57. return []
  58. out: list[str] = []
  59. seen: set[str] = set()
  60. for x in arr:
  61. q = re.sub(r"\s+", " ", str(x).strip())[:200]
  62. if len(q) < 16:
  63. continue
  64. _nq = re.sub(r"\s+", " ", preprocess_pdf_text_for_reference_blob(q or "").lower()).strip()
  65. _nr = re.sub(r"\s+", " ", preprocess_pdf_text_for_reference_blob(section_raw or "").lower()).strip()
  66. if len(_nq) < 12 or len(_nr) < 40:
  67. continue
  68. if not (_nq in _nr or (len(_nq[:48]) >= 14 and _nq[:48] in _nr)):
  69. _words = [w for w in re.findall(r"[a-z]{5,}", _nq) if len(w) >= 5][:8]
  70. if not _words or sum(1 for w in _words if w in _nr) < max(2, int(len(_words) * 0.5)):
  71. continue
  72. k = q.lower()[:240]
  73. if k in seen:
  74. continue
  75. seen.add(k)
  76. out.append(q[:520])
  77. if len(out) >= max_queries:
  78. break
  79. return out
  80. def merge_ref_lines_with_llm_queries(
  81. section_raw: str,
  82. snap: dict[str, Any],
  83. base_lines: list[str],
  84. *,
  85. max_queries: int = 12,
  86. ) -> list[str]:
  87. llm_q = extract_title_queries_from_ref_blob_llm(section_raw, snap, max_queries=max_queries)
  88. merged: list[str] = []
  89. seen: set[str] = set()
  90. for src in (base_lines or []) + llm_q:
  91. t = re.sub(r"\s+", " ", str(src).strip())
  92. if len(t) < 22:
  93. continue
  94. k = t.lower()[:260]
  95. if k in seen:
  96. continue
  97. seen.add(k)
  98. merged.append(t[:520])
  99. if len(merged) >= 72:
  100. break
  101. return merged
  102. def rerank_reader_recommend_pairs_by_llm(
  103. snap: dict[str, Any],
  104. pairs: list[tuple[Any, str]],
  105. *,
  106. user_message: str,
  107. history_lines: str = "",
  108. reco_max_hint: int = 2,
  109. ) -> list[tuple[Any, str]]:
  110. if not pairs:
  111. return pairs
  112. if not is_llm_configured():
  113. return prioritize_reader_related_pairs_refs_first(pairs)
  114. hint = max(1, min(int(reco_max_hint or 2), READER_RECOMMEND_MAX_RESULTS))
  115. head: list[tuple[Any, str]] = []
  116. bib: list[tuple[Any, str]] = []
  117. for p, s in pairs:
  118. if s in READER_BIB_SOURCES:
  119. bib.append((p, s))
  120. else:
  121. head.append((p, s))
  122. if len(bib) <= 1:
  123. return bib + head
  124. n = len(bib)
  125. title = str(snap.get("title") or "").strip()
  126. ab = truncate_text(str(snap.get("abstract") or "").strip(), 2000, suffix="...")
  127. kw = snap.get("keywords") or []
  128. kw_s = ", ".join(str(x) for x in kw[:24] if str(x).strip()) if isinstance(kw, (list, tuple)) else ""
  129. um = truncate_text((user_message or "").strip(), 600, suffix="...")
  130. hist = truncate_text((history_lines or "").strip(), 1400, suffix="...")
  131. lines: list[str] = []
  132. for i, (ap, _) in enumerate(bib):
  133. t = str(getattr(ap, "title", "") or "").strip() or "(no title)"
  134. y = getattr(ap, "year", None) or "-"
  135. j = str(getattr(ap, "journal", None) or getattr(ap, "venue", None) or "").strip() or "-"
  136. ax = str(getattr(ap, "arxiv_id", None) or "").strip() or "-"
  137. doi = str(getattr(ap, "doi", None) or "").strip() or "-"
  138. lines.append(f"{i}. {t} | year={y} | venue={j[:80]} | arxiv={ax} | doi={doi}")
  139. system = (
  140. "You are a relevance judge. Given the main paper, chat context, and user question, "
  141. "rank candidate papers (from its reference parsing) by relevance to the paper's method, task, data. "
  142. "Decide how many to keep (keep_n) -- don't pad to match the user's hint, "
  143. f"max = min(candidate_count, {READER_RECOMMEND_MAX_RESULTS}). "
  144. "Exclude unrelated domains, generic-topic surveys, shared buzzwords. "
  145. "Non-reference items are secondary. "
  146. "Output JSON:\n"
  147. "{\"keep_n\":int,\"order\":[int,...],"
  148. "\"items\":[{\"i\":0,\"score\":0.82,\"relation\":\"...\",\"why\":\"<=40 chars\"}]}\n"
  149. f"keep_n in 1..min(count,{READER_RECOMMEND_MAX_RESULTS}), matching conversation intent. "
  150. "order: full permutation of indices (0-based) by descending relevance, no dupes. "
  151. f"User hint (~{hint}) is non-binding -- explain in items[].why if different."
  152. )
  153. user = (
  154. f"[Title] {title}\n[Abstract] {ab}\n[Keywords] {kw_s}\n\n"
  155. f"[Chat context]\n{hist or '(none)'}\n\n"
  156. f"[User question] {um}\n\n"
  157. f"[Candidates] ({n} total)\n" + "\n".join(lines) + "\n"
  158. )
  159. order: list[int | None] = None
  160. keep_n: int | None = None
  161. try:
  162. llm = get_llm()
  163. raw = llm.invoke(
  164. [
  165. {"role": "system", "content": system},
  166. {"role": "user", "content": user},
  167. ]
  168. )
  169. text = coerce_hello_agents_llm_output_to_str(raw).strip()
  170. data = parse_llm_json(text)
  171. if isinstance(data, dict):
  172. if isinstance(data.get("order"), list):
  173. parsed: list[int] = []
  174. for x in data["order"]:
  175. try:
  176. parsed.append(int(x))
  177. except (TypeError, ValueError):
  178. continue
  179. order = parsed
  180. for key in ("keep_n", "keep", "n_keep", "num_keep"):
  181. v = data.get(key)
  182. if v is None:
  183. continue
  184. try:
  185. keep_n = int(v)
  186. break
  187. except (TypeError, ValueError):
  188. continue
  189. except Exception as exc:
  190. logger.debug("rerank_reader_recommend_llm_invoke_failed", exc_info=exc)
  191. if not order or len(order) < max(2, (n + 1) // 2):
  192. try:
  193. from ...agents.support.reader_reference_lookup_tool import rerank_reader_pairs_by_anchor
  194. kn = max(1, min(hint, n, READER_RECOMMEND_MAX_RESULTS))
  195. return rerank_reader_pairs_by_anchor(snap, bib, k=kn) + head
  196. except Exception:
  197. return bib[: max(1, min(hint, n, READER_RECOMMEND_MAX_RESULTS))] + head
  198. seen_i: set[int] = set()
  199. reordered: list[tuple[Any, str]] = []
  200. for i in order:
  201. try:
  202. ii = int(i)
  203. except (TypeError, ValueError):
  204. continue
  205. if 0 <= ii < n and ii not in seen_i:
  206. reordered.append(bib[ii])
  207. seen_i.add(ii)
  208. for i in range(n):
  209. if i not in seen_i:
  210. reordered.append(bib[i])
  211. kn = hint
  212. if keep_n is not None:
  213. try:
  214. kn = int(keep_n)
  215. except (TypeError, ValueError):
  216. kn = hint
  217. kn = max(1, min(kn, n, READER_RECOMMEND_MAX_RESULTS))
  218. return reordered[:kn] + head