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

163 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 01:24 +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 = "y", 

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 llm (Optional[BaseLLM]): Language model used for query optimization and classification. 

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

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

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

81 """ 

82 

83 super().__init__( 

84 llm=llm, 

85 max_filtered_results=max_filtered_results, 

86 max_results=max_results, 

87 search_snippets_only=search_snippets_only, 

88 settings_snapshot=settings_snapshot, 

89 **kwargs, 

90 ) 

91 

92 # Language initialization 

93 lang_code = LANGUAGE_CODE_MAP.get( 

94 search_language.lower(), 

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

96 ) 

97 

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

99 logger.warning( 

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

101 ) 

102 lang_code = "en" 

103 

104 self.lang_code: str = lang_code 

105 

106 # Adaptive search 

107 self.adaptive_search: bool = adaptive_search 

108 

109 # Date range initialization 

110 now = datetime.now(UTC) 

111 delta = TIME_PERIOD_DELTAS.get(time_period, timedelta(days=365)) 

112 self.from_date: datetime = ( 

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

114 ) 

115 self.to_date: datetime = now 

116 

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

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

119 

120 # API base URL 

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

122 

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

124 """ 

125 Optimize a natural language query for Wikinews search. 

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

127 

128 Args: 

129 query (str): Natural language query 

130 

131 Returns: 

132 Optimized search query for Wikinews 

133 """ 

134 if not self.llm: 

135 return query 

136 

137 try: 

138 # Prompt for query optimization 

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

140 

141Input question: 

142"{query}" 

143 

144STRICT OUTPUT REQUIREMENTS (follow ALL of them): 

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

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

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

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

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

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

1517. DO NOT use quotes inside the query. 

1528. DO NOT add explanations or comments. 

153 

154EXAMPLES: 

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

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

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

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

159 

160NOW RETURN ONLY THE JSON OBJECT. 

161""" 

162 # Get response from LLM 

163 response = self.llm.invoke(prompt) 

164 response_text = get_llm_response_text(response) 

165 

166 data = extract_json(response_text, expected_type=dict) 

167 

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

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

170 

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

172 

173 if not optimized_query: 

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

175 

176 except ( 

177 ValueError, 

178 TypeError, 

179 AttributeError, 

180 json.JSONDecodeError, 

181 ): 

182 logger.warning( 

183 "Error optimizing query for WikinewsUsing original query." 

184 ) 

185 return query 

186 

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

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

189 

190 return optimized_query 

191 

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

193 """ 

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

195 

196 Args: 

197 query (str): The search query 

198 """ 

199 # Reset to original date parameters first 

200 self.from_date, self.to_date = self._original_date_range 

201 

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

203 return 

204 

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

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

207 return 

208 

209 try: 

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

211 

212Query: "{query}" 

213 

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

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

216 

217Classification rules: 

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

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

220- UNCLEAR: Ambiguous temporal context 

221 

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

223 # Get response from LLM 

224 response = self.llm.invoke(prompt) 

225 response_text = ( 

226 getattr(response, "content", None) 

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

228 or str(response) 

229 ) 

230 answer = remove_think_tags(response_text).upper() 

231 

232 if "CURRENT" in answer: 

233 # For current events, focus on recent content 

234 logger.info( 

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

236 ) 

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

238 days=DEFAULT_RECENT_BACKWARD_DAYS 

239 ) 

240 elif "HISTORICAL" in answer: 

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

242 logger.info( 

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

244 ) 

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

246 else: 

247 logger.info( 

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

249 ) 

250 

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

252 # Keep original date parameters on error 

253 safe_msg = self._scrub_error(e) 

254 logger.exception( 

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

256 ) 

257 

258 def _fetch_search_results( 

259 self, query: str, sroffset: int 

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

261 """Fetch search results from Wikinews API. 

262 

263 Args: 

264 query (str): The search query. 

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

266 

267 Returns: 

268 List of search result items. 

269 """ 

270 retries = 0 

271 while retries < MAX_RETRIES: 

272 params = { 

273 "action": "query", 

274 "list": "search", 

275 "srsearch": query, 

276 "srprop": "snippet|timestamp", 

277 "srlimit": 50, 

278 "sroffset": sroffset, 

279 "format": "json", 

280 } 

281 

282 # Apply rate limiting before search request 

283 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

284 self.engine_type 

285 ) 

286 

287 try: 

288 response = safe_get( 

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

290 params=params, 

291 headers=HEADERS, 

292 timeout=TIMEOUT, 

293 ) 

