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

224 statements  

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

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

2 

3from typing import Any, Dict, List, Optional 

4 

5import requests 

6 

7from langchain_core.language_models import BaseLLM 

8 

9from ...constants import SNIPPET_LENGTH_LONG, USER_AGENT 

10from ...security.safe_requests import safe_get 

11from ...security.secure_logging import logger 

12from ...settings.exceptions import SettingsValueError 

13from ...utilities.openalex_enrichment import ( 

14 OPENALEX_AUTH_FAILURE_STATUSES, 

15 bounded_error_body, 

16 normalize_openalex_api_key, 

17 send_with_key_fallback, 

18) 

19from ..rate_limiting import RateLimitError 

20from ..search_engine_base import BaseSearchEngine, Exposure, Sensitivity 

21 

22 

23class OpenAlexAuthError(Exception): 

24 """OpenAlex refused the request as unauthenticated or forbidden. 

25 

26 Raised instead of returning ``[]`` so ``BaseSearchEngine.run`` records 

27 the search as *failed* with a message, rather than as a successful 

28 search that happened to find nothing. A silently empty result set is 

29 indistinguishable from "no papers matched" and hides a misconfigured 

30 key indefinitely. 

31 

32 Raised only when a key was actually sent *and* the automatic keyless 

33 retry also failed. When the retry succeeds the user gets their 

34 results and only a warning is logged; when no key was configured at 

35 all, a 401/403 keeps the pre-existing "log an API error, return []" 

36 behaviour, because there is no key to blame. 

37 """ 

38 

39 

40class OpenAlexSearchEngine(BaseSearchEngine): 

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

42 

43 # Restates the base default. ``openalex_api_key`` is bound to the very 

44 # same string object as ``api_key``, so it needs no entry of its own; 

45 # ``_rejected_api_key`` does, because once OpenAlex refuses the key 

46 # ``self.api_key`` becomes None and ``_scrub_error`` would no longer 

47 # have the literal to redact. Kept explicit here (rather than 

48 # inherited silently) so a future narrowing of this tuple has to 

49 # confront the rejected-key slot. 

50 _secret_attrs = ("api_key", "_rejected_api_key") 

51 

52 # Mark as public search engine 

53 is_public = True 

54 egress_sensitivity = Sensitivity.NON_SENSITIVE 

55 egress_exposure = Exposure.EXPOSING 

56 # Scientific/academic search engine 

57 is_scientific = True 

58 is_lexical = True 

59 needs_llm_relevance_filter = True 

60 

61 def __init__( 

62 self, 

63 max_results: int = 25, 

64 email: Optional[str] = None, 

65 api_key: Optional[str] = None, 

66 sort_by: str = "relevance", 

67 filter_open_access: bool = False, 

68 min_citations: int = 0, 

69 from_publication_date: Optional[str] = None, 

70 llm: Optional[BaseLLM] = None, 

71 max_filtered_results: Optional[int] = None, 

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

73 **kwargs, 

74 ): 

75 """ 

76 Initialize the OpenAlex search engine. 

77 

78 Args: 

79 max_results: Maximum number of search results 

80 email: Optional email included in the User-Agent for identification; 

81 the polite pool was deprecated in February 2026 

82 api_key: Optional OpenAlex API key for a higher daily request budget 

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

84 filter_open_access: Only return open access papers 

85 min_citations: Minimum citation count filter 

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

87 llm: Language model for relevance filtering 

88 max_filtered_results: Maximum number of results to keep after filtering 

89 settings_snapshot: Settings snapshot for configuration 

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

91 """ 

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

93 preview_filters = [] 

94 journal_filter = self._create_journal_filter( 

95 "openalex", llm, settings_snapshot 

96 ) 

97 if journal_filter is not None: 

98 preview_filters.append(journal_filter) 

99 

100 super().__init__( 

101 llm=llm, 

102 max_filtered_results=max_filtered_results, 

103 max_results=max_results, 

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

105 settings_snapshot=settings_snapshot, 

106 **kwargs, 

107 ) 

