Coverage for src/local_deep_research/web_search_engines/engines/search_engine_tavily.py: 99%

65 statements  

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

1from typing import Any, Dict, List, Optional 

2 

3import requests 

4from langchain_core.language_models import BaseLLM 

5from ...security.secure_logging import logger 

6 

7from ...security.safe_requests import safe_post 

8from ..rate_limiting import RateLimitError 

9from ..search_engine_base import BaseSearchEngine, Exposure, Sensitivity 

10 

11# Map LDR time_period codes to Tavily API time_range values. 

12# "all" and any unrecognized value (None, "", wrong case, whitespace, 

13# unknown strings) omit time_range so no filter is applied. The settings 

14# UI only emits the canonical d/w/m/y/all codes; programmatic callers 

15# must match exactly — we deliberately do NOT .lower()/strip() so a 

16# mistyped value fails visibly as "unfiltered" rather than silently 

17# being coerced. 

18_TIME_RANGE_MAP = {"d": "day", "w": "week", "m": "month", "y": "year"} 

19 

20 

21class TavilySearchEngine(BaseSearchEngine): 

22 """Tavily search engine implementation with two-phase approach""" 

23 

24 # Mark as public search engine 

25 is_public = True 

26 egress_sensitivity = Sensitivity.NON_SENSITIVE 

27 egress_exposure = Exposure.EXPOSING 

28 # Mark as generic search engine (general web search) 

29 is_generic = True 

30 

31 def __init__( 

32 self, 

33 max_results: int = 10, 

34 region: str = "US", 

35 time_period: str = "all", 

36 safe_search: bool = True, 

37 search_language: str = "English", 

38 api_key: Optional[str] = None, 

39 llm: Optional[BaseLLM] = None, 

40 include_full_content: bool = True, 

41 max_filtered_results: Optional[int] = None, 

42 search_depth: str = "basic", 

43 include_domains: Optional[List[str]] = None, 

44 exclude_domains: Optional[List[str]] = None, 

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

46 **kwargs, 

47 ): 

48 """ 

49 Initialize the Tavily search engine. 

50 

51 Args: 

52 max_results: Maximum number of search results 

53 region: Region code for search results (not used by Tavily currently) 

54 time_period: Time period filter (d/w/m/y/all); forwarded to the 

55 Tavily ``time_range`` param as day/week/month/year. ``all`` 

56 (or any unrecognized value) omits the filter. 

57 safe_search: Whether to enable safe search (not used by Tavily currently) 

58 search_language: Language for search results (not used by Tavily currently) 

59 api_key: Tavily API key (can also be set via LDR_SEARCH_ENGINE_WEB_TAVILY_API_KEY env var or in UI settings) 

60 llm: Language model for relevance filtering 

61 include_full_content: Retained for backward compatibility; full-content 

62 retrieval is handled via the factory wrapper. 

63 max_filtered_results: Maximum number of results to keep after filtering 

64 search_depth: "basic" or "advanced" - controls search quality vs speed 

65 include_domains: List of domains to include in search 

66 exclude_domains: List of domains to exclude from search 

67 settings_snapshot: Settings snapshot for thread context 

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

69 """ 

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

71 super().__init__( 

72 llm=llm, 

73 max_filtered_results=max_filtered_results, 

74 max_results=max_results, 

75 include_full_content=include_full_content, 

76 settings_snapshot=settings_snapshot, 

77 ) 

78 self.search_depth = search_depth 

79 self.time_period = time_period 

80 self.include_domains = include_domains or [] 

81 self.exclude_domains = exclude_domains or [] 

82 

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

84 tavily_api_key = self._resolve_api_key( 

85 api_key, 

86 "search.engine.web.tavily.api_key", 

87 engine_name="Tavily", 

88 settings_snapshot=settings_snapshot, 

89 ) 

90 

91 self.api_key = tavily_api_key 

92 self.base_url = "https://api.tavily.com" 

93 

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

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

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

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

98 

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

