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

139 statements  

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

1import json 

2from typing import Any, Dict, List, Optional 

3 

4import requests 

5import wikipedia 

6from langchain_core.language_models import BaseLLM 

7 

8from ..engine_availability import SearchFailure 

9from ..search_engine_base import BaseSearchEngine, Exposure, Sensitivity 

10from ...security import sanitize_for_log 

11from ...security.secure_logging import logger 

12 

13 

14# Wikipedia / MediaWiki returns a non-JSON HTML body with HTTP 429 when a 

15# client is rate-limited. The `wikipedia` PyPI library calls 

16# ``Response.json()`` unconditionally, so the symptom we observe is a 

17# ``JSONDecodeError`` on every subsequent ``wikipedia.summary()`` call. 

18# Catch that family explicitly so we can short-circuit instead of 

19# emitting a full traceback per title (the original behaviour spammed 

20# the log with 14+ stack traces for a single rate-limited query). 

21_TRANSIENT_DECODE_ERRORS: tuple = ( 

22 json.JSONDecodeError, 

23 requests.exceptions.JSONDecodeError, 

24) 

25 

26# The ``wikipedia`` library discards the response, so the 429 status is not 

27# available here; classify the decode error as the throttle it almost always 

28# is instead of letting it fall back to a generic one-minute failure. 

29_THROTTLED = SearchFailure( 

30 "Rate limit reached (Wikipedia returned a non-JSON response)", 300 

31) 

32 

33 

34class WikipediaSearchEngine(BaseSearchEngine): 

35 """Wikipedia search engine implementation with two-phase approach""" 

36 

37 # Mark as public search engine 

38 is_public = True 

39 egress_sensitivity = Sensitivity.NON_SENSITIVE 

40 egress_exposure = Exposure.EXPOSING 

41 is_lexical = True 

42 needs_llm_relevance_filter = True 

43 

44 def __init__( 

45 self, 

46 max_results: int = 10, 

47 language: str = "en", 

48 include_content: bool = True, 

49 sentences: int = 5, 

50 llm: Optional[BaseLLM] = None, 

51 max_filtered_results: Optional[int] = None, 

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

53 **kwargs, 

54 ): 

55 """ 

56 Initialize the Wikipedia search engine. 

57 

58 Args: 

59 max_results: Maximum number of search results 

60 language: Language code for Wikipedia (e.g., 'en', 'fr', 'es') 

61 include_content: Whether to include full page content in results 

62 sentences: Number of sentences to include in summary 

63 llm: Language model for relevance filtering 

64 max_filtered_results: Maximum number of results to keep after filtering 

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

66 """ 

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

68 super().__init__( 

69 llm=llm, 

70 max_filtered_results=max_filtered_results, 

71 max_results=max_results, 

72 settings_snapshot=settings_snapshot, 

73 ) 

74 self.include_content = include_content 

75 self.sentences = sentences 

76 

77 # Set the Wikipedia language 

78 wikipedia.set_lang(language) 

79 

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

81 """ 

82 Get preview information (titles and summaries) for Wikipedia pages. 

83 

84 Args: 

85 query: The search query 

86 

87 Returns: 

88 List of preview dictionaries 

89 """ 

90 logger.info("Getting Wikipedia page previews") 

91 

92 try: 

93 # Apply rate limiting before search request 

94 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

95 self.engine_type 

96 ) 

97 

98 # Get search results (just titles) 

99 search_results = wikipedia.search(query, results=self.max_results) 

100 safe_search_results = [ 

101 sanitize_for_log(title) for title in search_results 

102 ] 

103 

104 logger.info( 

105 f"Found {len(search_results)} Wikipedia results: " 

106 f"{safe_search_results}" 

107 ) 

108 

109 if not search_results: 

110 logger.info("No Wikipedia results found") 

111 return [] 

112 

113 # Generate previews with summaries. 

114 # NOTE: This loop is intentionally sequential. Do NOT parallelize with 

115 # ThreadPoolExecutor because: 

116 # 1. The `wikipedia` PyPI library is not thread-safe — it uses global 

117 # mutable state (API_URL, RATE_LIMIT_LAST_CALL) and an unlocked cache. 