108 

109 self.sort_by = sort_by 

110 self.filter_open_access = filter_open_access 

111 self.min_citations = min_citations 

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

113 self.from_publication_date = ( 

114 from_publication_date 

115 if from_publication_date and from_publication_date != "False" 

116 else None 

117 ) 

118 

119 # Get email from settings if not provided 

120 if not email and settings_snapshot: 

121 from ...config.search_config import get_setting_from_snapshot 

122 

123 try: 

124 # An explicit default is required: with no ``default`` 

125 # argument at all, a snapshot that lacks the key and no 

126 # thread-local settings context makes 

127 # ``get_setting_from_snapshot`` raise 

128 # ``NoSettingsContextError``. The empty string (rather 

129 # than None) is the safer spelling because it is falsy 

130 # under every version of that function's 

131 # "was a default supplied?" test. (exc_info is not 

132 # passed: loguru ignores it, so it never produced a 

133 # traceback anyway.) 

134 email = get_setting_from_snapshot( 

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

136 default="", 

137 settings_snapshot=settings_snapshot, 

138 ) 

139 except SettingsValueError: 

140 raise 

141 except Exception: 

142 logger.debug( 

143 "Failed to read openalex.email from settings snapshot" 

144 ) 

145 

146 # Handle "False" string for email 

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

148 

149 if not api_key and settings_snapshot: 

150 from ...config.search_config import get_setting_from_snapshot 

151 

152 try: 

153 # default="" for the same reason as the email lookup 

154 # above; ``normalize_openalex_api_key`` turns "" into 

155 # None, i.e. "search keyless". 

156 api_key = get_setting_from_snapshot( 

157 "search.engine.web.openalex.api_key", 

158 default="", 

159 settings_snapshot=settings_snapshot, 

160 ) 

161 except SettingsValueError: 

162 raise 

163 except Exception: 

164 logger.debug( 

165 "Failed to read openalex.api_key from settings snapshot" 

166 ) 

167 

168 # Placeholders, sentinels and non-strings all become None, i.e. 

169 # "search keyless". Sending them as a Bearer token would earn a 

170 # 401/403 and silently turn a working engine into an empty one. 

171 self.api_key: Optional[str] = normalize_openalex_api_key(api_key) 

172 self.openalex_api_key: Optional[str] = self.api_key 

173 # Set only once OpenAlex refuses the key; see ``_secret_attrs``. 

174 self._rejected_api_key: Optional[str] = None 

175 

176 # API configuration 

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

178 ua_email = self.email 

179 self.headers = { 

180 "User-Agent": f"{USER_AGENT} ({ua_email})" 

181 if ua_email 

182 else USER_AGENT, 

183 "Accept": "application/json", 

184 } 

185 if self.api_key: 

186 # Bearer header rather than the equally-supported ?api_key= 

187 # query parameter, so the key never lands in a URL (logs, 

188 # proxies, error messages). Both schemes "work identically" — 

189 # https://help.openalex.org/api/authentication/ ("Sending your 

190 # key"). 

191 self.headers["Authorization"] = f"Bearer {self.api_key}" 

192 

193 if self.api_key: 

194 logger.info( 

195 "Using OpenAlex with API key (10x keyless daily budget)" 

196 ) 

197 else: 

198 logger.info( 

199 "Using OpenAlex without an API key (free tier; a free key from " 

200 "https://openalex.org/settings/api raises the daily budget 10x — " 

201 "the old email 'polite pool' was deprecated in Feb 2026)" 

202 ) 

203 

204 def _request_works( 

205 self, params: Dict[str, Any], *, with_api_key: bool 

206 ) -> Any: 

207 """Call the OpenAlex works endpoint, optionally without the key. 

208 

209 Args: 

210 params: Query parameters for ``/works``. 

211 with_api_key: When False, the ``Authorization`` header is 

212 dropped so the request uses the keyless free tier. 

213 """ 

