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,513 @@
|
|
|
1
|
+
"""Docs indexing pipeline for Contextual Phase 2.
|
|
2
|
+
|
|
3
|
+
Mirrors ``contextual/indexing/pipeline.py`` but for markdown/MDX/RST/TXT files.
|
|
4
|
+
Orchestrates:
|
|
5
|
+
1. File discovery — respects .gitignore + .contextualignore
|
|
6
|
+
2. Docs chunking — heading-aware via DocsChunker
|
|
7
|
+
3. Embedding — fastembed (jina-embeddings-v2-base-code, 768-dim)
|
|
8
|
+
4. Storage — writes to chunks / vec_chunks / fts_chunks with source_type='docs'
|
|
9
|
+
5. File watching — integrates with the existing watchdog FileWatcher
|
|
10
|
+
|
|
11
|
+
Design constraints:
|
|
12
|
+
- Single-writer to SQLite (same pool as code indexer).
|
|
13
|
+
- Incremental: skip files whose content hash has not changed.
|
|
14
|
+
- source_type='docs' column used for filter pushdown in retrieval.
|
|
15
|
+
- All logging to stderr via structlog (NEVER stdout — breaks stdio MCP transport).
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from sqlite3 import Connection
|
|
24
|
+
from typing import Callable
|
|
25
|
+
|
|
26
|
+
import pathspec
|
|
27
|
+
import structlog
|
|
28
|
+
|
|
29
|
+
from contextual.docs.chunker import DocChunk, DocsChunker
|
|
30
|
+
from contextual.embedding import CodeEmbedder, DocsEmbedder
|
|
31
|
+
from contextual.storage import SQLitePool
|
|
32
|
+
|
|
33
|
+
logger = structlog.get_logger(__name__)
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Supported document extensions
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
DOC_EXTENSIONS: frozenset[str] = frozenset({".md", ".mdx", ".rst", ".txt"})
|
|
39
|
+
|
|
40
|
+
# Directories always excluded regardless of .gitignore
|
|
41
|
+
_ALWAYS_EXCLUDE: frozenset[str] = frozenset({
|
|
42
|
+
".git", ".hg", ".svn",
|
|
43
|
+
"node_modules", "__pycache__", ".venv", "venv", ".env",
|
|
44
|
+
"target", "dist", "build", ".contextual",
|
|
45
|
+
".mypy_cache", ".ruff_cache", ".pytest_cache",
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Stats dataclass
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class DocsIndexStats:
|
|
54
|
+
"""Counters returned after an indexing run."""
|
|
55
|
+
files_discovered: int = 0
|
|
56
|
+
files_skipped_unchanged: int = 0
|
|
57
|
+
files_indexed: int = 0
|
|
58
|
+
files_failed: int = 0
|
|
59
|
+
chunks_created: int = 0
|
|
60
|
+
embeddings_generated: int = 0
|
|
61
|
+
duration_seconds: float = 0.0
|
|
62
|
+
|
|
63
|
+
def __str__(self) -> str: # noqa: D105
|
|
64
|
+
return (
|
|
65
|
+
f"files={self.files_indexed}/{self.files_discovered} "
|
|
66
|
+
f"chunks={self.chunks_created} "
|
|
67
|
+
f"embeddings={self.embeddings_generated} "
|
|
68
|
+
f"skipped={self.files_skipped_unchanged} "
|
|
69
|
+
f"failed={self.files_failed} "
|
|
70
|
+
f"duration={self.duration_seconds:.2f}s"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
# Schema helpers
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
def _ensure_docs_schema(conn: Connection) -> None:
|
|
79
|
+
"""Ensure Phase 2 columns exist on the chunks table.
|
|
80
|
+
|
|
81
|
+
Idempotent — safe to call on every startup. We use ALTER TABLE … ADD
|
|
82
|
+
COLUMN which is a no-op if the column already exists (SQLite ≥ 3.37).
|
|
83
|
+
"""
|
|
84
|
+
existing_cols = {
|
|
85
|
+
row[1]
|
|
86
|
+
for row in conn.execute("PRAGMA table_info(chunks)").fetchall()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
migrations: list[str] = []
|
|
90
|
+
|
|
91
|
+
if "source_type" not in existing_cols:
|
|
92
|
+
migrations.append(
|
|
93
|
+
"ALTER TABLE chunks ADD COLUMN source_type TEXT NOT NULL DEFAULT 'code'"
|
|
94
|
+
)
|
|
95
|
+
if "heading_path" not in existing_cols:
|
|
96
|
+
migrations.append(
|
|
97
|
+
"ALTER TABLE chunks ADD COLUMN heading_path TEXT NOT NULL DEFAULT ''"
|
|
98
|
+
)
|
|
99
|
+
if "heading_level" not in existing_cols:
|
|
100
|
+
migrations.append(
|
|
101
|
+
"ALTER TABLE chunks ADD COLUMN heading_level INTEGER NOT NULL DEFAULT 0"
|
|
102
|
+
)
|
|
103
|
+
if "chunk_type" not in existing_cols:
|
|
104
|
+
migrations.append(
|
|
105
|
+
"ALTER TABLE chunks ADD COLUMN chunk_type TEXT NOT NULL DEFAULT 'code'"
|
|
106
|
+
)
|
|
107
|
+
if "frontmatter_json" not in existing_cols:
|
|
108
|
+
migrations.append(
|
|
109
|
+
"ALTER TABLE chunks ADD COLUMN frontmatter_json TEXT NOT NULL DEFAULT '{}'"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if migrations:
|
|
113
|
+
for sql in migrations:
|
|
114
|
+
conn.execute(sql)
|
|
115
|
+
conn.commit()
|
|
116
|
+
logger.info("Applied Phase 2 schema columns", count=len(migrations))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _file_content_hash(path: Path) -> str:
|
|
120
|
+
"""SHA-256 hex digest of file contents (for change detection)."""
|
|
121
|
+
h = hashlib.sha256()
|
|
122
|
+
h.update(path.read_bytes())
|
|
123
|
+
return h.hexdigest()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# Main pipeline class
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
class DocsPipeline:
|
|
131
|
+
"""Full docs indexing pipeline.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
repo_root: Absolute path to the repository root.
|
|
135
|
+
pool: Shared SQLitePool (single-writer).
|
|
136
|
+
embedder: Embedder instance (fastembed ONNX, 768-dim).
|
|
137
|
+
progress: Optional callback ``(current, total, message)`` for CLI progress.
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def __init__(
|
|
141
|
+
self,
|
|
142
|
+
repo_root: Path,
|
|
143
|
+
pool: SQLitePool,
|
|
144
|
+
embedder: DocsEmbedder | CodeEmbedder,
|
|
145
|
+
progress: Callable[[int, int, str], None] | None = None,
|
|
146
|
+
) -> None:
|
|
147
|
+
self._root = repo_root.resolve()
|
|
148
|
+
self._pool = pool
|
|
149
|
+
self._embedder = embedder
|
|
150
|
+
self._chunker = DocsChunker()
|
|
151
|
+
self._progress = progress or (lambda *_: None)
|
|
152
|
+
|
|
153
|
+
# ------------------------------------------------------------------
|
|
154
|
+
# Public entry points
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
def index_all(self, force: bool = False) -> DocsIndexStats:
|
|
158
|
+
"""Full docs index of the repository.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
force: Re-index every file even if hash unchanged.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
``DocsIndexStats`` with counters for this run.
|
|
165
|
+
"""
|
|
166
|
+
t0 = time.perf_counter()
|
|
167
|
+
stats = DocsIndexStats()
|
|
168
|
+
|
|
169
|
+
with self._pool.writer() as conn:
|
|
170
|
+
_ensure_docs_schema(conn)
|
|
171
|
+
|
|
172
|
+
file_paths = self._discover_files()
|
|
173
|
+
stats.files_discovered = len(file_paths)
|
|
174
|
+
logger.info("Docs discovery complete", discovered=len(file_paths), root=str(self._root))
|
|
175
|
+
|
|
176
|
+
for i, fp in enumerate(file_paths, start=1):
|
|
177
|
+
self._progress(i, len(file_paths), fp.name)
|
|
178
|
+
result = self._index_file(conn, fp, force=force)
|
|
179
|
+
if result is None:
|
|
180
|
+
stats.files_skipped_unchanged += 1
|
|
181
|
+
elif result == -1:
|
|
182
|
+
stats.files_failed += 1
|
|
183
|
+
else:
|
|
184
|
+
stats.files_indexed += 1
|
|
185
|
+
stats.chunks_created += result
|
|
186
|
+
|
|
187
|
+
stats.duration_seconds = time.perf_counter() - t0
|
|
188
|
+
logger.info("Docs index_all complete", stats=str(stats))
|
|
189
|
+
return stats
|
|
190
|
+
|
|
191
|
+
def index_file(self, file_path: Path, force: bool = False) -> DocsIndexStats:
|
|
192
|
+
"""Incrementally index (or re-index) a single document file.
|
|
193
|
+
|
|
194
|
+
Intended for file-watcher integration.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
file_path: Absolute path to the changed file.
|
|
198
|
+
force: Skip hash check and always re-index.
|
|
199
|
+
"""
|
|
200
|
+
t0 = time.perf_counter()
|
|
201
|
+
stats = DocsIndexStats(files_discovered=1)
|
|
202
|
+
|
|
203
|
+
if file_path.suffix.lower() not in DOC_EXTENSIONS:
|
|
204
|
+
logger.debug("Skipped: not a doc extension", path=str(file_path))
|
|
205
|
+
return stats
|
|
206
|
+
|
|
207
|
+
with self._pool.writer() as conn:
|
|
208
|
+
result = self._index_file(conn, file_path, force=force)
|
|
209
|
+
if result is None:
|
|
210
|
+
stats.files_skipped_unchanged = 1
|
|
211
|
+
elif result == -1:
|
|
212
|
+
stats.files_failed = 1
|
|
213
|
+
else:
|
|
214
|
+
stats.files_indexed = 1
|
|
215
|
+
stats.chunks_created = result
|
|
216
|
+
|
|
217
|
+
stats.duration_seconds = time.perf_counter() - t0
|
|
218
|
+
return stats
|
|
219
|
+
|
|
220
|
+
def delete_file(self, file_path: Path) -> int:
|
|
221
|
+
"""Remove all chunks for a deleted file from the index.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
Number of chunks deleted.
|
|
225
|
+
"""
|
|
226
|
+
rel = self._rel_path(file_path)
|
|
227
|
+
with self._pool.writer() as conn:
|
|
228
|
+
cur = conn.execute(
|
|
229
|
+
"SELECT id FROM chunks WHERE file_path = ? AND source_type = 'docs'",
|
|
230
|
+
(rel,),
|
|
231
|
+
)
|
|
232
|
+
chunk_ids = [row[0] for row in cur.fetchall()]
|
|
233
|
+
if not chunk_ids:
|
|
234
|
+
return 0
|
|
235
|
+
placeholders = ",".join("?" * len(chunk_ids))
|
|
236
|
+
conn.execute(f"DELETE FROM vec_chunks WHERE chunk_id IN ({placeholders})", chunk_ids)
|
|
237
|
+
conn.execute(
|
|
238
|
+
f"DELETE FROM chunks WHERE id IN ({placeholders})",
|
|
239
|
+
chunk_ids,
|
|
240
|
+
)
|
|
241
|
+
conn.commit()
|
|
242
|
+
logger.info("Deleted docs chunks", path=rel, count=len(chunk_ids))
|
|
243
|
+
return len(chunk_ids)
|
|
244
|
+
|
|
245
|
+
# ------------------------------------------------------------------
|
|
246
|
+
# File discovery
|
|
247
|
+
# ------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
def _discover_files(self) -> list[Path]:
|
|
250
|
+
"""Walk repo_root and return all indexable doc files.
|
|
251
|
+
|
|
252
|
+
Respects:
|
|
253
|
+
- .gitignore (via pathspec)
|
|
254
|
+
- .contextualignore (project-specific additions)
|
|
255
|
+
- _ALWAYS_EXCLUDE hardcoded dir set
|
|
256
|
+
"""
|
|
257
|
+
ignore_spec = self._load_ignore_spec()
|
|
258
|
+
results: list[Path] = []
|
|
259
|
+
|
|
260
|
+
for path in self._root.rglob("*"):
|
|
261
|
+
if not path.is_file():
|
|
262
|
+
continue
|
|
263
|
+
if path.suffix.lower() not in DOC_EXTENSIONS:
|
|
264
|
+
continue
|
|
265
|
+
# Exclude always-ignored dir components
|
|
266
|
+
parts = set(path.relative_to(self._root).parts)
|
|
267
|
+
if parts & _ALWAYS_EXCLUDE:
|
|
268
|
+
continue
|
|
269
|
+
# Exclude gitignore patterns
|
|
270
|
+
rel = str(path.relative_to(self._root))
|
|
271
|
+
if ignore_spec and ignore_spec.match_file(rel):
|
|
272
|
+
continue
|
|
273
|
+
results.append(path)
|
|
274
|
+
|
|
275
|
+
results.sort()
|
|
276
|
+
return results
|
|
277
|
+
|
|
278
|
+
def _load_ignore_spec(self) -> pathspec.PathSpec | None:
|
|
279
|
+
"""Load .gitignore + .contextualignore patterns."""
|
|
280
|
+
patterns: list[str] = []
|
|
281
|
+
for ignore_file in (".gitignore", ".contextualignore"):
|
|
282
|
+
ig_path = self._root / ignore_file
|
|
283
|
+
if ig_path.exists():
|
|
284
|
+
try:
|
|
285
|
+
patterns.extend(ig_path.read_text(encoding="utf-8").splitlines())
|
|
286
|
+
except OSError:
|
|
287
|
+
pass
|
|
288
|
+
if not patterns:
|
|
289
|
+
return None
|
|
290
|
+
return pathspec.PathSpec.from_lines("gitwildmatch", patterns)
|
|
291
|
+
|
|
292
|
+
# ------------------------------------------------------------------
|
|
293
|
+
# Single-file indexing
|
|
294
|
+
# ------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
def _index_file(
|
|
297
|
+
self, conn: Connection, file_path: Path, force: bool
|
|
298
|
+
) -> int | None:
|
|
299
|
+
"""Index a single file within an active writer connection.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
int: number of chunks written (0 is valid for empty files).
|
|
303
|
+
None: file skipped (hash unchanged and not force).
|
|
304
|
+
-1: file failed (logged internally).
|
|
305
|
+
"""
|
|
306
|
+
rel = self._rel_path(file_path)
|
|
307
|
+
|
|
308
|
+
# --- Change detection ---
|
|
309
|
+
try:
|
|
310
|
+
content_hash = _file_content_hash(file_path)
|
|
311
|
+
except OSError as exc:
|
|
312
|
+
logger.warning("Cannot hash file — skipping", path=rel, error=str(exc))
|
|
313
|
+
return -1
|
|
314
|
+
|
|
315
|
+
if not force:
|
|
316
|
+
row = conn.execute(
|
|
317
|
+
"SELECT content_hash FROM files WHERE path = ?", (rel,)
|
|
318
|
+
).fetchone()
|
|
319
|
+
if row and row[0] == content_hash:
|
|
320
|
+
logger.debug("File unchanged — skipping", path=rel)
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
# --- Remove stale chunks ---
|
|
324
|
+
self._purge_file_chunks(conn, rel)
|
|
325
|
+
|
|
326
|
+
# --- Chunk ---
|
|
327
|
+
try:
|
|
328
|
+
chunks: list[DocChunk] = self._chunker.chunk_file(file_path, repo_root=self._root)
|
|
329
|
+
except Exception as exc: # noqa: BLE001
|
|
330
|
+
logger.error("Chunking failed", path=rel, error=str(exc))
|
|
331
|
+
return -1
|
|
332
|
+
|
|
333
|
+
if not chunks:
|
|
334
|
+
logger.debug("No chunks produced", path=rel)
|
|
335
|
+
self._upsert_file_record(conn, rel, content_hash, chunk_count=0)
|
|
336
|
+
conn.commit()
|
|
337
|
+
return 0
|
|
338
|
+
|
|
339
|
+
# --- Embed ---
|
|
340
|
+
chunk_tuples = []
|
|
341
|
+
for c in chunks:
|
|
342
|
+
emb_hash = hashlib.sha256(c.embedding_text.encode("utf-8")).hexdigest()
|
|
343
|
+
chunk_tuples.append((emb_hash, c.embedding_text))
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
from contextual.embedding.helpers import embed_chunks
|
|
347
|
+
cached_results = embed_chunks(conn, chunk_tuples)
|
|
348
|
+
embeddings: list[list[float]] = [emb for _, emb in cached_results]
|
|
349
|
+
except Exception as exc: # noqa: BLE001
|
|
350
|
+
logger.error("Embedding failed", path=rel, chunks=len(chunks), error=str(exc))
|
|
351
|
+
return -1
|
|
352
|
+
|
|
353
|
+
if len(embeddings) != len(chunks):
|
|
354
|
+
logger.error(
|
|
355
|
+
"Embedding count mismatch",
|
|
356
|
+
expected=len(chunks),
|
|
357
|
+
got=len(embeddings),
|
|
358
|
+
path=rel,
|
|
359
|
+
)
|
|
360
|
+
return -1
|
|
361
|
+
|
|
362
|
+
# --- Write chunks + vectors ---
|
|
363
|
+
import json
|
|
364
|
+
|
|
365
|
+
file_id = self._upsert_file_record(conn, rel, content_hash, chunk_count=len(chunks))
|
|
366
|
+
|
|
367
|
+
chunk_ids: list[int] = []
|
|
368
|
+
chunk_data = []
|
|
369
|
+
for chunk in chunks:
|
|
370
|
+
chunk_data.append((
|
|
371
|
+
file_id,
|
|
372
|
+
rel,
|
|
373
|
+
chunk.content,
|
|
374
|
+
chunk.chunk_index,
|
|
375
|
+
chunk.start_line,
|
|
376
|
+
chunk.end_line,
|
|
377
|
+
"markdown",
|
|
378
|
+
"docs",
|
|
379
|
+
chunk.heading_path,
|
|
380
|
+
chunk.heading_level,
|
|
381
|
+
chunk.chunk_type,
|
|
382
|
+
json.dumps(chunk.frontmatter),
|
|
383
|
+
))
|
|
384
|
+
|
|
385
|
+
# Batch insert chunks and collect IDs
|
|
386
|
+
# Note: SQLite executemany doesn't support RETURNING in older versions,
|
|
387
|
+
# so we insert one by one to get lastrowid, OR we use a high enough version.
|
|
388
|
+
# Given the "N+1" fix requirement, we should batch.
|
|
389
|
+
# However, to get IDs for FTS and Vec0, we'll insert one by one and THEN batch FTS/Vec.
|
|
390
|
+
for data in chunk_data:
|
|
391
|
+
cur = conn.execute(
|
|
392
|
+
"""
|
|
393
|
+
INSERT INTO chunks (
|
|
394
|
+
file_id, file_path, content, chunk_index,
|
|
395
|
+
start_line, end_line, language,
|
|
396
|
+
source_type, heading_path, heading_level,
|
|
397
|
+
chunk_type, frontmatter_json, indexed_at
|
|
398
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, unixepoch('now'))
|
|
399
|
+
""",
|
|
400
|
+
data
|
|
401
|
+
)
|
|
402
|
+
if cur.lastrowid is None:
|
|
403
|
+
logger.error("Failed to retrieve inserted chunk ID", path=rel)
|
|
404
|
+
return -1
|
|
405
|
+
chunk_ids.append(cur.lastrowid)
|
|
406
|
+
|
|
407
|
+
# --- Batch FTS5 insert ---
|
|
408
|
+
if chunk_ids:
|
|
409
|
+
fts_rows = [
|
|
410
|
+
(cid, chunks[i].content, "", "", chunks[i].heading_path)
|
|
411
|
+
for i, cid in enumerate(chunk_ids)
|
|
412
|
+
]
|
|
413
|
+
conn.executemany(
|
|
414
|
+
"""
|
|
415
|
+
INSERT INTO fts_chunks (rowid, content, file_path, symbol_name, heading_path)
|
|
416
|
+
VALUES (?, ?, ?, ?, ?)
|
|
417
|
+
""",
|
|
418
|
+
fts_rows
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
# --- Vec0 batch insert ---
|
|
422
|
+
self._insert_vectors(conn, list(zip(chunk_ids, embeddings, strict=True)))
|
|
423
|
+
|
|
424
|
+
conn.commit()
|
|
425
|
+
logger.info(
|
|
426
|
+
"Docs file indexed",
|
|
427
|
+
path=rel,
|
|
428
|
+
chunks=len(chunks),
|
|
429
|
+
source_type="docs",
|
|
430
|
+
)
|
|
431
|
+
return len(chunks)
|
|
432
|
+
|
|
433
|
+
# ------------------------------------------------------------------
|
|
434
|
+
# Storage helpers
|
|
435
|
+
# ------------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
def _purge_file_chunks(self, conn: Connection, rel_path: str) -> None:
|
|
438
|
+
"""Delete all existing chunks + vectors for a doc file."""
|
|
439
|
+
cur = conn.execute(
|
|
440
|
+
"SELECT id FROM chunks WHERE file_path = ? AND source_type IN ('docs','docs_code')",
|
|
441
|
+
(rel_path,),
|
|
442
|
+
)
|
|
443
|
+
ids = [row[0] for row in cur.fetchall()]
|
|
444
|
+
if ids:
|
|
445
|
+
ph = ",".join("?" * len(ids))
|
|
446
|
+
conn.execute(f"DELETE FROM vec_chunks WHERE chunk_id IN ({ph})", ids)
|
|
447
|
+
conn.execute(f"DELETE FROM fts_chunks WHERE rowid IN ({ph})", ids)
|
|
448
|
+
conn.execute(f"DELETE FROM chunks WHERE id IN ({ph})", ids)
|
|
449
|
+
|
|
450
|
+
def _upsert_file_record(
|
|
451
|
+
self, conn: Connection, rel_path: str, content_hash: str, chunk_count: int
|
|
452
|
+
) -> int:
|
|
453
|
+
"""Insert or update the files table row, returning the file_id."""
|
|
454
|
+
conn.execute(
|
|
455
|
+
"""
|
|
456
|
+
INSERT INTO files (path, language, mtime, content_hash, indexed_at)
|
|
457
|
+
VALUES (?, ?, unixepoch('now'), ?, unixepoch('now'))
|
|
458
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
459
|
+
content_hash = excluded.content_hash,
|
|
460
|
+
indexed_at = excluded.indexed_at,
|
|
461
|
+
mtime = excluded.mtime
|
|
462
|
+
""",
|
|
463
|
+
(rel_path, "markdown", content_hash),
|
|
464
|
+
)
|
|
465
|
+
row = conn.execute("SELECT id FROM files WHERE path = ?", (rel_path,)).fetchone()
|
|
466
|
+
return row[0]
|
|
467
|
+
|
|
468
|
+
def _insert_vectors(
|
|
469
|
+
self, conn: Connection, pairs: list[tuple[int, list[float]]]
|
|
470
|
+
) -> None:
|
|
471
|
+
"""Batch-insert (chunk_id, embedding) into vec_chunks (vec0)."""
|
|
472
|
+
import json as _json
|
|
473
|
+
|
|
474
|
+
if pairs:
|
|
475
|
+
vec_rows = [(chunk_id, _json.dumps(vec)) for chunk_id, vec in pairs]
|
|
476
|
+
conn.executemany(
|
|
477
|
+
"INSERT OR REPLACE INTO vec_chunks (chunk_id, embedding) VALUES (?, ?)",
|
|
478
|
+
vec_rows,
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
def _rel_path(self, abs_path: Path) -> str:
|
|
482
|
+
"""Return repo-relative string path."""
|
|
483
|
+
try:
|
|
484
|
+
return str(abs_path.relative_to(self._root))
|
|
485
|
+
except ValueError:
|
|
486
|
+
return str(abs_path)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# ---------------------------------------------------------------------------
|
|
490
|
+
# Module-level convenience
|
|
491
|
+
# ---------------------------------------------------------------------------
|
|
492
|
+
|
|
493
|
+
def index_docs(
|
|
494
|
+
repo_root: Path,
|
|
495
|
+
pool: SQLitePool,
|
|
496
|
+
embedder: CodeEmbedder | DocsEmbedder,
|
|
497
|
+
force: bool = False,
|
|
498
|
+
progress: Callable[[int, int, str], None] | None = None,
|
|
499
|
+
) -> DocsIndexStats:
|
|
500
|
+
"""One-shot convenience: index all docs in a repository.
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
repo_root: Absolute repository root.
|
|
504
|
+
pool: Active SQLitePool.
|
|
505
|
+
embedder: Embedder instance.
|
|
506
|
+
force: Re-index regardless of content hash.
|
|
507
|
+
progress: Optional ``(current, total, filename)`` progress callback.
|
|
508
|
+
|
|
509
|
+
Returns:
|
|
510
|
+
``DocsIndexStats`` summary.
|
|
511
|
+
"""
|
|
512
|
+
pipeline = DocsPipeline(repo_root, pool, embedder, progress=progress)
|
|
513
|
+
return pipeline.index_all(force=force)
|