461bf6fd40
CI / lint (3.11) (push) Blocked by required conditions
CI / lint (3.12) (push) Blocked by required conditions
CI / lint (3.13) (push) Blocked by required conditions
CI / shellcheck (push) Waiting to run
CI / shfmt (push) Waiting to run
CI / setup (3.11) (push) Waiting to run
CI / setup (3.12) (push) Waiting to run
CI / setup (3.13) (push) Waiting to run
CI / check-licenses (3.12) (push) Blocked by required conditions
CI / test_unit (3.11) (push) Blocked by required conditions
CI / test_unit (3.12) (push) Blocked by required conditions
CI / test_unit (3.13) (push) Blocked by required conditions
CI / test_unit_no_extras (3.11) (push) Blocked by required conditions
CI / test_unit_no_extras (3.12) (push) Blocked by required conditions
CI / test_json_to_html (3.12) (push) Blocked by required conditions
CI / test_unit_no_extras (3.13) (push) Blocked by required conditions
CI / test_unit_dependency_extras (csv, 3.11, --extra csv) (push) Blocked by required conditions
CI / test_unit_dependency_extras (csv, 3.12, --extra csv) (push) Blocked by required conditions
CI / test_unit_dependency_extras (csv, 3.13, --extra csv) (push) Blocked by required conditions
CI / test_unit_dependency_extras (docx, 3.11, --extra docx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (docx, 3.12, --extra docx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (docx, 3.13, --extra docx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (markdown, 3.11, --extra md) (push) Blocked by required conditions
CI / test_unit_dependency_extras (markdown, 3.12, --extra md) (push) Blocked by required conditions
CI / test_unit_dependency_extras (markdown, 3.13, --extra md) (push) Blocked by required conditions
CI / test_unit_dependency_extras (odt, 3.11, --extra odt) (push) Blocked by required conditions
CI / test_unit_dependency_extras (odt, 3.12, --extra odt) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pdf-image, 3.12, --extra pdf --extra image --extra paddleocr) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pdf-image, 3.13, --extra pdf --extra image --extra paddleocr) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pptx, 3.13, --extra pptx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (odt, 3.13, --extra odt) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pdf-image, 3.11, --extra pdf --extra image --extra paddleocr) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pptx, 3.11, --extra pptx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pptx, 3.12, --extra pptx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pypandoc, 3.11, --extra epub --extra org --extra rtf --extra rst) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pypandoc, 3.12, --extra epub --extra org --extra rtf --extra rst) (push) Blocked by required conditions
CI / test_unit_dependency_extras (pypandoc, 3.13, --extra epub --extra org --extra rtf --extra rst) (push) Blocked by required conditions
CI / test_unit_dependency_extras (xlsx, 3.11, --extra xlsx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (xlsx, 3.12, --extra xlsx) (push) Blocked by required conditions
CI / test_unit_dependency_extras (xlsx, 3.13, --extra xlsx) (push) Blocked by required conditions
CI / test_ingest_src (3.12) (push) Blocked by required conditions
CI / test_json_to_markdown (3.12) (push) Blocked by required conditions
CI / changelog (push) Waiting to run
CI / test_dockerfile (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Build And Push Docker Image / set-short-sha (push) Waiting to run
Build And Push Docker Image / build-images (linux/amd64, opensource-linux-8core) (push) Blocked by required conditions
Build And Push Docker Image / build-images (linux/arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
Build And Push Docker Image / publish-images (push) Blocked by required conditions
Partition Benchmark / setup (push) Waiting to run
Partition Benchmark / Measure and compare partition() runtime (push) Blocked by required conditions
358 lines
12 KiB
Python
358 lines
12 KiB
Python
# pyright: reportPrivateUsage=false
|
|
|
|
"""Regression and characterization tests for table isolation during chunking (issue #3921).
|
|
|
|
`Table` (and `TableChunk`, which subclasses `Table`) must never share a pre-chunk with unrelated
|
|
text elements, so downstream logic can emit standalone table chunks instead of `CompositeElement`
|
|
wrapping mixed content.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Callable
|
|
|
|
import pytest
|
|
|
|
from unstructured.chunking.base import (
|
|
ChunkingOptions,
|
|
PreChunk,
|
|
PreChunkBuilder,
|
|
PreChunkCombiner,
|
|
PreChunker,
|
|
)
|
|
from unstructured.chunking.basic import chunk_elements
|
|
from unstructured.chunking.title import chunk_by_title
|
|
from unstructured.documents.elements import (
|
|
CompositeElement,
|
|
ElementMetadata,
|
|
Table,
|
|
TableChunk,
|
|
Text,
|
|
Title,
|
|
)
|
|
|
|
|
|
class DescribeTableIsolationPreChunkBuilder:
|
|
"""`PreChunkBuilder` must keep every table-family element in its own pre-chunk."""
|
|
|
|
@pytest.mark.parametrize(
|
|
("preamble", "make_table"),
|
|
[
|
|
(Text("Short preamble."), lambda: Table("H\nC")),
|
|
(Text("Short preamble."), lambda: TableChunk(text="x", metadata=ElementMetadata())),
|
|
],
|
|
)
|
|
def it_refuses_to_append_a_table_after_any_other_element(
|
|
self, preamble: Text, make_table: Callable[[], Table]
|
|
):
|
|
builder = PreChunkBuilder(opts=ChunkingOptions(max_characters=500))
|
|
builder.add_element(preamble)
|
|
|
|
assert not builder.will_fit(make_table())
|
|
|
|
@pytest.mark.parametrize(
|
|
"trailing",
|
|
[
|
|
Text("Follow-up paragraph."),
|
|
Title("Next section"),
|
|
],
|
|
)
|
|
def it_refuses_to_append_a_non_table_after_a_table(self, trailing: Text | Title):
|
|
builder = PreChunkBuilder(opts=ChunkingOptions(max_characters=500))
|
|
builder.add_element(Table("Heading\nCell"))
|
|
|
|
assert not builder.will_fit(trailing)
|
|
|
|
def it_allows_only_a_table_when_the_builder_is_empty(self):
|
|
builder = PreChunkBuilder(opts=ChunkingOptions())
|
|
assert builder.will_fit(Table("Heading\nCell text"))
|
|
|
|
def it_allows_text_after_flush_even_if_previous_pre_chunk_was_a_table(self):
|
|
opts = ChunkingOptions(max_characters=200)
|
|
builder = PreChunkBuilder(opts=opts)
|
|
builder.add_element(Table("Heading\nCell text"))
|
|
list(builder.flush()) # clears builder state
|
|
|
|
assert builder.will_fit(Text("Fresh start after table pre-chunk."))
|
|
|
|
|
|
class DescribeTableIsolationPreChunkStream:
|
|
"""End-to-end pre-chunk segmentation from `PreChunker.iter_pre_chunks`."""
|
|
|
|
def it_emits_a_table_only_pre_chunk_between_text_blocks(self):
|
|
elements = [
|
|
Title("Section A"),
|
|
Text("Narrative before the table."),
|
|
Table("Col1\nCell A"),
|
|
Text("Narrative after the table."),
|
|
]
|
|
# -- `new_after_n_chars=0` forces one element per pre-chunk (deterministic layout) --
|
|
opts = ChunkingOptions(max_characters=500, new_after_n_chars=0)
|
|
|
|
pre_chunks = list(PreChunker.iter_pre_chunks(elements, opts=opts))
|
|
|
|
assert len(pre_chunks) == 4
|
|
assert pre_chunks[0]._elements == [Title("Section A")]
|
|
assert pre_chunks[1]._elements == [Text("Narrative before the table.")]
|
|
assert pre_chunks[2]._elements == [Table("Col1\nCell A")]
|
|
assert pre_chunks[3]._elements == [Text("Narrative after the table.")]
|
|
|
|
def it_emits_one_pre_chunk_per_table_when_multiple_tables_are_adjacent(self):
|
|
elements = [
|
|
Table("T1\nA"),
|
|
Table("T2\nB"),
|
|
Text("Closing text."),
|
|
]
|
|
opts = ChunkingOptions(max_characters=500)
|
|
|
|
pre_chunks = list(PreChunker.iter_pre_chunks(elements, opts=opts))
|
|
|
|
assert len(pre_chunks) == 3
|
|
assert pre_chunks[0]._elements == [Table("T1\nA")]
|
|
assert pre_chunks[1]._elements == [Table("T2\nB")]
|
|
assert pre_chunks[2]._elements == [Text("Closing text.")]
|
|
|
|
|
|
class DescribeTableIsolationPreChunkCombiner:
|
|
"""`PreChunkCombiner` must not stitch table pre-chunks onto text neighbors."""
|
|
|
|
def it_keeps_a_table_pre_chunk_separate_when_combining_is_enabled(self):
|
|
opts = ChunkingOptions(max_characters=500, combine_text_under_n_chars=500)
|
|
stream = [
|
|
PreChunk([Text("Hello world.")], overlap_prefix="", opts=opts),
|
|
PreChunk([Table("H\nC")], overlap_prefix="", opts=opts),
|
|
PreChunk([Text("Goodbye world.")], overlap_prefix="", opts=opts),
|
|
]
|
|
|
|
combined = list(PreChunkCombiner(stream, opts=opts).iter_combined_pre_chunks())
|
|
|
|
assert len(combined) == 3
|
|
assert combined[0]._elements == [Text("Hello world.")]
|
|
assert combined[1]._elements == [Table("H\nC")]
|
|
assert combined[2]._elements == [Text("Goodbye world.")]
|
|
|
|
|
|
class DescribeTableIsolationOrderingGuarantees:
|
|
"""Invariants that should hold for any future refactor of table isolation."""
|
|
|
|
def it_preserves_global_element_order_in_pre_chunks(self):
|
|
elements = [
|
|
Text("alpha"),
|
|
Table("T\n1"),
|
|
Text("beta"),
|
|
Table("T\n2"),
|
|
Title("gamma"),
|
|
]
|
|
opts = ChunkingOptions(max_characters=500, new_after_n_chars=0)
|
|
flat = [e for pc in PreChunker.iter_pre_chunks(elements, opts=opts) for e in pc._elements]
|
|
|
|
assert flat == elements
|
|
|
|
def it_preserves_global_element_order_in_chunk_elements_output(self):
|
|
elements = [
|
|
Title("Intro"),
|
|
Text("Body before."),
|
|
Table("K\nV"),
|
|
Text("Body after."),
|
|
]
|
|
chunks = chunk_elements(elements, max_characters=500, new_after_n_chars=0)
|
|
# -- flatten chunk element categories in stream order (table chunks are atomic) --
|
|
categories = [c.category for c in chunks]
|
|
|
|
assert categories == [
|
|
"CompositeElement",
|
|
"CompositeElement",
|
|
"Table",
|
|
"CompositeElement",
|
|
]
|
|
|
|
|
|
class DescribeTableIsolationChunkElements:
|
|
"""`chunk_elements` (basic strategy) should emit `Table`/`TableChunk`, not mixed composites."""
|
|
|
|
def it_does_not_wrap_a_table_and_surrounding_text_in_one_composite_element(self):
|
|
elements = [
|
|
Title("Report"),
|
|
Text("Short intro."),
|
|
Table("Key\nValue"),
|
|
Text("Short outro."),
|
|
]
|
|
chunks = chunk_elements(
|
|
elements,
|
|
max_characters=500,
|
|
new_after_n_chars=0,
|
|
)
|
|
|
|
assert len(chunks) == 4
|
|
assert isinstance(chunks[0], CompositeElement)
|
|
assert isinstance(chunks[1], CompositeElement)
|
|
assert isinstance(chunks[2], Table)
|
|
assert isinstance(chunks[3], CompositeElement)
|
|
assert "Key" in chunks[2].text or "Value" in chunks[2].text
|
|
|
|
def it_yields_distinct_chunks_for_two_tables_in_a_row(self):
|
|
elements = [
|
|
Table("T1\nA"),
|
|
Table("T2\nB"),
|
|
]
|
|
chunks = chunk_elements(elements, max_characters=500)
|
|
|
|
assert len(chunks) == 2
|
|
assert all(isinstance(c, Table) for c in chunks)
|
|
|
|
def it_still_isolates_a_table_even_when_the_window_is_very_large(self):
|
|
"""Regression: isolation is a semantic rule, not a size heuristic."""
|
|
elements = [
|
|
Text("x"),
|
|
Table("tiny"),
|
|
Text("y"),
|
|
]
|
|
chunks = chunk_elements(elements, max_characters=50_000, new_after_n_chars=10_000)
|
|
|
|
table_chunks = [c for c in chunks if isinstance(c, Table)]
|
|
composite_chunks = [c for c in chunks if isinstance(c, CompositeElement)]
|
|
|
|
assert len(table_chunks) == 1
|
|
assert len(composite_chunks) == 2
|
|
assert "tiny" in table_chunks[0].text
|
|
|
|
def it_never_produces_a_composite_element_that_lists_a_table_in_orig_elements(
|
|
self,
|
|
):
|
|
"""`CompositeElement` chunks come from `_Chunker`, not `_TableChunker`."""
|
|
elements = [
|
|
Text("preamble"),
|
|
Table("H\nC"),
|
|
Text("post"),
|
|
]
|
|
chunks = chunk_elements(
|
|
elements,
|
|
max_characters=400,
|
|
new_after_n_chars=0,
|
|
include_orig_elements=True,
|
|
)
|
|
|
|
composites = [c for c in chunks if isinstance(c, CompositeElement)]
|
|
for comp in composites:
|
|
orig = comp.metadata.orig_elements or []
|
|
assert not any(isinstance(e, Table) for e in orig)
|
|
|
|
|
|
class DescribeTableIsolationDisabled:
|
|
"""When `isolate_table=False`, the pre-#4307 behavior is restored."""
|
|
|
|
def it_lets_a_table_share_a_pre_chunk_with_adjacent_text_when_disabled(self):
|
|
opts = ChunkingOptions(max_characters=500, isolate_table=False)
|
|
builder = PreChunkBuilder(opts=opts)
|
|
builder.add_element(Text("Short preamble."))
|
|
|
|
assert builder.will_fit(Table("Heading\nCell text"))
|
|
|
|
def it_lets_text_follow_a_table_in_the_same_pre_chunk_when_disabled(self):
|
|
opts = ChunkingOptions(max_characters=500, isolate_table=False)
|
|
builder = PreChunkBuilder(opts=opts)
|
|
builder.add_element(Table("Heading\nCell text"))
|
|
|
|
assert builder.will_fit(Text("Follow-up paragraph."))
|
|
|
|
def it_combines_a_table_pre_chunk_with_text_neighbors_when_disabled(self):
|
|
opts = ChunkingOptions(
|
|
max_characters=500, combine_text_under_n_chars=500, isolate_table=False
|
|
)
|
|
stream = [
|
|
PreChunk([Text("Hello world.")], overlap_prefix="", opts=opts),
|
|
PreChunk([Table("H\nC")], overlap_prefix="", opts=opts),
|
|
PreChunk([Text("Goodbye world.")], overlap_prefix="", opts=opts),
|
|
]
|
|
|
|
combined = list(PreChunkCombiner(stream, opts=opts).iter_combined_pre_chunks())
|
|
|
|
assert len(combined) == 1
|
|
assert combined[0]._elements == [
|
|
Text("Hello world."),
|
|
Table("H\nC"),
|
|
Text("Goodbye world."),
|
|
]
|
|
|
|
def it_wraps_a_table_into_a_composite_element_with_neighbors_when_disabled(self):
|
|
"""End-to-end: opting out collapses tiny table + text into one CompositeElement."""
|
|
elements = [
|
|
Text("preamble"),
|
|
Table("H\nC"),
|
|
Text("post"),
|
|
]
|
|
chunks = chunk_elements(
|
|
elements,
|
|
max_characters=500,
|
|
isolate_table=False,
|
|
)
|
|
|
|
assert len(chunks) == 1
|
|
assert isinstance(chunks[0], CompositeElement)
|
|
text = chunks[0].text
|
|
assert "preamble" in text
|
|
assert "post" in text
|
|
|
|
|
|
class DescribeTableIsolationOverlapAll:
|
|
"""With overlap_all=True, overlap must not cross table / narrative boundaries."""
|
|
|
|
def it_does_not_prefix_table_chunk_with_prior_text_overlap(self):
|
|
"""Regression: pre-chunk overlap_tail must not become table's overlap_prefix."""
|
|
elements = [Text("Alpha beta gamma delta."), Table("H\nC")]
|
|
chunks = chunk_elements(
|
|
elements,
|
|
max_characters=500,
|
|
new_after_n_chars=0,
|
|
overlap=5,
|
|
overlap_all=True,
|
|
)
|
|
|
|
table_chunks = [c for c in chunks if isinstance(c, Table)]
|
|
assert len(table_chunks) == 1
|
|
t = table_chunks[0].text or ""
|
|
assert "Alpha" not in t
|
|
assert "elta" not in t # tail of "delta" leaked in buggy overlap
|
|
|
|
def it_does_not_prefix_text_after_table_with_table_overlap(self):
|
|
elements = [Table("H\nC"), Text("Omega sigma tau upsilon.")]
|
|
chunks = chunk_elements(
|
|
elements,
|
|
max_characters=500,
|
|
new_after_n_chars=0,
|
|
overlap=5,
|
|
overlap_all=True,
|
|
)
|
|
|
|
composites = [c for c in chunks if isinstance(c, CompositeElement)]
|
|
assert len(composites) == 1
|
|
assert composites[0].text.startswith("Omega")
|
|
assert "H" not in composites[0].text[:20]
|
|
|
|
def it_chunk_by_title_respects_same_overlap_boundaries(self):
|
|
elements = [
|
|
Title("Section"),
|
|
Text("Alpha beta gamma delta."),
|
|
Table("H\nC"),
|
|
Text("Omega sigma tau upsilon."),
|
|
]
|
|
chunks = chunk_by_title(
|
|
elements,
|
|
max_characters=500,
|
|
new_after_n_chars=0,
|
|
overlap=5,
|
|
overlap_all=True,
|
|
combine_text_under_n_chars=0,
|
|
)
|
|
|
|
table_chunks = [c for c in chunks if isinstance(c, Table)]
|
|
assert len(table_chunks) == 1
|
|
assert "Alpha" not in (table_chunks[0].text or "")
|
|
assert "elta" not in (table_chunks[0].text or "")
|
|
|
|
omega_composites = [
|
|
c for c in chunks if isinstance(c, CompositeElement) and "Omega" in (c.text or "")
|
|
]
|
|
assert len(omega_composites) == 1
|
|
assert omega_composites[0].text.startswith("Omega")
|