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,455 @@
1
+ """Embedding cache for content-hash based deduplication.
2
+
3
+ Caches embeddings keyed by content hash to avoid re-embedding unchanged chunks.
4
+ Uses SQLite for persistence across indexing runs.
5
+
6
+ Cache strategy:
7
+ - Key: SHA-256 hash of chunk content (header + body)
8
+ - Value: 768-dim embedding vector (JSON-encoded)
9
+ - Lookup: O(1) with index on content_hash
10
+ - Eviction: None (cache grows unbounded - embeddings are small)
11
+
12
+ Performance impact:
13
+ - Cache hit: ~0.1ms lookup vs ~55ms embedding generation (550x faster)
14
+ - Storage: ~3KB per cached embedding (768 floats as JSON)
15
+ - On 10K chunks: ~30MB cache, saves ~9 minutes of embedding time
16
+
17
+ The cache table is separate from main schema to allow independent cleanup.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import sqlite3
24
+ from typing import TYPE_CHECKING
25
+
26
+ from contextual.core.errors import ErrorCode, StorageError, storage_context
27
+ from contextual.observability.logging import get_logger
28
+
29
+ if TYPE_CHECKING:
30
+ from sqlite3 import Connection
31
+
32
+
33
+ logger = get_logger(__name__)
34
+
35
+ # ============================================================================
36
+ # CACHE SCHEMA
37
+ # ============================================================================
38
+
39
+ CACHE_TABLE_DDL = """
40
+ CREATE TABLE IF NOT EXISTS embedding_cache (
41
+ content_hash TEXT PRIMARY KEY,
42
+ embedding TEXT NOT NULL,
43
+ model_name TEXT NOT NULL,
44
+ created_at INTEGER NOT NULL,
45
+ hit_count INTEGER DEFAULT 0
46
+ ) STRICT;
47
+ """
48
+
49
+ CACHE_INDEX_DDL = """
50
+ CREATE INDEX IF NOT EXISTS idx_embedding_cache_model
51
+ ON embedding_cache(model_name, created_at);
52
+ """
53
+
54
+
55
+ # ============================================================================
56
+ # CACHE OPERATIONS
57
+ # ============================================================================
58
+
59
+
60
+ def initialize_cache(conn: Connection) -> None:
61
+ """Initialize embedding cache table and indexes.
62
+
63
+ Safe to call multiple times (uses IF NOT EXISTS).
64
+
65
+ Args:
66
+ conn: SQLite connection.
67
+
68
+ Raises:
69
+ StorageError: If cache initialization fails.
70
+ """
71
+ try:
72
+ cursor = conn.cursor()
73
+ cursor.execute(CACHE_TABLE_DDL)
74
+ cursor.execute(CACHE_INDEX_DDL)
75
+ conn.commit()
76
+
77
+ logger.debug("Embedding cache initialized")
78
+
79
+ except sqlite3.Error as e:
80
+ msg = f"Failed to initialize embedding cache: {e}"
81
+ raise StorageError(
82
+ code=ErrorCode.STORAGE_SQLITE_SCHEMA_INVALID,
83
+ message=msg,
84
+ context=storage_context(table="embedding_cache"),
85
+ ) from e
86
+
87
+
88
+ def get_cached_embedding(
89
+ conn: Connection,
90
+ content_hash: str,
91
+ model_name: str,
92
+ ) -> list[float] | None:
93
+ """Retrieve cached embedding by content hash.
94
+
95
+ Increments hit_count on cache hit for analytics.
96
+
97
+ Args:
98
+ conn: SQLite connection.
99
+ content_hash: SHA-256 hash of content.
100
+ model_name: Embedding model identifier.
101
+
102
+ Returns:
103
+ 768-dim embedding vector, or None if not cached.
104
+
105
+ Raises:
106
+ StorageError: If cache lookup fails.
107
+ """
108
+ try:
109
+ cursor = conn.cursor()
110
+
111
+ # Fetch cached embedding
112
+ cursor.execute(
113
+ """
114
+ SELECT embedding
115
+ FROM embedding_cache
116
+ WHERE content_hash = ? AND model_name = ?
117
+ """,
118
+ (content_hash, model_name),
119
+ )
120
+
121
+ row = cursor.fetchone()
122
+
123
+ if row is None:
124
+ return None
125
+
126
+ # Parse JSON embedding
127
+ embedding = json.loads(row[0])
128
+
129
+ # Increment hit count
130
+ cursor.execute(
131
+ """
132
+ UPDATE embedding_cache
133
+ SET hit_count = hit_count + 1
134
+ WHERE content_hash = ?
135
+ """,
136
+ (content_hash,),
137
+ )
138
+ conn.commit()
139
+
140
+ logger.debug(
141
+ "Cache hit",
142
+ content_hash=content_hash[:8],
143
+ model=model_name,
144
+ )
145
+
146
+ return embedding
147
+
148
+ except (sqlite3.Error, json.JSONDecodeError) as e:
149
+ msg = f"Failed to retrieve cached embedding: {e}"
150
+ raise StorageError(
151
+ code=ErrorCode.STORAGE_SQLITE_QUERY_FAILED,
152
+ message=msg,
153
+ context=storage_context(
154
+ table="embedding_cache",
155
+ query="get_cached_embedding",
156
+ ),
157
+ ) from e
158
+
159
+
160
+ def put_cached_embedding(
161
+ conn: Connection,
162
+ content_hash: str,
163
+ embedding: list[float],
164
+ model_name: str,
165
+ ) -> None:
166
+ """Store embedding in cache.
167
+
168
+ Uses INSERT OR REPLACE for idempotent caching.
169
+
170
+ Args:
171
+ conn: SQLite connection.
172
+ content_hash: SHA-256 hash of content.
173
+ embedding: 768-dim embedding vector.
174
+ model_name: Embedding model identifier.
175
+
176
+ Raises:
177
+ StorageError: If cache write fails.
178
+ """
179
+ try:
180
+ import time
181
+
182
+ cursor = conn.cursor()
183
+ now_ms = int(time.time() * 1000)
184
+
185
+ # Serialize embedding as JSON
186
+ embedding_json = json.dumps(embedding)
187
+
188
+ # Insert or replace
189
+ cursor.execute(
190
+ """
191
+ INSERT OR REPLACE INTO embedding_cache
192
+ (content_hash, embedding, model_name, created_at, hit_count)
193
+ VALUES (?, ?, ?, ?, 0)
194
+ """,
195
+ (content_hash, embedding_json, model_name, now_ms),
196
+ )
197
+
198
+ conn.commit()
199
+
200
+ logger.debug(
201
+ "Cache write",
202
+ content_hash=content_hash[:8],
203
+ model=model_name,
204
+ )
205
+
206
+ except (sqlite3.Error, TypeError) as e:
207
+ msg = f"Failed to cache embedding: {e}"
208
+ raise StorageError(
209
+ code=ErrorCode.STORAGE_SQLITE_QUERY_FAILED,
210
+ message=msg,
211
+ context=storage_context(
212
+ table="embedding_cache",
213
+ query="put_cached_embedding",
214
+ ),
215
+ ) from e
216
+
217
+
218
+ def get_cached_embeddings_batch(
219
+ conn: Connection,
220
+ content_hashes: list[str],
221
+ model_name: str,
222
+ ) -> dict[str, list[float]]:
223
+ """Retrieve multiple cached embeddings in one query.
224
+
225
+ More efficient than individual lookups for batches.
226
+
227
+ Args:
228
+ conn: SQLite connection.
229
+ content_hashes: List of content hashes to lookup.
230
+ model_name: Embedding model identifier.
231
+
232
+ Returns:
233
+ Dict mapping content_hash -> embedding for cache hits only.
234
+
235
+ Raises:
236
+ StorageError: If batch lookup fails.
237
+ """
238
+ if not content_hashes:
239
+ return {}
240
+
241
+ try:
242
+ cursor = conn.cursor()
243
+
244
+ # Build parameterized query
245
+ placeholders = ",".join("?" * len(content_hashes))
246
+ query = f"""
247
+ SELECT content_hash, embedding
248
+ FROM embedding_cache
249
+ WHERE content_hash IN ({placeholders})
250
+ AND model_name = ?
251
+ """
252
+
253
+ params = [*content_hashes, model_name]
254
+ cursor.execute(query, params)
255
+
256
+ # Parse results
257
+ results = {}
258
+ for content_hash, embedding_json in cursor.fetchall():
259
+ embedding = json.loads(embedding_json)
260
+ results[content_hash] = embedding
261
+
262
+ # Increment hit counts for all hits
263
+ if results:
264
+ hit_hashes = list(results.keys())
265
+ hit_placeholders = ",".join("?" * len(hit_hashes))
266
+ cursor.execute(
267
+ f"""
268
+ UPDATE embedding_cache
269
+ SET hit_count = hit_count + 1
270
+ WHERE content_hash IN ({hit_placeholders})
271
+ """,
272
+ hit_hashes,
273
+ )
274
+ conn.commit()
275
+
276
+ logger.debug(
277
+ "Batch cache lookup",
278
+ requested=len(content_hashes),
279
+ hits=len(results),
280
+ hit_rate=len(results) / len(content_hashes) if content_hashes else 0,
281
+ )
282
+
283
+ return results
284
+
285
+ except (sqlite3.Error, json.JSONDecodeError) as e:
286
+ msg = f"Failed to retrieve cached embeddings batch: {e}"
287
+ raise StorageError(
288
+ code=ErrorCode.STORAGE_SQLITE_QUERY_FAILED,
289
+ message=msg,
290
+ context=storage_context(
291
+ table="embedding_cache",
292
+ query="get_cached_embeddings_batch",
293
+ ),
294
+ ) from e
295
+
296
+
297
+ def put_cached_embeddings_batch(
298
+ conn: Connection,
299
+ embeddings: list[tuple[str, list[float]]],
300
+ model_name: str,
301
+ ) -> None:
302
+ """Store multiple embeddings in cache efficiently.
303
+
304
+ Uses executemany for batch insertion.
305
+
306
+ Args:
307
+ conn: SQLite connection.
308
+ embeddings: List of (content_hash, embedding) tuples.
309
+ model_name: Embedding model identifier.
310
+
311
+ Raises:
312
+ StorageError: If batch cache write fails.
313
+ """
314
+ if not embeddings:
315
+ return
316
+
317
+ try:
318
+ import time
319
+
320
+ cursor = conn.cursor()
321
+ now_ms = int(time.time() * 1000)
322
+
323
+ # Prepare batch data
324
+ batch_data = [
325
+ (
326
+ content_hash,
327
+ json.dumps(embedding),
328
+ model_name,
329
+ now_ms,
330
+ )
331
+ for content_hash, embedding in embeddings
332
+ ]
333
+
334
+ # Batch insert
335
+ cursor.executemany(
336
+ """
337
+ INSERT OR REPLACE INTO embedding_cache
338
+ (content_hash, embedding, model_name, created_at, hit_count)
339
+ VALUES (?, ?, ?, ?, 0)
340
+ """,
341
+ batch_data,
342
+ )
343
+
344
+ conn.commit()
345
+
346
+ logger.info(
347
+ "Batch cache write",
348
+ count=len(embeddings),
349
+ model=model_name,
350
+ )
351
+
352
+ except (sqlite3.Error, TypeError) as e:
353
+ msg = f"Failed to cache embeddings batch: {e}"
354
+ raise StorageError(
355
+ code=ErrorCode.STORAGE_SQLITE_QUERY_FAILED,
356
+ message=msg,
357
+ context=storage_context(
358
+ table="embedding_cache",
359
+ query="put_cached_embeddings_batch",
360
+ ),
361
+ ) from e
362
+
363
+
364
+ def get_cache_stats(conn: Connection) -> dict[str, int]:
365
+ """Get cache statistics for monitoring.
366
+
367
+ Returns:
368
+ Dict with cache size, total hits, and model breakdown.
369
+
370
+ Example:
371
+ >>> stats = get_cache_stats(conn)
372
+ >>> stats
373
+ {
374
+ 'total_entries': 10000,
375
+ 'total_hits': 45000,
376
+ 'models': {'jinaai/jina-embeddings-v2-base-code': 10000}
377
+ }
378
+ """
379
+ try:
380
+ cursor = conn.cursor()
381
+
382
+ # Total entries
383
+ cursor.execute("SELECT COUNT(*) FROM embedding_cache")
384
+ total_entries = cursor.fetchone()[0]
385
+
386
+ # Total hits
387
+ cursor.execute("SELECT COALESCE(SUM(hit_count), 0) FROM embedding_cache")
388
+ total_hits = cursor.fetchone()[0]
389
+
390
+ # Model breakdown
391
+ cursor.execute(
392
+ """
393
+ SELECT model_name, COUNT(*)
394
+ FROM embedding_cache
395
+ GROUP BY model_name
396
+ """
397
+ )
398
+ models = dict(cursor.fetchall())
399
+
400
+ return {
401
+ "total_entries": total_entries,
402
+ "total_hits": total_hits,
403
+ "models": models,
404
+ }
405
+
406
+ except sqlite3.Error as e:
407
+ logger.warning("Failed to get cache stats", error=str(e))
408
+ return {"total_entries": 0, "total_hits": 0, "models": {}}
409
+
410
+
411
+ def clear_cache(conn: Connection, model_name: str | None = None) -> int:
412
+ """Clear embedding cache.
413
+
414
+ Args:
415
+ conn: SQLite connection.
416
+ model_name: Clear only entries for this model, or None for all.
417
+
418
+ Returns:
419
+ Number of entries deleted.
420
+
421
+ Raises:
422
+ StorageError: If cache clear fails.
423
+ """
424
+ try:
425
+ cursor = conn.cursor()
426
+
427
+ if model_name:
428
+ cursor.execute(
429
+ "DELETE FROM embedding_cache WHERE model_name = ?",
430
+ (model_name,),
431
+ )
432
+ else:
433
+ cursor.execute("DELETE FROM embedding_cache")
434
+
435
+ deleted = cursor.rowcount
436
+ conn.commit()
437
+
438
+ logger.info(
439
+ "Cache cleared",
440
+ deleted=deleted,
441
+ model=model_name or "all",
442
+ )
443
+
444
+ return deleted
445
+
446
+ except sqlite3.Error as e:
447
+ msg = f"Failed to clear cache: {e}"
448
+ raise StorageError(
449
+ code=ErrorCode.STORAGE_SQLITE_QUERY_FAILED,
450
+ message=msg,
451
+ context=storage_context(
452
+ table="embedding_cache",
453
+ query="clear_cache",
454
+ ),
455
+ ) from e