mdkeychunker 0.3.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.
- mdkeychunker/__init__.py +7 -0
- mdkeychunker/chunker.py +402 -0
- mdkeychunker/cli.py +63 -0
- mdkeychunker/config.py +40 -0
- mdkeychunker/enricher.py +201 -0
- mdkeychunker/llm_client.py +158 -0
- mdkeychunker/models.py +47 -0
- mdkeychunker/pipeline.py +86 -0
- mdkeychunker/restructurer.py +128 -0
- mdkeychunker-0.3.0.dist-info/METADATA +241 -0
- mdkeychunker-0.3.0.dist-info/RECORD +15 -0
- mdkeychunker-0.3.0.dist-info/WHEEL +5 -0
- mdkeychunker-0.3.0.dist-info/entry_points.txt +2 -0
- mdkeychunker-0.3.0.dist-info/licenses/LICENSE +21 -0
- mdkeychunker-0.3.0.dist-info/top_level.txt +1 -0
mdkeychunker/__init__.py
ADDED
mdkeychunker/chunker.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Markdown structural chunker.
|
|
3
|
+
|
|
4
|
+
Splits Markdown documents following structure (headers, lists, tables, code blocks)
|
|
5
|
+
without breaking semantic units.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import List, Tuple, Optional
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from .models import Chunk
|
|
13
|
+
from .config import Config
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_THEMATIC_BREAK = re.compile(r"^ {0,3}(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$")
|
|
17
|
+
_FENCE_OPEN = re.compile(r"^ {0,3}(`{3,}|~{3,})(.*)$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _is_thematic_break(line: str) -> bool:
|
|
21
|
+
return bool(_THEMATIC_BREAK.match(line))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _fence_open(line: str) -> Optional[re.Match]:
|
|
25
|
+
"""A fence opener; a backtick fence's info string may not contain backticks."""
|
|
26
|
+
m = _FENCE_OPEN.match(line)
|
|
27
|
+
if m and m.group(1)[0] == "`" and "`" in m.group(2):
|
|
28
|
+
return None
|
|
29
|
+
return m
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class _Block:
|
|
34
|
+
"""Internal structural block in Markdown."""
|
|
35
|
+
type: str # header, paragraph, list, code, table, blockquote, yaml_front_matter
|
|
36
|
+
content: str
|
|
37
|
+
start_line: int
|
|
38
|
+
end_line: int
|
|
39
|
+
level: int = 0 # For headers
|
|
40
|
+
section_title: str = ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MarkdownChunker:
|
|
44
|
+
"""
|
|
45
|
+
Chunks Markdown documents following structural boundaries.
|
|
46
|
+
|
|
47
|
+
Never splits:
|
|
48
|
+
- Code blocks
|
|
49
|
+
- Tables
|
|
50
|
+
- YAML front matter
|
|
51
|
+
- List items mid-item
|
|
52
|
+
- Blockquotes mid-paragraph
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, config: Config):
|
|
56
|
+
self.config = config
|
|
57
|
+
self.min_chunk_size = config.min_chunk_size
|
|
58
|
+
self.max_chunk_size = config.max_chunk_size
|
|
59
|
+
self._header_stack: List[Tuple[int, str]] = []
|
|
60
|
+
|
|
61
|
+
def chunk(self, markdown_text: str) -> List[Chunk]:
|
|
62
|
+
"""Chunk a Markdown document into structural units."""
|
|
63
|
+
blocks = self._parse_blocks(markdown_text)
|
|
64
|
+
chunks = self._group_blocks(blocks)
|
|
65
|
+
return chunks
|
|
66
|
+
|
|
67
|
+
# ------------------------------------------------------------------ #
|
|
68
|
+
# Parsing #
|
|
69
|
+
# ------------------------------------------------------------------ #
|
|
70
|
+
|
|
71
|
+
def _parse_blocks(self, text: str) -> List[_Block]:
|
|
72
|
+
"""Parse Markdown into structural blocks."""
|
|
73
|
+
blocks: List[_Block] = []
|
|
74
|
+
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
75
|
+
i = 0
|
|
76
|
+
current_section = ""
|
|
77
|
+
self._header_stack = []
|
|
78
|
+
|
|
79
|
+
while i < len(lines):
|
|
80
|
+
line = lines[i]
|
|
81
|
+
|
|
82
|
+
# YAML front matter
|
|
83
|
+
if i == 0 and line.lstrip("\ufeff").strip() == "---":
|
|
84
|
+
block, consumed = self._parse_yaml_front_matter(lines, i)
|
|
85
|
+
if block:
|
|
86
|
+
blocks.append(block)
|
|
87
|
+
i += consumed
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
# Fenced code blocks (a backtick info string may not contain backticks)
|
|
91
|
+
if _fence_open(line):
|
|
92
|
+
block, consumed = self._parse_code_block(lines, i)
|
|
93
|
+
block.section_title = current_section
|
|
94
|
+
blocks.append(block)
|
|
95
|
+
i += consumed
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
# Headers
|
|
99
|
+
header_match = re.match(r"^(#{1,6})\s+(.+?)(?:\s+#+)?\s*$", line)
|
|
100
|
+
setext_match = (
|
|
101
|
+
not header_match
|
|
102
|
+
and line.strip()
|
|
103
|
+
and i + 1 < len(lines)
|
|
104
|
+
and re.match(r"^ {0,3}(=+|-+)\s*$", lines[i + 1])
|
|
105
|
+
and not _is_thematic_break(line)
|
|
106
|
+
and not self._is_list_line(line)
|
|
107
|
+
and not line.strip().startswith(">")
|
|
108
|
+
)
|
|
109
|
+
if header_match or setext_match:
|
|
110
|
+
if header_match:
|
|
111
|
+
level = len(header_match.group(1))
|
|
112
|
+
title = header_match.group(2).strip()
|
|
113
|
+
content, end = line, i
|
|
114
|
+
else:
|
|
115
|
+
level = 1 if lines[i + 1].strip().startswith("=") else 2
|
|
116
|
+
title = line.strip()
|
|
117
|
+
content, end = line + "\n" + lines[i + 1], i + 1
|
|
118
|
+
self._update_header_stack(level, title)
|
|
119
|
+
current_section = self._get_current_section()
|
|
120
|
+
blocks.append(_Block(
|
|
121
|
+
type="header",
|
|
122
|
+
content=content,
|
|
123
|
+
start_line=i,
|
|
124
|
+
end_line=end,
|
|
125
|
+
level=level,
|
|
126
|
+
section_title=current_section,
|
|
127
|
+
))
|
|
128
|
+
i = end + 1
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
# Tables
|
|
132
|
+
if self._is_table_line(line, lines, i):
|
|
133
|
+
block, consumed = self._parse_table(lines, i)
|
|
134
|
+
block.section_title = current_section
|
|
135
|
+
blocks.append(block)
|
|
136
|
+
i += consumed
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
# Lists
|
|
140
|
+
if self._is_list_line(line):
|
|
141
|
+
block, consumed = self._parse_list(lines, i)
|
|
142
|
+
block.section_title = current_section
|
|
143
|
+
blocks.append(block)
|
|
144
|
+
i += consumed
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
# Blockquotes
|
|
148
|
+
if line.strip().startswith(">"):
|
|
149
|
+
block, consumed = self._parse_blockquote(lines, i)
|
|
150
|
+
block.section_title = current_section
|
|
151
|
+
blocks.append(block)
|
|
152
|
+
i += consumed
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
# Horizontal rules
|
|
156
|
+
if _is_thematic_break(line):
|
|
157
|
+
blocks.append(_Block(
|
|
158
|
+
type="horizontal_rule",
|
|
159
|
+
content=line,
|
|
160
|
+
start_line=i,
|
|
161
|
+
end_line=i,
|
|
162
|
+
section_title=current_section,
|
|
163
|
+
))
|
|
164
|
+
i += 1
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
# Paragraphs
|
|
168
|
+
if line.strip():
|
|
169
|
+
block, consumed = self._parse_paragraph(lines, i)
|
|
170
|
+
block.section_title = current_section
|
|
171
|
+
blocks.append(block)
|
|
172
|
+
i += consumed
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
i += 1 # empty line
|
|
176
|
+
|
|
177
|
+
return blocks
|
|
178
|
+
|
|
179
|
+
# ------------------------------------------------------------------ #
|
|
180
|
+
# Block parsers #
|
|
181
|
+
# ------------------------------------------------------------------ #
|
|
182
|
+
|
|
183
|
+
def _parse_yaml_front_matter(
|
|
184
|
+
self, lines: List[str], start: int
|
|
185
|
+
) -> Tuple[Optional[_Block], int]:
|
|
186
|
+
end = start + 1
|
|
187
|
+
while end < len(lines) and lines[end].strip() != "---":
|
|
188
|
+
end += 1
|
|
189
|
+
if end >= len(lines):
|
|
190
|
+
return None, 0
|
|
191
|
+
content = "\n".join(lines[start: end + 1])
|
|
192
|
+
return _Block(
|
|
193
|
+
type="yaml_front_matter",
|
|
194
|
+
content=content,
|
|
195
|
+
start_line=start,
|
|
196
|
+
end_line=end,
|
|
197
|
+
), end - start + 1
|
|
198
|
+
|
|
199
|
+
def _parse_code_block(self, lines: List[str], start: int) -> Tuple[_Block, int]:
|
|
200
|
+
opener = lines[start].strip()
|
|
201
|
+
fence_char = opener[0]
|
|
202
|
+
fence_len = len(opener) - len(opener.lstrip(fence_char))
|
|
203
|
+
# CommonMark: closes only on a line of the same char, at least as long,
|
|
204
|
+
# indented at most 3 spaces, with nothing else on the line
|
|
205
|
+
closer = re.compile(rf"^ {{0,3}}{re.escape(fence_char)}{{{fence_len},}}[ \t]*$")
|
|
206
|
+
end = start + 1
|
|
207
|
+
while end < len(lines):
|
|
208
|
+
if closer.match(lines[end]):
|
|
209
|
+
end += 1
|
|
210
|
+
break
|
|
211
|
+
end += 1
|
|
212
|
+
content = "\n".join(lines[start:end])
|
|
213
|
+
return _Block(
|
|
214
|
+
type="code", content=content, start_line=start, end_line=end - 1
|
|
215
|
+
), end - start
|
|
216
|
+
|
|
217
|
+
def _is_table_line(self, line: str, lines: list[str], idx: int) -> bool:
|
|
218
|
+
"""Check if this line starts a table (header row followed by separator)."""
|
|
219
|
+
if "|" not in line:
|
|
220
|
+
return False
|
|
221
|
+
# Must have a separator line immediately after — only detect from the header row
|
|
222
|
+
if idx + 1 < len(lines):
|
|
223
|
+
return bool(re.match(r"^\s*\|?[\s:-]+\|[\s|:-]+\|?\s*$", lines[idx + 1]))
|
|
224
|
+
return False
|
|
225
|
+
|
|
226
|
+
def _parse_table(self, lines: List[str], start: int) -> Tuple[_Block, int]:
|
|
227
|
+
end = start
|
|
228
|
+
while end < len(lines) and ("|" in lines[end] and lines[end].strip()):
|
|
229
|
+
end += 1
|
|
230
|
+
content = "\n".join(lines[start:end])
|
|
231
|
+
return _Block(
|
|
232
|
+
type="table", content=content, start_line=start, end_line=end - 1
|
|
233
|
+
), end - start
|
|
234
|
+
|
|
235
|
+
def _is_list_line(self, line: str) -> bool:
|
|
236
|
+
stripped = line.strip()
|
|
237
|
+
return bool(
|
|
238
|
+
re.match(r"^[\*\-\+]\s+",
|
|
239
|
+
stripped) or re.match(r"^\d+[.)]\s+", stripped)
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def _parse_list(self, lines: List[str], start: int) -> Tuple[_Block, int]:
|
|
243
|
+
end = start
|
|
244
|
+
base_indent = len(lines[start]) - len(lines[start].lstrip())
|
|
245
|
+
end += 1
|
|
246
|
+
while end < len(lines):
|
|
247
|
+
line = lines[end]
|
|
248
|
+
if not line.strip():
|
|
249
|
+
peek = end + 1
|
|
250
|
+
while peek < len(lines) and not lines[peek].strip():
|
|
251
|
+
peek += 1
|
|
252
|
+
if peek < len(lines) and (
|
|
253
|
+
self._is_list_line(lines[peek])
|
|
254
|
+
or len(lines[peek]) - len(lines[peek].lstrip()) > base_indent
|
|
255
|
+
):
|
|
256
|
+
end += 1
|
|
257
|
+
continue
|
|
258
|
+
break
|
|
259
|
+
indent = len(line) - len(line.lstrip())
|
|
260
|
+
if self._is_list_line(line) or indent > base_indent:
|
|
261
|
+
end += 1
|
|
262
|
+
else:
|
|
263
|
+
break
|
|
264
|
+
content = "\n".join(lines[start:end])
|
|
265
|
+
return _Block(
|
|
266
|
+
type="list", content=content, start_line=start, end_line=end - 1
|
|
267
|
+
), end - start
|
|
268
|
+
|
|
269
|
+
def _parse_blockquote(self, lines: List[str], start: int) -> Tuple[_Block, int]:
|
|
270
|
+
end = start
|
|
271
|
+
while end < len(lines) and lines[end].strip().startswith(">"):
|
|
272
|
+
end += 1
|
|
273
|
+
content = "\n".join(lines[start:end])
|
|
274
|
+
return _Block(
|
|
275
|
+
type="blockquote", content=content, start_line=start, end_line=end - 1
|
|
276
|
+
), end - start
|
|
277
|
+
|
|
278
|
+
def _parse_paragraph(self, lines: List[str], start: int) -> Tuple[_Block, int]:
|
|
279
|
+
end = start
|
|
280
|
+
while end < len(lines):
|
|
281
|
+
line = lines[end]
|
|
282
|
+
if not line.strip():
|
|
283
|
+
break
|
|
284
|
+
if end > start and (
|
|
285
|
+
line.strip().startswith("#")
|
|
286
|
+
or _fence_open(line)
|
|
287
|
+
or self._is_list_line(line)
|
|
288
|
+
or line.strip().startswith(">")
|
|
289
|
+
or "|" in line
|
|
290
|
+
):
|
|
291
|
+
break
|
|
292
|
+
end += 1
|
|
293
|
+
content = "\n".join(lines[start:end])
|
|
294
|
+
return _Block(
|
|
295
|
+
type="paragraph", content=content, start_line=start, end_line=end - 1
|
|
296
|
+
), max(end - start, 1)
|
|
297
|
+
|
|
298
|
+
# ------------------------------------------------------------------ #
|
|
299
|
+
# Header stack #
|
|
300
|
+
# ------------------------------------------------------------------ #
|
|
301
|
+
|
|
302
|
+
def _update_header_stack(self, level: int, title: str) -> None:
|
|
303
|
+
self._header_stack = [(l, t)
|
|
304
|
+
for l, t in self._header_stack if l < level]
|
|
305
|
+
self._header_stack.append((level, title))
|
|
306
|
+
|
|
307
|
+
def _get_current_section(self) -> str:
|
|
308
|
+
return " > ".join(t for _, t in self._header_stack)
|
|
309
|
+
|
|
310
|
+
# ------------------------------------------------------------------ #
|
|
311
|
+
# Grouping #
|
|
312
|
+
# ------------------------------------------------------------------ #
|
|
313
|
+
|
|
314
|
+
def _group_blocks(self, blocks: List[_Block]) -> List[Chunk]:
|
|
315
|
+
"""Group blocks into chunks respecting structure and size.
|
|
316
|
+
|
|
317
|
+
Rules: every header starts a new chunk once the current chunk has body
|
|
318
|
+
text; a chunk holding only headers is never flushed for size, so headers
|
|
319
|
+
always travel with their content; atomic blocks are never split.
|
|
320
|
+
"""
|
|
321
|
+
chunks: List[Chunk] = []
|
|
322
|
+
current: List[_Block] = []
|
|
323
|
+
current_size = 0
|
|
324
|
+
has_body = False
|
|
325
|
+
ATOMIC = {"code", "table", "yaml_front_matter", "blockquote"}
|
|
326
|
+
|
|
327
|
+
def flush() -> None:
|
|
328
|
+
nonlocal current, current_size, has_body
|
|
329
|
+
chunks.append(self._make_chunk(current))
|
|
330
|
+
current, current_size, has_body = [], 0, False
|
|
331
|
+
|
|
332
|
+
for block in blocks:
|
|
333
|
+
bsize = len(block.content)
|
|
334
|
+
if block.type == "header":
|
|
335
|
+
if has_body:
|
|
336
|
+
flush()
|
|
337
|
+
elif has_body and current_size + bsize > self.max_chunk_size:
|
|
338
|
+
flush()
|
|
339
|
+
|
|
340
|
+
current.append(block)
|
|
341
|
+
current_size += bsize
|
|
342
|
+
if block.type != "header":
|
|
343
|
+
has_body = True
|
|
344
|
+
if block.type in ATOMIC and current_size >= self.max_chunk_size:
|
|
345
|
+
flush()
|
|
346
|
+
|
|
347
|
+
if current:
|
|
348
|
+
tail = self._make_chunk(current)
|
|
349
|
+
if not has_body and chunks:
|
|
350
|
+
# Trailing headers with no content (e.g. an empty last section)
|
|
351
|
+
chunks[-1] = self._join(chunks[-1], tail)
|
|
352
|
+
else:
|
|
353
|
+
chunks.append(tail)
|
|
354
|
+
|
|
355
|
+
return self._merge_small_chunks(chunks)
|
|
356
|
+
|
|
357
|
+
def _make_chunk(self, blocks: List[_Block]) -> Chunk:
|
|
358
|
+
text = "\n\n".join(b.content for b in blocks)
|
|
359
|
+
# Section of the content; preamble or front matter falls back to the first header's
|
|
360
|
+
section_title = next(
|
|
361
|
+
(b.section_title for b in blocks if b.type != "header" and b.section_title),
|
|
362
|
+
next((b.section_title for b in blocks if b.section_title), ""),
|
|
363
|
+
)
|
|
364
|
+
content_types = list(set(b.type for b in blocks))
|
|
365
|
+
return Chunk(
|
|
366
|
+
text=text,
|
|
367
|
+
section_title=section_title,
|
|
368
|
+
start_line=blocks[0].start_line,
|
|
369
|
+
end_line=blocks[-1].end_line,
|
|
370
|
+
content_types=content_types,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
@staticmethod
|
|
374
|
+
def _join(a: Chunk, b: Chunk) -> Chunk:
|
|
375
|
+
"""Concatenate two adjacent chunks; the larger part names the section."""
|
|
376
|
+
major = a if len(a.text) >= len(b.text) else b
|
|
377
|
+
return Chunk(
|
|
378
|
+
text=a.text + "\n\n" + b.text,
|
|
379
|
+
section_title=major.section_title or a.section_title or b.section_title,
|
|
380
|
+
start_line=a.start_line,
|
|
381
|
+
end_line=b.end_line,
|
|
382
|
+
content_types=list(set(a.content_types + b.content_types)),
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
def _merge_small_chunks(self, chunks: List[Chunk]) -> List[Chunk]:
|
|
386
|
+
"""Merge chunks below min_chunk_size forward (repeatedly), capped at 2x max."""
|
|
387
|
+
cap = self.max_chunk_size * 2
|
|
388
|
+
merged: List[Chunk] = []
|
|
389
|
+
for c in chunks:
|
|
390
|
+
if not c.text.strip():
|
|
391
|
+
continue
|
|
392
|
+
if merged and len(merged[-1].text) < self.min_chunk_size \
|
|
393
|
+
and len(merged[-1].text) + len(c.text) <= cap:
|
|
394
|
+
merged[-1] = self._join(merged[-1], c)
|
|
395
|
+
else:
|
|
396
|
+
merged.append(c)
|
|
397
|
+
# A too-small final chunk joins the previous one, within the same cap
|
|
398
|
+
if len(merged) >= 2 and len(merged[-1].text) < self.min_chunk_size \
|
|
399
|
+
and len(merged[-2].text) + len(merged[-1].text) <= cap:
|
|
400
|
+
last = merged.pop()
|
|
401
|
+
merged[-1] = self._join(merged[-1], last)
|
|
402
|
+
return merged
|
mdkeychunker/cli.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""CLI for MDKeyChunker."""
|
|
2
|
+
import argparse
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from .config import Config
|
|
6
|
+
from .pipeline import Pipeline
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> int:
|
|
10
|
+
p = argparse.ArgumentParser(
|
|
11
|
+
description="MDKeyChunker — Markdown chunking for RAG")
|
|
12
|
+
p.add_argument("input", help="Input Markdown file")
|
|
13
|
+
p.add_argument("-o", "--output", help="Output JSONL file")
|
|
14
|
+
p.add_argument("--env", help="Path to .env file")
|
|
15
|
+
p.add_argument("--stats", action="store_true", help="Print statistics")
|
|
16
|
+
p.add_argument("--no-merge", action="store_true",
|
|
17
|
+
help="Disable key-based chunk merging")
|
|
18
|
+
p.add_argument("--summary", help="Output summary text file")
|
|
19
|
+
p.add_argument("--provider", choices=["openai", "anthropic", "openai_compatible"],
|
|
20
|
+
help="LLM provider (overrides .env)")
|
|
21
|
+
p.add_argument("--model", help="LLM model name (overrides .env)")
|
|
22
|
+
p.add_argument(
|
|
23
|
+
"--base-url", help="LLM base URL for Ollama/vLLM (overrides .env)")
|
|
24
|
+
args = p.parse_args()
|
|
25
|
+
|
|
26
|
+
input_path = Path(args.input)
|
|
27
|
+
if not input_path.exists():
|
|
28
|
+
print(f"Error: {args.input} not found", file=sys.stderr)
|
|
29
|
+
return 1
|
|
30
|
+
|
|
31
|
+
config = Config.from_env(args.env)
|
|
32
|
+
|
|
33
|
+
if args.no_merge:
|
|
34
|
+
config.merge_by_keys = False
|
|
35
|
+
if args.provider:
|
|
36
|
+
config.llm_provider = args.provider
|
|
37
|
+
if args.model:
|
|
38
|
+
config.llm_model = args.model
|
|
39
|
+
if args.base_url:
|
|
40
|
+
config.llm_base_url = args.base_url
|
|
41
|
+
|
|
42
|
+
pipeline = Pipeline(config)
|
|
43
|
+
chunks = pipeline.process_file(str(input_path))
|
|
44
|
+
|
|
45
|
+
out = args.output or str(input_path.with_suffix(".jsonl"))
|
|
46
|
+
pipeline.save_jsonl(chunks, out)
|
|
47
|
+
print(f"✓ {len(chunks)} chunks → {out}")
|
|
48
|
+
|
|
49
|
+
if args.summary:
|
|
50
|
+
pipeline.save_summary(chunks, args.summary)
|
|
51
|
+
print(f"✓ Summary → {args.summary}")
|
|
52
|
+
|
|
53
|
+
if args.stats:
|
|
54
|
+
print(f" Keys: {len(set(c.key for c in chunks if c.key))}")
|
|
55
|
+
print(f" Entities: {sum(len(c.entities) for c in chunks)}")
|
|
56
|
+
print(
|
|
57
|
+
f" Avg tokens: {sum(c.token_count for c in chunks) // max(len(chunks), 1)}"
|
|
58
|
+
)
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
sys.exit(main())
|
mdkeychunker/config.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Configuration for MDKeyChunker."""
|
|
2
|
+
import os
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Config:
|
|
9
|
+
# LLM
|
|
10
|
+
llm_provider: str = "openai" # openai | anthropic | openai_compatible
|
|
11
|
+
llm_api_key: str = ""
|
|
12
|
+
llm_base_url: str = "" # For Ollama/vLLM: http://localhost:11434/v1
|
|
13
|
+
llm_model: str = "gpt-4o-mini"
|
|
14
|
+
# Chunking
|
|
15
|
+
min_chunk_size: int = 100 # Minimum chars per chunk
|
|
16
|
+
max_chunk_size: int = 1500 # Soft max chars per chunk
|
|
17
|
+
# Restructuring
|
|
18
|
+
merge_by_keys: bool = True # Enable key-based chunk merging
|
|
19
|
+
max_merged_size: int = 3000 # Max size after merging
|
|
20
|
+
# Below this, keyless chunks get context enrichment
|
|
21
|
+
min_orphan_size: int = 200
|
|
22
|
+
# General
|
|
23
|
+
log_level: str = "INFO"
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_env(cls, env_file: str | None = None) -> "Config":
|
|
27
|
+
load_dotenv(env_file or ".env")
|
|
28
|
+
return cls(
|
|
29
|
+
llm_provider=os.getenv("LLM_PROVIDER", "openai"),
|
|
30
|
+
llm_api_key=os.getenv("LLM_API_KEY", ""),
|
|
31
|
+
llm_base_url=os.getenv("LLM_BASE_URL", ""),
|
|
32
|
+
llm_model=os.getenv("LLM_MODEL", "gpt-4o-mini"),
|
|
33
|
+
min_chunk_size=int(os.getenv("MIN_CHUNK_SIZE", "100")),
|
|
34
|
+
max_chunk_size=int(os.getenv("MAX_CHUNK_SIZE", "1500")),
|
|
35
|
+
merge_by_keys=os.getenv(
|
|
36
|
+
"MERGE_BY_KEYS", "true").lower() in ("true", "1", "yes"),
|
|
37
|
+
max_merged_size=int(os.getenv("MAX_MERGED_SIZE", "3000")),
|
|
38
|
+
min_orphan_size=int(os.getenv("MIN_ORPHAN_SIZE", "200")),
|
|
39
|
+
log_level=os.getenv("LOG_LEVEL", "INFO"),
|
|
40
|
+
)
|