Coverage for src/local_deep_research/utilities/openalex_enrichment.py: 98%

135 statements  

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

1""" 

2Batch DOI → OpenAlex source_id enrichment. 

3 

4Resolves paper DOIs to OpenAlex source IDs in a single batch HTTP request 

5(up to 50 DOIs per call). This populates ``openalex_source_id`` and 

6``source_type`` on result dicts so the journal reputation filter can look 

7up journals by ID rather than fragile name matching. 

8 

9Runs as a pre-enrichment layer before content filters — the existing 

10tiered scoring system is unchanged. This just gives Tier 2 (OpenAlex 

11snapshot lookup) a reliable key to work with. 

12""" 

13 

14import unicodedata 

15from typing import Any, Callable, Dict, List, Optional, Tuple 

16 

17from loguru import logger 

18 

19from ..constants import OPENALEX_ENRICHMENT_API_TIMEOUT, USER_AGENT 

20from ..security.log_sanitizer import scrub_error 

21from ..security.safe_requests import safe_get 

22from .citation_normalizer import _extract_doi 

23 

24 

25_OPENALEX_API = "https://api.openalex.org" 

26_MAX_DOIS_PER_REQUEST = 50 

27 

28# HTTP statuses treated as "this credential was refused". 

29# 

30# Sourced, not assumed. OpenAlex's published error table 

31# (https://help.openalex.org/api/errors/) documents 200, 301, 400, 

32# 403 ("Forbidden - you don't have access to this resource"), 404, 429 

33# and 500. It documents **no 401**, and 

34# https://help.openalex.org/api/authentication/ does not state which 

35# status a rejected key earns. So this is deliberately a small 

36# *defensive* set: 403 is documented, and 401 is the standard status for 

37# a refused bearer token. Nothing else is guessed at. 

38# 

39# In particular there is no separate "daily budget exceeded" status to 

40# map: the errors page defines 429 as "Rate limit or daily credit budget 

41# exceeded; slow down or wait for the reset", and the authentication 

42# page confirms "Two things return 429 Too Many Requests: exceeding your 

43# daily budget, or making more than 100 requests per second". 429 is 

44# already handled by the engine's rate-limit branch, which points at the 

45# free key that raises the daily budget 10x. 

46# 

47# A refused key must never look like "no results": it is an operator 

48# misconfiguration. See :func:`send_with_key_fallback`. 

49OPENALEX_AUTH_FAILURE_STATUSES = frozenset({401, 403}) 

50 

51 

52def normalize_openalex_api_key(api_key: Any) -> Optional[str]: 

53 """Normalize a configured OpenAlex API key to a real key, or ``None``. 

54 

55 ``None`` means "make the request keyless" — OpenAlex works without a 

56 key, so an unusable configured value must degrade to the free tier 

57 rather than being sent as a ``Bearer`` token that earns a 401/403 and 

58 turns a working engine into one that returns nothing. 

59 

60 Collapses to ``None``: 

61 

62 * any non-``str`` value (``True``/``1`` from a mis-typed setting used 

63 to raise ``AttributeError`` on ``.strip()``), 

64 * whitespace-only values (``None``, never ``""``), 

65 * the ``"False"`` settings sentinel, case-insensitively, 

66 * every placeholder ``BaseSearchEngine._is_valid_api_key`` rejects — 

67 ``your-api-key-here``, ``<your_api_key>``, ``${OPENALEX_API_KEY}``, … 

68 * any value that is not usable as an HTTP header value. Two 

69 independent rules, both required: 

70 

71 - *internal whitespace or control characters*. Such a value cannot 

72 be a real key, and a newline in it makes ``requests`` raise 

73 ``InvalidHeader`` whose message embeds the value's ``repr``, 

74 which neither scrub pass can match (the literal holds a real 

75 newline, the message holds a backslash and an "n"). Rejecting it 

76 keeps it out of the logs. 

77 - *not latin-1 encodable*. This is the hard constraint: CPython's 

78 ``http.client`` encodes every header value as latin-1 

79 (``values[i] = one_value.encode('latin-1')``), and neither 

80 ``requests``' own header validation nor anything below it gates 

81 the encoding. A key carrying U+2010, U+2013 or U+2019 — what a 

82 PDF paste or an editor's autocorrect produces — therefore raises 

83 ``UnicodeEncodeError`` *before any request leaves the process*. 

84 That is not an HTTP status, so :func:`send_with_key_fallback` 

85 cannot turn it into a keyless retry by inspecting 

86 ``response.status_code``; the value has to be refused here. 

87 Characters that *are* latin-1 (``é``, ``ü``) are left alone — 

88 they encode fine and could be part of a real key. 

89 

90 This lives here, rather than next to the placeholder list in 

91 ``web_search_engines/search_engine_base.py``, because all three 

92 OpenAlex callers need it — the search engine, the base-class DOI 

93 enrichment pass, and the research-library downloader — and only this 

94 module is low-level enough for the downloader to import. The 

95 placeholder list itself is *not* duplicated: it is imported inside 

96 the function, which keeps the *module-level* import graph free of the 

97 engine package (``research_library.downloaders.openalex`` imports 

98 this module at module level). 

99 """ 

