Coverage for src/local_deep_research/web_search_engines/engines/search_engine_wikinews.py: 95%

163 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1from datetime import datetime, timedelta, UTC 

2from typing import Any, Dict, List, Optional, Tuple 

3 

4import json 

5import html 

6import re 

7import requests 

8from langchain_core.language_models import BaseLLM 

9 

10from ...constants import USER_AGENT 

11from ...security.secure_logging import logger 

12from ...utilities.json_utils import extract_json, get_llm_response_text 

13from ...utilities.search_utilities import remove_think_tags, LANGUAGE_CODE_MAP 

14from ..search_engine_base import BaseSearchEngine, Exposure, Sensitivity 

15from ...security import safe_get 

16 

17HEADERS = {"User-Agent": USER_AGENT} 

18WIKINEWS_LANGUAGES = [ 

19 "ru", 

20 "sr", 

21 "pt", 

22 "fr", 

23 "pl", 

24 "en", 

25 "zh", 

26 "de", 

27 "it", 

28 "es", 

29 "cs", 

30 "nl", 

31 "ca", 

32 "ar", 

33 "ja", 

34] 

35TIMEOUT = 5 # Seconds 

36TIME_PERIOD_DELTAS = { 

37 "all": None, # No time filter 

38 "y": timedelta(days=365), # 1 year 

39 "m": timedelta(days=30), # 1 month 

40 "w": timedelta(days=7), # 1 week 

41 "d": timedelta(days=1), # 24 hours 

42} 

43DEFAULT_RECENT_BACKWARD_DAYS = 60 

44MAX_RETRIES = 3 

45 

46 

47class WikinewsSearchEngine(BaseSearchEngine): 

48 """Wikinews search engine implementation with LLM query optimization""" 

49 

50 # Mark as public and news search engine 

51 is_public = True 

52 egress_sensitivity = Sensitivity.NON_SENSITIVE 

53 egress_exposure = Exposure.EXPOSING 

54 is_news = True 

55 is_lexical = True 

56 needs_llm_relevance_filter = True 

57 

58 def __init__( 

59 self, 

60 search_language: str = "english", 

61 adaptive_search: bool = True, 

62 time_period: str = "all", 

63 llm: Optional[BaseLLM] = None, 

64 max_filtered_results: Optional[int] = None, 

65 max_results: int = 10, 

66 search_snippets_only: bool = True, 

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

68 **kwargs, 

69 ): 

70 """ 

71 Initialize the Wikinews search engine. 

72 

73 Args: 

74 search_language (str): Language for Wikinews search (e.g. "english"). 

75 adaptive_search (bool): Whether to expand or shrink date ranges based on query. 

76 time_period (str): Defines the look-back window used to filter search results ("all", "y", "m", "w", "d"). 

77 "all" (or any unrecognized value) applies no lower date bound. 

78 llm (Optional[BaseLLM]): Language model used for query optimization and classification. 

79 max_filtered_results (Optional[int]): Maximum number of results to keep after filtering. 

80 max_results (int): Maximum number of search results to return. 

81 search_snippets_only (bool): If True, full article content is ignored. 

82 """ 

83 

84 super().__init__( 

85 llm=llm, 

86 max_filtered_results=max_filtered_results, 

87 max_results=max_results, 

88 search_snippets_only=search_snippets_only, 

89 settings_snapshot=settings_snapshot, 

90 **kwargs, 

91 ) 

92 

93 # Language initialization 

94 lang_code = LANGUAGE_CODE_MAP.get( 

95 search_language.lower(), 

96 "en", # Default to English if not found 

97 ) 

98 

99 if lang_code not in WIKINEWS_LANGUAGES: 99 ↛ 100line 99 didn't jump to line 100 because the condition on line 99 was never true

100 logger.warning( 

101 f"Wikinews does not support language '{search_language}' ({lang_code}). Defaulting to English." 

102 ) 

103 lang_code = "en" 

104 

105 self.lang_code: str = lang_code 

106 

107 # Adaptive search 

108 self.adaptive_search: bool = adaptive_search 

109 

110 # Date range initialization 

111 now = datetime.now(UTC) 

112 # Unrecognized values fall back to "all" (no lower bound), matching 

113 # the tavily/serpapi/brave convention of unfiltered-on-unknown rather 

114 # than silently imposing a year window. 

115 delta = ( 

116 TIME_PERIOD_DELTAS.get(time_period) 

117 if isinstance(time_period, str) 

118 else None 

119 ) 

120 self.from_date: datetime = ( 

121 now - delta if delta else datetime.min.replace(tzinfo=UTC) 

122 ) 

123 self.to_date: datetime = now 

124 

125 # Preserve original date range so adaptive search can restore it 

126 self._original_date_range = (self.from_date, self.to_date) 

127 

128 # API base URL 

