Files

314 lines
10 KiB
Python
Raw Permalink Normal View History

2026-07-16 11:12:17 +08:00
"""Table helpers: normalization, header detection, Markdown export, cross-page merge."""
from __future__ import annotations
import re
from typing import Any
FOOTNOTE_ROW_RE = re.compile(r"^[\s*※①②③④⑤]*(?:注[::]?|备注[::]?|说明[::]?|Note[::]?)", re.I)
DATA_FIRST_CELL_RE = re.compile(r"^[a-z_][a-z0-9_.-]*$", re.I)
TABLE_TITLE_RE = re.compile(
r"^(?:表\s*\d+[::.]?|Table\s*\d+[::.]?|图\s*\d+[::.]?)?\s*.{2,80}$",
re.I,
)
KEYWORD_TERMS = (
"字段", "参数", "必填", "选填", "状态", "类型", "说明", "含义", "取值",
"field", "parameter", "required", "optional", "status", "description",
)
def normalize_cell(value: str | None) -> str:
"""Merge in-cell line breaks; escape pipe chars for Markdown tables."""
if not value:
return ""
text = str(value).replace("\r\n", "\n").replace("\r", "\n")
parts = [p.strip() for p in text.split("\n") if p.strip()]
merged = " ".join(parts) if parts else ""
return merged.replace("|", "\\|")
def normalize_rows(rows: list[list[str | None]]) -> list[list[str]]:
"""Pad columns and normalize every cell without losing row alignment."""
if not rows:
return []
cleaned = [[normalize_cell(c) for c in row] for row in rows]
col_count = max((len(r) for r in cleaned), default=0)
return [row + [""] * (col_count - len(row)) for row in cleaned]
def looks_like_table(rows: list[list[str | None]]) -> bool:
"""Return True only for rows that have a real table-like grid."""
if len(rows) < 2:
return False
cleaned = normalize_rows(rows)
col_count = max((len(r) for r in cleaned), default=0)
if col_count < 2:
return False
non_empty_cells = sum(1 for row in cleaned for cell in row if cell)
rows_with_two_cells = sum(1 for row in cleaned if sum(1 for cell in row if cell) >= 2)
return non_empty_cells >= 4 and rows_with_two_cells >= 2
DESCRIPTION_HINTS = ("必填", "选填", "格式要求", "required", "optional", "格式", "用于标识")
QA_HEADER_TERMS = ("query", "question", "用户输入", "reference_output", "answer", "标准答案", "session")
def _row_fill(row: list[str]) -> int:
return sum(1 for c in row if c)
def looks_like_column_header_row(row: list[str]) -> bool:
"""True when a row looks like short spreadsheet column names."""
filled = [c.strip() for c in row if c and c.strip()]
if len(filled) < 2:
return False
if any(len(c) > 40 for c in filled):
return False
identifier_like = sum(
1
for c in filled
if DATA_FIRST_CELL_RE.match(c) or re.match(r"^[a-z][a-z0-9_]*$", c, re.I)
)
return identifier_like >= max(2, (len(filled) + 1) // 2)
def looks_like_description_row(row: list[str]) -> bool:
"""True when a row is a template field-description line (not data/header)."""
filled = [c.strip() for c in row if c and c.strip()]
if not filled:
return False
if max(len(c) for c in filled) >= 48:
return True
return sum(1 for c in filled if any(h in c for h in DESCRIPTION_HINTS)) >= 2
def detect_header_row_count(rows: list[list[str]]) -> int:
"""Detect 1-2 header rows from content patterns."""
if len(rows) < 2:
return 1 if rows else 0
first_fill = _row_fill(rows[0])
second_fill = _row_fill(rows[1]) if len(rows) > 1 else 0
if first_fill < 2:
return 0
if len(rows) > 2 and second_fill >= 2:
first_short = all(len(c) <= 24 for c in rows[0] if c)
second_short = all(len(c) <= 24 for c in rows[1] if c)
second_is_data = bool(rows[1][0]) and DATA_FIRST_CELL_RE.match(rows[1][0])
third_data_like = _row_fill(rows[2]) >= max(1, first_fill - 1)
if first_short and second_short and third_data_like and not second_is_data:
return 2
return 1
def detect_spreadsheet_layout(rows: list[list[str]]) -> dict[str, int]:
"""
Detect spreadsheet preamble/header/data boundaries (1-based row numbers).
Common template: row 1 = field descriptions, row 2 = column names, row 3+ = data.
"""
normalized = normalize_rows(rows)
if not normalized:
return {
"preamble_rows": 0,
"header_rows": 1,
"header_row_start": 1,
"header_row_end": 1,
"data_start_row": 2,
}
preamble = 0
header_index = 0
if (
len(normalized) >= 3
and looks_like_description_row(normalized[0])
and looks_like_column_header_row(normalized[1])
):
preamble = 1
header_index = 1
header_rows = 1
else:
header_rows = detect_header_row_count(normalized)
header_index = preamble
header_end_index = header_index + header_rows - 1
data_start_index = header_end_index + 1
return {
"preamble_rows": preamble,
"header_rows": header_rows,
"header_row_start": header_index + 1,
"header_row_end": header_end_index + 1,
"data_start_row": data_start_index + 1,
}
def is_qa_style_table(rows: list[list[str]], layout: dict[str, int] | None = None) -> bool:
"""True for evaluation/Q&A sheets where each row should become one chunk."""
if not rows:
return False
layout = layout or detect_spreadsheet_layout(rows)
h_start = layout["header_row_start"] - 1
h_end = layout["header_row_end"]
header_text = " ".join(
(cell or "").lower() for row in rows[h_start:h_end] for cell in row if cell
)
return sum(1 for term in QA_HEADER_TERMS if term in header_text) >= 2
def split_body_and_footnotes(rows: list[list[str]], header_rows: int) -> tuple[list[list[str]], list[list[str]], str]:
"""Separate data rows from trailing footnote rows."""
if header_rows >= len(rows):
return [], [], ""
body = rows[header_rows:]
footnote_rows: list[list[str]] = []
while body:
first_cell = (body[-1][0] if body[-1] else "") or ""
joined = " ".join(c for c in body[-1] if c)
if FOOTNOTE_ROW_RE.match(first_cell) or FOOTNOTE_ROW_RE.match(joined):
footnote_rows.insert(0, body.pop())
elif len(joined) <= 80 and any(k in joined for k in ("注", "备注", "说明", "Note")):
footnote_rows.insert(0, body.pop())
else:
break
footnotes = " ".join(" ".join(c for c in row if c) for row in footnote_rows).strip()
return body, footnote_rows, footnotes
def header_signature(rows: list[list[str]], header_rows: int) -> tuple[tuple[str, ...], ...]:
if header_rows <= 0:
return ()
return tuple(tuple(row) for row in rows[:header_rows])
def rows_to_markdown(
rows: list[list[str | None]],
header_rows: int | None = None,
include_footnotes: str = "",
) -> str:
"""Render rows as a standard Markdown table."""
if not looks_like_table(rows):
return ""
normalized = normalize_rows(rows)
if header_rows is None:
header_rows = detect_header_row_count(normalized)
body_rows, _, inline_footnotes = split_body_and_footnotes(normalized, header_rows)
data_rows = normalized[:header_rows] + body_rows
if not data_rows:
return ""
col_count = max(len(r) for r in data_rows)
lines: list[str] = []
for i, row in enumerate(data_rows):
padded = row + [""] * (col_count - len(row))
lines.append("| " + " | ".join(padded) + " |")
if i == header_rows - 1:
lines.append("| " + " | ".join(["---"] * col_count) + " |")
md = "\n".join(lines)
footnotes = include_footnotes or inline_footnotes
if footnotes:
md += f"\n\n*{footnotes}*"
return md
def guess_table_title(text: str) -> str | None:
"""Guess table title from a short preceding line."""
cleaned = normalize_cell(text)
if not cleaned or len(cleaned) > 120:
return None
if TABLE_TITLE_RE.match(cleaned):
return cleaned
if cleaned.endswith("表") or cleaned.endswith("列表") or cleaned.endswith("说明"):
return cleaned
if re.match(r"^表\s*\d+", cleaned):
return cleaned
return None
def extract_table_keywords(*texts: str, limit: int = 20) -> list[str]:
"""Extract retrieval keywords from table title, headers and body."""
source = " ".join(t for t in texts if t)
words = re.findall(r"[A-Za-z][A-Za-z0-9_-]{2,}|[\u4e00-\u9fff]{2,}", source)
seen: set[str] = set()
result: list[str] = []
for word in words:
if word in seen:
continue
seen.add(word)
result.append(word)
if len(result) >= limit:
break
for term in KEYWORD_TERMS:
if term.lower() in source.lower() and term not in seen:
result.append(term)
seen.add(term)
if len(result) >= limit:
break
return result[:limit]
def build_table_embedding_text(
*,
chapter: str = "",
table_title: str = "",
markdown: str = "",
description: str = "",
footnotes: str = "",
keywords: list[str] | None = None,
ocr_text: str = "",
) -> str:
"""Compose embedding text: chapter + title + markdown + description + keywords."""
parts: list[str] = []
if chapter:
parts.append(f"章节标题:{chapter}")
if table_title:
parts.append(f"表格标题:{table_title}")
if markdown:
parts.append(markdown)
if description:
parts.append(f"表格说明:{description}")
if footnotes:
parts.append(f"脚注说明:{footnotes}")
if ocr_text:
parts.append(f"表格 OCR:{ocr_text}")
if keywords:
parts.append(f"关键词:{','.join(keywords)}")
return "\n".join(parts)
def table_meta_summary(meta: dict[str, Any]) -> dict[str, Any]:
"""Pick table-specific fields for chunk metadata."""
keys = (
"table_title",
"table_description",
"header_rows",
"header_signature",
"footnotes",
"keywords",
"chapter",
"pages",
"page",
"bbox",
"bboxes",
"crop_path",
"image_path",
"row_count",
"col_count",
"cross_page",
"table_source",
"preceding_text",
"following_text",
"nearest_heading",
"parent_heading",
)
return {k: meta[k] for k in keys if k in meta and meta[k] not in (None, "", [], {})}