294 response.raise_for_status() 

295 data = response.json() 

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

297 except ( 

298 requests.exceptions.RequestException, 

299 json.JSONDecodeError, 

300 ): 

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

302 retries += 1 

303 

304 return [] 

305 

306 def _process_search_result( 

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

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

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

310 

311 Args: 

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

313 query (str): The search query. 

314 

315 Returns: 

316 Processed result or None if filtered out. 

317 """ 

318 page_id = result.get("pageid") 

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

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

321 

322 try: 

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

324 last_edit_date = datetime.fromisoformat( 

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

326 ) 

327 except ValueError: 

328 logger.warning( 

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

330 ) 

331 last_edit_date = datetime.now(UTC) 

332 

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

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

335 # In this case we can skip fetching full content 

336 return None 

337 

338 # Fetch full article content and extract actual publication date 

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

340 full_content, publication_date = self._fetch_full_content_and_pubdate( 

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

342 ) 

343 

344 # Second filter: publication date within range 

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

346 return None 

347 

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

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

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

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

352 query_words = [ 

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

354 ] # Skip single chars 

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

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

357 for word in query_words 

358 ): 

359 return None 

360 

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

362 if self.search_snippets_only: 

363 full_content = snippet 

364 

365 return { 

366 "id": page_id, 

367 "title": title, 

368 "snippet": snippet, 

369 "source": "wikinews", 

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

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

372 "content": full_content, 

373 "full_content": full_content, 

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

375 } 

376 

377 def _fetch_full_content_and_pubdate( 

378 self, page_id: int, fallback_date: datetime 

379 ) -> Tuple[str, datetime]: 

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

381 

382 Args: 

383 page_id (int): The Wikinews page ID. 

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

385 

386 Returns: 

387 Tuple of (full_content, publication_date) 

388 """ 

389 try: 

390 content_params = { 

391 "action": "query", 

392 "prop": "revisions|extracts", 

393 "pageids": page_id, 

394 "rvprop": "timestamp", 

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

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

397 "explaintext": True, 

398 "format": "json", 

399 } 

400 

401 # Apply rate limiting before content request 

402 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

403 self.engine_type 

404 ) 

405 

406 content_resp = safe_get( 

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

408 params=content_params, 

409 headers=HEADERS, 

410 timeout=TIMEOUT, 

411 ) 

412 content_resp.raise_for_status() 

413 content_data = content_resp.json() 

414 

415 page_data = ( 

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

417 .get("pages", {}) 

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

419 ) 

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

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

422 

423 if revisions: 

424 try: 

425 # First revision timestamp is the publication date 

426 publication_date = datetime.fromisoformat( 

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

428 ) 

429 except ValueError: 

430 logger.warning( 

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

432 ) 

433 publication_date = fallback_date 

434 else: 

435 logger.warning( 

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

437 ) 

438 publication_date = fallback_date 

439 

440 return full_content, publication_date 

441 

442 except ( 

443 requests.exceptions.RequestException, 

444 json.JSONDecodeError, 

445 ): 

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

447 return "", fallback_date 

448 

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

450 """ 

451 Retrieve article previews from Wikinews based on the query. 

452 

453 Args: 

454 query (str): The search query 

455 

456 Returns: 

457 List of relevant article previews 

458 """ 

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

460 self._adapt_date_range_for_query(query) 

461 optimized_query = self._optimize_query_for_wikinews(query) 

462 

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

464 sroffset = 0 

465 

466 while len(articles) < self.max_results: 

467 search_results = self._fetch_search_results( 

468 optimized_query, sroffset 

469 ) 

470 if not search_results: 

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

472 break 

473 

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

475 article = self._process_search_result(result, optimized_query) 

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

477 articles.append(article) 

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

479 break 

480 

481 sroffset += len(search_results) 

482 

483 return articles 

484 

485 def _get_full_content( 

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

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

488 """ 

489 Retrieve full content for relevant Wikinews articles. 

490 

491 Args: 

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

493 

494 Returns: 

495 List of articles with full content 

496 """ 

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

498 return relevant_items 

499 

500 

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

502 """ 

503 Clean a Wikinews search snippet. 

504 

505 Args: 

506 snippet (str): Raw snippet from Wikinews API 

507 

508 Returns: 

509 Clean human-readable text 

510 """ 

511 if not snippet: 

512 return "" 

513 

514 # Unescape HTML entities 

515 unescaped = html.unescape(snippet) 

516 

517 # Remove HTML tags 

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

519 

520 # Normalize whitespace 

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