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.
- document2graph/__init__.py +0 -0
- document2graph/baseline_extractor/__init__.py +3 -0
- document2graph/baseline_extractor/extractor.py +82 -0
- document2graph/document2graph_extractor/__init__.py +3 -0
- document2graph/document2graph_extractor/extractor.py +146 -0
- document2graph/document2graph_extractor/snippet_graph_constructor.py +424 -0
- document2graph/models/Chunk.py +10 -0
- document2graph/models/Document.py +10 -0
- document2graph/models/DocumentMetadata.py +23 -0
- document2graph/models/EdgeWeightConfig.py +18 -0
- document2graph/models/ExtractorConfig.py +14 -0
- document2graph/models/ImageSnippetNode.py +21 -0
- document2graph/models/Snippet.py +19 -0
- document2graph/models/TableSnippetNode.py +6 -0
- document2graph/models/TextSnippet.py +6 -0
- document2graph/models/TextSnippetNode.py +19 -0
- document2graph/models/__init__.py +25 -0
- document2graph/utils/__init__.py +0 -0
- document2graph/utils/base_extractor.py +127 -0
- document2graph/utils/doc_to_tree_lib.py +52 -0
- document2graph/utils/hybrid_chunker_extractor.py +26 -0
- document2graph/utils/log.py +40 -0
- document2graph-0.1.0.dist-info/METADATA +243 -0
- document2graph-0.1.0.dist-info/RECORD +27 -0
- document2graph-0.1.0.dist-info/WHEEL +5 -0
- document2graph-0.1.0.dist-info/licenses/LICENSE +21 -0
- document2graph-0.1.0.dist-info/top_level.txt +1 -0
|
File without changes
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import uuid
|
|
3
|
+
|
|
4
|
+
from ..utils.hybrid_chunker_extractor import HybridChunkerExtractor
|
|
5
|
+
from tqdm import tqdm
|
|
6
|
+
import json
|
|
7
|
+
from ..models.Chunk import Chunk
|
|
8
|
+
from ..models.ExtractorConfig import ExtractorConfig
|
|
9
|
+
from typing import List
|
|
10
|
+
|
|
11
|
+
from ..utils.log import Log
|
|
12
|
+
|
|
13
|
+
_log = Log("BaselineExtractor")
|
|
14
|
+
|
|
15
|
+
class BaselineExtractor:
|
|
16
|
+
def __init__(self, config: ExtractorConfig):
|
|
17
|
+
self.logger = _log.logger
|
|
18
|
+
self.pdf_path = config.pdf_path
|
|
19
|
+
self.data_path = config.data_path
|
|
20
|
+
self.raw_text_save_dir = os.path.join(self.data_path, "raw_texts/")
|
|
21
|
+
self.pdfPipelineOptions = config.pdfPipelineOptions
|
|
22
|
+
self.save_json = config.save_json
|
|
23
|
+
self.document_type = config.document_type
|
|
24
|
+
|
|
25
|
+
def _get_clean_filename(self, filename: str) -> str:
|
|
26
|
+
return os.path.splitext(os.path.basename(filename))[0]
|
|
27
|
+
|
|
28
|
+
def extract_baseline_chunks(self, filename: str, baseline_description: str, enrich: bool = False) -> dict[str, List[Chunk]]:
|
|
29
|
+
"""Extract baseline chunks from the raw text files for a given document."""
|
|
30
|
+
sample = os.path.join(self.pdf_path, filename)
|
|
31
|
+
clean_filename = self._get_clean_filename(sample)
|
|
32
|
+
baseline_extractor = HybridChunkerExtractor(
|
|
33
|
+
source=sample,
|
|
34
|
+
merge_peers=True,
|
|
35
|
+
pipeline_options=self.pdfPipelineOptions
|
|
36
|
+
)
|
|
37
|
+
chunks = []
|
|
38
|
+
enriched_chunks = []
|
|
39
|
+
for chunk in baseline_extractor.extract_and_chunk(save_dir=self.raw_text_save_dir, filename=clean_filename):
|
|
40
|
+
meta = chunk.meta.export_json_dict()
|
|
41
|
+
|
|
42
|
+
chunks.append(Chunk(
|
|
43
|
+
chunk_id=str(uuid.uuid4()),
|
|
44
|
+
document_id=filename,
|
|
45
|
+
text=chunk.text,
|
|
46
|
+
meta=meta,
|
|
47
|
+
baseline_description=baseline_description
|
|
48
|
+
))
|
|
49
|
+
if enrich:
|
|
50
|
+
enriched_text = baseline_extractor.chunker.contextualize(chunk)
|
|
51
|
+
enriched_chunks.append(Chunk(
|
|
52
|
+
chunk_id=str(uuid.uuid4()),
|
|
53
|
+
document_id=filename,
|
|
54
|
+
text=enriched_text,
|
|
55
|
+
meta=meta,
|
|
56
|
+
baseline_description=f"Enriched; {baseline_description}"
|
|
57
|
+
))
|
|
58
|
+
return {"baseline": chunks, "enriched": enriched_chunks}
|
|
59
|
+
|
|
60
|
+
def generate_baseline_chunks(self, return_chunks: bool = False) -> dict[str, List[Chunk]] | None:
|
|
61
|
+
"""Generate baseline chunks for all documents in the pdf_path."""
|
|
62
|
+
all_chunks = {}
|
|
63
|
+
for filename in tqdm(os.listdir(self.pdf_path)):
|
|
64
|
+
clean_filename = self._get_clean_filename(filename)
|
|
65
|
+
if filename.endswith(".pdf"):
|
|
66
|
+
self.logger.info(f"Extracting baseline chunks for {filename}...")
|
|
67
|
+
baseline_description = f"Baseline chunks for {filename}"
|
|
68
|
+
chunks = self.extract_baseline_chunks(filename, baseline_description, enrich=True)
|
|
69
|
+
all_chunks[filename] = chunks
|
|
70
|
+
|
|
71
|
+
# by default, the chunks are saved as json
|
|
72
|
+
if self.save_json:
|
|
73
|
+
baseline_json = os.path.join(self.data_path, "baseline_chunks/")
|
|
74
|
+
os.makedirs(baseline_json, exist_ok=True)
|
|
75
|
+
with open(f"{self.raw_text_save_dir}/{clean_filename}_baseline_chunks.json", "w") as f:
|
|
76
|
+
json.dump([chunk.model_dump() for chunk in chunks["baseline"]], f, indent=4)
|
|
77
|
+
if len(chunks["enriched"]) > 0:
|
|
78
|
+
with open(f"{self.raw_text_save_dir}/{clean_filename}_enriched_chunks.json", "w") as f:
|
|
79
|
+
json.dump([chunk.model_dump() for chunk in chunks["enriched"]], f, indent=4)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
return all_chunks if return_chunks else None
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from .snippet_graph_constructor import SnippetGraphConstructor
|
|
4
|
+
from ..utils.base_extractor import Extractor
|
|
5
|
+
from tqdm import tqdm
|
|
6
|
+
import json
|
|
7
|
+
from docling_parse.pdf_parser import DoclingPdfParser, PdfDocument
|
|
8
|
+
from ..models.Snippet import Snippet
|
|
9
|
+
from ..models.ExtractorConfig import ExtractorConfig
|
|
10
|
+
from typing import Any, List
|
|
11
|
+
from ..utils.log import Log
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SnippetType:
|
|
15
|
+
TEXT = "text"
|
|
16
|
+
IMAGE = "image"
|
|
17
|
+
TABLE = "table"
|
|
18
|
+
|
|
19
|
+
class DocumentGraphExtractor:
|
|
20
|
+
def __init__(self, config: ExtractorConfig):
|
|
21
|
+
self.pdf_path = config.pdf_path
|
|
22
|
+
self.data_path = config.data_path
|
|
23
|
+
self.pdfPipelineOptions = config.pdfPipelineOptions
|
|
24
|
+
self.save_json = config.save_json
|
|
25
|
+
self.logger = Log("DocumentGraphExtractor").logger
|
|
26
|
+
self.document_type = config.document_type
|
|
27
|
+
self.metadata_config = config.metadata_config
|
|
28
|
+
self.edge_weights = config.edge_weights
|
|
29
|
+
|
|
30
|
+
def _run(self, filename: str) -> dict[str, Any]:
|
|
31
|
+
sample = os.path.join(self.pdf_path, filename)
|
|
32
|
+
clean_filename = os.path.splitext(os.path.basename(sample))[0]
|
|
33
|
+
raw_text_save_dir = os.path.join(self.data_path, "raw_texts/")
|
|
34
|
+
graph_save_dir = os.path.join(self.data_path, "nx_graphs/")
|
|
35
|
+
os.makedirs(raw_text_save_dir, exist_ok=True)
|
|
36
|
+
os.makedirs(graph_save_dir, exist_ok=True)
|
|
37
|
+
|
|
38
|
+
extractor = Extractor(
|
|
39
|
+
source=sample, pipeline_options=self.pdfPipelineOptions
|
|
40
|
+
)
|
|
41
|
+
extractor.extract(save_dir=raw_text_save_dir, filename=clean_filename)
|
|
42
|
+
parser = DoclingPdfParser()
|
|
43
|
+
pdf_doc: PdfDocument = parser.load(path_or_stream=sample)
|
|
44
|
+
|
|
45
|
+
snippet_to_graph = SnippetGraphConstructor(
|
|
46
|
+
pdf_doc,
|
|
47
|
+
extractor.docling_doc.document,
|
|
48
|
+
clean_filename,
|
|
49
|
+
document_type=self.document_type,
|
|
50
|
+
metadata_config=self.metadata_config,
|
|
51
|
+
edge_weights=self.edge_weights,
|
|
52
|
+
)
|
|
53
|
+
text_nodes, image_nodes, table_nodes, edges = snippet_to_graph.get_graph(
|
|
54
|
+
save_to=f"{graph_save_dir}/{clean_filename}.gexf"
|
|
55
|
+
)
|
|
56
|
+
doc_metadata = snippet_to_graph.document_metadata
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
"text_nodes": text_nodes,
|
|
60
|
+
"image_nodes": image_nodes,
|
|
61
|
+
"table_nodes": table_nodes,
|
|
62
|
+
"edges": edges,
|
|
63
|
+
"document_metadata": doc_metadata,
|
|
64
|
+
"clean_filename": clean_filename
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
def run(self, filename: str) -> dict[str, Any]:
|
|
68
|
+
"""Process a single PDF and return its graph components.
|
|
69
|
+
|
|
70
|
+
Returns a dict with keys: text_nodes, image_nodes, table_nodes,
|
|
71
|
+
edges (list of (parent_id, child_id, weight) tuples),
|
|
72
|
+
document_metadata, clean_filename.
|
|
73
|
+
"""
|
|
74
|
+
return self._run(filename)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _get_text_value_for_snippet_type(self, node, snippet_type:str) -> str:
|
|
78
|
+
if snippet_type == SnippetType.TEXT:
|
|
79
|
+
return node.text
|
|
80
|
+
elif snippet_type == SnippetType.IMAGE:
|
|
81
|
+
return node.caption_text
|
|
82
|
+
elif snippet_type == SnippetType.TABLE:
|
|
83
|
+
return node.markdown_serialization
|
|
84
|
+
else:
|
|
85
|
+
raise ValueError(f"Unknown snippet type: {snippet_type}")
|
|
86
|
+
|
|
87
|
+
def _node_to_snippet(
|
|
88
|
+
self, node, snippet_type: str
|
|
89
|
+
) -> Snippet:
|
|
90
|
+
"""Convert a node (text, image, or table) to a Document Snippet.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
node: TextSnippetNode or ImageSnippetNode or TableSnippetNode
|
|
94
|
+
snippet_type: SnippetType.TEXT or SnippetType.IMAGE or SnippetType.TABLE
|
|
95
|
+
"""
|
|
96
|
+
text_value = self._get_text_value_for_snippet_type(node, snippet_type)
|
|
97
|
+
snippet = Snippet(
|
|
98
|
+
snippet_id=node.snippet_id,
|
|
99
|
+
type=snippet_type,
|
|
100
|
+
document_id=str(node.document_id),
|
|
101
|
+
sequence_no=node.sequence_no,
|
|
102
|
+
label=node.label,
|
|
103
|
+
level=node.level,
|
|
104
|
+
level_label=node.level_label,
|
|
105
|
+
parent_id=node.parent_id,
|
|
106
|
+
is_grouped=node.is_grouped,
|
|
107
|
+
page_no=node.page_no,
|
|
108
|
+
bbox=node.bbox,
|
|
109
|
+
text=text_value,
|
|
110
|
+
docling_parent_ref=node.docling_parent_ref,
|
|
111
|
+
docling_self_ref=node.docling_self_ref
|
|
112
|
+
)
|
|
113
|
+
return snippet
|
|
114
|
+
|
|
115
|
+
def generate_snippets(self, return_snippets: bool = False) -> List[Snippet] | None:
|
|
116
|
+
"""
|
|
117
|
+
Main method to run the entire extraction and graph construction pipeline for all documents.
|
|
118
|
+
"""
|
|
119
|
+
all_snippets = []
|
|
120
|
+
for file in tqdm(os.listdir(self.pdf_path)):
|
|
121
|
+
result = self._run(file)
|
|
122
|
+
|
|
123
|
+
# Convert nodes to snippets
|
|
124
|
+
text_snippets = [
|
|
125
|
+
self._node_to_snippet(tn, SnippetType.TEXT)
|
|
126
|
+
for tn in result["text_nodes"]
|
|
127
|
+
]
|
|
128
|
+
image_snippets = [
|
|
129
|
+
self._node_to_snippet(inode, SnippetType.IMAGE)
|
|
130
|
+
for inode in result["image_nodes"]
|
|
131
|
+
]
|
|
132
|
+
table_snippets = [
|
|
133
|
+
self._node_to_snippet(tn, SnippetType.TABLE)
|
|
134
|
+
for tn in result["table_nodes"]
|
|
135
|
+
]
|
|
136
|
+
snippets = text_snippets + image_snippets + table_snippets
|
|
137
|
+
|
|
138
|
+
# default config is to save the snippets as json, but optionally can skip this step and just return the snippets
|
|
139
|
+
if self.save_json:
|
|
140
|
+
snippets_json_dir = os.path.join(self.data_path, "snippets/")
|
|
141
|
+
os.makedirs(snippets_json_dir, exist_ok=True)
|
|
142
|
+
with open(os.path.join(snippets_json_dir, f"{result['clean_filename']}_snippets.json"), "w") as f:
|
|
143
|
+
json.dump([snippet.model_dump() for snippet in snippets], f, indent=4)
|
|
144
|
+
if return_snippets:
|
|
145
|
+
all_snippets.extend(snippets)
|
|
146
|
+
return all_snippets if return_snippets else None
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
from docling_core.types.doc.document import TextItem, SectionHeaderItem, PictureItem, TableItem, RefItem, DoclingDocument
|
|
2
|
+
from docling_parse.pdf_parser import PdfDocument
|
|
3
|
+
from docling_core.types.doc.page import TextCellUnit
|
|
4
|
+
import numpy as np
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
import regex as re
|
|
7
|
+
import networkx as nx
|
|
8
|
+
from ..models.TextSnippet import TextSnippet
|
|
9
|
+
from ..models.TextSnippetNode import TextSnippetNode
|
|
10
|
+
from ..models.ImageSnippetNode import ImageSnippetNode
|
|
11
|
+
from ..models.TableSnippetNode import TableSnippetNode
|
|
12
|
+
from ..models.Document import Document
|
|
13
|
+
from ..models.DocumentMetadata import DocumentMetadata, MetadataExtractionConfig
|
|
14
|
+
from ..models.EdgeWeightConfig import EdgeWeightConfig
|
|
15
|
+
import uuid
|
|
16
|
+
from typing import TypeVar
|
|
17
|
+
|
|
18
|
+
from ..utils.doc_to_tree_lib import get_heights, get_height_hist, get_text_body_height, compute_snippet_level
|
|
19
|
+
|
|
20
|
+
T = TypeVar('T', bound=ImageSnippetNode)
|
|
21
|
+
|
|
22
|
+
ROOT_NODE_ID = "#/document-root"
|
|
23
|
+
|
|
24
|
+
SnippetNode = TextSnippetNode | ImageSnippetNode | TableSnippetNode
|
|
25
|
+
WeightedEdge = tuple[str, str, float]
|
|
26
|
+
|
|
27
|
+
class SnippetGraphConstructor():
|
|
28
|
+
|
|
29
|
+
def __init__(self, pdf_doc: PdfDocument, docling_doc: DoclingDocument, filename: str, document_type: str, metadata_config: MetadataExtractionConfig | None = None, edge_weights: EdgeWeightConfig | None = None):
|
|
30
|
+
self.pdf_doc = pdf_doc
|
|
31
|
+
self.docling_doc = docling_doc
|
|
32
|
+
self.edge_weights = edge_weights or EdgeWeightConfig()
|
|
33
|
+
self.text_items = [TextSnippet(text_item=item, line_heights=self.add_line_heights(item)) for item in docling_doc.texts]
|
|
34
|
+
self.table_items = docling_doc.tables
|
|
35
|
+
self.image_items = docling_doc.pictures
|
|
36
|
+
self.heights = get_heights(self.text_items, skip_first_page=True)
|
|
37
|
+
self._document_metadata = self.extract_doc_metadata(filename, document_type, metadata_config or MetadataExtractionConfig())
|
|
38
|
+
self.section_header_levels, self.section_level_to_label, self.text_body_levels, self.text_level_to_label = self.compute_doc_levels() # compute document levels once and pass to methods that need it
|
|
39
|
+
|
|
40
|
+
# document metadata extraction methods can be added here
|
|
41
|
+
def __safe_check_substring(self, search_string: str, item: TextItem) -> bool:
|
|
42
|
+
try:
|
|
43
|
+
return search_string.lower() in item.text.lower()
|
|
44
|
+
except Exception as e:
|
|
45
|
+
print(f"Error checking item text: {e}")
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
def __match_metadata_by_section_header(self, section_header: SectionHeaderItem | TextItem, texts: list[TextItem]) -> RefItem | None:
|
|
49
|
+
bottom = section_header.prov[0].bbox.b
|
|
50
|
+
left = section_header.prov[0].bbox.l
|
|
51
|
+
|
|
52
|
+
for text in texts:
|
|
53
|
+
text_top = text.prov[0].bbox.t
|
|
54
|
+
text_left = text.prov[0].bbox.l
|
|
55
|
+
if (abs(bottom - text_top) < 6) and (abs(left - text_left) < 6) and text.parent is not None and section_header.parent is not None:
|
|
56
|
+
if text.parent.get_ref() == section_header.parent.get_ref():
|
|
57
|
+
return text.get_ref()
|
|
58
|
+
if text.parent.get_ref() != section_header.parent.get_ref():
|
|
59
|
+
return text.parent.get_ref()
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def document_metadata(self):
|
|
64
|
+
return self._document_metadata
|
|
65
|
+
|
|
66
|
+
def add_line_heights(self, snippet: TextItem) -> list[float]:
|
|
67
|
+
lines = self.pdf_doc.get_page(snippet.prov[0].page_no).get_cells_in_bbox(cell_unit=TextCellUnit.LINE, bbox=snippet.prov[0].bbox)
|
|
68
|
+
heights = []
|
|
69
|
+
for line in lines:
|
|
70
|
+
# check if line is in snippet bbox
|
|
71
|
+
line_bbox = line.rect
|
|
72
|
+
if line.text in snippet.text:
|
|
73
|
+
heights.append(
|
|
74
|
+
((line_bbox.r_y3 - line_bbox.r_y0) + (line_bbox.r_y2 - line_bbox.r_y1)) / 2
|
|
75
|
+
)
|
|
76
|
+
return [round(h, 1) for h in heights]
|
|
77
|
+
|
|
78
|
+
def compute_section_header_levels(self) -> tuple[dict[int, int], dict[int, str]]:
|
|
79
|
+
all_heights = self.heights
|
|
80
|
+
if not all_heights.size:
|
|
81
|
+
return {}, {}
|
|
82
|
+
section_headers = [s for s in self.text_items if isinstance(s.text_item, SectionHeaderItem)]
|
|
83
|
+
section_header_height_hist = get_height_hist(get_heights(section_headers, skip_first_page=True))
|
|
84
|
+
unique_section_header_heights = sorted(section_header_height_hist.keys(), reverse=True)
|
|
85
|
+
text_body_height, iqr = get_text_body_height(all_heights)
|
|
86
|
+
|
|
87
|
+
# group levels that are very close to each other on text body level (within iqr)
|
|
88
|
+
level_dict = defaultdict(int)
|
|
89
|
+
level_to_label = defaultdict(str)
|
|
90
|
+
level = 0
|
|
91
|
+
for h in unique_section_header_heights:
|
|
92
|
+
if(h >= text_body_height - iqr and h <= text_body_height + iqr):
|
|
93
|
+
level_dict[int(h)] = level # body text headers, usually just bold printed text
|
|
94
|
+
level_to_label[level] = "Heading"
|
|
95
|
+
else:
|
|
96
|
+
level_dict[int(h)] = level
|
|
97
|
+
level_to_label[level] = "Heading"
|
|
98
|
+
level += 1
|
|
99
|
+
|
|
100
|
+
# check if largest height is unique -> title
|
|
101
|
+
if len(unique_section_header_heights) > 0 and (section_header_height_hist[unique_section_header_heights[0]] == 1):
|
|
102
|
+
level_to_label[0] = "Title"
|
|
103
|
+
|
|
104
|
+
return level_dict, level_to_label
|
|
105
|
+
|
|
106
|
+
def compute_text_body_levels(self, num_section_headers: int) -> tuple[dict[int, int], dict[int, str]]:
|
|
107
|
+
all_heights = self.heights
|
|
108
|
+
if not all_heights.size:
|
|
109
|
+
return {}, {}
|
|
110
|
+
|
|
111
|
+
texts = [s for s in self.text_items if not isinstance(s.text_item, SectionHeaderItem)]
|
|
112
|
+
|
|
113
|
+
text_body_heights = get_heights(texts, skip_first_page=True)
|
|
114
|
+
text_body_height_hist = get_height_hist(text_body_heights)
|
|
115
|
+
unique_text_body_heights = sorted(text_body_height_hist.keys(), reverse=True)
|
|
116
|
+
text_body_height, iqr = get_text_body_height(all_heights)
|
|
117
|
+
subtext_heights = [h for h in all_heights if h < text_body_height - iqr]
|
|
118
|
+
subtext_median = np.percentile(subtext_heights, 50) if subtext_heights else 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
level_dict = defaultdict(int)
|
|
122
|
+
level_to_label = defaultdict(str)
|
|
123
|
+
level = num_section_headers # continue after section header levels
|
|
124
|
+
subtexts = [h for h in unique_text_body_heights if h < text_body_height - iqr]
|
|
125
|
+
|
|
126
|
+
for h in unique_text_body_heights:
|
|
127
|
+
if(h >= text_body_height - iqr):
|
|
128
|
+
level_dict[int(h)] = level # body text
|
|
129
|
+
level_to_label[ level ] = "Body"
|
|
130
|
+
else:
|
|
131
|
+
level += 1
|
|
132
|
+
break
|
|
133
|
+
|
|
134
|
+
for h in subtexts:
|
|
135
|
+
if(h >= subtext_median - iqr and h <= subtext_median + iqr):
|
|
136
|
+
level_dict[int(h)] = level # subtext
|
|
137
|
+
level_to_label[ level ] = "Subtext"
|
|
138
|
+
else:
|
|
139
|
+
level += 1
|
|
140
|
+
level_dict[int(h)] = level # smaller subtext, footnotes, etc.
|
|
141
|
+
level_to_label[ level ] = "Subsubtext"
|
|
142
|
+
|
|
143
|
+
return level_dict, level_to_label
|
|
144
|
+
|
|
145
|
+
def compute_doc_levels(self) -> tuple[dict[int, int], dict[int, str], dict[int, int], dict[int, str]]:
|
|
146
|
+
# section headers
|
|
147
|
+
section_header_levels, section_level_to_label = self.compute_section_header_levels()
|
|
148
|
+
|
|
149
|
+
# text body levels
|
|
150
|
+
text_body_levels, text_level_to_label = self.compute_text_body_levels(num_section_headers=max(section_header_levels.values())+1)
|
|
151
|
+
|
|
152
|
+
return section_header_levels, section_level_to_label, text_body_levels, text_level_to_label
|
|
153
|
+
|
|
154
|
+
def compute_parent_id(self, node: TextSnippetNode, nodes: list[TextSnippetNode]) -> str | None:
|
|
155
|
+
if(node.is_grouped):
|
|
156
|
+
# first case: if node is grouped, parent is the adjacent non-grouped node
|
|
157
|
+
group = [n for n in nodes if n.docling_parent_ref.get_ref().cref == node.docling_parent_ref.get_ref().cref]
|
|
158
|
+
first_bullet = min(group, key=lambda n: n.sequence_no)
|
|
159
|
+
return nodes[first_bullet.sequence_no - 1].snippet_id if first_bullet.sequence_no > 0 else None
|
|
160
|
+
|
|
161
|
+
elif(node.docling_parent_ref.get_ref().cref != "#/body"):
|
|
162
|
+
# second case: if node has a meaningful parent_id from docling, use it
|
|
163
|
+
parent_ref = node.docling_parent_ref.get_ref().cref
|
|
164
|
+
return parent_ref
|
|
165
|
+
# default case for text items with inaccurate parent_id from docling: find the closest preceding node with a lower level
|
|
166
|
+
for prev_node in reversed(nodes[:node.sequence_no]):
|
|
167
|
+
if prev_node.level < node.level:
|
|
168
|
+
return prev_node.snippet_id
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
def compute_text_nodes(self, snippets: list[TextSnippet]) -> list[TextSnippetNode]:
|
|
172
|
+
nodes = []
|
|
173
|
+
for i, snippet in enumerate(snippets):
|
|
174
|
+
if isinstance(snippet.text_item, SectionHeaderItem):
|
|
175
|
+
snippet_level = compute_snippet_level(snippet, self.section_header_levels)
|
|
176
|
+
snippet_level_label = self.section_level_to_label.get(snippet_level, "unknown")
|
|
177
|
+
else:
|
|
178
|
+
snippet_level = compute_snippet_level(snippet, self.text_body_levels)
|
|
179
|
+
snippet_level_label = self.text_level_to_label.get(snippet_level, "unkown")
|
|
180
|
+
assert snippet.text_item.parent is not None, f"Text item {snippet.text_item.text} has no parent reference."
|
|
181
|
+
node = TextSnippetNode(
|
|
182
|
+
snippet_id=snippet.text_item.self_ref,
|
|
183
|
+
document_id=self._document_metadata.document_id,
|
|
184
|
+
label=snippet.text_item.label,
|
|
185
|
+
sequence_no=i,
|
|
186
|
+
level=snippet_level,
|
|
187
|
+
level_label=snippet_level_label,
|
|
188
|
+
parent_id="placeholder", # compute later
|
|
189
|
+
docling_parent_ref=snippet.text_item.parent.get_ref(),
|
|
190
|
+
docling_self_ref=snippet.text_item.get_ref(),
|
|
191
|
+
is_grouped=True if "group" in snippet.text_item.parent.get_ref().cref else False,
|
|
192
|
+
text=snippet.text_item.text,
|
|
193
|
+
bbox=snippet.text_item.prov[0].bbox,
|
|
194
|
+
charspan=snippet.text_item.prov[0].charspan,
|
|
195
|
+
page_no=snippet.text_item.prov[0].page_no
|
|
196
|
+
)
|
|
197
|
+
nodes.append(node)
|
|
198
|
+
nodes = [node.model_copy(update={"parent_id": self.compute_parent_id(node, nodes)}) for node in nodes]
|
|
199
|
+
for node in nodes:
|
|
200
|
+
if node.parent_id is None:
|
|
201
|
+
print(f"Node {node.text} has no parent_id assigned.")
|
|
202
|
+
elif node.parent_id == "placeholder":
|
|
203
|
+
print(f"Node {node.text} has placeholder parent_id.")
|
|
204
|
+
return nodes
|
|
205
|
+
|
|
206
|
+
def rb_image_parent_matching(self, images: list[T], text_nodes: list[TextSnippetNode]) -> list[T]:
|
|
207
|
+
# find text node that contains reference to image or table using a rule-based approach
|
|
208
|
+
ret_images = images.copy()
|
|
209
|
+
for i, image in enumerate(ret_images):
|
|
210
|
+
matched=False
|
|
211
|
+
if image.caption_nodes is not None:
|
|
212
|
+
# get the full caption text
|
|
213
|
+
caption = " ".join([node.text for node in image.caption_nodes])
|
|
214
|
+
caption_node_ids = [node.snippet_id for node in image.caption_nodes]
|
|
215
|
+
rules = ["Figure", "Fig.", "Table", "Abbildung", "Tabelle", "Abb.", "Tab."]
|
|
216
|
+
for rule in rules:
|
|
217
|
+
if rule in caption:
|
|
218
|
+
regex_pattern = rf"{rule}\s*\d+"
|
|
219
|
+
match = re.search(regex_pattern, caption, re.IGNORECASE)
|
|
220
|
+
if match:
|
|
221
|
+
matched_pattern = rf"{match.group(0)}"
|
|
222
|
+
# make sure we do not match caption nodes
|
|
223
|
+
text_item = [node for node in text_nodes if re.search(matched_pattern, node.text, re.IGNORECASE) and node.snippet_id not in caption_node_ids]
|
|
224
|
+
if text_item and text_item[0].parent_id is not None:
|
|
225
|
+
# we assign the level and parent_id of the matched text item to the image
|
|
226
|
+
# the first matched text item is taken
|
|
227
|
+
ret_images[i] = image.model_copy(update={"level": text_item[0].level, "level_label": text_item[0].level_label, "parent_id": text_item[0].parent_id})
|
|
228
|
+
matched=True
|
|
229
|
+
break
|
|
230
|
+
if not matched:
|
|
231
|
+
# no caption or no match: assign to closest preceding heading
|
|
232
|
+
preceding_headers = [node for node in text_nodes if node.level in self.section_header_levels.values() and node.sequence_no < image.sequence_no]
|
|
233
|
+
if preceding_headers:
|
|
234
|
+
lowest_level_header = min(preceding_headers, key=lambda n: n.level)
|
|
235
|
+
ret_images[i] = image.model_copy(update={"level": lowest_level_header.level + 1, "level_label": "Unreferenced Image", "parent_id": lowest_level_header.snippet_id})
|
|
236
|
+
|
|
237
|
+
return ret_images
|
|
238
|
+
|
|
239
|
+
def compute_image_nodes(self, images: list[PictureItem], text_nodes: list[TextSnippetNode]) -> list[ImageSnippetNode]:
|
|
240
|
+
image_nodes = []
|
|
241
|
+
for i, image in enumerate(images):
|
|
242
|
+
caption_nodes = self.get_caption_nodes(image, text_nodes)
|
|
243
|
+
node = ImageSnippetNode(
|
|
244
|
+
snippet_id=image.self_ref,
|
|
245
|
+
document_id=self._document_metadata.document_id,
|
|
246
|
+
label=image.label,
|
|
247
|
+
sequence_no=i,
|
|
248
|
+
level=1000, # placeholder, compute later
|
|
249
|
+
level_label="",
|
|
250
|
+
parent_id="placeholder", # compute later
|
|
251
|
+
docling_parent_ref=image.parent.get_ref() if image.parent else None,
|
|
252
|
+
docling_self_ref=image.get_ref(),
|
|
253
|
+
is_grouped=True if image.parent is not None and "group" in image.parent.get_ref().cref else False,
|
|
254
|
+
caption_nodes=caption_nodes,
|
|
255
|
+
caption_text=" ".join([node.text for node in caption_nodes]) if len(caption_nodes) > 0 else "",
|
|
256
|
+
bbox=image.prov[0].bbox,
|
|
257
|
+
page_no=image.prov[0].page_no
|
|
258
|
+
)
|
|
259
|
+
image_nodes.append(node)
|
|
260
|
+
# map parents and levels using rule-based matching
|
|
261
|
+
ret_images = self.rb_image_parent_matching(image_nodes, text_nodes)
|
|
262
|
+
return ret_images
|
|
263
|
+
|
|
264
|
+
def compute_table_nodes(self, tables: list[TableItem], text_nodes: list[TextSnippetNode]) -> list[TableSnippetNode]:
|
|
265
|
+
# for now, treat tables similar to images in terms of parent matching
|
|
266
|
+
table_nodes = []
|
|
267
|
+
for i, table in enumerate(tables):
|
|
268
|
+
caption_nodes = self.get_caption_nodes(table, text_nodes)
|
|
269
|
+
node = TableSnippetNode(
|
|
270
|
+
snippet_id=table.self_ref,
|
|
271
|
+
document_id=self._document_metadata.document_id,
|
|
272
|
+
label=table.label,
|
|
273
|
+
sequence_no=i,
|
|
274
|
+
level=1000, # placeholder, compute later
|
|
275
|
+
level_label="",
|
|
276
|
+
parent_id="placeholder", # compute later
|
|
277
|
+
docling_parent_ref=table.parent.get_ref() if table.parent else None,
|
|
278
|
+
docling_self_ref=table.get_ref(),
|
|
279
|
+
is_grouped=True if table.parent is not None and "group" in table.parent.get_ref().cref else False,
|
|
280
|
+
caption_nodes=caption_nodes,
|
|
281
|
+
caption_text=" ".join([node.text for node in caption_nodes]) if len(caption_nodes) > 0 else "",
|
|
282
|
+
markdown_serialization=table.export_to_markdown(self.docling_doc),
|
|
283
|
+
bbox=table.prov[0].bbox,
|
|
284
|
+
page_no=table.prov[0].page_no
|
|
285
|
+
)
|
|
286
|
+
table_nodes.append(node)
|
|
287
|
+
# map parents and levels using rule-based matching
|
|
288
|
+
ret_tables = self.rb_image_parent_matching(table_nodes, text_nodes)
|
|
289
|
+
return ret_tables
|
|
290
|
+
|
|
291
|
+
def get_caption_nodes(self, image: PictureItem | TableItem, text_nodes: list[TextSnippetNode]) -> list[TextSnippetNode] | list:
|
|
292
|
+
# find text node that contains reference to image or table
|
|
293
|
+
caption_refs = image.captions if image.captions else []
|
|
294
|
+
caption_nodes = []
|
|
295
|
+
for caption_ref in caption_refs:
|
|
296
|
+
for text_node in text_nodes:
|
|
297
|
+
if caption_ref.get_ref().cref == text_node.docling_self_ref.get_ref().cref:
|
|
298
|
+
caption_nodes.append(text_node)
|
|
299
|
+
return caption_nodes
|
|
300
|
+
|
|
301
|
+
def get_node_by_id(self, node_id: str, nodes: list[TextSnippetNode]) -> TextSnippetNode | None:
|
|
302
|
+
for node in nodes:
|
|
303
|
+
if node.snippet_id == node_id:
|
|
304
|
+
return node
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
def write_nx_graph(self, filename: str, text_nodes: list[TextSnippetNode], image_nodes: list[ImageSnippetNode], table_nodes: list[TableSnippetNode], edges: list[WeightedEdge]):
|
|
308
|
+
nx_graph = self.build_nx_graph(text_nodes, image_nodes, table_nodes, edges)
|
|
309
|
+
nx.write_gexf(nx_graph, filename, version="1.3")
|
|
310
|
+
return nx_graph
|
|
311
|
+
|
|
312
|
+
def build_nx_graph(self, text_nodes: list[TextSnippetNode], image_nodes: list[ImageSnippetNode], table_nodes: list[TableSnippetNode], edges: list[WeightedEdge]) -> nx.DiGraph:
|
|
313
|
+
nx_graph = nx.DiGraph()
|
|
314
|
+
nx_graph.add_node(ROOT_NODE_ID, label="document_root", level=-1, level_label="Root", text=self._document_metadata.title)
|
|
315
|
+
for node in text_nodes:
|
|
316
|
+
nx_graph.add_node(node.snippet_id, label=node.label, level=node.level, level_label=node.level_label, text=node.text)
|
|
317
|
+
for node in image_nodes + table_nodes:
|
|
318
|
+
nx_graph.add_node(node.snippet_id, label=node.label, level=node.level, level_label=node.level_label, text=node.caption_text)
|
|
319
|
+
for parent_id, child_id, weight in edges:
|
|
320
|
+
nx_graph.add_edge(parent_id, child_id, weight=weight)
|
|
321
|
+
return nx_graph
|
|
322
|
+
|
|
323
|
+
def construct_snippet_nodes(self) -> tuple[list[TextSnippetNode], list[ImageSnippetNode], list[TableSnippetNode]]:
|
|
324
|
+
# we ignore the first page
|
|
325
|
+
text_nodes = self.compute_text_nodes(self.text_items)
|
|
326
|
+
image_nodes = self.compute_image_nodes(self.image_items, text_nodes)
|
|
327
|
+
table_nodes = self.compute_table_nodes(self.table_items, text_nodes) # add markdownserialization to caption text for better matching
|
|
328
|
+
return text_nodes, image_nodes, table_nodes
|
|
329
|
+
|
|
330
|
+
def compute_edge_weight(self, parent: SnippetNode | None, child: SnippetNode) -> float:
|
|
331
|
+
weights = self.edge_weights
|
|
332
|
+
if isinstance(child, (ImageSnippetNode, TableSnippetNode)):
|
|
333
|
+
if child.level_label == "Unreferenced Image":
|
|
334
|
+
return weights.unreferenced_media
|
|
335
|
+
return weights.media
|
|
336
|
+
if child.is_grouped:
|
|
337
|
+
return weights.list_item
|
|
338
|
+
if child.level_label in ("Title", "Heading") and parent is not None and parent.level_label in ("Title", "Heading"):
|
|
339
|
+
return weights.section
|
|
340
|
+
return weights.text
|
|
341
|
+
|
|
342
|
+
def construct_snippet_edges(self, nodes: list[SnippetNode]) -> list[WeightedEdge]:
|
|
343
|
+
"""Build weighted parent->child edges; nodes without a resolvable parent attach to the document root."""
|
|
344
|
+
node_by_id = {node.snippet_id: node for node in nodes}
|
|
345
|
+
edges = []
|
|
346
|
+
for node in nodes:
|
|
347
|
+
parent = node_by_id.get(node.parent_id) if node.parent_id else None
|
|
348
|
+
if parent is not None:
|
|
349
|
+
edges.append((parent.snippet_id, node.snippet_id, self.compute_edge_weight(parent, node)))
|
|
350
|
+
else:
|
|
351
|
+
# missing or dangling parent_id: attach to the synthetic document root
|
|
352
|
+
edges.append((ROOT_NODE_ID, node.snippet_id, self.edge_weights.root))
|
|
353
|
+
return edges
|
|
354
|
+
|
|
355
|
+
def connect_components(self, nodes: list[SnippetNode], edges: list[WeightedEdge]) -> list[WeightedEdge]:
|
|
356
|
+
"""Attach every weakly connected component that does not reach the document root to it.
|
|
357
|
+
|
|
358
|
+
construct_snippet_edges already links orphan nodes to the root, but parent
|
|
359
|
+
cycles can still leave a component detached; this guarantees a single
|
|
360
|
+
connected graph per document.
|
|
361
|
+
"""
|
|
362
|
+
graph = nx.DiGraph()
|
|
363
|
+
graph.add_node(ROOT_NODE_ID)
|
|
364
|
+
graph.add_nodes_from(node.snippet_id for node in nodes)
|
|
365
|
+
graph.add_edges_from((p, c) for p, c, _ in edges)
|
|
366
|
+
node_by_id = {node.snippet_id: node for node in nodes}
|
|
367
|
+
extra_edges = []
|
|
368
|
+
for component in nx.weakly_connected_components(graph):
|
|
369
|
+
if ROOT_NODE_ID in component:
|
|
370
|
+
continue
|
|
371
|
+
component_nodes = [node_by_id[n] for n in component if n in node_by_id]
|
|
372
|
+
if not component_nodes:
|
|
373
|
+
continue
|
|
374
|
+
# attach the structurally highest node of the component to the root
|
|
375
|
+
top = min(component_nodes, key=lambda n: (n.level, n.sequence_no))
|
|
376
|
+
extra_edges.append((ROOT_NODE_ID, top.snippet_id, self.edge_weights.root))
|
|
377
|
+
return extra_edges
|
|
378
|
+
|
|
379
|
+
def get_graph(self, save_to="") -> tuple[list[TextSnippetNode], list[ImageSnippetNode], list[TableSnippetNode], list[WeightedEdge]]:
|
|
380
|
+
text_nodes, image_nodes, table_nodes = self.construct_snippet_nodes()
|
|
381
|
+
all_nodes: list[SnippetNode] = text_nodes + image_nodes + table_nodes
|
|
382
|
+
edges = self.construct_snippet_edges(all_nodes)
|
|
383
|
+
edges += self.connect_components(all_nodes, edges)
|
|
384
|
+
if save_to != "":
|
|
385
|
+
self.write_nx_graph(save_to, text_nodes, image_nodes, table_nodes, edges)
|
|
386
|
+
return text_nodes, image_nodes, table_nodes, edges
|
|
387
|
+
|
|
388
|
+
def extract_title(self, page_two_snippets: list[TextSnippet]) -> str:
|
|
389
|
+
# condition: page 2 and largest font size and on top of page
|
|
390
|
+
title = max(page_two_snippets, key=lambda s: s.line_heights[0] if s.line_heights else 0)
|
|
391
|
+
for snippet in page_two_snippets:
|
|
392
|
+
if snippet.line_heights and snippet.line_heights[0] >= title.line_heights[0] and snippet.text_item.prov[0].bbox.t < title.text_item.prov[0].bbox.t:
|
|
393
|
+
title = snippet
|
|
394
|
+
return title.text_item.text if title else ""
|
|
395
|
+
|
|
396
|
+
def extract_doc_metadata(self, filename: str, document_type: str, config: MetadataExtractionConfig) -> Document:
|
|
397
|
+
metadata_values: dict[str, str] = {}
|
|
398
|
+
for field_name in ["version", "authors", "institutions", "bibliography", "correspondence"]:
|
|
399
|
+
field_cfg = getattr(config, field_name)
|
|
400
|
+
if field_cfg is None:
|
|
401
|
+
metadata_values[field_name] = ""
|
|
402
|
+
continue
|
|
403
|
+
page_items = [item.text_item for item in self.text_items
|
|
404
|
+
if field_cfg.pages[0] <= item.text_item.prov[0].page_no <= field_cfg.pages[1]]
|
|
405
|
+
matched_ref = None
|
|
406
|
+
for text_item in page_items:
|
|
407
|
+
if self.__safe_check_substring(field_cfg.label, text_item):
|
|
408
|
+
matched_ref = self.__match_metadata_by_section_header(text_item, page_items)
|
|
409
|
+
if matched_ref is None:
|
|
410
|
+
matched_ref = text_item.get_ref()
|
|
411
|
+
break
|
|
412
|
+
metadata_values[field_name] = matched_ref.cref if matched_ref else ""
|
|
413
|
+
|
|
414
|
+
title_page_snippets = [item for item in self.text_items
|
|
415
|
+
if item.text_item.prov[0].page_no == config.title_page]
|
|
416
|
+
title = self.extract_title(title_page_snippets) if title_page_snippets else ""
|
|
417
|
+
|
|
418
|
+
return Document(
|
|
419
|
+
document_id=str(uuid.uuid4()),
|
|
420
|
+
title=title,
|
|
421
|
+
document_type=document_type,
|
|
422
|
+
filename=filename,
|
|
423
|
+
metadata=DocumentMetadata(**metadata_values),
|
|
424
|
+
)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import Any, Sequence
|
|
3
|
+
|
|
4
|
+
class Chunk(BaseModel):
|
|
5
|
+
chunk_id: str
|
|
6
|
+
document_id: str
|
|
7
|
+
meta: dict[str, Any] | None = None
|
|
8
|
+
text: str
|
|
9
|
+
embedding: Sequence[float] | None = None
|
|
10
|
+
baseline_description: str | None = None
|