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.
Files changed (60) hide show
  1. contextual/__init__.py +18 -0
  2. contextual/__main__.py +11 -0
  3. contextual/cli.py +339 -0
  4. contextual/cli_docs.py +685 -0
  5. contextual/config.py +7 -0
  6. contextual/core/__init__.py +11 -0
  7. contextual/core/errors.py +470 -0
  8. contextual/core/models.py +590 -0
  9. contextual/docs/__init__.py +66 -0
  10. contextual/docs/chunker.py +550 -0
  11. contextual/docs/pipeline.py +513 -0
  12. contextual/docs/retrieval.py +654 -0
  13. contextual/docs/watcher.py +265 -0
  14. contextual/embedding/__init__.py +87 -0
  15. contextual/embedding/cache.py +455 -0
  16. contextual/embedding/embedder.py +414 -0
  17. contextual/embedding/helpers.py +252 -0
  18. contextual/git/__init__.py +22 -0
  19. contextual/git/blame.py +334 -0
  20. contextual/indexing/__init__.py +20 -0
  21. contextual/indexing/bug_sweep.py +119 -0
  22. contextual/indexing/chunker.py +691 -0
  23. contextual/indexing/embedder.py +271 -0
  24. contextual/indexing/file_watcher.py +154 -0
  25. contextual/indexing/incremental.py +260 -0
  26. contextual/indexing/index_writer.py +442 -0
  27. contextual/indexing/pipeline.py +438 -0
  28. contextual/indexing/processor.py +436 -0
  29. contextual/indexing/queries/readme.md +22 -0
  30. contextual/indexing/symbol_extractor.py +426 -0
  31. contextual/indexing/tokenizer.py +203 -0
  32. contextual/integrations/__init__.py +10 -0
  33. contextual/mcp/__init__.py +15 -0
  34. contextual/mcp/__main__.py +24 -0
  35. contextual/mcp/docs_tools.py +286 -0
  36. contextual/mcp/server.py +118 -0
  37. contextual/mcp/tools.py +443 -0
  38. contextual/observability/__init__.py +21 -0
  39. contextual/observability/logging.py +115 -0
  40. contextual/py.typed +0 -0
  41. contextual/retrieval/__init__.py +24 -0
  42. contextual/retrieval/context_assembler.py +372 -0
  43. contextual/retrieval/ranker.py +193 -0
  44. contextual/retrieval/search.py +548 -0
  45. contextual/security/__init__.py +52 -0
  46. contextual/security/paths.py +347 -0
  47. contextual/security/sanitize.py +349 -0
  48. contextual/security/workspace.py +348 -0
  49. contextual/storage/__init__.py +36 -0
  50. contextual/storage/fts_manager.py +273 -0
  51. contextual/storage/migration_v2.py +289 -0
  52. contextual/storage/migrations.py +316 -0
  53. contextual/storage/schema.py +210 -0
  54. contextual/storage/sqlite_pool.py +468 -0
  55. contextual/storage/vec0_manager.py +421 -0
  56. contextual_engine-0.1.0.dist-info/METADATA +297 -0
  57. contextual_engine-0.1.0.dist-info/RECORD +60 -0
  58. contextual_engine-0.1.0.dist-info/WHEEL +4 -0
  59. contextual_engine-0.1.0.dist-info/entry_points.txt +2 -0
  60. 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)