Skip to content
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 0.23.4

### Enhancements

- **Add `max_page` parameter to `chunk_by_title` for page-count-bounded chunking**: `chunk_by_title()` now accepts an optional `max_page: int` argument. When set, a chunk is closed and a new one started whenever an element's page number is more than `max_page - 1` pages past the page where the current chunk began. This gives callers a hard upper bound on how many pages a single chunk may span, independent of character or token limits. A `Title` element always resets the page counter (it already starts a new chunk via the existing boundary logic), so each section's page span is counted independently. The constraint is additive — it combines with all other `by_title` boundaries. Must be `>= 1`; raises `ValueError` otherwise.

## 0.23.3

### Fixes
Expand Down
90 changes: 90 additions & 0 deletions test_unstructured/chunking/test_title.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,81 @@ def test_chunk_by_title_groups_across_pages():
)


def test_chunk_by_title_respects_max_page():
"""A chunk is closed when its elements would span more than max_page pages."""
elements: list[Element] = [
Title("Section A", metadata=ElementMetadata(page_number=1)),
Text("Page one text.", metadata=ElementMetadata(page_number=1)),
Text("Page two text.", metadata=ElementMetadata(page_number=2)),
Text("Page three text.", metadata=ElementMetadata(page_number=3)),
Text("Page four text.", metadata=ElementMetadata(page_number=4)),
Text("Page five text.", metadata=ElementMetadata(page_number=5)),
]

chunks = chunk_by_title(elements, max_page=2, combine_text_under_n_chars=0)

# max_page=2: chunk 1 covers pages 1-2, page 3 triggers new chunk covering pages 3-4,
# page 5 triggers another new chunk.
assert len(chunks) == 3
assert chunks[0] == CompositeElement(
"Section A\n\nPage one text.\n\nPage two text."
)
assert chunks[1] == CompositeElement("Page three text.\n\nPage four text.")
assert chunks[2] == CompositeElement("Page five text.")


def test_chunk_by_title_max_page_resets_on_title():
"""Title boundaries reset the max_page page-counter so each section is counted independently."""
elements: list[Element] = [
Title("Section A", metadata=ElementMetadata(page_number=1)),
Text("Page one.", metadata=ElementMetadata(page_number=1)),
Title("Section B", metadata=ElementMetadata(page_number=2)),
Text("Page two.", metadata=ElementMetadata(page_number=2)),
Text("Page three.", metadata=ElementMetadata(page_number=3)),
Text("Page four.", metadata=ElementMetadata(page_number=4)),
]

chunks = chunk_by_title(elements, max_page=2, combine_text_under_n_chars=0)

# Section A: pages 1 only → 1 chunk.
# Section B: pages 2-3 (2 pages) → 1 chunk, then page 4 triggers a new chunk.
assert len(chunks) == 3
assert chunks[0] == CompositeElement("Section A\n\nPage one.")
assert chunks[1] == CompositeElement("Section B\n\nPage two.\n\nPage three.")
assert chunks[2] == CompositeElement("Page four.")


def test_chunk_by_title_max_page_1_breaks_on_every_page():
"""max_page=1 produces one chunk per page-group (similar to multipage_sections=False)."""
elements: list[Element] = [
Title("Intro", metadata=ElementMetadata(page_number=1)),
Text("Page one.", metadata=ElementMetadata(page_number=1)),
Text("Page two.", metadata=ElementMetadata(page_number=2)),
Text("Page three.", metadata=ElementMetadata(page_number=3)),
]

chunks = chunk_by_title(elements, max_page=1, combine_text_under_n_chars=0)

assert len(chunks) == 3
assert chunks[0] == CompositeElement("Intro\n\nPage one.")
assert chunks[1] == CompositeElement("Page two.")
assert chunks[2] == CompositeElement("Page three.")


def test_chunk_by_title_max_page_none_does_not_add_page_boundary():
"""Omitting max_page leaves all existing by-title behavior unchanged."""
elements: list[Element] = [
Title("Section", metadata=ElementMetadata(page_number=1)),
Text("Page one.", metadata=ElementMetadata(page_number=1)),
Text("Page five.", metadata=ElementMetadata(page_number=5)),
]

chunks = chunk_by_title(elements, combine_text_under_n_chars=0)

assert len(chunks) == 1
assert chunks[0] == CompositeElement("Section\n\nPage one.\n\nPage five.")


