paper_reader_context.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532
  1. """阅读上下文管理 —— 论文正文、对话历史与工具调用结果的上下文组装."""
  2. from __future__ import annotations
  3. import os
  4. import re
  5. import sqlite3
  6. import time
  7. from typing import Any
  8. def extract_pdf_text_full(abspath: str | None) -> str:
  9. if not abspath or not os.path.isfile(abspath):
  10. return ""
  11. best = ""
  12. # Priority 1: pymupdf4llm Markdown — preserves tables, headings, structure
  13. try:
  14. import pymupdf4llm
  15. best = (pymupdf4llm.to_markdown(abspath) or "").strip()
  16. except Exception:
  17. pass
  18. # Priority 2: fitz plain text — only as fallback if Markdown is too short
  19. try:
  20. import fitz
  21. doc = fitz.open(abspath)
  22. pages: list[str] = []
  23. for page in doc:
  24. t = page.get_text("text")
  25. if t:
  26. pages.append(t.strip())
  27. doc.close()
  28. fitz_text = "\n\n".join(pages).strip()
  29. # Prefer Markdown even if shorter (preserves tables), but fall back if
  30. # Markdown is clearly broken (< 30% of fitz length and < 500 chars)
  31. if not best or (len(best) < 500 and len(fitz_text) > len(best) * 3):
  32. best = fitz_text
  33. except Exception:
  34. pass
  35. if len(best) < 200:
  36. try:
  37. import fitz
  38. doc = fitz.open(abspath)
  39. blocks: list[str] = []
  40. for page in doc:
  41. for block in page.get_text("blocks") or []:
  42. if len(block) >= 5 and block[4].strip():
  43. blocks.append(str(block[4]).strip())
  44. doc.close()
  45. block_text = "\n".join(blocks).strip()
  46. if len(block_text) > len(best):
  47. best = block_text
  48. except Exception:
  49. pass
  50. return best
  51. def extract_pdf_tables_markdown(abspath: str | None) -> str:
  52. """Extract tables from PDF as Markdown. Uses fitz's built-in table detection first,
  53. then falls back to pymupdf4llm."""
  54. if not abspath or not os.path.isfile(abspath):
  55. return ""
  56. tables: list[str] = []
  57. # Method 1: fitz page.find_tables() — best for structured tables
  58. try:
  59. import fitz
  60. doc = fitz.open(abspath)
  61. for page in doc:
  62. try:
  63. tabs = page.find_tables()
  64. except Exception:
  65. tabs = None
  66. if tabs:
  67. for tab in tabs:
  68. try:
  69. md = tab.to_markdown()
  70. if md and "|" in str(md) and len(str(md)) > 20:
  71. tables.append(str(md).strip())
  72. except Exception:
  73. pass
  74. doc.close()
  75. except Exception:
  76. pass
  77. if not tables:
  78. # Method 2: pymupdf4llm Markdown — parses full doc
  79. try:
  80. import pymupdf4llm
  81. md = (pymupdf4llm.to_markdown(abspath) or "").strip()
  82. for m in re.finditer(r"(\|[^\n]+\|\n\|[-:| ]+\|\n(?:\|[^\n]+\|\n?)+)", md):
  83. tables.append(m.group(1).strip())
  84. except Exception:
  85. pass
  86. if not tables:
  87. # Method 3: fitz text blocks — last resort
  88. try:
  89. import fitz
  90. doc = fitz.open(abspath)
  91. for page in doc:
  92. blocks = page.get_text("dict", flags=fitz.TEXT_PRESERVE_WHITESPACE)["blocks"]
  93. for block in blocks:
  94. if block.get("type") != 0:
  95. continue
  96. lines = block.get("lines", [])
  97. if len(lines) < 2:
  98. continue
  99. spans_by_line = [[s["text"] for s in ln["spans"]] for ln in lines]
  100. # Tabular data typically has 3+ aligned columns
  101. ncols = min(len(spans) for spans in spans_by_line)
  102. if ncols < 3:
  103. continue
  104. # Build markdown table by columns
  105. cols = [[] for _ in range(ncols)]
  106. for spans in spans_by_line:
  107. for j in range(ncols):
  108. cols[j].append(spans[j].strip())
  109. md_rows = [" | ".join(cols[i]) for i in range(ncols)]
  110. # Reformat: each original row → one markdown row
  111. nrows = len(cols[0])
  112. result = [" | ".join(str(cols[c][r]) for c in range(ncols)) for r in range(nrows)]
  113. result.insert(1, " | ".join("---" for _ in range(ncols)))
  114. tables.append("\n".join(result))
  115. doc.close()
  116. except Exception:
  117. pass
  118. return "\n\n".join(tables) if tables else ""
  119. def preprocess_pdf_text_for_reference_blob(blob: str) -> str:
  120. try:
  121. import ftfy
  122. return ftfy.fix_text(blob or "")
  123. except ImportError:
  124. return (blob or "").replace("\r\n", "\n").replace("\r", "\n").replace("\f", "\n")
  125. def soft_unwrap_reference_section_newlines(blob: str) -> str:
  126. s = preprocess_pdf_text_for_reference_blob(blob or "")
  127. s = re.sub(r",\s*\n(?!\n)", ", ", s)
  128. s = re.sub(r"(?<=[,.])\s*\n(?!\s*\n)\s*(?=[A-Za-z0-9(\u4e00-\u9fff])", " ", s)
  129. s = re.sub(r"\n{4,}", "\n\n\n", s)
  130. s = re.sub(r"[ \t]{2,}", " ", s)
  131. return s.strip()
  132. def normalize_saved_reference_entry(text: str) -> str:
  133. s = (text or "").strip()
  134. if not s:
  135. return ""
  136. s = re.sub(r"([A-Za-z]{2,})-\s*\r?\n\s*([A-Za-z]{2,})", r"\1\2", s)
  137. s = re.sub(r"([A-Za-z]{2,})-\s{1,3}([A-Za-z]{2,})", r"\1\2", s)
  138. s = re.sub(r"[\s\u00a0\u2000-\u200b\u202f\u2060\ufeff]+", " ", s).strip()
  139. return s
  140. _REF_SECTION = re.compile(
  141. r"(?:^|\n)\s*(?:"
  142. r"References|REFERENCES|Bibliography|BIBLIOGRAPHY|"
  143. r"参考文献|引用文献|參考文獻"
  144. r")\s*\n",
  145. re.MULTILINE,
  146. )
  147. def extract_references_section_raw_from_pdf_text(pdf_text: str) -> str:
  148. t = (pdf_text or "").strip()
  149. if len(t) < 120:
  150. return ""
  151. m = _REF_SECTION.search(t)
  152. body = t[m.end():].strip() if m else t[-min(len(t), 48000):]
  153. body = soft_unwrap_reference_section_newlines(body)
  154. return (body or "").strip()
  155. _REF_FALLBACK_ENTRY_HEAD = re.compile(
  156. r"^(?:\[\d{1,3}\]\s*)?(?:[A-Z][a-zA-Z'\u2019\-]{1,42},\s+[A-Z.\-]|\d{1,3}\.\s+[A-Za-z0-9])"
  157. )
  158. _REF_FALLBACK_DOI_LINE = re.compile(r"^doi:\s*10\.\d", re.I)
  159. def reference_strings_for_resolve_fallback(section_raw: str, *, max_strings: int = 80) -> list[str]:
  160. if not (section_raw or "").strip():
  161. return []
  162. text = soft_unwrap_reference_section_newlines(section_raw)
  163. lines = [ln.strip() for ln in text.split("\n") if (ln or "").strip()]
  164. out: list[str] = []
  165. buf = ""
  166. for ln in lines:
  167. if re.match(r"^(figure|fig\.|table|tab\.|appendix|section)\b", ln, re.I):
  168. if buf:
  169. s = re.sub(r"\s+", " ", buf.strip())
  170. if len(s) >= 28:
  171. out.append(normalize_saved_reference_entry(s)[:520])
  172. buf = ""
  173. continue
  174. starts = bool(_REF_FALLBACK_ENTRY_HEAD.match(ln) or _REF_FALLBACK_DOI_LINE.match(ln))
  175. if starts and buf:
  176. s = re.sub(r"\s+", " ", buf.strip())
  177. if len(s) >= 28:
  178. out.append(normalize_saved_reference_entry(s)[:520])
  179. if len(out) >= max_strings:
  180. break
  181. buf = ln
  182. elif starts and not buf:
  183. buf = ln
  184. else:
  185. buf = (buf + " " + ln).strip() if buf else ln
  186. if buf and len(out) < max_strings:
  187. s = re.sub(r"\s+", " ", buf.strip())
  188. if len(s) >= 28:
  189. out.append(normalize_saved_reference_entry(s)[:520])
  190. return out[:max_strings]
  191. def _ensure_cache_table(conn: sqlite3.Connection) -> None:
  192. cur = conn.cursor()
  193. cur.execute(
  194. """
  195. CREATE TABLE IF NOT EXISTS paper_pdf_excerpt_cache (
  196. paper_id INTEGER PRIMARY KEY,
  197. pdf_abspath TEXT,
  198. pdf_mtime INTEGER,
  199. pdf_size INTEGER,
  200. excerpt TEXT,
  201. updated_at INTEGER
  202. )
  203. """
  204. )
  205. cur.execute("PRAGMA table_info(paper_pdf_excerpt_cache)")
  206. cols = [r[1] for r in cur.fetchall()]
  207. if "hit_count" not in cols:
  208. cur.execute("ALTER TABLE paper_pdf_excerpt_cache ADD COLUMN hit_count INTEGER DEFAULT 0")
  209. if "miss_count" not in cols:
  210. cur.execute("ALTER TABLE paper_pdf_excerpt_cache ADD COLUMN miss_count INTEGER DEFAULT 0")
  211. if "last_hit_at" not in cols:
  212. cur.execute("ALTER TABLE paper_pdf_excerpt_cache ADD COLUMN last_hit_at INTEGER")
  213. if "last_miss_at" not in cols:
  214. cur.execute("ALTER TABLE paper_pdf_excerpt_cache ADD COLUMN last_miss_at INTEGER")
  215. conn.commit()
  216. def _pdf_stat(abspath: str) -> tuple[int, int | None]:
  217. try:
  218. st = os.stat(abspath)
  219. return int(st.st_mtime), int(st.st_size)
  220. except Exception:
  221. return None
  222. def _cache_get(db_path: str, paper_id: int, pdf_abspath: str, max_age_days: int = 30) -> str | None:
  223. if not db_path or not pdf_abspath:
  224. return None
  225. st = _pdf_stat(pdf_abspath)
  226. if not st:
  227. return None
  228. mtime, size = st
  229. now = int(time.time())
  230. conn = None
  231. try:
  232. conn = sqlite3.connect(db_path)
  233. cur = conn.cursor()
  234. _ensure_cache_table(conn)
  235. cur.execute(
  236. "SELECT pdf_mtime,pdf_size,excerpt,updated_at FROM paper_pdf_excerpt_cache WHERE paper_id=?",
  237. (int(paper_id),),
  238. )
  239. row = cur.fetchone()
  240. if not row:
  241. cur.execute(
  242. "INSERT OR IGNORE INTO paper_pdf_excerpt_cache(paper_id,pdf_abspath,pdf_mtime,pdf_size,excerpt,updated_at,miss_count,last_miss_at) VALUES(?,?,?,?,?,?,?,?)",
  243. (int(paper_id), pdf_abspath, mtime, size, "", 0, 1, now),
  244. )
  245. conn.commit()
  246. return None
  247. ok = int(row[0] or 0) == mtime and int(row[1] or 0) == size
  248. ex = (row[2] or "").strip()
  249. updated_at = int(row[3] or 0)
  250. expired = bool(updated_at and max_age_days > 0 and (now - updated_at) > max_age_days * 86400)
  251. if ok and ex and (not expired):
  252. cur.execute(
  253. "UPDATE paper_pdf_excerpt_cache SET hit_count=hit_count+1,last_hit_at=? WHERE paper_id=?",
  254. (now, int(paper_id)),
  255. )
  256. conn.commit()
  257. return ex
  258. cur.execute(
  259. "UPDATE paper_pdf_excerpt_cache SET miss_count=miss_count+1,last_miss_at=? WHERE paper_id=?",
  260. (now, int(paper_id)),
  261. )
  262. conn.commit()
  263. return None
  264. except Exception:
  265. return None
  266. finally:
  267. if conn:
  268. conn.close()
  269. def _cache_set(db_path: str, paper_id: int, pdf_abspath: str, excerpt: str) -> None:
  270. if not db_path or not pdf_abspath:
  271. return
  272. st = _pdf_stat(pdf_abspath)
  273. if not st:
  274. return
  275. mtime, size = st
  276. now = int(time.time())
  277. conn = None
  278. try:
  279. conn = sqlite3.connect(db_path)
  280. cur = conn.cursor()
  281. _ensure_cache_table(conn)
  282. cur.execute(
  283. """
  284. INSERT INTO paper_pdf_excerpt_cache(paper_id,pdf_abspath,pdf_mtime,pdf_size,excerpt,updated_at)
  285. VALUES(?,?,?,?,?,?)
  286. ON CONFLICT(paper_id) DO UPDATE SET
  287. pdf_abspath=excluded.pdf_abspath,
  288. pdf_mtime=excluded.pdf_mtime,
  289. pdf_size=excluded.pdf_size,
  290. excerpt=excluded.excerpt,
  291. updated_at=excluded.updated_at
  292. """,
  293. (int(paper_id), pdf_abspath, mtime, size, excerpt or "", now),
  294. )
  295. conn.commit()
  296. except Exception:
  297. return
  298. finally:
  299. if conn:
  300. conn.close()
  301. def extract_pdf_text_full_cached(db_path: str, paper_id: int, abspath: str | None, ) -> tuple[str, bool]:
  302. if not abspath or not os.path.isfile(abspath):
  303. return "", False
  304. ex = _cache_get(db_path, int(paper_id), abspath, max_age_days=45)
  305. if ex is not None:
  306. return ex, True
  307. return "", False
  308. def _cache_delete(db_path: str, paper_id: int) -> None:
  309. if not db_path:
  310. return
  311. try:
  312. conn = sqlite3.connect(db_path)
  313. conn.execute("DELETE FROM paper_pdf_excerpt_cache WHERE paper_id=?", (int(paper_id),))
  314. conn.commit()
  315. conn.close()
  316. except Exception:
  317. pass
  318. def compute_and_cache_excerpt(db_path: str, paper_id: int, pdf_abspath: str) -> None:
  319. ex = extract_pdf_text_full(pdf_abspath)
  320. if ex.strip():
  321. _cache_set(db_path, int(paper_id), pdf_abspath, ex)
  322. else:
  323. _cache_delete(db_path, int(paper_id))
  324. def _ensure_reader_pdf_available(db: Any, paper: Any) -> str | None:
  325. """阅读页兜底:库内无 PDF 但有 arXiv/pdf_url 时,现取现存一份供上下文解析。"""
  326. pid = getattr(paper, "id", None)
  327. if pid is None:
  328. return None
  329. try:
  330. existing = db.get_library_pdf_abspath(int(pid))
  331. if existing:
  332. return existing
  333. except Exception:
  334. return None
  335. if not any((getattr(paper, "arxiv_id", None), getattr(paper, "pdf_url", None), getattr(paper, "source_url", None))):
  336. return None
  337. try:
  338. from ...core.paper_paths import LIBRARY_PDF_ROOT_DIR, library_pdf_relative_path
  339. from ...core.pdf_download import download_paper_pdf_to_path, resolve_paper_pdf_url
  340. from ...settings import get_settings
  341. relpath = library_pdf_relative_path(getattr(paper, "category", None), int(pid), getattr(paper, "title", None))
  342. data_root = os.path.dirname(os.path.abspath(getattr(db, "db_path", "")))
  343. dest = os.path.join(data_root, relpath)
  344. os.makedirs(os.path.join(data_root, LIBRARY_PDF_ROOT_DIR), exist_ok=True)
  345. os.makedirs(os.path.dirname(dest), exist_ok=True)
  346. mail = (getattr(get_settings(), "ncbi_email", "") or "").strip()
  347. if os.path.isfile(dest) and os.path.getsize(dest) >= 256:
  348. db.set_local_pdf_path(int(pid), relpath)
  349. return dest
  350. if resolve_paper_pdf_url(paper, email=mail) and download_paper_pdf_to_path(paper, dest, email=mail):
  351. db.set_local_pdf_path(int(pid), relpath)
  352. return dest
  353. except Exception:
  354. return None
  355. return None
  356. def build_reader_snap(paper: Any, *, pdf_text_for_references: str = "") -> dict[str, Any]:
  357. refs_raw = getattr(paper, "references", None) or []
  358. refs: list[str] = []
  359. for r in refs_raw[:220]:
  360. s = normalize_saved_reference_entry(str(r))
  361. if len(s) >= 6:
  362. refs.append(s)
  363. refs_source = "db"
  364. references_section_raw = ""
  365. if not refs and (pdf_text_for_references or "").strip():
  366. references_section_raw = extract_references_section_raw_from_pdf_text(pdf_text_for_references)
  367. refs_source = "pdf_section" if references_section_raw else "none"
  368. elif not refs:
  369. refs_source = "none"
  370. pid = getattr(paper, "id", None)
  371. out: dict[str, Any] = {
  372. "paper_id": int(pid) if pid is not None and int(pid) > 0 else None,
  373. "title": (getattr(paper, "title", None) or "").strip(),
  374. "doi": (getattr(paper, "doi", None) or "").strip(),
  375. "arxiv_id": (getattr(paper, "arxiv_id", None) or "").strip(),
  376. "abstract": (getattr(paper, "abstract", None) or "").strip(),
  377. "keywords": [str(x) for x in (getattr(paper, "keywords", None) or [])[:32] if str(x).strip()],
  378. "references": refs,
  379. "references_source": refs_source,
  380. "references_section_raw": references_section_raw,
  381. }
  382. ptf = (pdf_text_for_references or "").strip()
  383. if len(ptf) >= 200:
  384. out["_pdf_merged_for_structure"] = ptf
  385. return out
  386. def format_paper_reader_block(
  387. paper: Any,
  388. pdf_excerpt: str,
  389. *,
  390. references_section_raw: str = "",
  391. reader_artifact_block: str = "",
  392. ) -> str:
  393. authors = ", ".join((a.name or "").strip() for a in (paper.authors or []) if (a.name or "").strip())
  394. lines = [
  395. f"标题:{paper.title}",
  396. f"作者:{authors or '—'}",
  397. f"年份:{paper.year if paper.year is not None else '—'}",
  398. f"来源/期刊:{(paper.journal or '').strip() or '—'}",
  399. f"DOI:{(paper.doi or '').strip() or '—'}",
  400. f"领域分类:{getattr(paper, 'category', None) or '—'}",
  401. f"摘要:\n{(paper.abstract or '').strip() or '(无摘要)'}",
  402. ]
  403. kw = getattr(paper, "keywords", None) or []
  404. if kw:
  405. lines.append(f"关键词:{', '.join(str(x) for x in kw[:32])}")
  406. refs = getattr(paper, "references", None) or []
  407. if refs:
  408. lines.append("【参考文献条目(库内保存的 references 列表;阅读助手仅从下列字符串解析并检索,不自由主题泛搜)】")
  409. for i, r in enumerate(refs[:120], start=1):
  410. s = normalize_saved_reference_entry(str(r))
  411. if not s:
  412. continue
  413. lines.append(f" [{i}] {s[:420]}")
  414. elif (references_section_raw or "").strip():
  415. raw = (references_section_raw or "").strip()
  416. lines.append(
  417. "【参考文献区 PDF 原文摘录(未程序切条;可先调 reader_pdf_structure 得 JSON 与 entries,"
  418. "再对用户相关请求用 reader_paper_lookup 且 from_pdf_references_section=true)】"
  419. )
  420. lines.append(raw)
  421. else:
  422. lines.append(
  423. "【参考文献】库表未保存结构化 references,且当前未能从 PDF 摘录中定位到参考文献标题后的文本。"
  424. "可换带参考文献的数据源重新保存,或由用户粘贴英文题名 / DOI。"
  425. )
  426. if (reader_artifact_block or "").strip():
  427. lines.append((reader_artifact_block or "").strip())
  428. ex = (pdf_excerpt or "").strip()
  429. if ex:
  430. lines.append(
  431. "【PDF 正文(结构化 Markdown;## 标记为自动识别的章节标题;精确内容以 PDF 视图为准)】\n"
  432. + ex
  433. )
  434. return "\n".join(lines)
  435. def build_reader_context_for_paper(db: Any, paper_id: int) -> tuple[Any | None, str, str]:
  436. p = db.get_paper_by_id(int(paper_id))
  437. if not p:
  438. return None, "", ""
  439. pdf_path = _ensure_reader_pdf_available(db, p)
  440. excerpt, is_cached = extract_pdf_text_full_cached(getattr(db, "db_path", ""), int(paper_id), pdf_path)
  441. if pdf_path and not excerpt:
  442. excerpt = extract_pdf_text_full(pdf_path)
  443. if excerpt.strip():
  444. _cache_set(getattr(db, "db_path", ""), int(paper_id), pdf_path, excerpt)
  445. merged_for_refs = excerpt.strip()
  446. refs_raw = ""
  447. if not (getattr(p, "references", None) or []):
  448. refs_raw = extract_references_section_raw_from_pdf_text(merged_for_refs) if merged_for_refs else ""
  449. # DBLP 论文无摘要→Tavily 搜摘要+正文摘录(作为 PDF 替代)
  450. if not (p.abstract or "").strip() and not excerpt.strip() and p.title:
  451. try:
  452. from ...settings import get_settings as _gs
  453. _ak = getattr(_gs(), "tavily_api_key", "").strip()
  454. if _ak:
  455. import httpx
  456. _resp = httpx.post("https://api.tavily.com/search", json={
  457. "api_key": _ak, "query": f"{p.title} paper",
  458. "max_results": 5, "include_answer": True, "search_depth": "advanced"}, timeout=20.0)
  459. _resp.raise_for_status()
  460. _parts = []
  461. for _it in (_resp.json().get("results") or []):
  462. _c = _it.get("content", "")
  463. if _c and len(_c) > 80: _parts.append(_c)
  464. _full = "\n\n".join(_parts)[:4000]
  465. if _full:
  466. p.abstract = _full[:2000]
  467. excerpt = _full # 替代 PDF 正文
  468. db.update_paper(p.id, abstract=p.abstract)
  469. except Exception: pass
  470. reader_artifact_block = ""
  471. if merged_for_refs:
  472. try:
  473. from .paper_reader_artifact import ensure_reader_artifact, format_reader_artifact_block
  474. artifact = ensure_reader_artifact(
  475. getattr(db, "db_path", ""),
  476. int(paper_id),
  477. p,
  478. merged_for_refs,
  479. pdf_path,
  480. )
  481. reader_artifact_block = format_reader_artifact_block(artifact)
  482. except Exception:
  483. reader_artifact_block = ""
  484. block = format_paper_reader_block(
  485. p,
  486. excerpt,
  487. references_section_raw=refs_raw,
  488. reader_artifact_block=reader_artifact_block,
  489. )
  490. pdf_parsing = False # 后台异步解析,不阻塞用户
  491. return p, block, merged_for_refs, pdf_parsing