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,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
+ ]