214 headers = dict(self.headers) 

215 if not with_api_key: 

216 headers.pop("Authorization", None) 

217 return safe_get( 

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

219 params=params, 

220 headers=headers, 

221 timeout=30, 

222 ) 

223 

224 def _note_openalex_key_rejected( 

225 self, api_key: Optional[str] = None 

226 ) -> None: 

227 """Latch the rejection *and* drop this engine's own credential. 

228 

229 The base implementation only latches, which is all a non-OpenAlex 

230 scientific engine needs: its ``self.api_key`` belongs to another 

231 vendor and never reaches ``api.openalex.org``. This engine is the 

232 one that also holds an OpenAlex key of its own, in 

233 ``self.api_key`` / ``self.openalex_api_key`` and in the cached 

234 ``Authorization`` header, so the latch alone would leave the 

235 refused credential live for its *searches*. 

236 

237 That matters when the DOI enrichment pass is the path that learns 

238 the key is bad — a search that returned 200 followed by an 

239 enrichment 401, e.g. a key revoked mid-run. Enrichment's 

240 ``on_key_rejected`` callback is this method; without the drop 

241 below the next search on the same instance would send 

242 ``Authorization: Bearer <refused key>`` again. 

243 

244 The single ``_rejected_api_key`` slot never has to hold two 

245 different literals: ``_resolve_openalex_enrichment_key`` reads 

246 ``self.openalex_api_key`` first, so whenever this engine has a 

247 key the enrichment pass sends that same string. 

248 

249 Idempotent, and safe in either order relative to 

250 :meth:`_drop_rejected_api_key`: the base method keeps the stored 

251 literal with ``or self._rejected_api_key``, and clearing an 

252 already-cleared attribute or popping an absent header is a no-op. 

253 The literal is passed up before the attributes are cleared, so it 

254 is never lost from ``_scrub_error``'s redaction set. 

255 """ 

256 super()._note_openalex_key_rejected( 

257 api_key or getattr(self, "api_key", None) 

258 ) 

259 self.api_key = None 

260 self.openalex_api_key = None 

261 headers = getattr(self, "headers", None) 

262 if isinstance(headers, dict): 262 ↛ exitline 262 didn't return from function '_note_openalex_key_rejected' because the condition on line 262 was always true

263 headers.pop("Authorization", None) 

264 

265 def _drop_rejected_api_key(self) -> None: 

266 """Forget a key OpenAlex refused, for this engine's lifetime. 

267 

268 Three things have to happen together for "no path sends a refused 

269 key twice" to hold, and all three are done by this engine's 

270 :meth:`_note_openalex_key_rejected` override: 

271 

272 * the ``Authorization`` header is cleared, not just the 

273 attribute, so every later query on this instance goes out 

274 keyless; 

275 * ``BaseSearchEngine._note_openalex_key_rejected`` latches the 

276 rejection, so the DOI enrichment pass later in the *same* 

277 ``run()`` resolves ``None`` instead of re-reading the identical 

278 key out of the settings snapshot both it and ``__init__`` read; 

279 * the literal is retained in ``_rejected_api_key`` so it stays in 

280 ``_scrub_error``'s redaction set. 

281 

282 Kept as the search path's named entry point (it is what 

283 ``send_with_key_fallback`` is handed as ``on_key_rejected``) so 

284 the two directions — search-learned and enrichment-learned — meet 

285 in one implementation instead of drifting apart. 

286 """ 

287 self._note_openalex_key_rejected(self.api_key) 

288 

289 def _reapply_rate_limit(self) -> None: 

290 """Account for the extra keyless retry request. 

291 

292 ``apply_rate_limit`` is what paces this engine against 

293 ``api.openalex.org``; the retry is a second real request, so it 

294 gets its own call rather than riding on the first one's budget. 

295 """ 

296 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

297 self.engine_type 

298 ) 

299 

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

301 """ 

302 Get preview information for OpenAlex search results. 

303 

304 Args: 

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

306 

307 Returns: 

308 List of preview dictionaries 

309 """ 