100 """ 

101 Get preview information from Tavily Search. 

102 

103 Args: 

104 query: The search query 

105 

106 Returns: 

107 List of preview dictionaries 

108 """ 

109 logger.info("Getting search results from Tavily") 

110 

111 try: 

112 # Prepare the request payload 

113 payload = { 

114 "api_key": self.api_key, 

115 "query": query[:400], # Limit query length 

116 "search_depth": self.search_depth, 

117 "max_results": min( 

118 20, self.max_results 

119 ), # Tavily has a max limit 

120 "include_answer": False, # We don't need the AI answer 

121 "include_images": False, # We don't need images 

122 "include_raw_content": self.include_full_content, # Get content if requested 

123 } 

124 

125 # Add domain filters if specified 

126 if self.include_domains: 

127 payload["include_domains"] = self.include_domains 

128 if self.exclude_domains: 

129 payload["exclude_domains"] = self.exclude_domains 

130 

131 # Apply time-period filter (Tavily time_range). "all" / unknown 

132 # map to None and are omitted so no filter is applied. Non-string 

133 # values (possible via programmatic construction) are treated as 

134 # "no filter" rather than raising on the unhashable dict lookup. 

135 time_range = ( 

136 _TIME_RANGE_MAP.get(self.time_period) 

137 if isinstance(self.time_period, str) 

138 else None 

139 ) 

140 if time_range: 

141 payload["time_range"] = time_range 

142 

143 # Apply rate limiting before request 

144 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

145 self.engine_type 

146 ) 

147 

148 # Make the API request 

149 response = safe_post( 

150 f"{self.base_url}/search", 

151 json=payload, 

152 headers={"Content-Type": "application/json"}, 

153 timeout=30, 

154 ) 

155 

156 # Check for rate limits 

157 self._raise_if_rate_limit(response.status_code) 

158 

159 response.raise_for_status() 

160 

161 # Parse the response 

162 data = response.json() 

163 results = data.get("results", []) 

164 

165 # Format results as previews 

166 previews = [] 

167 for i, result in enumerate(results): 

168 url = self._clean_result_url(result.get("url")) 

169 preview = { 

170 "id": url or str(i), # Use URL as ID 

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

172 "link": url, 

173 "snippet": result.get( 

174 "content", "" 

175 ), # Tavily calls it "content" 

176 "displayed_link": url, 

177 "position": i, 

178 } 

179 

180 # Store full Tavily result for later 

181 preview["_full_result"] = result 

182 

183 previews.append(preview) 

184 

185 # Store the previews for potential full content retrieval 

186 self._search_results = previews 

187 

188 return previews 

189 

190 except RateLimitError: 

191 raise # Re-raise rate limit errors 

192 except requests.exceptions.RequestException as e: 

193 safe_msg = self._scrub_error(e) 

194 logger.warning(f"Error getting Tavily results: {safe_msg}") 

195 self._raise_if_rate_limit(e) 

196 return [] 

197 except Exception as e: 

198 safe_msg = self._scrub_error(e) 

199 logger.warning( 

200 f"Unexpected error getting Tavily results: {safe_msg}" 

201 ) 

202 return [] 

203 

204 def _get_full_content( 

205 self, relevant_items: List[Dict[str, Any]] 

206 ) -> List[Dict[str, Any]]: 

207 """ 

208 Get full content for the relevant search results. 

209 Extends base implementation to include Tavily's raw_content. 

210 

211 Args: 

212 relevant_items: List of relevant preview dictionaries 

213 

214 Returns: 

215 List of result dictionaries with full content if available 

216 """ 

217 results = super()._get_full_content(relevant_items) 

218 

219 # If Tavily provided raw_content and full content is requested, use it 

220 if self.include_full_content: 

221 for result in results: 

222 if "raw_content" in result: 222 ↛ 221line 222 didn't jump to line 221 because the condition on line 222 was always true

223 result["content"] = result.get( 

224 "raw_content", result.get("content", "") 

225 ) 

226 

227 return results