paper_reader_service.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. """论文阅读服务 —— PDF 正文抽取、AI 导读生成、上下文对话与参考文献辅助."""
  2. from __future__ import annotations
  3. import logging
  4. from typing import Any
  5. from collections.abc import Iterable
  6. from fastapi import BackgroundTasks, HTTPException
  7. from starlette.concurrency import run_in_threadpool
  8. from ...agents import get_paper_analysis_agent
  9. from ...agents.support.reader_reference_lookup_tool import READER_RELATED_FROM_BIBLIOGRAPHY, READER_RELATED_FROM_PRE_SEARCH
  10. from ...utils.common import suppress_exceptions_async
  11. logger = logging.getLogger(__name__)
  12. _OPENING_PROMPT = (
  13. "请用中文写一段不超过 380 字的导读:研究问题、核心方法、实验与结论的阅读要点。"
  14. "仅依据当前提供的摘要与摘录组织表述;勿单列「不确定处」「局限」或待查清单(用户追问时再说明材料范围即可)。"
  15. )
  16. _NO_HISTORY_PLACEHOLDER = "(尚无对话历史)"
  17. def _update_memory_from_turn(*, store: Any, paper_id: int, user_message: str, assistant_reply: str) -> None:
  18. um = (user_message or "").strip()
  19. if not um:
  20. return
  21. store.add(scope="paper", paper_id=paper_id, kind="working", content=f"用户问:{um[:220]}", importance=0.5)
  22. try:
  23. store.extract_memory_via_llm(paper_id, um, assistant_reply)
  24. except Exception:
  25. logger.debug("extract_memory_via_llm failed for paper %s", paper_id, exc_info=True)
  26. class PaperReaderService:
  27. def __init__(self, db: Any, agent: Any | None = None) -> None:
  28. self._db = db
  29. self._agent = agent or get_paper_analysis_agent()
  30. @property
  31. def db(self) -> Any:
  32. return self._db
  33. @staticmethod
  34. def _format_reader_history(turns: Iterable[Any]) -> str:
  35. lines: list[str] = []
  36. tail = list(turns or [])[-24:]
  37. for t in tail:
  38. role = (getattr(t, "role", None) or "").strip().lower()
  39. content = (getattr(t, "content", None) or "").strip()
  40. if not content:
  41. continue
  42. if role not in ("user", "assistant"):
  43. role = "user"
  44. label = "用户" if role == "user" else "助手"
  45. lines.append(f"{label}:{content}")
  46. return "\n\n".join(lines)
  47. async def _build_reader_context(self, paper_id: int, user_message: str = "") -> tuple[Any, str, str]:
  48. from ..memory.memory_store import MemoryStore
  49. from .paper_reader_context import build_reader_context_for_paper
  50. paper, base_ctx, pdf_ref_text, pdf_parsing = await run_in_threadpool(build_reader_context_for_paper, self._db, paper_id)
  51. if not paper:
  52. raise HTTPException(status_code=404, detail="文献不存在")
  53. mem = await run_in_threadpool(
  54. MemoryStore(self._db.db_path).build_context_block,
  55. paper_id=paper_id,
  56. )
  57. title_hint = str(getattr(paper, "title", None) or "")
  58. ctx = (base_ctx + ("\n\n" + mem if mem else "")).strip()
  59. return paper, ctx, title_hint, pdf_ref_text, pdf_parsing
  60. def _schedule_pdf_excerpt(self, paper_id: int, ctx: str, background_tasks: BackgroundTasks) -> None:
  61. from .paper_reader_context import compute_and_cache_excerpt
  62. try:
  63. pdf_path = self._db.get_library_pdf_abspath(paper_id)
  64. if pdf_path and "【PDF 正文摘录" not in ctx:
  65. background_tasks.add_task(compute_and_cache_excerpt, self._db.db_path, paper_id, pdf_path)
  66. except Exception as exc:
  67. logger.debug("paper_reader.schedule_pdf_excerpt_failed", extra={"paper_id": paper_id}, exc_info=exc)
  68. @suppress_exceptions_async(default_return=None, log_level="warning", log_message="paper_reader.ensure_opening_turn_failed")
  69. async def _ensure_opening_turn_safe(self, *, paper_id: int, opening_text: str) -> None:
  70. from .paper_reader_history import ensure_opening_turn
  71. await run_in_threadpool(
  72. ensure_opening_turn,
  73. self._db.db_path,
  74. paper_id=int(paper_id),
  75. opening_text=opening_text,
  76. )
  77. @suppress_exceptions_async(default_return=None, log_level="warning", log_message="paper_reader.append_history_failed")
  78. async def _append_history(self, *, paper_id: int, user_message: str, reply: str) -> None:
  79. from .paper_reader_history import append_turn
  80. await run_in_threadpool(
  81. append_turn,
  82. self._db.db_path,
  83. paper_id=int(paper_id),
  84. role="user",
  85. content=user_message,
  86. )
  87. await run_in_threadpool(
  88. append_turn,
  89. self._db.db_path,
  90. paper_id=int(paper_id),
  91. role="assistant",
  92. content=reply,
  93. )
  94. @suppress_exceptions_async(default_return=None, log_level="warning", log_message="paper_reader.memory_update_failed")
  95. async def _update_memory(self, *, store: Any, paper_id: int, user_message: str, reply: str) -> None:
  96. await run_in_threadpool(
  97. _update_memory_from_turn,
  98. store=store, paper_id=paper_id,
  99. user_message=user_message, assistant_reply=reply,
  100. )
  101. async def get_opening(self, *, paper_id: int, background_tasks: BackgroundTasks) -> dict:
  102. from .reader_opening_cache import get_cached_opening, set_cached_opening
  103. from .paper_reader_context import build_reader_snap
  104. paper, ctx, title_hint, pdf_ref_text, pdf_parsing = await self._build_reader_context(paper_id)
  105. reader_snap = build_reader_snap(paper, pdf_text_for_references=pdf_ref_text)
  106. try:
  107. pdf_path = self._db.get_library_pdf_abspath(paper_id)
  108. if pdf_path:
  109. reader_snap["_pdf_abspath"] = pdf_path
  110. except Exception:
  111. pass
  112. if pdf_parsing:
  113. self._schedule_pdf_excerpt(paper_id, ctx, background_tasks)
  114. cached, fresh = await run_in_threadpool(get_cached_opening, self._db.db_path, paper_id, 72)
  115. if cached and fresh:
  116. op = cached.strip()
  117. await self._ensure_opening_turn_safe(paper_id=paper_id, opening_text=op)
  118. return {"opening": op, "pdf_parsing": pdf_parsing}
  119. if cached and not fresh:
  120. def _refresh() -> None:
  121. try:
  122. opening2, _, _ = self._agent.paper_reader_reply(
  123. ctx, _NO_HISTORY_PLACEHOLDER, _OPENING_PROMPT, reader_snap
  124. )
  125. set_cached_opening(self._db.db_path, paper_id, opening2.strip())
  126. except Exception as exc:
  127. logger.warning("paper_reader.opening_refresh_failed", extra={"paper_id": paper_id}, exc_info=exc)
  128. background_tasks.add_task(_refresh)
  129. op = cached.strip()
  130. await self._ensure_opening_turn_safe(paper_id=paper_id, opening_text=op)
  131. return {"opening": op, "pdf_parsing": pdf_parsing}
  132. opening, _, _ = await run_in_threadpool(
  133. lambda: self._agent.paper_reader_reply(ctx, _NO_HISTORY_PLACEHOLDER, _OPENING_PROMPT, reader_snap)
  134. )
  135. op = opening.strip()
  136. await run_in_threadpool(set_cached_opening, self._db.db_path, paper_id, op)
  137. await self._ensure_opening_turn_safe(paper_id=paper_id, opening_text=op)
  138. return {"opening": op, "pdf_parsing": pdf_parsing}
  139. async def process_chat(
  140. self,
  141. *,
  142. paper_id: int,
  143. messages: list[Any],
  144. user_message: str,
  145. background_tasks: BackgroundTasks,
  146. ) -> dict[str, Any]:
  147. from ..memory.memory_store import MemoryStore
  148. paper, ctx, title_hint, pdf_ref_text, pdf_parsing = await self._build_reader_context(paper_id, user_message)
  149. from .paper_reader_context import build_reader_snap
  150. reader_snap = build_reader_snap(paper, pdf_text_for_references=pdf_ref_text)
  151. try:
  152. pdf_path = self._db.get_library_pdf_abspath(paper_id)
  153. if pdf_path:
  154. reader_snap["_pdf_abspath"] = pdf_path
  155. except Exception:
  156. pass
  157. self._schedule_pdf_excerpt(paper_id, ctx, background_tasks)
  158. store = MemoryStore(self._db.db_path)
  159. rel_mem = await run_in_threadpool(
  160. store.get_context_for_query,
  161. paper_id=int(paper_id),
  162. query=user_message,
  163. limit=6,
  164. )
  165. if rel_mem:
  166. ctx += "\n\n" + rel_mem
  167. hist = self._format_reader_history(messages)
  168. reply, related_papers, related_sources = await run_in_threadpool(
  169. lambda: self._agent.paper_reader_reply(ctx, hist, user_message, reader_snap)
  170. )
  171. rs = list(related_sources or [])
  172. related_hints: list[dict[str, Any]] = [
  173. {
  174. "ref_idx": i,
  175. "title": getattr(p, "title", None),
  176. "reason": (
  177. "来自当前文献参考文献题录(OpenAlex 解析)"
  178. if i - 1 < len(rs) and rs[i - 1] == READER_RELATED_FROM_BIBLIOGRAPHY
  179. else "基于论文主题相似度匹配"
  180. if i - 1 < len(rs) and rs[i - 1] == READER_RELATED_FROM_PRE_SEARCH
  181. else "来自用户给定英文短语或外部题名检索(OpenAlex)"
  182. ),
  183. }
  184. for i, p in enumerate(related_papers or [], start=1)
  185. ]
  186. await self._append_history(paper_id=paper_id, user_message=user_message, reply=reply)
  187. await self._update_memory(store=store, paper_id=paper_id, user_message=user_message, reply=reply)
  188. background_tasks.add_task(store.compress_working, scope="paper", paper_id=int(paper_id), min_entries=6)
  189. return {
  190. "reply": reply.strip(),
  191. "pdf_parsing": pdf_parsing,
  192. "related_papers": related_papers,
  193. "related_hints": related_hints,
  194. "kg_edges": [],
  195. }
  196. async def get_history(self, *, paper_id: int, limit: int) -> list[dict[str, Any]]:
  197. from .paper_reader_history import list_turns
  198. paper = await run_in_threadpool(self._db.get_paper_by_id, int(paper_id))
  199. if not paper:
  200. raise HTTPException(status_code=404, detail="文献不存在")
  201. return await run_in_threadpool(
  202. list_turns,
  203. self._db.db_path,
  204. paper_id=int(paper_id),
  205. limit=int(limit),
  206. )