100 if not isinstance(api_key, str): 

101 return None 

102 

103 candidate = api_key.strip() 

104 # "False" is the repo-wide "unset" sentinel for string settings that 

105 # round-tripped through a boolean UI control; it is not a placeholder 

106 # name, so the shared list does not (and should not) carry it. 

107 if candidate.lower() == "false": 

108 return None 

109 

110 from ..web_search_engines.search_engine_base import ( 

111 _is_api_key_placeholder, 

112 ) 

113 

114 if _is_api_key_placeholder(candidate): 

115 return None 

116 

117 # Checked after the placeholder list so a whitespace-bearing 

118 # placeholder is still dropped silently rather than warned about. 

119 # The warning deliberately does not include the value. 

120 unusable_as_header = any( 

121 character.isspace() or unicodedata.category(character).startswith("C") 

122 for character in candidate 

123 ) 

124 if not unusable_as_header: 

125 # The encoding check the whitespace/control predicates do not 

126 # cover. ``http.client`` encodes header values as latin-1 and 

127 # nothing between here and there catches the resulting 

128 # ``UnicodeEncodeError``, so a key that is not latin-1 encodable 

129 # would take out every OpenAlex call — search, enrichment and 

130 # download — with no status code for the keyless fallback to 

131 # branch on. See the docstring. 

132 try: 

133 candidate.encode("latin-1") 

134 except UnicodeEncodeError: 

135 unusable_as_header = True 

136 

137 if unusable_as_header: 

138 logger.warning( 

139 "Ignoring the configured OpenAlex API key: it is not usable as " 

140 "an HTTP header value (it contains whitespace, control " 

141 "characters, or characters outside latin-1). Re-copy the key " 

142 "from https://openalex.org/settings/api" 

143 ) 

144 return None 

145 return candidate 

146 

147 

148ERROR_BODY_SCRUB_LIMIT = 8192 

149 

150 

151def bounded_error_body(text: str, limit: int = ERROR_BODY_SCRUB_LIMIT) -> str: 