129 self.api_url: str = "https://{lang_code}.wikinews.org/w/api.php" 

130 

131 def _optimize_query_for_wikinews(self, query: str) -> str: 

132 """ 

133 Optimize a natural language query for Wikinews search. 

134 Uses LLM to transform questions into effective news search queries. 

135 

136 Args: 

137 query (str): Natural language query 

138 

139 Returns: 

140 Optimized search query for Wikinews 

141 """ 

142 if not self.llm: 

143 return query 

144 

145 try: 

146 # Prompt for query optimization 

147 prompt = f"""You are a query condenser. Your task is to transform the user’s natural-language question into a very short Wikinews search query. 

148 

149Input question: 

150"{query}" 

151 

152STRICT OUTPUT REQUIREMENTS (follow ALL of them): 

1531. Return ONLY a JSON object with EXACTLY one field: {{"query": "<refined_query>"}}. 

1542. The JSON must be valid, minified, and contain no trailing text. 

1553. The refined query must be extremely short: MAXIMUM 3–4 words. 

1564. Include only the essential keywords (proper names, events, entities, places). 

1575. Remove filler words (e.g., "news", "latest", "about", "what", "how", "is"). 

1586. DO NOT add Boolean operators (AND, OR). 

1597. DO NOT use quotes inside the query. 

1608. DO NOT add explanations or comments. 

161 

162EXAMPLES: 

163- "What's the impact of rising interest rates on UK housing market?" → {{"query": "UK housing rates"}} 

164- "Latest developments in the Ukraine-Russia peace negotiations" → {{"query": "Ukraine Russia negotiations"}} 

165- "How are tech companies responding to AI regulation?" → {{"query": "tech AI regulation"}} 

166- "What is Donald Trump's current political activity?" → {{"query": "Trump political activity"}} 

167 

168NOW RETURN ONLY THE JSON OBJECT. 

169""" 

170 # Get response from LLM 

171 response = self.llm.invoke(prompt) 

172 response_text = get_llm_response_text(response) 

173 

174 data = extract_json(response_text, expected_type=dict) 

175 

176 if data is None or not isinstance(data, dict): 

177 raise ValueError("No valid JSON found in response") # noqa: TRY301 — caught by except ValueError to fall back to original query 

178 

179 optimized_query: str = str(data.get("query", "")).strip() 

180 

181 if not optimized_query: 

182 raise ValueError("Query field missing or empty") # noqa: TRY301 — caught by except ValueError to fall back to original query 

183 

184 except ( 

185 ValueError, 

186 TypeError, 

187 AttributeError, 

188 json.JSONDecodeError, 

189 ): 

190 logger.warning( 

191 "Error optimizing query for WikinewsUsing original query." 

192 ) 

193 return query 

194 

195 logger.info(f"Original query: '{query}'") 

196 logger.info(f"Optimized for Wikinews: '{optimized_query}'") 

197 

198 return optimized_query 

199 

200 def _adapt_date_range_for_query(self, query: str) -> None: 

201 """ 

202 Adapt the date range based on the query type (historical vs recent events). 

203 

204 Args: 

205 query (str): The search query 

206 """ 

207 # Reset to original date parameters first 

208 self.from_date, self.to_date = self._original_date_range 

209 

210 if not self.adaptive_search or not self.llm: 

211 return 

212 

213 # Do not adapt for very short queries (no enough context) 

214 if len(query.split()) <= 4: 

215 return 

216 

217 try: 

218 prompt = f"""Classify this query based on temporal scope. 

219 

220Query: "{query}" 

221 

222Current date: {datetime.now(UTC).strftime("%Y-%m-%d")} 

223Cutoff: Events within the last {DEFAULT_RECENT_BACKWARD_DAYS} days are CURRENT 

224 

225Classification rules: 

226- CURRENT: Recent events (last {DEFAULT_RECENT_BACKWARD_DAYS} days), ongoing situations, "latest", "recent", "today", "this week" 

227- HISTORICAL: Events before {DEFAULT_RECENT_BACKWARD_DAYS} days ago, timelines, chronologies, past tense ("what happened", "history of") 

228- UNCLEAR: Ambiguous temporal context 

229 

230Respond with ONE WORD ONLY: CURRENT, HISTORICAL, or UNCLEAR""" 

231 # Get response from LLM 

232 response = self.llm.invoke(prompt) 

233 response_text = ( 

234 getattr(response, "content", None) 

235 or getattr(response, "text", None) 

236 or str(response) 

237 ) 

238 answer = remove_think_tags(response_text).upper() 

239 

240 if "CURRENT" in answer: 

241 # For current events, focus on recent content 

242 logger.info( 

243 f"Query '{query}' classified as CURRENT - focusing on recent content" 

244 ) 

