agent_search_gateway.providers.academic.openalex
OpenAlex works API discovery adapter.
1"""OpenAlex works API discovery adapter.""" 2 3from __future__ import annotations 4 5from ...academic.normalization import normalize_openalex_id 6from ...observability import SecretValue 7from ..contracts import PaperSearchHit 8from .common import ( 9 AcademicHttpExecutor, 10 as_list, 11 as_mapping, 12 join_url, 13 nonnegative_int, 14 parse_iso_date, 15 protocol_failure, 16 reject_item, 17 text, 18) 19 20_DEFAULT_API_URL = "https://api.openalex.org" 21 22 23def reconstruct_abstract(value: object) -> str: 24 """Reconstruct OpenAlex's inverted abstract index by numeric position.""" 25 26 if not isinstance(value, dict): 27 return "" 28 positioned: list[tuple[int, str]] = [] 29 for raw_word, raw_positions in value.items(): 30 if not isinstance(raw_word, str) or not isinstance(raw_positions, list): 31 continue 32 for raw_position in raw_positions: 33 if ( 34 isinstance(raw_position, bool) 35 or not isinstance(raw_position, int) 36 or raw_position < 0 37 ): 38 continue 39 positioned.append((raw_position, raw_word)) 40 positioned.sort(key=lambda item: (item[0], item[1])) 41 return " ".join(word for _, word in positioned) 42 43 44class OpenAlexProvider: 45 name = "openalex" 46 47 def __init__( 48 self, 49 executor: AcademicHttpExecutor, 50 *, 51 api_url: str = _DEFAULT_API_URL, 52 contact_email: SecretValue | None = None, 53 ) -> None: 54 self._executor = executor 55 self._api_url = api_url 56 self._contact = contact_email 57 58 async def search(self, query: str) -> list[PaperSearchHit]: 59 params: dict[str, object] = {"search": query, "per_page": 10} 60 if self._contact is not None: 61 reveal = self._contact.reveal 62 params["mailto"] = reveal() 63 payload = await self._executor.request_json( 64 "GET", 65 join_url(self._api_url, "works"), 66 stage="paper_search", 67 params=params, 68 ) 69 envelope = as_mapping(payload) 70 results = as_list(envelope.get("results")) if envelope is not None else None 71 if results is None: 72 raise protocol_failure(self.name, "response results envelope was invalid") 73 hits: list[PaperSearchHit] = [] 74 for item in results: 75 mapped = self._map_item(item) 76 if mapped is None: 77 reject_item(self.name) 78 else: 79 hits.append(mapped) 80 return hits 81 82 def _map_item(self, value: object) -> PaperSearchHit | None: 83 item = as_mapping(value) 84 if item is None: 85 return None 86 source_id = normalize_openalex_id(text(item.get("id"))) 87 title = text(item.get("title")) 88 if source_id is None or not title: 89 return None 90 primary = as_mapping(item.get("primary_location")) or {} 91 oa = as_mapping(item.get("open_access")) or {} 92 landing = text(primary.get("landing_page_url")) or f"https://openalex.org/{source_id}" 93 pdf_url = text(primary.get("pdf_url")) 94 authors = self._authors(item.get("authorships")) 95 topics = self._concepts(item.get("concepts")) 96 raw_is_oa = oa.get("is_oa") 97 is_oa = raw_is_oa if isinstance(raw_is_oa, bool) else None 98 return PaperSearchHit( 99 source=self.name, 100 source_id=source_id, 101 title=title, 102 authors=authors, 103 abstract=reconstruct_abstract(item.get("abstract_inverted_index")), 104 doi=text(item.get("doi")), 105 published_date=parse_iso_date(item.get("publication_date")), 106 url=landing, 107 pdf_url=pdf_url, 108 topics=topics, 109 citation_count=nonnegative_int(item.get("cited_by_count")), 110 is_open_access=is_oa, 111 oa_status=text(oa.get("oa_status")), 112 license=text(primary.get("license")), 113 ) 114 115 @staticmethod 116 def _authors(value: object) -> tuple[str, ...]: 117 result: list[str] = [] 118 for raw_authorship in as_list(value) or []: 119 authorship = as_mapping(raw_authorship) 120 author = as_mapping(authorship.get("author")) if authorship is not None else None 121 name = text(author.get("display_name")) if author is not None else "" 122 if name: 123 result.append(name) 124 return tuple(result) 125 126 @staticmethod 127 def _concepts(value: object) -> tuple[str, ...]: 128 result: list[str] = [] 129 for raw_concept in as_list(value) or []: 130 concept = as_mapping(raw_concept) 131 name = text(concept.get("display_name")) if concept is not None else "" 132 if name: 133 result.append(name) 134 return tuple(result)
def
reconstruct_abstract(value: object) -> str:
24def reconstruct_abstract(value: object) -> str: 25 """Reconstruct OpenAlex's inverted abstract index by numeric position.""" 26 27 if not isinstance(value, dict): 28 return "" 29 positioned: list[tuple[int, str]] = [] 30 for raw_word, raw_positions in value.items(): 31 if not isinstance(raw_word, str) or not isinstance(raw_positions, list): 32 continue 33 for raw_position in raw_positions: 34 if ( 35 isinstance(raw_position, bool) 36 or not isinstance(raw_position, int) 37 or raw_position < 0 38 ): 39 continue 40 positioned.append((raw_position, raw_word)) 41 positioned.sort(key=lambda item: (item[0], item[1])) 42 return " ".join(word for _, word in positioned)
Reconstruct OpenAlex's inverted abstract index by numeric position.
class
OpenAlexProvider:
45class OpenAlexProvider: 46 name = "openalex" 47 48 def __init__( 49 self, 50 executor: AcademicHttpExecutor, 51 *, 52 api_url: str = _DEFAULT_API_URL, 53 contact_email: SecretValue | None = None, 54 ) -> None: 55 self._executor = executor 56 self._api_url = api_url 57 self._contact = contact_email 58 59 async def search(self, query: str) -> list[PaperSearchHit]: 60 params: dict[str, object] = {"search": query, "per_page": 10} 61 if self._contact is not None: 62 reveal = self._contact.reveal 63 params["mailto"] = reveal() 64 payload = await self._executor.request_json( 65 "GET", 66 join_url(self._api_url, "works"), 67 stage="paper_search", 68 params=params, 69 ) 70 envelope = as_mapping(payload) 71 results = as_list(envelope.get("results")) if envelope is not None else None 72 if results is None: 73 raise protocol_failure(self.name, "response results envelope was invalid") 74 hits: list[PaperSearchHit] = [] 75 for item in results: 76 mapped = self._map_item(item) 77 if mapped is None: 78 reject_item(self.name) 79 else: 80 hits.append(mapped) 81 return hits 82 83 def _map_item(self, value: object) -> PaperSearchHit | None: 84 item = as_mapping(value) 85 if item is None: 86 return None 87 source_id = normalize_openalex_id(text(item.get("id"))) 88 title = text(item.get("title")) 89 if source_id is None or not title: 90 return None 91 primary = as_mapping(item.get("primary_location")) or {} 92 oa = as_mapping(item.get("open_access")) or {} 93 landing = text(primary.get("landing_page_url")) or f"https://openalex.org/{source_id}" 94 pdf_url = text(primary.get("pdf_url")) 95 authors = self._authors(item.get("authorships")) 96 topics = self._concepts(item.get("concepts")) 97 raw_is_oa = oa.get("is_oa") 98 is_oa = raw_is_oa if isinstance(raw_is_oa, bool) else None 99 return PaperSearchHit( 100 source=self.name, 101 source_id=source_id, 102 title=title, 103 authors=authors, 104 abstract=reconstruct_abstract(item.get("abstract_inverted_index")), 105 doi=text(item.get("doi")), 106 published_date=parse_iso_date(item.get("publication_date")), 107 url=landing, 108 pdf_url=pdf_url, 109 topics=topics, 110 citation_count=nonnegative_int(item.get("cited_by_count")), 111 is_open_access=is_oa, 112 oa_status=text(oa.get("oa_status")), 113 license=text(primary.get("license")), 114 ) 115 116 @staticmethod 117 def _authors(value: object) -> tuple[str, ...]: 118 result: list[str] = [] 119 for raw_authorship in as_list(value) or []: 120 authorship = as_mapping(raw_authorship) 121 author = as_mapping(authorship.get("author")) if authorship is not None else None 122 name = text(author.get("display_name")) if author is not None else "" 123 if name: 124 result.append(name) 125 return tuple(result) 126 127 @staticmethod 128 def _concepts(value: object) -> tuple[str, ...]: 129 result: list[str] = [] 130 for raw_concept in as_list(value) or []: 131 concept = as_mapping(raw_concept) 132 name = text(concept.get("display_name")) if concept is not None else "" 133 if name: 134 result.append(name) 135 return tuple(result)
OpenAlexProvider( executor: agent_search_gateway.providers.academic.common.AcademicHttpExecutor, *, api_url: str = 'https://api.openalex.org', contact_email: agent_search_gateway.observability.SecretValue | None = None)
async def
search( self, query: str) -> list[agent_search_gateway.providers.contracts.PaperSearchHit]:
59 async def search(self, query: str) -> list[PaperSearchHit]: 60 params: dict[str, object] = {"search": query, "per_page": 10} 61 if self._contact is not None: 62 reveal = self._contact.reveal 63 params["mailto"] = reveal() 64 payload = await self._executor.request_json( 65 "GET", 66 join_url(self._api_url, "works"), 67 stage="paper_search", 68 params=params, 69 ) 70 envelope = as_mapping(payload) 71 results = as_list(envelope.get("results")) if envelope is not None else None 72 if results is None: 73 raise protocol_failure(self.name, "response results envelope was invalid") 74 hits: list[PaperSearchHit] = [] 75 for item in results: 76 mapped = self._map_item(item) 77 if mapped is None: 78 reject_item(self.name) 79 else: 80 hits.append(mapped) 81 return hits