Edit on GitHub

agent_search_gateway.providers.academic.core

CORE works search API discovery adapter.

  1"""CORE works search API discovery adapter."""
  2
  3from __future__ import annotations
  4
  5from ...academic.normalization import normalize_core_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.core.ac.uk/v3"
 21
 22
 23class CoreProvider:
 24    name = "core"
 25
 26    def __init__(
 27        self,
 28        executor: AcademicHttpExecutor,
 29        *,
 30        api_key: SecretValue,
 31        api_url: str = _DEFAULT_API_URL,
 32    ) -> None:
 33        self._executor = executor
 34        self._api_url = api_url
 35        self._credential = api_key
 36
 37    async def search(self, query: str) -> list[PaperSearchHit]:
 38        payload = await self._executor.request_json(
 39            "GET",
 40            join_url(self._api_url, "search/works/"),
 41            stage="paper_search",
 42            headers=self._request_headers(),
 43            params={"q": query, "limit": 10, "offset": 0},
 44        )
 45        envelope = as_mapping(payload)
 46        results = as_list(envelope.get("results")) if envelope is not None else None
 47        if results is None:
 48            raise protocol_failure(self.name, "response results envelope was invalid")
 49        hits: list[PaperSearchHit] = []
 50        for item in results:
 51            mapped = self._map_item(item)
 52            if mapped is None:
 53                reject_item(self.name)
 54            else:
 55                hits.append(mapped)
 56        return hits
 57
 58    def _request_headers(self) -> dict[str, str]:
 59        reveal = self._credential.reveal
 60        return {"Authorization": f"Bearer {reveal()}"}
 61
 62    def _map_item(self, value: object) -> PaperSearchHit | None:
 63        item = as_mapping(value)
 64        if item is None:
 65            return None
 66        raw_id = item.get("id")
 67        source_id = normalize_core_id(str(raw_id)) if isinstance(raw_id, str | int) else None
 68        title = text(item.get("title"))
 69        if source_id is None or not title:
 70            return None
 71        repository = as_mapping(item.get("repository")) or {}
 72        return PaperSearchHit(
 73            source=self.name,
 74            source_id=source_id,
 75            title=title,
 76            authors=self._authors(item.get("authors")),
 77            abstract=text(item.get("abstract")),
 78            doi=text(item.get("doi")),
 79            published_date=parse_iso_date(item.get("publishedDate")),
 80            url=f"https://core.ac.uk/works/{source_id}",
 81            pdf_url=self._pdf_url(item),
 82            venue=text(repository.get("name")),
 83            topics=self._topics(item),
 84            citation_count=nonnegative_int(item.get("citationCount")),
 85        )
 86
 87    @staticmethod
 88    def _authors(value: object) -> tuple[str, ...]:
 89        authors: list[str] = []
 90        for raw_author in as_list(value) or []:
 91            if isinstance(raw_author, str):
 92                name = raw_author.strip()
 93            else:
 94                author = as_mapping(raw_author)
 95                name = text(author.get("name")) if author is not None else ""
 96            if name:
 97                authors.append(name)
 98        return tuple(authors)
 99
