contextual-engine 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.
- contextual/__init__.py +18 -0
- contextual/__main__.py +11 -0
- contextual/cli.py +339 -0
- contextual/cli_docs.py +685 -0
- contextual/config.py +7 -0
- contextual/core/__init__.py +11 -0
- contextual/core/errors.py +470 -0
- contextual/core/models.py +590 -0
- contextual/docs/__init__.py +66 -0
- contextual/docs/chunker.py +550 -0
- contextual/docs/pipeline.py +513 -0
- contextual/docs/retrieval.py +654 -0
- contextual/docs/watcher.py +265 -0
- contextual/embedding/__init__.py +87 -0
- contextual/embedding/cache.py +455 -0
- contextual/embedding/embedder.py +414 -0
- contextual/embedding/helpers.py +252 -0
- contextual/git/__init__.py +22 -0
- contextual/git/blame.py +334 -0
- contextual/indexing/__init__.py +20 -0
- contextual/indexing/bug_sweep.py +119 -0
- contextual/indexing/chunker.py +691 -0
- contextual/indexing/embedder.py +271 -0
- contextual/indexing/file_watcher.py +154 -0
- contextual/indexing/incremental.py +260 -0
- contextual/indexing/index_writer.py +442 -0
- contextual/indexing/pipeline.py +438 -0
- contextual/indexing/processor.py +436 -0
- contextual/indexing/queries/readme.md +22 -0
- contextual/indexing/symbol_extractor.py +426 -0
- contextual/indexing/tokenizer.py +203 -0
- contextual/integrations/__init__.py +10 -0
- contextual/mcp/__init__.py +15 -0
- contextual/mcp/__main__.py +24 -0
- contextual/mcp/docs_tools.py +286 -0
- contextual/mcp/server.py +118 -0
- contextual/mcp/tools.py +443 -0
- contextual/observability/__init__.py +21 -0
- contextual/observability/logging.py +115 -0
- contextual/py.typed +0 -0
- contextual/retrieval/__init__.py +24 -0
- contextual/retrieval/context_assembler.py +372 -0
- contextual/retrieval/ranker.py +193 -0
- contextual/retrieval/search.py +548 -0
- contextual/security/__init__.py +52 -0
- contextual/security/paths.py +347 -0
- contextual/security/sanitize.py +349 -0
- contextual/security/workspace.py +348 -0
- contextual/storage/__init__.py +36 -0
- contextual/storage/fts_manager.py +273 -0
- contextual/storage/migration_v2.py +289 -0
- contextual/storage/migrations.py +316 -0
- contextual/storage/schema.py +210 -0
- contextual/storage/sqlite_pool.py +468 -0
- contextual/storage/vec0_manager.py +421 -0
- contextual_engine-0.1.0.dist-info/METADATA +297 -0
- contextual_engine-0.1.0.dist-info/RECORD +60 -0
- contextual_engine-0.1.0.dist-info/WHEEL +4 -0
- contextual_engine-0.1.0.dist-info/entry_points.txt +2 -0
- contextual_engine-0.1.0.dist-info/licenses/LICENSE +111 -0
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
"""Heading-aware markdown/MDX/RST document chunker for Contextual Phase 2.
|
|
2
|
+
|
|
3
|
+
Splits documents into semantically coherent chunks by heading hierarchy.
|
|
4
|
+
Strategy:
|
|
5
|
+
- Each H2 section is the primary chunk unit.
|
|
6
|
+
- H3+ sub-sections are nested under their H2 parent.
|
|
7
|
+
- Every chunk's ``embedding_text`` carries a breadcrumb prefix:
|
|
8
|
+
``[File: path | Section: H1 > H2 > H3]\\nchunk content``
|
|
9
|
+
- Code blocks <80 tokens stay inline; 80-600 become a parallel ``docs_code``
|
|
10
|
+
chunk; >600 become standalone with a placeholder in the parent chunk.
|
|
11
|
+
|
|
12
|
+
Supported formats:
|
|
13
|
+
.md — CommonMark + GFM, YAML/TOML frontmatter stripped
|
|
14
|
+
.mdx — MDX: JSX/import/export lines stripped before markdown parse
|
|
15
|
+
.rst — reStructuredText: docutils → pseudo-markdown heading tree
|
|
16
|
+
.txt — Plain text: split by double-newline paragraphs
|
|
17
|
+
|
|
18
|
+
Token accounting uses a fast 4-chars-per-token approximation consistent with
|
|
19
|
+
jina-embeddings-v2-base-code (768-dim) which the embedder uses.
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
import unicodedata
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Generator
|
|
28
|
+
|
|
29
|
+
import structlog
|
|
30
|
+
|
|
31
|
+
logger = structlog.get_logger(__name__)
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Token budget constants (tune here only)
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
CHUNK_TARGET_TOKENS: int = 450
|
|
37
|
+
CHUNK_MAX_TOKENS: int = 800
|
|
38
|
+
CHUNK_MIN_TOKENS: int = 120
|
|
39
|
+
OVERLAP_TOKENS: int = 60 # trailing overlap appended to next chunk
|
|
40
|
+
CODE_BLOCK_INLINE_MAX: int = 80 # tokens — keep inline in parent chunk
|
|
41
|
+
CODE_BLOCK_SPLIT_MAX: int = 600 # tokens — emit parallel docs_code chunk; >600 → standalone
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Public data model
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
@dataclass(slots=True)
|
|
49
|
+
class DocChunk:
|
|
50
|
+
"""A single semantically coherent chunk from a document.
|
|
51
|
+
|
|
52
|
+
Fields that map directly to the ``chunks`` table columns:
|
|
53
|
+
- ``content`` raw text sent to BM25/FTS5 index
|
|
54
|
+
- ``embedding_text`` breadcrumb-prefixed text sent to the embedder
|
|
55
|
+
- ``heading_path`` e.g. "Installation > macOS > Homebrew"
|
|
56
|
+
- ``heading_level`` depth of the deepest heading in this chunk (1-6)
|
|
57
|
+
- ``chunk_type`` "docs" or "docs_code"
|
|
58
|
+
- ``start_line`` 1-based line in source file
|
|
59
|
+
- ``end_line`` 1-based line in source file (inclusive)
|
|
60
|
+
- ``chunk_index`` sequential position within the file (0-based)
|
|
61
|
+
- ``token_estimate`` fast approximation; not exact token count
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
file_path: str # repo-relative or absolute
|
|
65
|
+
content: str
|
|
66
|
+
embedding_text: str
|
|
67
|
+
heading_path: str # ">"-joined breadcrumb
|
|
68
|
+
heading_level: int # 1-6
|
|
69
|
+
chunk_type: str # "docs" | "docs_code"
|
|
70
|
+
start_line: int
|
|
71
|
+
end_line: int
|
|
72
|
+
chunk_index: int
|
|
73
|
+
token_estimate: int
|
|
74
|
+
frontmatter: dict = field(default_factory=dict) # parsed YAML metadata
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Token estimation (fast, no tokeniser dependency)
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def _est_tokens(text: str) -> int:
|
|
82
|
+
"""Estimate token count at ~4 chars/token (standard LLM approximation)."""
|
|
83
|
+
return max(1, len(text) // 4)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# MDX pre-processor
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Patterns that are valid in MDX but crash markdown-it-py
|
|
90
|
+
_MDX_IMPORT_EXPORT_RE = re.compile(
|
|
91
|
+
r"^(import|export)\s.*$",
|
|
92
|
+
re.MULTILINE,
|
|
93
|
+
)
|
|
94
|
+
_JSX_COMPONENT_RE = re.compile(
|
|
95
|
+
r"<([A-Z][A-Za-z0-9]*)(?:\s[^>]*)?\s*/?>.*?(?:</\1>)?",
|
|
96
|
+
re.DOTALL,
|
|
97
|
+
)
|
|
98
|
+
_MDX_EXPRESSION_RE = re.compile(r"\{[^}]*\}", re.DOTALL)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _strip_mdx(source: str) -> str:
|
|
102
|
+
"""Strip MDX-specific syntax, leaving valid CommonMark behind."""
|
|
103
|
+
source = _MDX_IMPORT_EXPORT_RE.sub("", source)
|
|
104
|
+
source = _JSX_COMPONENT_RE.sub("", source)
|
|
105
|
+
source = _MDX_EXPRESSION_RE.sub("", source)
|
|
106
|
+
# Collapse multiple blank lines left by stripping
|
|
107
|
+
source = re.sub(r"\n{3,}", "\n\n", source)
|
|
108
|
+
return source
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
# RST → pseudo-markdown converter
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
# RST heading adornments in typical decoration order
|
|
115
|
+
_RST_ADORNMENTS = r"[=\-`:'\"~^_*+#<>]"
|
|
116
|
+
_RST_HEADING_RE = re.compile(
|
|
117
|
+
rf"^({_RST_ADORNMENTS}){{3,}}\n(.+)\n({_RST_ADORNMENTS}){{3,}}$|"
|
|
118
|
+
rf"^(.+)\n({_RST_ADORNMENTS}){{3,}}$",
|
|
119
|
+
re.MULTILINE,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Map adornment char to heading level using order of first encounter
|
|
123
|
+
def _rst_to_markdown(source: str) -> str:
|
|
124
|
+
"""Convert RST heading syntax to markdown ATX headings (#, ##, ###, ...).
|
|
125
|
+
|
|
126
|
+
We do a two-pass: first collect all adornment characters in order of
|
|
127
|
+
appearance (determines hierarchy), then substitute. Non-heading RST
|
|
128
|
+
directives are left as-is — they become fenced code blocks or plain text
|
|
129
|
+
when parsed by markdown-it-py.
|
|
130
|
+
"""
|
|
131
|
+
adornment_order: list[str] = []
|
|
132
|
+
lines = source.splitlines(keepends=True)
|
|
133
|
+
out: list[str] = []
|
|
134
|
+
i = 0
|
|
135
|
+
while i < len(lines):
|
|
136
|
+
line = lines[i].rstrip("\n")
|
|
137
|
+
# Detect underline-only (section title above)
|
|
138
|
+
if i > 0 and re.match(rf"^{_RST_ADORNMENTS}{{3,}}$", line):
|
|
139
|
+
prev = lines[i - 1].rstrip("\n")
|
|
140
|
+
char = line[0]
|
|
141
|
+
if char not in adornment_order:
|
|
142
|
+
adornment_order.append(char)
|
|
143
|
+
level = min(adornment_order.index(char) + 1, 6)
|
|
144
|
+
# Replace the already-pushed previous line with an ATX heading
|
|
145
|
+
if out:
|
|
146
|
+
out[-1] = "#" * level + " " + prev + "\n"
|
|
147
|
+
i += 1
|
|
148
|
+
continue
|
|
149
|
+
out.append(lines[i])
|
|
150
|
+
i += 1
|
|
151
|
+
return "".join(out)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# Frontmatter stripper
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
_YAML_FM_RE = re.compile(r"^---\s*\n(.*?\n?)---\s*\n", re.DOTALL)
|
|
158
|
+
_TOML_FM_RE = re.compile(r"^\+\+\+\s*\n(.*?\n?)\+\+\+\s*\n", re.DOTALL)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _strip_frontmatter(source: str) -> tuple[str, dict]:
|
|
162
|
+
"""Strip YAML or TOML frontmatter and return (body, metadata_dict)."""
|
|
163
|
+
meta: dict = {}
|
|
164
|
+
for pattern in (_YAML_FM_RE, _TOML_FM_RE):
|
|
165
|
+
m = pattern.match(source)
|
|
166
|
+
if m:
|
|
167
|
+
raw = m.group(1)
|
|
168
|
+
try:
|
|
169
|
+
import yaml # pyyaml ships with python-frontmatter
|
|
170
|
+
meta = yaml.safe_load(raw) or {}
|
|
171
|
+
except Exception: # noqa: BLE001
|
|
172
|
+
pass
|
|
173
|
+
source = source[m.end():]
|
|
174
|
+
break
|
|
175
|
+
return source, meta
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ---------------------------------------------------------------------------
|
|
179
|
+
# Heading tree builder
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
@dataclass
|
|
183
|
+
class _HeadingNode:
|
|
184
|
+
level: int # 1–6
|
|
185
|
+
title: str
|
|
186
|
+
start_line: int
|
|
187
|
+
end_line: int # set after next heading found
|
|
188
|
+
body_lines: list[str] = field(default_factory=list)
|
|
189
|
+
children: list["_HeadingNode"] = field(default_factory=list)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
_ATX_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)(?:\s+#+)?$")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _build_heading_tree(lines: list[str]) -> _HeadingNode:
|
|
196
|
+
"""Parse ATX headings and build a heading tree from markdown lines.
|
|
197
|
+
|
|
198
|
+
Returns a virtual root node whose children are the top-level headings.
|
|
199
|
+
Lines before the first heading are collected under root.body_lines.
|
|
200
|
+
"""
|
|
201
|
+
root = _HeadingNode(level=0, title="__root__", start_line=1, end_line=len(lines))
|
|
202
|
+
stack: list[_HeadingNode] = [root]
|
|
203
|
+
|
|
204
|
+
for lineno, raw in enumerate(lines, start=1):
|
|
205
|
+
m = _ATX_HEADING_RE.match(raw.rstrip("\n"))
|
|
206
|
+
if m:
|
|
207
|
+
level = len(m.group(1))
|
|
208
|
+
title = m.group(2).strip()
|
|
209
|
+
node = _HeadingNode(level=level, title=title, start_line=lineno, end_line=len(lines))
|
|
210
|
+
|
|
211
|
+
# Close nodes deeper than current level
|
|
212
|
+
while len(stack) > 1 and stack[-1].level >= level:
|
|
213
|
+
closed = stack.pop()
|
|
214
|
+
closed.end_line = lineno - 1
|
|
215
|
+
|
|
216
|
+
# Attach to parent
|
|
217
|
+
stack[-1].children.append(node)
|
|
218
|
+
stack.append(node)
|
|
219
|
+
else:
|
|
220
|
+
stack[-1].body_lines.append(raw)
|
|
221
|
+
|
|
222
|
+
return root
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _node_breadcrumb(ancestors: list[_HeadingNode], node: _HeadingNode) -> str:
|
|
226
|
+
"""Build a ' > '-joined breadcrumb from ancestor titles + this node."""
|
|
227
|
+
parts = [n.title for n in ancestors if n.level > 0] + [node.title]
|
|
228
|
+
return " > ".join(parts)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _collect_body(node: _HeadingNode, lines: list[str]) -> str:
|
|
232
|
+
"""Collect the raw text body of a node (excluding child headings)."""
|
|
233
|
+
# Body is lines from node start to (first child start - 1) or node end
|
|
234
|
+
if node.children:
|
|
235
|
+
body_end = node.children[0].start_line - 1
|
|
236
|
+
else:
|
|
237
|
+
body_end = node.end_line
|
|
238
|
+
start = node.start_line # start_line is the heading line itself
|
|
239
|
+
body_lines = lines[start:body_end] # skip heading line (index start-1)
|
|
240
|
+
return "".join(body_lines).strip()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
# Code block extractor
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
_FENCED_CODE_RE = re.compile(
|
|
247
|
+
r"(```(?:[^\n]*)?\n)(.*?)(```\s*$)",
|
|
248
|
+
re.DOTALL | re.MULTILINE,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _split_code_blocks(
|
|
253
|
+
text: str,
|
|
254
|
+
) -> tuple[str, list[tuple[str, str]]]:
|
|
255
|
+
"""Extract large code blocks from text.
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
(text_with_placeholders, [(placeholder, code_block_text), ...])
|
|
259
|
+
Large = > CODE_BLOCK_INLINE_MAX estimated tokens.
|
|
260
|
+
"""
|
|
261
|
+
extracted: list[tuple[str, str]] = []
|
|
262
|
+
counter = [0]
|
|
263
|
+
|
|
264
|
+
def replacer(m: re.Match) -> str:
|
|
265
|
+
full_block = m.group(0)
|
|
266
|
+
tok = _est_tokens(full_block)
|
|
267
|
+
if tok <= CODE_BLOCK_INLINE_MAX:
|
|
268
|
+
return full_block # keep inline
|
|
269
|
+
placeholder = f"[[CODE_BLOCK_{counter[0]}]]"
|
|
270
|
+
extracted.append((placeholder, full_block))
|
|
271
|
+
counter[0] += 1
|
|
272
|
+
return placeholder
|
|
273
|
+
|
|
274
|
+
result = _FENCED_CODE_RE.sub(replacer, text)
|
|
275
|
+
return result, extracted
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
# Paragraph splitter (for overlong sections)
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
def _split_by_paragraphs(
|
|
283
|
+
text: str,
|
|
284
|
+
max_tokens: int = CHUNK_MAX_TOKENS,
|
|
285
|
+
overlap_tokens: int = OVERLAP_TOKENS,
|
|
286
|
+
) -> list[str]:
|
|
287
|
+
"""Split overlong text into paragraph-aligned sub-chunks with overlap."""
|
|
288
|
+
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
|
|
289
|
+
if not paragraphs:
|
|
290
|
+
return [text] if text.strip() else []
|
|
291
|
+
|
|
292
|
+
chunks: list[str] = []
|
|
293
|
+
current_parts: list[str] = []
|
|
294
|
+
current_tokens = 0
|
|
295
|
+
overlap_tail = ""
|
|
296
|
+
|
|
297
|
+
for para in paragraphs:
|
|
298
|
+
ptok = _est_tokens(para)
|
|
299
|
+
if current_tokens + ptok > max_tokens and current_parts:
|
|
300
|
+
chunk_text = overlap_tail + "\n\n".join(current_parts)
|
|
301
|
+
chunks.append(chunk_text)
|
|
302
|
+
# Carry last paragraph as overlap seed
|
|
303
|
+
overlap_tail = current_parts[-1][:overlap_tokens * 4] + "\n\n"
|
|
304
|
+
current_parts = []
|
|
305
|
+
current_tokens = _est_tokens(overlap_tail)
|
|
306
|
+
current_parts.append(para)
|
|
307
|
+
current_tokens += ptok
|
|
308
|
+
|
|
309
|
+
if current_parts:
|
|
310
|
+
chunks.append(overlap_tail + "\n\n".join(current_parts))
|
|
311
|
+
|
|
312
|
+
return chunks if chunks else [text]
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# ---------------------------------------------------------------------------
|
|
316
|
+
# Main chunker
|
|
317
|
+
# ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
class DocsChunker:
|
|
320
|
+
"""Chunk a document file into ``DocChunk`` instances.
|
|
321
|
+
|
|
322
|
+
Usage::
|
|
323
|
+
|
|
324
|
+
chunker = DocsChunker()
|
|
325
|
+
chunks = chunker.chunk_file(Path("docs/README.md"))
|
|
326
|
+
"""
|
|
327
|
+
|
|
328
|
+
SUPPORTED_EXTENSIONS = frozenset({".md", ".mdx", ".rst", ".txt"})
|
|
329
|
+
|
|
330
|
+
def __init__(
|
|
331
|
+
self,
|
|
332
|
+
target_tokens: int = CHUNK_TARGET_TOKENS,
|
|
333
|
+
max_tokens: int = CHUNK_MAX_TOKENS,
|
|
334
|
+
min_tokens: int = CHUNK_MIN_TOKENS,
|
|
335
|
+
) -> None:
|
|
336
|
+
self._target = target_tokens
|
|
337
|
+
self._max = max_tokens
|
|
338
|
+
self._min = min_tokens
|
|
339
|
+
|
|
340
|
+
# ------------------------------------------------------------------
|
|
341
|
+
# Public API
|
|
342
|
+
# ------------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
def chunk_file(self, file_path: Path, repo_root: Path | None = None) -> list[DocChunk]:
|
|
345
|
+
"""Parse and chunk a single document file.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
file_path: Absolute path to the document.
|
|
349
|
+
repo_root: If provided, ``DocChunk.file_path`` is repo-relative.
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
List of ``DocChunk`` objects (may be empty for blank files).
|
|
353
|
+
"""
|
|
354
|
+
suffix = file_path.suffix.lower()
|
|
355
|
+
if suffix not in self.SUPPORTED_EXTENSIONS:
|
|
356
|
+
logger.debug("Skipping unsupported extension", path=str(file_path), suffix=suffix)
|
|
357
|
+
return []
|
|
358
|
+
|
|
359
|
+
try:
|
|
360
|
+
source = file_path.read_text(encoding="utf-8", errors="replace")
|
|
361
|
+
except OSError as exc:
|
|
362
|
+
logger.warning("Cannot read file", path=str(file_path), error=str(exc))
|
|
363
|
+
return []
|
|
364
|
+
|
|
365
|
+
if not source.strip():
|
|
366
|
+
return []
|
|
367
|
+
|
|
368
|
+
rel_path = str(file_path.relative_to(repo_root)) if repo_root else str(file_path)
|
|
369
|
+
return list(self._chunk_source(source, suffix, rel_path))
|
|
370
|
+
|
|
371
|
+
# ------------------------------------------------------------------
|
|
372
|
+
# Internal dispatch
|
|
373
|
+
# ------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
def _chunk_source(
|
|
376
|
+
self, source: str, suffix: str, rel_path: str
|
|
377
|
+
) -> Generator[DocChunk, None, None]:
|
|
378
|
+
"""Dispatch to format-specific handler and yield DocChunk objects."""
|
|
379
|
+
if suffix == ".rst":
|
|
380
|
+
source = _rst_to_markdown(source)
|
|
381
|
+
|
|
382
|
+
if suffix == ".txt":
|
|
383
|
+
yield from self._chunk_plain_text(source, rel_path)
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
# Strip frontmatter for .md / .mdx / (converted .rst)
|
|
387
|
+
source, frontmatter = _strip_frontmatter(source)
|
|
388
|
+
|
|
389
|
+
if suffix == ".mdx":
|
|
390
|
+
source = _strip_mdx(source)
|
|
391
|
+
|
|
392
|
+
yield from self._chunk_markdown(source, rel_path, frontmatter)
|
|
393
|
+
|
|
394
|
+
# ------------------------------------------------------------------
|
|
395
|
+
# Markdown chunker
|
|
396
|
+
# ------------------------------------------------------------------
|
|
397
|
+
|
|
398
|
+
def _chunk_markdown(
|
|
399
|
+
self,
|
|
400
|
+
source: str,
|
|
401
|
+
rel_path: str,
|
|
402
|
+
frontmatter: dict,
|
|
403
|
+
) -> Generator[DocChunk, None, None]:
|
|
404
|
+
"""Main heading-aware chunker for CommonMark/MDX/RST (post-convert)."""
|
|
405
|
+
lines = source.splitlines(keepends=True)
|
|
406
|
+
root = _build_heading_tree(lines)
|
|
407
|
+
|
|
408
|
+
chunk_index = 0
|
|
409
|
+
file_label = Path(rel_path).name
|
|
410
|
+
|
|
411
|
+
# Pre-heading body (before any heading in the file)
|
|
412
|
+
if root.body_lines:
|
|
413
|
+
preamble = "".join(root.body_lines).strip()
|
|
414
|
+
if _est_tokens(preamble) >= self._min:
|
|
415
|
+
emb = f"[File: {file_label}]\n{preamble}"
|
|
416
|
+
yield DocChunk(
|
|
417
|
+
file_path=rel_path,
|
|
418
|
+
content=preamble,
|
|
419
|
+
embedding_text=emb,
|
|
420
|
+
heading_path="",
|
|
421
|
+
heading_level=0,
|
|
422
|
+
chunk_type="docs",
|
|
423
|
+
start_line=1,
|
|
424
|
+
end_line=root.children[0].start_line - 1 if root.children else len(lines),
|
|
425
|
+
chunk_index=chunk_index,
|
|
426
|
+
token_estimate=_est_tokens(preamble),
|
|
427
|
+
frontmatter=frontmatter,
|
|
428
|
+
)
|
|
429
|
+
chunk_index += 1
|
|
430
|
+
|
|
431
|
+
# Walk heading tree depth-first
|
|
432
|
+
def _walk(
|
|
433
|
+
node: _HeadingNode, ancestors: list[_HeadingNode]
|
|
434
|
+
) -> Generator[DocChunk, None, None]:
|
|
435
|
+
nonlocal chunk_index
|
|
436
|
+
|
|
437
|
+
breadcrumb = _node_breadcrumb(ancestors, node)
|
|
438
|
+
body = _collect_body(node, lines)
|
|
439
|
+
|
|
440
|
+
# --- Extract oversize code blocks from body ---
|
|
441
|
+
body_no_code, code_blocks = _split_code_blocks(body)
|
|
442
|
+
|
|
443
|
+
# --- Emit code chunks for large blocks ---
|
|
444
|
+
for placeholder, code_text in code_blocks:
|
|
445
|
+
tok = _est_tokens(code_text)
|
|
446
|
+
if tok <= CODE_BLOCK_SPLIT_MAX:
|
|
447
|
+
# Parallel docs_code chunk
|
|
448
|
+
emb = f"[File: {file_label} | Section: {breadcrumb}]\n{code_text}"
|
|
449
|
+
yield DocChunk(
|
|
450
|
+
file_path=rel_path,
|
|
451
|
+
content=code_text,
|
|
452
|
+
embedding_text=emb,
|
|
453
|
+
heading_path=breadcrumb,
|
|
454
|
+
heading_level=node.level,
|
|
455
|
+
chunk_type="docs_code",
|
|
456
|
+
start_line=node.start_line,
|
|
457
|
+
end_line=node.end_line,
|
|
458
|
+
chunk_index=chunk_index,
|
|
459
|
+
token_estimate=tok,
|
|
460
|
+
frontmatter=frontmatter,
|
|
461
|
+
)
|
|
462
|
+
chunk_index += 1
|
|
463
|
+
# else: standalone — placeholder stays in body_no_code; body
|
|
464
|
+
# will reference it but we don't emit the raw code separately
|
|
465
|
+
# (too large; user sees [CODE_BLOCK_N] marker in content)
|
|
466
|
+
|
|
467
|
+
# --- Split body_no_code into sub-chunks if overlong ---
|
|
468
|
+
if body_no_code.strip():
|
|
469
|
+
sub_texts = (
|
|
470
|
+
_split_by_paragraphs(body_no_code, self._max)
|
|
471
|
+
if _est_tokens(body_no_code) > self._max
|
|
472
|
+
else [body_no_code]
|
|
473
|
+
)
|
|
474
|
+
for idx, sub in enumerate(sub_texts):
|
|
475
|
+
sub = sub.strip()
|
|
476
|
+
if not sub:
|
|
477
|
+
continue
|
|
478
|
+
tok = _est_tokens(sub)
|
|
479
|
+
if tok < self._min and len(sub_texts) > 1:
|
|
480
|
+
# Too small sub-chunk from a split — merge into prev
|
|
481
|
+
# by not yielding; caller will see gap in chunk_index
|
|
482
|
+
continue
|
|
483
|
+
|
|
484
|
+
section_label = f"{breadcrumb} (part {idx+1})" if len(sub_texts) > 1 else breadcrumb
|
|
485
|
+
emb = f"[File: {file_label} | Section: {section_label}]\n{sub}"
|
|
486
|
+
yield DocChunk(
|
|
487
|
+
file_path=rel_path,
|
|
488
|
+
content=sub,
|
|
489
|
+
embedding_text=emb,
|
|
490
|
+
heading_path=breadcrumb,
|
|
491
|
+
heading_level=node.level,
|
|
492
|
+
chunk_type="docs",
|
|
493
|
+
start_line=node.start_line,
|
|
494
|
+
end_line=node.end_line,
|
|
495
|
+
chunk_index=chunk_index,
|
|
496
|
+
token_estimate=tok,
|
|
497
|
+
frontmatter=frontmatter,
|
|
498
|
+
)
|
|
499
|
+
chunk_index += 1
|
|
500
|
+
|
|
501
|
+
# Recurse into children
|
|
502
|
+
for child in node.children:
|
|
503
|
+
yield from _walk(child, ancestors + [node])
|
|
504
|
+
|
|
505
|
+
for top_node in root.children:
|
|
506
|
+
yield from _walk(top_node, [])
|
|
507
|
+
|
|
508
|
+
# ------------------------------------------------------------------
|
|
509
|
+
# Plain text chunker
|
|
510
|
+
# ------------------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
def _chunk_plain_text(
|
|
513
|
+
self, source: str, rel_path: str
|
|
514
|
+
) -> Generator[DocChunk, None, None]:
|
|
515
|
+
"""Paragraph-based chunker for .txt files (no heading structure)."""
|
|
516
|
+
file_label = Path(rel_path).name
|
|
517
|
+
sub_texts = _split_by_paragraphs(source, self._max)
|
|
518
|
+
for idx, sub in enumerate(sub_texts):
|
|
519
|
+
sub = sub.strip()
|
|
520
|
+
if not sub:
|
|
521
|
+
continue
|
|
522
|
+
tok = _est_tokens(sub)
|
|
523
|
+
if tok < self._min and len(sub_texts) > 1:
|
|
524
|
+
continue
|
|
525
|
+
emb = f"[File: {file_label}]\n{sub}"
|
|
526
|
+
yield DocChunk(
|
|
527
|
+
file_path=rel_path,
|
|
528
|
+
content=sub,
|
|
529
|
+
embedding_text=emb,
|
|
530
|
+
heading_path="",
|
|
531
|
+
heading_level=0,
|
|
532
|
+
chunk_type="docs",
|
|
533
|
+
start_line=1,
|
|
534
|
+
end_line=source.count("\n") + 1,
|
|
535
|
+
chunk_index=idx,
|
|
536
|
+
token_estimate=tok,
|
|
537
|
+
frontmatter={},
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
# ---------------------------------------------------------------------------
|
|
542
|
+
# Module-level convenience
|
|
543
|
+
# ---------------------------------------------------------------------------
|
|
544
|
+
|
|
545
|
+
def chunk_doc_file(
|
|
546
|
+
file_path: Path,
|
|
547
|
+
repo_root: Path | None = None,
|
|
548
|
+
) -> list[DocChunk]:
|
|
549
|
+
"""Convenience wrapper — chunk a single doc file with default settings."""
|
|
550
|
+
return DocsChunker().chunk_file(file_path, repo_root=repo_root)
|