mcp-vector-search 0.12.6__py3-none-any.whl → 1.1.22__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 (92) hide show
  1. mcp_vector_search/__init__.py +3 -3
  2. mcp_vector_search/analysis/__init__.py +111 -0
  3. mcp_vector_search/analysis/baseline/__init__.py +68 -0
  4. mcp_vector_search/analysis/baseline/comparator.py +462 -0
  5. mcp_vector_search/analysis/baseline/manager.py +621 -0
  6. mcp_vector_search/analysis/collectors/__init__.py +74 -0
  7. mcp_vector_search/analysis/collectors/base.py +164 -0
  8. mcp_vector_search/analysis/collectors/cohesion.py +463 -0
  9. mcp_vector_search/analysis/collectors/complexity.py +743 -0
  10. mcp_vector_search/analysis/collectors/coupling.py +1162 -0
  11. mcp_vector_search/analysis/collectors/halstead.py +514 -0
  12. mcp_vector_search/analysis/collectors/smells.py +325 -0
  13. mcp_vector_search/analysis/debt.py +516 -0
  14. mcp_vector_search/analysis/interpretation.py +685 -0
  15. mcp_vector_search/analysis/metrics.py +414 -0
  16. mcp_vector_search/analysis/reporters/__init__.py +7 -0
  17. mcp_vector_search/analysis/reporters/console.py +646 -0
  18. mcp_vector_search/analysis/reporters/markdown.py +480 -0
  19. mcp_vector_search/analysis/reporters/sarif.py +377 -0
  20. mcp_vector_search/analysis/storage/__init__.py +93 -0
  21. mcp_vector_search/analysis/storage/metrics_store.py +762 -0
  22. mcp_vector_search/analysis/storage/schema.py +245 -0
  23. mcp_vector_search/analysis/storage/trend_tracker.py +560 -0
  24. mcp_vector_search/analysis/trends.py +308 -0
  25. mcp_vector_search/analysis/visualizer/__init__.py +90 -0
  26. mcp_vector_search/analysis/visualizer/d3_data.py +534 -0
  27. mcp_vector_search/analysis/visualizer/exporter.py +484 -0
  28. mcp_vector_search/analysis/visualizer/html_report.py +2895 -0
  29. mcp_vector_search/analysis/visualizer/schemas.py +525 -0
  30. mcp_vector_search/cli/commands/analyze.py +1062 -0
  31. mcp_vector_search/cli/commands/chat.py +1455 -0
  32. mcp_vector_search/cli/commands/index.py +621 -5
  33. mcp_vector_search/cli/commands/index_background.py +467 -0
  34. mcp_vector_search/cli/commands/init.py +13 -0
  35. mcp_vector_search/cli/commands/install.py +597 -335
  36. mcp_vector_search/cli/commands/install_old.py +8 -4
  37. mcp_vector_search/cli/commands/mcp.py +78 -6
  38. mcp_vector_search/cli/commands/reset.py +68 -26
  39. mcp_vector_search/cli/commands/search.py +224 -8
  40. mcp_vector_search/cli/commands/setup.py +1184 -0
  41. mcp_vector_search/cli/commands/status.py +339 -5
  42. mcp_vector_search/cli/commands/uninstall.py +276 -357
  43. mcp_vector_search/cli/commands/visualize/__init__.py +39 -0
  44. mcp_vector_search/cli/commands/visualize/cli.py +292 -0
  45. mcp_vector_search/cli/commands/visualize/exporters/__init__.py +12 -0
  46. mcp_vector_search/cli/commands/visualize/exporters/html_exporter.py +33 -0
  47. mcp_vector_search/cli/commands/visualize/exporters/json_exporter.py +33 -0
  48. mcp_vector_search/cli/commands/visualize/graph_builder.py +647 -0
  49. mcp_vector_search/cli/commands/visualize/layout_engine.py +469 -0
  50. mcp_vector_search/cli/commands/visualize/server.py +600 -0
  51. mcp_vector_search/cli/commands/visualize/state_manager.py +428 -0
  52. mcp_vector_search/cli/commands/visualize/templates/__init__.py +16 -0
  53. mcp_vector_search/cli/commands/visualize/templates/base.py +234 -0
  54. mcp_vector_search/cli/commands/visualize/templates/scripts.py +4542 -0
  55. mcp_vector_search/cli/commands/visualize/templates/styles.py +2522 -0
  56. mcp_vector_search/cli/didyoumean.py +27 -2
  57. mcp_vector_search/cli/main.py +127 -160
  58. mcp_vector_search/cli/output.py +158 -13
  59. mcp_vector_search/config/__init__.py +4 -0
  60. mcp_vector_search/config/default_thresholds.yaml +52 -0
  61. mcp_vector_search/config/settings.py +12 -0
  62. mcp_vector_search/config/thresholds.py +273 -0
  63. mcp_vector_search/core/__init__.py +16 -0
  64. mcp_vector_search/core/auto_indexer.py +3 -3
  65. mcp_vector_search/core/boilerplate.py +186 -0
  66. mcp_vector_search/core/config_utils.py +394 -0
  67. mcp_vector_search/core/database.py +406 -94
  68. mcp_vector_search/core/embeddings.py +24 -0
  69. mcp_vector_search/core/exceptions.py +11 -0
  70. mcp_vector_search/core/git.py +380 -0
  71. mcp_vector_search/core/git_hooks.py +4 -4
  72. mcp_vector_search/core/indexer.py +632 -54
  73. mcp_vector_search/core/llm_client.py +756 -0
  74. mcp_vector_search/core/models.py +91 -1
  75. mcp_vector_search/core/project.py +17 -0
  76. mcp_vector_search/core/relationships.py +473 -0
  77. mcp_vector_search/core/scheduler.py +11 -11
  78. mcp_vector_search/core/search.py +179 -29
  79. mcp_vector_search/mcp/server.py +819 -9
  80. mcp_vector_search/parsers/python.py +285 -5
  81. mcp_vector_search/utils/__init__.py +2 -0
  82. mcp_vector_search/utils/gitignore.py +0 -3
  83. mcp_vector_search/utils/gitignore_updater.py +212 -0
  84. mcp_vector_search/utils/monorepo.py +66 -4
  85. mcp_vector_search/utils/timing.py +10 -6
  86. {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.1.22.dist-info}/METADATA +184 -53
  87. mcp_vector_search-1.1.22.dist-info/RECORD +120 -0
  88. {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.1.22.dist-info}/WHEEL +1 -1
  89. {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.1.22.dist-info}/entry_points.txt +1 -0
  90. mcp_vector_search/cli/commands/visualize.py +0 -1467
  91. mcp_vector_search-0.12.6.dist-info/RECORD +0 -68
  92. {mcp_vector_search-0.12.6.dist-info → mcp_vector_search-1.1.22.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,756 @@
1
+ """LLM client for intelligent code search using OpenAI or OpenRouter API."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from collections.abc import AsyncIterator
7
+ from typing import Any, Literal
8
+
9
+ import httpx
10
+ from loguru import logger
11
+
12
+ from .exceptions import SearchError
13
+
14
+ # Type alias for provider
15
+ LLMProvider = Literal["openai", "openrouter"]
16
+
17
+ # Type alias for intent
18
+ IntentType = Literal["find", "answer", "analyze"]
19
+
20
+
21
+ class LLMClient:
22
+ """Client for LLM-powered intelligent search orchestration.
23
+
24
+ Supports both OpenAI and OpenRouter APIs:
25
+ 1. Generate multiple targeted search queries from natural language
26
+ 2. Analyze search results and select most relevant ones
27
+ 3. Provide contextual explanations for results
28
+
29
+ Provider Selection Priority:
30
+ 1. Explicit provider parameter
31
+ 2. Preferred provider from config
32
+ 3. Auto-detect: OpenAI if available, otherwise OpenRouter
33
+ """
34
+
35
+ # Default models for each provider (comparable performance/cost)
36
+ DEFAULT_MODELS = {
37
+ "openai": "gpt-4o-mini", # Fast, cheap, comparable to claude-3-haiku
38
+ "openrouter": "anthropic/claude-3-haiku",
39
+ }
40
+
41
+ # Advanced "thinking" models for complex queries (--think flag)
42
+ THINKING_MODELS = {
43
+ "openai": "gpt-4o", # More capable, better reasoning
44
+ "openrouter": "anthropic/claude-sonnet-4", # Claude Sonnet 4 for deep analysis
45
+ }
46
+
47
+ # API endpoints
48
+ API_ENDPOINTS = {
49
+ "openai": "https://api.openai.com/v1/chat/completions",
50
+ "openrouter": "https://openrouter.ai/api/v1/chat/completions",
51
+ }
52
+
53
+ TIMEOUT_SECONDS = 30.0
54
+
55
+ def __init__(
56
+ self,
57
+ api_key: str | None = None,
58
+ model: str | None = None,
59
+ timeout: float = TIMEOUT_SECONDS,
60
+ provider: LLMProvider | None = None,
61
+ openai_api_key: str | None = None,
62
+ openrouter_api_key: str | None = None,
63
+ think: bool = False,
64
+ ) -> None:
65
+ """Initialize LLM client.
66
+
67
+ Args:
68
+ api_key: API key (deprecated, use provider-specific keys)
69
+ model: Model to use (defaults based on provider)
70
+ timeout: Request timeout in seconds
71
+ provider: Explicit provider ('openai' or 'openrouter')
72
+ openai_api_key: OpenAI API key (or use OPENAI_API_KEY env var)
73
+ openrouter_api_key: OpenRouter API key (or use OPENROUTER_API_KEY env var)
74
+ think: Use advanced "thinking" model for complex queries
75
+
76
+ Raises:
77
+ ValueError: If no API key is found for any provider
78
+ """
79
+ self.think = think
80
+ # Get API keys from environment or parameters
81
+ self.openai_key = openai_api_key or os.environ.get("OPENAI_API_KEY")
82
+ self.openrouter_key = openrouter_api_key or os.environ.get("OPENROUTER_API_KEY")
83
+
84
+ # Support deprecated api_key parameter (assume OpenRouter for backward compatibility)
85
+ if api_key and not self.openrouter_key:
86
+ self.openrouter_key = api_key
87
+
88
+ # Determine which provider to use
89
+ if provider:
90
+ # Explicit provider specified
91
+ self.provider: LLMProvider = provider
92
+ if provider == "openai" and not self.openai_key:
93
+ raise ValueError(
94
+ "OpenAI provider specified but OPENAI_API_KEY not found. "
95
+ "Please set OPENAI_API_KEY environment variable."
96
+ )
97
+ elif provider == "openrouter" and not self.openrouter_key:
98
+ raise ValueError(
99
+ "OpenRouter provider specified but OPENROUTER_API_KEY not found. "
100
+ "Please set OPENROUTER_API_KEY environment variable."
101
+ )
102
+ else:
103
+ # Auto-detect provider (prefer OpenAI if both are available)
104
+ if self.openai_key:
105
+ self.provider = "openai"
106
+ elif self.openrouter_key:
107
+ self.provider = "openrouter"
108
+ else:
109
+ raise ValueError(
110
+ "No API key found. Please set OPENAI_API_KEY or OPENROUTER_API_KEY "
111
+ "environment variable, or pass openai_api_key or openrouter_api_key parameter."
112
+ )
113
+
114
+ # Set API key and endpoint based on provider
115
+ # Select model: explicit > env var > thinking model > default model
116
+ if self.provider == "openai":
117
+ self.api_key = self.openai_key
118
+ self.api_endpoint = self.API_ENDPOINTS["openai"]
119
+ default_model = (
120
+ self.THINKING_MODELS["openai"]
121
+ if think
122
+ else self.DEFAULT_MODELS["openai"]
123
+ )
124
+ self.model = model or os.environ.get("OPENAI_MODEL", default_model)
125
+ else:
126
+ self.api_key = self.openrouter_key
127
+ self.api_endpoint = self.API_ENDPOINTS["openrouter"]
128
+ default_model = (
129
+ self.THINKING_MODELS["openrouter"]
130
+ if think
131
+ else self.DEFAULT_MODELS["openrouter"]
132
+ )
133
+ self.model = model or os.environ.get("OPENROUTER_MODEL", default_model)
134
+
135
+ self.timeout = timeout
136
+
137
+ logger.debug(
138
+ f"Initialized LLM client with provider: {self.provider}, model: {self.model}"
139
+ )
140
+
141
+ async def generate_search_queries(
142
+ self, natural_language_query: str, limit: int = 3
143
+ ) -> list[str]:
144
+ """Generate targeted search queries from natural language.
145
+
146
+ Args:
147
+ natural_language_query: User's natural language query
148
+ limit: Maximum number of search queries to generate
149
+
150
+ Returns:
151
+ List of targeted search queries
152
+
153
+ Raises:
154
+ SearchError: If API call fails
155
+ """
156
+ system_prompt = """You are a code search expert. Your task is to convert natural language questions about code into targeted search queries.
157
+
158
+ Given a natural language query, generate {limit} specific search queries that will help find the relevant code.
159
+
160
+ Rules:
161
+ 1. Each query should target a different aspect of the question
162
+ 2. Use technical terms and identifiers when possible
163
+ 3. Keep queries concise (3-7 words each)
164
+ 4. Focus on code patterns, function names, class names, or concepts
165
+ 5. Return ONLY the search queries, one per line, no explanations
166
+
167
+ Example:
168
+ Input: "where is the similarity_threshold parameter set?"
169
+ Output:
170
+ similarity_threshold default value
171
+ similarity_threshold configuration
172
+ SemanticSearchEngine init threshold"""
173
+
174
+ user_prompt = f"""Natural language query: {natural_language_query}
175
+
176
+ Generate {limit} targeted search queries:"""
177
+
178
+ try:
179
+ messages = [
180
+ {"role": "system", "content": system_prompt.format(limit=limit)},
181
+ {"role": "user", "content": user_prompt},
182
+ ]
183
+
184
+ response = await self._chat_completion(messages)
185
+
186
+ # Parse queries from response
187
+ content = (
188
+ response.get("choices", [{}])[0].get("message", {}).get("content", "")
189
+ )
190
+ queries = [q.strip() for q in content.strip().split("\n") if q.strip()]
191
+
192
+ logger.debug(
193
+ f"Generated {len(queries)} search queries from: '{natural_language_query}'"
194
+ )
195
+
196
+ return queries[:limit]
197
+
198
+ except Exception as e:
199
+ logger.error(f"Failed to generate search queries: {e}")
200
+ raise SearchError(f"LLM query generation failed: {e}") from e
201
+
202
+ async def analyze_and_rank_results(
203
+ self,
204
+ original_query: str,
205
+ search_results: dict[str, list[Any]],
206
+ top_n: int = 5,
207
+ ) -> list[dict[str, Any]]:
208
+ """Analyze search results and select the most relevant ones.
209
+
210
+ Args:
211
+ original_query: Original natural language query
212
+ search_results: Dictionary mapping search queries to their results
213
+ top_n: Number of top results to return
214
+
215
+ Returns:
216
+ List of ranked results with explanations
217
+
218
+ Raises:
219
+ SearchError: If API call fails
220
+ """
221
+ # Format results for LLM analysis
222
+ results_summary = self._format_results_for_analysis(search_results)
223
+
224
+ system_prompt = """You are a code search expert. Your task is to analyze search results and identify the most relevant ones for answering a user's question.
225
+
226
+ Given:
227
+ 1. A natural language query
228
+ 2. Multiple search results from different queries
229
+
230
+ Select the top {top_n} most relevant results that best answer the user's question.
231
+
232
+ For each selected result, provide:
233
+ 1. Result identifier (e.g., "Query 1, Result 2")
234
+ 2. Relevance level: "High", "Medium", or "Low"
235
+ 3. Brief explanation (1-2 sentences) of why this result is relevant
236
+
237
+ Format your response as:
238
+ RESULT: [identifier]
239
+ RELEVANCE: [level]
240
+ EXPLANATION: [why this matches]
241
+
242
+ ---
243
+
244
+ Only include the top {top_n} results."""
245
+
246
+ user_prompt = f"""Original Question: {original_query}
247
+
248
+ Search Results:
249
+ {results_summary}
250
+
251
+ Select the top {top_n} most relevant results:"""
252
+
253
+ try:
254
+ messages = [
255
+ {"role": "system", "content": system_prompt.format(top_n=top_n)},
256
+ {"role": "user", "content": user_prompt},
257
+ ]
258
+
259
+ response = await self._chat_completion(messages)
260
+
261
+ # Parse LLM response
262
+ content = (
263
+ response.get("choices", [{}])[0].get("message", {}).get("content", "")
264
+ )
265
+
266
+ ranked_results = self._parse_ranking_response(
267
+ content, search_results, top_n
268
+ )
269
+
270
+ logger.debug(f"Ranked {len(ranked_results)} results from LLM analysis")
271
+
272
+ return ranked_results
273
+
274
+ except Exception as e:
275
+ logger.error(f"Failed to analyze results: {e}")
276
+ raise SearchError(f"LLM analysis failed: {e}") from e
277
+
278
+ async def _chat_completion(self, messages: list[dict[str, str]]) -> dict[str, Any]:
279
+ """Make chat completion request to OpenAI or OpenRouter API.
280
+
281
+ Args:
282
+ messages: List of message dictionaries with role and content
283
+
284
+ Returns:
285
+ API response dictionary
286
+
287
+ Raises:
288
+ SearchError: If API request fails
289
+ """
290
+ # Build headers based on provider
291
+ headers = {
292
+ "Authorization": f"Bearer {self.api_key}",
293
+ "Content-Type": "application/json",
294
+ }
295
+
296
+ # OpenRouter-specific headers
297
+ if self.provider == "openrouter":
298
+ headers["HTTP-Referer"] = "https://github.com/bobmatnyc/mcp-vector-search"
299
+ headers["X-Title"] = "MCP Vector Search"
300
+
301
+ payload = {
302
+ "model": self.model,
303
+ "messages": messages,
304
+ }
305
+
306
+ provider_name = self.provider.capitalize()
307
+
308
+ try:
309
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
310
+ response = await client.post(
311
+ self.api_endpoint,
312
+ headers=headers,
313
+ json=payload,
314
+ )
315
+
316
+ response.raise_for_status()
317
+ return response.json()
318
+
319
+ except httpx.TimeoutException as e:
320
+ logger.error(f"{provider_name} API timeout after {self.timeout}s")
321
+ raise SearchError(
322
+ f"LLM request timed out after {self.timeout} seconds. "
323
+ "Try a simpler query or check your network connection."
324
+ ) from e
325
+
326
+ except httpx.HTTPStatusError as e:
327
+ status_code = e.response.status_code
328
+ error_msg = f"{provider_name} API error (HTTP {status_code})"
329
+
330
+ if status_code == 401:
331
+ env_var = (
332
+ "OPENAI_API_KEY"
333
+ if self.provider == "openai"
334
+ else "OPENROUTER_API_KEY"
335
+ )
336
+ error_msg = f"Invalid {provider_name} API key. Please check {env_var} environment variable."
337
+ elif status_code == 429:
338
+ error_msg = f"{provider_name} API rate limit exceeded. Please wait and try again."
339
+ elif status_code >= 500:
340
+ error_msg = f"{provider_name} API server error. Please try again later."
341
+
342
+ logger.error(error_msg)
343
+ raise SearchError(error_msg) from e
344
+
345
+ except Exception as e:
346
+ logger.error(f"{provider_name} API request failed: {e}")
347
+ raise SearchError(f"LLM request failed: {e}") from e
348
+
349
+ def _format_results_for_analysis(self, search_results: dict[str, list[Any]]) -> str:
350
+ """Format search results for LLM analysis.
351
+
352
+ Args:
353
+ search_results: Dictionary mapping search queries to their results
354
+
355
+ Returns:
356
+ Formatted string representation of results
357
+ """
358
+ formatted = []
359
+
360
+ for i, (query, results) in enumerate(search_results.items(), 1):
361
+ formatted.append(f"\n=== Query {i}: {query} ===")
362
+
363
+ if not results:
364
+ formatted.append(" No results found.")
365
+ continue
366
+
367
+ for j, result in enumerate(results[:5], 1): # Top 5 per query
368
+ # Extract key information from SearchResult
369
+ file_path = str(result.file_path)
370
+ similarity = result.similarity_score
371
+ content_preview = result.content[:150].replace("\n", " ")
372
+
373
+ formatted.append(
374
+ f"\n Result {j}:\n"
375
+ f" File: {file_path}\n"
376
+ f" Similarity: {similarity:.3f}\n"
377
+ f" Preview: {content_preview}..."
378
+ )
379
+
380
+ if result.function_name:
381
+ formatted.append(f" Function: {result.function_name}")
382
+ if result.class_name:
383
+ formatted.append(f" Class: {result.class_name}")
384
+
385
+ return "\n".join(formatted)
386
+
387
+ def _parse_ranking_response(
388
+ self,
389
+ llm_response: str,
390
+ search_results: dict[str, list[Any]],
391
+ top_n: int,
392
+ ) -> list[dict[str, Any]]:
393
+ """Parse LLM ranking response into structured results.
394
+
395
+ Args:
396
+ llm_response: Raw LLM response text
397
+ search_results: Original search results dictionary
398
+ top_n: Maximum number of results to return
399
+
400
+ Returns:
401
+ List of ranked results with metadata
402
+ """
403
+ ranked = []
404
+ current_result = {}
405
+
406
+ for line in llm_response.split("\n"):
407
+ line = line.strip()
408
+
409
+ if line.startswith("RESULT:"):
410
+ if current_result:
411
+ ranked.append(current_result)
412
+ current_result = {"identifier": line.replace("RESULT:", "").strip()}
413
+
414
+ elif line.startswith("RELEVANCE:"):
415
+ current_result["relevance"] = line.replace("RELEVANCE:", "").strip()
416
+
417
+ elif line.startswith("EXPLANATION:"):
418
+ current_result["explanation"] = line.replace("EXPLANATION:", "").strip()
419
+
420
+ # Add last result
421
+ if current_result:
422
+ ranked.append(current_result)
423
+
424
+ # Map identifiers back to actual SearchResult objects
425
+ enriched_results = []
426
+
427
+ for item in ranked[:top_n]:
428
+ identifier = item.get("identifier", "")
429
+
430
+ # Parse identifier (e.g., "Query 1, Result 2" or "Query 1, Result 2 (filename.py)")
431
+ try:
432
+ parts = identifier.split(",")
433
+ query_part = parts[0].replace("Query", "").strip()
434
+ result_part = parts[1].replace("Result", "").strip()
435
+
436
+ # Handle case where LLM includes filename in parentheses: "5 (config.py)"
437
+ # Extract just the number
438
+ query_match = re.match(r"(\d+)", query_part)
439
+ result_match = re.match(r"(\d+)", result_part)
440
+
441
+ if not query_match or not result_match:
442
+ logger.warning(
443
+ f"Could not extract numbers from identifier '{identifier}'"
444
+ )
445
+ continue
446
+
447
+ query_idx = int(query_match.group(1)) - 1
448
+ result_idx = int(result_match.group(1)) - 1
449
+
450
+ # Get corresponding query and result
451
+ queries = list(search_results.keys())
452
+ if query_idx < len(queries):
453
+ query = queries[query_idx]
454
+ results = search_results[query]
455
+
456
+ if result_idx < len(results):
457
+ actual_result = results[result_idx]
458
+
459
+ enriched_results.append(
460
+ {
461
+ "result": actual_result,
462
+ "query": query,
463
+ "relevance": item.get("relevance", "Medium"),
464
+ "explanation": item.get(
465
+ "explanation", "Relevant to query"
466
+ ),
467
+ }
468
+ )
469
+
470
+ except (ValueError, IndexError) as e:
471
+ logger.warning(f"Failed to parse result identifier '{identifier}': {e}")
472
+ continue
473
+
474
+ return enriched_results
475
+
476
+ async def detect_intent(self, query: str) -> IntentType:
477
+ """Detect user intent from query.
478
+
479
+ Args:
480
+ query: User's natural language query
481
+
482
+ Returns:
483
+ Intent type: "find", "answer", or "analyze"
484
+
485
+ Raises:
486
+ SearchError: If API call fails
487
+ """
488
+ system_prompt = """You are a code search intent classifier. Classify the user's query into ONE of these categories:
489
+
490
+ 1. "find" - User wants to locate/search for something in the codebase
491
+ Examples: "where is X", "find the function that", "show me the code for", "locate X"
492
+
493
+ 2. "answer" - User wants an explanation/answer about the codebase
494
+ Examples: "what does this do", "how does X work", "explain the architecture", "why is X used"
495
+
496
+ 3. "analyze" - User wants analysis of code quality, metrics, complexity, or smells
497
+ Examples: "what's complex", "code smells", "cognitive complexity", "quality issues",
498
+ "dependencies", "coupling", "circular dependencies", "getting worse", "improving",
499
+ "analyze the complexity", "find the worst code", "most complex functions"
500
+
501
+ Return ONLY the word "find", "answer", or "analyze" with no other text."""
502
+
503
+ user_prompt = f"""Query: {query}
504
+
505
+ Intent:"""
506
+
507
+ try:
508
+ messages = [
509
+ {"role": "system", "content": system_prompt},
510
+ {"role": "user", "content": user_prompt},
511
+ ]
512
+
513
+ response = await self._chat_completion(messages)
514
+
515
+ content = (
516
+ response.get("choices", [{}])[0].get("message", {}).get("content", "")
517
+ )
518
+ intent = content.strip().lower()
519
+
520
+ if intent not in ("find", "answer", "analyze"):
521
+ # Default to find if unclear
522
+ logger.warning(
523
+ f"Unclear intent '{intent}' for query '{query}', defaulting to 'find'"
524
+ )
525
+ return "find"
526
+
527
+ logger.debug(f"Detected intent '{intent}' for query: '{query}'")
528
+ return intent # type: ignore
529
+
530
+ except Exception as e:
531
+ logger.error(f"Failed to detect intent: {e}, defaulting to 'find'")
532
+ return "find"
533
+
534
+ async def stream_chat_completion(
535
+ self, messages: list[dict[str, str]]
536
+ ) -> AsyncIterator[str]:
537
+ """Stream chat completion response chunk by chunk.
538
+
539
+ Args:
540
+ messages: List of message dictionaries with role and content
541
+
542
+ Yields:
543
+ Text chunks from the streaming response
544
+
545
+ Raises:
546
+ SearchError: If API request fails
547
+ """
548
+ headers = {
549
+ "Authorization": f"Bearer {self.api_key}",
550
+ "Content-Type": "application/json",
551
+ }
552
+
553
+ if self.provider == "openrouter":
554
+ headers["HTTP-Referer"] = "https://github.com/bobmatnyc/mcp-vector-search"
555
+ headers["X-Title"] = "MCP Vector Search"
556
+
557
+ payload = {
558
+ "model": self.model,
559
+ "messages": messages,
560
+ "stream": True,
561
+ }
562
+
563
+ provider_name = self.provider.capitalize()
564
+
565
+ try:
566
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
567
+ async with client.stream(
568
+ "POST", self.api_endpoint, headers=headers, json=payload
569
+ ) as response:
570
+ response.raise_for_status()
571
+
572
+ async for line in response.aiter_lines():
573
+ line = line.strip()
574
+
575
+ # Skip empty lines and comments
576
+ if not line or line.startswith(":"):
577
+ continue
578
+
579
+ # Parse SSE format: "data: {json}"
580
+ if line.startswith("data: "):
581
+ data = line[6:] # Remove "data: " prefix
582
+
583
+ # Check for end of stream
584
+ if data == "[DONE]":
585
+ break
586
+
587
+ try:
588
+ chunk = json.loads(data)
589
+ content = (
590
+ chunk.get("choices", [{}])[0]
591
+ .get("delta", {})
592
+ .get("content")
593
+ )
594
+
595
+ if content:
596
+ yield content
597
+
598
+ except json.JSONDecodeError as e:
599
+ logger.warning(f"Failed to parse SSE chunk: {e}")
600
+ continue
601
+
602
+ except httpx.TimeoutException as e:
603
+ logger.error(f"{provider_name} API timeout after {self.timeout}s")
604
+ raise SearchError(
605
+ f"LLM request timed out after {self.timeout} seconds. "
606
+ "Try a simpler query or check your network connection."
607
+ ) from e
608
+
609
+ except httpx.HTTPStatusError as e:
610
+ status_code = e.response.status_code
611
+ error_msg = f"{provider_name} API error (HTTP {status_code})"
612
+
613
+ if status_code == 401:
614
+ env_var = (
615
+ "OPENAI_API_KEY"
616
+ if self.provider == "openai"
617
+ else "OPENROUTER_API_KEY"
618
+ )
619
+ error_msg = f"Invalid {provider_name} API key. Please check {env_var} environment variable."
620
+ elif status_code == 429:
621
+ error_msg = f"{provider_name} API rate limit exceeded. Please wait and try again."
622
+ elif status_code >= 500:
623
+ error_msg = f"{provider_name} API server error. Please try again later."
624
+
625
+ logger.error(error_msg)
626
+ raise SearchError(error_msg) from e
627
+
628
+ except Exception as e:
629
+ logger.error(f"{provider_name} streaming request failed: {e}")
630
+ raise SearchError(f"LLM streaming failed: {e}") from e
631
+
632
+ async def generate_answer(
633
+ self,
634
+ query: str,
635
+ context: str,
636
+ conversation_history: list[dict[str, str]] | None = None,
637
+ ) -> str:
638
+ """Generate answer to user question using codebase context.
639
+
640
+ Args:
641
+ query: User's question
642
+ context: Relevant code context from search results
643
+ conversation_history: Previous conversation messages (optional)
644
+
645
+ Returns:
646
+ LLM response text
647
+
648
+ Raises:
649
+ SearchError: If API call fails
650
+ """
651
+ system_prompt = f"""You are a helpful code assistant analyzing a codebase. Answer the user's questions based on the provided code context.
652
+
653
+ Code Context:
654
+ {context}
655
+
656
+ Guidelines:
657
+ - Be concise but thorough in explanations
658
+ - Reference specific functions, classes, or files when relevant
659
+ - Use code examples from the context when helpful
660
+ - If the context doesn't contain enough information, say so
661
+ - Use markdown formatting for code snippets"""
662
+
663
+ messages = [{"role": "system", "content": system_prompt}]
664
+
665
+ # Add conversation history if provided
666
+ if conversation_history:
667
+ messages.extend(conversation_history)
668
+
669
+ # Add current query
670
+ messages.append({"role": "user", "content": query})
671
+
672
+ try:
673
+ response = await self._chat_completion(messages)
674
+ content = (
675
+ response.get("choices", [{}])[0].get("message", {}).get("content", "")
676
+ )
677
+
678
+ logger.debug(f"Generated answer for query: '{query}'")
679
+ return content
680
+
681
+ except Exception as e:
682
+ logger.error(f"Failed to generate answer: {e}")
683
+ raise SearchError(f"Failed to generate answer: {e}") from e
684
+
685
+ async def chat_with_tools(
686
+ self, messages: list[dict[str, Any]], tools: list[dict[str, Any]]
687
+ ) -> dict[str, Any]:
688
+ """Chat completion with tool/function calling support.
689
+
690
+ Args:
691
+ messages: List of message dictionaries
692
+ tools: List of tool definitions
693
+
694
+ Returns:
695
+ API response with tool calls or final message
696
+
697
+ Raises:
698
+ SearchError: If API request fails
699
+ """
700
+ headers = {
701
+ "Authorization": f"Bearer {self.api_key}",
702
+ "Content-Type": "application/json",
703
+ }
704
+
705
+ if self.provider == "openrouter":
706
+ headers["HTTP-Referer"] = "https://github.com/bobmatnyc/mcp-vector-search"
707
+ headers["X-Title"] = "MCP Vector Search"
708
+
709
+ payload = {
710
+ "model": self.model,
711
+ "messages": messages,
712
+ "tools": tools,
713
+ "tool_choice": "auto",
714
+ }
715
+
716
+ provider_name = self.provider.capitalize()
717
+
718
+ try:
719
+ async with httpx.AsyncClient(timeout=self.timeout) as client:
720
+ response = await client.post(
721
+ self.api_endpoint,
722
+ headers=headers,
723
+ json=payload,
724
+ )
725
+
726
+ response.raise_for_status()
727
+ return response.json()
728
+
729
+ except httpx.TimeoutException as e:
730
+ logger.error(f"{provider_name} API timeout after {self.timeout}s")
731
+ raise SearchError(
732
+ f"LLM request timed out after {self.timeout} seconds."
733
+ ) from e
734
+
735
+ except httpx.HTTPStatusError as e:
736
+ status_code = e.response.status_code
737
+ error_msg = f"{provider_name} API error (HTTP {status_code})"
738
+
739
+ if status_code == 401:
740
+ env_var = (
741
+ "OPENAI_API_KEY"
742
+ if self.provider == "openai"
743
+ else "OPENROUTER_API_KEY"
744
+ )
745
+ error_msg = f"Invalid {provider_name} API key. Check {env_var}."
746
+ elif status_code == 429:
747
+ error_msg = f"{provider_name} API rate limit exceeded."
748
+ elif status_code >= 500:
749
+ error_msg = f"{provider_name} API server error."
750
+
751
+ logger.error(error_msg)
752
+ raise SearchError(error_msg) from e
753
+
754
+ except Exception as e:
755
+ logger.error(f"{provider_name} API request failed: {e}")
756
+ raise SearchError(f"LLM request failed: {e}") from e