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

165 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +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 

16from ...utilities.llm_utils import invoke_llm_sync 

17 

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

19WIKINEWS_LANGUAGES = [ 

20 "ru", 

21 "sr", 

22 "pt", 

23 "fr", 

24 "pl", 

25 "en", 

26 "zh", 

27 "de", 

28 "it", 

29 "es", 

30 "cs", 

31 "nl", 

32 "ca", 

33 "ar", 

34 "ja", 

35] 

36TIMEOUT = 5 # Seconds 

37TIME_PERIOD_DELTAS = { 

38 "all": None, # No time filter 

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

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

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

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

43} 

44DEFAULT_RECENT_BACKWARD_DAYS = 60 

45MAX_RETRIES = 3 

46 

47 

48class WikinewsSearchEngine(BaseSearchEngine): 

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

50 

51 # Mark as public and news search engine 

52 is_public = True 

53 egress_sensitivity = Sensitivity.NON_SENSITIVE 

54 egress_exposure = Exposure.EXPOSING 

55 is_news = True 

56 is_lexical = True 

57 needs_llm_relevance_filter = True 

58 

59 def __init__( 

60 self, 

61 search_language: str = "english", 

62 adaptive_search: bool = True, 

63 time_period: str = "all", 

64 llm: Optional[BaseLLM] = None, 

65 max_filtered_results: Optional[int] = None, 

66 max_results: int = 10, 

67 search_snippets_only: bool = True, 

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

69 **kwargs, 

70 ): 

71 """ 

72 Initialize the Wikinews search engine. 

73 

74 Args: 

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

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

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

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

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

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

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

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

83 """ 

84 

85 super().__init__( 

86 llm=llm, 

87 max_filtered_results=max_filtered_results, 

88 max_results=max_results, 

89 search_snippets_only=search_snippets_only, 

90 settings_snapshot=settings_snapshot, 

91 **kwargs, 

92 ) 

93 

94 # Language initialization 

95 lang_code = LANGUAGE_CODE_MAP.get( 

96 search_language.lower(), 

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

98 ) 

99 

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

101 logger.warning( 

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

103 ) 

104 lang_code = "en" 

105 

106 self.lang_code: str = lang_code 

107 

108 # Adaptive search 

109 self.adaptive_search: bool = adaptive_search 

110 

111 # Date range initialization 

112 now = datetime.now(UTC) 

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

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

115 # than silently imposing a year window. 

116 delta = ( 

117 TIME_PERIOD_DELTAS.get(time_period) 

118 if isinstance(time_period, str) 

119 else None 

120 ) 

121 self.from_date: datetime = ( 

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

123 ) 

124 self.to_date: datetime = now 

125 

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

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

128 

129 # API base URL 

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

131 

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

133 """ 

134 Optimize a natural language query for Wikinews search. 

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

136 

137 Args: 

138 query (str): Natural language query 

139 

140 Returns: 

141 Optimized search query for Wikinews 

142 """ 

143 if not self.llm: 

144 return query 

145 

146 try: 

147 # Prompt for query optimization 

148 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. 

149 

150Input question: 

151"{query}" 

152 

153STRICT OUTPUT REQUIREMENTS (follow ALL of them): 

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

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

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

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

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

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

1607. DO NOT use quotes inside the query. 

1618. DO NOT add explanations or comments. 

162 

163EXAMPLES: 

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

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

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

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

168 

169NOW RETURN ONLY THE JSON OBJECT. 

170""" 

171 # Get response from LLM 

172 response = invoke_llm_sync(self.llm, prompt) 

173 response_text = get_llm_response_text(response) 

174 

175 data = extract_json(response_text, expected_type=dict) 

176 

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

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

179 

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

181 

182 if not optimized_query: 

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

184 

185 except ( 

186 ValueError, 

187 TypeError, 

188 AttributeError, 

189 json.JSONDecodeError, 

190 ): 

191 logger.warning( 

192 "Error optimizing query for WikinewsUsing original query." 

193 ) 

194 return query 

195 

196 logger.info( 

197 f"Optimized query for Wikinews " 

198 f"({len(query)} -> {len(optimized_query)} chars)" 

199 ) 

200 

201 return optimized_query 

202 

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

204 """ 

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

206 

207 Args: 

208 query (str): The search query 

209 """ 

210 # Reset to original date parameters first 

211 self.from_date, self.to_date = self._original_date_range 

212 

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

214 return 

215 

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

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

218 return 

219 

220 try: 

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

222 

223Query: "{query}" 

224 

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

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

227 

228Classification rules: 

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

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

231- UNCLEAR: Ambiguous temporal context 

232 

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

234 # Get response from LLM 

235 response = invoke_llm_sync(self.llm, prompt) 

236 response_text = ( 

237 getattr(response, "content", None) 

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

239 or str(response) 

240 ) 

241 answer = remove_think_tags(response_text).upper() 

242 

243 if "CURRENT" in answer: 

244 # For current events, focus on recent content 

245 logger.info( 

246 "Query classified as CURRENT - focusing on recent content" 

247 ) 

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

249 days=DEFAULT_RECENT_BACKWARD_DAYS 

250 ) 

251 elif "HISTORICAL" in answer: 

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

253 logger.info( 

254 "Query classified as HISTORICAL - extending search timeframe" 

255 ) 

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

257 else: 

258 logger.info( 

259 "Query classified as UNCLEAR - keeping original date range" 

260 ) 

261 

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

263 # Keep original date parameters on error 

264 safe_msg = self._scrub_error(e) 

265 logger.exception( 

266 f"Error adapting date range ({type(e).__name__}): keeping original date range: {safe_msg}" 

267 ) 

268 

269 def _fetch_search_results( 

270 self, query: str, sroffset: int 

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

272 """Fetch search results from Wikinews API. 