245 self.from_date = datetime.now(UTC) - timedelta( 

246 days=DEFAULT_RECENT_BACKWARD_DAYS 

247 ) 

248 elif "HISTORICAL" in answer: 

249 # For historical queries, go back as far as possible 

250 logger.info( 

251 f"Query '{query}' classified as HISTORICAL - extending search timeframe" 

252 ) 

253 self.from_date = datetime.min.replace(tzinfo=UTC) 

254 else: 

255 logger.info( 

256 f"Query '{query}' classified as UNCLEAR - keeping original date range" 

257 ) 

258 

259 except (AttributeError, TypeError, ValueError, RuntimeError) as e: 

260 # Keep original date parameters on error 

261 safe_msg = self._scrub_error(e) 

262 logger.exception( 

263 f"Error adapting date range for query '{query}' ({type(e).__name__}): keeping original date range: {safe_msg}" 

264 ) 

265 

266 def _fetch_search_results( 

267 self, query: str, sroffset: int 

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

269 """Fetch search results from Wikinews API. 

270 

271 Args: 

272 query (str): The search query. 

273 sroffset (int): The result offset for pagination. 

274 

275 Returns: 

276 List of search result items. 

277 """ 

278 retries = 0 

279 while retries < MAX_RETRIES: 

280 params = { 

281 "action": "query", 

282 "list": "search", 

283 "srsearch": query, 

284 "srprop": "snippet|timestamp", 

285 "srlimit": 50, 

286 "sroffset": sroffset, 

287 "format": "json", 

288 } 

289 

290 # Apply rate limiting before search request 

291 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

292 self.engine_type 

293 ) 

294 

295 try: 

296 response = safe_get( 

297 self.api_url.format(lang_code=self.lang_code), 

298 params=params, 

299 headers=HEADERS, 

300 timeout=TIMEOUT, 

301 ) 

302 response.raise_for_status() 

303 data = response.json() 

304 return data.get("query", {}).get("search", []) # type: ignore[no-any-return] 

305 except ( 

306 requests.exceptions.RequestException, 

307 json.JSONDecodeError, 

308 ): 

309 logger.warning("Error fetching search resultsretrying...") 

310 retries += 1 

311 

312 return [] 

313 

314 def _process_search_result( 

315 self, result: Dict[str, Any], query: str 

316 ) -> Optional[Dict[str, Any]]: 

317 """Process and filter a single search result. 

318 

319 Args: 

320 result (Dict[str, Any]): A single search result item. 

321 query (str): The search query. 

322 

323 Returns: 

324 Processed result or None if filtered out. 

325 """ 

326 page_id = result.get("pageid") 

327 title = result.get("title", "") 

328 snippet = _clean_wikinews_snippet(result.get("snippet", "")) 

329 

330 try: 

331 last_edit_timestamp = result.get("timestamp", "") 

332 last_edit_date = datetime.fromisoformat( 

333 last_edit_timestamp.replace("Z", "+00:00") 

334 ) 

335 except ValueError: 

336 logger.warning( 

337 f"Error parsing last edit date for page {page_id}, using current date as fallback." 

338 ) 

339 last_edit_date = datetime.now(UTC) 

340 

341 # First filter: last edit date must be after from_date 

342 if last_edit_date < self.from_date: 342 ↛ 344line 342 didn't jump to line 344 because the condition on line 342 was never true

343 # In this case we can skip fetching full content 

344 return None 

345 

346 # Fetch full article content and extract actual publication date 

347 # Note: Wikinews API do not allow to retrieve publication date in batched search results 

348 full_content, publication_date = self._fetch_full_content_and_pubdate( 

349 int(page_id) if page_id is not None else 0, last_edit_date 

350 ) 

351 

352 # Second filter: publication date within range 

353 if publication_date < self.from_date or publication_date > self.to_date: 

354 return None 

355 

356 # Third filter: check if all query words are in title or content 

357 # Note: Wikinews search return false positive if query words are in "related" articles section 

358 # Use word boundary matching to avoid substring matches (e.g., "is" matching "This") 

359 combined_text = f"{title} {full_content}".lower() 

360 query_words = [ 

361 w.lower() for w in query.split() if len(w) > 1 

362 ] # Skip single chars 

363 if query_words and not all( 363 ↛ 367line 363 didn't jump to line 367 because the condition on line 363 was never true

364 re.search(rf"\b{re.escape(word)}\b", combined_text) 

365 for word in query_words 

366 ): 

367 return None 

368 

369 # If only snippets are requested, we use snippet as full content 

370 if self.search_snippets_only: 

371 full_content = snippet 

372 

373 return { 

374 "id": page_id, 

375 "title": title, 

376 "snippet": snippet, 

377 "source": "wikinews", 

378 "url": f"https://{self.lang_code}.wikinews.org/?curid={page_id}", # Used by '_filter_for_relevance' function 

379 "link": f"https://{self.lang_code}.wikinews.org/?curid={page_id}", # Used by citation handler 

380 "content": full_content, 

381 "full_content": full_content, 

382 "publication_date": publication_date.isoformat(timespec="seconds"), 

383 } 