152 """Cut an upstream error body down to a bounded, scrub-safe prefix. 

153 

154 ``safe_requests`` allows a 1 GiB response body, and running all of it 

155 through the ~18 compiled patterns of the credential scrubbers only to 

156 keep a couple of hundred characters is pointless work. This bounds 

157 the *regex passes* — ``response.text`` has already decoded the whole 

158 body by the time it is called, so it bounds no allocation. 

159 

160 Truncating before scrubbing is only safe if the cut cannot create a 

161 partial credential, because the scrubbers are shape-anchored and a 

162 scrub can *shrink* the text (``Bearer <token>`` collapses to 17 

163 characters), pulling content from far beyond the caller's final cap 

164 into the characters that get logged. 

165 

166 The invariant that makes it safe: :func:`normalize_openalex_api_key` 

167 refuses any key containing whitespace, so a key is always contained 

168 in a single whitespace-free run. Cutting back to the last whitespace 

169 character at or before *limit* therefore drops the trailing partial 

170 run in its entirety — no fragment of a token that straddles the 

171 boundary can survive. A body with no whitespace at all in its first 

172 *limit* characters yields ``""``: the whole prefix is one unbroken 

173 run and none of it can be shown to be credential-free. 

174 

175 Args: 

176 text: The decoded response body. 

177 limit: Maximum number of characters considered before the cut 

178 back to a whitespace boundary. 

179 

180 Returns: 

181 *text* unchanged when it is no longer than *limit*, otherwise a 

182 prefix ending at a whitespace character, possibly ``""``. 

183 """ 

184 if len(text) <= limit: 

185 return text 

186 window = text[:limit] 

187 for index in range(len(window) - 1, -1, -1): 

188 if window[index].isspace(): 

189 return window[: index + 1] 

190 return "" 

191 

192 

193def send_with_key_fallback( 

194 send_request: Callable[[bool], Any], 

195 *, 

196 api_key: Optional[str], 

197 context: str, 

198 on_key_rejected: Optional[Callable[[], None]] = None, 

199 before_retry: Optional[Callable[[], None]] = None, 

200) -> Tuple[Any, Optional[str]]: 

201 """Send an OpenAlex request; drop a refused key and retry keyless once. 

202 

203 OpenAlex serves the same endpoints with no key at all, at a lower 

204 daily budget (https://help.openalex.org/api/authentication/), so a 

205 stale, rotated, typo'd or revoked key must never cost the caller its 

206 results. Every LDR call site that can send the OpenAlex credential 

207 goes through this one function so the guarantee cannot be 

208 implemented in one place and forgotten in another. 

209 

210 On an auth failure (:data:`OPENALEX_AUTH_FAILURE_STATUSES`) *while a 

211 key was actually sent*, or when the keyed send raises 

212 ``UnicodeEncodeError`` (see below): 

213 

214 * exactly one warning is logged. It is scrub-safe by construction: 

215 the only interpolated values are *context* (a caller literal) and 

216 the integer status code — the key itself is never formatted into 

217 it; 

218 * the key is dropped. This function returns ``None`` as the key in 

219 effect and calls *on_key_rejected* so the caller can clear any 

220 cached ``Authorization`` header, which means no later request pays 

221 another wasted round-trip with a credential already known to be 

222 refused; 

223 * *before_retry* runs (rate-limit accounting for the extra request); 

224 * the same request is retried once, keyless. 

225 

226 When no key was sent, or the response is not an auth failure, the 

227 response is returned untouched and nothing is logged — keyless 

228 behaviour is exactly what it was before the key existed. 

229 

230 If the keyless retry fails too, its response is returned as-is and 

231 the caller applies its own pre-existing failure handling. 

232 

233 ``UnicodeEncodeError`` from the keyed send is treated exactly like a 

234 rejection. It is the one exception that can only come from encoding a 

235 request *header value*: ``http.client`` encodes header values as 

236 latin-1 *before* opening the connection, so no request went out and a 

237 keyless resend is safe and correct. (URLs and query parameters are 

238 percent-encoded by ``requests`` and raise ``InvalidURL``, not 

239 ``UnicodeEncodeError``.) Which header is not knowable here. 

240 :func:`normalize_openalex_api_key` already refuses non-latin-1 keys, 

241 so on every in-tree path the ``Authorization`` value is already 

242 known-encodable and the likelier source is another configured header 

243 value — the contact email interpolated into the ``User-Agent``, which 

244 ``requests`` emits before ``Authorization``. Dropping the key is 

245 still the right response: it is the only credential-bearing header, 

246 and a keyless resend costs nothing when the key was not at fault. 

247 The catch is deliberately this one exception type: any other failure 

248 is a real network or protocol error, and retrying it keyless would 

249 both hide the fault and throw away a key the server never refused. 

250 

251 Args: 

252 send_request: ``send_request(with_api_key)`` issues the request 

253 and returns the response. It MUST omit the ``Authorization`` 

254 header when *with_api_key* is False. 

255 api_key: The key that will be sent, or ``None`` for keyless. 

256 context: Short literal naming the call site, for the log line. 

257 on_key_rejected: Called once, before the retry, to clear the 

258 caller's cached credential state. 

259 before_retry: Called once, immediately before the retry. 

260 

261 Returns: 

262 ``(response, api_key)`` where *api_key* is ``None`` once dropped. 

263 """ 

