chore: import upstream snapshot with attribution
CI / lint (3.11) (push) Has been cancelled
CI / lint (3.12) (push) Has been cancelled
CI / lint (3.13) (push) Has been cancelled
CI / shellcheck (push) Has been cancelled
CI / shfmt (push) Has been cancelled
CI / setup (3.11) (push) Has been cancelled
CI / setup (3.12) (push) Has been cancelled
CI / setup (3.13) (push) Has been cancelled
CI / check-licenses (3.12) (push) Has been cancelled
CI / test_unit (3.11) (push) Has been cancelled
CI / test_unit (3.12) (push) Has been cancelled
CI / test_unit (3.13) (push) Has been cancelled
CI / test_unit_no_extras (3.11) (push) Has been cancelled
CI / test_unit_no_extras (3.12) (push) Has been cancelled
CI / test_json_to_html (3.12) (push) Has been cancelled
CI / test_unit_no_extras (3.13) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.12, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.11, --extra xlsx) (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.12, --extra xlsx) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.11, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.13, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.11, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.12, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.13, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.11, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.12, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.13, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.11, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.12, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.13, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.11, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.12, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.13, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.11, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.12, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.13, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.11, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.12, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.13, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
Build And Push Docker Image / set-short-sha (push) Has been cancelled
Partition Benchmark / setup (push) Has been cancelled
Partition Benchmark / Measure and compare partition() runtime (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.13, --extra xlsx) (push) Has been cancelled
CI / test_ingest_src (3.12) (push) Has been cancelled
CI / test_json_to_markdown (3.12) (push) Has been cancelled
CI / changelog (push) Has been cancelled
CI / test_dockerfile (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Build And Push Docker Image / build-images (linux/amd64, opensource-linux-8core) (push) Has been cancelled
Build And Push Docker Image / build-images (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build And Push Docker Image / publish-images (push) Has been cancelled
CI / lint (3.11) (push) Has been cancelled
CI / lint (3.12) (push) Has been cancelled
CI / lint (3.13) (push) Has been cancelled
CI / shellcheck (push) Has been cancelled
CI / shfmt (push) Has been cancelled
CI / setup (3.11) (push) Has been cancelled
CI / setup (3.12) (push) Has been cancelled
CI / setup (3.13) (push) Has been cancelled
CI / check-licenses (3.12) (push) Has been cancelled
CI / test_unit (3.11) (push) Has been cancelled
CI / test_unit (3.12) (push) Has been cancelled
CI / test_unit (3.13) (push) Has been cancelled
CI / test_unit_no_extras (3.11) (push) Has been cancelled
CI / test_unit_no_extras (3.12) (push) Has been cancelled
CI / test_json_to_html (3.12) (push) Has been cancelled
CI / test_unit_no_extras (3.13) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.12, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.11, --extra xlsx) (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.12, --extra xlsx) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.11, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.13, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.11, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.12, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.13, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.11, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.12, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.13, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.11, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.12, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.13, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.11, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.12, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.13, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.11, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.12, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.13, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.11, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.12, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.13, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
Build And Push Docker Image / set-short-sha (push) Has been cancelled
Partition Benchmark / setup (push) Has been cancelled
Partition Benchmark / Measure and compare partition() runtime (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.13, --extra xlsx) (push) Has been cancelled
CI / test_ingest_src (3.12) (push) Has been cancelled
CI / test_json_to_markdown (3.12) (push) Has been cancelled
CI / changelog (push) Has been cancelled
CI / test_dockerfile (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Build And Push Docker Image / build-images (linux/amd64, opensource-linux-8core) (push) Has been cancelled
Build And Push Docker Image / build-images (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build And Push Docker Image / publish-images (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
"""Handles dispatch of elements to a chunking-strategy by name.
|
||||
|
||||
Also provides the `@add_chunking_strategy` decorator which is the chief current user of "by-name"
|
||||
chunking dispatch.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import dataclasses as dc
|
||||
import functools
|
||||
import inspect
|
||||
from functools import cached_property
|
||||
from typing import Any, Callable, Iterable, Optional, Protocol
|
||||
|
||||
from lxml.etree import ParserError, tostring
|
||||
from lxml.html import fragment_fromstring
|
||||
from typing_extensions import ParamSpec
|
||||
|
||||
from unstructured.chunking.basic import chunk_elements
|
||||
from unstructured.chunking.title import chunk_by_title
|
||||
from unstructured.documents.elements import Element, Table, TableChunk
|
||||
from unstructured.utils import get_call_args_applying_defaults
|
||||
|
||||
_P = ParamSpec("_P")
|
||||
|
||||
|
||||
class Chunker(Protocol):
|
||||
"""Abstract interface for chunking functions."""
|
||||
|
||||
def __call__(
|
||||
self, elements: Iterable[Element], *, max_characters: Optional[int]
|
||||
) -> list[Element]:
|
||||
"""A chunking function must have this signature.
|
||||
|
||||
In particular it must minimally have an `elements` parameter and all chunkers will have a
|
||||
`max_characters` parameter (doesn't need to follow `elements` directly). All others can
|
||||
vary by chunker.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
def add_chunking_strategy(func: Callable[_P, list[Element]]) -> Callable[_P, list[Element]]:
|
||||
"""Decorator for chunking text.
|
||||
|
||||
Chunks the element sequence produced by the partitioner it decorates when a `chunking_strategy`
|
||||
argument is present in the partitioner call and it names an available chunking strategy.
|
||||
"""
|
||||
# -- Patch the docstring of the decorated function to add chunking strategy and
|
||||
# -- chunking-related argument documentation. This only applies when `chunking_strategy`
|
||||
# -- is an explicit argument of the decorated function and "chunking_strategy" is not
|
||||
# -- already mentioned in the docstring.
|
||||
if func.__doc__ and (
|
||||
"chunking_strategy" in func.__code__.co_varnames and "chunking_strategy" not in func.__doc__
|
||||
):
|
||||
func.__doc__ += (
|
||||
"\nchunking_strategy"
|
||||
+ "\n\tStrategy used for chunking text into larger or smaller elements."
|
||||
+ "\n\tDefaults to `None` with optional arg of 'basic' or 'by_title'."
|
||||
+ "\n\tAdditional Parameters:"
|
||||
+ "\n\t\tmultipage_sections"
|
||||
+ "\n\t\t\tIf True, sections can span multiple pages. Defaults to True."
|
||||
+ "\n\t\tcombine_text_under_n_chars"
|
||||
+ "\n\t\t\tCombines elements (for example a series of titles) until a section"
|
||||
+ "\n\t\t\treaches a length of n characters. Only applies to 'by_title' strategy."
|
||||
+ "\n\t\tnew_after_n_chars"
|
||||
+ "\n\t\t\tCuts off chunks once they reach a length of n characters; a soft max."
|
||||
+ "\n\t\tmax_characters"
|
||||
+ "\n\t\t\tChunks elements text and text_as_html (if present) into chunks"
|
||||
+ "\n\t\t\tof length n characters, a hard max."
|
||||
+ "\n\t\trepeat_table_headers"
|
||||
+ "\n\t\t\tDefault: True. Repeat detected table headers on continuation"
|
||||
+ "\n\t\t\ttable chunks. Set to False to opt out."
|
||||
+ "\n\t\tskip_table_chunking"
|
||||
+ "\n\t\t\tDefault: False. When True, Table elements are passed through"
|
||||
+ "\n\t\t\tunchanged without being split into TableChunk elements."
|
||||
+ "\n\t\tisolate_table"
|
||||
+ "\n\t\t\tDefault: True. When True, Table/TableChunk elements are always"
|
||||
+ "\n\t\t\tstaged in their own pre-chunk. Set to False to allow tables to"
|
||||
+ "\n\t\t\tshare pre-chunks with adjacent non-table elements."
|
||||
)
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> list[Element]:
|
||||
"""The decorated function is replaced with this one."""
|
||||
|
||||
# -- call the partitioning function to get the elements --
|
||||
elements = func(*args, **kwargs)
|
||||
|
||||
# -- look for a chunking-strategy argument --
|
||||
call_args = get_call_args_applying_defaults(func, *args, **kwargs)
|
||||
chunking_strategy = call_args.pop("chunking_strategy", None)
|
||||
|
||||
# -- no chunking-strategy means no chunking --
|
||||
if chunking_strategy is None:
|
||||
return elements
|
||||
|
||||
# -- otherwise, chunk away :) --
|
||||
return chunk(elements, chunking_strategy, **call_args)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def chunk(elements: Iterable[Element], chunking_strategy: str, **kwargs: Any) -> list[Element]:
|
||||
"""Dispatch chunking of `elements` to the chunking function for `chunking_strategy`."""
|
||||
chunker_spec = _chunker_registry.get(chunking_strategy)
|
||||
|
||||
if chunker_spec is None:
|
||||
raise ValueError(f"unrecognized chunking strategy {repr(chunking_strategy)}")
|
||||
|
||||
# -- `kwargs` will in general be an omnibus dict of all keyword arguments to the partitioner;
|
||||
# -- pick out and use only those supported by this chunker.
|
||||
chunking_kwargs = {k: v for k, v in kwargs.items() if k in chunker_spec.kw_arg_names}
|
||||
|
||||
return chunker_spec.chunker(elements, **chunking_kwargs)
|
||||
|
||||
|
||||
def register_chunking_strategy(name: str, chunker: Chunker) -> None:
|
||||
"""Make chunker available by using `name` as `chunking_strategy` arg in partitioner call."""
|
||||
_chunker_registry[name] = _ChunkerSpec(chunker)
|
||||
|
||||
|
||||
@dc.dataclass(frozen=True)
|
||||
class _ChunkerSpec:
|
||||
"""A registry entry for a chunker."""
|
||||
|
||||
chunker: Chunker
|
||||
"""The "chunk_by_{x}() function that implements this chunking strategy."""
|
||||
|
||||
@cached_property
|
||||
def kw_arg_names(self) -> tuple[str, ...]:
|
||||
"""Keyword arguments supported by this chunker.
|
||||
|
||||
These are all arguments other than the required `elements: list[Element]` first parameter.
|
||||
"""
|
||||
sig = inspect.signature(self.chunker)
|
||||
return tuple(key for key in sig.parameters if key != "elements")
|
||||
|
||||
|
||||
_chunker_registry: dict[str, _ChunkerSpec] = {
|
||||
"basic": _ChunkerSpec(chunk_elements),
|
||||
"by_title": _ChunkerSpec(chunk_by_title),
|
||||
}
|
||||
|
||||
|
||||
def reconstruct_table_from_chunks(elements: Iterable[Element]) -> list[Table]:
|
||||
"""Reconstruct original tables from a mixed list of chunked elements.
|
||||
|
||||
Filters `TableChunk` elements, groups them by `table_id`, orders by `chunk_index`, and
|
||||
merges each group into a single `Table` with combined text and HTML. Non-`TableChunk`
|
||||
elements are ignored. Returns reconstructed tables in reading order (order of first chunk
|
||||
appearance).
|
||||
"""
|
||||
# -- filter to only TableChunk instances, preserving input order --
|
||||
table_chunks = [e for e in elements if isinstance(e, TableChunk)]
|
||||
if not table_chunks:
|
||||
return []
|
||||
|
||||
# -- group by table_id, preserving first-seen order --
|
||||
groups: dict[str, list[TableChunk]] = {}
|
||||
for chunk in table_chunks:
|
||||
tid = chunk.metadata.table_id
|
||||
if tid is None:
|
||||
continue
|
||||
if tid not in groups:
|
||||
groups[tid] = []
|
||||
groups[tid].append(chunk)
|
||||
|
||||
# -- sort each group by chunk_index and merge --
|
||||
tables: list[Table] = []
|
||||
|
||||
def _chunk_sort_key(chunk: TableChunk) -> tuple[bool, int]:
|
||||
chunk_index = chunk.metadata.chunk_index
|
||||
return (chunk_index is None, 0 if chunk_index is None else chunk_index)
|
||||
|
||||
for group in groups.values():
|
||||
group.sort(key=_chunk_sort_key)
|
||||
tables.append(_merge_table_chunks(group))
|
||||
|
||||
return tables
|
||||
|
||||
|
||||
def _merge_table_chunks(chunks: list[TableChunk]) -> Table:
|
||||
"""Merge an ordered list of TableChunks from the same table into a single Table."""
|
||||
# -- combine text --
|
||||
text = " ".join(
|
||||
chunk_text for chunk in chunks if (chunk_text := _strip_carried_over_header_text(chunk))
|
||||
)
|
||||
|
||||
# -- build metadata from first chunk --
|
||||
metadata = copy.deepcopy(chunks[0].metadata)
|
||||
metadata.is_continuation = None
|
||||
metadata.table_id = None
|
||||
metadata.chunk_index = None
|
||||
metadata.num_carried_over_header_rows = None
|
||||
|
||||
# -- combine HTML if all chunks have it --
|
||||
if all(c.metadata.text_as_html for c in chunks):
|
||||
combined = fragment_fromstring("<table></table>")
|
||||
canonical_header_row_count, canonical_header_rows = _first_carried_header_rows(chunks)
|
||||
if canonical_header_rows:
|
||||
thead = fragment_fromstring("<thead></thead>")
|
||||
for row in canonical_header_rows:
|
||||
thead.append(row)
|
||||
combined.append(thead)
|
||||
|
||||
for c in chunks:
|
||||
parsed = fragment_fromstring(c.metadata.text_as_html)
|
||||
carried_over_header_rows = _num_carried_over_header_rows(c)
|
||||
rows = parsed.xpath("./tr | ./thead/tr | ./tbody/tr | ./tfoot/tr")
|
||||
skip_count = carried_over_header_rows
|
||||
if c is chunks[0] and canonical_header_row_count:
|
||||
skip_count = canonical_header_row_count
|
||||
for row in rows[skip_count:]:
|
||||
combined.append(row)
|
||||
metadata.text_as_html = tostring(combined, encoding=str)
|
||||
else:
|
||||
metadata.text_as_html = None
|
||||
|
||||
return Table(text=text, metadata=metadata)
|
||||
|
||||
|
||||
def _num_carried_over_header_rows(chunk: TableChunk) -> int:
|
||||
"""Header rows prepended synthetically to this chunk.
|
||||
|
||||
Reconstruction can be called on user-provided/deserialized chunks, so treat missing values as
|
||||
"no carried header rows."
|
||||
"""
|
||||
value = chunk.metadata.num_carried_over_header_rows
|
||||
return value or 0
|
||||
|
||||
|
||||
def _first_carried_header_rows(chunks: list[TableChunk]) -> tuple[int, list[Any]]:
|
||||
"""Header rows from first continuation chunk carrying repeated headers, if any."""
|
||||
first_chunk_rows = _top_level_table_rows(chunks[0].metadata.text_as_html)
|
||||
if first_chunk_rows is None:
|
||||
return 0, []
|
||||
|
||||
for chunk in chunks:
|
||||
carried_row_count = _num_carried_over_header_rows(chunk)
|
||||
if carried_row_count <= 0:
|
||||
continue
|
||||
|
||||
rows = _top_level_table_rows(chunk.metadata.text_as_html)
|
||||
if rows is None:
|
||||
continue
|
||||
|
||||
if carried_row_count > len(rows):
|
||||
continue
|
||||
|
||||
carried_rows = rows[:carried_row_count]
|
||||
if not _leading_row_texts_match(first_chunk_rows, carried_rows):
|
||||
continue
|
||||
|
||||
return carried_row_count, [copy.deepcopy(row) for row in rows[:carried_row_count]]
|
||||
|
||||
return 0, []
|
||||
|
||||
|
||||
def _top_level_table_rows(text_as_html: str | None) -> list[Any] | None:
|
||||
"""Top-level rows from a table fragment, preserving section ordering."""
|
||||
if not text_as_html:
|
||||
return None
|
||||
|
||||
try:
|
||||
parsed = fragment_fromstring(text_as_html)
|
||||
except (ParserError, ValueError):
|
||||
return None
|
||||
|
||||
return parsed.xpath("./tr | ./thead/tr | ./tbody/tr | ./tfoot/tr")
|
||||
|
||||
|
||||
def _leading_row_texts_match(first_chunk_rows: list[Any], carried_rows: list[Any]) -> bool:
|
||||
"""True when carried rows match first chunk's leading rows by normalized cell text."""
|
||||
if len(first_chunk_rows) < len(carried_rows):
|
||||
return False
|
||||
|
||||
for first_row, carried_row in zip(first_chunk_rows, carried_rows):
|
||||
if _row_text_signature(first_row) != _row_text_signature(carried_row):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _row_text_signature(row: Any) -> tuple[str, ...]:
|
||||
"""Normalized cell text tuple for a row."""
|
||||
return tuple(" ".join(cell.text_content().split()) for cell in row.iter("td", "th"))
|
||||
|
||||
|
||||
def _strip_carried_over_header_text(chunk: TableChunk) -> str:
|
||||
"""Strip synthetic carried-over header text from continuation chunk text."""
|
||||
carried_row_count = _num_carried_over_header_rows(chunk)
|
||||
if carried_row_count == 0:
|
||||
return chunk.text
|
||||
|
||||
text_as_html = chunk.metadata.text_as_html
|
||||
if not text_as_html:
|
||||
return chunk.text
|
||||
|
||||
try:
|
||||
parsed = fragment_fromstring(text_as_html)
|
||||
except (ParserError, ValueError):
|
||||
return chunk.text
|
||||
|
||||
rows = parsed.xpath("./tr | ./thead/tr | ./tbody/tr | ./tfoot/tr")
|
||||
if carried_row_count > len(rows):
|
||||
return chunk.text
|
||||
|
||||
carried_header_text = " ".join(
|
||||
text
|
||||
for row in rows[:carried_row_count]
|
||||
for text in (" ".join(cell.text_content().split()) for cell in row.iter("td", "th"))
|
||||
if text
|
||||
)
|
||||
if not carried_header_text:
|
||||
return chunk.text
|
||||
|
||||
chunk_text = chunk.text.lstrip()
|
||||
if chunk_text == carried_header_text:
|
||||
return ""
|
||||
if chunk_text.startswith(f"{carried_header_text} "):
|
||||
return chunk_text[len(carried_header_text) + 1 :]
|
||||
if chunk_text.startswith(carried_header_text):
|
||||
return chunk_text[len(carried_header_text) :].lstrip()
|
||||
return chunk.text
|
||||
Reference in New Issue
Block a user