reader_table_tool.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. """表格提取工具 —— PDF 表格检测、解析与上下文关联查询."""
  2. from __future__ import annotations
  3. import logging
  4. import re
  5. from typing import Any, Callable
  6. from hello_agents.tools.base import Tool, ToolParameter
  7. from hello_agents.tools.response import ToolResponse
  8. logger = logging.getLogger(__name__)
  9. class ReaderTableTool(Tool):
  10. """Extract a specific table from the current paper's PDF."""
  11. def __init__(self, *, get_snap: Callable[[], dict[str, Any]]) -> None:
  12. super().__init__(
  13. name="reader_pdf_table",
  14. description=(
  15. "获取当前论文 PDF 中的指定表格内容。当用户询问表格数据或论文提到 'Tab. 3'/'Table 4' 时调用。"
  16. "输入表号(如 '3'、'4')或关键词(如 'ImageNet'、'ablation'),返回对应表格的 Markdown 内容。"
  17. ),
  18. )
  19. self._get_snap = get_snap
  20. def get_parameters(self) -> list[ToolParameter]:
  21. return [
  22. ToolParameter(
  23. name="table_ref",
  24. type="string",
  25. description="表格编号(如 '3')或关键词(如 'ImageNet'、'ablation')",
  26. required=True,
  27. ),
  28. ]
  29. def run(self, parameters: dict[str, Any]) -> ToolResponse:
  30. ref = str(parameters.get("table_ref") or "").strip()
  31. if not ref:
  32. return ToolResponse.error("NO_REF", "请指定表格编号或关键词,如 table_ref='3'")
  33. try:
  34. snap = self._get_snap() or {}
  35. except Exception as exc:
  36. return ToolResponse.error("SNAP_FAILED", f"读取快照失败:{exc}")
  37. pdf_path = str(snap.get("_pdf_abspath") or "").strip()
  38. if not pdf_path:
  39. merged = str(snap.get("_pdf_merged_for_structure") or "").strip()
  40. if not merged or len(merged) < 200:
  41. return ToolResponse.success(
  42. text="当前文献无可用 PDF。请先确认论文已保存且 PDF 已下载。"
  43. )
  44. tables = self._extract_tables_from_text(merged)
  45. else:
  46. try:
  47. from ...services.reader.paper_reader_context import extract_pdf_tables_markdown
  48. tables_md = extract_pdf_tables_markdown(pdf_path)
  49. if tables_md:
  50. tables = self._parse_table_blocks(tables_md)
  51. else:
  52. merged = str(snap.get("_pdf_merged_for_structure") or "").strip()
  53. tables = self._extract_tables_from_text(merged) if merged else []
  54. except Exception:
  55. merged = str(snap.get("_pdf_merged_for_structure") or "").strip()
  56. tables = self._extract_tables_from_text(merged) if merged else []
  57. if not tables:
  58. return ToolResponse.success(
  59. text="未能从 PDF 中提取到表格。表格可能为图片格式或 PDF 文本提取不完整。"
  60. )
  61. matched = self._find_table(tables, ref)
  62. if not matched:
  63. available = [t.get("label", f"表{i+1}") for i, t in enumerate(tables[:8])]
  64. return ToolResponse.success(
  65. text=f"未找到匹配 '{ref}' 的表格。可用表格:{', '.join(available)}"
  66. )
  67. result = f"## {matched['label']}\n\n{matched['content']}"
  68. return ToolResponse.success(text=result)
  69. @staticmethod
  70. def _parse_table_blocks(md: str) -> list[dict[str, Any]]:
  71. tables: list[dict[str, Any]] = []
  72. blocks = re.split(r"\n(?=##|\|)", md)
  73. for i, block in enumerate(blocks):
  74. block = block.strip()
  75. if not block or "|" not in block:
  76. continue
  77. label = f"表{i + 1}"
  78. m = re.match(r"^##\s*(.*)", block)
  79. if m:
  80. label = m.group(1).strip()
  81. block = block[m.end():].strip()
  82. if block.startswith("|"):
  83. tables.append({"label": label, "content": block[:3000]})
  84. return tables
  85. @staticmethod
  86. def _extract_tables_from_text(text: str) -> list[dict[str, Any]]:
  87. """Extract Markdown-style table blocks from merged text."""
  88. tables: list[dict[str, Any]] = []
  89. # Find table-like patterns: lines starting with | that have multiple columns
  90. for m in re.finditer(
  91. r"(?:^|\n)((?:Table\s*\d+[^\n]*|Tab\.\s*\d+[^\n]*))?\s*\n?"
  92. r"((?:\|[^\n]+\|\n){2,})",
  93. text, re.MULTILINE,
  94. ):
  95. caption = (m.group(1) or "").strip()
  96. body = m.group(2).strip()
  97. if body.count("|") >= 3:
  98. label = caption if caption else f"表{len(tables) + 1}"
  99. tables.append({"label": label, "content": body[:3000]})
  100. return tables
  101. @staticmethod
  102. def _find_table(tables: list[dict[str, Any]], ref: str) -> dict[str, Any] | None:
  103. ref_lower = ref.strip().lower()
  104. # Exact number match: "3" → "Table 3", "Tab. 3", "表3"
  105. if ref_lower.isdigit():
  106. patterns = [
  107. rf"\b(?:table|tab\.?|表)\s*{ref_lower}\b",
  108. rf"^{ref_lower}[\.\)]",
  109. ]
  110. for pat in patterns:
  111. for t in tables:
  112. if re.search(pat, t["label"], re.I):
  113. return t
  114. for t in tables:
  115. if re.search(pat, t["content"], re.I):
  116. return t
  117. # Keyword match in label or first rows
  118. for t in tables:
  119. blob = (t["label"] + " " + t["content"][:500]).lower()
  120. if ref_lower in blob:
  121. return t
  122. return None