Coverage for src/local_deep_research/web_search_engines/engines/search_engine_openalex.py: 93%

181 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-20 01:24 +0000

1"""OpenAlex search engine implementation for academic papers and research.""" 

2 

3from typing import Any, Dict, List, Optional 

4 

5from langchain_core.language_models import BaseLLM 

6 

7from ...constants import SNIPPET_LENGTH_LONG, USER_AGENT 

8from ...security.safe_requests import safe_get 

9from ...security.secure_logging import logger 

10from ..rate_limiting import RateLimitError 

11from ..search_engine_base import BaseSearchEngine, Exposure, Sensitivity 

12 

13 

14class OpenAlexSearchEngine(BaseSearchEngine): 

15 """OpenAlex search engine implementation with natural language query support.""" 

16 

17 # Mark as public search engine 

18 is_public = True 

19 egress_sensitivity = Sensitivity.NON_SENSITIVE 

20 egress_exposure = Exposure.EXPOSING 

21 # Scientific/academic search engine 

22 is_scientific = True 

23 is_lexical = True 

24 needs_llm_relevance_filter = True 

25 

26 def __init__( 

27 self, 

28 max_results: int = 25, 

29 email: Optional[str] = None, 

30 sort_by: str = "relevance", 

31 filter_open_access: bool = False, 

32 min_citations: int = 0, 

33 from_publication_date: Optional[str] = None, 

34 llm: Optional[BaseLLM] = None, 

35 max_filtered_results: Optional[int] = None, 

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

37 **kwargs, 

38 ): 

39 """ 

40 Initialize the OpenAlex search engine. 

41 

42 Args: 

43 max_results: Maximum number of search results 

44 email: Email for polite pool (gets faster response) - optional 

45 sort_by: Sort order ('relevance', 'cited_by_count', 'publication_date') 

46 filter_open_access: Only return open access papers 

47 min_citations: Minimum citation count filter 

48 from_publication_date: Filter papers from this date (YYYY-MM-DD) 

49 llm: Language model for relevance filtering 

50 max_filtered_results: Maximum number of results to keep after filtering 

51 settings_snapshot: Settings snapshot for configuration 

52 **kwargs: Additional parameters to pass to parent class 

53 """ 

54 # Journal filter runs before LLM relevance (Tiers 1-3 are instant) 

55 preview_filters = [] 

56 journal_filter = self._create_journal_filter( 

57 "openalex", llm, settings_snapshot 

58 ) 

59 if journal_filter is not None: 

60 preview_filters.append(journal_filter) 

61 

62 super().__init__( 

63 llm=llm, 

64 max_filtered_results=max_filtered_results, 

65 max_results=max_results, 

66 preview_filters=preview_filters, # type: ignore[arg-type] 

67 settings_snapshot=settings_snapshot, 

68 **kwargs, 

69 ) 

70 

71 self.sort_by = sort_by 

72 self.filter_open_access = filter_open_access 

73 self.min_citations = min_citations 

74 # Only set from_publication_date if it's not empty or "False" 

75 self.from_publication_date = ( 

76 from_publication_date 

77 if from_publication_date and from_publication_date != "False" 

78 else None 

79 ) 

80 

81 # Get email from settings if not provided 

82 if not email and settings_snapshot: 

83 from ...config.search_config import get_setting_from_snapshot 

84 

85 try: 

86 email = get_setting_from_snapshot( 

87 "search.engine.web.openalex.email", 

88 settings_snapshot=settings_snapshot, 

89 ) 

90 except Exception: 

91 logger.debug( 

92 "Failed to read openalex.email from settings snapshot", 

93 exc_info=True, 

94 ) 

95 

96 # Handle "False" string for email 

97 self.email = email if email and email != "False" else None 

98 

99 # API configuration 

100 self.api_base = "https://api.openalex.org" 

101 self.headers = { 

102 "User-Agent": f"{USER_AGENT} ({email})" if email else USER_AGENT, 

103 "Accept": "application/json", 

104 } 

105 

106 if email: 

107 # Email allows access to polite pool with faster response times 

108 logger.info(f"Using OpenAlex polite pool with email: {email}") 

109 else: 

110 logger.info( 

111 "Using OpenAlex without email (consider adding email for faster responses)" 

112 ) 

113 

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

115 """ 

116 Get preview information for OpenAlex search results. 

117 

118 Args: 

119 query: The search query (natural language supported!) 

120 

121 Returns: 

122 List of preview dictionaries 

123 """ 

124 logger.info(f"Searching OpenAlex for: {query}") 