def test_add_chunking_strategy_on_partition_html():
filename = "example-docs/example-10k-1p.html"
chunk_elements = partition_html(filename, chunking_strategy="by_title")
Expand Down Expand Up @@ -701,6 +776,21 @@ def it_knows_whether_to_break_chunks_on_page_boundaries(
opts = _ByTitleChunkingOptions(multipage_sections=multipage_sections)
assert opts.multipage_sections is expected_value

@pytest.mark.parametrize(
("max_page", "expected_value"),
[(1, 1), (3, 3), (None, None)],
)
def it_accepts_valid_max_page_values(
self, max_page: Optional[int], expected_value: Optional[int]
):
opts = _ByTitleChunkingOptions(max_page=max_page)
assert opts.max_page == expected_value

@pytest.mark.parametrize("max_page", [0, -1, -10])
def it_rejects_max_page_less_than_one(self, max_page: int):
with pytest.raises(ValueError, match=f"'max_page' argument must be >= 1, got {max_page}"):
_ByTitleChunkingOptions.new(max_page=max_page)


# ================================================================================================
# TOKEN-BASED CHUNKING INTEGRATION TESTS
Expand Down
2 changes: 1 addition & 1 deletion unstructured/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.23.3" # pragma: no cover
__version__ = "0.23.4" # pragma: no cover
48 changes: 48 additions & 0 deletions unstructured/chunking/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1874,6 +1874,54 @@ def page_number_incremented(element: Element) -> bool:
return page_number_incremented


def is_on_page_exceeding_max(max_page: int) -> BoundaryPredicate:
"""Returns a predicate that fires when a chunk would span more than `max_page` pages.

The lifetime of the returned callable cannot extend beyond a single element-stream because it
stores current state (chunk-start page and current page) that is particular to that stream.

The returned predicate tracks the page where the current chunk began. It fires (returns True)
when the current element's page number is more than `max_page - 1` pages past the chunk-start
page, i.e. when adding the element would make the chunk span more than `max_page` pages.

When it fires, it resets the chunk-start page to the current element's page so that the next
chunk begins fresh from that page.

A `Title` element resets the chunk-start page without firing, because the `is_title` predicate
already handles the boundary there; this keeps the two predicates consistent.
"""
chunk_start_page: int = 1
current_page: int = 1
is_first: bool = True

def page_count_exceeded(element: Element) -> bool:
nonlocal chunk_start_page, current_page, is_first

page_number = element.metadata.page_number

if is_first:
current_page = page_number or 1
chunk_start_page = current_page
is_first = False
return False

if page_number is not None:
current_page = page_number

# A Title element resets the chunk-start page (is_title handles the actual boundary).
if isinstance(element, Title):
chunk_start_page = current_page
return False

if current_page - chunk_start_page >= max_page:
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
chunk_start_page = current_page
return True

return False

return page_count_exceeded


def is_title(element: Element) -> bool:
"""True when `element` is a `Title` element, False otherwise."""
return isinstance(element, Title)
18 changes: 18 additions & 0 deletions unstructured/chunking/title.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
PreChunkCombiner,
PreChunker,
is_on_next_page,
is_on_page_exceeding_max,
is_title,
)
from unstructured.documents.elements import Element
Expand All @@ -26,6 +27,7 @@ def chunk_by_title(
combine_text_under_n_chars: Optional[int] = None,
include_orig_elements: Optional[bool] = None,
max_characters: Optional[int] = None,
max_page: Optional[int] = None,
max_tokens: Optional[int] = None,
multipage_sections: Optional[bool] = None,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Custom agent: Flag AI Slop and Fabricated Changes

PR description claims multipage_sections is 'removed entirely' and 'passing it now raises TypeError', but the code adds it back as an optional parameter with a DeprecationWarning and backward-compatibility logic instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At unstructured/chunking/title.py, line 31:

<comment>PR description claims multipage_sections is 'removed entirely' and 'passing it now raises TypeError', but the code adds it back as an optional parameter with a DeprecationWarning and backward-compatibility logic instead.</comment>

<file context>
@@ -27,6 +28,7 @@ def chunk_by_title(
     max_characters: Optional[int] = None,
     max_page: Optional[int] = None,
     max_tokens: Optional[int] = None,
+    multipage_sections: Optional[bool] = None,
     new_after_n_chars: Optional[int] = None,
     new_after_n_tokens: Optional[int] = None,
</file context>

new_after_n_chars: Optional[int] = None,
Expand Down Expand Up @@ -61,6 +63,11 @@ def chunk_by_title(
max_characters
Chunks elements text and text_as_html (if present) into chunks of length
n characters (hard max). Mutually exclusive with `max_tokens`.
max_page
Maximum number of pages a single chunk may span. When set, a new chunk is started whenever
an element's page number is more than `max_page - 1` pages past the page where the current
chunk began. Elements without a page number are assumed to continue the current page.
Must be >= 1 when specified.
max_tokens
Chunks elements into chunks of n tokens (hard max). Requires `tokenizer` to be specified.
Mutually exclusive with `max_characters`.
Expand Down Expand Up @@ -102,6 +109,7 @@ def chunk_by_title(
combine_text_under_n_chars=combine_text_under_n_chars,
include_orig_elements=include_orig_elements,
max_characters=max_characters,
max_page=max_page,
max_tokens=max_tokens,
multipage_sections=multipage_sections,
new_after_n_chars=new_after_n_chars,
Expand Down Expand Up @@ -154,6 +162,8 @@ def iter_boundary_predicates() -> Iterator[BoundaryPredicate]:
yield is_title
if not self.multipage_sections:
yield is_on_next_page()
if self.max_page is not None:
yield is_on_page_exceeding_max(self.max_page)

return tuple(iter_boundary_predicates())

Expand All @@ -169,6 +179,11 @@ def combine_text_under_n_chars(self) -> int:
arg_value = self._kwargs.get("combine_text_under_n_chars")
return self.hard_max if arg_value is None else arg_value

@cached_property
def max_page(self) -> Optional[int]:
"""Maximum number of pages a single chunk may span, or None for no page-count limit."""
return self._kwargs.get("max_page")

@cached_property
def multipage_sections(self) -> bool:
"""When False, break pre-chunks on page-boundaries."""
Expand Down Expand Up @@ -197,3 +212,6 @@ def _validate(self) -> None:
f"'combine_text_under_n_chars' argument must not exceed `max_characters`"
f" value, got {self.combine_text_under_n_chars} > {self.hard_max}"
)

if self.max_page is not None and self.max_page < 1:
raise ValueError(f"'max_page' argument must be >= 1, got {self.max_page}")