310 logger.info("Searching OpenAlex") 

311 

312 # Build the search URL with parameters 

313 params = { 

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

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

316 "page": 1, 

317 # Request specific fields including abstract for snippets 

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

319 } 

320 

321 # Add optional filters 

322 filters = [] 

323 

324 if self.filter_open_access: 

325 filters.append("is_oa:true") 

326 

327 if self.min_citations > 0: 

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

329 

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

331 filters.append( 

332 f"from_publication_date:{self.from_publication_date}" 

333 ) 

334 

335 if filters: 

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

337 

338 # Add sorting 

339 sort_map = { 

340 "relevance": "relevance_score:desc", 

341 "cited_by_count": "cited_by_count:desc", 

342 "publication_date": "publication_date:desc", 

343 } 

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

345 

346 try: 

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

348 self._last_wait_time = self.rate_tracker.apply_rate_limit( 

349 self.engine_type 

350 ) 

351 logger.debug( 

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

353 ) 

354 

355 # Make the API request. The shared helper is what guarantees 

356 # a refused key costs at most one extra request and never the 

357 # results: it retries keylessly once and drops the key for 

358 # this engine's lifetime. The same helper backs the DOI 

359 # enrichment pass and the research-library downloader. 

360 logger.info( 

361 f"Making OpenAlex API request with params: {sorted(params.keys())}" 

362 ) 

363 # Read once. ``self.api_key`` is shared mutable state (another 

364 # thread on this instance can drop it), and the retry decision 

365 # below and the OpenAlexAuthError gate further down must agree 

366 # about whether *this* request carried a credential. 

367 api_key = self.api_key 

368 key_was_sent = api_key is not None 

369 response, _ = send_with_key_fallback( 

370 lambda with_api_key: self._request_works( 

371 params, with_api_key=with_api_key 

372 ), 

373 api_key=api_key, 

374 context="OpenAlex search", 

375 on_key_rejected=self._drop_rejected_api_key, 

376 before_retry=self._reapply_rate_limit, 

377 ) 

378 

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

380 

381 # Log rate limit info if available 

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

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

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

385 logger.debug( 

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

387 ) 

388 

389 if response.status_code == 200: 

390 data = response.json() 

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

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

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

394 

395 logger.info( 

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

397 ) 

398 

399 # Log first result structure for debugging 

400 if results: 

401 first_result = results[0] 

402 logger.debug( 

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

404 ) 

405 logger.debug( 

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

407 ) 

408 if "open_access" in first_result: 

409 logger.debug( 

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

411 ) 

412 

413 # Format results as previews 

414 previews = [] 

415 for i, work in enumerate(results): 

416 logger.debug( 

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

418 ) 

419 preview = self._format_work_preview(work) 

420 if preview: 

421 previews.append(preview) 

422 logger.debug( 

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

424 ) 

425 else: 

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

427 

428 logger.info( 

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

430 ) 

431 return previews 

432 

433 if response.status_code == 429: 

434 # 429 is also the *daily budget* status, not just a burst 

435 # limit: https://help.openalex.org/api/errors/ defines it 

436 # as "Rate limit or daily credit budget exceeded", and 

437 # https://help.openalex.org/api/authentication/ says "Two 

438 # things return 429 Too Many Requests: exceeding your 

439 # daily budget, or making more than 100 requests per 

440 # second". Hence the message points at the free key, 

441 # which raises that budget 10x. 

442 logger.warning( 

443 "OpenAlex rate limit reached; a free API key from " 

444 "https://openalex.org/settings/api raises the daily budget 10x" 

445 ) 

446 raise RateLimitError( # noqa: TRY301 — re-raised for base retry handling 

447 "OpenAlex rate limit exceeded", 

448 retry_after=response.headers.get("Retry-After"), 

449 ) 

450 

451 self._record_search_failure(requests.HTTPError(response=response)) 

452 # response.text can echo the request (headers included), so it 

