tryworks 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tryworks/__init__.py +16 -0
- tryworks/__main__.py +72 -0
- tryworks/__version__.py +1 -0
- tryworks/_patterns.py +48 -0
- tryworks/chunking/__init__.py +25 -0
- tryworks/chunking/base.py +356 -0
- tryworks/chunking/basic.py +48 -0
- tryworks/chunking/title.py +69 -0
- tryworks/cleaners/__init__.py +0 -0
- tryworks/cleaners/core.py +229 -0
- tryworks/compat.py +58 -0
- tryworks/documents/__init__.py +0 -0
- tryworks/documents/coordinates.py +79 -0
- tryworks/documents/elements.py +553 -0
- tryworks/partition/__init__.py +0 -0
- tryworks/partition/_html_table.py +42 -0
- tryworks/partition/_ooxml.py +167 -0
- tryworks/partition/auto.py +247 -0
- tryworks/partition/common.py +412 -0
- tryworks/partition/csv.py +82 -0
- tryworks/partition/docx.py +515 -0
- tryworks/partition/email.py +135 -0
- tryworks/partition/html.py +538 -0
- tryworks/partition/json.py +31 -0
- tryworks/partition/md.py +285 -0
- tryworks/partition/pdf.py +259 -0
- tryworks/partition/pptx.py +214 -0
- tryworks/partition/text.py +93 -0
- tryworks/partition/text_type.py +341 -0
- tryworks/partition/xlsx.py +276 -0
- tryworks/staging/__init__.py +0 -0
- tryworks/staging/base.py +258 -0
- tryworks-0.1.0.dist-info/METADATA +270 -0
- tryworks-0.1.0.dist-info/RECORD +38 -0
- tryworks-0.1.0.dist-info/WHEEL +4 -0
- tryworks-0.1.0.dist-info/entry_points.txt +2 -0
- tryworks-0.1.0.dist-info/licenses/LICENSE +201 -0
- tryworks-0.1.0.dist-info/licenses/NOTICE +17 -0
tryworks/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""tryworks: partition documents into LLM-ready elements with zero required dependencies.
|
|
2
|
+
|
|
3
|
+
from tryworks.partition.auto import partition
|
|
4
|
+
from tryworks.chunking.title import chunk_by_title
|
|
5
|
+
|
|
6
|
+
elements = partition("report.docx")
|
|
7
|
+
chunks = chunk_by_title(elements, max_characters=1000)
|
|
8
|
+
|
|
9
|
+
The module layout mirrors ``unstructured``: replace ``unstructured.`` with ``tryworks.`` in imports,
|
|
10
|
+
or call ``tryworks.alias_as_unstructured()`` to serve code you cannot edit.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from tryworks.__version__ import __version__
|
|
14
|
+
from tryworks.compat import alias_as_unstructured
|
|
15
|
+
|
|
16
|
+
__all__ = ["__version__", "alias_as_unstructured"]
|
tryworks/__main__.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Command line: ``tryworks report.pdf`` prints the document's elements as JSON."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Optional, Sequence
|
|
8
|
+
|
|
9
|
+
from tryworks.__version__ import __version__
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _render(elements: list, fmt: str) -> str:
|
|
13
|
+
from tryworks.staging import base
|
|
14
|
+
|
|
15
|
+
if fmt == "json":
|
|
16
|
+
return base.elements_to_json(elements, indent=2)
|
|
17
|
+
if fmt == "ndjson":
|
|
18
|
+
return base.elements_to_ndjson(elements)
|
|
19
|
+
if fmt == "markdown":
|
|
20
|
+
return base.elements_to_md(elements)
|
|
21
|
+
if fmt == "csv":
|
|
22
|
+
return base.convert_to_csv(elements)
|
|
23
|
+
return base.convert_to_text(elements)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
27
|
+
parser = argparse.ArgumentParser(
|
|
28
|
+
prog="tryworks",
|
|
29
|
+
description="Partition a document (pdf, docx, pptx, xlsx, html, md, txt, csv, tsv, eml, json) into elements.",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument("source", help="path to a document, or an http(s) URL")
|
|
32
|
+
parser.add_argument("-f", "--format", choices=["json", "ndjson", "text", "markdown", "csv"], default="json")
|
|
33
|
+
parser.add_argument("-o", "--output", help="write to this file instead of standard output")
|
|
34
|
+
parser.add_argument("--content-type", help="MIME type, when the extension does not say")
|
|
35
|
+
parser.add_argument("--chunking-strategy", choices=["basic", "by_title"], help="chunk the elements")
|
|
36
|
+
parser.add_argument("--max-characters", type=int, help="chunk size limit (default 500)")
|
|
37
|
+
parser.add_argument("--include-page-breaks", action="store_true", help="emit PageBreak elements")
|
|
38
|
+
parser.add_argument("--version", action="version", version=f"tryworks {__version__}")
|
|
39
|
+
args = parser.parse_args(argv)
|
|
40
|
+
|
|
41
|
+
from tryworks.partition.auto import UnsupportedFileFormatError, partition
|
|
42
|
+
|
|
43
|
+
kwargs: dict = {"content_type": args.content_type}
|
|
44
|
+
if args.chunking_strategy:
|
|
45
|
+
kwargs["chunking_strategy"] = args.chunking_strategy
|
|
46
|
+
if args.max_characters:
|
|
47
|
+
kwargs["max_characters"] = args.max_characters
|
|
48
|
+
if args.include_page_breaks:
|
|
49
|
+
kwargs["include_page_breaks"] = True
|
|
50
|
+
source = args.source
|
|
51
|
+
try:
|
|
52
|
+
if source.lower().startswith(("http://", "https://")):
|
|
53
|
+
elements = partition(url=source, **kwargs)
|
|
54
|
+
else:
|
|
55
|
+
elements = partition(filename=source, **kwargs)
|
|
56
|
+
except (UnsupportedFileFormatError, FileNotFoundError, ValueError, NotImplementedError, ImportError) as e:
|
|
57
|
+
print(f"tryworks: {e}", file=sys.stderr)
|
|
58
|
+
return 2
|
|
59
|
+
|
|
60
|
+
rendered = _render(elements, args.format)
|
|
61
|
+
if args.output:
|
|
62
|
+
with open(args.output, "w", encoding="utf-8", newline="") as f:
|
|
63
|
+
f.write(rendered)
|
|
64
|
+
else:
|
|
65
|
+
sys.stdout.buffer.write(rendered.encode("utf-8"))
|
|
66
|
+
if not rendered.endswith("\n"):
|
|
67
|
+
sys.stdout.buffer.write(b"\n")
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
raise SystemExit(main())
|
tryworks/__version__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
tryworks/_patterns.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Regular expressions shared by the text classifiers and the cleaners."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
# -- characters that mark a bulleted line on their own --
|
|
8
|
+
UNICODE_BULLETS = (
|
|
9
|
+
"\u0095\u00b7\u2022\u2023\u2043\u204c\u204d\u2219\u25a0\u25a1\u25aa\u25ab\u25b6\u25ba"
|
|
10
|
+
"\u25cb\u25cf\u25d8\u25e6\u2619\u2765\u2767\u27a2\u27a4\u29be\u29bf\uf0a7\uf0b7"
|
|
11
|
+
)
|
|
12
|
+
# -- ASCII-ish markers only count when followed by whitespace, so "**bold**", "-5 degrees"
|
|
13
|
+
# -- and "+49 30 1234" are not taken for list items.
|
|
14
|
+
BULLET_RE = re.compile(rf"^\s*(?:[{UNICODE_BULLETS}]|[-*+\u2013\u2014](?=\s))")
|
|
15
|
+
BULLET_PREFIX_RE = re.compile(rf"^\s*(?:[{UNICODE_BULLETS}]+|[-*+\u2013\u2014](?=\s))\s*")
|
|
16
|
+
|
|
17
|
+
NUMBERED_LIST_RE = re.compile(r"^\s*\d{1,3}(?:\.\d{1,3})*[.)]\s+\S")
|
|
18
|
+
|
|
19
|
+
EMAIL_ADDRESS_RE = re.compile(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}$")
|
|
20
|
+
|
|
21
|
+
US_PHONE_NUMBER_RE = re.compile(
|
|
22
|
+
r"(?:\+?1[\s.-]?)?(?:\(\d{3}\)\s?|\b\d{3}[\s.-])?\b\d{3}[\s.-]\d{4}\b"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_US_STATES = (
|
|
26
|
+
"Alabama|Alaska|Arizona|Arkansas|California|Colorado|Connecticut|Delaware|Florida|Georgia|"
|
|
27
|
+
"Hawaii|Idaho|Illinois|Indiana|Iowa|Kansas|Kentucky|Louisiana|Maine|Maryland|Massachusetts|"
|
|
28
|
+
"Michigan|Minnesota|Mississippi|Missouri|Montana|Nebraska|Nevada|New Hampshire|New Jersey|"
|
|
29
|
+
"New Mexico|New York|North Carolina|North Dakota|Ohio|Oklahoma|Oregon|Pennsylvania|"
|
|
30
|
+
"Rhode Island|South Carolina|South Dakota|Tennessee|Texas|Utah|Vermont|Virginia|Washington|"
|
|
31
|
+
"West Virginia|Wisconsin|Wyoming|District of Columbia"
|
|
32
|
+
)
|
|
33
|
+
_US_STATE_CODES = (
|
|
34
|
+
"AL|AK|AZ|AR|CA|CO|CT|DE|DC|FL|GA|HI|ID|IL|IN|IA|KS|KY|LA|ME|MD|MA|MI|MN|MS|MO|MT|NE|NV|NH|"
|
|
35
|
+
"NJ|NM|NY|NC|ND|OH|OK|OR|PA|RI|SC|SD|TN|TX|UT|VT|VA|WA|WV|WI|WY"
|
|
36
|
+
)
|
|
37
|
+
US_CITY_STATE_ZIP_RE = re.compile(
|
|
38
|
+
rf"^[A-Za-z][A-Za-z .'\-]*,\s*(?:{_US_STATES}|{_US_STATE_CODES}),?\s+\d{{5}}(?:-\d{{4}})?$",
|
|
39
|
+
re.IGNORECASE,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
ENDS_IN_PUNCT_RE = re.compile(r"[^\w\s]\Z")
|
|
43
|
+
|
|
44
|
+
# -- paragraph grouping --
|
|
45
|
+
BLANK_LINE_SPLIT_RE = re.compile(r"\n[ \t\f\v\r]*\n\s*")
|
|
46
|
+
LINE_SPLIT_RE = re.compile(r"[ \t\f\v\r]*\n[ \t\f\v\r]*")
|
|
47
|
+
|
|
48
|
+
WORD_RE = re.compile(r"\w+(?:['\u2019-]\w+)*|[^\w\s]")
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Chunking strategies: "basic" and "by_title"."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Iterable
|
|
6
|
+
|
|
7
|
+
from tryworks.chunking.base import CHUNK_MAX_CHARS_DEFAULT, CHUNK_MULTI_PAGE_DEFAULT
|
|
8
|
+
from tryworks.documents.elements import Element
|
|
9
|
+
|
|
10
|
+
__all__ = ["CHUNK_MAX_CHARS_DEFAULT", "CHUNK_MULTI_PAGE_DEFAULT", "chunk"]
|
|
11
|
+
|
|
12
|
+
_TITLE_ONLY = ("combine_text_under_n_chars", "multipage_sections")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def chunk(elements: Iterable[Element], chunking_strategy: str, **kwargs: Any) -> list[Element]:
|
|
16
|
+
"""Apply a named chunking strategy, as ``partition(..., chunking_strategy=...)`` does."""
|
|
17
|
+
if chunking_strategy == "by_title":
|
|
18
|
+
from tryworks.chunking.title import chunk_by_title
|
|
19
|
+
|
|
20
|
+
return chunk_by_title(elements, **kwargs)
|
|
21
|
+
if chunking_strategy == "basic":
|
|
22
|
+
from tryworks.chunking.basic import chunk_elements
|
|
23
|
+
|
|
24
|
+
return chunk_elements(elements, **{k: v for k, v in kwargs.items() if k not in _TITLE_ONLY})
|
|
25
|
+
raise ValueError(f"Unsupported chunking strategy {chunking_strategy!r}; use 'basic' or 'by_title'.")
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""Chunking machinery shared by the "basic" and "by_title" strategies.
|
|
2
|
+
|
|
3
|
+
Chunking runs in three passes:
|
|
4
|
+
|
|
5
|
+
1. Pre-chunking groups consecutive elements into the largest runs that fit ``max_characters``,
|
|
6
|
+
starting a new run at every semantic boundary (a Title for "by_title", optionally a page
|
|
7
|
+
change) and giving each Table a run of its own.
|
|
8
|
+
2. Combining (by_title only) merges a small run of text into the next one while the first is
|
|
9
|
+
under ``combine_text_under_n_chars`` and the result still fits.
|
|
10
|
+
3. Chunking turns each run into a CompositeElement (texts joined by a blank line) and splits a
|
|
11
|
+
run that is still too long at a newline, else a space. Tables pass through whole when they
|
|
12
|
+
fit, otherwise they are split row by row into TableChunk elements.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import copy
|
|
18
|
+
import html
|
|
19
|
+
import re
|
|
20
|
+
from typing import Any, Callable, Iterable, Iterator, Optional
|
|
21
|
+
|
|
22
|
+
from tryworks.documents.elements import (
|
|
23
|
+
CompositeElement,
|
|
24
|
+
Element,
|
|
25
|
+
ElementMetadata,
|
|
26
|
+
PageBreak,
|
|
27
|
+
Table,
|
|
28
|
+
TableChunk,
|
|
29
|
+
Text,
|
|
30
|
+
Title,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
CHUNK_MAX_CHARS_DEFAULT = 500
|
|
34
|
+
CHUNK_MULTI_PAGE_DEFAULT = True
|
|
35
|
+
TEXT_SEPARATOR = "\n\n"
|
|
36
|
+
|
|
37
|
+
BoundaryPredicate = Callable[[Element], bool]
|
|
38
|
+
|
|
39
|
+
# -- how each metadata field is merged when several elements become one chunk --
|
|
40
|
+
_DROP = frozenset(
|
|
41
|
+
"""category_depth coordinates detection_class_prob detection_origin header_footer_type image_url
|
|
42
|
+
image_path image_base64 image_mime_type is_continuation is_extracted link_start_indexes links
|
|
43
|
+
max_characters orig_elements parent_id routing routing_score table_id chunk_index
|
|
44
|
+
num_carried_over_header_rows segment_start_seconds segment_end_seconds key_value_pairs""".split()
|
|
45
|
+
)
|
|
46
|
+
_LIST_CONCATENATE = frozenset(["emphasized_text_contents", "emphasized_text_tags", "link_texts", "link_urls"])
|
|
47
|
+
_LIST_UNIQUE = frozenset(["languages"])
|
|
48
|
+
_STRING_CONCATENATE = frozenset(["text_as_html"])
|
|
49
|
+
|
|
50
|
+
_TR_RE = re.compile(r"<tr\b[^>]*>.*?</tr>|<tr\s*/>", re.DOTALL)
|
|
51
|
+
_TAG_RE = re.compile(r"<[^>]+>")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ChunkingOptions:
|
|
55
|
+
"""Validated chunking parameters, with unstructured's defaults."""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
max_characters: Optional[int] = None,
|
|
61
|
+
new_after_n_chars: Optional[int] = None,
|
|
62
|
+
overlap: Optional[int] = None,
|
|
63
|
+
overlap_all: Optional[bool] = None,
|
|
64
|
+
combine_text_under_n_chars: Optional[int] = None,
|
|
65
|
+
include_orig_elements: Optional[bool] = None,
|
|
66
|
+
repeat_table_headers: Optional[bool] = None,
|
|
67
|
+
skip_table_chunking: Optional[bool] = None,
|
|
68
|
+
isolate_table: Optional[bool] = None,
|
|
69
|
+
max_tokens: Optional[int] = None,
|
|
70
|
+
new_after_n_tokens: Optional[int] = None,
|
|
71
|
+
tokenizer: Optional[Any] = None,
|
|
72
|
+
combine_default_to_max: bool = False,
|
|
73
|
+
):
|
|
74
|
+
if max_tokens is not None or new_after_n_tokens is not None or tokenizer is not None:
|
|
75
|
+
raise NotImplementedError("Token-based chunking is not supported by tryworks; use max_characters.")
|
|
76
|
+
self.hard_max = CHUNK_MAX_CHARS_DEFAULT if max_characters is None else max_characters
|
|
77
|
+
if self.hard_max <= 0:
|
|
78
|
+
raise ValueError(f"'max_characters' argument must be > 0, got {self.hard_max}")
|
|
79
|
+
if new_after_n_chars is not None and new_after_n_chars < 0:
|
|
80
|
+
raise ValueError(f"'new_after_n_chars' argument must be >= 0, got {new_after_n_chars}")
|
|
81
|
+
self.soft_max = self.hard_max if new_after_n_chars is None else min(new_after_n_chars, self.hard_max)
|
|
82
|
+
self.overlap = overlap or 0
|
|
83
|
+
if self.overlap < 0 or self.overlap >= self.hard_max:
|
|
84
|
+
raise ValueError(f"'overlap' argument must be >= 0 and less than `max_characters`, got {self.overlap}")
|
|
85
|
+
self.inter_chunk_overlap = self.overlap if overlap_all else 0
|
|
86
|
+
if combine_text_under_n_chars is None:
|
|
87
|
+
combine_text_under_n_chars = self.hard_max if combine_default_to_max else 0
|
|
88
|
+
if combine_text_under_n_chars < 0:
|
|
89
|
+
raise ValueError(f"'combine_text_under_n_chars' argument must be >= 0, got {combine_text_under_n_chars}")
|
|
90
|
+
if combine_text_under_n_chars > self.hard_max:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
"'combine_text_under_n_chars' argument must not exceed `max_characters` value, "
|
|
93
|
+
f"got {combine_text_under_n_chars}"
|
|
94
|
+
)
|
|
95
|
+
self.combine_text_under_n_chars = combine_text_under_n_chars
|
|
96
|
+
self.include_orig_elements = True if include_orig_elements is None else bool(include_orig_elements)
|
|
97
|
+
# -- accepted for API compatibility; tryworks' partitioners never mark header rows --
|
|
98
|
+
self.repeat_table_headers = True if repeat_table_headers is None else bool(repeat_table_headers)
|
|
99
|
+
self.skip_table_chunking = bool(skip_table_chunking)
|
|
100
|
+
self.isolate_table = True if isolate_table is None else bool(isolate_table)
|
|
101
|
+
if self.skip_table_chunking and not self.isolate_table:
|
|
102
|
+
raise ValueError("'skip_table_chunking=True' requires 'isolate_table=True' (the default).")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_table(element: Element) -> bool:
|
|
106
|
+
return isinstance(element, Table)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _joined_length(elements: list[Element]) -> int:
|
|
110
|
+
return len(TEXT_SEPARATOR.join(e.text for e in elements if e.text))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class PreChunk:
|
|
114
|
+
"""A run of elements that will become one chunk (or several, if it must be split)."""
|
|
115
|
+
|
|
116
|
+
def __init__(self, elements: list[Element], opts: ChunkingOptions):
|
|
117
|
+
self.elements = elements
|
|
118
|
+
self.opts = opts
|
|
119
|
+
self.overlap_prefix = ""
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def is_table(self) -> bool:
|
|
123
|
+
return len(self.elements) == 1 and _is_table(self.elements[0])
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def contains_table(self) -> bool:
|
|
127
|
+
return any(_is_table(e) for e in self.elements)
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def text(self) -> str:
|
|
131
|
+
segments = ([self.overlap_prefix] if self.overlap_prefix else []) + [e.text for e in self.elements if e.text]
|
|
132
|
+
return TEXT_SEPARATOR.join(segments)
|
|
133
|
+
|
|
134
|
+
def overlap_tail(self) -> str:
|
|
135
|
+
n = self.opts.inter_chunk_overlap
|
|
136
|
+
return self.text[-n:].strip() if n else ""
|
|
137
|
+
|
|
138
|
+
def can_combine(self, other: PreChunk) -> bool:
|
|
139
|
+
if _joined_length(self.elements) >= self.opts.combine_text_under_n_chars:
|
|
140
|
+
return False
|
|
141
|
+
if self.opts.isolate_table and (self.contains_table or other.contains_table):
|
|
142
|
+
return False
|
|
143
|
+
return _joined_length(self.elements + other.elements) <= self.opts.hard_max
|
|
144
|
+
|
|
145
|
+
def combine(self, other: PreChunk) -> PreChunk:
|
|
146
|
+
return PreChunk(self.elements + other.elements, self.opts)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def iter_pre_chunks(
|
|
150
|
+
elements: Iterable[Element], opts: ChunkingOptions, boundaries: tuple[BoundaryPredicate, ...]
|
|
151
|
+
) -> Iterator[PreChunk]:
|
|
152
|
+
current: list[Element] = []
|
|
153
|
+
|
|
154
|
+
def fits(element: Element) -> bool:
|
|
155
|
+
if not current:
|
|
156
|
+
return True
|
|
157
|
+
if opts.isolate_table and (_is_table(element) or any(_is_table(e) for e in current)):
|
|
158
|
+
return False
|
|
159
|
+
length = _joined_length(current)
|
|
160
|
+
if length >= opts.soft_max:
|
|
161
|
+
return False
|
|
162
|
+
return _joined_length(current + [element]) <= opts.hard_max
|
|
163
|
+
|
|
164
|
+
for element in elements:
|
|
165
|
+
if not isinstance(element, Text):
|
|
166
|
+
continue # -- CheckBox and other text-less elements are dropped --
|
|
167
|
+
# -- evaluate every predicate: stateful ones (page tracking) must see each element --
|
|
168
|
+
new_unit = any([predicate(element) for predicate in boundaries])
|
|
169
|
+
if current and (new_unit or not fits(element)):
|
|
170
|
+
yield PreChunk(current, opts)
|
|
171
|
+
current = []
|
|
172
|
+
current.append(element)
|
|
173
|
+
if current:
|
|
174
|
+
yield PreChunk(current, opts)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def combine_pre_chunks(pre_chunks: Iterable[PreChunk], opts: ChunkingOptions) -> Iterator[PreChunk]:
|
|
178
|
+
accumulated: Optional[PreChunk] = None
|
|
179
|
+
for pre_chunk in pre_chunks:
|
|
180
|
+
if accumulated is None:
|
|
181
|
+
accumulated = pre_chunk
|
|
182
|
+
elif accumulated.can_combine(pre_chunk):
|
|
183
|
+
accumulated = accumulated.combine(pre_chunk)
|
|
184
|
+
else:
|
|
185
|
+
yield accumulated
|
|
186
|
+
accumulated = pre_chunk
|
|
187
|
+
if accumulated is not None:
|
|
188
|
+
yield accumulated
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def split_text(s: str, maxlen: int, overlap: int = 0) -> tuple[str, str]:
|
|
192
|
+
"""Split ``s`` into a fragment of at most ``maxlen`` characters and the remainder.
|
|
193
|
+
|
|
194
|
+
The split is made at the last newline before ``maxlen``, else the last space, else exactly at
|
|
195
|
+
``maxlen``. The remainder begins up to ``overlap`` characters before the split point, moved
|
|
196
|
+
forward to a word boundary.
|
|
197
|
+
"""
|
|
198
|
+
if len(s) <= maxlen:
|
|
199
|
+
return s, ""
|
|
200
|
+
for separator in ("\n", " "):
|
|
201
|
+
cut = s.rfind(separator, 0, maxlen + 1)
|
|
202
|
+
fragment = s[:cut].rstrip() if cut > 0 else ""
|
|
203
|
+
if not fragment:
|
|
204
|
+
continue
|
|
205
|
+
rest_start = cut + 1
|
|
206
|
+
if overlap:
|
|
207
|
+
back = max(cut - overlap, 0)
|
|
208
|
+
boundary = s.find(" ", back, cut)
|
|
209
|
+
rest_start = boundary + 1 if boundary != -1 else back
|
|
210
|
+
return fragment, s[rest_start:].lstrip()
|
|
211
|
+
return s[:maxlen], s[maxlen - overlap :]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def consolidate_metadata(elements: list[Element]) -> ElementMetadata:
|
|
215
|
+
"""Merge metadata of several elements: first value, concatenation or union, per field."""
|
|
216
|
+
merged: dict[str, Any] = {}
|
|
217
|
+
for element in elements:
|
|
218
|
+
for name, value in element.metadata.fields.items():
|
|
219
|
+
if name in _DROP:
|
|
220
|
+
continue
|
|
221
|
+
if name in _LIST_CONCATENATE:
|
|
222
|
+
merged.setdefault(name, []).extend(value)
|
|
223
|
+
elif name in _LIST_UNIQUE:
|
|
224
|
+
bucket = merged.setdefault(name, [])
|
|
225
|
+
bucket.extend(v for v in value if v not in bucket)
|
|
226
|
+
elif name in _STRING_CONCATENATE:
|
|
227
|
+
merged[name] = merged.get(name, "") + value
|
|
228
|
+
elif name == "enrichment_origins":
|
|
229
|
+
target = merged.setdefault(name, {})
|
|
230
|
+
for key, records in value.items():
|
|
231
|
+
existing = target.setdefault(key, [])
|
|
232
|
+
existing.extend(r for r in records if r not in existing)
|
|
233
|
+
elif name not in merged:
|
|
234
|
+
merged[name] = copy.deepcopy(value)
|
|
235
|
+
metadata = ElementMetadata()
|
|
236
|
+
for name, value in merged.items():
|
|
237
|
+
setattr(metadata, name, value)
|
|
238
|
+
return metadata
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _orig_elements(elements: list[Element]) -> list[Element]:
|
|
242
|
+
out = []
|
|
243
|
+
for element in elements:
|
|
244
|
+
clone = copy.deepcopy(element)
|
|
245
|
+
clone.metadata.orig_elements = None
|
|
246
|
+
out.append(clone)
|
|
247
|
+
return out
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _iter_text_chunks(pre_chunk: PreChunk, opts: ChunkingOptions) -> Iterator[Element]:
|
|
251
|
+
metadata = consolidate_metadata([e for e in pre_chunk.elements if not isinstance(e, PageBreak)])
|
|
252
|
+
if opts.include_orig_elements:
|
|
253
|
+
metadata.orig_elements = _orig_elements(pre_chunk.elements)
|
|
254
|
+
remainder = pre_chunk.text
|
|
255
|
+
index = 0
|
|
256
|
+
while remainder:
|
|
257
|
+
fragment, remainder = split_text(remainder, opts.hard_max, opts.overlap)
|
|
258
|
+
chunk_metadata = copy.deepcopy(metadata)
|
|
259
|
+
if index:
|
|
260
|
+
chunk_metadata.is_continuation = True
|
|
261
|
+
yield CompositeElement(text=fragment, metadata=chunk_metadata)
|
|
262
|
+
index += 1
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _row_text(rows: list[str]) -> str:
|
|
266
|
+
return " ".join(html.unescape(" ".join(_TAG_RE.sub(" ", row).split())) for row in rows).strip()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _iter_table_chunks(table: Table, opts: ChunkingOptions, overlap_prefix: str) -> Iterator[Element]:
|
|
270
|
+
text = f"{overlap_prefix} {table.text}".strip() if overlap_prefix else table.text
|
|
271
|
+
table_html = table.metadata.text_as_html
|
|
272
|
+
if opts.skip_table_chunking or (len(text) <= opts.hard_max and (table_html is None or len(table_html) <= opts.hard_max)):
|
|
273
|
+
whole = copy.deepcopy(table)
|
|
274
|
+
whole.text = text
|
|
275
|
+
whole._element_id = None
|
|
276
|
+
if opts.include_orig_elements:
|
|
277
|
+
whole.metadata.orig_elements = _orig_elements([table])
|
|
278
|
+
yield whole
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
pieces: list[tuple[str, Optional[str]]] = []
|
|
282
|
+
|
|
283
|
+
def add_text_pieces(value: str) -> None:
|
|
284
|
+
remainder = value
|
|
285
|
+
while remainder:
|
|
286
|
+
fragment, remainder = split_text(remainder, opts.hard_max, opts.overlap)
|
|
287
|
+
pieces.append((fragment, None))
|
|
288
|
+
|
|
289
|
+
rows = _TR_RE.findall(table_html) if table_html else []
|
|
290
|
+
if rows:
|
|
291
|
+
batch: list[str] = []
|
|
292
|
+
for row in rows:
|
|
293
|
+
if len(f"<table>{row}</table>") > opts.hard_max or len(_row_text([row])) > opts.hard_max:
|
|
294
|
+
if batch:
|
|
295
|
+
pieces.append((_row_text(batch), f"<table>{''.join(batch)}</table>"))
|
|
296
|
+
batch = []
|
|
297
|
+
add_text_pieces(_row_text([row]))
|
|
298
|
+
continue
|
|
299
|
+
candidate = batch + [row]
|
|
300
|
+
too_big = len(f"<table>{''.join(candidate)}</table>") > opts.hard_max or len(_row_text(candidate)) > opts.hard_max
|
|
301
|
+
if batch and too_big:
|
|
302
|
+
pieces.append((_row_text(batch), f"<table>{''.join(batch)}</table>"))
|
|
303
|
+
batch = [row]
|
|
304
|
+
else:
|
|
305
|
+
batch = candidate
|
|
306
|
+
if batch:
|
|
307
|
+
pieces.append((_row_text(batch), f"<table>{''.join(batch)}</table>"))
|
|
308
|
+
else:
|
|
309
|
+
add_text_pieces(text)
|
|
310
|
+
|
|
311
|
+
base = copy.deepcopy(table.metadata)
|
|
312
|
+
base.orig_elements = None
|
|
313
|
+
for index, (piece_text, piece_html) in enumerate(pieces):
|
|
314
|
+
metadata = copy.deepcopy(base)
|
|
315
|
+
metadata.text_as_html = piece_html
|
|
316
|
+
metadata.table_id = table.id
|
|
317
|
+
metadata.chunk_index = index
|
|
318
|
+
metadata.is_continuation = True if index else None
|
|
319
|
+
if opts.include_orig_elements:
|
|
320
|
+
metadata.orig_elements = _orig_elements([table])
|
|
321
|
+
yield TableChunk(text=piece_text, metadata=metadata)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def iter_chunks(pre_chunks: Iterable[PreChunk], opts: ChunkingOptions) -> Iterator[Element]:
|
|
325
|
+
previous_tail = ""
|
|
326
|
+
for pre_chunk in pre_chunks:
|
|
327
|
+
pre_chunk.overlap_prefix = previous_tail if opts.inter_chunk_overlap else ""
|
|
328
|
+
if pre_chunk.is_table and opts.isolate_table:
|
|
329
|
+
yield from _iter_table_chunks(pre_chunk.elements[0], opts, pre_chunk.overlap_prefix) # type: ignore[arg-type]
|
|
330
|
+
else:
|
|
331
|
+
yield from _iter_text_chunks(pre_chunk, opts)
|
|
332
|
+
pre_chunk.overlap_prefix = ""
|
|
333
|
+
previous_tail = pre_chunk.overlap_tail()
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def is_title(element: Element) -> bool:
|
|
337
|
+
return isinstance(element, Title)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def is_on_next_page() -> BoundaryPredicate:
|
|
341
|
+
"""A stateful predicate that is True for the first element on each new page."""
|
|
342
|
+
state: dict[str, Optional[int]] = {"page": None}
|
|
343
|
+
|
|
344
|
+
def predicate(element: Element) -> bool:
|
|
345
|
+
page = element.metadata.page_number
|
|
346
|
+
if page is None:
|
|
347
|
+
return False
|
|
348
|
+
if state["page"] is None:
|
|
349
|
+
state["page"] = page
|
|
350
|
+
return False
|
|
351
|
+
if page != state["page"]:
|
|
352
|
+
state["page"] = page
|
|
353
|
+
return True
|
|
354
|
+
return False
|
|
355
|
+
|
|
356
|
+
return predicate
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Fill chunks greedily up to a size limit, same API as ``unstructured.chunking.basic``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Iterable, Iterator, Optional
|
|
6
|
+
|
|
7
|
+
from tryworks.chunking.base import ChunkingOptions, iter_chunks, iter_pre_chunks
|
|
8
|
+
from tryworks.documents.elements import Element
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def iter_chunk_elements(
|
|
12
|
+
elements: Iterable[Element],
|
|
13
|
+
*,
|
|
14
|
+
include_orig_elements: Optional[bool] = None,
|
|
15
|
+
max_characters: Optional[int] = None,
|
|
16
|
+
max_tokens: Optional[int] = None,
|
|
17
|
+
new_after_n_chars: Optional[int] = None,
|
|
18
|
+
new_after_n_tokens: Optional[int] = None,
|
|
19
|
+
overlap: Optional[int] = None,
|
|
20
|
+
overlap_all: Optional[bool] = None,
|
|
21
|
+
tokenizer: Optional[Any] = None,
|
|
22
|
+
repeat_table_headers: Optional[bool] = None,
|
|
23
|
+
skip_table_chunking: Optional[bool] = None,
|
|
24
|
+
isolate_table: Optional[bool] = None,
|
|
25
|
+
) -> Iterator[Element]:
|
|
26
|
+
"""Lazily yield chunks; see ``chunk_elements``."""
|
|
27
|
+
opts = ChunkingOptions(
|
|
28
|
+
max_characters=max_characters,
|
|
29
|
+
new_after_n_chars=new_after_n_chars,
|
|
30
|
+
overlap=overlap,
|
|
31
|
+
overlap_all=overlap_all,
|
|
32
|
+
include_orig_elements=include_orig_elements,
|
|
33
|
+
repeat_table_headers=repeat_table_headers,
|
|
34
|
+
skip_table_chunking=skip_table_chunking,
|
|
35
|
+
isolate_table=isolate_table,
|
|
36
|
+
max_tokens=max_tokens,
|
|
37
|
+
new_after_n_tokens=new_after_n_tokens,
|
|
38
|
+
tokenizer=tokenizer,
|
|
39
|
+
)
|
|
40
|
+
yield from iter_chunks(iter_pre_chunks(elements, opts, ()), opts)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def chunk_elements(elements: Iterable[Element], **kwargs: Any) -> list[Element]:
|
|
44
|
+
"""Pack consecutive elements into chunks of up to ``max_characters`` (default 500).
|
|
45
|
+
|
|
46
|
+
Sections are not respected; tables still get chunks of their own.
|
|
47
|
+
"""
|
|
48
|
+
return list(iter_chunk_elements(elements, **kwargs))
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Chunk elements into sections that start at each Title, same API as ``unstructured.chunking.title``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Iterable, Iterator, Optional
|
|
6
|
+
|
|
7
|
+
from tryworks.chunking.base import (
|
|
8
|
+
CHUNK_MULTI_PAGE_DEFAULT,
|
|
9
|
+
ChunkingOptions,
|
|
10
|
+
combine_pre_chunks,
|
|
11
|
+
is_on_next_page,
|
|
12
|
+
is_title,
|
|
13
|
+
iter_chunks,
|
|
14
|
+
iter_pre_chunks,
|
|
15
|
+
)
|
|
16
|
+
from tryworks.documents.elements import Element
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def iter_chunks_by_title(
|
|
20
|
+
elements: Iterable[Element],
|
|
21
|
+
*,
|
|
22
|
+
combine_text_under_n_chars: Optional[int] = None,
|
|
23
|
+
include_orig_elements: Optional[bool] = None,
|
|
24
|
+
max_characters: Optional[int] = None,
|
|
25
|
+
max_tokens: Optional[int] = None,
|
|
26
|
+
multipage_sections: Optional[bool] = None,
|
|
27
|
+
new_after_n_chars: Optional[int] = None,
|
|
28
|
+
new_after_n_tokens: Optional[int] = None,
|
|
29
|
+
overlap: Optional[int] = None,
|
|
30
|
+
overlap_all: Optional[bool] = None,
|
|
31
|
+
tokenizer: Optional[Any] = None,
|
|
32
|
+
repeat_table_headers: Optional[bool] = None,
|
|
33
|
+
skip_table_chunking: Optional[bool] = None,
|
|
34
|
+
isolate_table: Optional[bool] = None,
|
|
35
|
+
) -> Iterator[Element]:
|
|
36
|
+
"""Lazily yield chunks; see ``chunk_by_title``."""
|
|
37
|
+
opts = ChunkingOptions(
|
|
38
|
+
max_characters=max_characters,
|
|
39
|
+
new_after_n_chars=new_after_n_chars,
|
|
40
|
+
overlap=overlap,
|
|
41
|
+
overlap_all=overlap_all,
|
|
42
|
+
combine_text_under_n_chars=combine_text_under_n_chars,
|
|
43
|
+
include_orig_elements=include_orig_elements,
|
|
44
|
+
repeat_table_headers=repeat_table_headers,
|
|
45
|
+
skip_table_chunking=skip_table_chunking,
|
|
46
|
+
isolate_table=isolate_table,
|
|
47
|
+
max_tokens=max_tokens,
|
|
48
|
+
new_after_n_tokens=new_after_n_tokens,
|
|
49
|
+
tokenizer=tokenizer,
|
|
50
|
+
combine_default_to_max=True,
|
|
51
|
+
)
|
|
52
|
+
multipage = CHUNK_MULTI_PAGE_DEFAULT if multipage_sections is None else multipage_sections
|
|
53
|
+
boundaries = (is_title,) if multipage else (is_title, is_on_next_page())
|
|
54
|
+
pre_chunks = iter_pre_chunks(elements, opts, boundaries)
|
|
55
|
+
if opts.combine_text_under_n_chars > 0:
|
|
56
|
+
pre_chunks = combine_pre_chunks(pre_chunks, opts)
|
|
57
|
+
yield from iter_chunks(pre_chunks, opts)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def chunk_by_title(elements: Iterable[Element], **kwargs: Any) -> list[Element]:
|
|
61
|
+
"""Group elements into chunks of up to ``max_characters`` (default 500), one section per Title.
|
|
62
|
+
|
|
63
|
+
``new_after_n_chars`` closes a chunk early once it reaches that size.
|
|
64
|
+
``combine_text_under_n_chars`` (default ``max_characters``) merges small sections.
|
|
65
|
+
``multipage_sections=False`` also starts a new section on every page.
|
|
66
|
+
``overlap`` repeats that many characters across split chunks (all chunks with ``overlap_all``).
|
|
67
|
+
``include_orig_elements`` (default True) keeps the source elements in ``metadata.orig_elements``.
|
|
68
|
+
"""
|
|
69
|
+
return list(iter_chunks_by_title(elements, **kwargs))
|
|
File without changes
|