125 

126 # Build the search URL with parameters 

127 params = { 

128 "search": query, # OpenAlex handles natural language beautifully 

129 "per_page": min(self.max_results, 200), # OpenAlex allows up to 200 

130 "page": 1, 

131 # Request specific fields including abstract for snippets 

132 "select": "id,display_name,publication_year,publication_date,doi,primary_location,authorships,cited_by_count,open_access,best_oa_location,abstract_inverted_index", 

133 } 

134 

135 # Add optional filters 

136 filters = [] 

137 

138 if self.filter_open_access: 

139 filters.append("is_oa:true") 

140 

141 if self.min_citations > 0: 

142 filters.append(f"cited_by_count:>{self.min_citations}") 

143 

144 if self.from_publication_date and self.from_publication_date != "False": 

145 filters.append( 

146 f"from_publication_date:{self.from_publication_date}" 

147 ) 

148 

149 if filters: 

150 params["filter"] = ",".join(filters) 

151 

152 # Add sorting 

153 sort_map = { 

154 "relevance": "relevance_score:desc", 

155 "cited_by_count": "cited_by_count:desc", 

156 "publication_date": "publication_date:desc", 

157 } 

158 params["sort"] = sort_map.get(self.sort_by, "relevance_score:desc") 

159 

160 # Add email to params for polite pool 

161 if self.email and self.email != "False": 

162 params["mailto"] = self.email 

163 

164 try: 

165 # Apply rate limiting before making the request (simple like PubMed) 

166 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

167 self.engine_type 

168 ) 

169 logger.debug( 

170 f"Applied rate limit wait: {self._last_wait_time:.2f}s" 

171 ) 

172 

173 # Make the API request 

174 logger.info(f"Making OpenAlex API request with params: {params}") 

175 response = safe_get( 

176 f"{self.api_base}/works", 

177 params=params, 

178 headers=self.headers, 

179 timeout=30, 

180 ) 

181 logger.info(f"OpenAlex API response status: {response.status_code}") 

182 

183 # Log rate limit info if available 

184 if "x-ratelimit-remaining" in response.headers: 

185 remaining = response.headers.get("x-ratelimit-remaining") 

186 limit = response.headers.get("x-ratelimit-limit", "unknown") 

187 logger.debug( 

188 f"OpenAlex rate limit: {remaining}/{limit} requests remaining" 

189 ) 

190 

191 if response.status_code == 200: 

192 data = response.json() 

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

194 meta = data.get("meta", {}) 

195 total_count = meta.get("count", 0) 

196 

197 logger.info( 

198 f"OpenAlex returned {len(results)} results (total available: {total_count:,})" 

199 ) 

200 

201 # Log first result structure for debugging 

202 if results: 

203 first_result = results[0] 

204 logger.debug( 

205 f"First result keys: {list(first_result.keys())}" 

206 ) 

207 logger.debug( 

208 f"First result has abstract: {'abstract_inverted_index' in first_result}" 

209 ) 

210 if "open_access" in first_result: 

211 logger.debug( 

212 f"Open access structure: {first_result['open_access']}" 

213 ) 

214 

215 # Format results as previews 

216 previews = [] 

217 for i, work in enumerate(results): 

218 logger.debug( 

219 f"Formatting work {i + 1}/{len(results)}: {(work.get('display_name') or 'Unknown')[:50]}" 

220 ) 

221 preview = self._format_work_preview(work) 

222 if preview: 

223 previews.append(preview) 

224 logger.debug( 

225 f"Preview created with snippet: {preview.get('snippet', '')[:100]}..." 

226 ) 

227 else: 

228 logger.warning(f"Failed to format work {i + 1}") 

229 

230 logger.info( 

231 f"Successfully formatted {len(previews)} previews from {len(results)} results" 

232 ) 

233 return previews 

234 

235 if response.status_code == 429: 

236 # Rate limited (very rare with OpenAlex) 

237 logger.warning("OpenAlex rate limit reached") 

238 raise RateLimitError("OpenAlex rate limit exceeded") # noqa: TRY301 — re-raised by except RateLimitError for base class retry 

239 

240 logger.error( 

241 f"OpenAlex API error: {response.status_code} - {response.text[:200]}" 

242 ) 

243 return [] 

244 

245 except RateLimitError: 

246 # Re-raise rate limit errors for base class retry handling 

247 raise 

248 except Exception as e: 

249 safe_msg = self._scrub_error(e) 

250 logger.exception( 

251 f"Error searching OpenAlex ({type(e).__name__}): {safe_msg}" 

252 ) 

