Coverage for src/local_deep_research/web_search_engines/engines/search_engine_serpapi.py: 100%

41 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1from ...security.secure_logging import logger 

2from typing import Any, Dict, List, Optional 

3 

4from langchain_community.utilities import SerpAPIWrapper 

5from langchain_core.language_models import BaseLLM 

6 

7from ..rate_limiting import RateLimitError 

8from ..search_engine_base import ( 

9 VALID_TIME_PERIODS, 

10 BaseSearchEngine, 

11 Exposure, 

12 Sensitivity, 

13) 

14 

15 

16class SerpAPISearchEngine(BaseSearchEngine): 

17 """Google search engine implementation using SerpAPI with two-phase approach""" 

18 

19 # Mark as public search engine 

20 is_public = True 

21 egress_sensitivity = Sensitivity.NON_SENSITIVE 

22 egress_exposure = Exposure.EXPOSING 

23 # Mark as generic search engine (general web search via Google) 

24 is_generic = True 

25 

26 def __init__( 

27 self, 

28 max_results: int = 10, 

29 region: str = "us", 

30 time_period: str = "y", 

31 safe_search: bool = True, 

32 search_language: str = "English", 

33 api_key: Optional[str] = None, 

34 language_code_mapping: Optional[Dict[str, str]] = None, 

35 llm: Optional[BaseLLM] = None, 

36 include_full_content: bool = False, 

37 max_filtered_results: Optional[int] = None, 

38 settings_snapshot: Optional[Dict[str, Any]] = None, 

39 **kwargs, 

40 ): 

41 """ 

42 Initialize the SerpAPI search engine. 

43 

44 Args: 

45 max_results: Maximum number of search results 

46 region: Region code for search results 

47 time_period: Time period filter (d/w/m/y); forwarded to Google's 

48 ``tbs`` param as qdr:{d,w,m,y}. ``all`` (or any unrecognized 

49 value) omits the filter. 

50 safe_search: Whether to enable safe search 

51 search_language: Language for search results 

52 api_key: SerpAPI API key (can also be set via LDR_SEARCH_ENGINE_WEB_SERPAPI_API_KEY env var or in UI settings) 

53 language_code_mapping: Mapping from language names to codes 

54 llm: Language model for relevance filtering 

55 include_full_content: Retained for backward compatibility; full-content 

56 retrieval is handled via the factory wrapper. 

57 max_filtered_results: Maximum number of results to keep after filtering 

58 settings_snapshot: Settings snapshot for thread context 

59 **kwargs: Additional parameters (ignored but accepted for compatibility) 

60 """ 

61 # Initialize the BaseSearchEngine with LLM, max_filtered_results, and max_results 

62 super().__init__( 

63 llm=llm, 

64 max_filtered_results=max_filtered_results, 

65 max_results=max_results, 

66 include_full_content=include_full_content, 

67 settings_snapshot=settings_snapshot, 

68 ) 

69 

70 # Set up language code mapping 

71 if language_code_mapping is None: 

72 from ...utilities.search_utilities import LANGUAGE_CODE_MAP 

73 

74 language_code_mapping = LANGUAGE_CODE_MAP 

75 

76 # Get API key - check params, settings, or env vars 

77 serpapi_api_key = self._resolve_api_key( 

78 api_key, 

79 "search.engine.web.serpapi.api_key", 

80 engine_name="SerpAPI", 

81 settings_snapshot=settings_snapshot, 

82 ) 

83 # Store for error-message redaction (BaseSearchEngine._scrub_error 

84 # reads self.api_key via _secret_attrs). SerpAPIWrapper sends the key 

85 # as a URL param the regex pass already catches, but storing it gives 

86 # the literal-redaction pass a belt-and-suspenders for any other shape. 

87 self.api_key = serpapi_api_key 

88 

89 # Get language code 

90 language_code = language_code_mapping.get(search_language.lower(), "en") 

91 

92 # Initialize SerpAPI wrapper 

93 params = { 

94 "engine": "google", 

95 "hl": language_code, 

96 "gl": region, 

97 "safe": "active" if safe_search else "off", 

98 # Google's "num" tops out at 100 results per request; cap so a 

99 # large user-supplied max_results can't request an unbounded page. 

100 # Use the base-class-normalized self.max_results (positive int) 

101 # rather than the raw arg, which could be None/str. 

102 "num": min(self.max_results, 100), 

103 } 

104 # Google's tbs validator accepts qdr:d/w/m/y (h is not one of LDR's 

105 # canonical time_period codes). Only forward recognized codes; "all", 

106 # any unrecognized value (typo, None, "") and non-string types omit 

107 # the param entirely so the search is unfiltered rather than 

108 # silently malformed (e.g. "qdr:all") — the isinstance guard also 

109 # keeps unhashable values from raising on the frozenset lookup. 

110 if isinstance(time_period, str) and time_period in VALID_TIME_PERIODS: 

111 params["tbs"] = f"qdr:{time_period}" 

112 self.engine = SerpAPIWrapper( 

113 serpapi_api_key=serpapi_api_key, 

114 params=params, 

115 ) 

116 

117 # Full-content retrieval is the factory wrapper's job — the 

118 # wrapper fetches pages via ``batch_fetch_and_extract`` and 

119 # populates ``full_content`` on each result; the inner engine 

120 # only emits snippets from ``_get_previews``. 

121 

122 def _get_previews(self, query: str) -> List[Dict[str, Any]]: 

123 """ 

124 Get preview information from SerpAPI. 

125 

126 Args: 

127 query: The search query 

128 

129 Returns: 

130 List of preview dictionaries 

131 """ 

132 logger.info("Getting search results from SerpAPI") 

133 

134 try: 

135 # Get search results from SerpAPI 

136 organic_results = self.engine.results(query).get( 

137 "organic_results", [] 

138 ) 

139 

140 # Format results as previews 

141 previews: list[dict[str, Any]] = [] 

142 for result in organic_results: 

143 preview = { 

144 "id": result.get( 

145 "position", len(previews) 

146 ), # Use position as ID 

147 "title": result.get("title", ""), 

148 "link": result.get("link", ""), 

149 "snippet": result.get("snippet", ""), 

150 "displayed_link": result.get("displayed_link", ""), 

151 "position": result.get("position"), 

152 } 

153 

154 # Store full SerpAPI result for later 

155 preview["_full_result"] = result 

156 

157 previews.append(preview) 

158 

159 # Store the previews for potential full content retrieval 

160 self._search_results = previews 

161 

162 return previews 

163 

164 except RateLimitError: 

165 raise 

166 except Exception as e: 

167 safe_msg = self._scrub_error(e) 

168 logger.warning(f"Error getting SerpAPI results: {safe_msg}") 

169 self._raise_if_rate_limit(e) 

170 return []