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
contextual/mcp/tools.py
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""MCP tool implementations for search, indexing, and stats.
|
|
2
|
+
|
|
3
|
+
This module provides the server-side implementations for the MCP tools
|
|
4
|
+
that expose Contextual's core functionality to clients.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
import warnings
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, cast
|
|
16
|
+
|
|
17
|
+
from contextual.core.errors import ErrorCode, MCPError
|
|
18
|
+
from contextual.docs.retrieval import docs_search
|
|
19
|
+
from contextual.embedding import embed_query_simple
|
|
20
|
+
from contextual.embedding.embedder import CodeEmbedder, get_docs_embedder, get_embedder
|
|
21
|
+
from contextual.indexing.processor import index_project
|
|
22
|
+
from contextual.mcp.server import mcp
|
|
23
|
+
from contextual.observability.logging import get_logger
|
|
24
|
+
from contextual.retrieval.context_assembler import assemble_context
|
|
25
|
+
from contextual.retrieval.search import hybrid_search
|
|
26
|
+
from contextual.storage.sqlite_pool import SQLitePool
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# === CRITICAL: Suppress ALL stdout pollution BEFORE any imports ===
|
|
30
|
+
os.environ["TQDM_DISABLE"] = "1" # Disable tqdm progress bars
|
|
31
|
+
os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "1" # Disable HuggingFace progress
|
|
32
|
+
os.environ["HF_HUB_DISABLE_TELEMETRY"] = "1" # Disable telemetry
|
|
33
|
+
os.environ["TOKENIZERS_PARALLELISM"] = "false" # Silence tokenizers warning
|
|
34
|
+
|
|
35
|
+
warnings.filterwarnings("ignore")
|
|
36
|
+
# ===================================================================
|
|
37
|
+
|
|
38
|
+
logger = get_logger(__name__)
|
|
39
|
+
|
|
40
|
+
# Module-level connection pool singleton
|
|
41
|
+
_pool: SQLitePool | None = None
|
|
42
|
+
_pool_lock = threading.Lock()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _get_pool() -> SQLitePool:
|
|
46
|
+
"""Get or create global SQLite pool with thread-safe double-checked locking.
|
|
47
|
+
|
|
48
|
+
Looks for database in this order:
|
|
49
|
+
1. .contextual/contextual.db in current working directory (project-local)
|
|
50
|
+
2. ~/.contextual/contextual.db (user-global fallback)
|
|
51
|
+
"""
|
|
52
|
+
global _pool
|
|
53
|
+
if _pool is None:
|
|
54
|
+
with _pool_lock:
|
|
55
|
+
if _pool is None:
|
|
56
|
+
# Try project-local database first
|
|
57
|
+
project_db = Path.cwd() / ".contextual" / "contextual.db"
|
|
58
|
+
if project_db.exists():
|
|
59
|
+
db_path = project_db
|
|
60
|
+
logger.info("Using project-local database", db_path=str(db_path))
|
|
61
|
+
else:
|
|
62
|
+
# Fallback to home directory
|
|
63
|
+
db_path = Path.home() / ".contextual" / "contextual.db"
|
|
64
|
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
logger.info("Using user-global database", db_path=str(db_path))
|
|
66
|
+
|
|
67
|
+
_pool = SQLitePool(db_path)
|
|
68
|
+
return _pool
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@mcp.tool()
|
|
72
|
+
def search_codebase(
|
|
73
|
+
query: str,
|
|
74
|
+
top_k: int = 10,
|
|
75
|
+
language: str | None = None,
|
|
76
|
+
token_budget: int = 100_000,
|
|
77
|
+
) -> str:
|
|
78
|
+
"""Semantically search the indexed codebase.
|
|
79
|
+
|
|
80
|
+
Performs hybrid BM25 + vector search and returns formatted context
|
|
81
|
+
ready for LLM consumption.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
query: Natural-language question or concept to search for.
|
|
85
|
+
top_k: Number of results to return (1-50).
|
|
86
|
+
language: Optional language filter (python, typescript, javascript, rust, go).
|
|
87
|
+
token_budget: Maximum tokens for assembled context (default 100k).
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Formatted context string with search results and metadata.
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
MCPError: If search fails or database unavailable.
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
# Embed query (no caching for queries)
|
|
97
|
+
query_embedding = embed_query_simple(query)
|
|
98
|
+
|
|
99
|
+
# Execute hybrid search with code-specific RRF weights
|
|
100
|
+
# Code benefits more from exact symbol matching (BM25) than semantic similarity
|
|
101
|
+
with _get_pool().reader() as conn:
|
|
102
|
+
results = hybrid_search(
|
|
103
|
+
conn,
|
|
104
|
+
query,
|
|
105
|
+
query_embedding,
|
|
106
|
+
top_k,
|
|
107
|
+
language,
|
|
108
|
+
bm25_weight=0.55, # Exact term matching for code symbols
|
|
109
|
+
dense_weight=0.45, # Less weight on semantic similarity for code
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Assemble context with token budget
|
|
113
|
+
context = assemble_context(results, token_budget)
|
|
114
|
+
|
|
115
|
+
logger.info(
|
|
116
|
+
"search_codebase_success",
|
|
117
|
+
query=query,
|
|
118
|
+
top_k=top_k,
|
|
119
|
+
language=language,
|
|
120
|
+
results_count=len(results),
|
|
121
|
+
total_tokens=context.total_tokens,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return context.content
|
|
125
|
+
|
|
126
|
+
except Exception as e:
|
|
127
|
+
logger.error("Tool failed", tool="search_codebase", error=str(e))
|
|
128
|
+
raise MCPError(
|
|
129
|
+
code=ErrorCode.MCP_TOOL_CALL_FAILED,
|
|
130
|
+
message=f"Tool 'search_codebase' failed: {e}",
|
|
131
|
+
context={"tool": "search_codebase"},
|
|
132
|
+
) from e
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@mcp.tool()
|
|
136
|
+
def index_codebase(
|
|
137
|
+
path: str,
|
|
138
|
+
) -> dict[str, Any]:
|
|
139
|
+
"""Index or re-index a code repository.
|
|
140
|
+
|
|
141
|
+
Discovers source files, parses with tree-sitter, chunks code,
|
|
142
|
+
generates embeddings, and stores in SQLite + vec0.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
path: Absolute path to repository root directory.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Dictionary with indexing statistics:
|
|
149
|
+
- files_indexed: Number of files processed
|
|
150
|
+
- chunks_created: Number of code chunks generated
|
|
151
|
+
- duration_ms: Indexing duration in milliseconds
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
MCPError: If path invalid or indexing fails.
|
|
155
|
+
"""
|
|
156
|
+
start = time.perf_counter()
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
# Validate path
|
|
160
|
+
p = Path(path)
|
|
161
|
+
if not p.exists() or not p.is_dir():
|
|
162
|
+
raise ValueError(f"Invalid path: {path} is not an existing directory.")
|
|
163
|
+
|
|
164
|
+
# Resolve and security check
|
|
165
|
+
resolved_path = p.resolve()
|
|
166
|
+
if not resolved_path.is_absolute():
|
|
167
|
+
raise ValueError("Path must be absolute for security reasons.")
|
|
168
|
+
|
|
169
|
+
# Run indexing
|
|
170
|
+
chunks = index_project(resolved_path)
|
|
171
|
+
duration_ms = int((time.perf_counter() - start) * 1000)
|
|
172
|
+
|
|
173
|
+
result = {
|
|
174
|
+
"files_indexed": len({chunk.path for chunk in chunks}),
|
|
175
|
+
"chunks_created": len(chunks),
|
|
176
|
+
"duration_ms": duration_ms,
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
logger.info("index_codebase_success", path=path, stats=result)
|
|
180
|
+
|
|
181
|
+
return result
|
|
182
|
+
|
|
183
|
+
except Exception as e:
|
|
184
|
+
logger.error("Tool failed", tool="index_codebase", error=str(e))
|
|
185
|
+
raise MCPError(
|
|
186
|
+
code=ErrorCode.MCP_TOOL_CALL_FAILED,
|
|
187
|
+
message=f"Tool 'index_codebase' failed: {e}",
|
|
188
|
+
context={"tool": "index_codebase"},
|
|
189
|
+
) from e
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@mcp.tool()
|
|
193
|
+
def get_stats() -> dict[str, Any]:
|
|
194
|
+
"""Get current index statistics.
|
|
195
|
+
|
|
196
|
+
Returns summary of indexed repository state including file counts,
|
|
197
|
+
chunk counts, languages, and last indexing timestamp.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
Dictionary with statistics:
|
|
201
|
+
- file_count: Total indexed files
|
|
202
|
+
- chunk_count: Total code chunks
|
|
203
|
+
- vector_count: Total vectors in vec_chunks
|
|
204
|
+
- last_indexed_at: Unix timestamp (ms) of last index, or None
|
|
205
|
+
- languages: List of detected languages
|
|
206
|
+
"""
|
|
207
|
+
try:
|
|
208
|
+
with _get_pool().reader() as conn:
|
|
209
|
+
# Get counts
|
|
210
|
+
file_count = conn.execute("SELECT COUNT(*) FROM files").fetchone()[0]
|
|
211
|
+
chunk_count = conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
|
|
212
|
+
|
|
213
|
+
# Vec0 vector count
|
|
214
|
+
vector_count_row = conn.execute("SELECT COUNT(*) FROM vec_chunks").fetchone()
|
|
215
|
+
vector_count = vector_count_row[0] if vector_count_row else 0
|
|
216
|
+
|
|
217
|
+
# Last indexed timestamp
|
|
218
|
+
last_indexed_row = conn.execute("SELECT MAX(indexed_at) FROM files").fetchone()
|
|
219
|
+
last_indexed_at = last_indexed_row[0] if last_indexed_row else None
|
|
220
|
+
|
|
221
|
+
# Languages
|
|
222
|
+
lang_rows = conn.execute(
|
|
223
|
+
"SELECT DISTINCT language FROM files ORDER BY language"
|
|
224
|
+
).fetchall()
|
|
225
|
+
languages = [row[0] for row in lang_rows if row[0]]
|
|
226
|
+
|
|
227
|
+
stats = {
|
|
228
|
+
"file_count": file_count or 0,
|
|
229
|
+
"chunk_count": chunk_count or 0,
|
|
230
|
+
"vector_count": vector_count or 0,
|
|
231
|
+
"last_indexed_at": last_indexed_at,
|
|
232
|
+
"languages": languages,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
logger.info("get_stats_success", stats=stats)
|
|
236
|
+
|
|
237
|
+
return stats
|
|
238
|
+
|
|
239
|
+
except Exception as e:
|
|
240
|
+
logger.error("Tool failed", tool="get_stats", error=str(e))
|
|
241
|
+
|
|
242
|
+
# Handle fresh database (no tables yet)
|
|
243
|
+
if "no such table" in str(e):
|
|
244
|
+
empty_stats: dict[str, Any] = {
|
|
245
|
+
"file_count": 0,
|
|
246
|
+
"chunk_count": 0,
|
|
247
|
+
"vector_count": 0,
|
|
248
|
+
"last_indexed_at": None,
|
|
249
|
+
"languages": [],
|
|
250
|
+
}
|
|
251
|
+
logger.warning("get_stats_empty_db", reason=str(e))
|
|
252
|
+
return empty_stats
|
|
253
|
+
|
|
254
|
+
raise MCPError(
|
|
255
|
+
code=ErrorCode.MCP_TOOL_CALL_FAILED,
|
|
256
|
+
message=f"Tool 'get_stats' failed: {e}",
|
|
257
|
+
context={"tool": "get_stats"},
|
|
258
|
+
) from e
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
# Query heuristics — detect code vs prose intent
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
_CODE_SIGNAL_RE = re.compile(
|
|
266
|
+
r"[a-z_]{2,}\.[a-z_]{2,}\(" # method call: pool.reader(
|
|
267
|
+
r"|[A-Z][a-zA-Z0-9]{2,}\(" # class call: SQLitePool(
|
|
268
|
+
r"|`[^`]+`" # backtick symbol: `hybrid_search`
|
|
269
|
+
r"|\b[a-z_]{2,}_[a-z_]{2,}\b" # snake_case: get_pool, chunk_id
|
|
270
|
+
r"|\bdef\b|\bclass\b|\bimport\b" # Python keywords
|
|
271
|
+
r"|\b[A-Z][a-zA-Z0-9]{3,}(?:Pool|Manager|Writer|Handler|Client|Server|Cache|Store|Engine|Pipeline|Chunker|Embedder|Watcher|Builder|Parser|Runner|Iterator|Provider|Retrieval|Indexer)\b"
|
|
272
|
+
r"|\bthreading\b|\bsqlite3?\b|\bfastembed\b|\bwatchdog\b"
|
|
273
|
+
r"|\b(sqlite|vec0|fts5|onnx|pytest|async|await|bool|None|str|int)\b"
|
|
274
|
+
r"|\.(py|ts|js|rs|go|toml|yaml|json)\b"
|
|
275
|
+
r"|\b[A-Z][a-zA-Z0-9]{3,}(?:"
|
|
276
|
+
r"Pool|Manager|Writer|Handler|Client|Server|Cache|Store|Engine|Pipeline|"
|
|
277
|
+
r"Chunker|Embedder|Watcher|Builder|Parser|Runner|Iterator|Provider|Retrieval|"
|
|
278
|
+
r"Indexer"
|
|
279
|
+
r")\b" # PascalCase class names with technical suffixes
|
|
280
|
+
r"|\bthreading\b|\bsqlite3?\b|\bfastembed\b|\bwatchdog\b", # code-domain signals
|
|
281
|
+
re.IGNORECASE,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _query_source_weights(query: str) -> tuple[float, float]:
|
|
286
|
+
"""Return (code_weight, docs_weight) from heuristic signal detection.
|
|
287
|
+
|
|
288
|
+
Strong code signals → (0.70, 0.30)
|
|
289
|
+
Strong prose signals → (0.30, 0.70)
|
|
290
|
+
Ambiguous → (0.50, 0.50)
|
|
291
|
+
"""
|
|
292
|
+
words = query.split()
|
|
293
|
+
hits = len(_CODE_SIGNAL_RE.findall(query))
|
|
294
|
+
ratio = hits / max(len(words), 1)
|
|
295
|
+
|
|
296
|
+
if ratio >= 0.25:
|
|
297
|
+
return 0.70, 0.30
|
|
298
|
+
if ratio <= 0.05:
|
|
299
|
+
return 0.30, 0.70
|
|
300
|
+
return 0.50, 0.50
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@mcp.tool()
|
|
304
|
+
def contextual_search(
|
|
305
|
+
query: str,
|
|
306
|
+
k: int = 10,
|
|
307
|
+
source: str = "auto",
|
|
308
|
+
) -> str:
|
|
309
|
+
"""Unified semantic search across codebase AND documentation.
|
|
310
|
+
|
|
311
|
+
This is the recommended default search tool. It automatically decides
|
|
312
|
+
whether your query needs code, docs, or both — and merges the results
|
|
313
|
+
into a single ranked list.
|
|
314
|
+
|
|
315
|
+
When to use this vs the specific tools:
|
|
316
|
+
contextual_search — use by default; auto-routes intelligently
|
|
317
|
+
search_codebase — use when you know you want code only
|
|
318
|
+
docs_search_tool — use when you know you want docs only
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
query: Natural language or keyword query.
|
|
322
|
+
Examples:
|
|
323
|
+
"how is the db connection pool implemented" → auto: both
|
|
324
|
+
"SQLitePool.writer signature" → auto: code
|
|
325
|
+
"installation prerequisites macOS" → auto: docs
|
|
326
|
+
|
|
327
|
+
k: Total number of results to return (1-50, default 10).
|
|
328
|
+
|
|
329
|
+
source: Routing override.
|
|
330
|
+
"auto" — detect from query content (default)
|
|
331
|
+
"code" — search codebase index only
|
|
332
|
+
"docs" — search docs index only (requires CONTEXTUAL_DOCS_ENABLED=1)
|
|
333
|
+
"both" — always search both, merge results
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
JSON array sorted by relevance score (highest first). Each result:
|
|
337
|
+
{
|
|
338
|
+
"source": "code" | "docs",
|
|
339
|
+
"file_path": "contextual/storage/sqlite_pool.py",
|
|
340
|
+
"content": "..chunk text..",
|
|
341
|
+
"score": 0.0412,
|
|
342
|
+
"symbol_name": "writer" (code only, empty string for docs),
|
|
343
|
+
"language": "python" (code only, empty string for docs),
|
|
344
|
+
"heading_path": "Installation > macOS" (docs only, empty for code),
|
|
345
|
+
"start_line": 172,
|
|
346
|
+
"end_line": 213
|
|
347
|
+
}
|
|
348
|
+
"""
|
|
349
|
+
k = max(1, min(k, 50))
|
|
350
|
+
half_k = max(k, 10) # over-fetch per corpus so merge has enough candidates
|
|
351
|
+
|
|
352
|
+
docs_enabled = os.getenv("CONTEXTUAL_DOCS_ENABLED", "0") == "1"
|
|
353
|
+
|
|
354
|
+
# ── Routing decision ────────────────────────────────────────────────────
|
|
355
|
+
if source == "auto":
|
|
356
|
+
run_code = True
|
|
357
|
+
run_docs = docs_enabled
|
|
358
|
+
code_w, docs_w = _query_source_weights(query)
|
|
359
|
+
elif source == "code":
|
|
360
|
+
run_code, run_docs = True, False
|
|
361
|
+
code_w, docs_w = 1.0, 0.0
|
|
362
|
+
elif source == "docs":
|
|
363
|
+
run_code, run_docs = False, docs_enabled
|
|
364
|
+
code_w, docs_w = 0.0, 1.0
|
|
365
|
+
else: # "both"
|
|
366
|
+
run_code = True
|
|
367
|
+
run_docs = docs_enabled
|
|
368
|
+
code_w, docs_w = 0.50, 0.50
|
|
369
|
+
|
|
370
|
+
candidates: list[dict[str, Any]] = []
|
|
371
|
+
|
|
372
|
+
# ── Code path ───────────────────────────────────────────────────────────
|
|
373
|
+
if run_code:
|
|
374
|
+
try:
|
|
375
|
+
embedder = get_embedder()
|
|
376
|
+
query_embedding = embedder.embed([query])[0]
|
|
377
|
+
|
|
378
|
+
with _get_pool().reader() as conn:
|
|
379
|
+
hits = hybrid_search(
|
|
380
|
+
conn=conn,
|
|
381
|
+
query=query,
|
|
382
|
+
query_embedding=query_embedding,
|
|
383
|
+
top_k=half_k,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
for hit in hits:
|
|
387
|
+
candidates.append({
|
|
388
|
+
"source": "code",
|
|
389
|
+
"file_path": hit.path,
|
|
390
|
+
"content": hit.snippet,
|
|
391
|
+
"score": round(hit.score * code_w, 6),
|
|
392
|
+
"symbol_name": hit.symbol_name or "",
|
|
393
|
+
"language": "",
|
|
394
|
+
"heading_path": "",
|
|
395
|
+
"start_line": hit.start_line,
|
|
396
|
+
"end_line": hit.end_line,
|
|
397
|
+
})
|
|
398
|
+
except Exception as _exc:
|
|
399
|
+
logger.warning("contextual_search: code path failed", error=str(_exc))
|
|
400
|
+
|
|
401
|
+
# ── Docs path ───────────────────────────────────────────────────────────
|
|
402
|
+
if run_docs:
|
|
403
|
+
try:
|
|
404
|
+
with _get_pool().reader() as conn:
|
|
405
|
+
doc_hits = docs_search(
|
|
406
|
+
query=query,
|
|
407
|
+
conn=conn,
|
|
408
|
+
embedder=cast(CodeEmbedder, get_docs_embedder()),
|
|
409
|
+
k=half_k,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
for doc_hit in doc_hits:
|
|
413
|
+
candidates.append({
|
|
414
|
+
"source": "docs",
|
|
415
|
+
"file_path": doc_hit.file_path,
|
|
416
|
+
"content": doc_hit.content,
|
|
417
|
+
"score": round(doc_hit.score * docs_w, 6),
|
|
418
|
+
"symbol_name": "",
|
|
419
|
+
"language": "",
|
|
420
|
+
"heading_path": doc_hit.heading_path,
|
|
421
|
+
"start_line": doc_hit.start_line,
|
|
422
|
+
"end_line": doc_hit.end_line,
|
|
423
|
+
})
|
|
424
|
+
except Exception as _exc:
|
|
425
|
+
logger.warning("contextual_search: docs path failed", error=str(_exc))
|
|
426
|
+
|
|
427
|
+
# ── Merge + rank ────────────────────────────────────────────────────────
|
|
428
|
+
candidates.sort(key=lambda x: x["score"], reverse=True)
|
|
429
|
+
results = candidates[:k]
|
|
430
|
+
|
|
431
|
+
logger.info(
|
|
432
|
+
"contextual_search",
|
|
433
|
+
query=query[:80],
|
|
434
|
+
source=source,
|
|
435
|
+
run_code=run_code,
|
|
436
|
+
run_docs=run_docs,
|
|
437
|
+
code_w=code_w,
|
|
438
|
+
docs_w=docs_w,
|
|
439
|
+
candidates=len(candidates),
|
|
440
|
+
returned=len(results),
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
return json.dumps(results, ensure_ascii=False, indent=2)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Observability: structured logging, metrics collection, and graceful shutdown.
|
|
2
|
+
|
|
3
|
+
Provides structlog-based logging pipeline with JSON output, local metrics collection,
|
|
4
|
+
and graceful shutdown handlers for WAL checkpointing and async task draining.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from contextual.observability.logging import (
|
|
9
|
+
add_context,
|
|
10
|
+
clear_context,
|
|
11
|
+
configure_logging,
|
|
12
|
+
get_logger,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"add_context",
|
|
18
|
+
"clear_context",
|
|
19
|
+
"configure_logging",
|
|
20
|
+
"get_logger",
|
|
21
|
+
]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Structured logging pipeline using structlog."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from logging.handlers import RotatingFileHandler
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import structlog
|
|
11
|
+
from structlog.types import Processor
|
|
12
|
+
|
|
13
|
+
from contextual.config import get_log_dir
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def configure_logging(log_level: str = "INFO", enable_file_logging: bool = True) -> None:
|
|
17
|
+
"""Configure the structlog pipeline.
|
|
18
|
+
|
|
19
|
+
Sets up JSON file logging and colored console logging.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
log_level: The minimum log level to capture.
|
|
23
|
+
enable_file_logging: Whether to write logs to a file.
|
|
24
|
+
"""
|
|
25
|
+
processors: list[Processor] = [
|
|
26
|
+
structlog.contextvars.merge_contextvars,
|
|
27
|
+
structlog.processors.add_log_level,
|
|
28
|
+
structlog.processors.TimeStamper(fmt="iso", utc=True),
|
|
29
|
+
structlog.processors.StackInfoRenderer(),
|
|
30
|
+
structlog.processors.format_exc_info,
|
|
31
|
+
structlog.processors.CallsiteParameterAdder(),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
# Console renderer (when sys.stderr.isatty())
|
|
35
|
+
if sys.stderr.isatty():
|
|
36
|
+
console_processor = structlog.dev.ConsoleRenderer()
|
|
37
|
+
else:
|
|
38
|
+
console_processor = structlog.processors.JSONRenderer()
|
|
39
|
+
|
|
40
|
+
# Configure stdlib logging handlers
|
|
41
|
+
handlers: list[logging.Handler] = []
|
|
42
|
+
|
|
43
|
+
# Console handler
|
|
44
|
+
console_handler = logging.StreamHandler(sys.stderr)
|
|
45
|
+
console_handler.setFormatter(structlog.stdlib.ProcessorFormatter(
|
|
46
|
+
processor=console_processor,
|
|
47
|
+
))
|
|
48
|
+
handlers.append(console_handler)
|
|
49
|
+
|
|
50
|
+
if enable_file_logging:
|
|
51
|
+
try:
|
|
52
|
+
log_dir = get_log_dir()
|
|
53
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
log_file = log_dir / "contextual.log"
|
|
55
|
+
|
|
56
|
+
file_handler = RotatingFileHandler(
|
|
57
|
+
log_file,
|
|
58
|
+
maxBytes=10 * 1024 * 1024, # 10 MB
|
|
59
|
+
backupCount=5,
|
|
60
|
+
encoding="utf-8"
|
|
61
|
+
)
|
|
62
|
+
# Ensure log file has proper permissions (0o644)
|
|
63
|
+
if log_file.exists():
|
|
64
|
+
log_file.chmod(0o644)
|
|
65
|
+
|
|
66
|
+
file_handler.setFormatter(structlog.stdlib.ProcessorFormatter(
|
|
67
|
+
processor=structlog.processors.JSONRenderer(),
|
|
68
|
+
))
|
|
69
|
+
handlers.append(file_handler)
|
|
70
|
+
except Exception as e:
|
|
71
|
+
# Fallback to console-only if file logging setup fails
|
|
72
|
+
print(f"Warning: Failed to setup file logging: {e}", file=sys.stderr)
|
|
73
|
+
|
|
74
|
+
# Route stdlib logs through structlog
|
|
75
|
+
logging.basicConfig(
|
|
76
|
+
format="%(message)s",
|
|
77
|
+
level=log_level,
|
|
78
|
+
handlers=handlers,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
structlog.configure(
|
|
82
|
+
processors=processors + [structlog.stdlib.ProcessorFormatter.wrap_for_formatter],
|
|
83
|
+
wrapper_class=structlog.make_filtering_bound_logger(getattr(logging, log_level.upper())),
|
|
84
|
+
context_class=dict,
|
|
85
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
86
|
+
cache_logger_on_first_use=True,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_logger(name: str) -> structlog.BoundLogger:
|
|
91
|
+
"""Get a structured logger.
|
|
92
|
+
|
|
93
|
+
Args:
|
|
94
|
+
name: Logger name (usually __name__).
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Configured structlog logger.
|
|
98
|
+
"""
|
|
99
|
+
return structlog.get_logger(name)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def add_context(**kwargs: Any) -> None:
|
|
103
|
+
"""Add context variables to all logs in current context.
|
|
104
|
+
|
|
105
|
+
Uses structlog.contextvars to bind variables.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
**kwargs: Key-value pairs to add to log context.
|
|
109
|
+
"""
|
|
110
|
+
structlog.contextvars.bind_contextvars(**kwargs)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def clear_context() -> None:
|
|
114
|
+
"""Clear all context variables."""
|
|
115
|
+
structlog.contextvars.clear_contextvars()
|
contextual/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Retrieval module: hybrid search, ranking, and context assembly.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
- hybrid_search: BM25 + vector search with RRF fusion
|
|
5
|
+
- assemble_context: Token-budgeted context formatting
|
|
6
|
+
- deduplicate_results: Content deduplication
|
|
7
|
+
- apply_mmr: Diversity filtering
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from contextual.retrieval.context_assembler import assemble_context
|
|
12
|
+
from contextual.retrieval.search import SearchResult, hybrid_search
|
|
13
|
+
from contextual.retrieval.ranker import apply_mmr, deduplicate_results
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ranker imports will be added after delegation
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"SearchResult",
|
|
20
|
+
"assemble_context",
|
|
21
|
+
"hybrid_search",
|
|
22
|
+
"apply_mmr",
|
|
23
|
+
"deduplicate_results"
|
|
24
|
+
]
|