| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532 |
- """阅读上下文管理 —— 论文正文、对话历史与工具调用结果的上下文组装."""
- from __future__ import annotations
- import os
- import re
- import sqlite3
- import time
- from typing import Any
- def extract_pdf_text_full(abspath: str | None) -> str:
- if not abspath or not os.path.isfile(abspath):
- return ""
- best = ""
- # Priority 1: pymupdf4llm Markdown — preserves tables, headings, structure
- try:
- import pymupdf4llm
- best = (pymupdf4llm.to_markdown(abspath) or "").strip()
- except Exception:
- pass
- # Priority 2: fitz plain text — only as fallback if Markdown is too short
- try:
- import fitz
- doc = fitz.open(abspath)
- pages: list[str] = []
- for page in doc:
- t = page.get_text("text")
- if t:
- pages.append(t.strip())
- doc.close()
- fitz_text = "\n\n".join(pages).strip()
- # Prefer Markdown even if shorter (preserves tables), but fall back if
- # Markdown is clearly broken (< 30% of fitz length and < 500 chars)
- if not best or (len(best) < 500 and len(fitz_text) > len(best) * 3):
- best = fitz_text
- except Exception:
- pass
- if len(best) < 200:
- try:
- import fitz
- doc = fitz.open(abspath)
- blocks: list[str] = []
- for page in doc:
- for block in page.get_text("blocks") or []:
- if len(block) >= 5 and block[4].strip():
- blocks.append(str(block[4]).strip())
- doc.close()
- block_text = "\n".join(blocks).strip()
- if len(block_text) > len(best):
- best = block_text
- except Exception:
- pass
- return best
- def extract_pdf_tables_markdown(abspath: str | None) -> str:
- """Extract tables from PDF as Markdown. Uses fitz's built-in table detection first,
- then falls back to pymupdf4llm."""
- if not abspath or not os.path.isfile(abspath):
- return ""
- tables: list[str] = []
- # Method 1: fitz page.find_tables() — best for structured tables
- try:
- import fitz
- doc = fitz.open(abspath)
- for page in doc:
- try:
- tabs = page.find_tables()
- except Exception:
- tabs = None
- if tabs:
- for tab in tabs:
- try:
- md = tab.to_markdown()
- if md and "|" in str(md) and len(str(md)) > 20:
- tables.append(str(md).strip())
- except Exception:
- pass
- doc.close()
- except Exception:
- pass
- if not tables:
- # Method 2: pymupdf4llm Markdown — parses full doc
- try:
- import pymupdf4llm
- md = (pymupdf4llm.to_markdown(abspath) or "").strip()
- for m in re.finditer(r"(\|[^\n]+\|\n\|[-:| ]+\|\n(?:\|[^\n]+\|\n?)+)", md):
- tables.append(m.group(1).strip())
- except Exception:
- pass
- if not tables:
- # Method 3: fitz text blocks — last resort
- try:
- import fitz
- doc = fitz.open(abspath)
- for page in doc:
- blocks = page.get_text("dict", flags=fitz.TEXT_PRESERVE_WHITESPACE)["blocks"]
- for block in blocks:
- if block.get("type") != 0:
- continue
- lines = block.get("lines", [])
- if len(lines) < 2:
- continue
- spans_by_line = [[s["text"] for s in ln["spans"]] for ln in lines]
- # Tabular data typically has 3+ aligned columns
- ncols = min(len(spans) for spans in spans_by_line)
- if ncols < 3:
- continue
- # Build markdown table by columns
- cols = [[] for _ in range(ncols)]
- for spans in spans_by_line:
- for j in range(ncols):
- cols[j].append(spans[j].strip())
- md_rows = [" | ".join(cols[i]) for i in range(ncols)]
- # Reformat: each original row → one markdown row
- nrows = len(cols[0])
- result = [" | ".join(str(cols[c][r]) for c in range(ncols)) for r in range(nrows)]
- result.insert(1, " | ".join("---" for _ in range(ncols)))
- tables.append("\n".join(result))
- doc.close()
- except Exception:
- pass
- return "\n\n".join(tables) if tables else ""
- def preprocess_pdf_text_for_reference_blob(blob: str) -> str:
- try:
- import ftfy
- return ftfy.fix_text(blob or "")
- except ImportError:
- return (blob or "").replace("\r\n", "\n").replace("\r", "\n").replace("\f", "\n")
- def soft_unwrap_reference_section_newlines(blob: str) -> str:
- s = preprocess_pdf_text_for_reference_blob(blob or "")
- s = re.sub(r",\s*\n(?!\n)", ", ", s)
- s = re.sub(r"(?<=[,.])\s*\n(?!\s*\n)\s*(?=[A-Za-z0-9(\u4e00-\u9fff])", " ", s)
- s = re.sub(r"\n{4,}", "\n\n\n", s)
- s = re.sub(r"[ \t]{2,}", " ", s)
- return s.strip()
- def normalize_saved_reference_entry(text: str) -> str:
- s = (text or "").strip()
- if not s:
- return ""
- s = re.sub(r"([A-Za-z]{2,})-\s*\r?\n\s*([A-Za-z]{2,})", r"\1\2", s)
- s = re.sub(r"([A-Za-z]{2,})-\s{1,3}([A-Za-z]{2,})", r"\1\2", s)
- s = re.sub(r"[\s\u00a0\u2000-\u200b\u202f\u2060\ufeff]+", " ", s).strip()
- return s
- _REF_SECTION = re.compile(
- r"(?:^|\n)\s*(?:"
- r"References|REFERENCES|Bibliography|BIBLIOGRAPHY|"
- r"参考文献|引用文献|參考文獻"
- r")\s*\n",
- re.MULTILINE,
- )
- def extract_references_section_raw_from_pdf_text(pdf_text: str) -> str:
- t = (pdf_text or "").strip()
- if len(t) < 120:
- return ""
- m = _REF_SECTION.search(t)
- body = t[m.end():].strip() if m else t[-min(len(t), 48000):]
- body = soft_unwrap_reference_section_newlines(body)
- return (body or "").strip()
- _REF_FALLBACK_ENTRY_HEAD = re.compile(
- r"^(?:\[\d{1,3}\]\s*)?(?:[A-Z][a-zA-Z'\u2019\-]{1,42},\s+[A-Z.\-]|\d{1,3}\.\s+[A-Za-z0-9])"
- )
- _REF_FALLBACK_DOI_LINE = re.compile(r"^doi:\s*10\.\d", re.I)
- def reference_strings_for_resolve_fallback(section_raw: str, *, max_strings: int = 80) -> list[str]:
- if not (section_raw or "").strip():
- return []
- text = soft_unwrap_reference_section_newlines(section_raw)
- lines = [ln.strip() for ln in text.split("\n") if (ln or "").strip()]
- out: list[str] = []
- buf = ""
- for ln in lines:
- if re.match(r"^(figure|fig\.|table|tab\.|appendix|section)\b", ln, re.I):
- if buf:
- s = re.sub(r"\s+", " ", buf.strip())
- if len(s) >= 28:
- out.append(normalize_saved_reference_entry(s)[:520])
- buf = ""
- continue
- starts = bool(_REF_FALLBACK_ENTRY_HEAD.match(ln) or _REF_FALLBACK_DOI_LINE.match(ln))
- if starts and buf:
- s = re.sub(r"\s+", " ", buf.strip())
- if len(s) >= 28:
- out.append(normalize_saved_reference_entry(s)[:520])
- if len(out) >= max_strings:
- break
- buf = ln
- elif starts and not buf:
- buf = ln
- else:
- buf = (buf + " " + ln).strip() if buf else ln
- if buf and len(out) < max_strings:
- s = re.sub(r"\s+", " ", buf.strip())
- if len(s) >= 28:
- out.append(normalize_saved_reference_entry(s)[:520])
- return out[:max_strings]
- def _ensure_cache_table(conn: sqlite3.Connection) -> None:
- cur = conn.cursor()
- cur.execute(
- """
- CREATE TABLE IF NOT EXISTS paper_pdf_excerpt_cache (
- paper_id INTEGER PRIMARY KEY,
- pdf_abspath TEXT,
- pdf_mtime INTEGER,
- pdf_size INTEGER,
- excerpt TEXT,
- updated_at INTEGER
- )
- """
- )
- cur.execute("PRAGMA table_info(paper_pdf_excerpt_cache)")
- cols = [r[1] for r in cur.fetchall()]
- if "hit_count" not in cols:
- cur.execute("ALTER TABLE paper_pdf_excerpt_cache ADD COLUMN hit_count INTEGER DEFAULT 0")
- if "miss_count" not in cols:
- cur.execute("ALTER TABLE paper_pdf_excerpt_cache ADD COLUMN miss_count INTEGER DEFAULT 0")
- if "last_hit_at" not in cols:
- cur.execute("ALTER TABLE paper_pdf_excerpt_cache ADD COLUMN last_hit_at INTEGER")
- if "last_miss_at" not in cols:
- cur.execute("ALTER TABLE paper_pdf_excerpt_cache ADD COLUMN last_miss_at INTEGER")
- conn.commit()
- def _pdf_stat(abspath: str) -> tuple[int, int | None]:
- try:
- st = os.stat(abspath)
- return int(st.st_mtime), int(st.st_size)
- except Exception:
- return None
- def _cache_get(db_path: str, paper_id: int, pdf_abspath: str, max_age_days: int = 30) -> str | None:
- if not db_path or not pdf_abspath:
- return None
- st = _pdf_stat(pdf_abspath)
- if not st:
- return None
- mtime, size = st
- now = int(time.time())
- conn = None
- try:
- conn = sqlite3.connect(db_path)
- cur = conn.cursor()
- _ensure_cache_table(conn)
- cur.execute(
- "SELECT pdf_mtime,pdf_size,excerpt,updated_at FROM paper_pdf_excerpt_cache WHERE paper_id=?",
- (int(paper_id),),
- )
- row = cur.fetchone()
- if not row:
- cur.execute(
- "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(?,?,?,?,?,?,?,?)",
- (int(paper_id), pdf_abspath, mtime, size, "", 0, 1, now),
- )
- conn.commit()
- return None
- ok = int(row[0] or 0) == mtime and int(row[1] or 0) == size
- ex = (row[2] or "").strip()
- updated_at = int(row[3] or 0)
- expired = bool(updated_at and max_age_days > 0 and (now - updated_at) > max_age_days * 86400)
- if ok and ex and (not expired):
- cur.execute(
- "UPDATE paper_pdf_excerpt_cache SET hit_count=hit_count+1,last_hit_at=? WHERE paper_id=?",
- (now, int(paper_id)),
- )
- conn.commit()
- return ex
- cur.execute(
- "UPDATE paper_pdf_excerpt_cache SET miss_count=miss_count+1,last_miss_at=? WHERE paper_id=?",
- (now, int(paper_id)),
- )
- conn.commit()
- return None
- except Exception:
- return None
- finally:
- if conn:
- conn.close()
- def _cache_set(db_path: str, paper_id: int, pdf_abspath: str, excerpt: str) -> None:
- if not db_path or not pdf_abspath:
- return
- st = _pdf_stat(pdf_abspath)
- if not st:
- return
- mtime, size = st
- now = int(time.time())
- conn = None
- try:
- conn = sqlite3.connect(db_path)
- cur = conn.cursor()
- _ensure_cache_table(conn)
- cur.execute(
- """
- INSERT INTO paper_pdf_excerpt_cache(paper_id,pdf_abspath,pdf_mtime,pdf_size,excerpt,updated_at)
- VALUES(?,?,?,?,?,?)
- ON CONFLICT(paper_id) DO UPDATE SET
- pdf_abspath=excluded.pdf_abspath,
- pdf_mtime=excluded.pdf_mtime,
- pdf_size=excluded.pdf_size,
- excerpt=excluded.excerpt,
- updated_at=excluded.updated_at
- """,
- (int(paper_id), pdf_abspath, mtime, size, excerpt or "", now),
- )
- conn.commit()
- except Exception:
- return
- finally:
- if conn:
- conn.close()
- def extract_pdf_text_full_cached(db_path: str, paper_id: int, abspath: str | None, ) -> tuple[str, bool]:
- if not abspath or not os.path.isfile(abspath):
- return "", False
- ex = _cache_get(db_path, int(paper_id), abspath, max_age_days=45)
- if ex is not None:
- return ex, True
- return "", False
- def _cache_delete(db_path: str, paper_id: int) -> None:
- if not db_path:
- return
- try:
- conn = sqlite3.connect(db_path)
- conn.execute("DELETE FROM paper_pdf_excerpt_cache WHERE paper_id=?", (int(paper_id),))
- conn.commit()
- conn.close()
- except Exception:
- pass
- def compute_and_cache_excerpt(db_path: str, paper_id: int, pdf_abspath: str) -> None:
- ex = extract_pdf_text_full(pdf_abspath)
- if ex.strip():
- _cache_set(db_path, int(paper_id), pdf_abspath, ex)
- else:
- _cache_delete(db_path, int(paper_id))
- def _ensure_reader_pdf_available(db: Any, paper: Any) -> str | None:
- """阅读页兜底:库内无 PDF 但有 arXiv/pdf_url 时,现取现存一份供上下文解析。"""
- pid = getattr(paper, "id", None)
- if pid is None:
- return None
- try:
- existing = db.get_library_pdf_abspath(int(pid))
- if existing:
- return existing
- except Exception:
- return None
- if not any((getattr(paper, "arxiv_id", None), getattr(paper, "pdf_url", None), getattr(paper, "source_url", None))):
- return None
- try:
- from ...core.paper_paths import LIBRARY_PDF_ROOT_DIR, library_pdf_relative_path
- from ...core.pdf_download import download_paper_pdf_to_path, resolve_paper_pdf_url
- from ...settings import get_settings
- relpath = library_pdf_relative_path(getattr(paper, "category", None), int(pid), getattr(paper, "title", None))
- data_root = os.path.dirname(os.path.abspath(getattr(db, "db_path", "")))
- dest = os.path.join(data_root, relpath)
- os.makedirs(os.path.join(data_root, LIBRARY_PDF_ROOT_DIR), exist_ok=True)
- os.makedirs(os.path.dirname(dest), exist_ok=True)
- mail = (getattr(get_settings(), "ncbi_email", "") or "").strip()
- if os.path.isfile(dest) and os.path.getsize(dest) >= 256:
- db.set_local_pdf_path(int(pid), relpath)
- return dest
- if resolve_paper_pdf_url(paper, email=mail) and download_paper_pdf_to_path(paper, dest, email=mail):
- db.set_local_pdf_path(int(pid), relpath)
- return dest
- except Exception:
- return None
- return None
- def build_reader_snap(paper: Any, *, pdf_text_for_references: str = "") -> dict[str, Any]:
- refs_raw = getattr(paper, "references", None) or []
- refs: list[str] = []
- for r in refs_raw[:220]:
- s = normalize_saved_reference_entry(str(r))
- if len(s) >= 6:
- refs.append(s)
- refs_source = "db"
- references_section_raw = ""
- if not refs and (pdf_text_for_references or "").strip():
- references_section_raw = extract_references_section_raw_from_pdf_text(pdf_text_for_references)
- refs_source = "pdf_section" if references_section_raw else "none"
- elif not refs:
- refs_source = "none"
- pid = getattr(paper, "id", None)
- out: dict[str, Any] = {
- "paper_id": int(pid) if pid is not None and int(pid) > 0 else None,
- "title": (getattr(paper, "title", None) or "").strip(),
- "doi": (getattr(paper, "doi", None) or "").strip(),
- "arxiv_id": (getattr(paper, "arxiv_id", None) or "").strip(),
- "abstract": (getattr(paper, "abstract", None) or "").strip(),
- "keywords": [str(x) for x in (getattr(paper, "keywords", None) or [])[:32] if str(x).strip()],
- "references": refs,
- "references_source": refs_source,
- "references_section_raw": references_section_raw,
- }
- ptf = (pdf_text_for_references or "").strip()
- if len(ptf) >= 200:
- out["_pdf_merged_for_structure"] = ptf
- return out
- def format_paper_reader_block(
- paper: Any,
- pdf_excerpt: str,
- *,
- references_section_raw: str = "",
- reader_artifact_block: str = "",
- ) -> str:
- authors = ", ".join((a.name or "").strip() for a in (paper.authors or []) if (a.name or "").strip())
- lines = [
- f"标题:{paper.title}",
- f"作者:{authors or '—'}",
- f"年份:{paper.year if paper.year is not None else '—'}",
- f"来源/期刊:{(paper.journal or '').strip() or '—'}",
- f"DOI:{(paper.doi or '').strip() or '—'}",
- f"领域分类:{getattr(paper, 'category', None) or '—'}",
- f"摘要:\n{(paper.abstract or '').strip() or '(无摘要)'}",
- ]
- kw = getattr(paper, "keywords", None) or []
- if kw:
- lines.append(f"关键词:{', '.join(str(x) for x in kw[:32])}")
- refs = getattr(paper, "references", None) or []
- if refs:
- lines.append("【参考文献条目(库内保存的 references 列表;阅读助手仅从下列字符串解析并检索,不自由主题泛搜)】")
- for i, r in enumerate(refs[:120], start=1):
- s = normalize_saved_reference_entry(str(r))
- if not s:
- continue
- lines.append(f" [{i}] {s[:420]}")
- elif (references_section_raw or "").strip():
- raw = (references_section_raw or "").strip()
- lines.append(
- "【参考文献区 PDF 原文摘录(未程序切条;可先调 reader_pdf_structure 得 JSON 与 entries,"
- "再对用户相关请求用 reader_paper_lookup 且 from_pdf_references_section=true)】"
- )
- lines.append(raw)
- else:
- lines.append(
- "【参考文献】库表未保存结构化 references,且当前未能从 PDF 摘录中定位到参考文献标题后的文本。"
- "可换带参考文献的数据源重新保存,或由用户粘贴英文题名 / DOI。"
- )
- if (reader_artifact_block or "").strip():
- lines.append((reader_artifact_block or "").strip())
- ex = (pdf_excerpt or "").strip()
- if ex:
- lines.append(
- "【PDF 正文(结构化 Markdown;## 标记为自动识别的章节标题;精确内容以 PDF 视图为准)】\n"
- + ex
- )
- return "\n".join(lines)
- def build_reader_context_for_paper(db: Any, paper_id: int) -> tuple[Any | None, str, str]:
- p = db.get_paper_by_id(int(paper_id))
- if not p:
- return None, "", ""
- pdf_path = _ensure_reader_pdf_available(db, p)
- excerpt, is_cached = extract_pdf_text_full_cached(getattr(db, "db_path", ""), int(paper_id), pdf_path)
- if pdf_path and not excerpt:
- excerpt = extract_pdf_text_full(pdf_path)
- if excerpt.strip():
- _cache_set(getattr(db, "db_path", ""), int(paper_id), pdf_path, excerpt)
- merged_for_refs = excerpt.strip()
- refs_raw = ""
- if not (getattr(p, "references", None) or []):
- refs_raw = extract_references_section_raw_from_pdf_text(merged_for_refs) if merged_for_refs else ""
- # DBLP 论文无摘要→Tavily 搜摘要+正文摘录(作为 PDF 替代)
- if not (p.abstract or "").strip() and not excerpt.strip() and p.title:
- try:
- from ...settings import get_settings as _gs
- _ak = getattr(_gs(), "tavily_api_key", "").strip()
- if _ak:
- import httpx
- _resp = httpx.post("https://api.tavily.com/search", json={
- "api_key": _ak, "query": f"{p.title} paper",
- "max_results": 5, "include_answer": True, "search_depth": "advanced"}, timeout=20.0)
- _resp.raise_for_status()
- _parts = []
- for _it in (_resp.json().get("results") or []):
- _c = _it.get("content", "")
- if _c and len(_c) > 80: _parts.append(_c)
- _full = "\n\n".join(_parts)[:4000]
- if _full:
- p.abstract = _full[:2000]
- excerpt = _full # 替代 PDF 正文
- db.update_paper(p.id, abstract=p.abstract)
- except Exception: pass
- reader_artifact_block = ""
- if merged_for_refs:
- try:
- from .paper_reader_artifact import ensure_reader_artifact, format_reader_artifact_block
- artifact = ensure_reader_artifact(
- getattr(db, "db_path", ""),
- int(paper_id),
- p,
- merged_for_refs,
- pdf_path,
- )
- reader_artifact_block = format_reader_artifact_block(artifact)
- except Exception:
- reader_artifact_block = ""
- block = format_paper_reader_block(
- p,
- excerpt,
- references_section_raw=refs_raw,
- reader_artifact_block=reader_artifact_block,
- )
- pdf_parsing = False # 后台异步解析,不阻塞用户
- return p, block, merged_for_refs, pdf_parsing
|