100    @staticmethod
101    def _pdf_url(item: object) -> str:
102        mapping = as_mapping(item)
103        if mapping is None:
104            return ""
105        direct = text(mapping.get("downloadUrl"))
106        if direct:
107            return direct
108        for raw_url in as_list(mapping.get("fullTextUrls")) or []:
109            candidate = text(raw_url)
110            if candidate:
111                return candidate
112        return ""
113
114    @staticmethod
115    def _topics(item: object) -> tuple[str, ...]:
116        mapping = as_mapping(item)
117        if mapping is None:
118            return ()
119        topics: list[str] = []
120        seen: set[str] = set()
121        for key in ("subjects", "tags"):
122            for raw_topic in as_list(mapping.get(key)) or []:
123                topic = text(raw_topic)
124                folded = topic.casefold()
125                if topic and folded not in seen:
126                    seen.add(folded)
127                    topics.append(topic)
128        return tuple(topics)
class CoreProvider:
 24class CoreProvider:
 25    name = "core"
 26
 27    def __init__(
 28        self,
 29        executor: AcademicHttpExecutor,
 30        *,
 31        api_key: SecretValue,
 32        api_url: str = _DEFAULT_API_URL,
 33    ) -> None:
 34        self._executor = executor
 35        self._api_url = api_url
 36        self._credential = api_key
 37
 38    async def search(self, query: str) -> list[PaperSearchHit]:
 39        payload = await self._executor.request_json(
 40            "GET",
 41            join_url(self._api_url, "search/works/"),
 42            stage="paper_search",
 43            headers=self._request_headers(),
 44            params={"q": query, "limit": 10, "offset": 0},
 45        )
 46        envelope = as_mapping(payload)
 47        results = as_list(envelope.get("results")) if envelope is not None else None
 48        if results is None:
 49            raise protocol_failure(self.name, "response results envelope was invalid")
 50        hits: list[PaperSearchHit] = []
 51        for item in results:
 52            mapped = self._map_item(item)
 53            if mapped is None:
 54                reject_item(self.name)
 55            else:
 56                hits.append(mapped)
 57        return hits
 58
 59    def _request_headers(self) -> dict[str, str]:
 60        reveal = self._credential.reveal
 61        return {"Authorization": f"Bearer {reveal()}"}
 62
 63    def _map_item(self, value: object) -> PaperSearchHit | None:
 64        item = as_mapping(value)
 65        if item is None:
 66            return None
 67        raw_id = item.get("id")
 68        source_id = normalize_core_id(str(raw_id)) if isinstance(raw_id, str | int) else None
 69        title = text(item.get("title"))
 70        if source_id is None or not title:
 71            return None
 72        repository = as_mapping(item.get("repository")) or {}
 73        return PaperSearchHit(
 74            source=self.name,
 75            source_id=source_id,
 76            title=title,
 77            authors=self._authors(item.get("authors")),
 78            abstract=text(item.get("abstract")),
 79            doi=text(item.get("doi")),
 80            published_date=parse_iso_date(item.get("publishedDate")),
 81            url=f"https://core.ac.uk/works/{source_id}",
 82            pdf_url=self._pdf_url(item),
 83            venue=text(repository.get("name")),
 84            topics=self._topics(item),
 85            citation_count=nonnegative_int(item.get("citationCount")),
 86        )
 87
 88    @staticmethod
 89    def _authors(value: object) -> tuple[str, ...]:
 90        authors: list[str] = []
 91        for raw_author in as_list(value) or []:
 92            if isinstance(raw_author, str):
 93                name = raw_author.strip()
 94            else:
 95                author = as_mapping(raw_author)
 96                name = text(author.get("name")) if author is not None else ""
 97            if name:
 98                authors.append(name)
 99        return tuple(authors)
100
101    @staticmethod
102    def _pdf_url(item: object) -> str:
103        mapping = as_mapping(item)
104        if mapping is None:
105            return ""
106        direct = text(mapping.get("downloadUrl"))
107        if direct:
108            return direct
109        for raw_url in as_list(mapping.get("fullTextUrls")) or []:
110            candidate = text(raw_url)
111            if candidate:
112                return candidate
113        return ""
114
115    @staticmethod
116    def _topics(item: object) -> tuple[str, ...]:
117        mapping = as_mapping(item)
118        if mapping is None:
119            return ()
120        topics: list[str] = []
121        seen: set[str] = set()
122        for key in ("subjects", "tags"):
123            for raw_topic in as_list(mapping.get(key)) or []:
124                topic = text(raw_topic)
125                folded = topic.casefold()
126                if topic and folded not in seen:
127                    seen.add(folded)
128                    topics.append(topic)
129        return tuple(topics)
CoreProvider( executor: agent_search_gateway.providers.academic.common.AcademicHttpExecutor, *, api_key: agent_search_gateway.observability.SecretValue, api_url: str = 'https://api.core.ac.uk/v3')
27    def __init__(
28        self,
29        executor: AcademicHttpExecutor,
30        *,
31        api_key: SecretValue,
32        api_url: str = _DEFAULT_API_URL,
33    ) -> None:
34        self._executor = executor
35        self._api_url = api_url
36        self._credential = api_key
name = 'core'
async def search( self, query: str) -> list[agent_search_gateway.providers.contracts.PaperSearchHit]:
38    async def search(self, query: str) -> list[PaperSearchHit]:
39        payload = await self._executor.request_json(
40            "GET",
41            join_url(self._api_url, "search/works/"),
42            stage="paper_search",
43            headers=self._request_headers(),
44            params={"q": query, "limit": 10, "offset": 0},
45        )
46        envelope = as_mapping(payload)
47        results = as_list(envelope.get("results")) if envelope is not None else None
48        if results is None:
49            raise protocol_failure(self.name, "response results envelope was invalid")
50        hits: list[PaperSearchHit] = []
51        for item in results:
52            mapped = self._map_item(item)
53            if mapped is None:
54                reject_item(self.name)
55            else:
56                hits.append(mapped)
57        return hits