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 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())
@@ -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