453 # gets the full dual-scrub — credential shapes AND this 

454 # engine's literal key — before it reaches a log sink. 

455 # Scrub FIRST, truncate after: cutting at 200 characters 

456 # first can split the key so that neither the literal pass 

457 # nor the anchored Bearer/Authorization regexes match the 

458 # surviving prefix. Same ordering rule as 

459 # ``security/log_sanitizer.sanitize_error_for_client``. 

460 # ``bounded_error_body`` bounds the *regex passes* (not the 

461 # body materialisation: ``response.text`` has already decoded 

462 # all of it) by cutting the body back to the last whitespace 

463 # character at or before 8192. Cutting mid-token would be 

464 # unsafe even at that offset, because scrubbing shrinks the 

465 # text — a token-shaped run collapses to 17 characters — and 

466 # would pull the fragment the cut created into the surviving 

467 # 200. Because ``normalize_openalex_api_key`` refuses any key 

468 # containing whitespace, a key always lies inside one 

469 # whitespace-free run, so cutting at a whitespace boundary 

470 # drops any run straddling it in its entirety and no partial 

471 # credential can survive. 

472 safe_detail = self._scrub_error(bounded_error_body(response.text))[ 

473 :200 

474 ] 

475 

476 if ( 

477 response.status_code in OPENALEX_AUTH_FAILURE_STATUSES 

478 and key_was_sent 

479 ): 

480 # Reached only when a key was sent AND the keyless retry 

481 # above also failed. A keyless install that gets a 401/403 

482 # from a proxy has no key to blame, so it keeps the 

483 # pre-existing "API error → []" behaviour below rather 

484 # than being told to check a key it never configured. 

485 logger.error( 

486 "OpenAlex rejected the API key: " 

487 f"{response.status_code} - {safe_detail}" 

488 ) 

489 raise OpenAlexAuthError( # noqa: TRY301 — re-raised by except OpenAlexAuthError so run() records the failure 

490 f"OpenAlex authentication failed (HTTP {response.status_code})" 

491 ) 

492 

493 logger.error( 

494 f"OpenAlex API error: {response.status_code} - {safe_detail}" 

495 ) 

496 return [] 

497 

498 except (RateLimitError, OpenAlexAuthError): 

499 # Re-raise for the base class: rate limits drive its retry 

500 # handling, auth failures must be recorded as a failed search 

501 # rather than disappearing into an empty result list. 

502 raise 

503 except Exception as e: 

504 self._record_search_failure(e) 

505 # logger.warning rather than logger.exception: the traceback 

506 # frames hold self.headers (the "Authorization: Bearer <key>" 

507 # value) and would render it under loguru diagnose. Same 

508 # documented trade-off as search_engine_nasa_ads. 

509 safe_msg = self._scrub_error(e) 

510 logger.warning( 

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

512 ) 

513 return [] 

514 

515 def _format_work_preview( 

516 self, work: Dict[str, Any] 

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

518 """ 

519 Format an OpenAlex work as a preview dictionary. 

520 

521 Args: 

522 work: OpenAlex work object 

523 

524 Returns: 

525 Formatted preview dictionary or None if formatting fails 

526 """ 

527 try: 

528 # Extract basic information 

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

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

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

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

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

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

535 

536 # Build snippet from abstract or first part of title 

537 abstract = None 

538 if work.get("abstract_inverted_index"): 

539 logger.debug( 

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

541 ) 

542 # Reconstruct abstract from inverted index 

543 abstract = self._reconstruct_abstract( 

544 work["abstract_inverted_index"] 

545 ) 

546 logger.debug( 

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

548 ) 

549 else: 

550 logger.debug("No abstract_inverted_index found") 

551 

552 snippet = ( 

553 abstract[:SNIPPET_LENGTH_LONG] 

554 if abstract 

555 else f"Academic paper: {title}" 

556 ) 

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

558 

559 # Get publication info 

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

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

562 

563 # Get venue/journal info 

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

565 journal_name = "unknown" 

566 openalex_source_id = None 

567 source_type = None 

568 issn = None 

569 if venue: 

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

571 if source: 

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

573 # Extract source ID for journal quality lookups 

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

575 if raw_sid: 

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

577 source_type = source.get("type") 

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

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

580 # to fuzzy name matching. 

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

582 

583 # Get authors 

584 authors = [] 

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

586 :5 

587 ]: # Limit to 5 authors 

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

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

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

