Files
ragflow/test/unit_test/rag/flow/parser/test_spreadsheet_output.py
T

231 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#
# Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Spreadsheet parsing must hand the chunker typed, self-contained tables.
Regression guard for "TokenChunker shreds xlsx tables": the spreadsheet parser
used to emit one *text* item per row (or a single raw HTML string under
``output_format=html``), so the TokenChunker cut tables in the middle of
``<td>`` text and dropped the delimiter characters it cut on.
The parser now emits one ``doc_type_kwd="table"`` item per sheet chunk (Go's
``defaultTableChunkRows`` = 256), each carrying its own caption and header row,
and the TokenChunker keeps those items whole.
"""
import importlib
import io
import sys
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from openpyxl import Workbook
DEFAULT_SETUP = {
"parse_method": "deepdoc",
"flatten_media_to_text": False,
"output_format": "json",
"suffix": ["xls", "xlsx", "csv"],
}
@pytest.fixture
def flow_modules(monkeypatch):
"""Import the flow modules the way the sibling parser test does.
The component registry imports ``deepdoc.parser.pdf_parser`` first and
leaves it half-initialized, so importing ``rag.flow.parser.parser``
directly raises ``cannot import name 'PlainParser'``. Drop the stale
entries and re-import ``deepdoc.parser.pdf_parser`` first.
"""
for module_name in (
"rag.flow.parser.parser",
"rag.flow.chunker.token_chunker",
"deepdoc.parser.pdf_parser",
"deepdoc.parser",
"deepdoc",
):
monkeypatch.delitem(sys.modules, module_name, raising=False)
importlib.invalidate_caches()
importlib.import_module("deepdoc.parser.pdf_parser")
return SimpleNamespace(
parser=importlib.import_module("rag.flow.parser.parser"),
chunker=importlib.import_module("rag.flow.chunker.token_chunker"),
delim=importlib.import_module("rag.nlp.delim"),
)
def build_xlsx(sheets: dict[str, list[list]], pad_rows: int = 0) -> bytes:
"""Build an xlsx with one sheet per entry, padded with ``pad_rows`` data rows."""
wb = Workbook()
wb.remove(wb.active)
for name, rows in sheets.items():
ws = wb.create_sheet(name)
for row in rows:
ws.append(row)
for i in range(pad_rows):
ws.append([f"数据{i}", f"部门{i % 3}", f"备注{i};说明。"])
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue()
def parse(flow_modules, blob: bytes, name: str = "sheet.xlsx", **overrides) -> dict:
"""Run ``Parser._spreadsheet`` against a minimal process double."""
setup = {**DEFAULT_SETUP, **overrides}
process = SimpleNamespace(
_param=SimpleNamespace(setups={"spreadsheet": setup}),
_canvas=SimpleNamespace(_doc_id=None, _tenant_id="tenant", _language="English"),
callback=Mock(),
outputs={},
set_output=lambda k, v: process.outputs.__setitem__(k, v),
)
flow_modules.parser.Parser._spreadsheet(process, name, blob)
return process.outputs
def test_spreadsheet_default_output_format_is_json(flow_modules):
# The default must be the structured format; "html" flattens a table into a
# raw string the chunker can only cut on character boundaries.
assert flow_modules.parser.ParserParam().setups["spreadsheet"]["output_format"] == "json"
def test_spreadsheet_emits_one_typed_table_item_per_sheet(flow_modules):
blob = build_xlsx({"SheetA": [["姓名", "部门"], ["张三", "研发"]], "SheetB": [["产品", "数量"], ["A", 1]]})
items = parse(flow_modules, blob)["json"]
assert [i["doc_type_kwd"] for i in items] == ["table", "table"]
# Every sheet is emitted; the old html path silently kept only the first.
assert [i["positions"][0][0] for i in items] == [0, 1]
for item in items:
text = item["text"].strip()
assert text.startswith("<table>") and text.endswith("</table>")
assert "<caption>" in text
def test_spreadsheet_table_chunk_is_self_contained(flow_modules):
# A chunk is retrieved and sent to the LLM on its own, so it must carry the
# header row that gives the columns their meaning.
blob = build_xlsx({"Big": [["列1", "列2", "列3"]]}, pad_rows=600)
items = parse(flow_modules, blob)["json"]
for item in items:
assert item["text"].count("<th>") == 3
assert item["doc_type_kwd"] == "table"
def test_large_table_is_not_split(flow_modules):
"""A sheet is one table chunk, however many rows it has."""
rows = [["h"]] + [[str(i)] for i in range(2000)]
items = parse(flow_modules, build_xlsx({"Narrow": rows}))["json"]
assert len(items) == 1
# The chunk spans every data row.
assert items[0]["positions"] == [[0, 2, len(rows), 1, 1]]
def test_wide_table_is_not_split(flow_modules):
"""Even a wide table stays a single chunk.
Splitting it would cut inside ``<td>`` content or drop the delimiter
characters on the cut, which is the bug this parser output fixes.
"""
cols = 10
header = [f"列{c}" for c in range(cols)]
rows = [[f"单元格{r}-{c}描述文本。" for c in range(cols)] for r in range(300)]
items = parse(flow_modules, build_xlsx({"Wide": [header] + rows}))["json"]
assert len(items) == 1
assert items[0]["text"].count("<th>") == cols
# Punctuation inside cells must survive.
assert "。" in items[0]["text"]
def test_header_only_sheet_still_emits_a_table(flow_modules):
"""A template sheet with no data rows must not vanish.
``ExcelParser.html`` computes the chunk count from the data-row count, so a
sheet holding only its header row used to yield zero chunks and disappear
silently. Go emits a captioned header-only table in that case
(``office_table_render.go``, ``nData == 0``).
"""
blob = build_xlsx({"Template": [["姓名", "电话", "地址"]]})
items = parse(flow_modules, blob)["json"]
assert len(items) == 1
assert items[0]["doc_type_kwd"] == "table"
# Row 1 is the header row; there is no data row below it.
assert items[0]["positions"] == [[0, 1, 1, 1, 3]]
text = items[0]["text"].strip()
assert text.startswith("<table>") and text.endswith("</table>")
assert text.count("<th>") == 3
def test_header_only_sheet_is_kept_next_to_a_data_sheet(flow_modules):
blob = build_xlsx({"Data": [["a", "b"], ["1", "2"]], "Empty": [["x", "y", "z"]]})
items = parse(flow_modules, blob)["json"]
assert len(items) == 2
# Both sheets survive, the empty one last.
assert [i["positions"][0][0] for i in items] == [0, 1]
assert all(i["doc_type_kwd"] == "table" for i in items)
def test_spreadsheet_flatten_media_to_text_marks_tables_as_text(flow_modules):
blob = build_xlsx({"S": [["a", "b"], ["1", "2"]]})
items = parse(flow_modules, blob, flatten_media_to_text=True)["json"]
assert [i["doc_type_kwd"] for i in items] == ["text"]
@pytest.mark.parametrize(("suffix", "blob"), [("xlsx", None), ("csv", "姓名,部门\n张三,研发\n".encode())])
def test_spreadsheet_supported_suffixes_produce_tables(flow_modules, suffix, blob):
if blob is None:
blob = build_xlsx({"S": [["姓名", "部门"], ["张三", "研发"]]})
items = parse(flow_modules, blob, name=f"sheet.{suffix}")["json"]
assert items and items[0]["doc_type_kwd"] == "table"
async def test_token_chunker_keeps_spreadsheet_table_whole(flow_modules):
"""The reported bug: delimiter chunking must not cut inside a table."""
blob = build_xlsx(
{
"Sheet1": [
["问题", "回答"],
# Cells carrying the default delimiter chars and embedded newlines.
["碳排放纳入名录是否公布?", "需要公布。根据《管理办法》\n由省级主管部门确定。"],
["关停企业如何报告?", "向实际所在地报告;不能独立报告的随上级法人。"],
]
}
)
parsed = parse(flow_modules, blob)["json"]
param = flow_modules.chunker.TokenChunkerParam() # current upstream default delimiters
assert param.delimiters == list(flow_modules.delim.DEFAULT_DELIMITER)
chunker = flow_modules.chunker.TokenChunker.__new__(flow_modules.chunker.TokenChunker)
chunker._canvas = SimpleNamespace(_doc_id=None, _tenant_id="tenant")
chunker._param = param
chunker.callback = Mock()
await chunker._invoke(name="sheet.xlsx", output_format="json", json_result=parsed)
chunks = [c["text"] for c in chunker._param.outputs["chunks"]["value"]]
assert len(chunks) == 1
text = chunks[0].strip()
assert text.startswith("<table>") and text.endswith("</table>")
# No delimiter character may be eaten by the chunker.
assert "。" in text and "?" in text and ";" in text