dot-parser 2.0.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.
- dot_parser/__init__.py +49 -0
- dot_parser/backends/__init__.py +26 -0
- dot_parser/backends/_base.py +79 -0
- dot_parser/backends/docling.py +59 -0
- dot_parser/backends/llama.py +76 -0
- dot_parser/backends/mistral.py +622 -0
- dot_parser/backends/pymu.py +56 -0
- dot_parser/chunking.py +257 -0
- dot_parser/docx_images.py +633 -0
- dot_parser/image_utils.py +152 -0
- dot_parser/images.py +125 -0
- dot_parser/markdown_utils.py +48 -0
- dot_parser/models.py +22 -0
- dot_parser/parsers.py +325 -0
- dot_parser/pricing.py +58 -0
- dot_parser/tokens.py +6 -0
- dot_parser/vlms.py +203 -0
- dot_parser-2.0.0.dist-info/METADATA +274 -0
- dot_parser-2.0.0.dist-info/RECORD +21 -0
- dot_parser-2.0.0.dist-info/WHEEL +4 -0
- dot_parser-2.0.0.dist-info/licenses/LICENSE.md +660 -0
dot_parser/chunking.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Kannon For Deep Tech
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import statistics
|
|
6
|
+
|
|
7
|
+
import semchunk
|
|
8
|
+
|
|
9
|
+
from dot_parser.models import Chunk
|
|
10
|
+
from dot_parser.tokens import count_tokens
|
|
11
|
+
|
|
12
|
+
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$", re.MULTILINE)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _split_by_headings(markdown: str) -> list[dict]:
|
|
16
|
+
"""Split markdown into sections by headings, tracking hierarchy via a stack."""
|
|
17
|
+
sections: list[dict] = []
|
|
18
|
+
stack: list[str] = [] # heading lines currently in scope
|
|
19
|
+
|
|
20
|
+
last_end = 0
|
|
21
|
+
for match in _HEADING_RE.finditer(markdown):
|
|
22
|
+
# Flush text before this heading as body of the previous section
|
|
23
|
+
if last_end < match.start():
|
|
24
|
+
body = markdown[last_end : match.start()].strip()
|
|
25
|
+
if body:
|
|
26
|
+
if sections:
|
|
27
|
+
sections[-1]["body"] = body
|
|
28
|
+
else:
|
|
29
|
+
# Text before any heading → root section
|
|
30
|
+
sections.append({"heading": None, "section_path": list(stack), "body": body})
|
|
31
|
+
|
|
32
|
+
level = len(match.group(1))
|
|
33
|
+
heading_line = match.group(0).strip()
|
|
34
|
+
|
|
35
|
+
# Pop headings of level >= current
|
|
36
|
+
while stack and _heading_level(stack[-1]) >= level:
|
|
37
|
+
stack.pop()
|
|
38
|
+
|
|
39
|
+
stack.append(heading_line)
|
|
40
|
+
|
|
41
|
+
sections.append({"heading": heading_line, "section_path": list(stack), "body": ""})
|
|
42
|
+
last_end = match.end()
|
|
43
|
+
|
|
44
|
+
# Remaining text after the last heading
|
|
45
|
+
trailing = markdown[last_end:].strip()
|
|
46
|
+
if trailing:
|
|
47
|
+
if sections:
|
|
48
|
+
sections[-1]["body"] = trailing
|
|
49
|
+
else:
|
|
50
|
+
sections.append({"heading": None, "section_path": list(stack), "body": trailing})
|
|
51
|
+
|
|
52
|
+
return sections
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _heading_level(heading_line: str) -> int:
|
|
56
|
+
m = re.match(r"^(#{1,6})\s", heading_line)
|
|
57
|
+
return len(m.group(1)) if m else 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _split_oversized(section: dict, max_tokens: int) -> list[dict]:
|
|
61
|
+
"""Split a section that exceeds max_tokens."""
|
|
62
|
+
heading = section["heading"]
|
|
63
|
+
body = section["body"]
|
|
64
|
+
path = section["section_path"]
|
|
65
|
+
|
|
66
|
+
full_text = f"{heading}\n\n{body}" if heading else body
|
|
67
|
+
if count_tokens(full_text) <= max_tokens:
|
|
68
|
+
return [section]
|
|
69
|
+
|
|
70
|
+
# Try paragraph splitting first
|
|
71
|
+
paragraphs = re.split(r"\n\n+", body)
|
|
72
|
+
if len(paragraphs) > 1:
|
|
73
|
+
sub_chunks = _group_paragraphs(paragraphs, max_tokens, heading)
|
|
74
|
+
else:
|
|
75
|
+
# Semantic splitting via semchunk
|
|
76
|
+
sub_chunks = semchunk.chunk(body, chunk_size=max_tokens, token_counter=count_tokens)
|
|
77
|
+
|
|
78
|
+
results = []
|
|
79
|
+
for i, text in enumerate(sub_chunks):
|
|
80
|
+
if i == 0 and heading:
|
|
81
|
+
results.append(
|
|
82
|
+
{
|
|
83
|
+
"heading": heading,
|
|
84
|
+
"section_path": list(path),
|
|
85
|
+
"body": text,
|
|
86
|
+
}
|
|
87
|
+
)
|
|
88
|
+
else:
|
|
89
|
+
results.append(
|
|
90
|
+
{
|
|
91
|
+
"heading": None,
|
|
92
|
+
"section_path": list(path),
|
|
93
|
+
"body": text,
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
return results
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _group_paragraphs(paragraphs: list[str], max_tokens: int, heading: str | None) -> list[str]:
|
|
100
|
+
"""Group paragraphs into chunks that fit within max_tokens."""
|
|
101
|
+
groups: list[str] = []
|
|
102
|
+
current: list[str] = []
|
|
103
|
+
current_tokens = 0
|
|
104
|
+
|
|
105
|
+
# Account for heading in first group
|
|
106
|
+
heading_tokens = count_tokens(heading + "\n\n") if heading else 0
|
|
107
|
+
first_group = True
|
|
108
|
+
|
|
109
|
+
for para in paragraphs:
|
|
110
|
+
para = para.strip()
|
|
111
|
+
if not para:
|
|
112
|
+
continue
|
|
113
|
+
para_tokens = count_tokens(para)
|
|
114
|
+
overhead = heading_tokens if first_group and not current else 0
|
|
115
|
+
|
|
116
|
+
if current and current_tokens + overhead + para_tokens > max_tokens:
|
|
117
|
+
groups.append("\n\n".join(current))
|
|
118
|
+
current = [para]
|
|
119
|
+
current_tokens = para_tokens
|
|
120
|
+
first_group = False
|
|
121
|
+
else:
|
|
122
|
+
current.append(para)
|
|
123
|
+
current_tokens += para_tokens
|
|
124
|
+
|
|
125
|
+
if current:
|
|
126
|
+
groups.append("\n\n".join(current))
|
|
127
|
+
|
|
128
|
+
return groups
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _merge_small(sections: list[dict], max_tokens: int) -> list[dict]:
|
|
132
|
+
"""Merge adjacent sections sharing the same parent if they fit."""
|
|
133
|
+
if not sections:
|
|
134
|
+
return sections
|
|
135
|
+
|
|
136
|
+
merged: list[dict] = [sections[0]]
|
|
137
|
+
|
|
138
|
+
for section in sections[1:]:
|
|
139
|
+
prev = merged[-1]
|
|
140
|
+
|
|
141
|
+
# Heading-only sections always start a new group
|
|
142
|
+
if section["heading"] and not section["body"]:
|
|
143
|
+
merged.append(section)
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
# Check same parent (all but last element of section_path)
|
|
147
|
+
prev_parent = prev["section_path"][:-1] if prev["section_path"] else []
|
|
148
|
+
curr_parent = section["section_path"][:-1] if section["section_path"] else []
|
|
149
|
+
|
|
150
|
+
if prev_parent == curr_parent:
|
|
151
|
+
prev_text = _section_text(prev)
|
|
152
|
+
curr_text = _section_text(section)
|
|
153
|
+
combined_tokens = count_tokens(prev_text + "\n\n" + curr_text)
|
|
154
|
+
|
|
155
|
+
if combined_tokens <= max_tokens:
|
|
156
|
+
# Merge into previous
|
|
157
|
+
merged[-1] = {
|
|
158
|
+
"heading": prev["heading"],
|
|
159
|
+
"section_path": prev["section_path"],
|
|
160
|
+
"body": (prev_text + "\n\n" + curr_text).strip(),
|
|
161
|
+
"merged": True,
|
|
162
|
+
}
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
merged.append(section)
|
|
166
|
+
|
|
167
|
+
return merged
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _absorb_undersized(sections: list[dict], max_tokens: int) -> list[dict]:
|
|
171
|
+
"""Absorb chunks smaller than 15% of max_tokens into neighbors."""
|
|
172
|
+
threshold = max_tokens * 0.15
|
|
173
|
+
result: list[dict] = []
|
|
174
|
+
|
|
175
|
+
for section in sections:
|
|
176
|
+
text = _section_text(section)
|
|
177
|
+
tokens = count_tokens(text)
|
|
178
|
+
|
|
179
|
+
if tokens < threshold and result:
|
|
180
|
+
# Append to previous
|
|
181
|
+
prev = result[-1]
|
|
182
|
+
prev_text = _section_text(prev)
|
|
183
|
+
result[-1] = {
|
|
184
|
+
"heading": prev["heading"],
|
|
185
|
+
"section_path": prev["section_path"],
|
|
186
|
+
"body": (prev_text + "\n\n" + text).strip(),
|
|
187
|
+
"merged": True,
|
|
188
|
+
}
|
|
189
|
+
else:
|
|
190
|
+
result.append(section)
|
|
191
|
+
|
|
192
|
+
# Check if first chunk is undersized and can be prepended to next
|
|
193
|
+
if len(result) >= 2:
|
|
194
|
+
first_text = _section_text(result[0])
|
|
195
|
+
if count_tokens(first_text) < threshold:
|
|
196
|
+
second = result[1]
|
|
197
|
+
second_text = _section_text(second)
|
|
198
|
+
result[1] = {
|
|
199
|
+
"heading": second["heading"],
|
|
200
|
+
"section_path": second["section_path"],
|
|
201
|
+
"body": (first_text + "\n\n" + second_text).strip(),
|
|
202
|
+
"merged": True,
|
|
203
|
+
}
|
|
204
|
+
result.pop(0)
|
|
205
|
+
|
|
206
|
+
return result
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _section_text(section: dict) -> str:
|
|
210
|
+
if section.get("merged"):
|
|
211
|
+
return section["body"]
|
|
212
|
+
heading = section["heading"]
|
|
213
|
+
body = section["body"]
|
|
214
|
+
if heading and body:
|
|
215
|
+
return f"{heading}\n\n{body}"
|
|
216
|
+
return heading or body
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _auto_max_tokens(sections: list[dict]) -> int:
|
|
220
|
+
"""Compute max_tokens from 95th percentile of section sizes, clamped 64–512."""
|
|
221
|
+
sizes = [count_tokens(_section_text(s)) for s in sections if _section_text(s)]
|
|
222
|
+
if not sizes:
|
|
223
|
+
return 256
|
|
224
|
+
p95 = statistics.quantiles(sizes, n=20)[18] if len(sizes) >= 2 else sizes[0]
|
|
225
|
+
return max(64, min(512, int(p95)))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def chunk(markdown: str, max_tokens: int | None = None, merge: bool = True) -> list[Chunk]:
|
|
229
|
+
if not markdown.strip():
|
|
230
|
+
return []
|
|
231
|
+
|
|
232
|
+
# Step 1: Split by headings
|
|
233
|
+
sections = _split_by_headings(markdown)
|
|
234
|
+
|
|
235
|
+
# Auto-compute max_tokens if not provided
|
|
236
|
+
if max_tokens is None:
|
|
237
|
+
max_tokens = _auto_max_tokens(sections)
|
|
238
|
+
|
|
239
|
+
# Step 2: Split oversized sections
|
|
240
|
+
split_sections: list[dict] = []
|
|
241
|
+
for section in sections:
|
|
242
|
+
split_sections.extend(_split_oversized(section, max_tokens))
|
|
243
|
+
|
|
244
|
+
# Steps 3 & 4: Merge and absorb (when merge=True)
|
|
245
|
+
if merge:
|
|
246
|
+
split_sections = _merge_small(split_sections, max_tokens)
|
|
247
|
+
split_sections = _absorb_undersized(split_sections, max_tokens)
|
|
248
|
+
|
|
249
|
+
# Build Chunk objects
|
|
250
|
+
return [
|
|
251
|
+
Chunk(
|
|
252
|
+
content=_section_text(s),
|
|
253
|
+
section_path=s["section_path"],
|
|
254
|
+
)
|
|
255
|
+
for s in split_sections
|
|
256
|
+
if _section_text(s)
|
|
257
|
+
]
|