264 

265 def _drop_and_resend_keyless() -> Tuple[Any, Optional[str]]: 

266 # on_key_rejected runs BEFORE the resend, so even if the keyless 

267 # request raises, the refused key is already gone and no later 

268 # call can send it again. 

269 if on_key_rejected is not None: 269 ↛ 271line 269 didn't jump to line 271 because the condition on line 269 was always true

270 on_key_rejected() 

271 if before_retry is not None: 

272 before_retry() 

273 return send_request(False), None 

274 

275 try: 

276 response = send_request(bool(api_key)) 

277 except UnicodeEncodeError: 

278 if not api_key: 

279 # Nothing to drop and nothing to retry differently: the 

280 # failure did not come from the credential. 

281 raise 

282 # The exception carries one character of the offending header 

283 # value and its offset, so it is never interpolated into the log 

284 # line. 

285 logger.warning( 

286 f"{context}: a configured OpenAlex request header value (the " 

287 "API key or the contact email used in the User-Agent) cannot " 

288 "be encoded as an HTTP header value; dropping the key and " 

289 "continuing keyless. Re-check both settings at " 

290 "https://openalex.org/settings/api" 

291 ) 

292 return _drop_and_resend_keyless() 

293 

294 if not api_key: 

295 return response, api_key 

296 if response.status_code not in OPENALEX_AUTH_FAILURE_STATUSES: 

297 return response, api_key 

298 

299 logger.warning( 

300 f"{context}: OpenAlex rejected the configured API key " 

301 f"(HTTP {response.status_code}); dropping it and continuing " 

302 "keyless. Check or rotate the key at " 

303 "https://openalex.org/settings/api" 

304 ) 

305 return _drop_and_resend_keyless() 

306 

307 

308def _normalize_doi(doi: str) -> str: 

309 """Normalize a DOI to ``https://doi.org/<...>`` form for OpenAlex. 

310 

311 The ``startswith`` prefix checks below are fully anchored and 

312 CodeQL-safe. A previous code-scanning bot comment cited alert 7635 

313 (``py/incomplete-url-substring-sanitization``) against an earlier 

314 snapshot of this file; the current CodeQL scan does not raise it, 

315 and the anchored ``startswith`` pattern is the rule's recommended 

316 mitigation. Refactoring to a bare-first normalization was 

317 evaluated (PR #3081) but rejected as no-op churn — the 

318 ``https://doi.org/`` form OpenAlex actually returns round-trips 

319 unchanged through every branch here. Do not refactor without a 

320 new, reproducible functional issue. 

321 """ 

322 doi = doi.strip() 

323 if doi.startswith("https://doi.org/"): 

324 return doi 

325 if doi.startswith("http://doi.org/"): 

326 return doi.replace("http://", "https://") 

327 if doi.startswith("10."): 

328 return f"https://doi.org/{doi}" 

329 return doi 

330 

331 

