trustgraph-docling 2.7.10__tar.gz

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.
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: trustgraph-docling
3
+ Version: 2.7.10
4
+ Summary: TrustGraph document decoder powered by Docling — lightweight alternative to the unstructured-based decoder.
5
+ Author-email: "trustgraph.ai" <security@trustgraph.ai>
6
+ Project-URL: Homepage, https://github.com/trustgraph-ai/trustgraph
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.8
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: trustgraph-base<2.8,>=2.7
12
+ Requires-Dist: pulsar-client
13
+ Requires-Dist: prometheus-client
14
+ Requires-Dist: docling
15
+ Requires-Dist: onnxruntime
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "trustgraph-docling"
7
+ dynamic = ["version"]
8
+ authors = [{name = "trustgraph.ai", email = "security@trustgraph.ai"}]
9
+ description = "TrustGraph document decoder powered by Docling — lightweight alternative to the unstructured-based decoder."
10
+ readme = "README.md"
11
+ requires-python = ">=3.8"
12
+ dependencies = [
13
+ "trustgraph-base>=2.7,<2.8",
14
+ "pulsar-client",
15
+ "prometheus-client",
16
+ "docling",
17
+ "onnxruntime",
18
+ ]
19
+ classifiers = [
20
+ "Programming Language :: Python :: 3",
21
+ "Operating System :: OS Independent",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/trustgraph-ai/trustgraph"
26
+
27
+ [project.scripts]
28
+ docling-decoder = "trustgraph.decoding.docling:run"
29
+
30
+ [tool.setuptools.packages.find]
31
+ include = ["trustgraph*"]
32
+
33
+ [tool.setuptools.dynamic]
34
+ version = {attr = "trustgraph.docling_version.__version__"}
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+
2
+ from . processor import *
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from . processor import run
4
+
5
+ if __name__ == '__main__':
6
+ run()
@@ -0,0 +1,565 @@
1
+
2
+ """
3
+ Document decoder powered by Docling.
4
+
5
+ Accepts documents in common formats (PDF, DOCX, XLSX, HTML, Markdown,
6
+ plain text, PPTX, etc.) on input. Two output modes:
7
+
8
+ - page mode: emits pages/sections as TextDocument objects for
9
+ downstream chunking (drop-in replacement for the unstructured decoder)
10
+ - hybrid mode: uses Docling's HybridChunker to emit Chunk objects
11
+ directly, bypassing the downstream chunker
12
+
13
+ Supports both inline document data and fetching from librarian via
14
+ Pulsar for large documents.
15
+
16
+ Tables are preserved as HTML markup for better downstream extraction.
17
+ Images are stored in the librarian but not sent to the text pipeline.
18
+ """
19
+
20
+ import os
21
+ os.environ.setdefault("DOCLING_NUM_THREADS", "1")
22
+ os.environ.setdefault("OMP_NUM_THREADS", "1")
23
+
24
+ import base64
25
+ import io
26
+ import logging
27
+
28
+ from docling.datamodel.base_models import InputFormat
29
+ from docling.datamodel.pipeline_options import PdfPipelineOptions
30
+ from docling.document_converter import DocumentConverter, DocumentStream, PdfFormatOption
31
+ from docling.backend.pypdfium2_backend import PyPdfiumDocumentBackend
32
+ from docling.chunking import HybridChunker
33
+
34
+ from ... schema import Document, TextDocument, Chunk, Metadata
35
+ from ... schema import Triples, Triple, Term, IRI, LITERAL
36
+ from ... base import FlowProcessor, ConsumerSpec, ProducerSpec, LibrarianSpec
37
+
38
+ from ... provenance import (
39
+ document_uri, page_uri as make_page_uri,
40
+ section_uri as make_section_uri, chunk_uri as make_chunk_uri,
41
+ image_uri as make_image_uri,
42
+ derived_entity_triples, set_graph, GRAPH_SOURCE,
43
+ TG_PAGE_NUMBER,
44
+ )
45
+
46
+ COMPONENT_NAME = "docling-decoder"
47
+ COMPONENT_VERSION = "1.0.0"
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+ default_ident = "document-decoder"
52
+
53
+ MIME_EXTENSIONS = {
54
+ "application/pdf": ".pdf",
55
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
56
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ".xlsx",
57
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
58
+ "text/html": ".html",
59
+ "text/markdown": ".md",
60
+ "text/plain": ".md",
61
+ "text/csv": ".csv",
62
+ }
63
+
64
+ PAGE_BASED_FORMATS = {
65
+ "application/pdf",
66
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
67
+ }
68
+
69
+
70
+ class Processor(FlowProcessor):
71
+
72
+ def __init__(self, **params):
73
+
74
+ id = params.get("id", default_ident)
75
+
76
+ self.chunking_mode = params.get("chunking_mode", "page")
77
+ self.chunk_max_tokens = params.get("chunk_max_tokens", 512)
78
+ self.languages = params.get("languages", "eng").split(",")
79
+
80
+ pipeline_options = PdfPipelineOptions()
81
+ pipeline_options.do_ocr = True
82
+ pipeline_options.do_table_structure = True
83
+ pipeline_options.generate_page_images = True
84
+
85
+ self.converter = DocumentConverter(
86
+ allowed_formats=[
87
+ InputFormat.PDF, InputFormat.DOCX, InputFormat.XLSX,
88
+ InputFormat.PPTX, InputFormat.HTML, InputFormat.MD,
89
+ InputFormat.CSV,
90
+ ],
91
+ format_options={
92
+ InputFormat.PDF: PdfFormatOption(
93
+ pipeline_options=pipeline_options,
94
+ backend=PyPdfiumDocumentBackend,
95
+ ),
96
+ },
97
+ )
98
+
99
+ super(Processor, self).__init__(
100
+ **params | {
101
+ "id": id,
102
+ }
103
+ )
104
+
105
+ self.register_specification(
106
+ ConsumerSpec(
107
+ name="input",
108
+ schema=Document,
109
+ handler=self.on_message,
110
+ )
111
+ )
112
+
113
+ self.register_specification(
114
+ ProducerSpec(
115
+ name="output",
116
+ schema=TextDocument,
117
+ )
118
+ )
119
+
120
+ if self.chunking_mode == "hybrid":
121
+ self.register_specification(
122
+ ProducerSpec(
123
+ name="chunks",
124
+ schema=Chunk,
125
+ )
126
+ )
127
+
128
+ self.register_specification(
129
+ ProducerSpec(
130
+ name="triples",
131
+ schema=Triples,
132
+ )
133
+ )
134
+
135
+ self.register_specification(
136
+ LibrarianSpec()
137
+ )
138
+
139
+ logger.info(
140
+ f"Docling decoder initialized (mode: {self.chunking_mode})"
141
+ )
142
+
143
+ def convert_document(self, blob, mime_type=None, name_hint="document"):
144
+ """
145
+ Convert raw bytes to a DoclingDocument.
146
+
147
+ Uses single-document convert with raises_on_error=False
148
+ to capture page-level errors without batch panic.
149
+ """
150
+ suffix = MIME_EXTENSIONS.get(mime_type, ".bin")
151
+ routing_filename = f"source_file{suffix}"
152
+
153
+ buf = io.BytesIO(blob)
154
+ stream = DocumentStream(name=routing_filename, stream=buf)
155
+
156
+ result = self.converter.convert(stream, raises_on_error=True)
157
+
158
+ if result.errors:
159
+ logger.error(
160
+ f"Docling backend failed processing pages for {name_hint}"
161
+ )
162
+ for err in result.errors:
163
+ logger.error(
164
+ f"-> Conversion error: {err!r} "
165
+ f"attrs={vars(err) if hasattr(err, '__dict__') else 'N/A'}"
166
+ )
167
+
168
+ if result.document:
169
+ logger.warning(
170
+ "Returning partially recovered layout context."
171
+ )
172
+ return result.document
173
+
174
+ raise ValueError(
175
+ f"Conversion completely failed for {name_hint}. "
176
+ f"First error: {repr(result.errors[0])}"
177
+ )
178
+
179
+ logger.info(f"Docling conversion complete for {routing_filename}")
180
+ return result.document
181
+
182
+ def get_page_texts(self, doc):
183
+ """
184
+ Extract text grouped by page from a DoclingDocument.
185
+
186
+ Returns list of (page_number, text, table_count, image_count).
187
+ An item that spans multiple pages is included in each page.
188
+ """
189
+ page_map = {}
190
+
191
+ for item, _level in doc.iterate_items():
192
+ prov = getattr(item, 'prov', None)
193
+
194
+ page_nos = set()
195
+ if prov:
196
+ for p in prov:
197
+ pg = getattr(p, 'page_no', None)
198
+ if pg is not None:
199
+ page_nos.add(pg)
200
+
201
+ if not page_nos:
202
+ page_nos.add(1)
203
+
204
+ item_type = type(item).__name__
205
+
206
+ for page_no in page_nos:
207
+ if page_no not in page_map:
208
+ page_map[page_no] = {
209
+ "texts": [],
210
+ "table_count": 0,
211
+ "image_count": 0,
212
+ }
213
+
214
+ if item_type == "PictureItem":
215
+ page_map[page_no]["image_count"] += 1
216
+ continue
217
+
218
+ if item_type == "TableItem":
219
+ page_map[page_no]["table_count"] += 1
220
+ html = item.export_to_html(doc=doc)
221
+ if html:
222
+ page_map[page_no]["texts"].append(html)
223
+ continue
224
+
225
+ text = getattr(item, 'text', None)
226
+ if text and text.strip():
227
+ page_map[page_no]["texts"].append(text)
228
+
229
+ pages = []
230
+ for page_no in sorted(page_map.keys()):
231
+ info = page_map[page_no]
232
+ text = "\n\n".join(info["texts"])
233
+ if text.strip():
234
+ pages.append((
235
+ page_no, text,
236
+ info["table_count"], info["image_count"],
237
+ ))
238
+
239
+ return pages
240
+
241
+ def get_full_text(self, doc):
242
+ """
243
+ Export the full document as markdown text.
244
+
245
+ Tables are exported as HTML for structural fidelity.
246
+ """
247
+ parts = []
248
+ table_count = 0
249
+ image_count = 0
250
+
251
+ for item, _level in doc.iterate_items():
252
+ item_type = type(item).__name__
253
+
254
+ if item_type == "PictureItem":
255
+ image_count += 1
256
+ continue
257
+
258
+ if item_type == "TableItem":
259
+ table_count += 1
260
+ html = item.export_to_html(doc=doc)
261
+ if html:
262
+ parts.append(html)
263
+ continue
264
+
265
+ text = getattr(item, 'text', None)
266
+ if text and text.strip():
267
+ parts.append(text)
268
+
269
+ return "\n\n".join(parts), table_count, image_count
270
+
271
+ async def emit_section(self, text, parent_doc_id, doc_uri_str,
272
+ metadata, flow, mime_type=None,
273
+ page_number=None, section_index=None,
274
+ table_count=0, image_count=0):
275
+ """
276
+ Save a page or section to the librarian, emit provenance,
277
+ and send a TextDocument downstream.
278
+ """
279
+ if not text.strip():
280
+ return None
281
+
282
+ is_page = page_number is not None
283
+ char_length = len(text)
284
+
285
+ if is_page:
286
+ entity_uri = make_page_uri()
287
+ label = f"Page {page_number}"
288
+ else:
289
+ entity_uri = make_section_uri()
290
+ label = f"Section {section_index}" if section_index else "Section"
291
+
292
+ doc_id = entity_uri
293
+ content = text.encode("utf-8")
294
+
295
+ await flow.librarian.save_child_document(
296
+ doc_id=doc_id,
297
+ parent_id=parent_doc_id,
298
+ content=content,
299
+ document_type="page" if is_page else "section",
300
+ title=label,
301
+ )
302
+
303
+ prov_triples = derived_entity_triples(
304
+ entity_uri=entity_uri,
305
+ parent_uri=doc_uri_str,
306
+ component_name=COMPONENT_NAME,
307
+ component_version=COMPONENT_VERSION,
308
+ label=label,
309
+ page_number=page_number,
310
+ section=not is_page,
311
+ char_length=char_length,
312
+ mime_type=mime_type,
313
+ table_count=table_count if table_count > 0 else None,
314
+ image_count=image_count if image_count > 0 else None,
315
+ )
316
+
317
+ await flow("triples").send(Triples(
318
+ metadata=Metadata(
319
+ id=entity_uri,
320
+ root=metadata.root,
321
+ collection=metadata.collection,
322
+ ),
323
+ triples=set_graph(prov_triples, GRAPH_SOURCE),
324
+ ))
325
+
326
+ r = TextDocument(
327
+ metadata=Metadata(
328
+ id=entity_uri,
329
+ root=metadata.root,
330
+ collection=metadata.collection,
331
+ ),
332
+ document_id=doc_id,
333
+ text=content,
334
+ )
335
+
336
+ await flow("output").send(r)
337
+
338
+ return entity_uri
339
+
340
+ async def emit_chunk(self, text, chunk_index, parent_doc_id,
341
+ doc_uri_str, metadata, flow,
342
+ page_numbers=None, char_offset=None):
343
+ """
344
+ Save a chunk to the librarian, emit provenance, and send
345
+ a Chunk message downstream.
346
+ """
347
+ if not text.strip():
348
+ return None
349
+
350
+ c_uri = make_chunk_uri()
351
+ chunk_content = text.encode("utf-8")
352
+ char_length = len(text)
353
+
354
+ primary_page = page_numbers[0] if page_numbers else None
355
+
356
+ await flow.librarian.save_child_document(
357
+ doc_id=c_uri,
358
+ parent_id=parent_doc_id,
359
+ content=chunk_content,
360
+ document_type="chunk",
361
+ title=f"Chunk {chunk_index}",
362
+ )
363
+
364
+ prov_triples = derived_entity_triples(
365
+ entity_uri=c_uri,
366
+ parent_uri=doc_uri_str,
367
+ component_name=COMPONENT_NAME,
368
+ component_version=COMPONENT_VERSION,
369
+ label=f"Chunk {chunk_index}",
370
+ chunk_index=chunk_index,
371
+ char_offset=char_offset,
372
+ char_length=char_length,
373
+ page_number=primary_page,
374
+ )
375
+
376
+ if page_numbers and len(page_numbers) > 1:
377
+ for extra_page in page_numbers[1:]:
378
+ prov_triples.append(Triple(
379
+ s=Term(type=IRI, iri=c_uri),
380
+ p=Term(type=IRI, iri=TG_PAGE_NUMBER),
381
+ o=Term(type=LITERAL, value=str(extra_page)),
382
+ ))
383
+
384
+ await flow("triples").send(Triples(
385
+ metadata=Metadata(
386
+ id=c_uri,
387
+ root=metadata.root,
388
+ collection=metadata.collection,
389
+ ),
390
+ triples=set_graph(prov_triples, GRAPH_SOURCE),
391
+ ))
392
+
393
+ r = Chunk(
394
+ metadata=Metadata(
395
+ id=c_uri,
396
+ root=metadata.root,
397
+ collection=metadata.collection,
398
+ ),
399
+ chunk=chunk_content,
400
+ document_id=c_uri,
401
+ )
402
+
403
+ await flow("chunks").send(r)
404
+
405
+ return c_uri
406
+
407
+ async def process_page_mode(self, doc, source_doc_id, doc_uri_str,
408
+ metadata, flow, mime_type):
409
+ """Page mode: emit pages or sections as TextDocument."""
410
+ is_page_based = mime_type in PAGE_BASED_FORMATS if mime_type else False
411
+
412
+ logger.info(
413
+ f"Page mode: mime={mime_type} is_page_based={is_page_based}"
414
+ )
415
+
416
+ if is_page_based:
417
+ pages = self.get_page_texts(doc)
418
+ logger.info(f"Extracted {len(pages)} pages")
419
+ for page_num, text, table_count, image_count in pages:
420
+ await self.emit_section(
421
+ text, source_doc_id, doc_uri_str,
422
+ metadata, flow,
423
+ mime_type=mime_type, page_number=page_num,
424
+ table_count=table_count, image_count=image_count,
425
+ )
426
+ else:
427
+ text, table_count, image_count = self.get_full_text(doc)
428
+ if text.strip():
429
+ await self.emit_section(
430
+ text, source_doc_id, doc_uri_str,
431
+ metadata, flow,
432
+ mime_type=mime_type, section_index=1,
433
+ table_count=table_count, image_count=image_count,
434
+ )
435
+
436
+ async def process_hybrid_mode(self, doc, source_doc_id, doc_uri_str,
437
+ metadata, flow, mime_type):
438
+ """Hybrid mode: use Docling HybridChunker, emit Chunk directly."""
439
+ chunker = HybridChunker(
440
+ max_tokens=self.chunk_max_tokens,
441
+ )
442
+
443
+ chunks = chunker.chunk(doc)
444
+ char_offset = 0
445
+
446
+ for idx, chunk in enumerate(chunks):
447
+ chunk_index = idx + 1
448
+ text = chunker.serialize(chunk)
449
+
450
+ page_numbers = None
451
+ meta = getattr(chunk, 'meta', None)
452
+ if meta:
453
+ pn = getattr(meta, 'doc_items', None)
454
+ if pn:
455
+ pages = set()
456
+ for di in pn:
457
+ for p in getattr(di, 'prov', []):
458
+ pg = getattr(p, 'page_no', None)
459
+ if pg is not None:
460
+ pages.add(pg)
461
+ if pages:
462
+ page_numbers = sorted(pages)
463
+
464
+ await self.emit_chunk(
465
+ text, chunk_index, source_doc_id, doc_uri_str,
466
+ metadata, flow,
467
+ page_numbers=page_numbers,
468
+ char_offset=char_offset,
469
+ )
470
+
471
+ char_offset += len(text)
472
+
473
+ async def on_message(self, msg, consumer, flow):
474
+
475
+ logger.debug("Document message received")
476
+
477
+ v = msg.value()
478
+
479
+ logger.info(f"Decoding {v.metadata.id}...")
480
+
481
+ mime_type = None
482
+
483
+ if v.document_id:
484
+ logger.info(
485
+ f"Fetching document {v.document_id} from librarian..."
486
+ )
487
+
488
+ doc_meta = await flow.librarian.fetch_document_metadata(
489
+ document_id=v.document_id,
490
+ )
491
+ mime_type = doc_meta.kind if doc_meta else None
492
+
493
+ content = await flow.librarian.fetch_document_content(
494
+ document_id=v.document_id,
495
+ )
496
+
497
+ if isinstance(content, str):
498
+ content = content.encode('utf-8')
499
+ blob = base64.b64decode(content)
500
+
501
+ logger.info(
502
+ f"Fetched {len(blob)} bytes, mime: {mime_type}"
503
+ )
504
+ else:
505
+ blob = base64.b64decode(v.data)
506
+
507
+ source_doc_id = v.document_id or v.metadata.id
508
+ doc_uri_str = document_uri(source_doc_id)
509
+
510
+ try:
511
+ doc = self.convert_document(
512
+ blob, mime_type, name_hint=source_doc_id,
513
+ )
514
+ except Exception as e:
515
+ logger.error(
516
+ f"Failed to convert {source_doc_id}: "
517
+ f"{type(e).__name__}: {e}",
518
+ exc_info=True,
519
+ )
520
+ return
521
+
522
+ if self.chunking_mode == "hybrid":
523
+ await self.process_hybrid_mode(
524
+ doc, source_doc_id, doc_uri_str,
525
+ v.metadata, flow, mime_type,
526
+ )
527
+ else:
528
+ await self.process_page_mode(
529
+ doc, source_doc_id, doc_uri_str,
530
+ v.metadata, flow, mime_type,
531
+ )
532
+
533
+ logger.info("Document decoding complete")
534
+
535
+ @staticmethod
536
+ def add_args(parser):
537
+
538
+ FlowProcessor.add_args(parser)
539
+
540
+ parser.add_argument(
541
+ '--chunking-mode',
542
+ default='page',
543
+ choices=['page', 'hybrid'],
544
+ help='Chunking mode: page emits TextDocument for downstream '
545
+ 'chunker, hybrid uses Docling HybridChunker directly '
546
+ '(default: page)',
547
+ )
548
+
549
+ parser.add_argument(
550
+ '--languages',
551
+ default='eng',
552
+ help='Comma-separated OCR language codes (default: eng)',
553
+ )
554
+
555
+ parser.add_argument(
556
+ '--chunk-max-tokens',
557
+ type=int,
558
+ default=512,
559
+ help='Max tokens per chunk for hybrid mode (default: 512)',
560
+ )
561
+
562
+
563
+ def run():
564
+
565
+ Processor.launch(default_ident, __doc__)
@@ -0,0 +1 @@
1
+ __version__ = "2.7.10"
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.4
2
+ Name: trustgraph-docling
3
+ Version: 2.7.10
4
+ Summary: TrustGraph document decoder powered by Docling — lightweight alternative to the unstructured-based decoder.
5
+ Author-email: "trustgraph.ai" <security@trustgraph.ai>
6
+ Project-URL: Homepage, https://github.com/trustgraph-ai/trustgraph
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.8
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: trustgraph-base<2.8,>=2.7
12
+ Requires-Dist: pulsar-client
13
+ Requires-Dist: prometheus-client
14
+ Requires-Dist: docling
15
+ Requires-Dist: onnxruntime
@@ -0,0 +1,11 @@
1
+ pyproject.toml
2
+ trustgraph/docling_version.py
3
+ trustgraph/decoding/docling/__init__.py
4
+ trustgraph/decoding/docling/__main__.py
5
+ trustgraph/decoding/docling/processor.py
6
+ trustgraph_docling.egg-info/PKG-INFO
7
+ trustgraph_docling.egg-info/SOURCES.txt
8
+ trustgraph_docling.egg-info/dependency_links.txt
9
+ trustgraph_docling.egg-info/entry_points.txt
10
+ trustgraph_docling.egg-info/requires.txt
11
+ trustgraph_docling.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ docling-decoder = trustgraph.decoding.docling:run
@@ -0,0 +1,5 @@
1
+ trustgraph-base<2.8,>=2.7
2
+ pulsar-client
3
+ prometheus-client
4
+ docling
5
+ onnxruntime