384 

385 def _fetch_full_content_and_pubdate( 

386 self, page_id: int, fallback_date: datetime 

387 ) -> Tuple[str, datetime]: 

388 """Fetch full article content and publication date from Wikinews API. 

389 

390 Args: 

391 page_id (int): The Wikinews page ID. 

392 fallback_date (datetime): Fallback date if publication date cannot be determined. 

393 

394 Returns: 

395 Tuple of (full_content, publication_date) 

396 """ 

397 try: 

398 content_params = { 

399 "action": "query", 

400 "prop": "revisions|extracts", 

401 "pageids": page_id, 

402 "rvprop": "timestamp", 

403 "rvdir": "newer", # Older revisions first 

404 "rvlimit": 1, # Get the first revision (i.e. publication) 

405 "explaintext": True, 

406 "format": "json", 

407 } 

408 

409 # Apply rate limiting before content request 

410 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

411 self.engine_type 

412 ) 

413 

414 content_resp = safe_get( 

415 self.api_url.format(lang_code=self.lang_code), 

416 params=content_params, 

417 headers=HEADERS, 

418 timeout=TIMEOUT, 

419 ) 

420 content_resp.raise_for_status() 

421 content_data = content_resp.json() 

422 

423 page_data = ( 

424 content_data.get("query", {}) 

425 .get("pages", {}) 

426 .get(str(page_id), {}) 

427 ) 

428 full_content = page_data.get("extract", "") 

429 revisions = page_data.get("revisions", []) 

430 

431 if revisions: 

432 try: 

433 # First revision timestamp is the publication date 

434 publication_date = datetime.fromisoformat( 

435 revisions[0]["timestamp"].replace("Z", "+00:00") 

436 ) 

437 except ValueError: 

438 logger.warning( 

439 f"Error parsing publication date for page {page_id}, using fallback date." 

440 ) 

441 publication_date = fallback_date 

442 else: 

443 logger.warning( 

444 f"No revisions found for page {page_id}, using fallback date." 

445 ) 

446 publication_date = fallback_date 

447 

448 return full_content, publication_date 

449 

450 except ( 

451 requests.exceptions.RequestException, 

452 json.JSONDecodeError, 

453 ): 

454 logger.warning(f"Error fetching content for page {page_id}") 

455 return "", fallback_date 

456 

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

458 """ 

459 Retrieve article previews from Wikinews based on the query. 

460 

461 Args: 

462 query (str): The search query 

463 

464 Returns: 

465 List of relevant article previews 

466 """ 

467 # Adapt date range based on query and optimize query (if LLM is available) 

468 self._adapt_date_range_for_query(query) 

469 optimized_query = self._optimize_query_for_wikinews(query) 

470 

471 articles: list[dict[str, Any]] = [] 

472 sroffset = 0 

473 

474 while len(articles) < self.max_results: 

475 search_results = self._fetch_search_results( 

476 optimized_query, sroffset 

477 ) 

478 if not search_results: 

479 # No more results available (or multiple retries failed) 

480 break 

481 

482 for result in search_results: 482 ↛ 489line 482 didn't jump to line 489 because the loop on line 482 didn't complete

483 article = self._process_search_result(result, optimized_query) 

484 if article: 484 ↛ 486line 484 didn't jump to line 486 because the condition on line 484 was always true

485 articles.append(article) 

486 if len(articles) >= self.max_results: 486 ↛ 482line 486 didn't jump to line 482 because the condition on line 486 was always true

487 break 

488 

489 sroffset += len(search_results) 

490 

491 return articles 

492 

493 def _get_full_content( 

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

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

496 """ 

497 Retrieve full content for relevant Wikinews articles. 

498 

499 Args: 

500 relevant_items (List[Dict[str, Any]]): List of relevant article previews 

501 

502 Returns: 

503 List of articles with full content 

504 """ 

505 # Since full content is already fetched in _get_previews, just return relevant items 

506 return relevant_items 

507 

508 

509def _clean_wikinews_snippet(snippet: str) -> str: 

510 """ 

511 Clean a Wikinews search snippet. 

512 

513 Args: 

514 snippet (str): Raw snippet from Wikinews API 

515 

516 Returns: 

517 Clean human-readable text 

518 """ 

519 if not snippet: 

520 return "" 

521 

522 # Unescape HTML entities 

523 unescaped = html.unescape(snippet) 

524 

525 # Remove HTML tags 

526 clean_text = re.sub(r"<.*?>", "", unescaped) 

527 

528 # Normalize whitespace 

529 return re.sub(r"\s+", " ", clean_text).strip()