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,372 @@
1
+ """Context assembly: Budget-aware formatting of search results for LLM consumption.
2
+
3
+ Takes search results and assembles them into a context string that fits within
4
+ a token budget. Implements:
5
+ - Token counting (using tiktoken cl100k_base)
6
+ - Budget allocation (15% structural, 85% content per TECHNICAL_SPEC §5)
7
+ - Progressive inclusion with truncation
8
+ - Rich metadata formatting
9
+
10
+ The assembler ensures that:
11
+ 1. Structural context (file paths, imports, class hierarchy) gets 15% of budget
12
+ 2. Actual code chunks get 85% of budget
13
+ 3. Higher-ranked results are prioritized
14
+ 4. Snippets are truncated gracefully if budget is tight
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ from typing import TYPE_CHECKING
21
+
22
+ import tiktoken
23
+
24
+ from contextual.observability.logging import get_logger
25
+
26
+
27
+ if TYPE_CHECKING:
28
+ from contextual.retrieval.search import SearchResult
29
+
30
+
31
+ logger = get_logger(__name__)
32
+
33
+ # ============================================================================
34
+ # CONSTANTS (from TECHNICAL_SPEC §5)
35
+ # ============================================================================
36
+
37
+ STRUCTURAL_BUDGET_RATIO = 0.15
38
+ """Percentage of budget allocated to structural context."""
39
+
40
+ CONTENT_BUDGET_RATIO = 0.85
41
+ """Percentage of budget allocated to code chunks."""
42
+
43
+ DEFAULT_TOKEN_BUDGET = 100_000
44
+ """Default maximum tokens for assembled context (Claude 3.7 window)."""
45
+
46
+ # Encoding for token counting (matches Claude's tokenizer)
47
+ TOKENIZER = tiktoken.get_encoding("cl100k_base")
48
+
49
+
50
+ # ============================================================================
51
+ # DATA MODELS
52
+ # ============================================================================
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class AssembledContext:
57
+ """Assembled context ready for LLM consumption.
58
+
59
+ Attributes:
60
+ content: Formatted context string.
61
+ total_tokens: Total token count.
62
+ structural_tokens: Tokens used for structural metadata.
63
+ content_tokens: Tokens used for code chunks.
64
+ results_included: Number of search results included.
65
+ results_truncated: Number of results truncated due to budget.
66
+ """
67
+
68
+ content: str
69
+ total_tokens: int
70
+ structural_tokens: int
71
+ content_tokens: int
72
+ results_included: int
73
+ results_truncated: int
74
+
75
+
76
+ # ============================================================================
77
+ # TOKEN COUNTING
78
+ # ============================================================================
79
+
80
+
81
+ def count_tokens(text: str) -> int:
82
+ """Count tokens in a string using cl100k_base encoding.
83
+
84
+ Args:
85
+ text: String to count tokens for.
86
+
87
+ Returns:
88
+ Number of tokens.
89
+ """
90
+ return len(TOKENIZER.encode(text))
91
+
92
+
93
+ # ============================================================================
94
+ # STRUCTURAL CONTEXT FORMATTING
95
+ # ============================================================================
96
+
97
+
98
+ def _build_structural_context(results: list[SearchResult]) -> str:
99
+ """Build structural metadata from search results.
100
+
101
+ Extracts:
102
+ - Unique file paths
103
+ - Language distribution
104
+ - Symbol types present
105
+
106
+ Args:
107
+ results: Search results.
108
+
109
+ Returns:
110
+ Formatted structural context string.
111
+ """
112
+ if not results:
113
+ return ""
114
+
115
+ # Collect unique metadata
116
+ files = sorted(set(r.path for r in results))
117
+ languages = sorted(set(r.path.split(".")[-1] for r in results if "." in r.path))
118
+ symbol_types = sorted(set(r.chunk_type for r in results if r.chunk_type))
119
+
120
+ # Format structural context
121
+ parts = ["# Search Result Context\n"]
122
+
123
+ parts.append(f"\n## Files ({len(files)} total)")
124
+ for file_path in files:
125
+ parts.append(f"- {file_path}")
126
+
127
+ if languages:
128
+ parts.append("\n## Languages")
129
+ parts.append(f"{', '.join(languages)}")
130
+
131
+ if symbol_types:
132
+ parts.append("\n## Symbol Types")
133
+ parts.append(f"{', '.join(symbol_types)}")
134
+
135
+ parts.append("\n---\n")
136
+
137
+ return "\n".join(parts)
138
+
139
+
140
+ # ============================================================================
141
+ # CONTENT FORMATTING
142
+ # ============================================================================
143
+
144
+
145
+ def _format_result(result: SearchResult, index: int) -> str:
146
+ """Format a single search result as a markdown section.
147
+
148
+ Args:
149
+ result: Search result to format.
150
+ index: Result index (1-based).
151
+
152
+ Returns:
153
+ Formatted result string.
154
+ """
155
+ parts = [f"\n## Result {index} (score: {result.score:.3f})"]
156
+
157
+ # Metadata header
158
+ meta_parts = [
159
+ f"**File:** `{result.path}:{result.start_line}-{result.end_line}`",
160
+ ]
161
+
162
+ if result.symbol_name:
163
+ meta_parts.append(f"**Symbol:** `{result.symbol_name}` ({result.chunk_type})")
164
+
165
+ if result.bm25_rank is not None and result.dense_rank is not None:
166
+ meta_parts.append(
167
+ f"**Ranking:** BM25 rank {result.bm25_rank}, "
168
+ f"Vector rank {result.dense_rank}"
169
+ )
170
+
171
+ parts.append("\n".join(meta_parts))
172
+
173
+ # Code snippet
174
+ parts.append("\n```")
175
+ parts.append(result.snippet)
176
+ parts.append("```\n")
177
+
178
+ return "\n".join(parts)
179
+
180
+
181
+ def _format_result_truncated(result: SearchResult, index: int, max_tokens: int) -> str:
182
+ """Format a result with snippet truncated to fit token budget.
183
+
184
+ Args:
185
+ result: Search result to format.
186
+ index: Result index (1-based).
187
+ max_tokens: Maximum tokens allowed for this result.
188
+
189
+ Returns:
190
+ Formatted result string, potentially with truncated snippet.
191
+ """
192
+ # Start with full format
193
+ full_format = _format_result(result, index)
194
+ full_tokens = count_tokens(full_format)
195
+
196
+ if full_tokens <= max_tokens:
197
+ return full_format
198
+
199
+ # Need to truncate snippet
200
+ # Calculate overhead from metadata (everything except snippet)
201
+ metadata_format = full_format.replace(result.snippet, "")
202
+ metadata_tokens = count_tokens(metadata_format)
203
+
204
+ # Tokens available for snippet
205
+ available_for_snippet = max_tokens - metadata_tokens
206
+
207
+ if available_for_snippet < 50:
208
+ # Not enough room even for truncated snippet - return just metadata
209
+ return metadata_format.replace("```\n```", "```\n[truncated]\n```")
210
+
211
+ # Binary search to find max snippet length that fits
212
+ snippet = result.snippet
213
+ left, right = 0, len(snippet)
214
+ best_length = 0
215
+
216
+ while left <= right:
217
+ mid = (left + right) // 2
218
+ truncated_snippet = snippet[:mid] + "..."
219
+ test_format = metadata_format.replace("```\n```", f"```\n{truncated_snippet}\n```")
220
+ test_tokens = count_tokens(test_format)
221
+
222
+ if test_tokens <= max_tokens:
223
+ best_length = mid
224
+ left = mid + 1
225
+ else:
226
+ right = mid - 1
227
+
228
+ if best_length == 0:
229
+ # Even minimal truncation doesn't fit
230
+ return metadata_format.replace("```\n```", "```\n[truncated]\n```")
231
+
232
+ truncated_snippet = snippet[:best_length] + "..."
233
+ return metadata_format.replace("```\n```", f"```\n{truncated_snippet}\n```")
234
+
235
+
236
+ # ============================================================================
237
+ # PUBLIC API
238
+ # ============================================================================
239
+
240
+
241
+ def assemble_context(
242
+ results: list[SearchResult],
243
+ token_budget: int = DEFAULT_TOKEN_BUDGET,
244
+ ) -> AssembledContext:
245
+ """Assemble search results into context string with token budget management.
246
+
247
+ Process:
248
+ 1. Build structural context (15% of budget)
249
+ 2. Format results in rank order (85% of budget)
250
+ 3. Truncate snippets if needed to fit budget
251
+ 4. Track what was included vs truncated
252
+
253
+ Args:
254
+ results: Search results ordered by relevance.
255
+ token_budget: Maximum tokens for assembled context.
256
+
257
+ Returns:
258
+ AssembledContext with formatted content and metadata.
259
+
260
+ Raises:
261
+ RetrievalError: If budget is too small to include any results.
262
+
263
+ Example:
264
+ >>> results = hybrid_search(conn, query, embedding, top_k=10)
265
+ >>> context = assemble_context(results, token_budget=50_000)
266
+ >>> print(context.content) # Ready for LLM
267
+ >>> print(f"Used {context.total_tokens} tokens")
268
+ """
269
+ if not results:
270
+ return AssembledContext(
271
+ content="No results found.",
272
+ total_tokens=count_tokens("No results found."),
273
+ structural_tokens=0,
274
+ content_tokens=0,
275
+ results_included=0,
276
+ results_truncated=0,
277
+ )
278
+
279
+ # Allocate budgets
280
+ structural_budget = int(token_budget * STRUCTURAL_BUDGET_RATIO)
281
+ content_budget = int(token_budget * CONTENT_BUDGET_RATIO)
282
+
283
+ logger.debug(
284
+ "Assembling context",
285
+ total_budget=token_budget,
286
+ structural_budget=structural_budget,
287
+ content_budget=content_budget,
288
+ result_count=len(results),
289
+ )
290
+
291
+ # Build structural context
292
+ structural_context = _build_structural_context(results)
293
+ structural_tokens = count_tokens(structural_context)
294
+
295
+ if structural_tokens > structural_budget:
296
+ # Structural context is too large - truncate file list
297
+ logger.warning(
298
+ "Structural context exceeds budget",
299
+ structural_tokens=structural_tokens,
300
+ structural_budget=structural_budget,
301
+ )
302
+ # Fallback: just include counts
303
+ structural_context = (
304
+ f"# Search Result Context\n\n"
305
+ f"{len(results)} results found across "
306
+ f"{len(set(r.path for r in results))} files\n\n---\n"
307
+ )
308
+ structural_tokens = count_tokens(structural_context)
309
+
310
+ # Format results with remaining budget
311
+ remaining_budget = content_budget
312
+ formatted_results = []
313
+ results_included = 0
314
+ results_truncated = 0
315
+
316
+ for index, result in enumerate(results, start=1):
317
+ # Try to format this result
318
+ formatted = _format_result(result, index)
319
+ formatted_tokens = count_tokens(formatted)
320
+
321
+ if formatted_tokens <= remaining_budget:
322
+ # Fits fully
323
+ formatted_results.append(formatted)
324
+ remaining_budget -= formatted_tokens
325
+ results_included += 1
326
+ elif remaining_budget > 100:
327
+ # Try truncated version
328
+ truncated = _format_result_truncated(result, index, remaining_budget)
329
+ truncated_tokens = count_tokens(truncated)
330
+
331
+ if truncated_tokens <= remaining_budget:
332
+ formatted_results.append(truncated)
333
+ remaining_budget -= truncated_tokens
334
+ results_included += 1
335
+ results_truncated += 1
336
+ else:
337
+ # Even truncated doesn't fit - stop here
338
+ break
339
+ else:
340
+ # Budget exhausted
341
+ break
342
+
343
+ # Assemble final content
344
+ content_parts = [structural_context]
345
+ content_parts.extend(formatted_results)
346
+
347
+ if results_included < len(results):
348
+ omitted = len(results) - results_included
349
+ content_parts.append(
350
+ f"\n_({omitted} additional results omitted due to token budget)_\n"
351
+ )
352
+
353
+ final_content = "\n".join(content_parts)
354
+ total_tokens = count_tokens(final_content)
355
+
356
+ logger.info(
357
+ "Context assembled",
358
+ total_tokens=total_tokens,
359
+ structural_tokens=structural_tokens,
360
+ content_tokens=total_tokens - structural_tokens,
361
+ results_included=results_included,
362
+ results_truncated=results_truncated,
363
+ )
364
+
365
+ return AssembledContext(
366
+ content=final_content,
367
+ total_tokens=total_tokens,
368
+ structural_tokens=structural_tokens,
369
+ content_tokens=total_tokens - structural_tokens,
370
+ results_included=results_included,
371
+ results_truncated=results_truncated,
372
+ )
@@ -0,0 +1,193 @@
1
+ """Result ranking and diversity filtering.
2
+
3
+ Provides post-processing for search results:
4
+ - deduplicate_results: Content deduplication
5
+ - apply_mmr: Maximal Marginal Relevance diversity
6
+ - rerank_with_cross_encoder: Cross-encoder reranking (Phase 2)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections import defaultdict
12
+ from typing import TYPE_CHECKING
13
+
14
+ from contextual.observability.logging import get_logger
15
+
16
+
17
+ if TYPE_CHECKING:
18
+ from contextual.retrieval.search import SearchResult
19
+
20
+ logger = get_logger(__name__)
21
+
22
+ # ============================================================================
23
+ # CONSTANTS
24
+ # ============================================================================
25
+
26
+ MMR_LAMBDA = 0.7
27
+ """Default MMR relevance/diversity tradeoff parameter."""
28
+
29
+ MMR_MAX_PER_FILE = 2
30
+ """Default maximum number of results per file path."""
31
+
32
+ MMR_MAX_PER_SYMBOL = 1
33
+ """Default maximum number of results per symbol name."""
34
+
35
+ RERANK_MAX_PAIRS = 30
36
+ """Maximum query-document pairs for cross-encoder reranking."""
37
+
38
+
39
+ def deduplicate_results(results: list[SearchResult]) -> list[SearchResult]:
40
+ """Remove duplicate snippets while preserving overall ordering.
41
+
42
+ Duplicates are detected by exact snippet string match. For each unique snippet,
43
+ the highest-scoring result is retained. The final output follows the input order
44
+ (stable order) of chosen representatives to avoid introducing rank jitter.
45
+
46
+ Args:
47
+ results: Ranked retrieval results.
48
+
49
+ Returns:
50
+ Deduplicated result list.
51
+ """
52
+ if not results:
53
+ return []
54
+
55
+ best_score_by_snippet: dict[str, float] = {}
56
+ best_chunk_id_by_snippet: dict[str, int] = {}
57
+
58
+ # Pass 1: determine representative result for each snippet.
59
+ for result in results:
60
+ snippet_key = result.snippet.strip()
61
+ if snippet_key not in best_score_by_snippet:
62
+ best_score_by_snippet[snippet_key] = result.score
63
+ best_chunk_id_by_snippet[snippet_key] = result.chunk_id
64
+ continue
65
+
66
+ if result.score > best_score_by_snippet[snippet_key]:
67
+ best_score_by_snippet[snippet_key] = result.score
68
+ best_chunk_id_by_snippet[snippet_key] = result.chunk_id
69
+
70
+ # Pass 2: keep only chosen representatives in original order.
71
+ deduplicated: list[SearchResult] = []
72
+ emitted_snippets: set[str] = set()
73
+ for result in results:
74
+ snippet_key = result.snippet.strip()
75
+ if snippet_key in emitted_snippets:
76
+ continue
77
+
78
+ chosen_chunk_id = best_chunk_id_by_snippet.get(snippet_key)
79
+ if chosen_chunk_id == result.chunk_id:
80
+ deduplicated.append(result)
81
+ emitted_snippets.add(snippet_key)
82
+
83
+ logger.debug(
84
+ "Deduplicated retrieval results",
85
+ input_count=len(results),
86
+ output_count=len(deduplicated),
87
+ duplicates_removed=len(results) - len(deduplicated),
88
+ )
89
+
90
+ return deduplicated
91
+
92
+
93
+ def apply_mmr(
94
+ results: list[SearchResult],
95
+ lambda_param: float = MMR_LAMBDA,
96
+ max_per_file: int = MMR_MAX_PER_FILE,
97
+ max_per_symbol: int = MMR_MAX_PER_SYMBOL,
98
+ ) -> list[SearchResult]:
99
+ """Apply diversity filtering constraints over ranked results.
100
+
101
+ Phase 1 uses deterministic quota-based filtering:
102
+ - At most `max_per_file` results per file path.
103
+ - At most `max_per_symbol` results per symbol name.
104
+
105
+ Args:
106
+ results: Ranked retrieval results.
107
+ lambda_param: Reserved MMR tradeoff parameter for future semantic MMR.
108
+ max_per_file: Max results allowed for the same file path.
109
+ max_per_symbol: Max results allowed for the same symbol name.
110
+
111
+ Returns:
112
+ Diversity-filtered results, preserving input ordering.
113
+ """
114
+ if not results:
115
+ return []
116
+ if max_per_file <= 0:
117
+ logger.warning("max_per_file must be positive; returning empty result set", value=max_per_file)
118
+ return []
119
+ if max_per_symbol <= 0:
120
+ logger.warning(
121
+ "max_per_symbol must be positive; returning empty result set",
122
+ value=max_per_symbol,
123
+ )
124
+ return []
125
+
126
+ # Phase 1 doesn't yet compute pairwise similarity; keep parameter visible.
127
+ if not 0.0 <= lambda_param <= 1.0:
128
+ logger.warning(
129
+ "lambda_param out of range for MMR; clamping",
130
+ original=lambda_param,
131
+ )
132
+ lambda_param = max(0.0, min(1.0, lambda_param))
133
+
134
+ _ = lambda_param # Reserved for true MMR scoring in Phase 2.
135
+
136
+ file_counts: defaultdict[str, int] = defaultdict(int)
137
+ symbol_counts: defaultdict[str, int] = defaultdict(int)
138
+ filtered: list[SearchResult] = []
139
+
140
+ skipped_file_quota = 0
141
+ skipped_symbol_quota = 0
142
+
143
+ for result in results:
144
+ file_key = result.path
145
+ symbol_key = result.symbol_name.strip() if result.symbol_name else ""
146
+
147
+ if file_counts[file_key] >= max_per_file:
148
+ skipped_file_quota += 1
149
+ continue
150
+
151
+ if symbol_key and symbol_counts[symbol_key] >= max_per_symbol:
152
+ skipped_symbol_quota += 1
153
+ continue
154
+
155
+ filtered.append(result)
156
+ file_counts[file_key] += 1
157
+ if symbol_key:
158
+ symbol_counts[symbol_key] += 1
159
+
160
+ logger.debug(
161
+ "Applied diversity filtering",
162
+ input_count=len(results),
163
+ output_count=len(filtered),
164
+ skipped_file_quota=skipped_file_quota,
165
+ skipped_symbol_quota=skipped_symbol_quota,
166
+ max_per_file=max_per_file,
167
+ max_per_symbol=max_per_symbol,
168
+ )
169
+
170
+ return filtered
171
+
172
+
173
+ def rerank_with_cross_encoder(
174
+ query: str,
175
+ results: list[SearchResult],
176
+ top_k: int = RERANK_MAX_PAIRS,
177
+ ) -> list[SearchResult]:
178
+ """Rerank top candidates with a cross-encoder model.
179
+
180
+ Args:
181
+ query: User search query text.
182
+ results: Candidate retrieval results.
183
+ top_k: Maximum candidates to rerank with the cross-encoder.
184
+
185
+ Returns:
186
+ Reranked results (Phase 2).
187
+
188
+ Raises:
189
+ NotImplementedError: Always raised in Phase 1.
190
+ """
191
+ _ = (query, results, top_k)
192
+ # TODO(Phase 2): Integrate jina-reranker-v2-base-multilingual over query/chunk pairs.
193
+ raise NotImplementedError("Cross-encoder reranking coming in Phase 2")