paper_analysis_agent.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773
  1. """论文分析智能体 —— PDF 全文解析、表格提取与深度问答."""
  2. from __future__ import annotations
  3. import logging
  4. import re
  5. import threading
  6. from dataclasses import dataclass
  7. from typing import Any, Callable, Dict, List, Optional, Tuple
  8. from hello_agents import SimpleAgent
  9. from hello_agents.tools.registry import ToolRegistry
  10. from app.core.paper_paths import normalize_library_category_display
  11. from ..utils import parse_llm_json
  12. from ..services.llm.agent_config import papergraph_agent_config
  13. from ..services.llm.llm_service import is_llm_configured, coerce_hello_agents_llm_output_to_str
  14. from .base import BaseAgent
  15. from .support.paper_analysis_helpers import (
  16. clip_text as _clip,
  17. clip_reader_history as _clip_reader_history,
  18. clean_library_tag as _clean_library_tag,
  19. dedupe_tags as _dedupe_tags,
  20. nearest_major_in as _nearest_major_in,
  21. parse_taxonomy_majors as _parse_taxonomy_majors,
  22. prioritize_reader_context as _prioritize_reader_context,
  23. top_similar_categories as _top_similar_categories,
  24. )
  25. from .support.reader_pdf_parse_tool import ReaderPdfParseTool
  26. from .support.reader_table_tool import ReaderTableTool
  27. from .support.reader_paper_lookup_tool import ReaderPaperLookupTool, ground_score_paper_vs_reference_blob
  28. from .support.reader_reference_lookup_tool import (
  29. READER_RECOMMEND_MAX_RESULTS, ReaderReferenceLookupTool,
  30. READER_RELATED_FROM_BIBLIOGRAPHY, READER_RELATED_FROM_REF_BLOCK,
  31. paper_matches_reader_snap, parse_reader_recommendation_intent,
  32. prioritize_reader_related_pairs_refs_first, reader_user_allows_external_paper_lookup,
  33. rerank_reader_pairs_by_anchor_refs_first, resolve_references_via_openalex,
  34. strip_reader_reco_boilerplate, user_message_may_need_reference_lookup,
  35. READER_RELATED_FROM_PRE_SEARCH,
  36. )
  37. from .prompts.paper_analysis import ANALYSIS_SYSTEM, READER_CHAT_SYSTEM
  38. logger = logging.getLogger(__name__)
  39. def _reader_resolve_user_hint(user_message: str, snap: Dict[str, Any], *, want_reco: bool) -> str:
  40. um = (user_message or "").strip()
  41. if not want_reco:
  42. return um[:900]
  43. core = strip_reader_reco_boilerplate(um) or um
  44. if len(core) >= 28:
  45. return um[:900]
  46. title = str(snap.get("title") or "").strip()
  47. ab = str(snap.get("abstract") or "").strip()[:520]
  48. bits = [x for x in (core, title, ab) if x]
  49. return "\n".join(bits).strip()[:900] or um[:900]
  50. _MAJOR_LOCK = threading.Lock()
  51. _MAJOR_WHITELIST: Optional[Tuple[str, ...]] = None
  52. @dataclass
  53. class TaskSpec:
  54. name: str
  55. agent: Any
  56. parser: Optional[Callable[[str], Any]] = None
  57. max_chars: int = 6000
  58. class PaperAnalysisAgent(BaseAgent):
  59. """论文分析核心智能体 —— 管理多个子 Agent 协同完成 PDF 解析、表格提取与问答."""
  60. def __init__(self) -> None:
  61. super().__init__()
  62. self._analysis = SimpleAgent(
  63. name="papergraph_analysis",
  64. llm=self.llm,
  65. system_prompt=ANALYSIS_SYSTEM,
  66. config=papergraph_agent_config(),
  67. )
  68. self._reader_lookup_lock = threading.Lock()
  69. self._reader_lookup_buffer: List[Tuple[Any, str]] = []
  70. self._reader_snap: Dict[str, Any] = {}
  71. self._reader_last_user_message: str = ""
  72. self._reader_reco_ref_offset: Dict[int, int] = {}
  73. _reader_reg = ToolRegistry()
  74. _reader_reg.register_tool(
  75. ReaderPaperLookupTool(
  76. on_papers_found=self._reader_tool_on_found,
  77. get_snap=lambda: getattr(self, "_reader_snap", None) or {},
  78. )
  79. )
  80. _reader_reg.register_tool(
  81. ReaderReferenceLookupTool(
  82. get_snap=lambda: getattr(self, "_reader_snap", None) or {},
  83. on_papers_found=self._reader_tool_on_found,
  84. get_user_message=lambda: getattr(self, "_reader_last_user_message", "") or "",
  85. )
  86. )
  87. _reader_reg.register_tool(
  88. ReaderPdfParseTool(
  89. get_snap=lambda: getattr(self, "_reader_snap", None) or {},
  90. on_parsed=self._reader_on_pdf_structure,
  91. )
  92. )
  93. _reader_reg.register_tool(
  94. ReaderTableTool(
  95. get_snap=lambda: getattr(self, "_reader_snap", None) or {},
  96. )
  97. )
  98. self._reader = SimpleAgent(
  99. name="papergraph_paper_reader",
  100. llm=self.llm,
  101. system_prompt=READER_CHAT_SYSTEM,
  102. config=papergraph_agent_config(),
  103. tool_registry=_reader_reg,
  104. enable_tool_calling=True,
  105. max_tool_iterations=5,
  106. )
  107. def _ensure_major_whitelist(self) -> None:
  108. global _MAJOR_WHITELIST
  109. if _MAJOR_WHITELIST is not None:
  110. return
  111. with _MAJOR_LOCK:
  112. if _MAJOR_WHITELIST is not None:
  113. return
  114. wl: Optional[Tuple[str, ...]] = None
  115. taxonomy_prompt = (
  116. "# 任务:为个人/小团队文献库生成顶层大类\n"
  117. '输出: {"majors":["名称1",...]}\n'
  118. "- 共 16~24 条;恰含一个「未分类」\n"
  119. "- 名称须具学术划分意义,覆盖计算机与交叉学科\n"
  120. "- 每条 2~10 个中文字;互异;禁含「/」及路径非法字符\n"
  121. )
  122. try:
  123. raw = self._analysis.run(taxonomy_prompt)
  124. parsed = _parse_taxonomy_majors(raw)
  125. if not parsed:
  126. self._analysis.run(taxonomy_prompt + '\n请确保输出合法 JSON。')
  127. if parsed:
  128. wl = tuple(parsed)
  129. except Exception:
  130. logger.exception("major_taxonomy_bootstrap_failed")
  131. if not wl:
  132. raise RuntimeError("major_taxonomy_bootstrap_failed")
  133. _MAJOR_WHITELIST = wl
  134. logger.info("paper_analysis_major_whitelist_ready", extra={"n": len(wl)})
  135. def _get_major_whitelist(self) -> Tuple[str, ...]:
  136. self._ensure_major_whitelist()
  137. if _MAJOR_WHITELIST is None:
  138. raise RuntimeError("major_whitelist_unavailable")
  139. return _MAJOR_WHITELIST
  140. def _cleanup_mixed_reader_response(self, reply: str, user_message: str) -> str:
  141. """Use one cleanup pass when tool output leaks into the final reply."""
  142. t = reply.strip()
  143. # Typical leak: disclaimer plus useful data, or raw tool JSON.
  144. has_disclaimer = any(x in t[:300] for x in ("材料不足", "缺少", "仅有标题")) or bool(re.search(r"基于.*推测", t[:300]))
  145. has_actual_data = len(t) > 600 and any(x in t for x in ("Tab.", "Table", "结果", "实验", "| "))
  146. has_tool_artifact = "reader_pdf_struct" in t or '"chapters"' in t[:500]
  147. if (has_disclaimer and has_actual_data) or has_tool_artifact:
  148. if not is_llm_configured():
  149. return reply
  150. logger.info("paper_reader: detected mixed response, running cleanup pass")
  151. try:
  152. um = (user_message or "").strip()[:200]
  153. # Keep extracted paper facts; drop wrapper noise.
  154. cleaned = re.sub(r"^.*?(?:当前文献材料说明|当前提供的材料).*?\n\n", "", t, flags=re.S)
  155. cleaned = re.sub(r"reader_pdf_struct\S*", "", cleaned)
  156. cleaned = cleaned.strip()[:6000]
  157. if cleaned:
  158. prompt = (
  159. f"用户问:{um}\n\n"
  160. f"以下是从论文中提取的信息(可能含多个片段):\n\n{cleaned}\n\n"
  161. "请整合成一个连贯的回答。用中文分点说明。如有表格数据用 Markdown 表格呈现。"
  162. "不要提及'材料不足'或'推测'——只基于已有信息回答,不确定的地方标注'论文未提供'。"
  163. )
  164. result = self._reader_chat_llm(prompt)
  165. if result.strip():
  166. return result.strip()
  167. except Exception:
  168. logger.debug("cleanup_mixed_response_failed", exc_info=True)
  169. return reply
  170. @staticmethod
  171. def _looks_like_raw_tool_output(text: str) -> bool:
  172. """Detect raw tool output that still needs interpretation."""
  173. t = (text or "").strip()
  174. if not t:
  175. return False
  176. if "reader_pdf_structure" in t:
  177. return True
  178. if t.startswith("## ") and len(t) > 300:
  179. first_line = t.split("\n")[0]
  180. if re.match(r"^## \d", first_line) or re.match(r"^## [A-Z]", first_line):
  181. return True
  182. if '"chapters"' in t[:500] or '"references"' in t[:500]:
  183. return True
  184. return False
  185. def _interpret_tool_output(self, tool_output: str, user_message: str) -> str:
  186. """Convert raw tool output into a user-facing answer."""
  187. if not is_llm_configured():
  188. return tool_output
  189. try:
  190. um = (user_message or "").strip()[:200]
  191. # Strip tool wrappers before asking the LLM to explain.
  192. cleaned = re.sub(r"^.*?reader_pdf_structure[::]\s*", "", tool_output, flags=re.S)
  193. cleaned = re.sub(r"^以下为 JSON.*?\n", "", cleaned)
  194. cleaned = re.sub(r"^\s*\{\s*\"chapters\".*?\n", "", cleaned)
  195. cleaned = re.sub(r"reader_pdf_structu\S*$", "", cleaned)
  196. cleaned = re.sub(r"[\u007F-\u009F]", "", cleaned)
  197. cleaned = cleaned.strip()
  198. if not cleaned or len(cleaned) < 50:
  199. return "当前文献材料不足以回答该问题。PDF 文本提取不完整,建议确认 PDF 文件是否可读。"
  200. cleaned = cleaned[:6000]
  201. prompt = (
  202. f"用户问:{um}\n\n"
  203. f"以下是从论文中提取的相关章节内容:\n\n{cleaned}\n\n"
  204. "请用中文为用户解读这段内容。分点说明关键发现、方法和结论。"
  205. "用 Markdown 表格对比数据(如有)。\n"
  206. "重要:如果上面的内容不足以回答用户问题(如仅有章节标题无正文),"
  207. "请直接说明材料不足,严禁编造论文中不存在的数据、方法或结论。"
  208. )
  209. raw = self._reader_chat_llm(prompt)
  210. return raw.strip() or tool_output
  211. except Exception:
  212. logger.debug("interpret_tool_output_failed", exc_info=True)
  213. return tool_output
  214. def _reader_chat_llm(self, prompt: str) -> str:
  215. """Reader chat without tools."""
  216. from ..services.llm.llm_service import coerce_hello_agents_llm_output_to_str
  217. if not hasattr(self, "_reader_interpreter"):
  218. from hello_agents import SimpleAgent
  219. self._reader_interpreter = SimpleAgent(
  220. name="papergraph_reader_interpreter",
  221. llm=self.llm,
  222. system_prompt=READER_CHAT_SYSTEM,
  223. config=papergraph_agent_config(),
  224. enable_tool_calling=False,
  225. )
  226. return coerce_hello_agents_llm_output_to_str(self._reader_interpreter.run(prompt))
  227. def _reader_tool_on_found(self, papers: List[Any], source: str) -> None:
  228. with self._reader_lookup_lock:
  229. for p in papers or []:
  230. self._reader_lookup_buffer.append((p, source))
  231. def _reader_on_pdf_structure(self, obj: Dict[str, Any]) -> None:
  232. try:
  233. snap = getattr(self, "_reader_snap", None)
  234. if not isinstance(snap, dict):
  235. return
  236. refs = obj.get("references") or {}
  237. entries = refs.get("entries")
  238. if isinstance(entries, list) and entries:
  239. snap["references_from_structure"] = [str(x).strip() for x in entries if str(x).strip()]
  240. except Exception:
  241. logger.debug("reader_on_pdf_structure_failed", exc_info=True)
  242. @staticmethod
  243. def _dedupe_reader_paper_pairs(buffer: List[Tuple[Any, str]]) -> List[Tuple[Any, str]]:
  244. seen: set[str] = set()
  245. out: List[Tuple[Any, str]] = []
  246. for p, src in buffer:
  247. k = str(getattr(p, "title", "") or "").strip().lower()
  248. if not k or k in seen:
  249. continue
  250. seen.add(k)
  251. out.append((p, src))
  252. return out
  253. def _run_task(self, spec: TaskSpec, user: str) -> Any:
  254. prompt = _clip(user, spec.max_chars)
  255. try:
  256. raw = spec.agent.run(prompt)
  257. except Exception as exc:
  258. logger.exception("paper_analysis_llm_failed", extra={"task": spec.name})
  259. raise RuntimeError(f"paper_analysis_llm_failed:{spec.name}") from exc
  260. raw_text = (raw if isinstance(raw, str) else str(raw or "")).strip()
  261. if not spec.parser:
  262. if not raw_text:
  263. if spec.name == "paper_reader_reply":
  264. return ""
  265. raise RuntimeError(f"paper_analysis_empty_response:{spec.name}")
  266. # Some tool responses need a final explanation pass.
  267. if spec.name == "paper_reader_reply" and self._looks_like_raw_tool_output(raw_text):
  268. logger.info("paper_reader: detected raw tool output, invoking interpreter")
  269. raw_text = self._interpret_tool_output(raw_text, user)
  270. return raw_text
  271. data = spec.parser(raw_text)
  272. if data is not None:
  273. return data
  274. try:
  275. raw2 = spec.agent.run("请只输出合法 JSON。\n" + prompt)
  276. data2 = spec.parser((raw2 or "").strip())
  277. if data2 is not None:
  278. return data2
  279. except Exception:
  280. logger.exception("paper_analysis_json_retry_failed", extra={"task": spec.name})
  281. raise RuntimeError(f"paper_analysis_parse_failed:{spec.name}")
  282. def _record_preference_signals(
  283. self,
  284. *,
  285. signal: str,
  286. title: Optional[str] = None,
  287. tags: Optional[List[str]] = None,
  288. category: Optional[str] = None,
  289. major: Optional[str] = None,
  290. shared: bool = True,
  291. ) -> None:
  292. try:
  293. from ..services.memory.agent_memory import get_agent_memory
  294. am = get_agent_memory()
  295. parts: List[str] = [f"偏好信号({signal})"]
  296. if title:
  297. parts.append(f"title={str(title).strip()[:120]}")
  298. if major:
  299. parts.append(f"major={str(major).strip()[:24]}")
  300. if category:
  301. parts.append(f"cat={str(category).strip()[:40]}")
  302. if tags:
  303. clean = [str(x).strip() for x in (tags or []) if str(x).strip()][:10]
  304. if clean:
  305. parts.append("tags=" + ",".join(clean))
  306. line = " | ".join(parts)[:360]
  307. am.add(agent_name="paper_analysis", content=line, memory_type="working", importance=0.55, shared=bool(shared))
  308. except Exception:
  309. return
  310. def _parse_major_json(self, raw: str) -> Optional[str]:
  311. d = parse_llm_json(raw)
  312. if not isinstance(d, dict):
  313. return None
  314. m = str(d.get("major") or d.get("category") or "").strip()
  315. if not m:
  316. return None
  317. return _nearest_major_in(m, self._get_major_whitelist())
  318. def _parse_fine_classify_json(self, raw: str, major: str) -> Optional[Tuple[str, List[str]]]:
  319. d = parse_llm_json(raw)
  320. if not isinstance(d, dict):
  321. return None
  322. cat = normalize_library_category_display(str(d.get("category") or "未分类"))
  323. tags_raw = d.get("tags")
  324. extra: List[str] = []
  325. if isinstance(tags_raw, list):
  326. for x in tags_raw:
  327. c = _clean_library_tag(str(x))
  328. if c:
  329. extra.append(c)
  330. extra = _dedupe_tags(extra)
  331. if not cat:
  332. return None
  333. if major and major != "未分类" and not (cat.startswith(major) or major in cat):
  334. cat = normalize_library_category_display(f"{major}/{cat.split('/')[-1]}")
  335. return cat, extra
  336. _venue_type_cache: dict[str, str] = {}
  337. def classify_venue_type(self, journal: str | None) -> str | None:
  338. if not journal or not str(journal).strip():
  339. return None
  340. j = str(journal).strip()
  341. if j.startswith("arXiv:"):
  342. return "preprint"
  343. if j in self._venue_type_cache:
  344. return self._venue_type_cache[j]
  345. try:
  346. prompt = f'判断以下学术来源名称是会议(conference)还是期刊(journal)。只回复一个单词:conference 或 journal。\n\n名称:{j}'
  347. resp = self._analysis.run(prompt)
  348. result = str(resp).strip().lower()
  349. if "conference" in result:
  350. vt = "conference"
  351. elif "journal" in result:
  352. vt = "journal"
  353. else:
  354. vt = None
  355. except Exception:
  356. vt = None
  357. if vt:
  358. self._venue_type_cache[j] = vt
  359. return vt
  360. def classify_for_library(
  361. self,
  362. title: str,
  363. abstract: Optional[str],
  364. journal: Optional[str],
  365. keywords: Optional[List[str]] = None,
  366. existing_categories: Optional[List[str]] = None,
  367. ) -> Tuple[str, List[str]]:
  368. kw = "、".join(keywords or []) or "(无)"
  369. journal = journal or "(无)"
  370. abstract = (abstract or "").strip() or "(无摘要)"
  371. seed = f"{title}\n{abstract[:800]}"
  372. cats_all = [str(x).strip() for x in (existing_categories or []) if str(x).strip()]
  373. candidates = _top_similar_categories(seed, cats_all, k=18)
  374. base_user = f"标题:{title}\n摘要:{abstract}\n来源:{journal}\n关键词:{kw}"
  375. wl = self._get_major_whitelist()
  376. major_list_block = "\n".join(f"- {m}" for m in wl)
  377. major_user = f"{base_user}\n\n【可选大类列表】\n{major_list_block}"
  378. major_fb = "未分类"
  379. major_user = (
  380. "# 任务:归类(大类)\n"
  381. "从【可选大类列表】中选一个最匹配的大类\n"
  382. '输出: {"major":"大类名"}\n'
  383. '- 必须从列表选;优先字面匹配,否则语义最接近;无法判断→"未分类";禁列表外值\n\n'
  384. + major_user
  385. )
  386. major_spec = TaskSpec(
  387. name="classify_major",
  388. agent=self._analysis,
  389. parser=self._parse_major_json,
  390. max_chars=5200,
  391. )
  392. major = self._run_task(major_spec, major_user)
  393. if not isinstance(major, str) or not major.strip():
  394. major = major_fb
  395. major = _nearest_major_in(major, wl)
  396. if not candidates:
  397. cat0 = normalize_library_category_display(major)
  398. self._record_preference_signals(signal="classify", title=title, major=major, category=cat0, shared=True)
  399. return cat0, []
  400. prefixed = [c for c in candidates if c.startswith(major) or c.split("/")[0] == major]
  401. pool = prefixed if len(prefixed) >= 2 else candidates
  402. pool_block = "\n".join(f"- {c}" for c in pool[:18])
  403. fine_user = (
  404. "# 任务:归类(路径与标签)\n"
  405. "给定大类,从候选已有路径中选择路径,生成标签\n"
  406. '输出: {"category":"路径","tags":["标签1",...]}\n'
  407. "- category:优先原样选候选;否则「大类/子类」,子类 2~8 个中文字\n"
  408. "- tags:3~8 个,单条 ≤24 字,不重复;无法判断可为 []\n"
  409. "- 禁含路径非法字符 \\ / : * ? \" < > |\n\n"
  410. f"{base_user}\n\n给定大类:{major}\n\n"
  411. f"【候选已有路径】(请优先从中复制一条作为 category)\n{pool_block}"
  412. )
  413. default_cat = normalize_library_category_display(major)
  414. def _fine_parser(raw: str) -> Optional[Tuple[str, List[str]]]:
  415. return self._parse_fine_classify_json(raw, major)
  416. fine_spec = TaskSpec(
  417. name="classify_fine",
  418. agent=self._analysis,
  419. parser=_fine_parser,
  420. max_chars=5500,
  421. )
  422. out = self._run_task(fine_spec, fine_user)
  423. if isinstance(out, tuple) and len(out) == 2:
  424. cat, tags = out[0], out[1]
  425. cat = normalize_library_category_display(str(cat or "未分类"))
  426. if isinstance(tags, list):
  427. cleaned: List[str] = []
  428. for x in tags:
  429. c = _clean_library_tag(str(x))
  430. if c:
  431. cleaned.append(c)
  432. tags = _dedupe_tags(cleaned)
  433. else:
  434. tags = []
  435. self._record_preference_signals(signal="classify", title=title, major=major, category=cat, tags=tags, shared=True)
  436. return cat, tags
  437. return default_cat, []
  438. def paper_reader_reply(
  439. self,
  440. context_block: str,
  441. history_lines: str,
  442. user_message: str,
  443. reader_snap: Optional[Dict[str, Any]] = None,
  444. ) -> Tuple[str, List[Any], List[str]]:
  445. snap: Dict[str, Any] = dict(reader_snap or {})
  446. self._reader_snap = snap
  447. reco_pid: Optional[int] = None
  448. try:
  449. spid = snap.get("paper_id")
  450. if spid is not None and int(spid) > 0:
  451. reco_pid = int(spid)
  452. except (TypeError, ValueError):
  453. reco_pid = None
  454. try:
  455. with self._reader_lookup_lock:
  456. self._reader_lookup_buffer.clear()
  457. ctx = _prioritize_reader_context(context_block, max_chars=3600)
  458. hist = _clip_reader_history((history_lines or "").strip() or "(尚无此前对话)", max_chars=2200)
  459. um = _clip(user_message, 900)
  460. want_reco, reco_max = parse_reader_recommendation_intent(um)
  461. self._reader_last_user_message = um
  462. try:
  463. from ..services.memory.agent_memory import get_agent_memory
  464. mem_block = get_agent_memory().build_context_block(agent_name="paper_analysis", query=um)
  465. except Exception:
  466. mem_block = ""
  467. user = (
  468. (f"【共享/独立记忆】\n{mem_block}\n\n" if mem_block else "")
  469. + f"【当前文献材料】\n{ctx}\n\n"
  470. + f"【对话历史】\n{hist}\n\n"
  471. + f"【用户最新问题】\n{um}"
  472. )
  473. spec = TaskSpec(
  474. name="paper_reader_reply",
  475. agent=self._reader,
  476. parser=None,
  477. max_chars=7200,
  478. )
  479. out = self._run_task(spec, user)
  480. # Clean up mixed tool/user-facing output.
  481. if isinstance(out, str) and out.strip():
  482. out = self._cleanup_mixed_reader_response(out, um)
  483. with self._reader_lookup_lock:
  484. raw_pairs = list(self._reader_lookup_buffer)
  485. self._reader_lookup_buffer.clear()
  486. pairs = self._dedupe_reader_paper_pairs(raw_pairs)
  487. rb_pdf = str(snap.get("references_section_raw") or "").strip()
  488. if (
  489. len(rb_pdf) >= 140
  490. and not (snap.get("references") or [])
  491. ):
  492. _thr_bib = 0.48 if want_reco else 0.54
  493. pairs = [
  494. (p, s) for p, s in pairs
  495. if s != READER_RELATED_FROM_BIBLIOGRAPHY
  496. or ground_score_paper_vs_reference_blob(p, rb_pdf) >= _thr_bib
  497. ]
  498. if user_message_may_need_reference_lookup(um) and len(pairs) == 0:
  499. try:
  500. fb_max = reco_max if want_reco else 2
  501. resolve_mr = max(fb_max, min(READER_RECOMMEND_MAX_RESULTS, fb_max * 4)) if want_reco else fb_max
  502. extra: List[Any] = []
  503. if snap.get("references"):
  504. refs_full = [str(x).strip() for x in (snap.get("references") or []) if str(x).strip()]
  505. off = self._reader_reco_ref_offset.get(reco_pid, 0) if reco_pid else 0
  506. snap_res: Dict[str, Any] = snap
  507. if want_reco and reco_pid and refs_full and off >= len(refs_full):
  508. off = 0
  509. self._reader_reco_ref_offset[reco_pid] = 0
  510. if want_reco and reco_pid and off > 0 and off < len(refs_full):
  511. snap_res = dict(snap)
  512. snap_res["references"] = refs_full[off:]
  513. extra = resolve_references_via_openalex(
  514. snap_res,
  515. max_results=resolve_mr,
  516. user_hint=_reader_resolve_user_hint(um, snap, want_reco=want_reco),
  517. )
  518. if extra and want_reco and reco_pid:
  519. self._reader_reco_ref_offset[reco_pid] = off + max(1, len(extra))
  520. elif (snap.get("references_section_raw") or "").strip():
  521. from ..services.reader.paper_reader_context import reference_strings_for_resolve_fallback
  522. ref_lines = reference_strings_for_resolve_fallback(
  523. str(snap.get("references_section_raw") or "")
  524. )
  525. rs_struct = snap.get("references_from_structure")
  526. if isinstance(rs_struct, list) and rs_struct:
  527. ref_lines = [str(x).strip() for x in rs_struct if str(x).strip()] or ref_lines
  528. ref_core = list(ref_lines)
  529. if ref_core and is_llm_configured():
  530. try:
  531. from ..services.reader.reader_recommend_llm import merge_ref_lines_with_llm_queries
  532. merged = merge_ref_lines_with_llm_queries(
  533. str(snap.get("references_section_raw") or ""),
  534. snap,
  535. ref_core,
  536. max_queries=12,
  537. )
  538. ref_lines = merged if merged else ref_core
  539. except Exception:
  540. logger.debug("merge_llm_ref_queries_failed", exc_info=True)
  541. ref_lines = ref_core
  542. else:
  543. ref_lines = ref_core
  544. if ref_lines:
  545. off = self._reader_reco_ref_offset.get(reco_pid, 0) if reco_pid else 0
  546. if want_reco and reco_pid and off >= len(ref_lines):
  547. off = 0
  548. self._reader_reco_ref_offset[reco_pid] = 0
  549. if want_reco and reco_pid and off > 0:
  550. ref_lines = ref_lines[off:]
  551. if ref_lines:
  552. snap_fb = dict(snap)
  553. snap_fb["references"] = ref_lines
  554. extra = resolve_references_via_openalex(
  555. snap_fb,
  556. max_results=resolve_mr,
  557. user_hint=_reader_resolve_user_hint(um, snap, want_reco=want_reco),
  558. )
  559. rb = str(snap.get("references_section_raw") or "").strip()
  560. if extra and len(rb) >= 140 and not (snap.get("references") or []):
  561. raw_extra = list(extra)
  562. def _gf(th: float) -> List[Any]:
  563. return [
  564. p
  565. for p in raw_extra
  566. if ground_score_paper_vs_reference_blob(p, rb) >= th
  567. ]
  568. extra = _gf(0.54)
  569. if not extra and want_reco:
  570. extra = _gf(0.42)
  571. if not extra and want_reco and raw_extra:
  572. extra = list(raw_extra)[: max(1, min(len(raw_extra), fb_max))]
  573. if extra and want_reco and reco_pid:
  574. self._reader_reco_ref_offset[reco_pid] = off + max(1, len(extra))
  575. except Exception:
  576. logger.debug("reader_reference_server_fallback_failed", exc_info=True)
  577. bib_only = (
  578. (want_reco or user_message_may_need_reference_lookup(um))
  579. and not reader_user_allows_external_paper_lookup(um)
  580. )
  581. pairs = [(p, s) for p, s in pairs if not paper_matches_reader_snap(snap, p)]
  582. if bib_only:
  583. pairs = [
  584. (p, s)
  585. for p, s in pairs
  586. if s in (READER_RELATED_FROM_BIBLIOGRAPHY, READER_RELATED_FROM_REF_BLOCK, READER_RELATED_FROM_PRE_SEARCH)
  587. ]
  588. if want_reco and pairs:
  589. try:
  590. if is_llm_configured():
  591. from ..services.reader.reader_recommend_llm import rerank_reader_recommend_pairs_by_llm
  592. pairs = rerank_reader_recommend_pairs_by_llm(
  593. snap,
  594. pairs,
  595. user_message=um,
  596. history_lines=hist,
  597. reco_max_hint=reco_max,
  598. )
  599. else:
  600. pairs = rerank_reader_pairs_by_anchor_refs_first(snap, pairs, k=reco_max)
  601. except Exception:
  602. logger.debug("reader_llm_recommend_rerank_failed", exc_info=True)
  603. pairs = rerank_reader_pairs_by_anchor_refs_first(snap, pairs, k=reco_max)
  604. if pairs:
  605. pairs = prioritize_reader_related_pairs_refs_first(pairs)
  606. if want_reco and pairs:
  607. cap = max(1, min(int(reco_max or 2), READER_RECOMMEND_MAX_RESULTS, len(pairs)))
  608. pairs = pairs[:cap]
  609. papers = [p for p, _ in pairs]
  610. provenances = [s for _, s in pairs]
  611. text_out = (str(out) if out is not None else "").strip()
  612. if not text_out:
  613. if papers:
  614. if snap.get("references_source") == "pdf_section":
  615. text_out = (
  616. "已根据 PDF 参考文献区摘录检索到相关论文,请点击下方「推荐论文」查看条目;"
  617. "如需结合摘要或方法做对比,请告诉我关注点。"
  618. )
  619. else:
  620. text_out = (
  621. "已根据参考文献检索列出相关论文,请点击下方「推荐论文」查看条目;"
  622. "如需结合摘要或方法做对比,请告诉我关注点。"
  623. )
  624. elif user_message_may_need_reference_lookup(um) and not (snap.get("references") or []) and not (
  625. snap.get("references_section_raw") or ""
  626. ).strip():
  627. text_out = (
  628. "未在库表中找到 references,且当前 PDF 摘录中未能定位到「参考文献 / References」标题后的文本块,"
  629. "无法从原文区检索。若为扫描版、参考文献不在已抽取页范围内,或版式特殊,会出现此情况。"
  630. "可从带参考文献的数据源重新保存该文,或直接粘贴英文题名 / DOI 以便检索。"
  631. )
  632. elif user_message_may_need_reference_lookup(um) and (snap.get("references_section_raw") or "").strip():
  633. text_out = (
  634. "上下文中已含 PDF 参考文献区原文,但本轮未产生可展示的检索命中。"
  635. "你可指定要查的一条英文题名或 DOI;或让我从摘录中逐条用检索工具核对。"
  636. )
  637. elif user_message_may_need_reference_lookup(um):
  638. text_out = (
  639. "已按库表参考文献题录在 OpenAlex / arXiv / DBLP 中尝试解析,但未找到与题录足够一致的条目"
  640. "(已过滤明显不符的综述/泛命中)。你可粘贴 DOI 或标准英文题名,我会用 reader_paper_lookup 检索并展示在下方列表。"
  641. )
  642. else:
  643. text_out = "(本次未收到模型有效正文。请稍后重试,或缩短问题后再次提问。)"
  644. try:
  645. from ..services.memory.agent_memory import get_agent_memory
  646. am = get_agent_memory()
  647. am.add(agent_name="paper_analysis", content=f"用户问:{um}", memory_type="working", importance=0.55, shared=False)
  648. am.add(agent_name="paper_analysis", content=f"助手答:{text_out[:240]}", memory_type="working", importance=0.5, shared=False)
  649. am.add(agent_name="paper_analysis", content=f"阅读问答要点:{text_out[:180]}", memory_type="working", importance=0.55, shared=True)
  650. except Exception:
  651. pass
  652. logger.info("reader_post: text_out_len=%d papers=%d want_reco=%s",
  653. len(text_out or ""), len(papers), want_reco)
  654. if text_out and len(text_out) >= 80:
  655. try:
  656. prompt = (
  657. "从以下学术助手的回复中,提取被推荐的论文信息。\n"
  658. "返回纯 JSON 数组,每项可含 title(英文题名)和/或 arxiv_id(如 2307.05973)。\n"
  659. '格式:[{"title": "...", "arxiv_id": "..."}, ...]\n'
  660. "若回复未推荐具体论文,返回 []。\n\n"
  661. "回复原文:\n" + text_out[:3000]
  662. )
  663. raw = self.llm.invoke([{"role": "user", "content": prompt}])
  664. llm_text = coerce_hello_agents_llm_output_to_str(raw)
  665. import json as _json
  666. extracted = _json.loads(llm_text.strip().removeprefix("```json").removesuffix("```").strip())
  667. if isinstance(extracted, list) and extracted:
  668. logger.info("reader_llm_extract: got %d papers from LLM", len(extracted))
  669. try:
  670. from app.api.dependencies import get_searcher as _es
  671. from app.services.papers.papers_converters import litpaper_to_api_paper as _ep
  672. ese = _es()
  673. existing_titles = {str(getattr(p, "title", "") or "").strip().lower() for p in papers}
  674. existing_titles.add(str(snap.get("title") or "").strip().lower())
  675. for item in extracted[:6]:
  676. if not isinstance(item, dict):
  677. continue
  678. title = str(item.get("title") or "").strip()
  679. axid = str(item.get("arxiv_id") or "").strip()
  680. if axid and re.match(r"^\d{4}\.\d{4,5}", axid):
  681. try:
  682. for fp in (ese.search_arxiv("", max_results=2, arxiv_id_list=axid,
  683. http_timeout_sec=8, http_max_attempts=1) or []):
  684. afp = _ep(fp)
  685. tafp = str(getattr(afp, "title", "") or "").strip().lower()
  686. if tafp and tafp not in existing_titles:
  687. existing_titles.add(tafp)
  688. papers.insert(0, afp)
  689. provenances.insert(0, READER_RELATED_FROM_PRE_SEARCH)
  690. logger.info("reader_llm_extract: added by arxiv %s", axid)
  691. except Exception:
  692. continue
  693. if title and len(title) >= 4:
  694. try:
  695. for fp in (ese.search_openalex(title, max_results=2, venue_proceedings_journal=False) or []):
  696. afp = _ep(fp)
  697. tafp = str(getattr(afp, "title", "") or "").strip().lower()
  698. if tafp and tafp not in existing_titles:
  699. existing_titles.add(tafp)
  700. papers.insert(0, afp)
  701. provenances.insert(0, READER_RELATED_FROM_PRE_SEARCH)
  702. logger.info("reader_llm_extract: added by title %s", tafp[:80])
  703. except Exception:
  704. continue
  705. except Exception as e:
  706. logger.warning("reader_llm_extract_search_failed: %s", e)
  707. except Exception as e:
  708. logger.warning("reader_llm_extract_failed: %s", e)
  709. return (text_out, papers, provenances)
  710. finally:
  711. self._reader_snap = {}