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/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
+ ]