Coverage for src/local_deep_research/web_search_engines/engines/search_engine_github.py: 99%
343 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
1import base64
2import json
3import time
4from typing import Any, Dict, List, Optional
6from langchain_core.language_models import BaseLLM
7from ...security.secure_logging import logger
9from ...config import llm_config
10from ...constants import USER_AGENT
11from ...security.safe_requests import safe_get
12from ...utilities.json_utils import extract_json, get_llm_response_text
13from ..search_engine_base import BaseSearchEngine, Exposure, Sensitivity
16_VALID_SEARCH_TYPES = frozenset({"repositories", "code", "issues", "users"})
19class GitHubSearchEngine(BaseSearchEngine):
20 """
21 GitHub search engine implementation.
22 Provides search across GitHub repositories, code, issues, and users.
23 """
25 is_public = True
26 egress_sensitivity = Sensitivity.NON_SENSITIVE
27 egress_exposure = Exposure.EXPOSING
28 is_code = True
29 is_lexical = True
30 needs_llm_relevance_filter = True
32 def __init__(
33 self,
34 max_results: int = 15,
35 api_key: Optional[str] = None,
36 search_type: str = "repositories",
37 include_readme: bool = True,
38 include_issues: bool = False,
39 llm: Optional[BaseLLM] = None,
40 max_filtered_results: Optional[int] = None,
41 settings_snapshot: Optional[Dict[str, Any]] = None,
42 ):
43 """
44 Initialize the GitHub search engine.
46 Args:
47 max_results: Maximum number of search results
48 api_key: GitHub API token (can also be set via LDR_SEARCH_ENGINE_WEB_GITHUB_API_KEY env var or in UI settings)
49 search_type: Type of GitHub search ("repositories", "code", "issues", "users")
50 include_readme: Whether to include README content for repositories
51 include_issues: Whether to include recent issues for repositories
52 llm: Language model for relevance filtering
53 max_filtered_results: Maximum number of results to keep after filtering
54 """
55 # Initialize the BaseSearchEngine with LLM, max_filtered_results, and max_results
56 super().__init__(
57 llm=llm,
58 max_filtered_results=max_filtered_results,
59 max_results=max_results,
60 settings_snapshot=settings_snapshot,
61 )
62 self.api_key = api_key
63 if search_type not in _VALID_SEARCH_TYPES:
64 raise ValueError(
65 f"Invalid GitHub search_type: {search_type!r}. "
66 f"Must be one of {_VALID_SEARCH_TYPES}"
67 )
68 self.search_type = search_type
69 self.include_readme = include_readme
70 self.include_issues = include_issues
72 self._owns_llm = False
74 # API endpoints
75 self.api_base = "https://api.github.com"
76 self.search_endpoint = f"{self.api_base}/search/{search_type}"
78 # Set up API headers
79 self.headers = {
80 "Accept": "application/vnd.github.v3+json",
81 "User-Agent": USER_AGENT,
82 }
84 # Add authentication if API key provided
85 if self.api_key:
86 self.headers["Authorization"] = f"token {self.api_key}"
87 logger.info("Using authenticated GitHub API requests")
88 else:
89 logger.warning(
90 "No GitHub API key provided. Rate limits will be restricted."
91 )
93 def close(self) -> None:
94 """Close the lazily-loaded LLM client if this engine created it."""
95 from ...utilities.resource_utils import safe_close
97 if self._owns_llm:
98 safe_close(self.llm, "GitHub LLM")
99 super().close()
101 def _handle_rate_limits(self, response):
102 """Handle GitHub API rate limits by logging warnings and sleeping if necessary"""
103 remaining = int(response.headers.get("X-RateLimit-Remaining", 60))
104 reset_time = int(response.headers.get("X-RateLimit-Reset", 0))
106 if remaining < 5:
107 current_time = time.time()
108 wait_time = max(reset_time - current_time, 0)
109 logger.warning(
110 f"GitHub API rate limit almost reached. {remaining} requests remaining."
111 )
113 if wait_time > 0 and remaining == 0:
114 logger.warning(
115 f"GitHub API rate limit exceeded. Waiting {wait_time:.0f} seconds."
116 )
117 time.sleep(min(wait_time, 60)) # Wait at most 60 seconds
119 def _optimize_github_query(self, query: str) -> str:
120 """
121 Optimize the GitHub search query using LLM to improve search results.
123 Args:
124 query: Original search query
126 Returns:
127 Optimized GitHub search query
128 """
129 # Get LLM from config if not already set
130 if not self.llm:
131 try:
132 self.llm = llm_config.get_llm()
133 self._owns_llm = True
134 if not self.llm:
135 logger.warning("No LLM available for query optimization")
136 return query
137 except Exception as e:
138 safe_msg = self._scrub_error(e)
139 logger.warning(f"Error getting LLM from config: {safe_msg}")
140 return query
142 prompt = f"""Transform this GitHub search query into an optimized version for the GitHub search API. Follow these steps:
143 1. Strip question words (e.g., 'what', 'are', 'is'), stop words (e.g., 'and', 'as', 'of', 'on'), and redundant terms (e.g., 'repositories', 'repos', 'github') since they're implied by the search context.
144 2. Keep only domain-specific keywords and avoid using "-related" terms.
145 3. Add GitHub-specific filters with dynamic thresholds based on query context:
146 - For stars: Use higher threshold (e.g., 'stars:>1000') for mainstream topics, lower (e.g., 'stars:>50') for specialized topics
147 - For language: Detect programming language from query or omit if unclear
148 - For search scope: Use 'in:name,description,readme' for general queries, 'in:file' for code-specific queries
149 4. For date ranges, adapt based on query context:
150 - For emerging: Use 'created:>2024-01-01'
151 - For mature: Use 'pushed:>2023-01-01'
152 - For historical research: Use 'created:2020-01-01..2024-01-01'
153 5. For excluding results, adapt based on query:
154 - Exclude irrelevant languages based on context
155 - Use 'NOT' to exclude competing terms
156 6. Ensure the output is a concise, space-separated string with no punctuation or extra text beyond keywords and filters.
159 Original query: "{query}"
161 Return ONLY the optimized query, ready for GitHub's search API. Do not include explanations or additional text."""
163 try:
164 response = self.llm.invoke(prompt)
166 optimized_query = get_llm_response_text(response).strip()
168 # Validate the optimized query
169 if optimized_query and len(optimized_query) > 0:
170 logger.info(
171 f"LLM optimized query from '{query}' to '{optimized_query}'"
172 )
173 return optimized_query
174 logger.warning("LLM returned empty query, using original")
175 return query
177 except Exception as e:
178 safe_msg = self._scrub_error(e)
179 logger.warning(f"Error optimizing query with LLM: {safe_msg}")
180 return query
182 def _search_github(self, query: str) -> List[Dict[str, Any]]:
183 """
184 Perform a GitHub search based on the configured search type.
186 Args:
187 query: The search query
189 Returns:
190 List of GitHub search result items
191 """
192 results = []
194 try:
195 # Optimize GitHub query using LLM
196 github_query = self._optimize_github_query(query)
198 logger.info(f"Final GitHub query: {github_query}")
200 # Construct search parameters
201 params = {
202 "q": github_query,
203 "per_page": min(
204 self.max_results, 100
205 ), # GitHub API max is 100 per page
206 "page": 1,
207 }
209 # Add sort parameters based on search type
210 if self.search_type == "repositories":
211 params["sort"] = "stars"
212 params["order"] = "desc"
213 elif self.search_type == "code":
214 params["sort"] = "indexed"
215 params["order"] = "desc"
216 elif self.search_type == "issues":
217 params["sort"] = "updated"
218 params["order"] = "desc"
219 elif self.search_type == "users": 219 ↛ 224line 219 didn't jump to line 224 because the condition on line 219 was always true
220 params["sort"] = "followers"
221 params["order"] = "desc"
223 # Apply rate limiting before request
224 self._last_wait_time = self.rate_tracker.apply_rate_limit(
225 self.engine_type
226 )
228 # Execute the API request
229 response = safe_get(
230 self.search_endpoint, headers=self.headers, params=params
231 )
233 # Check for rate limiting
234 self._handle_rate_limits(response)
236 # Handle response with detailed logging
237 if response.status_code == 200:
238 data = response.json()
239 total_count = data.get("total_count", 0)
240 results = data.get("items", [])
241 logger.info(
242 f"GitHub search returned {len(results)} results (total available: {total_count})"
243 )
245 # Log the rate limit information
246 rate_limit_remaining = response.headers.get(
247 "X-RateLimit-Remaining", "unknown"
248 )
249 logger.info(
250 f"GitHub API rate limit: {rate_limit_remaining} requests remaining"
251 )
253 # If no results, try to provide more guidance
254 if not results:
255 logger.warning(
256 "No results found. Consider these search tips:"
257 )
258 logger.warning("1. Use shorter, more specific queries")
259 logger.warning(
260 "2. For repositories, try adding 'stars:>100' or 'language:python'"
261 )
262 logger.warning(
263 "3. For contribution opportunities, search for 'good-first-issue' or 'help-wanted'"
264 )
265 else:
266 logger.error(
267 f"GitHub API error: {response.status_code} - {response.text}"
268 )
270 except Exception as e:
271 safe_msg = self._scrub_error(e)
272 logger.warning(f"Error searching GitHub: {safe_msg}")
274 return results
276 def _get_readme_content(self, repo_full_name: str) -> str:
277 """
278 Get README content for a repository.
280 Args:
281 repo_full_name: Full name of the repository (owner/repo)
283 Returns:
284 Decoded README content or empty string if not found
285 """
286 try:
287 # Get README
288 # Apply rate limiting before request
289 self._last_wait_time = self.rate_tracker.apply_rate_limit(
290 self.engine_type
291 )
293 response = safe_get(
294 f"{self.api_base}/repos/{repo_full_name}/readme",
295 headers=self.headers,
296 )
298 # Check for rate limiting
299 self._handle_rate_limits(response)
301 if response.status_code == 200:
302 data = response.json()
303 content: str = data.get("content", "")
304 encoding = data.get("encoding", "")
306 if encoding == "base64" and content:
307 return base64.b64decode(content).decode(
308 "utf-8", errors="replace"
309 )
310 return content
311 logger.warning(
312 f"Could not get README for {repo_full_name}: {response.status_code}"
313 )
314 return ""
316 except Exception as e:
317 safe_msg = self._scrub_error(e)
318 logger.warning(
319 f"Error getting README for {repo_full_name}: {safe_msg}"
320 )
321 return ""
323 def _get_recent_issues(
324 self, repo_full_name: str, limit: int = 5
325 ) -> List[Dict[str, Any]]:
326 """
327 Get recent issues for a repository.
329 Args:
330 repo_full_name: Full name of the repository (owner/repo)
331 limit: Maximum number of issues to return
333 Returns:
334 List of recent issues
335 """
336 issues = []
338 try:
339 # Get recent issues
340 # Apply rate limiting before request
341 self._last_wait_time = self.rate_tracker.apply_rate_limit(
342 self.engine_type
343 )
345 response = safe_get(
346 f"{self.api_base}/repos/{repo_full_name}/issues",
347 headers=self.headers,
348 params={
349 "state": "all",
350 "per_page": limit,
351 "sort": "updated",
352 "direction": "desc",
353 },
354 )
356 # Check for rate limiting
357 self._handle_rate_limits(response)
359 if response.status_code == 200:
360 issues = response.json()
361 logger.info(
362 f"Got {len(issues)} recent issues for {repo_full_name}"
363 )
364 else:
365 logger.warning(
366 f"Could not get issues for {repo_full_name}: {response.status_code}"
367 )
369 except Exception as e:
370 safe_msg = self._scrub_error(e)
371 logger.warning(
372 f"Error getting issues for {repo_full_name}: {safe_msg}"
373 )
375 return issues
377 def _get_file_content(self, file_url: str) -> str:
378 """
379 Get content of a file from GitHub.
381 Args:
382 file_url: API URL for the file
384 Returns:
385 Decoded file content or empty string if not found
386 """
387 try:
388 # Apply rate limiting before request
389 self._last_wait_time = self.rate_tracker.apply_rate_limit(
390 self.engine_type
391 )
393 # Get file content
394 response = safe_get(file_url, headers=self.headers)
396 # Check for rate limiting
397 self._handle_rate_limits(response)
399 if response.status_code == 200:
400 data = response.json()
401 content2: str = data.get("content", "")
402 encoding = data.get("encoding", "")
404 if encoding == "base64" and content2:
405 return base64.b64decode(content2).decode(
406 "utf-8", errors="replace"
407 )
408 return content2
409 logger.warning(
410 f"Could not get file content: {response.status_code}"
411 )
412 return ""
414 except Exception as e:
415 safe_msg = self._scrub_error(e)
416 logger.warning(f"Error getting file content: {safe_msg}")
417 return ""
419 def _format_repository_preview(
420 self, repo: Dict[str, Any]
421 ) -> Dict[str, Any]:
422 """Format repository search result as preview"""
423 return {
424 "id": str(repo.get("id", "")),
425 "title": repo.get("full_name", ""),
426 "link": repo.get("html_url", ""),
427 "snippet": repo.get("description", "No description provided"),
428 "stars": repo.get("stargazers_count", 0),
429 "forks": repo.get("forks_count", 0),
430 "language": repo.get("language", ""),
431 "updated_at": repo.get("updated_at", ""),
432 "created_at": repo.get("created_at", ""),
433 "topics": repo.get("topics", []),
434 "owner": repo.get("owner", {}).get("login", ""),
435 "is_fork": repo.get("fork", False),
436 "search_type": "repository",
437 "repo_full_name": repo.get("full_name", ""),
438 }
440 def _format_code_preview(self, code: Dict[str, Any]) -> Dict[str, Any]:
441 """Format code search result as preview"""
442 repo = code.get("repository", {})
443 return {
444 "id": f"code_{code.get('sha', '')}",
445 "title": f"{code.get('name', '')} in {repo.get('full_name', '')}",
446 "link": code.get("html_url", ""),
447 "snippet": f"Match in {code.get('path', '')}",
448 "path": code.get("path", ""),
449 "repo_name": repo.get("full_name", ""),
450 "repo_url": repo.get("html_url", ""),
451 "search_type": "code",
452 "file_url": code.get("url", ""),
453 }
455 def _format_issue_preview(self, issue: Dict[str, Any]) -> Dict[str, Any]:
456 """Format issue search result as preview"""
457 repo = (
458 issue.get("repository", {})
459 if "repository" in issue
460 else {"full_name": ""}
461 )
462 return {
463 "id": f"issue_{issue.get('number', '')}",
464 "title": issue.get("title", ""),
465 "link": issue.get("html_url", ""),
466 "snippet": (
467 issue.get("body", "")[:200] + "..."
468 if len(issue.get("body", "")) > 200
469 else issue.get("body", "")
470 ),
471 "state": issue.get("state", ""),
472 "created_at": issue.get("created_at", ""),
473 "updated_at": issue.get("updated_at", ""),
474 "user": issue.get("user", {}).get("login", ""),
475 "comments": issue.get("comments", 0),
476 "search_type": "issue",
477 "repo_name": repo.get("full_name", ""),
478 }
480 def _format_user_preview(self, user: Dict[str, Any]) -> Dict[str, Any]:
481 """Format user search result as preview"""
482 return {
483 "id": f"user_{user.get('id', '')}",
484 "title": user.get("login", ""),
485 "link": user.get("html_url", ""),
486 "snippet": user.get("bio", "No bio provided"),
487 "name": user.get("name", ""),
488 "followers": user.get("followers", 0),
489 "public_repos": user.get("public_repos", 0),
490 "location": user.get("location", ""),
491 "search_type": "user",
492 "user_login": user.get("login", ""),
493 }
495 def _get_previews(self, query: str) -> List[Dict[str, Any]]:
496 """
497 Get preview information for GitHub search results.
499 Args:
500 query: The search query
502 Returns:
503 List of preview dictionaries
504 """
505 logger.info(f"Getting GitHub previews for query: {query}")
507 # For contribution-focused queries, automatically adjust search type and add filters
508 if any(
509 term in query.lower()
510 for term in [
511 "contribute",
512 "contributing",
513 "contribution",
514 "beginner",
515 "newcomer",
516 ]
517 ):
518 # Use repositories search with help-wanted or good-first-issue labels
519 original_search_type = self.search_type
520 self.search_type = "repositories"
521 self.search_endpoint = f"{self.api_base}/search/repositories"
523 # Create a specialized query for finding beginner-friendly projects
524 specialized_query = "good-first-issues:>5 is:public archived:false"
526 # Extract language preferences if present
527 languages = []
528 for lang in [
529 "python",
530 "javascript",
531 "java",
532 "rust",
533 "go",
534 "typescript",
535 "c#",
536 "c++",
537 "ruby",
538 ]:
539 if lang in query.lower():
540 languages.append(lang)
542 if languages:
543 specialized_query += f" language:{' language:'.join(languages)}"
545 # Extract keywords
546 keywords = [
547 word
548 for word in query.split()
549 if len(word) > 3
550 and word.lower()
551 not in [
552 "recommend",
553 "recommended",
554 "github",
555 "repositories",
556 "looking",
557 "developers",
558 "contribute",
559 "contributing",
560 "beginner",
561 "newcomer",
562 ]
563 ]
565 if keywords: 565 ↛ 570line 565 didn't jump to line 570 because the condition on line 565 was always true
566 specialized_query += " " + " ".join(
567 keywords[:5]
568 ) # Add up to 5 keywords
570 logger.info(
571 f"Using specialized contribution query: {specialized_query}"
572 )
574 # Perform GitHub search with specialized query
575 results = self._search_github(specialized_query)
577 # Restore original search type
578 self.search_type = original_search_type
579 self.search_endpoint = f"{self.api_base}/search/{self.search_type}"
580 else:
581 # Perform standard GitHub search
582 results = self._search_github(query)
584 if not results:
585 logger.warning(f"No GitHub results found for query: {query}")
586 return []
588 # Format results as previews
589 previews = []
590 for result in results:
591 # Format based on search type
592 if self.search_type == "repositories":
593 preview = self._format_repository_preview(result)
594 elif self.search_type == "code":
595 preview = self._format_code_preview(result)
596 elif self.search_type == "issues":
597 preview = self._format_issue_preview(result)
598 elif self.search_type == "users": 598 ↛ 601line 598 didn't jump to line 601 because the condition on line 598 was always true
599 preview = self._format_user_preview(result)
600 else:
601 logger.warning(f"Unknown search type: {self.search_type}")
602 continue
604 previews.append(preview)
606 logger.info(f"Formatted {len(previews)} GitHub preview results")
607 return previews
609 def _get_full_content(
610 self, relevant_items: List[Dict[str, Any]]
611 ) -> List[Dict[str, Any]]:
612 """
613 Get full content for the relevant GitHub search results.
615 Args:
616 relevant_items: List of relevant preview dictionaries
618 Returns:
619 List of result dictionaries with full content
620 """
621 logger.info(
622 f"Getting full content for {len(relevant_items)} GitHub results"
623 )
625 results = []
626 for item in relevant_items:
627 result = item.copy()
628 search_type = item.get("search_type", "")
630 # Add content based on search type
631 if search_type == "repository" and self.include_readme:
632 repo_full_name = item.get("repo_full_name", "")
633 if repo_full_name:
634 # Get README content
635 readme_content = self._get_readme_content(repo_full_name)
636 result["full_content"] = readme_content
637 result["content_type"] = "readme"
639 # Get recent issues if requested
640 if self.include_issues:
641 issues = self._get_recent_issues(repo_full_name)
642 result["recent_issues"] = issues
644 elif search_type == "code":
645 file_url = item.get("file_url", "")
646 if file_url:
647 # Get file content
648 file_content = self._get_file_content(file_url)
649 result["full_content"] = file_content
650 result["content_type"] = "file"
652 elif search_type == "issue":
653 # For issues, the snippet usually contains a summary already
654 # We'll just keep it as is
655 result["full_content"] = item.get("snippet", "")
656 result["content_type"] = "issue"
658 elif search_type == "user":
659 # For users, construct a profile summary
660 profile_summary = f"GitHub user: {item.get('title', '')}\n"
662 if item.get("name"):
663 profile_summary += f"Name: {item.get('name')}\n"
665 if item.get("location"):
666 profile_summary += f"Location: {item.get('location')}\n"
668 profile_summary += f"Followers: {item.get('followers', 0)}\n"
669 profile_summary += (
670 f"Public repositories: {item.get('public_repos', 0)}\n"
671 )
673 if (
674 item.get("snippet")
675 and item.get("snippet") != "No bio provided"
676 ):
677 profile_summary += f"\nBio: {item.get('snippet')}\n"
679 result["full_content"] = profile_summary
680 result["content_type"] = "user_profile"
682 results.append(result)
684 return results
686 def search_repository(
687 self, repo_owner: str, repo_name: str
688 ) -> Dict[str, Any]:
689 """
690 Get detailed information about a specific repository.
692 Args:
693 repo_owner: Owner of the repository
694 repo_name: Name of the repository
696 Returns:
697 Dictionary with repository information
698 """
699 repo_full_name = f"{repo_owner}/{repo_name}"
700 logger.info(f"Getting details for repository: {repo_full_name}")
702 try:
703 # Get repository details
704 # Apply rate limiting before request
705 self._last_wait_time = self.rate_tracker.apply_rate_limit(
706 self.engine_type
707 )
709 response = safe_get(
710 f"{self.api_base}/repos/{repo_full_name}", headers=self.headers
711 )
713 # Check for rate limiting
714 self._handle_rate_limits(response)
716 if response.status_code == 200:
717 repo = response.json()
719 # Format as repository preview
720 result = self._format_repository_preview(repo)
722 # Add README content if requested
723 if self.include_readme:
724 readme_content = self._get_readme_content(repo_full_name)
725 result["full_content"] = readme_content
726 result["content_type"] = "readme"
728 # Add recent issues if requested
729 if self.include_issues:
730 issues = self._get_recent_issues(repo_full_name)
731 result["recent_issues"] = issues
733 return result
734 logger.error(
735 f"Error getting repository details: {response.status_code} - {response.text}"
736 )
737 return {}
739 except Exception as e:
740 safe_msg = self._scrub_error(e)
741 logger.warning(f"Error getting repository details: {safe_msg}")
742 return {}
744 def search_code(
745 self,
746 query: str,
747 language: Optional[str] = None,
748 user: Optional[str] = None,
749 ) -> List[Dict[str, Any]]:
750 """
751 Search for code with more specific parameters.
753 Args:
754 query: Code search query
755 language: Filter by programming language
756 user: Filter by GitHub username/organization
758 Returns:
759 List of code search results
760 """
761 # Build advanced query
762 advanced_query = query
764 if language:
765 advanced_query += f" language:{language}"
767 if user:
768 advanced_query += f" user:{user}"
770 # Save current search type
771 original_search_type = self.search_type
773 try:
774 # Set search type to code
775 self.search_type = "code"
776 self.search_endpoint = f"{self.api_base}/search/code"
778 # Perform search
779 results = self._search_github(advanced_query)
781 # Format results
782 previews = [self._format_code_preview(result) for result in results]
784 return self._get_full_content(previews)
786 finally:
787 # Restore original search type
788 self.search_type = original_search_type
789 self.search_endpoint = f"{self.api_base}/search/{self.search_type}"
791 def search_issues(
792 self, query: str, state: str = "open", sort: str = "updated"
793 ) -> List[Dict[str, Any]]:
794 """
795 Search for issues with more specific parameters.
797 Args:
798 query: Issue search query
799 state: Filter by issue state ("open", "closed", "all")
800 sort: Sort order ("updated", "created", "comments")
802 Returns:
803 List of issue search results
804 """
805 # Build advanced query
806 advanced_query = query + f" state:{state}"
808 # Save current search type
809 original_search_type = self.search_type
811 try:
812 # Set search type to issues
813 self.search_type = "issues"
814 self.search_endpoint = f"{self.api_base}/search/issues"
816 # Set sort parameter
817 params = {
818 "q": advanced_query,
819 "per_page": min(self.max_results, 100),
820 "page": 1,
821 "sort": sort,
822 "order": "desc",
823 }
825 # Perform search
826 response = safe_get(
827 self.search_endpoint, headers=self.headers, params=params
828 )
830 # Check for rate limiting
831 self._handle_rate_limits(response)
833 if response.status_code == 200:
834 data = response.json()
835 results = data.get("items", [])
837 # Format results
838 return [
839 self._format_issue_preview(result) for result in results
840 ]
842 # For issues, we don't need to get full content
843 logger.error(
844 f"GitHub API error: {response.status_code} - {response.text}"
845 )
846 return []
848 finally:
849 # Restore original search type
850 self.search_type = original_search_type
851 self.search_endpoint = f"{self.api_base}/search/{self.search_type}"
853 def set_search_type(self, search_type: str):
854 """
855 Set the search type for subsequent searches.
857 Args:
858 search_type: Type of GitHub search ("repositories", "code", "issues", "users")
859 """
860 if search_type not in _VALID_SEARCH_TYPES:
861 raise ValueError(
862 f"Invalid GitHub search_type: {search_type!r}. "
863 f"Must be one of {_VALID_SEARCH_TYPES}"
864 )
865 self.search_type = search_type
866 self.search_endpoint = f"{self.api_base}/search/{search_type}"
867 logger.info(f"Set GitHub search type to: {search_type}")
869 @staticmethod
870 def _valid_unique_indices(ranked_indices, upper_bound):
871 """Yield valid indices once, preserving first-seen (ranked) order.
873 Rejects non-integers, booleans, negative and out-of-range indices,
874 and deduplicates so a malformed LLM response cannot select the wrong
875 preview (e.g. ``-1`` -> last preview) or list the same preview twice.
876 """
877 seen = set()
878 for idx in ranked_indices:
879 if not isinstance(idx, int) or isinstance(idx, bool):
880 continue
881 if idx in seen:
882 continue
883 if 0 <= idx < upper_bound:
884 seen.add(idx)
885 yield idx
887 def _filter_for_relevance(
888 self, previews: List[Dict[str, Any]], query: str
889 ) -> List[Dict[str, Any]]:
890 """
891 Filter GitHub search results for relevance using LLM.
893 Args:
894 previews: List of preview dictionaries
895 query: Original search query
897 Returns:
898 List of relevant preview dictionaries
899 """
900 if not self.llm or not previews:
901 return previews
903 # Create a specialized prompt for GitHub results
904 prompt = f"""Analyze these GitHub search results and rank them by relevance to the query.
905Consider:
9061. Repository stars and activity (higher is better)
9072. Match between query intent and repository description
9083. Repository language and topics
9094. Last update time (more recent is better)
9105. Whether it's a fork (original repositories are preferred)
912Query: "{query}"
914Results:
915{json.dumps(previews, indent=2)}
917Return ONLY a JSON array of indices in order of relevance (most relevant first).
918Example: [0, 2, 1, 3]
919Do not include any other text or explanation."""
921 try:
922 response = self.llm.invoke(prompt)
923 response_text = get_llm_response_text(response)
925 ranked_indices = extract_json(response_text, expected_type=list)
927 if ranked_indices is not None:
928 # Return the results in ranked order, validated and
929 # deduplicated so each preview appears at most once.
930 ranked_results = [
931 previews[idx]
932 for idx in self._valid_unique_indices(
933 ranked_indices, len(previews)
934 )
935 ]
937 # Limit to max_filtered_results if specified
938 if (
939 self.max_filtered_results
940 and len(ranked_results) > self.max_filtered_results
941 ):
942 logger.info(
943 f"Limiting filtered results to top {self.max_filtered_results}"
944 )
945 return ranked_results[: self.max_filtered_results]
947 return ranked_results
948 logger.info(
949 "Could not find JSON array in response, returning no previews"
950 )
951 return []
953 except Exception as e:
954 safe_msg = self._scrub_error(e)
955 logger.warning(f"Error filtering GitHub results: {safe_msg}")
956 return []