253 return [] 

254 

255 def _format_work_preview( 

256 self, work: Dict[str, Any] 

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

258 """ 

259 Format an OpenAlex work as a preview dictionary. 

260 

261 Args: 

262 work: OpenAlex work object 

263 

264 Returns: 

265 Formatted preview dictionary or None if formatting fails 

266 """ 

267 try: 

268 # Extract basic information 

269 # Use `or` instead of dict.get default — OpenAlex routinely 

270 # returns these keys with explicit None values, which would 

271 # bypass the default and crash on downstream string ops. 

272 work_id = work.get("id") or "" 

273 title = work.get("display_name") or "No title" 

274 logger.debug(f"Formatting work: {title[:50]}") 

275 

276 # Build snippet from abstract or first part of title 

277 abstract = None 

278 if work.get("abstract_inverted_index"): 

279 logger.debug( 

280 f"Found abstract_inverted_index with {len(work['abstract_inverted_index'])} words" 

281 ) 

282 # Reconstruct abstract from inverted index 

283 abstract = self._reconstruct_abstract( 

284 work["abstract_inverted_index"] 

285 ) 

286 logger.debug( 

287 f"Reconstructed abstract length: {len(abstract) if abstract else 0}" 

288 ) 

289 else: 

290 logger.debug("No abstract_inverted_index found") 

291 

292 snippet = ( 

293 abstract[:SNIPPET_LENGTH_LONG] 

294 if abstract 

295 else f"Academic paper: {title}" 

296 ) 

297 logger.debug(f"Created snippet: {snippet[:100]}...") 

298 

299 # Get publication info 

300 publication_year = work.get("publication_year", "unknown") 

301 publication_date = work.get("publication_date", "unknown") 

302 

303 # Get venue/journal info 

304 venue = work.get("primary_location", {}) 

305 journal_name = "unknown" 

306 openalex_source_id = None 

307 source_type = None 

308 issn = None 

309 if venue: 

310 source = venue.get("source", {}) 

311 if source: 

312 journal_name = source.get("display_name") or "unknown" 

313 # Extract source ID for journal quality lookups 

314 raw_sid = source.get("id") or "" 

315 if raw_sid: 

316 openalex_source_id = raw_sid.split("/")[-1] 

317 source_type = source.get("type") 

318 # Forward the linking ISSN so the reputation filter's 

319 # Tier 2/3 lookups can use it instead of falling back 

320 # to fuzzy name matching. 

321 issn = source.get("issn_l") or None 

322 

323 # Get authors 

324 authors = [] 

325 for authorship in work.get("authorships", [])[ 

326 :5 

327 ]: # Limit to 5 authors 

328 author = authorship.get("author", {}) 

329 if author: 329 ↛ 325line 329 didn't jump to line 325 because the condition on line 329 was always true

330 authors.append(author.get("display_name", "")) 

331 

332 authors_str = ", ".join(authors) 

333 if len(work.get("authorships", [])) > 5: 

334 authors_str += " et al." 

335 

336 # Extract author affiliations for the institution-tier scoring. 

337 # Each entry is a dict with the OpenAlex institution id, ROR id, 

338 # and display name — the lookup_institution() helper accepts any 

339 # of those three. 

340 affiliations: list[dict] = [] 

341 seen_inst_ids: set[str] = set() 

342 for authorship in work.get("authorships", []): 

343 for inst in authorship.get("institutions", []) or []: 343 ↛ 344line 343 didn't jump to line 344 because the loop on line 343 never started

344 raw_id = inst.get("id") or "" 

345 short_id = raw_id.split("/")[-1] if raw_id else "" 

346 if short_id and short_id in seen_inst_ids: 

347 continue 

348 if short_id: 

349 seen_inst_ids.add(short_id) 

350 affiliations.append( 

351 { 

352 "openalex_id": short_id or None, 

353 "ror": (inst.get("ror") or "") 

354 .rstrip("/") 

355 .split("/")[-1] 

356 or None, 

357 "name": inst.get("display_name"), 

358 } 

359 ) 

360 

361 # Get metrics 

362 cited_by_count = work.get("cited_by_count", 0) 

363 

364 # Get URL - prefer DOI, fallback to OpenAlex URL. 

365 # `.get("doi", work_id)` is wrong: when the key exists with value 

366 # None (common for non-DOI works) it returns None, not the 

367 # default. Use `or` so a None DOI falls through to work_id. 

368 url = work.get("doi") or work_id 

369 if not url.startswith("http"): 

370 if url.startswith("https://doi.org/"): 370 ↛ 371line 370 didn't jump to line 371 because the condition on line 370 was never true

371 pass # Already a full DOI URL 

372 elif url.startswith("10."): 

373 url = f"https://doi.org/{url}" 

374 else: 

375 url = work_id # OpenAlex URL 

376 

377 # Check if open access 

378 open_access_info = work.get("open_access", {}) 

379 is_oa = ( 

380 open_access_info.get("is_oa", False) 

381 if open_access_info 

382 else False 

383 ) 

384 oa_url = None 

385 if is_oa: 

386 best_location = work.get("best_oa_location", {}) 

387 if best_location: 387 ↛ 392line 387 didn't jump to line 392 because the condition on line 387 was always true

388 oa_url = best_location.get("pdf_url") or best_location.get( 

389 "landing_page_url" 

390 ) 

391 

392 return { 

393 "id": work_id, 

394 "title": title, 

395 "link": url, 

396 "snippet": snippet, 

397 "authors": authors_str, 

398 "year": publication_year, 

399 "date": publication_date, 

400 # Both fields emit None (not the "unknown" sentinel) when 

401 # OpenAlex has no venue for this work. Downstream consumers 

402 # (citation normalizer, journal reputation filter) treat 

403 # missing venue as "no scoring signal", which is accurate; 

404 # the old "unknown" sentinel leaked through the normalizer 

405 # as a literal container_title and even matched a real 

406 # OpenAlex source named "unknown" (h_index=5, Q1) in the 

407 # reference DB. 

408 "journal": journal_name if journal_name != "unknown" else None, 

409 "journal_ref": journal_name 

410 if journal_name != "unknown" 

411 else None, 

412 "issn": issn, 

413 "affiliations": affiliations or None, 

414 "openalex_source_id": openalex_source_id, 

415 "source_type": source_type, 

416 "citations": cited_by_count, 

417 "is_open_access": is_oa, 

418 "oa_url": oa_url, 

419 "abstract": abstract, 

420 "type": "academic_paper", 

421 } 

422 

423 except Exception as e: 

424 safe_msg = self._scrub_error(e) 

425 logger.exception( 

426 f"Error formatting OpenAlex work: {work.get('id', 'unknown')} ({type(e).__name__}): {safe_msg}" 

427 ) 

428 return None 

429 

430 def _reconstruct_abstract( 

431 self, inverted_index: Dict[str, List[int]] 

432 ) -> str: 

433 """ 

434 Reconstruct abstract text from OpenAlex inverted index format. 

435 

436 Args: 

437 inverted_index: Dictionary mapping words to their positions 

438 

439 Returns: 

440 Reconstructed abstract text 

441 """ 

442 try: 

443 # Create position-word mapping 

444 position_word = {} 

445 for word, positions in inverted_index.items(): 

446 for pos in positions: 

447 position_word[pos] = word 

448 

449 # Sort by position and reconstruct 

450 sorted_positions = sorted(position_word.keys()) 

451 words = [position_word[pos] for pos in sorted_positions] 

452 

453 return " ".join(words) 

454 

455 except Exception: 

456 logger.debug("Could not reconstruct abstract from inverted index") 

457 return "" 

458 

459 def _get_full_content( 

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

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

462 """ 

463 Get full content for relevant items (OpenAlex provides most content in preview). 

464 

465 Args: 

466 relevant_items: List of relevant preview dictionaries 

467 

468 Returns: 

469 List of result dictionaries with full content 

470 """ 

471 # OpenAlex returns comprehensive data in the initial search, 

472 # so we don't need a separate full content fetch 

473 results = [] 

474 for item in relevant_items: 

475 result = { 

476 "title": item.get("title", ""), 

477 "link": item.get("link", ""), 

478 "snippet": item.get("snippet", ""), 

479 "content": item.get("abstract", item.get("snippet", "")), 

480 # Forward journal quality fields for content filters 

481 "journal_ref": item.get("journal_ref"), 

482 "openalex_source_id": item.get("openalex_source_id"), 

483 "source_type": item.get("source_type"), 

484 "affiliations": item.get("affiliations"), 

485 "metadata": { 

486 "authors": item.get("authors", ""), 

487 "year": item.get("year", ""), 

488 "journal": item.get("journal", ""), 

489 "citations": item.get("citations", 0), 

490 "is_open_access": item.get("is_open_access", False), 

491 "oa_url": item.get("oa_url"), 

492 "affiliations": item.get("affiliations"), 

493 }, 

494 } 

495 results.append(result) 

496 

497 return results