273 

274 Args: 

275 query (str): The search query. 

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

277 

278 Returns: 

279 List of search result items. 

280 """ 

281 retries = 0 

282 while retries < MAX_RETRIES: 

283 params = { 

284 "action": "query", 

285 "list": "search", 

286 "srsearch": query, 

287 "srprop": "snippet|timestamp", 

288 "srlimit": 50, 

289 "sroffset": sroffset, 

290 "format": "json", 

291 } 

292 

293 # Apply rate limiting before search request 

294 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

295 self.engine_type 

296 ) 

297 

298 try: 

299 response = safe_get( 

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

301 params=params, 

302 headers=HEADERS, 

303 timeout=TIMEOUT, 

304 ) 

305 response.raise_for_status() 

306 data = response.json() 

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

308 except ( 

309 requests.exceptions.RequestException, 

310 json.JSONDecodeError, 

311 ) as exc: 

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

313 retries += 1 

314 if retries == MAX_RETRIES: 

315 self._record_search_failure(exc) 

316 

317 return [] 

318 

319 def _process_search_result( 

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

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

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

323 

324 Args: 

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

326 query (str): The search query. 

327 

328 Returns: 

329 Processed result or None if filtered out. 

330 """ 

331 page_id = result.get("pageid") 

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

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

334 

335 try: 

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

337 last_edit_date = datetime.fromisoformat( 

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

339 ) 

340 except ValueError: 

341 logger.warning( 

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

343 ) 

344 last_edit_date = datetime.now(UTC) 

345 

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

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

348 # In this case we can skip fetching full content 

349 return None 

350 

351 # Fetch full article content and extract actual publication date 

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

353 full_content, publication_date = self._fetch_full_content_and_pubdate( 

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

355 ) 

356 

357 # Second filter: publication date within range 

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

359 return None 

360 

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

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

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

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

365 query_words = [ 

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

367 ] # Skip single chars 

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

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

370 for word in query_words 

371 ): 

372 return None 

373 

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

375 if self.search_snippets_only: 

376 full_content = snippet 

377 

378 return { 

379 "id": page_id, 

380 "title": title, 

381 "snippet": snippet, 

382 "source": "wikinews", 

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

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

385 "content": full_content, 

386 "full_content": full_content, 

387 "full_content_is_complete": not self.search_snippets_only 

388 and bool(full_content), 

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

390 } 

391 

392 def _fetch_full_content_and_pubdate( 

393 self, page_id: int, fallback_date: datetime 

394 ) -> Tuple[str, datetime]: 

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

396 

397 Args: 

398 page_id (int): The Wikinews page ID. 

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

400 

401 Returns: 

402 Tuple of (full_content, publication_date) 

403 """ 

404 try: 

405 content_params = { 

406 "action": "query", 

407 "prop": "revisions|extracts", 

408 "pageids": page_id, 

409 "rvprop": "timestamp", 

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

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

412 "explaintext": True, 

413 "format": "json", 

414 } 

415 

416 # Apply rate limiting before content request 

417 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

418 self.engine_type 

419 ) 

420 

421 content_resp = safe_get( 

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

423 params=content_params, 

424 headers=HEADERS, 

425 timeout=TIMEOUT, 

426 ) 

427 content_resp.raise_for_status() 

428 content_data = content_resp.json() 

429 

430 page_data = ( 

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

432 .get("pages", {}) 

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

434 ) 

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

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

437 

438 if revisions: 

439 try: 

440 # First revision timestamp is the publication date 

441 publication_date = datetime.fromisoformat( 

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

443 ) 

444 except ValueError: 

445 logger.warning( 

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

447 ) 

448 publication_date = fallback_date 

449 else: 

450 logger.warning( 

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

452 ) 

453 publication_date = fallback_date 

454 

455 return full_content, publication_date 

456 

457 except ( 

458 requests.exceptions.RequestException, 

459 json.JSONDecodeError, 

460 ): 

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

462 return "", fallback_date 

463 

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

465 """ 

466 Retrieve article previews from Wikinews based on the query. 

467 

468 Args: 

469 query (str): The search query 

470 

471 Returns: 

472 List of relevant article previews 

473 """ 

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

475 self._adapt_date_range_for_query(query) 

476 optimized_query = self._optimize_query_for_wikinews(query) 

477 

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

479 sroffset = 0 

480 

481 while len(articles) < self.max_results: 

482 search_results = self._fetch_search_results( 

483 optimized_query, sroffset 

484 ) 

485 if not search_results: 

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

487 break 

488 

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

490 article = self._process_search_result(result, optimized_query) 

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

492 articles.append(article) 

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

494 break 

495 

496 sroffset += len(search_results) 

497 

498 return articles 

499 

500 def _get_full_content( 

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

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

503 """ 

504 Retrieve full content for relevant Wikinews articles. 

505 

506 Args: 

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

508 

509 Returns: 

510 List of articles with full content 

511 """ 

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

513 return relevant_items 

514 

515 

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

517 """ 

518 Clean a Wikinews search snippet. 

519 

520 Args: 

521 snippet (str): Raw snippet from Wikinews API 

522 

523 Returns: 

524 Clean human-readable text 

525 """ 

526 if not snippet: 

527 return "" 

528 

529 # Unescape HTML entities 

530 unescaped = html.unescape(snippet) 

531 

532 # Remove HTML tags 

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

534 

535 # Normalize whitespace 

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