332def enrich_results_with_source_ids( 

333 results: List[Dict[str, Any]], 

334 email: Optional[str] = None, 

335 api_key: Optional[str] = None, 

336 *, 

337 on_key_rejected: Optional[Callable[[str], None]] = None, 

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

339 """ 

340 Batch-enrich results with OpenAlex source_id by DOI lookup. 

341 

342 For results that have a DOI but no ``openalex_source_id``, makes a 

343 single batch request to the OpenAlex works endpoint to resolve 

344 DOI → journal/conference source_id. 

345 

346 The results list is modified in-place and also returned. 

347 

348 Args: 

349 results: List of result dicts from search engines. 

350 email: Optional email included in the User-Agent for identification. 

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

352 Normalized through :func:`normalize_openalex_api_key`, so a 

353 placeholder or sentinel value degrades to a keyless request 

354 instead of a guaranteed 401. A real key OpenAlex refuses is 

355 dropped for the rest of this call and every remaining batch 

356 goes out keyless (:func:`send_with_key_fallback`), so a bad 

357 key costs at most one extra request, never the enrichment. 

358 on_key_rejected: Called with the refused key, once, the first 

359 time OpenAlex refuses it. This call's own drop is a local, 

360 so a caller that keeps the engine alive across runs passes 

361 this to latch the rejection on itself — otherwise the next 

362 run resolves the same key from the same settings snapshot 

363 and pays the wasted round-trip again. 

364 ``BaseSearchEngine._note_openalex_key_rejected`` is the 

365 production implementation. 

366 

367 Returns: 

368 The same results list, with ``openalex_source_id`` and 

369 ``source_type`` injected where resolved. 

370 """ 

371 if not results: 

372 return results 

373 

374 api_key = normalize_openalex_api_key(api_key) 

375 

376 # Collect DOIs for results that need enrichment 

377 doi_to_indices: Dict[str, List[int]] = {} 

378 for i, result in enumerate(results): 

379 # Skip results that already have a source_id 

380 if result.get("openalex_source_id"): 

381 continue 

382 

383 doi = _extract_doi(result) 

384 if doi: 

385 normalized = _normalize_doi(doi) 

386 doi_to_indices.setdefault(normalized, []).append(i) 

387 

388 if not doi_to_indices: 

389 logger.debug("DOI enrichment: no DOIs to resolve") 

390 return results 

391 

392 # Batch DOIs into chunks of MAX_DOIS_PER_REQUEST 

393 all_dois = list(doi_to_indices.keys()) 

394 enriched_count = 0 

395 # Kept for redaction after the key is dropped: ``scrub_error`` can 

396 # only redact a literal it is handed, and ``api_key`` becomes None 

397 # the moment OpenAlex refuses it. 

398 rejected_key: Optional[str] = None 

399 

400 def _drop_rejected_key() -> None: 

401 # The single mutation point: this runs *before* the keyless 

402 # retry, so even if that retry raises, the refused key is already 

403 # gone and no later chunk can send it again. 

404 nonlocal api_key, rejected_key 

405 # ``or rejected_key``: a second call must not store None over the 

406 # literal and drop it out of the redaction set. 

407 rejected_key = api_key or rejected_key 

408 api_key = None 

409 if on_key_rejected is not None and rejected_key: 

410 # Last, so a raising callback cannot leave the key un-dropped. 

411 on_key_rejected(rejected_key) 

412 

413 for chunk_start in range(0, len(all_dois), _MAX_DOIS_PER_REQUEST): 

414 chunk = all_dois[chunk_start : chunk_start + _MAX_DOIS_PER_REQUEST] 

415 doi_filter = "|".join(chunk) 

416 

417 params = { 

418 "filter": f"doi:{doi_filter}", 

419 "per_page": str(len(chunk)), 

420 "select": "doi,primary_location", 

421 } 

422 # safe_get auto-injects the project User-Agent. We only override 

423 # it here when an email is configured to identify this client. 

424 headers: Dict[str, str] = {"Accept": "application/json"} 

425 if email: 

426 headers["User-Agent"] = f"{USER_AGENT} ({email})" 

427 

428 # Defaults bind the loop variables (ruff B023): within a chunk the 

429 # params, headers and key are fixed. 

430 def _send( 

431 with_api_key: bool, 

432 _params: Dict[str, str] = params, 

433 _headers: Dict[str, str] = headers, 

434 _api_key: Optional[str] = api_key, 

435 ) -> Any: 

436 request_headers = dict(_headers) 

437 if with_api_key and _api_key: 

438 # Bearer header rather than the equally-supported 

439 # ?api_key= query parameter, so the key never lands in a 

440 # URL. https://help.openalex.org/api/authentication/ 

441 request_headers["Authorization"] = f"Bearer {_api_key}" 

442 return safe_get( 

443 f"{_OPENALEX_API}/works", 

444 params=_params, 

445 headers=request_headers, 

446 timeout=OPENALEX_ENRICHMENT_API_TIMEOUT, 

447 require_https=True, 

448 ) 

449 

450 try: 

451 # A refused key is dropped here for the rest of this 

452 # enrichment pass, so the remaining chunks do not each pay a 

453 # wasted round-trip with a credential already refused. 

454 response, _ = send_with_key_fallback( 

455 _send, 

456 api_key=api_key, 

457 context="DOI enrichment", 

458 on_key_rejected=_drop_rejected_key, 

459 ) 

460 

461 # No 401/403 branch of its own: the helper has already logged 

462 # the one "key refused" warning if a key was involved, and a 

463 # keyless 401/403 is not a key problem at all. Either way the 

464 # batch is skipped with exactly the warning this module used 

465 # before the key existed. 

466 if response.status_code != 200: 

467 logger.warning( 

468 f"DOI enrichment: OpenAlex returned {response.status_code}" 

469 ) 

470 continue 

471 

472 data = response.json() 

473 works = data.get("results", []) 

474 

475 for work in works: 

476 work_doi = work.get("doi", "") 

477 if not work_doi: 477 ↛ 478line 477 didn't jump to line 478 because the condition on line 477 was never true

478 continue 

479 

480 # Normalize for matching 

481 work_doi_normalized = _normalize_doi(work_doi) 

482 

483 # Extract source info 

484 location = work.get("primary_location") or {} 

485 source = location.get("source") or {} 

486 source_id_raw = source.get("id", "") 

487 source_type = source.get("type") 

488 

489 if not source_id_raw: 

490 continue 

491 

492 # Extract short ID from URL 

493 source_id = source_id_raw.split("/")[-1] 

494 

495 # Apply to all results with this DOI 

496 indices = doi_to_indices.get(work_doi_normalized, []) 

497 for idx in indices: 

498 results[idx]["openalex_source_id"] = source_id 

499 if source_type: 

500 results[idx]["source_type"] = source_type 

501 enriched_count += 1 

502 

503 except Exception as exc: 

504 # logger.warning rather than logger.exception: this frame's 

505 # locals hold ``headers`` (the "Authorization: Bearer <key>" 

506 # value) and ``api_key``, which loguru's diagnose would render 

507 # into the traceback. Same documented trade-off as 

508 # ``search_engine_nasa_ads``. This module is not a 

509 # BaseSearchEngine, so it scrubs the key itself rather than 

510 # relying on ``_scrub_error``/``_secret_attrs``. 

511 safe_msg = scrub_error(exc, api_key, rejected_key) 

512 logger.warning( 

513 f"DOI enrichment: OpenAlex batch lookup failed: {safe_msg}" 

514 ) 

515 # Graceful: results pass through unenriched 

516 continue 

517 

518 if enriched_count > 0: 

519 logger.info( 

520 f"DOI enrichment: resolved {enriched_count} of " 

521 f"{len(doi_to_indices)} DOIs to OpenAlex source IDs" 

522 ) 

523 else: 

524 logger.debug( 

525 f"DOI enrichment: no matches from {len(doi_to_indices)} DOIs" 

526 ) 

527 

528 return results