118 # Concurrent threads would corrupt the library's built-in rate limiting. 

119 # 2. self._last_wait_time is a shared instance attribute with no lock — 

120 # concurrent writes would feed incorrect data to record_outcome(). 

121 # 3. Downstream _filter_for_relevance uses positional indices — random 

122 # completion order would cause the LLM to select wrong articles. 

123 previews = [] 

124 for title in search_results: 

125 safe_title = sanitize_for_log(title) 

126 try: 

127 # Get just the summary, with auto_suggest=False to be more precise 

128 summary = None 

129 try: 

130 # Apply rate limiting before summary request 

131 self._last_wait_time = ( 

132 self.rate_tracker.apply_rate_limit(self.engine_type) 

133 ) 

134 

135 summary = wikipedia.summary( 

136 title, sentences=self.sentences, auto_suggest=False 

137 ) 

138 except wikipedia.exceptions.DisambiguationError as e: 

139 # If disambiguation error, try the first option 

140 if e.options and len(e.options) > 0: 

141 # Page titles are external content; sanitize 

142 # before interpolation to prevent log injection. 

143 first_option = e.options[0] 

144 safe_first_option = sanitize_for_log(first_option) 

145 logger.info( 

146 f"Disambiguation for '{safe_title}', trying " 

147 f"first option: {safe_first_option}" 

148 ) 

149 try: 

150 summary = wikipedia.summary( 

151 first_option, 

152 sentences=self.sentences, 

153 auto_suggest=False, 

154 ) 

155 title = first_option # Use the new title 

156 except Exception as inner_e: 

157 safe_msg = self._scrub_error(inner_e) 

158 logger.exception( 

159 f"Error with disambiguation option ({type(inner_e).__name__}): {safe_msg}" 

160 ) 

161 continue 

162 else: 

163 logger.warning( 

164 f"Disambiguation with no options for '{safe_title}'" 

165 ) 

166 continue 

167 

168 if summary: 

169 preview = { 

170 "id": title, # Use title as ID 

171 "title": title, 

172 "snippet": summary, 

173 "link": f"https://en.wikipedia.org/wiki/{title.replace(' ', '_')}", 

174 "source": "Wikipedia", 

175 } 

176 

177 previews.append(preview) 

178 

179 except ( 

180 wikipedia.exceptions.PageError, 

181 wikipedia.exceptions.WikipediaException, 

182 ): 

183 # Skip pages with errors 

184 logger.warning(f"Error getting summary for '{safe_title}'") 

185 continue 

186 except _TRANSIENT_DECODE_ERRORS: 

187 self.last_search_failure = _THROTTLED 

188 # MediaWiki almost certainly returned a 429 (or other 

189 # non-JSON page) — every remaining title in this batch 

190 # will hit the same throttle. Bail out with one warning 

191 # instead of a per-title traceback storm. 

192 logger.warning( 

193 "Wikipedia rate-limited (non-JSON response while " 

194 "fetching '{}'); returning {} previews collected so far", 

195 safe_title, 

196 len(previews), 

197 ) 

198 break 

199 except Exception as e: 

200 self._record_search_failure(e) 

201 safe_msg = self._scrub_error(e) 

202 logger.exception( 

203 f"Unexpected error for '{safe_title}' " 

204 f"({type(e).__name__}): {safe_msg}" 

205 ) 

206 continue 

207 

208 logger.info( 

209 f"Successfully created {len(previews)} previews from Wikipedia" 

210 ) 

211 return previews 

212 

213 except _TRANSIENT_DECODE_ERRORS: 

214 self.last_search_failure = _THROTTLED 

215 # Same 429-style failure on the outer wikipedia.search() call — 

216 # log once at warning level and return an empty list. 

217 logger.warning( 

218 "Wikipedia rate-limited on search; returning no previews" 

219 ) 

220 return [] 

221 except Exception as e: 

222 self._record_search_failure(e) 

223 safe_msg = self._scrub_error(e) 

224 logger.exception( 

225 f"Error getting Wikipedia previews ({type(e).__name__}): {safe_msg}" 

226 ) 

227 return [] 

228 

