document2graph 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.
@@ -0,0 +1,10 @@
1
+ from pydantic import BaseModel
2
+ from .DocumentMetadata import DocumentMetadata
3
+
4
+
5
+ class Document(BaseModel):
6
+ document_id: str = ""
7
+ document_type: str = ""
8
+ filename: str = ""
9
+ title: str = ""
10
+ metadata: DocumentMetadata = DocumentMetadata()
@@ -0,0 +1,23 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class MetadataFieldConfig(BaseModel):
5
+ label: str # substring to search for in the document text
6
+ pages: tuple[int, int] # inclusive 1-based page range to search on
7
+
8
+
9
+ class MetadataExtractionConfig(BaseModel):
10
+ title_page: int = 2
11
+ version: MetadataFieldConfig | None = MetadataFieldConfig(label="version", pages=(1, 1))
12
+ authors: MetadataFieldConfig | None = MetadataFieldConfig(label="korrespondenzadresse", pages=(2, 2))
13
+ institutions: MetadataFieldConfig | None = MetadataFieldConfig(label="institute", pages=(2, 2))
14
+ bibliography: MetadataFieldConfig | None = MetadataFieldConfig(label="bibliografie", pages=(2, 2))
15
+ correspondence: MetadataFieldConfig | None = MetadataFieldConfig(label="korrespondenzadresse", pages=(2, 2))
16
+
17
+
18
+ class DocumentMetadata(BaseModel):
19
+ version: str = ""
20
+ authors: str = ""
21
+ institutions: str = ""
22
+ bibliography: str = ""
23
+ correspondence: str = ""
@@ -0,0 +1,18 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class EdgeWeightConfig(BaseModel):
5
+ """Weights assigned to graph edges by structural category.
6
+
7
+ Higher values indicate a stronger structural connection between
8
+ parent and child node. All weights are stored on the edge as the
9
+ ``weight`` attribute (both in the returned edge list and in the
10
+ exported GEXF graph).
11
+ """
12
+
13
+ section: float = 1.0 # heading -> subheading
14
+ text: float = 0.8 # heading/body -> body text, subtext, footnotes
15
+ list_item: float = 0.6 # anchor text -> grouped list item (bullets)
16
+ media: float = 0.9 # referencing text -> image/table (matched via caption)
17
+ unreferenced_media: float = 0.3 # fallback heading -> image/table without a caption match
18
+ root: float = 0.1 # synthetic document root -> otherwise disconnected node
@@ -0,0 +1,14 @@
1
+ from pydantic import BaseModel, Field
2
+ from docling.datamodel.pipeline_options import PdfPipelineOptions
3
+ from .DocumentMetadata import MetadataExtractionConfig
4
+ from .EdgeWeightConfig import EdgeWeightConfig
5
+
6
+
7
+ class ExtractorConfig(BaseModel):
8
+ pdf_path: str
9
+ data_path: str
10
+ pdfPipelineOptions: PdfPipelineOptions = PdfPipelineOptions()
11
+ document_type: str = ""
12
+ save_json: bool = True
13
+ metadata_config: MetadataExtractionConfig = Field(default_factory=MetadataExtractionConfig)
14
+ edge_weights: EdgeWeightConfig = Field(default_factory=EdgeWeightConfig)
@@ -0,0 +1,21 @@
1
+ from pydantic import BaseModel
2
+ from docling_core.types.doc.document import RefItem
3
+ from docling_core.types.doc.base import BoundingBox
4
+ from .TextSnippetNode import TextSnippetNode
5
+
6
+
7
+ class ImageSnippetNode(BaseModel):
8
+ snippet_id: str
9
+ document_id: str
10
+ label: str
11
+ sequence_no: int
12
+ level: int
13
+ level_label: str
14
+ parent_id: str
15
+ docling_parent_ref: RefItem | None
16
+ docling_self_ref: RefItem | None
17
+ is_grouped: bool = False
18
+ caption_nodes: list[TextSnippetNode] | list = []
19
+ caption_text: str
20
+ bbox: BoundingBox
21
+ page_no: int
@@ -0,0 +1,19 @@
1
+ from pydantic import BaseModel
2
+ from docling_core.types.doc.document import RefItem
3
+ from docling_core.types.doc.base import BoundingBox
4
+
5
+ class Snippet(BaseModel):
6
+ snippet_id: str
7
+ type: str
8
+ document_id: str
9
+ sequence_no: int
10
+ label: str
11
+ level: int
12
+ level_label: str
13
+ parent_id: str | None
14
+ is_grouped: bool = False
15
+ page_no: int
16
+ bbox: BoundingBox
17
+ text: str
18
+ docling_parent_ref: RefItem | None
19
+ docling_self_ref: RefItem | None
@@ -0,0 +1,6 @@
1
+
2
+ from .ImageSnippetNode import ImageSnippetNode
3
+
4
+
5
+ class TableSnippetNode(ImageSnippetNode):
6
+ markdown_serialization: str
@@ -0,0 +1,6 @@
1
+ from docling_core.types.doc.document import TextItem
2
+ from pydantic import BaseModel
3
+
4
+ class TextSnippet(BaseModel):
5
+ text_item: TextItem
6
+ line_heights: list[float] = []
@@ -0,0 +1,19 @@
1
+ from pydantic import BaseModel
2
+ from docling_core.types.doc.document import RefItem
3
+ from docling_core.types.doc.base import BoundingBox
4
+
5
+ class TextSnippetNode(BaseModel):
6
+ snippet_id: str
7
+ document_id: str
8
+ label: str
9
+ sequence_no: int
10
+ level: int
11
+ level_label: str
12
+ parent_id: str | None
13
+ docling_parent_ref: RefItem
14
+ docling_self_ref: RefItem
15
+ is_grouped: bool = False
16
+ text: str
17
+ bbox: BoundingBox
18
+ charspan: tuple[int, int]
19
+ page_no: int
@@ -0,0 +1,25 @@
1
+ from .Chunk import Chunk
2
+ from .EdgeWeightConfig import EdgeWeightConfig
3
+ from .ExtractorConfig import ExtractorConfig
4
+ from .Snippet import Snippet
5
+ from .Document import Document
6
+ from .DocumentMetadata import DocumentMetadata, MetadataExtractionConfig, MetadataFieldConfig
7
+ from .ImageSnippetNode import ImageSnippetNode
8
+ from .TableSnippetNode import TableSnippetNode
9
+ from .TextSnippetNode import TextSnippetNode
10
+ from .TextSnippet import TextSnippet
11
+
12
+ __all__ = [
13
+ "Chunk",
14
+ "EdgeWeightConfig",
15
+ "ExtractorConfig",
16
+ "Snippet",
17
+ "Document",
18
+ "DocumentMetadata",
19
+ "MetadataExtractionConfig",
20
+ "MetadataFieldConfig",
21
+ "ImageSnippetNode",
22
+ "TableSnippetNode",
23
+ "TextSnippetNode",
24
+ "TextSnippet",
25
+ ]
File without changes
@@ -0,0 +1,127 @@
1
+ from docling.document_converter import DocumentConverter, PdfFormatOption
2
+ from docling.datamodel.base_models import InputFormat
3
+ from docling_core.types.doc.document import DoclingDocument, TableItem, TextItem, PictureItem
4
+ from docling.datamodel.pipeline_options import PdfPipelineOptions
5
+ from docling_core.types.doc.document import GroupItem, RefItem
6
+ import os
7
+ import pickle
8
+ from .log import Log
9
+
10
+ class Extractor:
11
+ def __init__(self, source: str, pipeline_options: PdfPipelineOptions = PdfPipelineOptions()):
12
+ self.source = source
13
+ self.converter = DocumentConverter(format_options={
14
+ InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_options)
15
+ })
16
+ self.docling_doc = self.converter.convert(source)
17
+ self.doc = self.docling_doc.document
18
+
19
+ self.logger = Log("Extractor").logger
20
+
21
+
22
+ def is_furniture(self, item):
23
+ """
24
+ Check if the item is furniture (e.g., title page, table of contents).
25
+ """
26
+ return item.content_layer.value == "furniture"
27
+
28
+ def is_text(self, item):
29
+ """
30
+ Check if the item is a text item.
31
+ """
32
+ return isinstance(item, TextItem)
33
+
34
+ def is_picture(self, item):
35
+ """
36
+ Check if the item is a picture item.
37
+ """
38
+ return isinstance(item, PictureItem)
39
+
40
+ def is_table(self, item):
41
+ """
42
+ Check if the item is a table item.
43
+ """
44
+ return isinstance(item, TableItem)
45
+
46
+ def is_group(self, item):
47
+ """
48
+ Check if the item is a group item.
49
+ """
50
+ return isinstance(item, GroupItem)
51
+
52
+ def serialize_ref_items(
53
+ self, ref_items: list[RefItem], doc: DoclingDocument
54
+ ) -> tuple[list[str], list[str]]:
55
+ """
56
+ Serialize the reference items to a string representation.
57
+ This function is necessary to handle also string representations of groups, tables and images.
58
+ Only for annotation of extracted doc necessary.
59
+ """
60
+ serialized_items = []
61
+ serialized_labels = []
62
+ for refItem in ref_items:
63
+ item = refItem.resolve(doc)
64
+ if self.is_furniture(item):
65
+ continue
66
+ if self.is_text(item):
67
+ serialized_items.append(item.text)
68
+ serialized_labels.append(item.label.value)
69
+ elif self.is_picture(item):
70
+ serialized_items.append(item.caption_text(doc))
71
+ serialized_labels.append(item.label.value)
72
+ elif self.is_table(item):
73
+ serialized_items.append(item.export_to_markdown(doc))
74
+ serialized_labels.append(item.label.value)
75
+ elif self.is_group(item):
76
+ # Recursively serialize group items
77
+ group_items, group_labels = self.serialize_ref_items(item.children, doc)
78
+ serialized_items.append(group_items)
79
+ serialized_labels.append(group_labels)
80
+ else:
81
+ serialized_items.append(item.text)
82
+ serialized_labels.append(item.label.value)
83
+ return serialized_items, serialized_labels
84
+
85
+
86
+ def serialize_doc(self, doc: DoclingDocument) -> dict:
87
+ """
88
+ Serialize the document to a string representation.
89
+ Leave furniture out. Bring text in order, to see if we can manually label the paragraphs.
90
+ """
91
+ full_text, full_text_labels = self.serialize_ref_items(
92
+ doc.body.children, doc
93
+ )
94
+ return {
95
+ "full_text": full_text,
96
+ "full_text_labels": full_text_labels,
97
+ "pictures": doc.pictures,
98
+ "tables": doc.tables,
99
+ "texts": doc.texts,
100
+ }
101
+
102
+ def extract(self, save_dir: str, filename: str) -> dict:
103
+ """
104
+ Extract and serialize the document, saving intermediate results.
105
+ """
106
+ self.logger.info(f"Saving parsed pdf as {filename}.json")
107
+
108
+ os.makedirs(save_dir, exist_ok=True)
109
+
110
+ self.docling_doc.document.save_as_json(
111
+ f"{save_dir}/{filename}_docling_doc.json"
112
+ )
113
+ self.logger.info(f"Start extracting {filename}...")
114
+ result = self.serialize_doc(self.doc)
115
+ with open(f"{save_dir}/{filename}_serialized_doc.pkl", "wb") as f:
116
+ pickle.dump(result, f)
117
+ return result
118
+
119
+
120
+ def main():
121
+ # only for quick testing
122
+ source = "../pdf/ddg_pdf/ddg_praxisempfehlungen/DuS_2024_S02_Praxisempfehlungen_Aberle_Adipositas-und-Diabetes.pdf"
123
+ extractor = Extractor(source)
124
+ _ = extractor.extract("test", "test")
125
+
126
+ if __name__ == "__main__":
127
+ main()
@@ -0,0 +1,52 @@
1
+ import numpy as np
2
+
3
+ # schemas
4
+ from ..models.TextSnippet import TextSnippet
5
+
6
+ def get_heights(text_items: list[TextSnippet], skip_first_page: bool = False) -> np.ndarray:
7
+ if skip_first_page:
8
+ heights = [height for snippet in text_items for height in snippet.line_heights if snippet.text_item.prov[0].page_no > 1]
9
+ else:
10
+ heights = [height for snippet in text_items for height in snippet.line_heights]
11
+ return np.multiply(np.round(np.array(heights), 1), 10)
12
+
13
+ def get_height_hist(heights=np.array, plot=False) -> dict[int, int]:
14
+ if not heights.size:
15
+ print("No heights found, returning empty histogram.")
16
+ return {}
17
+ unique_heights, counts = np.unique(heights, return_counts=True)
18
+ height_hist = dict(zip(unique_heights, counts))
19
+ if plot:
20
+ # matplotlib is only needed for this debug plot, not a package dependency
21
+ import matplotlib.pyplot as plt
22
+ plt.bar(height_hist.keys(), height_hist.values()) # type: ignore
23
+ xticks = unique_heights[::max(1, len(unique_heights)//20)]
24
+ plt.xticks(xticks, rotation=90, size=6)
25
+ plt.xlabel("Font size")
26
+ plt.ylabel("Count")
27
+ plt.show()
28
+
29
+ return height_hist
30
+
31
+ def get_text_body_height(heights: np.ndarray) -> tuple[float, float]:
32
+ assert heights.size > 0, "No heights provided to compute text body height."
33
+ # text body level range: between 25th and 75th percentile
34
+ text_body_height = np.percentile(heights, 50) # median
35
+ iqr = np.percentile(heights, 75) - np.percentile(heights, 25)
36
+ return float(text_body_height), float(iqr)
37
+
38
+ def line_height_to_key(heights: list[float]) -> np.ndarray:
39
+ return np.multiply(heights, 10).astype(int) # multiply by 10 to avoid float precision issues
40
+
41
+ def compute_snippet_level(snippet: TextSnippet, levels: dict[int, int]) -> int:
42
+ heights = line_height_to_key(snippet.line_heights)
43
+ if not heights.size:
44
+ return 1000 # unknown level
45
+ # get median height in case there are multiple lines with different heights
46
+ median_height = np.median(heights)
47
+ if median_height in levels:
48
+ return levels[int(median_height)]
49
+ else:
50
+ # find closest height in levels
51
+ closest_height = min(levels.keys(), key=lambda h: abs(h - median_height))
52
+ return levels[closest_height]
@@ -0,0 +1,26 @@
1
+ import os
2
+ from .base_extractor import Extractor
3
+ from docling.datamodel.pipeline_options import PdfPipelineOptions
4
+ from docling_core.transforms.chunker.hybrid_chunker import HybridChunker
5
+ from docling_core.transforms.chunker.tokenizer.huggingface import HuggingFaceTokenizer
6
+
7
+ class HybridChunkerExtractor(Extractor):
8
+ def __init__(self, source: str, merge_peers: bool, pipeline_options: PdfPipelineOptions = PdfPipelineOptions()):
9
+ super().__init__(source, pipeline_options)
10
+ self.merge_peers = merge_peers
11
+ self.chunker = HybridChunker(
12
+ tokenizer=HuggingFaceTokenizer.from_pretrained("google/gemma-3-4b-it", max_tokens=131072),
13
+ merge_peers=merge_peers)
14
+
15
+ def extract_and_chunk(self, save_dir: str, filename: str):
16
+ # First, we extract the document using the base extractor logic
17
+ docling_doc = self.converter.convert(self.source).document
18
+ # Then, we apply the hybrid chunker to the extracted document
19
+ self.logger.info(f"Saving parsed pdf as {filename}.json")
20
+
21
+ os.makedirs(save_dir, exist_ok=True)
22
+
23
+ docling_doc.save_as_json(
24
+ f"{save_dir}/{filename}_baseline_docling_doc.json"
25
+ )
26
+ yield from self.chunker.chunk(docling_doc)
@@ -0,0 +1,40 @@
1
+ import sys
2
+ import coloredlogs
3
+ import logging
4
+ from pprint import pprint
5
+
6
+ logger = logging.getLogger()
7
+ coloredlogs.install(level='INFO', logger=logger, isatty=True)
8
+
9
+
10
+ class Log:
11
+ def __init__(self, name):
12
+ self.logger = logging.getLogger(name)
13
+
14
+ def log(self, s):
15
+ self.logger.info(s)
16
+ sys.stdout.flush()
17
+
18
+ def plog(self, s):
19
+ plog(s)
20
+
21
+ def info(self, s):
22
+ self.logger.info(s)
23
+ sys.stdout.flush()
24
+
25
+ def debug(self, s):
26
+ pprint(s)
27
+ sys.stdout.flush()
28
+
29
+ def error(self, s):
30
+ self.logger.error(s)
31
+ sys.stdout.flush()
32
+
33
+ def warning(self, s):
34
+ self.logger.warning(s)
35
+ sys.stdout.flush()
36
+
37
+
38
+ def plog(s):
39
+ pprint(s)
40
+ sys.stdout.flush()
@@ -0,0 +1,243 @@
1
+ Metadata-Version: 2.4
2
+ Name: document2graph
3
+ Version: 0.1.0
4
+ Summary: Extract hierarchical document graphs and baseline chunks from PDF
5
+ Author-email: Marina Walther <marina.walther@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/mwalther10/document2graph
8
+ Project-URL: Issues, https://github.com/mwalther10/document2graph/issues
9
+ Keywords: pdf,document2graph,docling,networkx,chunking,retrieval
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Text Processing :: Indexing
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: docling
22
+ Requires-Dist: docling-core
23
+ Requires-Dist: docling-parse
24
+ Requires-Dist: numpy
25
+ Requires-Dist: pydantic>=2
26
+ Requires-Dist: regex
27
+ Requires-Dist: networkx
28
+ Requires-Dist: coloredlogs
29
+ Requires-Dist: tqdm
30
+ Requires-Dist: transformers
31
+ Provides-Extra: dev
32
+ Requires-Dist: pytest; extra == "dev"
33
+ Requires-Dist: httpx; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # document2graph
37
+
38
+ Extract hierarchical document graphs and baseline chunks from PDF files. The package parses PDFs using [Docling](https://github.com/DS4SD/docling) and produces two complementary representations:
39
+
40
+ - **Document graph** — text, image, and table nodes connected by weighted structural edges, capturing the layout hierarchy of the document. Every graph is guaranteed to be connected: nodes without a resolvable parent are attached to a synthetic document root node.
41
+ - **Baseline chunks** — flat, semantically merged chunks suitable for retrieval pipelines, with optional context enrichment.
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ pip install document2graph
47
+ ```
48
+
49
+ Or from source:
50
+
51
+ ```bash
52
+ pip install -e .
53
+ ```
54
+
55
+ For development dependencies (pytest):
56
+
57
+ ```bash
58
+ pip install -e ".[dev]"
59
+ ```
60
+
61
+ Requires Python 3.11+.
62
+
63
+ ## Quick start
64
+
65
+ ### Configuration
66
+
67
+ Both extractors share the same `ExtractorConfig`:
68
+
69
+ ```python
70
+ from document2graph.models import ExtractorConfig
71
+ from docling.datamodel.pipeline_options import PdfPipelineOptions
72
+
73
+ config = ExtractorConfig(
74
+ pdf_path="data/pdfs/", # directory containing input PDFs
75
+ data_path="data/output/", # root directory for all output files
76
+ save_json=True, # write results to disk as JSON (default: True)
77
+ document_type="report", # arbitrary label stored in document metadata
78
+ pdfPipelineOptions=PdfPipelineOptions(), # optional Docling pipeline config
79
+ )
80
+ ```
81
+
82
+ #### Metadata extraction
83
+
84
+ The `metadata_config` field controls how document metadata (title, authors, version, etc.) is
85
+ located inside each PDF. Each field is described by a search string and an inclusive page range
86
+ to search on. Fields set to `None` are skipped.
87
+
88
+ ```python
89
+ from document2graph.models import ExtractorConfig, MetadataExtractionConfig, MetadataFieldConfig
90
+
91
+ config = ExtractorConfig(
92
+ pdf_path="data/pdfs/",
93
+ data_path="data/output/",
94
+ metadata_config=MetadataExtractionConfig(
95
+ title_page=1,
96
+ version=MetadataFieldConfig(label="edition", pages=(1, 1)),
97
+ authors=MetadataFieldConfig(label="authors", pages=(1, 2)),
98
+ institutions=MetadataFieldConfig(label="affiliations", pages=(1, 2)),
99
+ bibliography=None, # not present in this document type
100
+ correspondence=MetadataFieldConfig(label="contact", pages=(2, 3)),
101
+ ),
102
+ )
103
+ ```
104
+
105
+ The default `MetadataExtractionConfig()` targets German-language Praxisempfehlungen PDFs
106
+ (version on page 1, authors/institutions/bibliography/correspondence on page 2).
107
+
108
+ #### Edge weights
109
+
110
+ Every edge in the document graph carries a `weight` attribute reflecting the strength of the
111
+ structural connection. The defaults can be overridden per category via `edge_weights`:
112
+
113
+ ```python
114
+ from document2graph.models import ExtractorConfig, EdgeWeightConfig
115
+
116
+ config = ExtractorConfig(
117
+ pdf_path="data/pdfs/",
118
+ data_path="data/output/",
119
+ edge_weights=EdgeWeightConfig(
120
+ section=1.0, # heading -> subheading
121
+ text=0.8, # heading/body -> body text, subtext, footnotes
122
+ list_item=0.6, # anchor text -> grouped list item (bullets)
123
+ media=0.9, # referencing text -> image/table (matched via caption)
124
+ unreferenced_media=0.3, # fallback heading -> image/table without a caption match
125
+ root=0.1, # document root -> otherwise disconnected node
126
+ ),
127
+ )
128
+ ```
129
+
130
+ #### Connectivity guarantee
131
+
132
+ Each graph contains a synthetic root node (`#/document-root`, labelled with the document
133
+ title). Any node whose parent cannot be resolved — as well as any component that would
134
+ otherwise be disconnected — is attached to this root with the `root` edge weight, so every
135
+ document graph is a single (weakly) connected component.
136
+
137
+ Extracted metadata is available on the `Document` object under the `metadata` sub-object:
138
+
139
+ ```python
140
+ result = extractor.run("my_document.pdf")
141
+ doc = result["document_metadata"]
142
+
143
+ print(doc.title)
144
+ print(doc.metadata.authors)
145
+ print(doc.metadata.version)
146
+ ```
147
+
148
+ ### Document graph extraction
149
+
150
+ Processes every PDF in `pdf_path` and builds a NetworkX graph per document.
151
+
152
+ ```python
153
+ from document2graph.document2graph_extractor import DocumentGraphExtractor
154
+
155
+ extractor = DocumentGraphExtractor(config)
156
+
157
+ # Process all PDFs and save graphs + snippets to disk
158
+ extractor.generate_snippets()
159
+
160
+ # Or collect Snippet objects in memory as well
161
+ snippets = extractor.generate_snippets(return_snippets=True)
162
+ ```
163
+
164
+ To process a single file and get the raw graph components:
165
+
166
+ ```python
167
+ result = extractor.run("my_document.pdf")
168
+
169
+ text_nodes = result["text_nodes"]
170
+ image_nodes = result["image_nodes"]
171
+ table_nodes = result["table_nodes"]
172
+ edges = result["edges"] # list of (parent_id, child_id, weight) tuples
173
+ metadata = result["document_metadata"]
174
+ ```
175
+
176
+ Graphs are saved as `.gexf` files under `<data_path>/nx_graphs/` and can be loaded with NetworkX:
177
+
178
+ ```python
179
+ import networkx as nx
180
+ G = nx.read_gexf("data/output/nx_graphs/my_document.gexf")
181
+ ```
182
+
183
+ ### Baseline chunk extraction
184
+
185
+ Produces flat, retrieval-ready chunks using Docling's hybrid chunker.
186
+
187
+ ```python
188
+ from document2graph.baseline_extractor import BaselineExtractor
189
+
190
+ extractor = BaselineExtractor(config)
191
+
192
+ # Process all PDFs; saves JSON to disk
193
+ extractor.generate_baseline_chunks()
194
+
195
+ # Or return chunks in memory
196
+ all_chunks = extractor.generate_baseline_chunks(return_chunks=True)
197
+ # all_chunks["my_document.pdf"]["baseline"] -> List[Chunk]
198
+ # all_chunks["my_document.pdf"]["enriched"] -> List[Chunk] (context-enriched)
199
+ ```
200
+
201
+ To process a single file:
202
+
203
+ ```python
204
+ chunks = extractor.extract_baseline_chunks(
205
+ filename="my_document.pdf",
206
+ baseline_description="Q1 report chunks",
207
+ enrich=True, # also produce context-enriched variants
208
+ )
209
+ ```
210
+
211
+ ## Output structure
212
+
213
+ ```
214
+ <data_path>/
215
+ ├── raw_texts/ # intermediate Docling extraction output
216
+ ├── nx_graphs/ # .gexf graph files (one per document)
217
+ ├── snippets/ # <filename>_snippets.json (document graph output)
218
+ └── baseline_chunks/ # <filename>_baseline_chunks.json
219
+ ```
220
+
221
+ ## Data models
222
+
223
+ | Model | Key fields |
224
+ |---|---|
225
+ | `Snippet` | `snippet_id`, `type` (text/image/table), `document_id`, `sequence_no`, `label`, `level`, `page_no`, `bbox`, `text` |
226
+ | `Chunk` | `chunk_id`, `document_id`, `text`, `meta`, `baseline_description`, `embedding` |
227
+ | `Document` | `document_id`, `document_type`, `filename`, `title`, `metadata: DocumentMetadata` |
228
+ | `DocumentMetadata` | `version`, `authors`, `institutions`, `bibliography`, `correspondence` |
229
+ | `MetadataExtractionConfig` | `title_page`, `version`, `authors`, `institutions`, `bibliography`, `correspondence` (each a `MetadataFieldConfig`) |
230
+ | `MetadataFieldConfig` | `label` (search string), `pages` (inclusive 1-based page range, e.g. `(1, 3)`) |
231
+ | `EdgeWeightConfig` | `section`, `text`, `list_item`, `media`, `unreferenced_media`, `root` (edge weights by category) |
232
+
233
+ ## Running tests
234
+
235
+ ```bash
236
+ pytest
237
+ ```
238
+
239
+ Skip slow tests that download models:
240
+
241
+ ```bash
242
+ pytest -m "not slow"
243
+ ```