591 

592 authors_str = ", ".join(authors) 

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

594 authors_str += " et al." 

595 

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

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

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

599 # of those three. 

600 affiliations: list[dict] = [] 

601 seen_inst_ids: set[str] = set() 

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

603 for inst in authorship.get("institutions", []) or []: 

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

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

606 if short_id and short_id in seen_inst_ids: 606 ↛ 607line 606 didn't jump to line 607 because the condition on line 606 was never true

607 continue 

608 if short_id: 608 ↛ 610line 608 didn't jump to line 610 because the condition on line 608 was always true

609 seen_inst_ids.add(short_id) 

610 affiliations.append( 

611 { 

612 "openalex_id": short_id or None, 

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

614 .rstrip("/") 

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

616 or None, 

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

618 } 

619 ) 

620 

621 # Get metrics 

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

623 

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

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

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

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

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

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

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

631 pass # Already a full DOI URL 

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

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

634 else: 

635 url = work_id # OpenAlex URL 

636 

637 # Check if open access 

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

639 is_oa = ( 

640 open_access_info.get("is_oa", False) 

641 if open_access_info 

642 else False 

643 ) 

644 oa_url = None 

645 if is_oa: 

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

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

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

649 "landing_page_url" 

650 ) 

651 

652 return { 

653 "id": work_id, 

654 "title": title, 

655 "link": url, 

656 "snippet": snippet, 

657 "authors": authors_str, 

658 "year": publication_year, 

659 "date": publication_date, 

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

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

662 # (citation normalizer, journal reputation filter) treat 

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

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

665 # as a literal container_title and even matched a real 

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

667 # reference DB. 

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

669 "journal_ref": journal_name 

670 if journal_name != "unknown" 

671 else None, 

672 "issn": issn, 

673 "affiliations": affiliations or None, 

674 "openalex_source_id": openalex_source_id, 

675 "source_type": source_type, 

676 "citations": cited_by_count, 

677 "is_open_access": is_oa, 

678 "oa_url": oa_url, 

679 "abstract": abstract, 

680 "type": "academic_paper", 

681 } 

682 

683 except Exception as e: 

684 safe_msg = self._scrub_error(e) 

685 logger.exception( 

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

687 ) 

688 return None 

689 

690 def _reconstruct_abstract( 

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

692 ) -> str: 

693 """ 

694 Reconstruct abstract text from OpenAlex inverted index format. 

695 

696 Args: 

697 inverted_index: Dictionary mapping words to their positions 

698 

699 Returns: 

700 Reconstructed abstract text 

701 """ 

702 try: 

703 # Create position-word mapping 

704 position_word = {} 

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

706 for pos in positions: 

707 position_word[pos] = word 

708 

709 # Sort by position and reconstruct 

710 sorted_positions = sorted(position_word.keys()) 

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

712 

713 return " ".join(words) 

714 

715 except Exception: 

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

717 return "" 

718 

719 def _get_full_content( 

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

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

722 """ 

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

724 

725 Args: 

726 relevant_items: List of relevant preview dictionaries 

727 

728 Returns: 

729 List of result dictionaries with full content 

730 """ 

731 # OpenAlex returns comprehensive data in the initial search, 

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

733 results = [] 

734 for item in relevant_items: 

735 result = { 

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

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

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

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

740 # Forward journal quality fields for content filters 

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

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

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

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

745 "metadata": { 

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

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

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

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

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

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

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

753 }, 

754 } 

755 results.append(result) 

756 

757 return results