229 def _get_full_content( 

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

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

232 """ 

233 Get full content for the relevant Wikipedia pages. 

234 

235 Args: 

236 relevant_items: List of relevant preview dictionaries 

237 

238 Returns: 

239 List of result dictionaries with full content 

240 """ 

241 logger.info( 

242 f"Getting full content for {len(relevant_items)} relevant Wikipedia pages" 

243 ) 

244 

245 results = [] 

246 for item in relevant_items: 

247 title = item.get("id") # Title stored as ID 

248 

249 if not title: 

250 results.append(item) 

251 continue 

252 

253 safe_title = sanitize_for_log(str(title)) 

254 try: 

255 # Apply rate limiting before page request 

256 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

257 self.engine_type 

258 ) 

259 

260 # Get the full page 

261 page = wikipedia.page(title, auto_suggest=False) 

262 

263 # Create a full result with all information 

264 result = { 

265 "title": page.title, 

266 "link": page.url, 

267 "snippet": item.get("snippet", ""), # Keep existing snippet 

268 "source": "Wikipedia", 

269 } 

270 

271 # Add additional information 

272 result["content"] = page.content 

273 result["full_content"] = page.content 

274 result["full_content_is_complete"] = True 

275 result["categories"] = page.categories 

276 result["references"] = page.references 

277 result["links"] = page.links 

278 result["images"] = page.images 

279 result["sections"] = page.sections 

280 

281 results.append(result) 

282 

283 except ( 

284 wikipedia.exceptions.DisambiguationError, 

285 wikipedia.exceptions.PageError, 

286 wikipedia.exceptions.WikipediaException, 

287 ): 

288 # If error, use the preview 

289 logger.warning(f"Error getting full content for '{safe_title}'") 

290 results.append(item) 

291 except Exception as e: 

292 safe_msg = self._scrub_error(e) 

293 logger.exception( 

294 f"Unexpected error getting full content for " 

295 f"'{safe_title}' ({type(e).__name__}): {safe_msg}" 

296 ) 

297 results.append(item) 

298 

299 return results 

300 

301 def get_summary(self, title: str, sentences: Optional[int] = None) -> str: 

302 """ 

303 Get a summary of a specific Wikipedia page. 

304 

305 Args: 

306 title: Title of the Wikipedia page 

307 sentences: Number of sentences to include (defaults to self.sentences) 

308 

309 Returns: 

310 Summary of the page 

311 """ 

312 sentences = sentences or self.sentences 

313 try: 

314 return str( 

315 wikipedia.summary( 

316 title, sentences=sentences, auto_suggest=False 

317 ) 

318 ) 

319 except wikipedia.exceptions.DisambiguationError as e: 

320 if e.options and len(e.options) > 0: 

321 return str( 

322 wikipedia.summary( 

323 e.options[0], sentences=sentences, auto_suggest=False 

324 ) 

325 ) 

326 raise 

327 

328 def get_page(self, title: str) -> Dict[str, Any]: 

329 """ 

330 Get detailed information about a specific Wikipedia page. 

331 

332 Args: 

333 title: Title of the Wikipedia page 

334 

335 Returns: 

336 Dictionary with page information 

337 """ 

338 include_content = self.include_content 

339 

340 try: 

341 page = wikipedia.page(title, auto_suggest=False) 

342 

343 result = { 

344 "title": page.title, 

345 "link": page.url, 

346 "snippet": self.get_summary(title, self.sentences), 

347 "source": "Wikipedia", 

348 } 

349 

350 # Add additional information if requested 

351 if include_content: 

352 result["content"] = page.content 

353 result["full_content"] = page.content 

354 result["full_content_is_complete"] = True 

355 result["categories"] = page.categories 

356 result["references"] = page.references 

357 result["links"] = page.links 

358 result["images"] = page.images 

359 result["sections"] = page.sections 

360 

361 return result 

362 except wikipedia.exceptions.DisambiguationError as e: 

363 if e.options and len(e.options) > 0: 

364 return self.get_page(e.options[0]) 

365 raise 

366 

367 def set_language(self, language: str) -> None: 

368 """ 

369 Change the Wikipedia language. 

370 

371 Args: 

372 language: Language code (e.g., 'en', 'fr', 'es') 

373 """ 

374 wikipedia.set_lang(language)